使用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<SocketChannel>() {
            @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<Channel>() {
            @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<Channel, Channel> 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<Packet>{
  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<Object> 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


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342