前言

xresloader 是一组用于把Excel数据结构化并导出为程序可读的数据文件的导表工具集。它包含了一系列跨平台的工具、协议描述和数据读取代码。

主要功能特点:

  • 跨平台(java 11 or upper)
  • Excel => protobuf/msgpack/lua/javascript/json/xml
  • 完整支持协议结构,包括嵌套结构和数组嵌套
  • 同时支持protobuf proto v2 和 proto v3
  • 支持导出proto枚举值到lua/javascript代码和json/xml数据
  • 支持导出proto描述信息值到lua/javascript代码和json/xml数据(支持自定义插件,方便用户根据proto描述自定义反射功能)
  • 支持导出 UnrealEngine 支持的json或csv格式,支持自动生成和导出 UnrealEngine 的 DataTable 加载代码
  • 支持别名表,用于给数据内容使用一个易读的名字
  • 支持验证器,可以在数据里直接填写proto字段名或枚举名,或者验证填入数据的是否有效
  • 支持通过protobuf协议插件控制部分输出
  • 支持自动合表,把多个Excel数据表合并成一个输出文件
  • 支持公式
  • 支持oneof,支持plain模式输入字符串转为数组或复杂结构,支持map
  • 支持空数据压缩(裁剪)或保留定长数组
  • 支持基于正则表达式分词的字段名映射转换规则
  • 支持设置数据版本号
  • Lua输出支持全局导出或导出为 require 模块或导出为 module 模块。
  • Javascript输出支持全局导出或导出为 nodejs 模块或导出为 AMD 模块。
  • 提供CLI批量转换工具(支持python 2.7/python 3 @ Windows、macOS、Linux)
  • 提供GUI批量转换工具(支持Windows、macOS、Linux)
  • CLI/GUI批量转换工具支持include来实现配置复用

xresloader 包含了多个组件,其中最主要的部分分别是。

xresloader 很早前内置了导出 Unreal Engine(UE) 中DataTable所支持的Json和Csv格式,并输出支持这些DataTable的导入设置和代码文件的功能。 但是一方面由于UE的DataTable还是有比较大的局限性(比如只支持Key-Value型数据且Key只能有一个,我们用了一些trick的方法支持了多key联合索引),另一方面java手夯的输出代码维护起来还是比较复杂。导致后续一些新功能这套输出没有跟上。

后来我们演化除了一套基于模板引擎的的读表代码生成工具 xres-code-generator,所以我们的读表代码都在往这上面靠。很早先前我们就基于 xres-code-generator 实现了C++、C#、lua原生、lua-pbc、lua-upb的读取方式并同时用于服务器、Unity客户端,Unreal Engine(UE)客户端。但是对 Unreal Engine(UE) 蓝图支持和更流行的 lua-protobuf 一直没提供官方支持。(之前提供的lua-upb是因为 upbprotobuf 官方的组件,并提供了 lua 支持,xres-code-generator 是支持用户自定义模板的用户可以根据自己的需要自己扩展)。

这次也是补上了这些支持,并且像其他的读取器一样,支持多版本并存,支持自定义loader等等。

蓝图支持

基础数据读取接口和索引接口

为了减少重复造轮子,我们这里还是复用了整个C++版本的版本管理、索引管理与文件读取和日志接口托管。那么这里需要做的事情就相对简单一些,我们只要实现新的模板来实现生成蓝图类并提取这里面的数据就行了。

由于Unreal Engine(UE)有一些自己的命名规则和数据类型,这不属于生成代码基础功能,所以我实现了一个单独的模块 UEExcelUtils.py 来实现这些转换,后续的模板都可以复用相同的转换规则和类型转换规则。比如把 protobuf 里默认的 string 类型转 FString 时,统一采用 FString Var = TCHAR_TO_ANSI(*container.var()) 这种形式。

我们举个例子,对于这个协议设置

message role_upgrade_cfg {
    option (org.xresloader.msg_description) = "Test role_upgrade_cfg with multi keys";

    option (xrescode.loader) = {
        file_path : "role_upgrade_cfg.bytes"
        indexes : {
            fields : "Id"
            index_type : EN_INDEX_KL // Key - List index: (Id) => list<role_upgrade_cfg>
        }
        indexes : {
            fields : "Id"
            fields : "Level"
            index_type : EN_INDEX_KV // Key - Value index: (Id, Level) => role_upgrade_cfg
        }
        tags : "client"
        tags : "server"
    };

    uint32 Id        = 1;
    uint32 Level     = 2;
    uint32 CostType  = 3 [ (org.xresloader.verifier) = "cost_type", (org.xresloader.field_description) = "Refer to cost_type" ];
    int64  CostValue = 4;
    int32  ScoreAdd  = 5;

    map<int32, string> TestMap = 6;
}

首先,对于外层管理层,我们提供了模板 UEExcelGroupApi.h.makoUEExcelGroupApi.cpp.mako 生成这样的管理层接口。

UCLASS(Blueprintable, BlueprintType)
class EXCELLOADER_API UExcelLoaderConfigGroupWrapper : public UObject
{
    GENERATED_BODY()

public:
    UExcelLoaderConfigGroupWrapper();

    /**
      * @brief Bind to a config group
      * @note It's a internal function, please don't call it
      * @param ConfigGroup config group
      */
    void _InternalBindConfigGroup(const std::shared_ptr<excel::config_group_t>& ConfigGroup);


    // ======================================== UExcelLoaderRoleUpgradeCfg ========================================
    // ---------------------------------------- role_upgrade_cfg ----------------------------------------
    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    int64 GetRoleUpgradeCfg_SizeOf_Id();

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    TArray<UExcelLoaderRoleUpgradeCfg*> GetAllRoleUpgradeCfg_Of_Id();

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    TArray<UExcelLoaderRoleUpgradeCfg*> GetRowRoleUpgradeCfg_AllOf_Id(int64 Id, bool& IsValid);

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    UExcelLoaderRoleUpgradeCfg* GetRowRoleUpgradeCfg_Of_Id(int64 Id, int64 Index, bool& IsValid);

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    int64 GetRoleUpgradeCfg_SizeOf_IdLevel();

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    TArray<UExcelLoaderRoleUpgradeCfg*> GetAllRoleUpgradeCfg_Of_IdLevel();

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    UExcelLoaderRoleUpgradeCfg* GetRowRoleUpgradeCfg_Of_IdLevel(int64 Id, int64 Level, bool& IsValid);

private:
    std::shared_ptr<excel::config_group_t> config_group_;
};

可以看到,首先我们数据生命周期会绑定一个配置组。这个配置组其实就是对应了一个版本的数据。其他的接口可以看到是基本和C++接口保持一致的,只是数据类型和生命周期管理换成了UE的,并且和大多数UE组件代码的设计模式一样,函数尾部增加了 bool& IsValid 的传出参数用来区分返回数据是否有效。 上面的例子既包含Key-List的接口输出,也包含Key-Value的接口输出。为了保证实际数据生命周期的有效性,我们会对返回的类型实例先绑定数据行的生命周期,具体实现如下:

EXCELLOADER_API UExcelLoaderRoleUpgradeCfg* UExcelLoaderConfigGroupWrapper::GetRowRoleUpgradeCfg_Of_IdLevel(int64 Id, int64 Level, bool& IsValid)
{
    if(!config_group_)
    {
        IsValid = false;
        return nullptr;
    }

    auto item = config_group_->role_upgrade_cfg.get_by_id_level(static_cast<uint32_t>(Id), static_cast<uint32_t>(Level));
    if (!item)
    {
        IsValid = false;
        return nullptr;
    }

    IsValid = true;
    UExcelLoaderRoleUpgradeCfg* Value = NewObject<UExcelLoaderRoleUpgradeCfg>();
    Value->_InternalBindLifetime(std::static_pointer_cast<const ::google::protobuf::Message>(item), *item);
    return Value;
}

然后再来看生成的具体类型的接口(模板为 UEExcelLoader.h.makoUEExcelLoader.cpp.mako , 前缀可以通过 --set ue_type_prefix=ExcelLoader 修改):

UCLASS(Blueprintable, BlueprintType)
class EXCELLOADER_API UExcelLoaderRoleUpgradeCfg : public UObject
{
    GENERATED_BODY()

public:
    UExcelLoaderRoleUpgradeCfg();

    /**
      * @brief Bind to a config item to keep lifeime and bind to the real config message
      * @note It's a internal function, please don't call it
      * @param Lifetime config group
      * @param CurrentMessage real message of UExcelLoaderRoleUpgradeCfg
      */
    void _InternalBindLifetime(std::shared_ptr<const ::google::protobuf::Message> Lifetime, const ::google::protobuf::Message& CurrentMessage);

    /**
      * @brief Get the internal message pointer
      * @note It's a internal function, please don't call it
      * @return The binded internal message pointer of type role_upgrade_cfg
      */
    const ::google::protobuf::Message* _InternalGetMessage() const;

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    int64 GetId(bool& IsValid);

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    int64 GetLevel(bool& IsValid);

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    int64 GetCostType(bool& IsValid);

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    int64 GetCostValue(bool& IsValid);

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    int32 GetScoreAdd(bool& IsValid);

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    int64 GetTestMapSize();

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg")
    FString FindTestMap(int32 Index, bool& IsValid);

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfg Get All Of TestMap")
    TArray<UExcelLoaderRoleUpgradeCfgTestMapEntry*> GetAllOfTestMap();


private:
    // The real message type is role_upgrade_cfg
    const ::google::protobuf::Message* current_message_;
    std::shared_ptr<const ::google::protobuf::Message> lifetime_;
};

首先可以看到,这里生命周期持有引用的是 std::shared_ptr<const ::google::protobuf::Message> ,为什么不是 std::shared_ptr<const 实际类型> 呢?

UCLASS(Blueprintable, BlueprintType)
class EXCELLOADER_API UExcelLoaderRoleUpgradeCfgTestMapEntry : public UObject
{
    GENERATED_BODY()

public:
    UExcelLoaderRoleUpgradeCfgTestMapEntry();

    /**
      * @brief Bind to a config item to keep lifeime and bind to the real config message
      * @note It's a internal function, please don't call it
      * @param Lifetime config group
      * @param CurrentMessage real data pointer of ::google::protobuf::Map<int32_t, std::string>::const_pointer
      */
    void _InternalBindLifetime(std::shared_ptr<const ::google::protobuf::Message> Lifetime, const void* CurrentMessage);

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfgTestMapEntry")
    int32 GetKey(bool& IsValid);

    UFUNCTION(BlueprintCallable, Category = "Excel Config UExcelLoaderRoleUpgradeCfgTestMapEntry")
    FString GetValue(bool& IsValid);


private:
    // The real message type is ::google::protobuf::Map<int32_t, std::string>::const_pointer
    const void* current_message_;
    std::shared_ptr<const ::google::protobuf::Message> lifetime_;
};

因为我们的数据的生命周期维护粒度是到行的,不是到每个子结构。那么在访问内部字段的时候,我们要通过保持行数据生命周期有效来确保当前对象的实际生命周期有效。 所以这里的 lifetime_ 总是指向行数据。且由于数据类型并不和子类型一致,所以这里用了公共基类。

蓝图对于Enum的限制问题

由于 enum 类型在 protobuf 里是 int32 ,而UE蓝图的enum必须小于256。所以我们只对最大值小于256的enum类型做了蓝图导出,示例如下。

UENUM(BlueprintType)
enum class EExcelLoaderEnTestEnumType : uint8
{
    EELETET_EN_TET_NONE = 0 UMETA(DisplayName="EN_TET_NONE"),
    EELETET_EN_TET_ONE = 1 UMETA(DisplayName="EN_TET_ONE"),
};

其他类型仅仅导出类型方便C++代码引用,示例如下。

enum class ETGFProtoTgfEnTradeOrderTicketLabelTagType : int32
{
    ETGFPTETOTLTT_EN_TRADE_ORDER_TICKET_LABEL_TAG_UNKNOWN = 0, // EN_TRADE_ORDER_TICKET_LABEL_TAG_UNKNOWN
    ETGFPTETOTLTT_EN_TRADE_ORDER_TICKET_LABEL_TAG_ITEM_PATCH_TYPE = 1, // EN_TRADE_ORDER_TICKET_LABEL_TAG_ITEM_PATCH_TYPE
    ETGFPTETOTLTT_EN_TRADE_ORDER_TICKET_LABEL_TAG_GEAR_SUB_TYPE = 1001, // EN_TRADE_ORDER_TICKET_LABEL_TAG_GEAR_SUB_TYPE
    ETGFPTETOTLTT_EN_TRADE_ORDER_TICKET_LABEL_TAG_ROLE_CAREER = 1002, // EN_TRADE_ORDER_TICKET_LABEL_TAG_ROLE_CAREER
    ETGFPTETOTLTT_EN_TRADE_ORDER_TICKET_LABEL_TAG_AFFIX_ATTRIBUTE = 1003, // EN_TRADE_ORDER_TICKET_LABEL_TAG_AFFIX_ATTRIBUTE
    ETGFPTETOTLTT_EN_TRADE_ORDER_TICKET_LABEL_TAG_WEARING_PART = 1004, // EN_TRADE_ORDER_TICKET_LABEL_TAG_WEARING_PART
};

第二套接口 - 简化蓝图类和protobuf原生类型互转

上面的接口仅仅用于配置读取,并且对数据结构是只读的。为了方便UE内使用支持蓝图的数据类型与原生的 protobuf 生成代码互操作。也是由于我们的模板引擎扩展起来十分简单,所以我这里又扩展了一组UE蓝图类的生成模板(UEBPProtocol.h.makoUEBPProtocol.cpp.mako),并支持和 protobuf 生成的原生 C++类互转。

和上面配置读取不同的地方是,这组接口里是会Copy整个内存数据的。生成的接口如下:

class role_upgrade_cfg;
class role_upgrade_cfg_TestMapEntry;

UENUM(BlueprintType)
enum class EProtoEnTestEnumType : uint8
{
    EPETET_EN_TET_NONE = 0 UMETA(DisplayName="EN_TET_NONE"),
    EPETET_EN_TET_ONE = 1 UMETA(DisplayName="EN_TET_ONE"),
};

// ========================== UProtoRoleUpgradeCfg ==========================
UCLASS(Blueprintable, BlueprintType)
class EXCELLOADER_API UProtoRoleUpgradeCfg : public UObject
{
    GENERATED_BODY()

public:
    UProtoRoleUpgradeCfg();

    UProtoRoleUpgradeCfg& operator=(const role_upgrade_cfg& other);

    UProtoRoleUpgradeCfg& operator<<(const role_upgrade_cfg& other);

    UProtoRoleUpgradeCfg& operator>>(role_upgrade_cfg& other);


    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Protocol UProtoRoleUpgradeCfg")
    int64 Id;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Protocol UProtoRoleUpgradeCfg")
    int64 Level;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Protocol UProtoRoleUpgradeCfg")
    int64 CostType;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Protocol UProtoRoleUpgradeCfg")
    int64 CostValue;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Protocol UProtoRoleUpgradeCfg")
    int32 ScoreAdd;


    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Protocol UProtoRoleUpgradeCfg")
    TMap<int32, FString> TestMap;
};

为了减少重名冲突,我们这里用 operator>>operator<< 来设置重载。同时为了和上面读表代码的类名区分开来,我们所有的模板都支持通过 --set ue_bp_protocol_type_prefix=Proto 来设置生成的类名前缀。

lua-protobuf支持

之前我们尝试在UE里使用 upb 作为 protobuf 的lua适配层,因为这相当于是官方的lua语言的 protobuf 解决方案,另外 upb 也是Python和Ruby等语言的官方解析器,包括 gRPC 也依赖它。理论上有更好的社区支持。

然而实际上由于这套东西出来的比较晚,加上近期变动一致特别大(最近 upb 被合入了 protobuf 主仓库,但是构建系统还没适配完。),实际上当前游戏领域更成熟的方案还是 lua-protobuf 。所以这波我也实现了新的基于 lua-protobuf 的配置管理器和配置组规则模板。

和之前 upb 的模板一样,这个配置管理依然支持多Key索引、多组索引和多版本支持。由于 lua-protobuf 的反射能力相对薄弱,所以我自己加了一点点需要用到的反射信息,也是方便项目组同学使用。 公共管理器代码可参见: https://github.com/xresloader/xres-code-generator/blob/main/template/common/lua-protobuf/DataTableServiceLuaProtobuf.lua 读表清单模板复用了 upb 的清单模板 DataTableCustomIndexUpb.lua.mako (可以通过 .../DataTableCustomIndexUpb.lua.mako:DataTableCustomIndexLuaProtobuf.lua 重命名输出文件,详见: https://github.com/xresloader/xres-code-generator/blob/main/sample/sample_gen.sh)。

读表代码示例如下:

-- 加载 lua-protobuf 模块,这里采用官方的方式
local pb = require(pb)

-- 加载pb文件,lua-protobuf也支持直接加载proto文件。这里由于其他语言用的是pb文件,所以复用了
local function load_pb(file_path)
  local f = io.open(file_path, "rb")
  if f == nil then
    error(string.format("Open file %s failed", file_path))
    return nil
  end
  local data = f:read("a")
  f:close()
  pb.load(data)
end

load_pb("pb_header_v3.pb")
load_pb("../sample.pb")


-- 初始化管理器
local excel_config_service = require("DataTableServiceLuaProtobuf")
-- 这里可以设置一些控制回调:
--     excel_config_service.BufferLoader = function(file_path) ... end 控制如何读取二进制文件,默认为从文件系统读取
--     excel_config_service.VersionLoader = function() ... end 控制如何读取版本号,默认为总是返回空
--     excel_config_service.OnError = function(msg, ...) ... end 控制如何打印Error日志
--     excel_config_service.OnInfo = function(msg, ...) ... end 控制如何打印Info日志

excel_config_service:ReloadTables()

print("----------------------- Get by reflection and Key-List index -----------------------")
local current_group = excel_config_service:GetCurrentGroup()
local role_upgrade_cfg2 = excel_config_service:GetByGroup(current_group, "role_upgrade_cfg")
-- require("vardump")
-- vardump(role_upgrade_cfg2, { show_all = true })
local data2 = role_upgrade_cfg2:GetByIndex("id", 10001) -- using the Key-List index: id
for _, v1 in ipairs(data2) do
  print(string.format("\tid: %s, level: %s", tostring(v1.Id), tostring(v1.Level)))
end

-- 这里仅仅是一些方便用户使用提供的反射接口
print("Fields of " .. role_upgrade_cfg2:GetMessageDescriptor().name)
for _, fds in ipairs(role_upgrade_cfg2:GetMessageDescriptor().fields) do
  if fds.type.type == nil then
    print(string.format("\t%s %s=%s", fds.type.name, fds.name, tostring(fds.number)))
  else
    print(string.format("\t%s(%s) %s=%s", fds.type.name, fds.type.type, fds.name, tostring(fds.number)))
  end
end

至此,基本的 lua-protobuf 配置加载器也就实现完了。

最后

欢迎有兴趣的小伙伴互相交流,也欢迎共建。