Akka分布式游戏后端开发6 配置表序列化与反序列化

56 阅读5分钟

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

在将 Excel 配置表数据解析成可供程序使用的对象之后,我们需要对这个配置表对象进行序列化,以便后续发布到生产环境时,直接反序列化就可以得到此对象。因为我们不可能发布到生产环境时,还带着一堆配置表数据,通常是在打包阶段就会把配置表数据解析好,生成一个二进制文件,最后程序启动的时候,直接解析此二进制文件就可以了。

因此,我们需要一个序列化方案,将 Java 对象序列化以及反序列化。这里我们选用的序列化框架是 Kryo。

Kryo 是一个快速、高效的对象序列化框架,主要用于将 Java 对象序列化为字节流(以及反序列化为对象)。它以性能和紧凑性著称,常用于分布式系统、缓存机制和高效的数据传输场景,例如 Apache Spark、Flink、Akka 等分布式计算框架中。

Kryo 的主要特点:

  1. 高性能

    • Kryo 的序列化和反序列化速度通常快于 Java 自带的 ObjectOutputStream/ObjectInputStream
    • 生成的序列化数据体积较小,占用更少的带宽或存储空间。
  2. 支持多种数据类型

    • Kryo 可以序列化多种 Java 数据类型,包括基本类型、集合、数组、用户自定义类等。
  3. 可扩展性

    • Kryo 支持注册自定义的序列化器(Serializer),以优化序列化逻辑。
    • 用户可以根据需求扩展其序列化机制以支持复杂对象。
  4. 跨平台兼容性

    • 序列化后的数据格式可以在不同平台上解码,从而实现跨系统数据传输。
  5. 支持循环引用和嵌套对象

    • Kryo 能够自动检测循环引用,避免递归堆栈溢出问题。
  6. 轻量级

    • Kryo 体积小,易于嵌入到其他项目中。

GameConfigManager 序列化/反序列化

提供序列化接口

为了使序列化后的文件体积更加紧凑,我们需要抹掉对象的类型信息,使用 id 来代替类型信息,也就是在 Kryo 中使用 register 方法把类型信息关联到某个 id 上,反序列化的时候,直接根据 id 就可以找到类型信息。

abstract class GameConfigs<K : Any, C : GameConfig<K>> : AnalysisEventListener<Map<Int, String?>>(), Map<K, C> {
    private var configs: HashMap<K, C> = hashMapOf()

    /**
     * 将[configs]进行序列化
     */
    fun serialize(kryo: Kryo, output: Output) {
        kryo.writeObject(output, configs)
    }

    /**
     * 将[configs]进行反序列化
     */
    fun deserialize(kryo: Kryo, input: Input) {
        @Suppress("UNCHECKED_CAST")
        configs = kryo.readObject(input, HashMap::class.java) as HashMap<K, C>
    }
}

我们在 GameConfigs 中新增两个接口,用于将里面的 configs 对象序列化和反序列化。之所以要单独把 configs 拿出来序列化,是因为 configs 中的 KV 中含有通配符,在通过反射注册类型时,无法获取到具体的类型,只能把序列化和反序列化的操作延迟到具体的实现类中。在进行反射分析的时候,我们并没有直接注册这个 KV,而是去看子类的 KV 是什么,再进行注册。

编写序列化工具类

在序列化的过程中,我们先将 GameConfigManager 序列化,然后再序列化里面的 configs,因为 configs@Transient 注解,所以序列化 GameConfigManager 的时候不会序列化到 configs。然后向 Kryo 中写入 configs 的数量,迭代整个 configs,写入每个 GameConfigs 的类型信息以及调用 GameConfigsserialize 进行序列化。反序列化过程和序列化过程相反。

object GameConfigManagerSerde {

    fun deserialize(inputStream: InputStream): GameConfigManager =
        Input(GZIPInputStream(inputStream)).use { deserialize(it) }

    fun deserialize(bytes: ByteArray): GameConfigManager =
        Input(GZIPInputStream(bytes.inputStream())).use { deserialize(it) }

    fun serialize(manager: GameConfigManager, outputStream: OutputStream) =
        Output(GZIPOutputStream(outputStream)).use { serialize(manager, it) }

    fun serialize(manager: GameConfigManager): ByteArray {
        val bos = ByteArrayOutputStream()
        Output(GZIPOutputStream(bos)).use { serialize(manager, it) }
        return bos.toByteArray()
    }

    private fun deserialize(input: Input): GameConfigManager {
        return GameConfigKryoPool.use {
            val manager = readObject(input, GameConfigManager::class.java)
            val size = readObject(input, Int::class.java)
            repeat(size) {
                @Suppress("UNCHECKED_CAST")
                val key = readClass(input).type.kotlin as K
                val constructor =
                    requireNotNull(key.primaryConstructor) { "${key.simpleName} primaryConstructor is null" }
                val gameConfigs = constructor.call()
                gameConfigs.manager = manager
                gameConfigs.deserialize(this, input)
                manager.configs[key] = gameConfigs
            }
            manager
        }
    }

    private fun serialize(manager: GameConfigManager, output: Output) {
        GameConfigKryoPool.use {
            writeObject(output, manager)
            writeObject(output, manager.configs.size)
            manager.configs.values.forEach {
                writeClass(output, it::class.java)
                it.serialize(this, output)
            }
        }
    }
}

GameConfigKryoPool

我们对 Kryo 进行了一下简单封装,提供了一个 Kryo 池来对数据进行序列化和反序列化,因为 Kryo 不是线程安全的对象。然而真正麻烦的不是这里,而是 Kryo 的类注册,因为为了序列化性能和体积,需要进行类注册,Kryo 需要知道序列化对象的所有类型信息,因此我们需要递归的遍历序列化对象中字段的所有类型,提前进行注册。这个操作有一个额外的程序来执行代码生成,因为配置表的变动不频繁,不需要放到编译流程中。

val GameConfigKryoPool = KryoPool(ConfigDeps + ConfigImpl + ConfigsImpl + DepsExtra)
class KryoPool(val classes: Array<KClass<*>>) {
    val logger = logger()

    private val pool = object : Pool<Kryo>(true, true) {
        override fun create(): Kryo {
            val kryo = Kryo()
            kryo.isRegistrationRequired = true
            kryo.instantiatorStrategy = DefaultInstantiatorStrategy(StdInstantiatorStrategy())
            var id = 30
            classes.toSet().forEach {
                val nextId = ++id
                kryo.register(it.java, nextId)
                logger.debug("register class: {} -> {}", it, nextId)
            }
            return kryo
        }
    }

    init {
        //预先创建一些Kryo对象以供使用
        val kryoList = mutableListOf<Kryo>()
        repeat(100) {
            kryoList.add(pool.obtain())
        }
        kryoList.forEach {
            pool.free(it)
        }
    }

    fun <R> use(block: Kryo.() -> R): R {
        val kryo = pool.obtain()
        return try {
            block(kryo)
        } finally {
            pool.free(kryo)
        }
    }
}

结尾

以上我们完成的配置表的序列化和反序列化工作,后续会讲配置表代码的生成以及 Kryo 依赖的生成逻辑,这一套模式是不变的,不可能说策划配了一张表后,我还需要手动写一大堆代码,最好的方式就是根据配置表生成配置表代码。