Akka分布式游戏后端开发5 配置表代码设计

本专栏的项目代码在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 配置表到配置表代码的转换,后续还会涉及到配置表代码的自动生成以及序列化与反序列化工作。

相关推荐
Pandaconda8 分钟前
【Golang 面试题】每日 3 题(三十九)
开发语言·经验分享·笔记·后端·面试·golang·go
是梦终空10 分钟前
JAVA毕业设计210—基于Java+Springboot+vue3的中国历史文化街区管理系统(源代码+数据库)
java·spring boot·vue·毕业设计·课程设计·历史文化街区管理·景区管理
基哥的奋斗历程35 分钟前
学到一些小知识关于Maven 与 logback 与 jpa 日志
java·数据库·maven
m0_5127446435 分钟前
springboot使用logback自定义日志
java·spring boot·logback
十二同学啊39 分钟前
JSqlParser:Java SQL 解析利器
java·开发语言·sql
编程小筑43 分钟前
R语言的编程范式
开发语言·后端·golang
技术的探险家1 小时前
Elixir语言的文件操作
开发语言·后端·golang
老马啸西风1 小时前
Plotly 函数图像绘制
java
方圆想当图灵1 小时前
缓存之美:万文详解 Caffeine 实现原理(上)
java·缓存
ss2731 小时前
【2025小年源码免费送】
前端·后端