Akka分布式游戏后端开发7 配置表代码生成

30 阅读2分钟

本专栏的项目代码在Github上,如果感兴趣的话,麻烦点点Star,谢谢啦。

本篇文章会介绍如何将策划配置的 Excel 表结构自动生成到可供程序读取的代码结构。大致的思路就是读取配置表的表头,根据表头的数据生成配置表代码。

idgrouptask_idconditionrewardpoint
intintintintvector3_array_intint
allkeyallallallallall
id分组任务id条件奖励积分
11111,1,11
21111,1,11
31111,1,11

还是用这个结构进行说明,策划在配置每张表时,按照规范,前面5行为表头,导表工具根据表头信息导出程序使用的配置表,比如前端可能是 Lua。

读取配置表表头

我们直接复用之前的代码,由于我们只需要读取表头,所以稍微把之前的代码复制一份稍微改造一下:

class ExcelHeaderListener(val completeCallback: (ExcelField?, List<ExcelField>) -> Unit) :
    AnalysisEventListener<Map<Int, String?>>() {
    //表头名称到列号的映射
    private val nameIndex: MutableMap<String, Int> = mutableMapOf()

    //列号到数据类型的映射
    private val typeIndex: MutableMap<Int, String> = mutableMapOf()

    //作用域
    private val scopeIndex = mutableMapOf<Int, ScopeType>()

    //注释
    private val commentIndex: MutableMap<Int, String> = mutableMapOf()

    override fun invoke(data: Map<Int, String?>, context: AnalysisContext) = Unit

    override fun invokeHeadMap(
        headMap: Map<Int, String?>,
        context: AnalysisContext
    ) {
        when (context.readRowHolder().rowIndex) {
            0 -> {
                headMap.forEach { (k, v) ->
                    nameIndex[requireNotNull(v) { "null value at row 1 column ${k + 1}" }] = k
                }
            }

            1 -> {
                headMap.forEach { (k, v) ->
                    typeIndex[k] = requireNotNull(v) { "null value at row 2 column ${k + 1}" }
                }
            }

            2 -> {
                headMap.forEach { (k, v) ->
                    scopeIndex[k] = ScopeType.fromString(requireNotNull(v) { "null value at row 3 column ${k + 1}" })
                }
            }

            4 -> {
                headMap.forEach { (k, v) ->
                    commentIndex[k] = v?.replace("\n", " ") ?: ""
                }
            }
        }
    }

    override fun doAfterAllAnalysed(context: AnalysisContext) {
        var idField: ExcelField? = null
        val fields = nameIndex.entries.sortedBy { it.value }.mapNotNull { (name, index) ->
            val scope = scopeIndex[index] ?: ScopeType.Other
            if (scope == ScopeType.Other) {
                null
            } else {
                val typeStr = typeIndex[index]
                val type = TYPE_MAPPING[typeStr] ?: error("未知的数据类型:$typeStr")
                val field = ExcelField(
                    name.snakeCaseToCamelCase(),
                    name,
                    type,
                    commentIndex[index] ?: ""
                )
                if (scope == ScopeType.AllKey && idField == null) {
                    idField = field
                }
                field
            }
        }
        completeCallback(idField ?: fields.firstOrNull(), fields)
    }
}

然后我们就可以在程序中这样使用:

val excelDir = "xxx".replace("\", "/")
val targetDir = "common/src/main/kotlin"
val excelNames = File(excelDir).listFiles()!!.map { it.name }
excelNames.forEach { excelName ->
    println("Processing $excelName")
    EasyExcel.read(
        "$excelDir/$excelName",
        ExcelHeaderListener { id, fields ->
            if (id != null) {
                println("Generating $excelName")
                val file = buildConfigFile(excelName.removeSuffix(".xlsx"), id, fields)
                file.writeTo(Path(targetDir))
                println("Generated ${file.packageName}.${file.name} to $targetDir/${file.relativePath}")
            } else {
                println("Skip $excelName because of no id")
            }
        }
    ).headRowNumber(HEADER_SIZE).sheet().doRead()
}

代码生成

代码生成我们使用专门的生成框架 KotlinPoet,不需要自己手搓。

先看一下最终生成的代码形式,可以看到需要生成的代码量不多,拿到表头信息之后很容易就实现了。

/**
 * @param id id
 * @param group 分组
 * @param taskId 任务id
 * @param condition 条件
 * @param reward 奖励
 * @param point 积分
 */
public data class TestConfig(
    public val id: Int,
    public val group: Int,
    public val taskId: Int,
    public val condition: Int,
    public val reward: List<Triple<Int, Int, Int>>,
    public val point: Int,
) : GameConfig<Int> {
    override fun id(): Int = id
}

public class TestConfigs : GameConfigs<Int, TestConfig>() {
    override fun excelName(): String = "test.xlsx"

    override fun parseRow(row: Row): TestConfig {
        val id = row.parseInt("id")
        val group = row.parseInt("group")
        val taskId = row.parseInt("task_id")
        val condition = row.parseInt("condition")
        val reward = row.parseIntTripleArray("reward")
        val point = row.parseInt("point")
        return TestConfig(id, group, taskId, condition, reward, point)
    }

    override fun parseComplete(): Unit = Unit

    /**
     * TODO: Implement validation logic
     */
    override fun validate() {
    }
}

生成 XXConfig 部分

fun buildGameConfig(configName: String, id: ExcelField, fields: List<ExcelField>): TypeSpec {
    val primaryConstructor = FunSpec.constructorBuilder().let {
        fields.forEach { (name, _, type) ->
            it.addParameter(name, type)
        }
        it.build()
    }
    val properties = fields.map { (name, _, type) ->
        PropertySpec.builder(name, type).initializer(name).build()
    }
    val idFunction = FunSpec.builder("id")
        .addModifiers(KModifier.OVERRIDE)
        .returns(id.type)
        .addStatement("return %L", id.name)
        .build()
    val fieldsDocs = fields.map { (name, _, _, comment) ->
        CodeBlock.of("@param %L %L", name, comment.replace("%", "%%"))
    }
    return TypeSpec.classBuilder("${configName}Config")
        .addModifiers(KModifier.DATA)
        .addAnnotation(NoArg::class)
        .primaryConstructor(primaryConstructor)
        .addProperties(properties)
        .addSuperinterface(GAME_CONFIG.parameterizedBy(id.type))
        .addFunction(idFunction)
        .addKdoc(fieldsDocs.joinToString("\n"))
        .build()
}

如果读者不熟悉 KotlinPoet 的话,可以去看看官方的文档,还是文档写的很详细。

生成 XXConfigs 部分

fun buildGameConfigs(
    excelName: String,
    configName: String,
    id: ExcelField,
    fields: List<ExcelField>,
): TypeSpec {
    val gameConfigClass = ClassName(GENERATE_PACKAGE, "${configName}Config")
    val superClassGameConfigs = GAME_CONFIGS.parameterizedBy(id.type, gameConfigClass)
    return TypeSpec.classBuilder("${configName}Configs")
        .addAnnotation(NoArg::class)
        .superclass(superClassGameConfigs)
        .addFunctions(buildGameConfigsFunctions(excelName, gameConfigClass, fields))
        .build()
}

fun buildGameConfigsFunctions(excelName: String, configClass: ClassName, fields: List<ExcelField>): List<FunSpec> {
    return listOf(
        FunSpec.builder("excelName")
            .addModifiers(KModifier.OVERRIDE)
            .returns(String::class)
            .addStatement("return %S", "$excelName.xlsx")
            .build(),
        buildParseRowFunction(configClass, fields),
        FunSpec.builder("parseComplete")
            .addModifiers(KModifier.OVERRIDE)
            .addStatement("return Unit")
            .build(),
        FunSpec.builder("validate")
            .addKdoc("TODO: Implement validation logic")
            .addModifiers(KModifier.OVERRIDE)
            .build(),
    )
}

fun buildParseRowFunction(configClass: ClassName, fields: List<ExcelField>): FunSpec {
    val argsPlaceholder = fields.joinToString(", ") { "%N" }
    return FunSpec.builder("parseRow")
        .addModifiers(KModifier.OVERRIDE)
        .addParameter("row", ClassName(INTERFACE_PACKAGE, "Row"))
        .returns(configClass).also {
            fields.forEach { (name, excelName, type) ->
                val parseFn = PARSE_MAPPING[type] ?: error("Unsupported type: $type")
                it.addStatement("val %N = row.%L(%S)", name, parseFn, excelName)
            }
        }
        .addStatement("return %T($argsPlaceholder)", configClass, *fields.map { it.name }.toTypedArray())
        .build()
}

这部分相对来说要复杂一些,要将生成的配置类继承抽象类 GameConfigs,以及实现部分方法。好在框架会自动为我们处理各种类的导入以及格式问题,这些要用手搓的话,也是蛮麻烦的。

写文件

fun buildConfigFile(excelName: String, id: ExcelField, fields: List<ExcelField>): FileSpec {
    val configName = excelName.snakeCaseToUpperCamelCase()
    return FileSpec.builder(GENERATE_PACKAGE, configName)
        .addType(buildGameConfig(configName, id, fields))
        .addType(buildGameConfigs(excelName, configName, id, fields))
        .build()
}

结尾

这部分的代码比较少,事实上写这种代码生成的代码,或许可以叫它元编程,是非常费脑子的事情。通过这段程序,可以在新增配置表时,快速的生成对应的配置表代码,然后程序再根据自己的需求调整代码。