使用Netty+Protobuf实现游戏TCP通信

规则就是用来打破的 --金克丝

如大家对Netty和Protobuf还不甚了解,请先参照本系列网络介绍博文 游戏之网络进阶

网络上对Netty的使用和对Protobuf的使用都是分开举例的,很难搜到它们结合使用的实例,对于Java游戏编程爱好者来说可能仍然不知道它们在游戏服务器中是如何应用的,本文将以实际游戏项目中结合Netty和Protobuf为例,为大家讲解一下Java游戏服务器如何使用Netty+Protobuf实现TCP通信的。

Netty是一款用Java写的开源网络框架,所以通常都被应用于Java系统中,其它语言是不支持Netty的,比如游戏开发的前端通常不是用Java来写的,如今已是手游的天下,手游常用的前端语言为Unity3D,Cocos2dx,H5,所以它们在与Java服务器通信时,都是调用它们相应的网络API与Java通信的,如Unity3D可用C#的Socket API,Cocos2dx可用C++的Socket API(注意网络字节顺序、字节长度对齐、字符串末尾'\0'处理),(H5基本都用Websocket),而Protobuf是支持C#,C++,Javascript的(H5游戏基本都是用白鹭引擎做的,而白鹭引擎是一款使用JavaScript编写的Html5开源游戏框架),所以这些前端语言与Java通信时,都是用相应的网络库与相应的Protobuf来通信的。因在下不才,对这些前端不是很懂,所以本文的实例将采用Java前端和Java后端实现。

以下分别是服务器和客户端项目工程结构:

服务端和客户端项目工程结构.png

这里只抽取了游戏服务器网络部分作为整个项目工程,目的是屏蔽其它模块对新手读者的干扰,后续会在游戏服务器框架中介绍所有重点模块,以让新手读者能循序渐进,清晰辨别各个模块作用。

从上图可以看出二者的工程目录差不多是一样的,区别是服务端(左)和客户端(右)里面使用的引导类是不同的,这对Netty有所了解的人肯定都知道,客户端的引导类使用的是Bootstrap,而服务端的引导类是ServerBootstrap,用于搭建整个Netty框架及其初始化工作,这是所有Netty框架中必不可少的启动入口,在工程里的文件分别对应NettyTcpServer.java和NettyTcpClient.java,核心代码如下:
NettyTcpServer.java

    private final EventLoopGroup bossGroup;//监听SeverChannel
    private final EventLoopGroup workerGroup;//创建所有客户端Channel
    private final ServerBootstrap bootstrap;//netty服务端启动类

    private int upLimit = 2048;//解码大小限制
    private int downLimit = 5120;//编码大小限制

    public NettyTcpServer() {
        bossGroup = new NioEventLoopGroup();
        workerGroup = new NioEventLoopGroup(4);
        bootstrap = new ServerBootstrap();//netty服务端启动类,与客户端不同
        bootstrap.group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)//绑定服务端通道,与客户端不同
                .option(ChannelOption.SO_BACKLOG, 5)//指定客户端连接请求队列大小
                .childOption(ChannelOption.TCP_NODELAY, true);//关闭nagle算法,实时性高的游戏不需延迟粘包
    }

    public void bind(String ip, int port) {
        bootstrap.childHandler(new ChannelInitializer() {
            @Override
            protected void initChannel(SocketChannel ch) throws Exception {
                ch.pipeline().addLast("decoder", new ProtoDecoder(upLimit))//解码器,将二进制字节流解码成游戏自定义协议包Packet
                        .addLast("server-handler", new ServerHandler()) //业务处理handler
                        .addLast("encoder", new ProtoEncoder(downLimit));//编码器,将游戏业务数据编码为二进制字节流下发给客户端
            }
        });
        InetSocketAddress address = new InetSocketAddress(ip, port);
        try {
            bootstrap.bind(address).sync();//监听端口
        } catch (InterruptedException e) {
            log.error("bind "+ip+":"+port+" failed", e);
            shutdown();
        }
    }

以上为通用的Netty服务端启动类写法,很多其它博文也有介绍,相信大家已司空见惯了。所以下面的客户端启动类大家见了也不足为奇。
NettyTcpClient.java

    public void conect(String host, int port){
        EventLoopGroup group = new NioEventLoopGroup();
        Bootstrap bootstrap = new Bootstrap();//netty客户端启动类,与服务端不同
        bootstrap.group(group);
        bootstrap.channel(NioSocketChannel.class);//绑定客户端通道,与服务端不同
        bootstrap.handler(new ChannelInitializer() {
            @Override
            protected void initChannel(Channel ch) throws Exception {
                ChannelPipeline pipeline = ch.pipeline();
                pipeline.addLast("decoder", new ProtoDecoder(5120));//解码器
                pipeline.addLast("encoder", new ProtoEncoder(2048));//编码器
                pipeline.addLast("serverHandler", new ClientHandler());//客户端业务处理handler
            }
        });

        ChannelFuture future = bootstrap.connect(new InetSocketAddress(host, port));//连接服务器ip与端口
        System.out.println("----channel:"+future.channel());
        //future.channel().closeFuture().awaitUninterruptibly();
    }

上面两个工程目录还有一点区别是各自的ChannelPipeline中业务处理handler不同,分别对应ServerHandler.java和ClientHandler.java文件,因为服务端和客户端都是对解码后的Packet数据包进行处理,那么它们在收到Packet数据包时的业务处理handler其实是非常类似的,不同之处在于,客户端的handler只针对一个Channel的收发数据进行处理,而服务的的handler是针对所有客户端的连接Channel的收发数据进行处理,这非常符合现实中实际情况,核心代码如下:
ServerHandler.java

@ChannelHandler.Sharable
public class ServerHandler extends ChannelInboundHandlerAdapter{

    private static final Logger log = LoggerFactory.getLogger(ServerHandler.class);

    //value值实际为另一包装Channel的对象,这里避免引入太多业务逻辑,简化处理了
    private final ConcurrentMap ref = new ConcurrentHashMap<>();

    protected ServerHandler() {
        
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        super.channelActive(ctx);
        log.info("["+ctx.channel().remoteAddress()+"] connected");
        ref.put(ctx.channel(), ctx.channel());
    }

    //游戏业务处理核心逻辑,解码器将解码的二进制流反序列化为自定义Packet包,根据protobuf协议即可解析游戏协议内容
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        Packet packet = (Packet)msg;
        Channel channel = ref.get(ctx.channel());
        
        ProtoManager.handleProto(packet, channel);
    }
}

ClientHandler.java

public class ClientHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        Packet packet = (Packet)msg;

        //将Packet再解析为Protobuf
        Class clz = ProtoManager.getRespMap().get(packet.getCmd());
        try {
            Method method = clz.getMethod("parseFrom", byte[].class);
            Object object = method.invoke(clz, packet.getBytes());

            ProtoPrinter.print(object);
        } catch (Exception e1) {
            e1.printStackTrace();
        }
    }
}

以上是工程目录的不同之处,再来看客户端与服务端实际是如何通信的。
之前在 游戏之网络初篇 中介绍过,游戏协议通常采用自定义的 消息头 + 消息体的数据包,即上面的Packet对象,游戏中所有的通信数据都需先封装为此Packet对象,发送至异端时,需要将此Packet对象编码为二进制字节流,接收自异端的二进制字节流时,需将这些二进制字节流解码为此Packet对象,定义如下:
Packet.java

public class Packet{
  public static final byte HEAD_TCP = -128;
  public static final byte HEAD_UDP = 0;
  public static final byte HEAD_NEED_ACK = 64;
  public static final byte HEAD_ACK = 44;
  public static final byte HEAD_PROTOCOL_MASK = 3;
  public static final byte PROTOCOL_PROTOBUF = 0;
  public static final byte PROTOCOL_JSON = 1;
  private final byte head;
  private final short sid;
  private final int cmd;
  private final byte[] bytes;

  public Packet(byte head, int cmd, byte[] bytes) {
    this(head, (short)0, cmd, bytes);
  }

  public Packet(byte head, short sid, int cmd, byte[] bytes){
    this.cmd = cmd;
    this.bytes = bytes;
    this.head = head;
    this.sid = sid;
  }
}

我们的数据包(即一条游戏前后端通信的消息长度)可以定义如下:
数据包 = 1字节标志位 + 2字节消息体长度 + 4字节协议号长度 + N消息体
比如客户端请求登录的Protobuf协议如下:

private static void login(){
        LoginReq_1001001.Builder builder = LoginReq_1001001.newBuilder();
        builder.setAccount("xiaosheng996");
        builder.setPassword("jianshu");
  
        send(builder.build());
}

//将Protobuf打包成自定义数据包对象
public static 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);
}

游戏协议封装成自定义数据包Packet后,需要编码成二进制字节流才能发送给服务端或客户端,根据如上的数据包消息头和消息体定义,编码器ProtoEncoder.java核心代码如下:
ProtoEncoder.java

public class ProtoEncoder extends MessageToByteEncoder{
  private static final Logger log = LoggerFactory.getLogger(ProtoEncoder.class);
  private final int limit;

  public ProtoEncoder(int limit){
      this.limit = limit;
  }

  protected void encode(ChannelHandlerContext ctx, Packet packet, ByteBuf buf) throws Exception{
      if ((packet.getBytes().length > this.limit) && (log.isWarnEnabled()))
          log.warn("packet size[" + packet.getBytes().length + "] is over limit[" + this.limit + "]");
      
      buf.writeByte(packet.getHead());
      buf.writeShort(packet.getBytes().length + 4);
      buf.writeInt(packet.getCmd());
      buf.writeBytes(packet.getBytes());
  }
}

服务端或客户端在收到二进制字节码后,需要反序列化为自定义游戏数据包,即需再反序列化为Packet,因此解码器ProtoDecoder.java的核心代码如下:
ProtoDecoder.java

public class ProtoDecoder extends ByteToMessageDecoder{
  private final int limit;

  public ProtoDecoder(int limit){
      this.limit = limit;
  }

  protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception{
      if (in.readableBytes() < 7)
          return;
      in.markReaderIndex();
      byte head = in.readByte();
      short length = in.readShort();
      if ((length <= 0) || (length > this.limit))
          throw new IllegalArgumentException();
      int cmd = in.readInt();
      if (in.readableBytes() < length - 4) {
          in.resetReaderIndex();
          return;
      }
      byte[] bytes = new byte[length - 4];
      in.readBytes(bytes);
      out.add(new Packet(head, cmd, bytes));
  }
}
 
 

解码后,再交给Netty的ChannelPipeline中的业务逻辑处理器处理,即上面的ServerHandler.java或ClientHandler.java处理。

综上,假设由客户端发起请求协议至服务端下发返回协议,整个的消息流程是:
客户端 -> Protobuf封装游戏数据 -> 打包成自定义数据包Packet -> 编码器将Packet编码为二进制字节流Bytebuf -> 发送给服务端 -> 服务端 -> 收到二进制字节流Bytebuf解码为Packet -> 服务端业务处理handler根据Packet协议号和Protobuf协议文件取出相应的请求数据进行游戏逻辑处理 -> 下发协议给客户端

另外,有些读者可能对这种目录结构的protobuf文件生成配置有些迷惑,故此附上protoc.bat生成配置以作参考

protoc.exe --proto_path=./src/main/resource/protoFiles --java_out=./src/main/java src/main/resource/protoFiles/*.proto
pause

至此,一个完整的客户端和服务端使用Netty+Protobuf实现游戏TCP通信的实例便完全实现了。

它们在github的下载地址为:
https://github.com/zhou-hj/NettyProtobufTcpServer.git
https://github.com/zhou-hj/NettyProtobufTcpClient.git


你可能感兴趣的:(使用Netty+Protobuf实现游戏TCP通信)