Akka
Akka是一个用于构建高性能、分布式和容错系统的工具包及运行时环境,特别适合于Java和Scala开发者在JVM平台上开发高并发应用。
它采用Actor模型来简化并发编程,Actor作为最小的计算单元,通过异步消息传递进行通信,从而有效地解决了传统并发问题。
Akka还提供了丰富的容错机制和位置透明性,使得构建可伸缩的分布式系统更为便捷。此外,Akka还包括其他模块,如Akka Streams用于处理数据流,并支持反压机制,增强了系统在处理大量数据时的稳定性和效率。
akka本身是用scala编写的库,可以运行在JVM上。在c#中,用的是c#版本的akka,github地址如下:
- scala: github.com/akka/akka
- c#: github.com/akkadotnet/...
背景
由于现实情况限制,公司现有某项目的部分模块采用了C#开发,并通过Akka进行了进程间通信。现计划对选定模块进行重构,以Java语言替代C#实现。
为了保持重构后的模块与现有系统兼容,新Java项目需作为被调用的服务端,遵循Akka协议来确保与原有C#调用方的接口无缝对接。尽管全面转向Java是一种理想方案,但由于多种复杂的现实因素,暂时无法对此整个项目进行彻底的语言迁移和技术栈统一。
因此,当前任务的核心在于,在Java环境中建立一个遵循Akka规范的服务端,以便于与遗留的C#客户端进行有效的通信交互。
初探
我把c#的akka源码下载下来,查阅相关文档并基于这个demo进行调试.
在c#版本akka的文档,关于序列化部分,描述了用到的所有序列化器:
我使用demo调试中,发现请求与响应对象的序列化/反序列化只用到了NewtonSoftJsonSerializer ,这让我一开始以为事情的解决如此简单,我只需要在服务端以字符串接收并解析json即可。
事实并非如此简单,Akka Remote类的序列化器都可能用到,我们的项目中,框架把几种序列化器都用了,除了json这个。
再探
我开始深入debug c#版本的源码,同时把scala版本源码也下载下来进行调试分析。
scala版本必须是最远古的那几个版本。
两个版本的实现非常接近,c#的通信框架基于dot.netty实现,scala版本基于netty 3.x的版本实现。
通过源码分析整个实现流程之后,我对协议的实现有了大致思路。
基于netty实现,流的编码采用LengthFieldPrepender,解码采用LengthFieldBasedFrameDecoder,但是需要注意,c#的实现使用小端网络字节序,而Java默认实现是大端网络字节序,因此这个地方实现的时候必须指定小端。
难解
我基于java实现一个简易版的服务端,尝试接收demo中c#客户端的请求并在打印日志后给予响应。
结果,生活总是没有想象那么美好。
akka协议的请求分为两种:
- 指令,控制类消息
- 消息
消息又分为下面两种:
- 系统消息(akka自身)
- 业务消息(我们发的)
所以在编写解码器的时候,必须考虑指令类消息的解码:
java
private AkkaPduCodec.AkkaPdu decodeInstruction(final WireFormats.AkkaControlMessage message) {
switch (message.getCommandType()) {
case ASSOCIATE -> {
if (message.hasHandshakeInfo()) {
WireFormats.AkkaHandshakeInfo handshakeInfo = message.getHandshakeInfo();
String cookie = null;
if (handshakeInfo.hasCookie()) {
cookie = handshakeInfo.getCookie();
}
WireFormats.AddressData origin = handshakeInfo.getOrigin();
Address address = new Address(origin.getProtocol(), origin.getSystem(), origin.getHostname(), origin.getPort());
HandshakeInfo info = new HandshakeInfo(address, (int) handshakeInfo.getUid(), Option.apply(cookie));
return new AkkaPduCodec.Associate(info);
}
}
case DISASSOCIATE -> {
return new AkkaPduCodec.Disassociate(AssociationHandle.Unknown$.MODULE$);
}
case DISASSOCIATE_SHUTTING_DOWN -> {
return new AkkaPduCodec.Disassociate(AssociationHandle.Shutdown$.MODULE$);
}
case DISASSOCIATE_QUARANTINED -> {
return new AkkaPduCodec.Disassociate(AssociationHandle.Quarantined$.MODULE$);
}
case HEARTBEAT -> {
return AkkaPduCodec.Heartbeat$.MODULE$;
}
default -> log.error("Unknown:{}", message);
}
return null;
}
这里面用到的一些类,实际是我引用了scala版本的akka依赖。
注意,akka通信也采用了protobuf的序列化,而如何判断是否是指令类型还是其它消息类型,我的实现也很简单:
java
WireFormats.AkkaProtocolMessage message = WireFormats.AkkaProtocolMessage.parseFrom(bytes);
if (message.hasPayload()) {
AkkaPduCodec.Payload payload = new AkkaPduCodec.Payload(ByteString.
fromByteBuffer(message.getPayload().asReadOnlyByteBuffer()));
out.add(payload);
} else if (message.hasInstruction()) {
AkkaPduCodec.AkkaPdu akkaPdu = decodeInstruction(message.getInstruction());
out.add(akkaPdu);
} else {
log.error("Unknown:{}", message);
}
我以为问题如此简单,便兴致勃勃的进行了实现,和本地的c#的客户端联调也通过了。
当我和实际业务系统进行联调的时候,才发现并不简单,业务系统也并不是使用的如此简单,消息的序列化也并不是json,并且出现了其它我无法解析的消息格式。
把我之前的认知和推断推翻了。好难。
好难解
我对业务系统的配置和源码进行了分析,发现还使用了Remote DeathWatch:
同时进行了抓包分析并结合源码,查询这个命令的请求情况,发现根据配置的间隔定时请求(还是有不少额外开销的)。
通过源码分析,这个请求客户端发送到服务端,服务端本身的实现是进行相关监听器的注册处理,但是不需要给予客户端相关响应。这样我实现起来就方便多了,只管接收这个请求,不需要响应。
但是比较麻烦的是,不同的请求用的反序列化器,不一样。我本身以为只是固定的一两个,结果发现不是。我差点懵了,实现这一两个已经耗费不少精力,如果要考虑所有情况把所有的都实现一遍,成本太高。
完美解决
仔细核对c#和scala版本akka的序列化及网络传输部分的实现,包括使用的protobuf定义。
然后把c#版本的定义拿过来在java里调整过后,重新实现,在不懈努力下,重新写出了一个通用的协议的序列化与反序列化版本,下面是一些debug看到的格式示例:
swift
envelope {
recipient {
path: "akka.tcp://server@localhost:4567/"
}
message {
message: "\n\203\001\nM\b\002\020\b\032Gakka.tcp://client@localhost:53151/system/inbox-1#149231235\020\002\0320AkkaDemo.Proto.Request, AkkaDemo\022\b\b\002\022\004user\022\022\b\002\022\016DispatchServer"
serializerId: 6
}
sender {
path: "akka.tcp://client@localhost:53151/user/dispatch-client-8-0#1086482885"
}
seq: 18446744073709551615
}
ack {
cumulativeAck: 1
}
envelope {
recipient {
path: "akka.tcp://server@localhost:4567/"
}
message {
message: "\n\b\020\020\032\004RWHB\022\n\b\002\022\006system\022\022\b\002\022\016remote-watcher"
serializerId: 6
}
sender {
path: "akka.tcp://client@localhost:58542/system/remote-watcher#2129229977"
}
seq: 18446744073709551615
}
最终,终于成功实现了这个服务端
其它方案
实现的时候,也考虑过是否能直接用scala版本的实现直接对接c#版本,最终发现两个问题,让我放弃了这个方案:
-
上面示例的包格式里c#版本的带有一个命名空间,是自动生成的,scala实现应该是包路径,两边完全对不上。
-
远古版本用的是3.x的netty,项目里有4.x的netty,版本冲突,如果想用4.x的,我需要把scala实现的通信协议改成4.x的netty重新打包,不了解sbt这个工具(scala用的这个),我感觉了解这个的时间我已经写完了。新版本的实现已经不用netty了,用的另一套,基于udp协议的,所以和c#版本的更难对接了。
总之,直接对接的方案,改动scala实现并重新打包的成本,大于我直接实现的成本。所以考虑之后就放弃了。