场景

你能从本文中学到什么

  1. 如何用 go 语言读取 csv 文件(第一行为列名,第二行开始为数据)
  2. 如何将读取到的数据,填充入任意结构体数据中,类似于
type AnyStruct struct {
	m map[int32]string
	s []bool
	...
}
    
	stru:= AnyStruct{}
	struList, err:=csv.Unmarshal(str,&stru) // struList map[string]AnyStruct{}
...
  1. 一些使用 reflect 库的实际经验
  2. 对于单处理函数接口的简化技巧

实际案例

我们现有的游戏项目采用 UE4 引擎,策划配置的数据以 .uasset 文件格式存储。后端各服务需要读取到指定的游戏数据(例如交易服务需要获取物品列表和价格)。我们采用如下的开发计划:

  1. 使用 UE4 自带的 Python 环境,编写python脚本脚本自动把指定的 .uasset 转成 .csv (待更新成博客)

注:.uasset.csv 是 UE4 自带功能,支持打开引擎界面后,选中指定 .uasset(可多个)右击输出为 .csv。但是使用必须打开引擎界面,必须每次手动选择,没有命令行可执行、对特定的项目一键输出的脚本

  1. 后端使用 Go 语言,服务启动时读取需要的 .csv 文件数据,将数据按照自定义结构体的方式存储入内存,方便程序运行时(runtime)取用 (本文内容)
  2. 后续开发定制化配表工具(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 等表格工具打开后:

abc
123
456

内存中:

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 数组。

技巧使用场景

当出现如下情形时,使用这种方案高效且易于扩展:

  1. 大段类似的switch case 代码块
swicth kind {
	case String:
	// 处理
	case Int8:
	// 处理
	case Int16:
	// 处理
	case Map:
	// 处理 key
		swicth key {
			case String:
			...
		}
	// 处理 value
	...
} // 显而意见,这种大段switch很难维护,且在有嵌套(不管是递归、引用还是其他)时,很容易在增加删除一个case后出问题
  1. 使用事件系统(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。
如果你有更好的想法或建议,请留言指出,谢谢!