本专栏的项目代码在Github上,如果感兴趣的话,麻烦点点Star,谢谢啦。
我们需要将策划配置的 Excel 配置表导出为可供程序读取和使用的类型,为此需要写配置表类和每张配置表一一对应,这样就可以把 Excel 的数据读取到程序中供业务逻辑使用了。为了简化 Excel 读取,这里使用 EasyExcel 读取数据。
假设配置表的格式如下,第一行为字段名,第二行为数据类型,第三行为数据的作用域,第五行为注释,后面是数据。
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 |
2 | 1 | 1 | 1 | 1 | 1 |
3 | 1 | 1 | 1 | 1 | 1 |
我们期望最终生成的配置表代码格式如下:
kotlin
/**
* @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() {
}
}
接口设计
kotlin
typealias K = KClass<out GameConfigs<*, *>>
typealias V = GameConfigs<*, *>
interface GameConfig<K : Any> {
fun id(): K
}
定义一个接口,表示配置表中的一行数据,其中 id
表示这一行数据的主键,在一张配置表中,主键不能重复,是某一行数据的唯一标识,可能会在其它配置表中被引用。
接下来就是定义 GameConfigs
,表示一整张配置表的数据,同时附带将 Excel 解析功能。
kotlin
abstract class GameConfigs<K : Any, C : GameConfig<K>> : AnalysisEventListener<Map<Int, String?>>(), Map<K, C> {
val logger = logger()
lateinit var manager: GameConfigManager
internal set
//表头名称到列号的映射
private val nameIndex: MutableMap<String, Int> = mutableMapOf()
//列号到数据类型的映射
private val typeIndex: MutableMap<Int, String> = mutableMapOf()
private var configs: HashMap<K, C> = hashMapOf()
override fun invoke(
data: Map<Int, String?>,
context: AnalysisContext
) {
val rowIndex = context.readRowHolder().rowIndex
val row = Row(rowIndex, nameIndex, data)
val config = parseRow(row)
check(configs.containsKey(config.id()).not()) { "Duplicate id:${config.id()} at row ${rowIndex + 1}" }
configs[config.id()] = config
}
override fun invokeHeadMap(
headMap: Map<Int, String?>,
context: AnalysisContext
) {
val rowIndex = context.readRowHolder().rowIndex
//解析名字
if (rowIndex == 0) {
headMap.forEach { (k, v) ->
nameIndex[requireNotNull(v) { "null value at row 1 column ${k + 1}" }] = k
}
} else if (rowIndex == 1) {
headMap.forEach { (k, v) ->
typeIndex[k] = requireNotNull(v) { "null value at row 2 column ${k + 1}" }
}
}
}
override fun doAfterAllAnalysed(context: AnalysisContext) = Unit
abstract fun parseRow(row: Row): C
abstract fun excelName(): String
fun ids(): Set<K> = configs.keys
fun getById(id: K): C {
return requireNotNull(configs[id]) { "id: $id not found in ${this::class.simpleName}" }
}
/**
* 解析完[configs]后可以基于[configs]重新构建一些数据结构
* 例如:将[configs]中的数据按照某个字段分组 或者 构建一些索引
*/
abstract fun parseComplete()
abstract fun validate()
}
上面的代码做了部分删减,只保留的必要的部分。其逻辑为提取表头,然后根据表头的字段名映射到应该读取哪一列数据。在解析数据时,将映射信息以及当前行的数据组装成一个 Row
数据,交给子类实现去解析。
- parseRow 解析一行配置表数据到具体的数据类型,由子类实现
- excelName 此配置表代码对应的配置表名,读取配置表时,依据此名称找到正确的配置表
- ids 所有的配置表主键,通常用来比较两个版本的配置表之间的 id 差异,防止一些 id 被错误的删除
- parseComplete 整张配置表解析完成后的钩子,可以用于根据现有的数据重建数据结构
- validate 在 parseComplete 之后执行,用于配置表数据校验
Row
Row 主要封装一些把 Excel 数据解析到具体类型的方法:
kotlin
/**
* Excel行数据
* @param rowIndex 行号 从0开始
* @param index 列名到列号的映射
* @param data 列号到数据的映射
*/
data class Row(
val rowIndex: Int,
val index: Map<String, Int>,
val data: Map<Int, String?>,
) {
var currentName: String = ""
private fun getValueByName(name: String): String {
currentName = name
val columnIndex = index[name] ?: throw IllegalArgumentException("Column `$name` not found")
return data[columnIndex] ?: ""
}
fun parseString(name: String): String {
currentName = name
return getValueByName(name)
}
fun parseInt(name: String): Int {
currentName = name
val value = getValueByName(name)
return if (value.isBlankOrDefault()) {
0
} else {
value.toInt()
}
}
private fun String.isBlankOrDefault(): Boolean {
return this.isBlank() || this == "0"
}
}
GameConfigManager
GameConfigManager
的功能则是管理所有的配置表数据,负责数据的加载解析以及验证工作,在执行 load
方法时,会把注册到 ConfigsImpl
中的所有配置表类执行实例化,并开始加载配置表中的数据。
kotlin
class GameConfigManager(val version: String) {
//这个是给Kryo通过反射构造用的
@Suppress("unused")
constructor() : this("Unknown")
companion object {
const val HEADER_SIZE = 5
}
@Transient
var logger = logger()
private set
@Transient
var errors: MutableMap<String, MutableList<ValidateError>> = mutableMapOf()
private set
@Transient
var configs: HashMap<K, V> = hashMapOf()
internal set
inline fun <reified T : V> get(): T {
return requireNotNull(configs[T::class]) { "GameConfigs not found: ${T::class.simpleName}" } as T
}
inline fun <reified T : GameConfigs<K, C>, C : GameConfig<K>, K : Any> getById(id: K): C {
return get<T>().getById(id)
}
/**
* @param excelDir excel文件夹路径
* @throws IllegalStateException 如果已经加载过配置表
* @throws IllegalArgumentException 如果配置表类没有空构造函数
*/
suspend fun load(excelDir: String) {
check(configs.isEmpty()) { "GameConfigManager already loaded" }
val loadedGameConfigs = coroutineScope {
ConfigsImpl.map { configClazz ->
val primaryConstructor =
requireNotNull(configClazz.primaryConstructor) { "GameConfigs ${configClazz.simpleName} must have an empty primary constructor" }
async(Dispatchers.IO) {
val gameConfigs = primaryConstructor.call()
gameConfigs.manager = this@GameConfigManager
val path = "$excelDir/${gameConfigs.excelName()}".replace("\", "/")
logger.info("Loading ${gameConfigs.excelName()} from $path")
EasyExcel.read(path, gameConfigs).headRowNumber(HEADER_SIZE).sheet().doRead()
gameConfigs
}
}.awaitAll()
}
loadedGameConfigs.forEach { gameConfigs ->
configs[gameConfigs::class] = gameConfigs
}
loadComplete()
}
/**
* 所有配置表加载完成之后重建配置表数据结构以及校验
*/
fun loadComplete() {
val completeFirstClasses = completeFirst()
val completeSecondClasses = configs.keys.filter { it !in completeFirstClasses }
(completeFirstClasses + completeSecondClasses).forEach { configClazz ->
val gameConfigs =
requireNotNull(configs[configClazz]) { "GameConfigs not found: ${configClazz.simpleName}" }
gameConfigs.parseComplete()
}
configs.values.forEach {
ValidateThreadLocal.set(it.excelName(), this)
it.validate()
ValidateThreadLocal.remove()
}
val flattenErrors = errors.values.flatten()
errors.clear()
if (flattenErrors.isNotEmpty()) {
flattenErrors.forEach { validateError ->
logger.error(validateError.toString())
}
throw IllegalStateException("GameConfigManager validate failed")
}
}
/**
* 优先构造完成的配置表
*/
private fun completeFirst(): List<KClass<out V>> {
return listOf()
}
override fun toString(): String {
return "GameConfigManager(version='$version', configs=$configs)"
}
}
结尾
以上,我们完成了从 Excel 配置表到配置表代码的转换,后续还会涉及到配置表代码的自动生成以及序列化与反序列化工作。