用Netty做传奇2服务器-网络部分
私服Delphi版本的源码地址: EGameOfMir2
仿造Netty版本项目地址: MirServer-Netty
原版结构
原版服务器一共分为如下模块,
- 登录门服
- 登录服
- 角色门服
- 角色服
- 游戏服
- 数据库服务器
两个门服实际上并不做任何业务处理, 只是当做前端跟用户做连接, 然后转发封包给后面的登录服和角色服. 感觉就有点像方向代理的感觉; 可能开发这个服务器的时候还没有成熟的负载均衡技术吧, 自己开发; 我的版本不打算用这个结构, 简单为主;
封包结构
反服务器开发肯定首先是封包的结构. 传奇的封包都是由’#’开始并且以’!’结束的; 用netty的LogginHandler打出来就是这样:
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 23 31 3c 3c 3c 3c 3c 49 40 43 3c 3c 3c 3c 3c 3c |#1<<<<<I@C<<<<<<|
|00000010| 3c 3c 48 4f 44 6f 47 6f 40 6e 48 6c 21 |<<HODoGo@nHl! |
+--------+-------------------------------------------------+----------------+
确实看不懂, 但是实际上他是经过类base64转码的. 个人考虑可能是为了避免’内容’部分出现#!这两个字符吧. 解码函数在源码的EDcode.pas
里, 解码之后:
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 23 31 00 00 00 00 d1 07 00 00 00 00 00 00 31 32 |#1............12|
|00000010| 33 2f 31 32 00 00 00 00 00 00 00 00 21 |3/12........! |
+--------+-------------------------------------------------+----------------+
经过分析源码知道, 封包构造是在MakeDefaultMsg
函数, 然后使用它的地方都类似这个样子:
function MakeDefaultMsg(wIdent: Word; nRecog: Integer; wParam, wTag, wSeries: Word): TDefaultMessage;
begin
Result.Recog := nRecog;
Result.Ident := wIdent;
Result.Param := wParam;
Result.Tag := wTag;
Result.Series := wSeries;
end;
SendGateMsg(
UserInfo.Socket, UserInfo.sSockIndex,
EncodeMessage(DefMsg)
+ EncodeString(sSelGateIP + '/' + IntToStr(nSelGatePort) + '/' + IntToStr(UserInfo.nSessionID))
);
那知道了, 封包就是一个转码之后的’defualmsg’ 和 ‘内容’; 那么实际defaultmsg上可以理解为协议头
, 整个封包就是这个结构:
+-----------------------------------------------------------------------------------------+
| 0 | 1 | 2 3 4 5 | 6 7 | 8 9 | 10 11 | 12 13 | 14 ..... n -1 | n |
+-----------------------------------------------------------------------------------------+
| # | header | body | ! |
+-----------------------------------------------------------------------------------------+
| # |index| p0 |protocol| p1 | p2 | p3 | body | ! |
+-----------------------------------------------------------------------------------------+
但是麻烦的是, index这个是客户端发来的封包才有的, 表示封包的序号, 防止中间被恶意插包作假
Netty封包编解码
服务器开发封包首先就是注意分包粘包问题, 可喜的是netty已经帮我们做了很多工作; 既然传奇的封包是’!’结尾, 我们就可以用一个DelimiterBasedFrameDecoder
进行拆包. 粘包用!符自然就分开了, 分包问题没收到!不算.
拆完包之后就是解码, 参考VC版代码写个Bit6Decoder
的MessageToMessageDecoder
的解码器,解码之后再放回到ByteBuf中
,然后再用一个PacketDecoder
把字节码转成java对象
那么在netty的pipeline也就是类似这个样子:
ch.pipeline().addLast(
//编码
new DelimiterBasedFrameDecoder(REQUEST_MAX_FRAME_LENGTH, false, Unpooled.wrappedBuffer(new byte[]{'!'})),
new ClientPacketBit6Decoder(),
new ClientPacketDecoder(ClientPackets.class.getCanonicalName()),
//解码
new PacketBit6Encoder(),
new PacketEncoder(),
);
封包对象映射
转成java对象的时候, 使用了一个枚举类进行协议id
->封包对象类名
的映射, 这样我知道id使用Class.forName
就自然load到封包对象的class, 再newInstance()就可以拼装了; 那么多的封包就不用一个个写映射了, 代码就像这样:
protected Packet decodePacket(short protocolId, ByteBuf in) throws Exception {
Protocol protocol = Protocol.get(protocolId);
if (null == protocol) {
throw new Exception("unknow protocol id:" + protocolId);
}
Class<? extends Packet> packetClass;
try {
packetClass = (Class<? extends Packet>) Class.forName(packetPackageName + "$" + protocol.name());
} catch (ClassNotFoundException e) {
packetClass = Packet.class;
}
Packet packet = packetClass.newInstance();
packet.readPacket(in);
packet.protocol = Protocol.get(protocolId);
return packet;
}
不过这里之前有个纠结的地方, 就是这个装配
, 到底是放到Packet里让packet自己去read自己装配自己, 还是说我应该在Decoder里装配; 因为按照netty的设计, Decoder就是干装配这件事情的. Packet就是一个简单
的POJO;
但是放到packet里自己read也有好处, 因为如果封包格式并不都是标准
的, 比如传奇这里的body部分, 有些是loginId/pwd
,有些是loginId/charName
, 或者不能反推
的比如是json格式, 那用Decoder的话, 它也干不了这个事情, 因为if else 太多了;
所以这里选用packet自己读自己装配的方式; 不过这部分的代码进行了两次数据拷贝, 生产服有优化的空间. 简单实现先; 而且把拆包, 解码, 分开来也是为了方便使用log打印出每一步来进行封包查看, 就像上面的封包输出的那样子
处理器映射
封包处理器同样的, 通过Protocol的name来映射; 交给一个Dispatcher来分发, 代码就像这样:
public void channelRead(ChannelHandlerContext ctx, Object pakcet) throws Exception {
if (!(pakcet instanceof Packet)) {
throw new Exception("Recv msg is not instance of Packet");
}
String protocolName = pakcet.getClass().getSimpleName(); //Packet 就是通过 protocol id 反射出来的 name
Class<? extends Handler> handlerClass;
try {
handlerClass = (Class<? extends Handler>) Class.forName(handlerPackageName + "." + protocolName + "Handler");
}catch(ClassNotFoundException e)
{
handlerClass = Handler.class;
}
Handler handler = handlerClass.newInstance();
handler.exce(ctx, (Packet) pakcet);
}
后来我在Protocol里又加了个eventName 来让Packet和Handler的名字复合java的命名规则; 而让Protocol枚举的名字保持跟Delphi的一致,类似SM_ID_NOTFOUND
,方便参考源码, 也能根据前缀SM和CM区分是client的包还是server的包)
那么这样映射完之后, 我只要这个构造Decoder和dispatcher的时候传入不同的包名, 我就能自动映射到不同模块的包定义中; 这个逻辑可以重用到上面那一堆 门服 和业务处理服中; 所以被我放到了Core
这个模块; 而这些服模块, 就只需要定义封包类
和封包处理类
即可
各个模块的netty的pipeline就像这样:
// ********************** 登录服务器
//编码
new DelimiterBasedFrameDecoder(REQUEST_MAX_FRAME_LENGTH, false, Unpooled.wrappedBuffer(new byte[]{'!'})),
new ClientPacketBit6Decoder(),
new ClientPacketDecoder("loginserver.clientpackets"),
//解码
new PacketBit6Encoder(),
new PacketEncoder(),
//分包分发
new PacketDispatcher("loginserver.handlers"),
// ********************** 游戏服务器
//编码
new DelimiterBasedFrameDecoder(REQUEST_MAX_FRAME_LENGTH, false, Unpooled.wrappedBuffer(new byte[]{'!'})),
new ClientPacketBit6Decoder(),
new ClientPacketDecoder("gameserver.clientpackets"),
//解码
new PacketBit6Encoder(),
new PacketEncoder(),
//分包分发
new PacketDispatcher("gameserver.handlers"),
然后假如要写客户端都是可以用的, 反过来就可以了, 就比如MockClient
模块里的
//编码
new DelimiterBasedFrameDecoder(2048, false, Unpooled.wrappedBuffer(new byte[]{'!'})),
new PacketBit6Decoder(),
new PacketDecoder(ServerPackets.class.getName()),
//解码
new ClientPacketBit6Encoder(),
new PacketEncoder(),