当前位置: 首页 > article >正文

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游戏服务器


http://www.kler.cn/a/514058.html

相关文章:

  • React 表单处理与网络请求封装详解[特殊字符][特殊字符]
  • [德州扑克]
  • QT多语言Demo及心得
  • 代码中使用 Iterable<T> 作为方法参数的解释
  • 图片生成Prompt编写技巧
  • Linux下MySQL的简单使用
  • 第四天 深入学习JavaScript,包括函数、数组、对象、闭包等
  • VUE3 vite下的axios跨域
  • React 中hooks之 React.memo 和 useMemo用法总结
  • 红外热成像之无人机载荷
  • 跨站脚本攻击(XSS)原理及防护方案
  • 优秀代码段案例__笔记
  • C++11的多线程
  • 亚博microros小车-原生ubuntu支持系列:1 键盘控制
  • Prometheus部署及linux、mysql、monog、redis、RocketMQ、java_jvm监控配置
  • QT 中 UDP 的使用
  • 专业138总分400+中国科学技术大学843信号与系统考研中科大电子信息通信生医先研,真题,大纲,参考书。
  • Java面试专题——常见面试题1
  • (5)STM32 USB设备开发-USB键盘
  • TiDB 的优势与劣势
  • 基于卷积神经网络的验证码识别
  • oneplus3t-lineageos-16.1编译-android9,
  • 机器学习有哪些应用场景
  • Java后端Controller参数校验的一些干货及问题~
  • element-plus中的table为什么相同的数据并没有合并成一个
  • Ollama能本地部署Llama 3等大模型的原因解析(ollama核心架构、技术特性、实际应用)