你能从本文中学到什么
- 如何用 go 语言读取 csv 文件(第一行为列名,第二行开始为数据)
- 如何将读取到的数据,填充入任意结构体数据中,类似于
type AnyStruct struct {
m map[int32]string
s []bool
...
}
stru:= AnyStruct{}
struList, err:=csv.Unmarshal(str,&stru) // struList map[string]AnyStruct{}
...
- 一些使用 reflect 库的实际经验
- 对于单处理函数接口的简化技巧
实际案例
我们现有的游戏项目采用 UE4 引擎,策划配置的数据以 .uasset 文件格式存储。后端各服务需要读取到指定的游戏数据(例如交易服务需要获取物品列表和价格)。我们采用如下的开发计划:
- 使用 UE4 自带的 Python 环境,编写python脚本脚本自动把指定的 .uasset 转成 .csv (待更新成博客)
注:.uasset 转 .csv 是 UE4 自带功能,支持打开引擎界面后,选中指定 .uasset(可多个)右击输出为 .csv。但是使用必须打开引擎界面,必须每次手动选择,没有命令行可执行、对特定的项目一键输出的脚本
- 后端使用 Go 语言,服务启动时读取需要的 .csv 文件数据,将数据按照自定义结构体的方式存储入内存,方便程序运行时(runtime)取用 (本文内容)
- 后续开发定制化配表工具(C#),直接读取 .uasset 内容,策划也可直接使用定制化配表工具直接修改游戏数据,也能直接一键输出成任意后端可读取格式 (开发中,待更新)
下述所有代码均节选核心逻辑展示,完整项目请查看 csvLoader.
读取 csv 文件
import (
"encoding/csv"
"os"
)
f, err := os.Open(filepath)
check(err) // 此处为伪代码,需要对错误进行处理,下同
reader := csv.NewReader(f)
preData, err := reader.ReadAll() // preData 数据格式为 [][]string
check(err)
至此,我们将 csv 文件保存到一个二维数组 preData 中,第一维是行,第二维是列,例:
源文件 example.csv:
a,b,c
1,2,3
4,5,6
excel 等表格工具打开后:
a | b | c |
---|---|---|
1 | 2 | 3 |
4 | 5 | 6 |
内存中:
fmt.Println(preData) // [[a b c] [1 2 3] [4 5 6]]
预处理表头
type fieldInfo struct {
name string // struct field name 结构体字段名
kind FieldKind // 结构体字段类型
index int // 结构体字段在表头的列号
context reflect.Type// 附加类型信息,map、slice等复合类型需要用到
}
// 检查处理表头
length := len(preData) - 1
if length < 0 {
return nil, nil
}
allToLower(&preData[0]) // 将表头转化为小写,需求是大小写不敏感
// 提取结构体参数名、类型、对应的位置
vValue := reflect.ValueOf(v).Elem()
fieldNum := vValue.NumField() // 获取结构体字段个数
vType := reflect.TypeOf(v).Elem()
fieldInfoList := make([]*fieldInfo, fieldNum)
for i := 0; i < fieldNum; i++ { //遍历所有结构体字段
field := vType.Field(i)
fieldInfoList[i] = &fieldInfo{
name: field.Name,
kind: getFieldKind(field.Type.Kind()), // 获取字段类型
index: getFieldIndex(field.Name, &preData[0]), // 获取字段在 csv 中的列号
}
if fieldInfoList[i].kind == Map || fieldInfoList[i].kind == Slice {
fieldInfoList[i].context = field.Type
}
}
至此,我们将 csv 文件中的列名,按名称对应到了结构体的字段名称上,并提取了类型等信息(注:结构体中的字段如果未在csv中找到,则跳过不生成,csv中的字段结构体中未找到同理)
按照数据填充结构体
data := make(map[string]interface{}, length)
for i := 0; i < length; i++ {
data[preData[i+1][0]] = reflect.New(vValue.Type()).Interface() // 新建一个给定结构体
setFieldValue(data[preData[i+1][0]], &fieldInfoList, &preData[i+1]) // 填充数据
}
return data
func setFieldValue(v interface{}, infoList *[]*fieldInfo, data *[]string) {
vValue := reflect.ValueOf(v).Elem()
for i, info := range *infoList {
if info.index > -1 {
// string 转化为字段指定类型
value, err := fieldParser[info.kind]((*data)[info.index], info.context)
if err == nil {
vValue.Field(i).Set(value) // 把动态生成的值写入结构体
}
}
}
}
上述代码中,需要把csv单个单元格中的数据(string 格式),转化为结构体字段的给定格式(int、bool、map、slice 等),这里我使用的是 map[kind]handler 数组。
技巧使用场景
当出现如下情形时,使用这种方案高效且易于扩展:
- 大段类似的switch case 代码块
swicth kind {
case String:
// 处理
case Int8:
// 处理
case Int16:
// 处理
case Map:
// 处理 key
swicth key {
case String:
...
}
// 处理 value
...
} // 显而意见,这种大段switch很难维护,且在有嵌套(不管是递归、引用还是其他)时,很容易在增加删除一个case后出问题
- 使用事件系统(event handler),且事件和对应的处理函数不在运行时(runtime)发生变化
可以使用 map[kind]handler 数组,把函数作为值进行直接调用,如下:
技巧写法
type fieldHandler func(str string, context reflect.Type) (reflect.Value, error)
var (
fieldParser = map[FieldKind]fieldHandler {
Invalid: stringToInvalid,
String: stringToString,
Bool: stringToBool,
Int: stringToInt,
Int8: stringToInt8,
Int16: stringToInt16,
Int32: stringToInt32,
Int64: stringToInt64,
Uint: stringToUint,
Uint8: stringToUint8,
Uint16: stringToUint16,
Uint32: stringToUint32,
Uint64: stringToUint64,
Float32: stringToFloat32,
Float64: stringToFloat64,
Map: stringToMap,
Slice: stringToSlice,
}
fieldParserHelper map[FieldKind]fieldHandler
)
func init() {
fieldParserHelper = fieldParser // fieldParserHelper 是在运行时赋值的指针,和 fieldParser 指向同一地址
}
func stringToInvalid(_ string, _ reflect.Type) (reflect.Value, error) {
return reflect.Value{}, ErrUnsupportedDataType
}
func stringToString(str string, _ reflect.Type) (reflect.Value, error) {
str = strings.Trim(str, "\"")
return reflect.ValueOf(str), nil
}
.......
// stringToMap 格式 (A:"aaa",B:"ccc"),支持去除任何前后置空格
func stringToMap(str string, context reflect.Type) (reflect.Value, error) {
if fieldParserHelper == nil {
return reflect.Value{}, ErrInvalidDataSource
}
str = strings.Trim(str, " ")
str = str[1:len(str)-1]
strArr := strings.Split(str, ",")
result := reflect.MakeMap(context) // reflect 动态生成指定格式的map
for _, s := range strArr {
s = strings.Trim(s, " ")
kv := strings.Split(s, ":")
if len(kv) != 2 {
continue
}
// 避免循环引用,顺便检查是否是支持的数据类型,这里可以有嵌套,也能正常执行
keyHandler, ok := fieldParserHelper[getFieldKind(context.Key().Kind())]
if !ok {
continue
}
valueKind, ok := fieldParserHelper[getFieldKind(context.Elem().Kind())]
if !ok {
continue
}
// 赋值
key, err := keyHandler(kv[0], context.Key())
if err != nil {
continue
}
value, err := valueKind(kv[1], context.Elem())
if err != nil {
continue
}
result.SetMapIndex(key, value) // 动态设置 map 的 key - value
}
return result, nil
}
// stringToSlice 格式 ("aaa","ccc"),支持去除任何前后置空格
func stringToSlice(str string, context reflect.Type) (reflect.Value, error) {
if fieldParserHelper == nil {
return reflect.Value{}, ErrInvalidDataSource
}
str = strings.Trim(str, " ")
str = str[1:len(str)-1]
strArr := strings.Split(str, ",")
result := reflect.MakeSlice(context, 0, len(strArr)) // reflect 动态生成指定格式的 slice
for _, s := range strArr {
s = strings.Trim(s, " ")
if handler, ok := fieldParserHelper[getFieldKind(context.Elem().Kind())]; ok {
value, err := handler(s, context.Elem())
if err != nil {
continue
}
result = reflect.Append(result, value) // 动态增加 slice 的元素
}
}
return result, nil
}
引入 fieldParserHelper 目的是避免对于 fieldParser 的循环引用,因为 fieldParserHelper 和 fieldParser 都是指向同一个 map 真正地址的指针,而指针可以在运行时再赋值,从而避免了 fieldParser 内函数引用 fieldParser 会导致的循环引用问题。
循环引用链路为 fieldParser — 存储了–> stringToMap 函数 — 函数里会使用–> fieldParser
麻烦一点的面向对象写法
type fieldParser interface {
handler(string, reflect.Type) (reflect.Value, error)
}
type boolParser struct{}
func (bp *boolParser) handler(str string, context reflect.Type) (reflect.Value, error) {
// 逻辑代码
}
type stringParser struct{}
func (sp *stringParser) handler(str string, context reflect.Type) (reflect.Value, error) {
// 逻辑代码
}
...
这种标准面向对象写法也能达到需求,且执行效率并不差,但需要额外写不少结构体,这些结构体全局只会生成一次,且只做一个函数的活。
总结本文是在我实现一个具体业务需求时,实现的一个 csv 解析库,他能用在 UE4 数据读取等多种涉及 csv 格式的场景。整个库的代码量约 300 行,且支持所有常用的数据格式解析。完整项目请查看 csvLoader。
如果你有更好的想法或建议,请留言指出,谢谢!