Akka分布式游戏后端开发12 网关

66 阅读3分钟

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

我们使用 Netty 来接收客户端连接,然后再转发给 Akka 处理。

Netty 简介

Netty 是一个高性能、异步事件驱动的 网络框架,基于 Java 实现,广泛用于开发高并发、高性能的网络应用程序(如服务器、客户端)。它封装了 Java 的原生网络 API(如 NIOEpoll),简化了网络编程的复杂性。

Netty 被用于实现通信协议(如 HTTP、WebSocket)、RPC 框架(如 gRPC、Dubbo)等。

Netty 的核心特点

  1. 高性能

    • 基于 Java NIO(非阻塞 IO)。
    • 充分利用多核 CPU,提供良好的并发性能。
    • 零拷贝支持(如直接内存、堆外内存等)。
  2. 易用性

    • 提供了丰富的抽象和封装,隐藏了底层的复杂性(如缓冲区管理、选择器)。
    • 简化了网络应用的开发流程,减少了样板代码。
  3. 事件驱动模型

    • 基于 Reactor 模式,通过事件循环处理 I/O 事件,实现异步和高效的处理。
  4. 可扩展性

    • 通过 ChannelHandler 链式设计,轻松实现协议解析、数据处理等功能。
  5. 跨平台支持

    • 支持 Epoll(Linux)和 KQueue(macOS)等操作系统的优化。
  6. 多种协议支持

    • 内置支持多种协议,如 HTTP、WebSocket、FTP、TLS 等,也可自定义协议。

将网络连接关联到 Actor

我们会建立一个 ChannelActor,用来作为 Netty 和 Akka 的桥梁,当有新的连接进来时,我们就会启动一个 ChannelActor,然后让它持有 ChannelHandlerContext,之后就可以在 Actor 中向客户端写入消息了。

class ChannelActor(node: GateNode, private val handlerContext: ChannelHandlerContext) : StatefulActor<GateNode>(node) {
    companion object {
        val MaxIdleDuration = 1.minutes

        fun props(node: GateNode, handlerContext: ChannelHandlerContext): Props =
            Props.create(ChannelActor::class.java, node, handlerContext)
    }
}

添加一个 ChannelHandler,继承 ChannelInboundHandlerAdapter,然后覆写 channelActive 方法启动 Actor

override fun channelActive(ctx: ChannelHandlerContext) {
    val state = node.state
    if (node.state == State.Started) {
        val channelActor = node.system.actorOf(ChannelActor.props(node, ctx))
        ctx.channel().attr(actorKey).set(channelActor)
    } else {
        logger.warn("gate is not running, current state:{}, channel will close", state)
        ctx.close()
    }
}

转发消息

同样在 ChannelHandler 中覆写 channelRead

override fun channelRead(ctx: ChannelHandlerContext, message: Any) {
    if (message is ClientProtobuf) {
        tell(ctx, message)
    } else {
        logger.error("unsupported message:{}", message)
    }
}

private fun tell(ctx: ChannelHandlerContext, message: ChannelMessage) {
    val channelActorRef: ActorRef? = ctx.channel().attr(actorKey).get()
    if (channelActorRef == null) {
        logger.warn("failed to send message:{} to channel actor because of channel actor not found", message)
    } else {
        channelActorRef.tell(message)
    }
}

这样就完成了 Netty 和 Akka 之间的关联了,如果在 Actor 中收到服务端推送给客户端的消息,只需要调用 ChannelHandlerContext 中的方法就好了。

fun write(message: GeneratedMessage, listener: ChannelFutureListener? = null) {
    val future = handlerContext.writeAndFlush(message)
    listener?.let { future.addListener(it) }
}

消息路由

现在 ChannelActor 已经收到消息了,但是距离消息的目的地还远,例如接受客户端连接的是 node1,但是消息的目的地 Actor 可能在 node2 或者 node3 上面,我们还需要在内部做一次消息转发。在设计中,PlayerActorWorldActor 都是基于集群分片创建的,只要我们知道这些 Actor 的 id,那么 Akka 就会正确的将消息转发给对应的 Actor。对于集群单例的 Actor,也可以启动 ClusterSingletonProxy 将消息转发到对应的 Actor。

好了,现在我们又遇到下一个问题,哪些协议应该发往哪些 Actor?,例如消息 A 的处理函数是在 WorldActor 上的,消息 B 的处理函数是在 PlayerActor 上的。为此我们需要做一个消息转发的 Map,哪些消息应该转发到哪个 Actor 上。我们会在 Gradle 中定义如下的代码,用于生成转发 Map:

val actor = Forward[project.name]
if (actor != null) {
    tasks.register<JavaExec>("generateProtoForwardMap") {
        group = "other"
        description = "Generates protobuf forward map for gate"
        mainClass = "com.mikai233.common.message.MessageForwardGeneratorKt"
        val sourceSetsMain = sourceSets.main.get()
        classpath = sourceSetsMain.runtimeClasspath
        val gateResourcesPath =
            project(":gate").extensions.getByType<SourceSetContainer>().main.get().resources.srcDirs.first().path
        args =
            listOf(
                "-p",
                "com.mikai233.${project.name}.handler",
                "-o",
                gateResourcesPath,
                "-f",
                actor
            )
    }
    tasks.named("compileKotlin") {
        finalizedBy("generateProtoForwardMap")
    }
}

执行的程序也很简单,就是通过反射扫描 MessageHandler,查看这个包下面处理了哪些消息,然后再转成消息 id 写入 gate 的 resources 目录下, gate 启动的时候,通过读取这些文件,就知道怎么转发消息了。

resources
├─ ChannelActor.json
├─ PlayerActor.json
├─ WorldActor.json
├─ gate.conf
└─ logback.xml

文件里面就单纯写入了消息的 id。

[ 3, 2 ]

单例初始化的时候就直接扫 resources 目录,然后生成转发的 Map 就可以了。

object MessageForward {

    private val ForwardMap = mutableMapOf<Int, Forward>()

    fun whichActor(id: Int): Forward? {
        return ForwardMap[id]
    }

    init {
        Forward.entries.forEach { actor ->
            val url = Resources.getResource("${actor.name}.json")
            Json.fromBytes<List<Int>>(url.openStream().readBytes()).forEach { id ->
                ForwardMap[id] = actor
            }
        }
    }

}

ChannelActor 收到消息之后,就可以这么处理了:

private fun forwardClientMessage(clientProtobuf: ClientProtobuf) {
    val (id, message) = clientProtobuf
    val actor = MessageForward.whichActor(id)
    logger.debug("forward message:{} to target:{}", formatMessage(message), actor)
    if (actor == null) {
        logger.warning("proto: {} has no target to forward", clientProtobuf.id)
    } else {
        when (actor) {
            Forward.PlayerActor -> {
                val playerId = playerId
                if (playerId != null) {
                    node.playerSharding.tell(ProtobufEnvelope(playerId, message), self)
                } else {
                    logger.warning("try to send message to uninitialized playerId, this message will be dropped")
                }
            }

            Forward.WorldActor -> {
                val worldId = worldId
                if (worldId != null) {
                    node.worldSharding.tell(ProtobufEnvelope(worldId, message), self)
                } else {
                    logger.warning("try to send message to uninitialized worldId, this message will be dropped")
                }
            }

            Forward.ChannelActor -> {
                handleProtobuf(message)
            }
        }
    }
}