Akka分布式游戏后端开发4 脚本支持

107 阅读5分钟

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


执行脚本在游戏业务中是一个非常重要的需求,例如逻辑有Bug需要修复、某个玩家的数据出问题了需要修复、策划表配错了帮策划擦屁股以及遇到匪夷所思的Bug定位需要查内存数据等等。游戏不会轻易停服,因为停服的成本太高了。

为了方便通过脚本进行热更新,我们会把业务逻辑写在 XXXService 中 或者 XXXHandler 中,其中 Handler 主要用来处理客户端协议以及内部消息,Service 用来写一些公共的逻辑,可以在 Handler 中调用。在做热更新的时候,直接继承目标 Service 或者 Handler,覆写部分方法完成逻辑修复。

使用哪种脚本方案?

在计划中,我们打算支持以 Jar 包和 Groovy 代码的形式做热更新/脚本支持。Groovy 优点是很灵活,不需要在本地编译,直接写好代码发往目标服执行就好,缺点是由于整个项目的代码是以 Kotlin 为主的,所以很多同学并不熟悉 Groovy 语法,同时在与 Kotlin 代码进行交互时,也可能遇到一些问题。Jar 包的方式优点是我可以使用任何 JVM 平台的语言来写代码,只要实现了对应的接口,最后编译成 Jar 包就可以了,缺点自然就是需要编译。

接口设计

我们计划提供一个接口,里面有一个 call 方法,当脚本发送到目标节点、目标 Actor 时,使用 ClassLoader 加载此 Class,然后调用 call 方法完成逻辑的执行。

在 Kotlin 中,我们可以直接基于 Function 接口定义接口:

internal interface NodeScriptFunction : Function2<Node, ByteArray?, Unit>

继承自此接口的脚本会在指定节点上执行,并带有两个参数,第一个是节点本身,第二个是一些额外的数据(取决于脚本怎么传)。

interface NodeRoleScriptFunction<T : Node> : Function2<T, ByteArray?, Unit>

继承自此接口的脚本会在指定节点并且具有指定角色的目标上执行。

interface ActorScriptFunction<T : AbstractActor> : Function2<T, ByteArray?, Unit>

继承自此接口的脚本会在指定 Actor 上执行。

定义消息

ScriptType

enum class ScriptType {
    GroovyScript,
    JarScript,
}

Script

/**
 * @param name 脚本名
 * @param type 脚本类型
 * @param body 脚本本体
 * @param extra 执行脚本的额外数据
 */
class Script(val name: String, val type: ScriptType, val body: ByteArray, val extra: ByteArray?) {
    override fun toString(): String {
        return "Script(name='$name', type=$type)"
    }
}

ExecuteNodeRoleScript

/**
 * Execute a script on the node with a specific role
 * @param uid unique id of the script
 * @param script script to execute
 * @param role role to execute the script
 * @param filter filter the nodes to execute the script
 */
data class ExecuteNodeRoleScript(val uid: String, val script: Script, val role: Role, val filter: Set<Address>) :
    Message

ExecuteNodeScript

/**
 * Execute a script on the node
 * @param uid unique id of the script
 * @param script script to execute
 * @param filter filter the nodes to execute the script
 */
data class ExecuteNodeScript(val uid: String, val script: Script, val filter: Set<Address>) : Message

ExecuteActorScript

/**
 * Execute a script on the actor
 * @param id if actor is a shard actor, id is the shard entity id else id is not used
 * @param uid unique id of the script
 * @param script script to execute
 */
data class ExecuteActorScript(override val id: Long, val uid: String, val script: Script) : ShardMessage<Long>

CompileActorScript

/**
 * Compile a script on script actor
 * @param uid unique id of the script
 * @param script script to compile
 * @param actor actor to execute the script
 */
data class CompileActorScript(val uid: String, val script: Script, val actor: ActorRef) : Message

ScriptActor 设计

我们会在每个节点上启动一个 ScriptActor,用于加载或者编译脚本,再将编译好的脚本发送到目的地执行。

class ScriptActor<N>(private val node: N) : AbstractActor() where N : Node {
    companion object {
        const val SCRIPT_CLASS_NAME = "Script-Class"
        const val NAME = "scriptActor"

        fun <N : Node> props(node: N): Props {
            return Props.create(ScriptActor::class.java) { ScriptActor(node) }
        }
    }

    private val logger = actorLogger()
    private val selfMember = Cluster.get(context.system).selfMember()

    override fun preStart() {
        logger.info("{} started", self)
    }

    override fun createReceive(): Receive {
        return receiveBuilder()
            .match(ExecuteNodeRoleScript::class.java) { handleNodeRoleScript(it) }
            .match(ExecuteNodeScript::class.java) { handleNodeScript(it) }
            .match(CompileActorScript::class.java) { handleCompileActorScript(it) }
            .build()
    }
}

从脚本文件中加载 Class

private fun loadClass(script: Script): KClass<*> {
    return when (script.type) {
        ScriptType.GroovyScript -> {
            val text = String(script.body)
            GroovyClassLoader().parseClass(text).kotlin
        }

        ScriptType.JarScript -> {
            val file = File.createTempFile(script.name, ".jar")
            file.writeBytes(script.body)
            val jarFile = JarFile(file)
            val scriptName = requireNotNull(jarFile.manifest.mainAttributes.getValue(SCRIPT_CLASS_NAME)) {
                "Script-Class not found in manifest"
            }
            val loader = URLClassLoader(arrayOf(file.toURI().toURL()))
            loader.loadClass(scriptName).kotlin
        }
    }
}

我们会在 Jar 包打包的时候向 manifest 中写入需要执行的目标脚本的类名,然后在这里加载目标类名的类就可以了。然后我们使用 URLClassLoader 进行类的加载工作。

脚本实例化

private fun <T> scriptInstance(script: Script): T {
    val scriptClass = loadClassWithCache(script)
    val constructor = requireNotNull(scriptClass.primaryConstructor) {
        "$scriptClass must have an empty primary constructor"
    }
    @Suppress("UNCHECKED_CAST")
    return constructor.call() as T
}

加载完类之后,我们调用主构造函数把类实例化出来,然后转换成目标类型。

脚本执行

以执行 Actor 脚本为例,脚本首先是发送到目标 Actor 的,然后目标 Actor 将脚本发送到 ScriptActor,执行类的加载工作,等类加载好了之后再发送回目标 Actor 进行执行。

private fun handleCompileActorScript(message: CompileActorScript) {
    runCatching {
        val script = message.script
        val actorScriptFunction = scriptInstance<ActorScriptFunction<in AbstractActor>>(script)
        message.actor.forward(ExecuteActorFunction(message.uid, actorScriptFunction, script.extra), context)
    }.onFailure {
        logger.error(it, "compile actor script failed")
        sender.tell(ExecuteScriptResult(message.uid, false), message.actor)
    }
}

脚本编译

之前的方案中,进行脚本编译的时候需要手动传入要执行的哪个 Class,实际的体验比较差,总是要在编译的时候去输入参数,后面做了一些改进,直接在 Gradle 中扫描 script 的编译目录,看编译出了哪些 class 文件,然后动态的去生成编译任务。

if (Boot.contains(project.name) || project.name == "common") {
    sourceSets {
        create("script") {
            compileClasspath += main.get().run { compileClasspath + output }
        }
    }
    val scriptSourceSets = sourceSets["script"]
    scriptSourceSets.output.classesDirs.forEach { file ->
        val scriptClassesDir = file.resolve("com/mikai233/${project.name}/script")
        scriptClassesDir.walk().filter { it.isFile && it.extension == "class" }.forEach { classFile ->
            val className = classFile.nameWithoutExtension
            tasks.register<Jar>("buildJarFor${className}") {
                group = "script"
                description = "Build JAR for $className"
                archiveFileName.set("${rootProject.name}_${project.name}_${className}.jar")
                manifest {
                    attributes("Script-Class" to "com.mikai233.${project.name}.script.${className}")
                }
                from(scriptSourceSets.output)
                include("com/mikai233/${project.name}/script/*")
            }
        }
    }
}

脚本如何正确的路由到目的地

  • 对于发送到 Actor 中去执行的脚本,需要分几种情况
    1. 对于分片类型的 Actor,直接根据 Actor 的 id 就可以发往对应的 Actor
val uuid = uuid()
val results = worlds.map { worldId ->
   val executeActorScript = ExecuteActorScript(worldId, uuid, script)
   async { node.worldSharding.ask<ExecuteScriptResult>(executeActorScript) }
}.awaitAll()
  1. 对于集群单例类型的 Actor(Global包下面),可以通过 ClusterSingletonProxy 发往对应的 Actor
  2. 对于其它类型的 Actor,就只能通过 ActorPath,也就是这个 Actor 的地址,发送了
val actorSelection = node.system.actorSelection(actorPath)
runCatching {
   actorSelection.resolveOne(3.seconds.toJavaDuration()).await()
}.onSuccess { channelActor ->
   val uuid = uuid()
   val executeActorScript = ExecuteActorScript(0, uuid, script)
   val result = channelActor.ask<ExecuteScriptResult>(executeActorScript)
   call.respond(result)
}.onFailure {
   call.respond(HttpStatusCode.BadGateway, "Channel actor not found")
}
  • 对于发往节点的脚本,需要使用 Akka 中的 Cluster Aware Routers 来操作,相当于是集群广播的方式,节点收到脚本之后自行判断需不需要在此节点上执行
deployment {
  /scriptActorRouter {
    router = broadcast-group
    routees.paths = ["/user/scriptActor"]
    cluster {
      enabled = on
      allow-local-routees = on
    }
  }
}

一些例子

查询玩家数据

class TestPlayerScript : ActorScriptFunction<PlayerActor> {
    private val logger = logger()

    override fun invoke(player: PlayerActor, p2: ByteArray?) {
        logger.info("playerId:{} hello world", player.playerId)
        player.node.gameWorldConfigCache.forEach { (id, config) ->
            logger.info("id:{} config:{}", id, config)
        }
    }
}

修复业务逻辑

class LoginServiceFix : LoginService() {
    override fun createPlayer(player: PlayerActor, playerCreateReq: PlayerCreateReq) {
        super.createPlayer(player, playerCreateReq)
    }
}
class PlayerScriptFunction : NodeRoleScriptFunction<PlayerNode> {
    private val logger = logger()

    override fun invoke(p1: PlayerNode, p2: ByteArray?) {
        loginService = LoginServiceFix()
        logger.info("fix login service done")
    }
}

结尾

做完脚本支持之后,还需要写对应的 Web 界面,通过 Gm 后台进行可视化的操作,才比较人性化。