go读取excel游戏配置
1.背景
游戏服务器,配置数据一般采用csv/excel来作为载体,这种方式,策划同学配置方便,服务器解析也方便。在jforgame框架里,我们使用以下的excel配置格式。
然后可以非常方便的进行数据检索,例如:
本文使用go实现类似的功能。
2.读取excel
2.1.使用github.com/tealeg/xlsx
库
github.com/tealeg/xlsx
是一个流行的 Go 语言库,用于读取和写入 Excel 文件。
定义数据读取接口,既可以选择excel格式,也可以拓展成csv等格式。
package data
import "io"
type DataReader interface {
Read(io.Reader, interface{}) ([]interface{}, error)
}
Excel实现
package data
import (
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
"github.com/tealeg/xlsx"
)
type ExcelDataReader struct {
ignoreUnknownFields bool
}
func NewExcelDataReader(ignoreUnknownFields bool) *ExcelDataReader {
return &ExcelDataReader{
ignoreUnknownFields: ignoreUnknownFields,
}
}
func (r *ExcelDataReader) Read(filePath string, clazz interface{}) ([]interface{}, error) {
// 使用 xlsx.OpenFile 打开 Excel 文件
xlFile, err := xlsx.OpenFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open Excel file: %v", err)
}
sheet := xlFile.Sheets[0]
rows := sheet.Rows
var headers []CellHeader
var records [][]CellColumn
// 遍历每一行
for _, row := range rows {
firstCell := getCellValue(row.Cells[0])
if firstCell == "HEADER" {
headers, err = r.readHeader(clazz, row.Cells)
if err != nil {
return nil, err
}
continue
}
if len(headers) == 0 {
continue
}
record := r.readExcelRow(headers, row)
records = append(records, record)
if firstCell == "end" {
break
}
}
return r.readRecords(clazz, records)
}
func (r *ExcelDataReader) readRecords(clazz interface{}, rows [][]CellColumn) ([]interface{}, error) {
var records []interface{}
clazzType := reflect.TypeOf(clazz).Elem()
for _, row := range rows {
obj := reflect.New(clazzType).Elem()
for _, column := range row {
colName := column.Header.Column
if colName == "" {
continue
}
// 根据 Tag 查找字段
field, err := findFieldByTag(obj, colName)
if err != nil {
if !r.ignoreUnknownFields {
return nil, err
}
continue
}
fieldVal, err := convertValue(column.Value, field.Type())
if err != nil {
return nil, err
}
field.Set(reflect.ValueOf(fieldVal))
}
records = append(records, obj.Interface())
}
return records, nil
}
func (r *ExcelDataReader) readHeader(clazz interface{}, cells []*xlsx.Cell) ([]CellHeader, error) {
var headers []CellHeader
for _, cell := range cells {
cellValue := getCellValue(cell)
if cellValue == "HEADER" {
continue
}
header := CellHeader{
Column: cellValue,
}
headers = append(headers, header)
}
return headers, nil
}
func getCellValue(cell *xlsx.Cell) string {
if cell == nil {
return ""
}
return cell.String()
}
func (r *ExcelDataReader) readExcelRow(headers []CellHeader, row *xlsx.Row) []CellColumn {
var columns []CellColumn
for i, cell := range row.Cells {
// 忽略 header 所在的第一列
if i == 0 {
continue
}
if i >= len(headers) {
break
}
cellValue := getCellValue(cell)
column := CellColumn{
// headers 从 0 开始,所以这里 -1
Header: headers[i-1],
Value: cellValue,
}
columns = append(columns, column)
}
return columns
}
func convertValue(value string, fieldType reflect.Type) (interface{}, error) {
switch fieldType.Kind() {
case reflect.String:
return value, nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.ParseInt(value, 10, 64)
case reflect.Float32, reflect.Float64:
return strconv.ParseFloat(value, 64)
case reflect.Bool:
return strconv.ParseBool(value)
case reflect.Slice, reflect.Struct:
// 处理嵌套的 JSON 对象
fieldVal := reflect.New(fieldType).Interface()
if err := json.Unmarshal([]byte(value), &fieldVal); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %v", err)
}
return reflect.ValueOf(fieldVal).Elem().Interface(), nil
default:
return nil, fmt.Errorf("unsupported type: %v", fieldType.Kind())
}
}
// 根据 Tag 查找字段
func findFieldByTag(obj reflect.Value, tagValue string) (reflect.Value, error) {
objType := obj.Type()
for i := 0; i < objType.NumField(); i++ {
field := objType.Field(i)
tag := field.Tag.Get("excel") // 获取 Tag 值
if strings.EqualFold(tag, tagValue) { // 忽略大小写匹配
return obj.Field(i), nil
}
}
return reflect.Value{}, fmt.Errorf("field with tag %s not found", tagValue)
}
type CellHeader struct {
Column string
Field reflect.Value
}
type CellColumn struct {
Header CellHeader
Value string
}
2.2.主要技术点
这里有几个需要注意的点
2.2.1.go结构体变量与excel字段分离
go使用首字母大写来标识一个变量是否包外可见,如果直接使用go的反射api,需要将excel的字段定义成大写,两者强绑定在一起,不方便。为了支持代码与配置命名的分离,可以使用go的tag定义,通过把excel的字段名称,写在struct的tag注释。有点类似于java的注解。
type Item struct {
Id int64 `json:"id" excel:"id"`
Name string `json:"name" excel:"name"`
Quality int64 `json:"quality" excel:"quality"`
Tips string `json:"tips" excel:"tips"`
Icon string `json:"icon" excel:"icon"`
}
代码片段
// 根据 Tag 查找字段
func findFieldByTag(obj reflect.Value, tagValue string) (reflect.Value, error) {
objType := obj.Type()
for i := 0; i < objType.NumField(); i++ {
field := objType.Field(i)
tag := field.Tag.Get("excel") // 获取 Tag 值
if strings.EqualFold(tag, tagValue) { // 忽略大小写匹配
return obj.Field(i), nil
}
}
return reflect.Value{}, fmt.Errorf("field with tag %s not found", tagValue)
}
2.2.2.exce支持嵌套结构
程序员很喜欢配置直接使用json格式,这样代码具有很高的拓展性,当策划改配置,只要不添加新类型,都可以无需程序介入。(其实大部分策划很讨厌json格式,配置容易出错,而且excel的自动公式无法很智能地工作)
例如下面的配置
结构体定义
type RewardDef struct {
Type string `json:"type" excel:"type"`
Value string `json:"value" excel:"value"`
}
type ConsumeDef struct {
Type string `json:"type" excel:"type"`
Value string `json:"value" excel:"value"`
}
type Mall struct {
Id int64 `json:"id" excel:"id"`
Type int64 `json:"type" excel:"type"`
Name string `json:"name" excel:"name"`
Rewards []RewardDef `json:"rewards" excel:"rewards"`
Consumes []ConsumeDef `json:"consumes" excel:"consumes"`
}
主要代码
func convertValue(value string, fieldType reflect.Type) (interface{}, error) {
switch fieldType.Kind() {
case reflect.String:
return value, nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.ParseInt(value, 10, 64)
case reflect.Float32, reflect.Float64:
return strconv.ParseFloat(value, 64)
case reflect.Bool:
return strconv.ParseBool(value)
case reflect.Slice, reflect.Struct:
// 处理嵌套的 JSON 对象
fieldVal := reflect.New(fieldType).Interface()
if err := json.Unmarshal([]byte(value), &fieldVal); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %v", err)
}
return reflect.ValueOf(fieldVal).Elem().Interface(), nil
default:
return nil, fmt.Errorf("unsupported type: %v", fieldType.Kind())
}
}
2.3.单元测试用例
由于go只有main包能使用main函数,为了对我们的工具进行测试,我们可以直接使用类的单元测试。
新建一个文件excel_test.go(必须以_test结尾)
package data
import (
"fmt"
"io/github/gforgame/logger"
"testing"
)
func TestExcelReader(t *testing.T) {
// 创建 ExcelDataReader 实例
reader := NewExcelDataReader(true)
type RewardDef struct {
Type string `json:"type" excel:"type"`
Value string `json:"value" excel:"value"`
}
type ConsumeDef struct {
Type string `json:"type" excel:"type"`
Value string `json:"value" excel:"value"`
}
type Name struct {
Id int64 `json:"id" excel:"id"`
Name string `json:"type" excel:"name"`
Rewards []RewardDef `json:"rewards" excel:"rewards"`
Consumes []ConsumeDef `json:"consumes" excel:"consumes"`
}
// 读取 Excel 文件
result, err := reader.Read("mall.xlsx", &Name{})
if err != nil {
logger.Error(fmt.Errorf("session.Send: %v", err))
}
// 打印结果
for _, item := range result {
fmt.Printf("%+v\n", item)
}
}
3.数据载体
3.1.数据容器定义
读取excel文件,得到的是一个记录数组,我们还需要进一步进行封装,方便业务代码使用。
所以我们还需要把这批数据塞入到一个容器里,并且容器应该提供至少以下API。
// GetRecord 根据 ID 获取单个记录
func (c *Container[K, V]) GetRecord(id K) (V, bool) {
}
// GetAllRecords 获取所有记录
func (c *Container[K, V]) GetAllRecords() []V {
}
// GetRecordsBy 根据索引名称和索引值获取记录
func (c *Container[K, V]) GetRecordsBy(name string, index interface{}) []V {
}
该容器必须支持泛型,适配不同的表定义。代码如下:
package data
import "fmt"
// Container 是一个通用的数据容器,支持按 ID 查询、按索引查询和查询所有记录
type Container[K comparable, V any] struct {
data map[K]V // 存储 ID 到记录的映射
indexMapper map[string][]V // 存储索引到记录的映射
}
// NewContainer 创建一个新的 Container 实例
func NewContainer[K comparable, V any]() *Container[K, V] {
return &Container[K, V]{
data: make(map[K]V),
indexMapper: make(map[string][]V),
}
}
// Inject 将数据注入容器,并构建索引
func (c *Container[K, V]) Inject(records []V, getIdFunc func(V) K, indexFuncs map[string]func(V) interface{}) {
for _, record := range records {
id := getIdFunc(record)
c.data[id] = record
// 构建索引
for name, indexFunc := range indexFuncs {
indexValue := indexFunc(record)
key := indexKey(name, indexValue)
c.indexMapper[key] = append(c.indexMapper[key], record)
}
}
}
// GetRecord 根据 ID 获取单个记录
func (c *Container[K, V]) GetRecord(id K) (V, bool) {
record, exists := c.data[id]
return record, exists
}
// GetAllRecords 获取所有记录
func (c *Container[K, V]) GetAllRecords() []V {
records := make([]V, 0, len(c.data))
for _, record := range c.data {
records = append(records, record)
}
return records
}
// GetRecordsBy 根据索引名称和索引值获取记录
func (c *Container[K, V]) GetRecordsBy(name string, index interface{}) []V {
key := indexKey(name, index)
return c.indexMapper[key]
}
// indexKey 生成索引键
func indexKey(name string, index interface{}) string {
return fmt.Sprintf("%s@%v", name, index)
}
对于java版本的游戏服务器框架,配置表定义格式如下:
/**
* 成就表
*/
@Setter
@Getter
@DataTable(name = "achievement")
public class AchievementData {
@Id
private int id;
/**
* 名字
*/
private String name;
/**
* 排序
*/
private int rank;
/**
* 类型
*/
@Index
private int type;
/**
* 条件,每个类型自行定义配置结构
*/
private String target;
}
通过@Id注解定义主键,通过@Index注解定义索引。程序业务代码示例:
// 查询单条记录
AchievementData achievementData = GameContext.dataManager.queryById(AchievementData.class, 1);
// 查询指定索引的所有记录
List<AchievementData> records = GameContext.dataManager.queryByIndex(AchievementData.class, "type", type);
由于go目前不支持注解,无法通过注解让程序自动识别哪一个字段为主键,所以对于每一个容器,需要定义一个函数,手动标识应该取哪一个字段。
// 定义 ID 获取函数和索引函数
getIdFunc := func(record Mall) int64 {
return record.Id
}
按索引取记录的逻辑也是同样的道理。
// 将记录注入容器
nameRecords := make([]Mall, len(records))
for i, record := range records {
nameRecords[i] = record.(Mall)
}
单元测试代码
func TestDataContainer(t *testing.T) {
// 创建 ExcelDataReader
reader := NewExcelDataReader(true)
// 读取 Excel 文件
records, err := reader.Read("mall.xlsx", &Mall{})
if err != nil {
fmt.Println("Failed to read Excel file:", err)
return
}
// 创建 Container
container := NewContainer[int64, Mall]()
// 定义 ID 获取函数和索引函数
getIdFunc := func(record Mall) int64 {
return record.Id
}
indexFuncs := map[string]func(Mall) interface{}{
"type": func(record Mall) interface{} {
return record.Type
},
}
// 将记录注入容器
nameRecords := make([]Mall, len(records))
for i, record := range records {
nameRecords[i] = record.(Mall)
}
container.Inject(nameRecords, getIdFunc, indexFuncs)
// 查询记录
fmt.Println("All records:", container.GetAllRecords())
target, _ := container.GetRecord(1)
fmt.Println("Record with ID 1:", target)
fmt.Println("Records with type 2:", container.GetRecordsBy("type", 2))
}
3.2.适配不同的表配置
从上面的代码可以看出,对于一份excel配置,每次都要复制一段非常相似的代码,无疑非常繁琐。所以我们对以上的代码进一步封装。
首先,定义各种表的元信息(java可通过注解定义)
type TableMeta struct {
TableName string // 表名
IDField string // ID 字段名
IndexFuncs map[string]string // 索引字段名 -> 索引名称
RecordType reflect.Type // 记录类型
}
将excel配置注入容器
func ProcessTable(reader *ExcelDataReader, filePath string, config TableMeta) (*Container[int64, interface{}], error) {
// 读取 Excel 文件
records, err := reader.Read(filePath, reflect.New(config.RecordType).Interface())
if err != nil {
return nil, fmt.Errorf("failed to read table %s: %v", config.TableName, err)
}
// 创建 Container
container := NewContainer[int64, interface{}]()
// 定义 ID 获取函数
getIdFunc := func(record interface{}) int64 {
val := reflect.ValueOf(record)
// 如果 record 是指针,则调用 Elem() 获取实际值
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
field := val.FieldByName(config.IDField)
return field.Int()
}
// 定义索引函数
indexFuncs := make(map[string]func(interface{}) interface{})
if config.IndexFuncs != nil {
for indexName, fieldName := range config.IndexFuncs {
indexFuncs[indexName] = func(record interface{}) interface{} {
val := reflect.ValueOf(record)
// 如果 record 是指针,则调用 Elem() 获取实际值
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
field := val.FieldByName(fieldName)
return field.Interface()
}
}
}
// 将记录注入容器
container.Inject(records, getIdFunc, indexFuncs)
return container, nil
}
在jforgame的版本实现,利用java的类扫描,可以非常方便把所有配置容器一次性扫描并注册,如下:
public void init() {
if (!StringUtils.isEmpty(properties.getContainerScanPath())) {
Set<Class<?>> containers = ClassScanner.listAllSubclasses(properties.getContainerScanPath(), Container.class);
containers.forEach(c -> {
// container命名必须以配置文件名+Container,例如配置表为common.csv,则对应的Container命名为CommonContainer
String name = c.getSimpleName().replace("Container", "").toLowerCase();
containerDefinitions.put(name, (Class<? extends Container>) c);
});
}
Set<Class<?>> classSet = ClassScanner.listClassesWithAnnotation(properties.getTableScanPath(), DataTable.class);
classSet.forEach(this::registerContainer);
}
go目前不支持类扫描这种元编程,我们只能通过手动注册。
// 定义表配置
tableConfigs := []TableMeta{
// 商城表
{
TableName: "mall",
IDField: "Id",
IndexFuncs: map[string]string{"type": "Type"},
RecordType: reflect.TypeOf(Mall{}),
},
// 道具表
{
TableName: "item",
IDField: "Id",
RecordType: reflect.TypeOf(Item{}),
},
}
3.3.单元测试用例
func TestMultiDataContainer(t *testing.T) {
// 创建 ExcelDataReader
reader := NewExcelDataReader(true)
// 定义表配置
tableConfigs := []TableMeta{
// 商城表
{
TableName: "mall",
IDField: "Id",
IndexFuncs: map[string]string{"type": "Type"},
RecordType: reflect.TypeOf(Mall{}),
},
// 道具表
{
TableName: "item",
IDField: "Id",
RecordType: reflect.TypeOf(Item{}),
},
}
// 处理每张表
containers := make(map[string]*Container[int64, interface{}])
for _, config := range tableConfigs {
container, err := ProcessTable(reader, config.TableName+".xlsx", config)
if err != nil {
fmt.Printf("Failed to process table %s: %v\n", config.TableName, err)
continue
}
containers[config.TableName] = container
}
// 查询商城记录
mallContainer := containers["mall"]
fmt.Println("All records in Mall table:", mallContainer.GetAllRecords())
target, _ := mallContainer.GetRecord(1)
fmt.Println("Record with ID 1:", target)
fmt.Println("Records with type 2 in Mall table:", mallContainer.GetRecordsBy("type", 2))
// 查询商城记录
itemContainer := containers["item"]
fmt.Println("All records in Mall table:", itemContainer.GetAllRecords())
target2, _ := itemContainer.GetRecord(1)
fmt.Println("Record with ID 1:", target2)
}
完整代码请移步:
--> go游戏服务器