本专栏的项目代码在Github上,如果感兴趣的话,麻烦点点Star,谢谢啦。
本篇文章会介绍如何将策划配置的 Excel 表结构自动生成到可供程序读取的代码结构。大致的思路就是读取配置表的表头,根据表头的数据生成配置表代码。
id | group | task_id | condition | reward | point |
---|---|---|---|---|---|
int | int | int | int | vector3_array_int | int |
allkey | all | all | all | all | all |
id | 分组 | 任务id | 条件 | 奖励 | 积分 |
1 | 1 | 1 | 1 | 1,1,1 | 1 |
2 | 1 | 1 | 1 | 1,1,1 | 1 |
3 | 1 | 1 | 1 | 1,1,1 | 1 |
还是用这个结构进行说明,策划在配置每张表时,按照规范,前面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()
}
结尾
这部分的代码比较少,事实上写这种代码生成的代码,或许可以叫它元编程,是非常费脑子的事情。通过这段程序,可以在新增配置表时,快速的生成对应的配置表代码,然后程序再根据自己的需求调整代码。