1 何为通信协议

无论是使用 Netty 还是原始的 Socket 编程,基于 TCP 通信的数据包格式均为二进制,协议指的就是客户端与服务端事先商量好的,每一个二进制数据包中每一段字节分别代表什么含义的规则

客户端与服务端的通信过程如下:

客户端 服务端架构协同 服务端和客户端协议_网络

  1. 首先,客户端把一个 Java 对象按照通信协议转换成二进制数据包
  2. 然后通过网络,把这段二进制数据包发送到服务端;
  3. 服务端接受到数据之后,按照协议取出二进制数据包中的相应字段,包装成 Java 对象,交给应用逻辑处理

服务器响应也是如此~


2 通用协议的设计

客户端 服务端架构协同 服务端和客户端协议_客户端 服务端架构协同_02

  1. 魔数: 通常情况下为固定的4个字节,为什么需要这个字段,且还是一个固定的数?

如果开放了一个服务器,且协议没有魔数,任何数据包传递到服务器,服务器都会根据自定义协议来进行处理,包括不符合自定义协议规范的数据包。
例如: 服务器只支持HTTP协议,但是我用TPC去请求他,服务器闭着眼解析,解析完了才发现不对劲,这就浪费了资源,所以服务器需要先阅读魔数来判断该协议是否符合自己的协议,第一时间识别出这个数据包并非是遵循自定义协议的,也就是无效数据包,为了安全考虑可以直接关闭连接以节省资源。

  1. Java 的字节码的二进制文件中,开头的 4 个字节为0xcafebabe 用来标识这是个字节码文件,亦是异曲同工之妙。
  2. 版本号: 绝大多数情况下都是预留字段,用于协议升级的时候用到,有点类似 TCP 协议中的一个字段标识是 IPV4 协议还是 IPV6 协议;
  3. 序列化算法: 表示如何把 Java 对象转换二进制数据以及二进制数据如何转换回 Java 对象,比如 Java 自带的序列化,json,hessian 等序列化方式;
  4. 指令: 服务端或者客户端每收到一种指令都会有相应的处理逻辑,指令占一个字节,最高支持256种指令,完全够小型系统用了;
  5. 数据部分的长度: 占四个字节;
  6. 数据内容: 每一种指令对应的数据是不一样的,如登录的时候需要用户名密码,收消息的时候需要用户标识和具体消息内容等;

3 通信协议的实现原理(Demo)

先了解两个概念:

  1. 编码: 把 Java 对象根据协议封装成二进制数据包的过程;
  2. 解码: 从二进制数据包中解析出 Java 对象的过程;

写个小demo,演示编码和解码:
先new一个对象:

@Data
public abstract class Packet {
    /**
     * 协议版本
     */
    private Byte version = 1;

    /**
    * 指令
    * /
    public abstract Byte getCommand();
}

说明: 以上是通信过程中 Java 对象的抽象类,可以看到,定义了一个默认值为 1 的版本号,以及一个获取指令的抽象方法,所有的指令数据包都必须实现这个方法,这样就可以知道某种指令的含义。


以客户端登录请求为例,定义登录请求数据包:

public interface Command {
    Byte LOGIN_REQUEST = 1;
}

@Data
public class LoginRequestPacket extends Packet {
    private Integer userId;

    private String username;

    private String password;

    @Override
    public Byte getCommand() {
        return LOGIN_REQUEST;
    }
}

登录请求数据包继承自 Packet,然后定义三个字段:用户 ID用户名密码,这里最为重要的就是覆盖了父类的getCommand()方法,值为常量LOGIN_REQUEST

Java 对象定义完成之后,接下来就需要定义一种规则:如何把一个 Java 对象转换成二进制数据,这个规则叫做 Java 对象的序列化

3.1 序列化

下定义序列化接口:

public interface Serializer {

    /**
     * 序列化算法
     */
    byte getSerializerAlgorithm();
    
    /**
     * java 对象转换成二进制
     */
    byte[] serialize(Object object);

    /**
     * 二进制转换成 java 对象
     */
    <T> T deserialize(Class<T> clazz, byte[] bytes);
}

序列化接口有三个方法:

  1. getSerializerAlgorithm()获取具体的序列化算法标识;
  2. serialize() 将 Java 对象转换成字节数组;
  3. deserialize() 将字节数组转换成某种类型的 Java 对象,

本demo使用最简单的 json 序列化方式,使用阿里巴巴的 fastjson 作为序列化框架。

public interface SerializerAlgorithm {
    /**
     * json 序列化标识
     */
    byte JSON = 1;
}


public class JSONSerializer implements Serializer {
   
    @Override
    public byte getSerializerAlgorithm() {
        return SerializerAlgorithm.JSON;
    } 

    @Override
    public byte[] serialize(Object object) {
        return JSON.toJSONBytes(object);
    }

    @Override
    public <T> T deserialize(Class<T> clazz, byte[] bytes) {
        return JSON.parseObject(bytes, clazz);
    }
}

定义一下序列化算法的类型以及默认序列化算法:

public interface Serializer {
    /**
     * json 序列化
     */
    byte JSON_SERIALIZER = 1;

    Serializer DEFAULT = new JSONSerializer();

    // ...
}

这样,我们就实现了序列化相关的逻辑,如果想要实现其他序列化算法的话,只需要继承一下 Serializer,然后定义一下序列化算法的标识,再覆盖一下两个方法即可。

3.2 编码

PacketCodeC.java
private static final int MAGIC_NUMBER = 0x12345678;

public ByteBuf encode(Packet packet) {
    // 1. 创建 ByteBuf 对象
    ByteBuf byteBuf = ByteBufAllocator.DEFAULT.ioBuffer();
    // 2. 序列化 Java 对象
    byte[] bytes = Serializer.DEFAULT.serialize(packet);

    // 3. 实际编码过程
    byteBuf.writeInt(MAGIC_NUMBER);
    byteBuf.writeByte(packet.getVersion());
    byteBuf.writeByte(Serializer.DEFAULT.getSerializerAlgorithm());
    byteBuf.writeByte(packet.getCommand());
    byteBuf.writeInt(bytes.length);
    byteBuf.writeBytes(bytes);

    return byteBuf;
}

编码过程分为三个过程:

  1. 创建一个 ByteBuf,这里我们调用 Netty 的 ByteBuf 分配器来创建,ioBuffer() 方法会返回适配 io 读写相关的内存,它会尽可能创建一个直接内存;

直接内存可以理解为不受 jvm 堆管理的内存空间,写到 IO 缓冲区的效果更高。

  1. 将 Java 对象序列化成二进制数据包;
  2. 逐个往 ByteBuf 写入字段,即实现了编码过程;

一端实现了编码之后,Netty 会将此 ByteBuf 写到另外一端,另外一端拿到的也是一个 ByteBuf 对象,基于这个 ByteBuf 对象,就可以反解出在对端创建的 Java 对象,这个过程我们称作为解码

3.3 解码

PacketCodeC.java
public Packet decode(ByteBuf byteBuf) {
    // 跳过 magic number
    byteBuf.skipBytes(4);

    // 跳过版本号
    byteBuf.skipBytes(1);

    // 序列化算法标识
    byte serializeAlgorithm = byteBuf.readByte();

    // 指令
    byte command = byteBuf.readByte();

    // 数据包长度
    int length = byteBuf.readInt();

    byte[] bytes = new byte[length];
    byteBuf.readBytes(bytes);

    Class<? extends Packet> requestType = getRequestType(command);
    Serializer serializer = getSerializer(serializeAlgorithm);

    if (requestType != null && serializer != null) {
        return serializer.deserialize(requestType, bytes);
    }
    return null;
}

解码的流程如下:

  1. 假定 decode 方法传递进来的 ByteBuf 已经是合法的(即首四个字节是我们前面定义的魔数 0x12345678),这里调用 skipBytes 跳过这四个字节;
  2. 暂时不关注协议版本,通常我们在没有遇到协议升级的时候,这个字段暂时不处理;
  3. 接下来,调用 ByteBuf 的 API 分别拿到序列化算法标识、指令、数据包的长度;
  4. 最后,根据拿到的数据包的长度取出数据,通过指令拿到该数据包对应的 Java 对象的类型,根据序列化算法标识拿到序列化对象,将字节数组转换为 Java 对象;

4 总结

  1. 通信协议是为了服务端与客户端交互,双方协商出来的满足一定规则的二进制数据格式;
  2. 展示了通用的通信协议的设计,包括魔数、版本号、序列化算法标识、指令、数据长度、数据几个字段,该协议能够满足绝大多数的通信场景;
  3. Java 对象以及序列化,目的就是实现 Java 对象与二进制数据的互转;
  4. 最后,依照我们设计的协议以及 ByteBuf 的 API 实现了通信协议,这个过程称为编解码过程。