用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版代码写个Bit6DecoderMessageToMessageDecoder的解码器,解码之后再放回到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(),