战士的最高境界,就是不拿盾牌也能开盾墙 --阿利斯塔
在前面的两篇博文中 使用Netty+Protobuf实现游戏TCP通信 制作一款游戏协议联调工具 已经介绍了Java游戏如何使用Netty和Protobuf实现TCP通信,本文将实现Java游戏另一种比较常用的通信协议,这在当今的H5游戏和微信小程序游戏中使用比较广泛,即使用Netty和Protobuf实现WebSocket通信,读者会发现游戏的WebSocket通信和TCP通信在使用Netty框架后,它们的代码结构是不需要怎么改变的,这也验证了使用Netty网络框架的另一个优点,即把游戏通信协议改为另一种时(如TCP改为Websocket),它的改动是比较少的,如果自己手写实现网络通信,这样的改动可能是灾难性的。此外,该WebSocket通信实例将会给游戏协议加一层安全防范机制,以让读者能有一些其他的收获,即做到循序渐进,逐步提高。
在 游戏之网络初篇 中已经介绍了Websocket和Http的关系,它其实是Http协议的升级版,使用它可以实现web客户端和服务器后台的全双工通信。一个Websocket的连接建立过程大致是如下的:客户端(浏览器)首先向服务端发起HTTP连接请求,但这个请求中包含了一些与平常HTTP请求不同的附加头信息,比如会附带“Upgrade: websocket 和 Connection: Upgrade” 以及Websocket版本信息,用以表明此HTTP协议需要升级为Websocket,随后,服务器接收到信息后,如果服务端也支持Websocket,则会返回一些附带Websocket的升级信息给客户端,这样,客户端和服务端的Websocket通信就建立起来了,此后,它们间的通信就可以不用HTTP协议了,可以直接互发数据了。
在 使用Netty+Protobuf实现游戏TCP通信 中已经介绍过,在网络中,数据都是以二进制字节流传输的,但是在以Websocket通信的游戏中,客户端(浏览器)通常都是处理Json格式的文本协议的,因为浏览器处理Json非常容易,所以,这时客户端和服务端在Packet数据包中byte[] bytes存储的实际上是Json文本格式的二进制流(即将类似{"account":xs996,"password":123456,"platform":3}这样的协议内容编码成二进制流,而游戏TCP通信通常则是把其中的"xs996,123456,3"编码成二进制流,可见Websockt的Json格式协议占用的数据包会大点),因此,以Json格式作为内容传输的二进制流转为Protobuf的过程是:
String json = new String(packet.getBytes(), Charset.forName("UTF-8"));//Packet转json文本 Message.Builder builder = message.newBuilderForType();//message为具体协议方法 JsonFormat.merge(json, builder); Message msg = builder.build();//转Protobuf
而TCP通信中通常就是对传输数据对象编码成二进制流,即并不是先转Json再编码成二进制流,所以它的解码为Protobuf的过程是:
Message msg = message.newBuilderForType().mergeFrom(packet.getBytes()).build();//转Protobuf,其中message为具体协议方法
相应的Json文本格式编码过程如下:
byte[] bytes = JsonFormat.printToString(message).getBytes(Charset.forName("UTF-8")); //message为Protobuf协议 Packet packet = new Packet(Packet.HEAD_TCP, cmd, bytes);
而TCP通信中普通对象的编码过程如下:
byte[] bytes = message.toByteArray(); Packet packet = new Packet(Packet.HEAD_TCP, cmd, bytes);
首先看客户端和服务端如何接入Websocket的及如何处理握手,此为本文的重点之一。
先看客户端的Websocket接入,核心代码如下:
NettyWebsocketClient.java
private final CRC16CheckSum checkSum = new CRC16CheckSum();
public void connect(String host, int port){
EventLoopGroup client = new NioEventLoopGroup();
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(client);
bootstrap.channel(NioSocketChannel.class);
bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
bootstrap.option(ChannelOption.TCP_NODELAY, true);
bootstrap.handler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
//HTTP编解码器
pipeline.addLast("http_codec", new HttpClientCodec());
//HTTP消息聚合,使用FullHttpResponse和FullHttpRequest到ChannelPipeline中的下一个ChannelHandler,这就消除了断裂消息,保证了消息的完整。
pipeline.addLast("http_aggregator", new HttpObjectAggregator(65536));
pipeline.addLast("protobuf_decoder", new ProtoDecoder(null, 5120));
pipeline.addLast("client_handler", new ClientHandler());
pipeline.addLast("protobuf_encoder", new ProtoEncoder(checkSum, 2048));
}
});
ChannelFuture future;
try {
URI websocketURI = new URI(String.format("ws://%s:%d/", host, port));
HttpHeaders httpHeaders = new DefaultHttpHeaders();
//进行握手
WebSocketClientHandshaker handshaker = WebSocketClientHandshakerFactory.newHandshaker(websocketURI, WebSocketVersion.V13, (String)null, true,httpHeaders);
channel = bootstrap.connect(websocketURI.getHost(), websocketURI.getPort()).sync().channel();
ClientHandler handler = (ClientHandler)channel.pipeline().get("client_handler");
handler.setHandshaker(handshaker);
// 通过它构造握手响应消息返回给客户端,
// 同时将WebSocket相关的编码和解码类动态添加到ChannelPipeline中,用于WebSocket消息的编解码,
// 添加WebSocketEncoder和WebSocketDecoder之后,服务端就可以自动对WebSocket消息进行编解码了
handshaker.handshake(channel);
//阻塞等待是否握手成功
future = handler.handshakeFuture().sync();
System.out.println("----channel:"+future.channel());
} catch (Exception e) {
e.printStackTrace();
}
//future.channel().closeFuture().awaitUninterruptibly();
}
public void send(Message msg) {
if (channel == null || msg == null || !channel.isWritable()) {
return;
}
int cmd = ProtoManager.getMessageID(msg);
Packet packet = new Packet(Packet.HEAD_TCP, cmd, msg.toByteArray());
channel.writeAndFlush(packet);
}
它的ClientHandler.java因为要处理与服务端的HTTP握手,及握手成功后数据处理,它的核心代码如下:
public class ClientHandler extends SimpleChannelInboundHandler<Object> {
WebSocketClientHandshaker handshaker;
ChannelPromise handshakeFuture;
public void handlerAdded(ChannelHandlerContext ctx) {
this.handshakeFuture = ctx.newPromise();
}
public WebSocketClientHandshaker getHandshaker() {
return handshaker;
}
public void setHandshaker(WebSocketClientHandshaker handshaker) {
this.handshaker = handshaker;
}
public ChannelPromise getHandshakeFuture() {
return handshakeFuture;
}
public void setHandshakeFuture(ChannelPromise handshakeFuture) {
this.handshakeFuture = handshakeFuture;
}
public ChannelFuture handshakeFuture() {
return this.handshakeFuture;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
//System.out.println("channelRead0 " + this.handshaker.isHandshakeComplete());
Channel ch = ctx.channel();
FullHttpResponse response;
if (!this.handshaker.isHandshakeComplete()) {
try {
response = (FullHttpResponse)msg;
//握手协议返回,设置结束握手
this.handshaker.finishHandshake(ch, response);
//设置成功
this.handshakeFuture.setSuccess();
//System.out.println("WebSocket Client connected! response headers[sec-websocket-extensions]:{}"+response.headers());
} catch (WebSocketHandshakeException var7) {
FullHttpResponse res = (FullHttpResponse)msg;
String errorMsg = String.format("WebSocket Client failed to connect,status:%s,reason:%s", res.status(), res.content().toString(CharsetUtil.UTF_8));
this.handshakeFuture.setFailure(new Exception(errorMsg));
}
} else if (msg instanceof FullHttpResponse) {//1.第一次握手请求消息由HTTP协议承载,所以它是一个HTTP消息,执行handleHttpRequest方法来处理WebSocket握手请求。
response = (FullHttpResponse)msg;
//this.listener.onFail(response.status().code(), response.content().toString(CharsetUtil.UTF_8));
throw new IllegalStateException("Unexpected FullHttpResponse (getStatus=" + response.status() + ", content=" + response.content().toString(CharsetUtil.UTF_8) + ')');
} else if(msg instanceof Packet){
Packet packet = (Packet)msg;
System.out.println("\n<<<<<<<<<<<<收到服务端协议:"+packet.getCmd()+"<<<<<<<<<<<<");
Class<?> clazz = ProtoManager.getRespMap().get(packet.getCmd());
Method m = ClassUtils.findMethod(clazz, "getDefaultInstance");
Message message = (Message) m.invoke(null);
msg = message.newBuilderForType().mergeFrom(packet.getBytes()).build();
ProtoPrinter.print(msg);
}else {//2.客户端通过socket提交请求消息给服务端,WebSocketServerHandler接收到的是已经解码后的WebSocketFrame消息。
WebSocketFrame frame = (WebSocketFrame)msg;
if (frame instanceof TextWebSocketFrame) {
TextWebSocketFrame textFrame = (TextWebSocketFrame)frame;
//this.listener.onMessage(textFrame.text());
System.out.println("TextWebSocketFrame");
} else if (frame instanceof BinaryWebSocketFrame) {
BinaryWebSocketFrame binFrame = (BinaryWebSocketFrame)frame;
System.out.println("BinaryWebSocketFrame received------------------------");
} else if (frame instanceof PongWebSocketFrame) {
System.out.println("WebSocket Client received pong");
} else if (frame instanceof CloseWebSocketFrame) {
System.out.println("receive close frame");
//this.listener.onClose(((CloseWebSocketFrame)frame).statusCode(), ((CloseWebSocketFrame)frame).reasonText());
ch.close();
}
}
}
}
这样,客户端的webSocket升级就完成了,再来看服务端的。
NettyWebsocketServer.java 它的接入Websocket核心代码如下:
public class NettyWebsocketServer {
private static final Logger log = LoggerFactory.getLogger(NettyWebsocketServer.class);
private final EventLoopGroup bossGroup;
private final EventLoopGroup workerGroup;
private final ServerBootstrap bootstrap;
private int upLimit = 2048;
private int downLimit = 5120;
//循环冗余校验
private final CRC16CheckSum upCheckSum = new CRC16CheckSum();
public NettyWebsocketServer(){
bossGroup = new NioEventLoopGroup();
workerGroup = new NioEventLoopGroup(4);
bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 5)
.childOption(ChannelOption.TCP_NODELAY, true);
}
public void bind(String ip, int port) {
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast("http-codec", new HttpServerCodec())//HTTP编解码器
.addLast("aggregator", new HttpObjectAggregator(65536))//HTTP消息聚合
.addLast("websocket", new WebSocketServerProtocolHandler("/", null, true))//处理http升级websocket,还有心跳
.addLast("decoder", new ProtoDecoder(upCheckSum, upLimit))
.addLast("server-handler", new ServerHandler())
.addLast("encoder", new ProtoEncoder(downLimit));
}
});
InetSocketAddress address = new InetSocketAddress(ip, port);
try {
bootstrap.bind(address).sync();
} catch (InterruptedException e) {
log.error("bind {} : {} failed", ip, port, e);
shutdown();
}
}
服务端的Websocket初始化及握手是通过WebSocketServerProtocolHandler来完成的,升级后,双端建立通信通道,客户端与服务端的通信便和以前的《使用Netty+Protobuf实现游戏TCP通信》大同小异了,由此也体现了Netty变更协议的方便性。
在 游戏如何防刷 一文中,提到了游戏协议的安全防范,因为在网络传输过程中,传输的数据是可能被破解和篡改的,游戏中也会如此,利益的驱动能使某些人修改协议,盗刷游戏资源,这种情况还很常见,因此很有必要对游戏的协议做一层加密或完整性校验等防范措施。此为本文的重点之二。
在上面的代码中,可以看到编解码的handler中多了一个CRC16CheckSum对象,它就是用作协议的完整性校验的(专业名词叫循环冗余校验,该校验占用两个字节,即包含了一个16位的二进制CRC值,该值由输入数据按照一定规则计算出来,然后附加到Packet数据包中,接收端在收到数据时重新计算该CRC值,然后与Packet数据包中的CRC值进行比较,如果这两个值不相等,就表示数据传输发生了错误),它的核心代码如下:
public class CRC16CheckSum {
public byte[] checksum(byte[] bytes) {
int crc = 0xffff;
for (int i = 0; i < bytes.length; i++) {
if (bytes[i] < 0) {
crc ^= (int) bytes[i] + 256;
} else {
crc ^= (int) bytes[i];
}
for (int j = 0; j < 8; j++) {
if ((crc & 0x0001) != 0) {
crc >>= 1;
crc ^= 0xa001;
} else {
crc >>= 1;
}
}
}
byte[] result = new byte[2];
result[0] = (byte)((crc >> 8) & 0xff);
result[1] = (byte) (crc & 0xff);
return result;
}
public int length() {
return 2;
}
}
在客户端编码时,核心代码如下:
ProtoEncoder.java
public class ProtoEncoder extends ChannelOutboundHandlerAdapter {
private static final Logger log = LoggerFactory.getLogger(ProtoEncoder.class);
public static final AttributeKey<Short> SEND_SID = AttributeKey.valueOf("SEND_SID");
private final int limit;
private final CRC16CheckSum checkSum;
public ProtoEncoder(CRC16CheckSum checkSum, int limit) {
this.checkSum = checkSum;
this.limit = limit;
}
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
if (msg instanceof Packet) {
Packet packet = (Packet) msg;
if (packet.getBytes().length > limit && log.isWarnEnabled())
log.warn("cmd[{}], packet size[{}], is over limit[{}]", packet.getCmd(), packet.getBytes().length, limit);
if (checkSum == null) {
int size = 7 + packet.getBytes().length;
ByteBuf buf = ctx.alloc().buffer(size);
try {
buf.writeByte(packet.getHead());
buf.writeShort(packet.getBytes().length + 4);
buf.writeInt(packet.getCmd());
buf.writeBytes(packet.getBytes());
msg = new BinaryWebSocketFrame(buf);
} catch (Exception e) {
buf.release();
throw e;
}
} else {
int size = 7 + packet.getBytes().length + checkSum.length();
ByteBuf buf = ctx.alloc().buffer(size);
try {
buf.writeByte(packet.getHead());
size = 2 + 2 + 4 + packet.getBytes().length;
ByteBuf temp = Unpooled.buffer(size, size);
temp.writeShort(getSid(ctx));
temp.writeShort(packet.getBytes().length + 4);
temp.writeInt(packet.getCmd());
temp.writeBytes(packet.getBytes());
byte[] check = checkSum.checksum(temp.array());
buf.writeBytes(check);
buf.writeBytes(temp);
temp.release();
msg = new BinaryWebSocketFrame(buf);
} catch (Exception e) {
buf.release();
throw e;
}
}
}
super.write(ctx, msg, promise);
}
private short getSid(ChannelHandlerContext ctx) {
Attribute<Short> attr = ctx.channel().attr(SEND_SID);
if (attr.get() == null) {
attr.set((short)1);
return 1;
}
short sid = (short)(attr.get() + 1);
if (sid == Short.MAX_VALUE) {
attr.set((short)0);
} else {
attr.set(sid);
}
return sid;
}
}
在服务端解码时核心代码如下:
ProtoDecoder.java
public class ProtoDecoder extends ChannelInboundHandlerAdapter{
public static final AttributeKey<Short> RECV_SID = AttributeKey.valueOf("RECV_SID");
private final int limit;
private final CRC16CheckSum checkSum;
public ProtoDecoder(CRC16CheckSum checkSum, int limit) {
this.limit = limit;
this.checkSum = checkSum;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof BinaryWebSocketFrame) {
BinaryWebSocketFrame frame = (BinaryWebSocketFrame)msg;
try {
ByteBuf in = frame.content();
if (checkSum == null) {
if (in.readableBytes() < 7)
throw new IllegalArgumentException();
byte head = in.readByte();
short length = in.readShort();
if (length <= 0 || length > limit)
throw new IllegalArgumentException();
int cmd = in.readInt();
if (in.readableBytes() < length - 4)
throw new IllegalArgumentException();
byte[] bytes = new byte[length - 4];
in.readBytes(bytes);
ctx.fireChannelRead(new Packet(head, cmd, bytes));
} else {
if (in.readableBytes() < 7 + checkSum.length())
throw new IllegalArgumentException();
in.markReaderIndex();
byte head = in.readByte();
byte[] orig = new byte[checkSum.length()];
in.readBytes(orig);
short sid = in.readShort();
if (!checkSid(ctx, sid))
throw new IllegalArgumentException();
short length = in.readShort();
if (length <= 0 || length > limit)
throw new IllegalArgumentException();
int cmd = in.readInt();
if (in.readableBytes() < length - 4)
throw new IllegalArgumentException();
byte[] bytes = new byte[length - 4];
in.readBytes(bytes);
byte[] check = new byte[2 + 2 + length];
in.resetReaderIndex();
in.skipBytes(1 + checkSum.length());
in.readBytes(check);
//检验循环冗余检验码是否一致,不是,则抛弃该协议
byte[] compare = checkSum.checksum(check);
for (int i = 0; i < orig.length; i++) {
if (orig[i] != compare[i]) {
throw new IllegalArgumentException();
}
}
ctx.fireChannelRead(new Packet(head, sid, cmd, bytes));
}
return;
} finally {
frame.release();
}
}
ctx.fireChannelRead(msg);
}
private boolean checkSid(ChannelHandlerContext ctx, short sid) {
Attribute<Short> attr = ctx.channel().attr(RECV_SID);
if (attr.get() == null) {
attr.set((short)1);
return sid == 1;
}
if (sid != attr.get() + 1)
return false;
if (sid == Short.MAX_VALUE)
attr.set((short)0);
else
attr.set(sid);
return true;
}
}
做了这一层校验后,在一定程度上防止了协议被篡改的可能。此外,还有CRC32,MD5都可以用作协议完整性校验,感兴趣的读者可以在网络上搜索这两种实现,一大把,CRC16在游戏中足以够用。
此外,通常游戏给协议做安全防范还有一种方法,在上面代码中也体现出来了,就是客户端和服务端的建立连接的channel可以维护一个私有的协议序号,每请求一条协议,该序号就递增1,如果两端序号不相等,说明可能是没经过客户端程序发上来的协议,因此把它丢弃掉,这也是一种简单的办法。通常来说,这两种防范已经够用了,在Websocket游戏通信中,通常还会做SSL认证(广泛用于Web浏览器与服务器之间的身份认证和加密数据传输),Netty对SSL也有很好的支持,生成证书后,Netty的核心代码为:
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("SunX509");
KeyStore keyStore = KeyStore.getInstance("JKS");
keyStore.load(new FileInputStream(sslKey), sslPass.toCharArray());
keyManagerFactory.init(keyStore,sslPass.toCharArray());
SslContext sslContext = SslContextBuilder.forServer(keyManagerFactory).build();
SSLEngine engine = sslContext.newEngine(ch.alloc());
engine.setUseClientMode(false);
ch.pipeline().addFirst("ssl", new SslHandler(engine));
至此,游戏协议的安全防范机制就介绍完毕了。
该实例源码在github的地址为:
https://github.com/zhou-hj/NettyProtobufWebsocket.git