本专栏的项目代码在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 中去执行的脚本,需要分几种情况
- 对于分片类型的 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()
- 对于集群单例类型的 Actor(Global包下面),可以通过
ClusterSingletonProxy
发往对应的 Actor - 对于其它类型的 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 后台进行可视化的操作,才比较人性化。