物联网行业智能硬件之间的通信、异构系统之间的对接、中间件的研发、以及各种即时聊天软件等,都会涉及自定义协议。
为了满足不同的业务场景的需要, 应用层之间通信需要实现各种各样的网络协议。以异构系统的对接为例。在早期,我们使用 Web Service 来解决异构系统的对接,后来我们逐渐使用 MQ、RPC 等方式来实现异构系统的通信和整合。
Web Service 是使用 SOAP 协议通过 HTTP 进行传输。MQ 有很多常用的消息队列协议,例如 AMQP、MQTT、STOMP 等,而新兴的消息队列,如 Kafka 和 ZeroMQ,它们并没有严格遵循 MQ 规范,而是基于TCP/IP 协议自行封装了一套协议,并通过 TCP 进行传输。另外,像 Dubbo 这样的 RPC 框架,本身支持多种协议。其自身的 Dubbo 协议也是阿里巴巴自己实现的应用层协议,并通过 TCP 进行传输。
因此,设计好一款合理的、可扩展的自定义协议,可以打通不同的异构系统,亦或者可以作为一款 RPC 框架的基石。今天我将手把手带你设计一个高效、可扩展、易维护的自定义通信协议,以及如何使用 Netty 实现该协议的 TCP 服务端。
为什么需要自定义通信协议?
我们在开发一款工业自动化的智能硬件时,通常需要一台上位机(一般是一款桌面端应用程序)来控制不同的硬件设备。上位机可以独立存在,也可以由 Web 后台发送指令到上位机,再通过上位机来控制智能硬件,以此来完成业务上的操作。
从上位机到 Web 后台之间的通信,可能是由一个 TCP 长连接(也可能是 WebSocket 长连接)来进行维护,而上位机到各个硬件设备之间也可能通过长连接来维护,当然也可以是串口、MQTT、CoAP 等协议,这主要取决于所连接的设备。从 Web 后台到上位机再到智能硬件,假如都使用了 TCP 长连接,那么后两者甚者可以使用 TCP 透传。
无论是 TCP 的长连接,还是 WebSocket 的长连接,本质都是基于 TCP 的连接,为此我们需要使用 Socket 编程,通俗地说可以认为它是对 TCP 协议的具体实现。此外,我们所熟知的中间件、网络游戏、智能硬件、金融等领域也都会涉及 Socket 相关的编程。在使用 Socket 编程时,我们经常会听到别人提起“自定义协议”。事实上,目前已经有了很多标准的协议,那我们为何还需要“自定义”呢?
我们先从下面这张 OSI 七层模型的图开始,快速回顾一下网络通信的面貌。
TCP/IP 协议将 OSI 七层模型进行了简化,变成四层模型。
在 TCP/IP 协议中从应用层到网络接口层,每一层传输的数据包都会包含两部分内容:一部分是协议所要用到的首部,另一部分是从上一层传过来的数据。下图展示了 TCP/IP 包的全貌。
我们所熟知的各种网络应用程序都是在应用层上使用的,TCP/IP 协议的应用层为我们提供了多种常见的应用层协议,例如 HTTP、SSH、Telnet、FTP 等。正是有了这些协议,各种网络应用程序才可以为我们服务。
另外,应用层也支持给我们的程序“量身”制定协议,也就是支持“自定义协议”。当常用的应用层协议不满足我们的应用开发时,例如扩展性不够、安全性不足、不能针对特定领域、无法追求极致的性能等,就需要“自定义协议”。
如何设计自定义通信协议?
TCP 是一种流模式的协议,在实现自定义协议时,我们会遇到诸如以下的问题:
1.应用程序如何知道业务数据是全部接收完毕的,如何解决拆包和粘包问题?
2.如何实现请求/响应机制?
3.如何解决超时问题和实际应用的通信需求?
4.如何定义消息指令或报文类型?
……
自定义通信协议
为了解决上述的问题,首先我们介绍一种比较通用的 TCP 通信协议,其协议结构如下:
+--------------+---------------+------------+---------------+-----------+-
| 魔数(4)| version(1)|序列化方式(1)|command(1)|SerialNo(2)|数据长度(4)|数据(n) |
+--------------+---------------+------------+---------------+-----------+-
下面我们对这个协议中的内容展开介绍。
魔数:4 个字节,为了防止该 TCP 端口被意外调用。我们在收到报文后取前 4 个字节与魔数比对,如果不相同则直接拒绝并关闭连接。魔数可以随意定义,比如采用 20200803 作为魔数,它的 16 进制是 0x1343d63。
版本号:1 个字节,仅表示协议的版本号,便于协议升级时使用。
序列化方式:1 个字节,表示如何将 Java 对象转化为二进制数据,以及如何反序列化。
指令:1 个字节,也可以叫报文类型,表示该消息的意图,如登录、心跳、升级,以及不同的业务指令等。最多可支持 256 种指令(-127 到 127)。
SerialNo:2 个字节,表示整个任务的 id 或者任务的流水号,便于进行追踪。最多支持 2^16 位(-32,768 到 32,767)。
数据长度:4 个字节,表示该字段后数据部分的长度。类似于 HTTP 协议的报文头中的 Content-Length 这个字段。最多支持 2^32 位。
数据:具体的数据内容。
根据上述设计的通信协议,定义一个报文类 Message,它代表通信协议的报文,如下所示:
public abstract class Message{ private MessageHeader messageHeader; private T messageBody; public T getMessageBody() { return messageBody;
}
}
Message 参考 TCP 协议,将其抽象成由 Header 和 Payload 组成(即首部和数据块)。其中,报文的 Header 部分共 9 个字节,包含魔数、版本号、序列化方式、指令、SerialNo,结构如下:
+--------------+---------------+------------+---------------+-----------+
| 魔数(4) | version(1) |序列化方式(1) | command(1) |SerialNo(2)|
+--------------+---------------+------------+---------------+-----------+
因此可以定义一个如下的 Header 类:
public class MessageHeader {
private int magicNumber; // 魔数
private int version = 1; // 版本号,当前协议的版本号为 1
private int serializeMethod; // 序列化方式,默认使用 json
private int command; // 消息的指令
private long serialNo; // 任务的流水号
}
每个 Payload 都是报文的具体内容,即协议体。它可以是一个字符串也可以是一个复杂的对象,因此我们定义一个空接口用于表示 Payload,所有的 Payload 都需要实现该接口:
public abstract class MessageBody { }
考虑到需要预留和扩展性,以避免在将来报文经常性地被修改,可以给 Payload 增加一个预留的属性 extra ,它是一个 Map 类型。因此,再定义一个基类的 BasePayload,我们也可以在 Header 中额外定义一个字段作为一个预留字段。
按照上述的设计,该协议的报文头/首部只有 9 个字节,相比于 HTTP 协议的报文头还是少了很多,极大地精简了传输内容。这也是为什么后端的 RPC 框架通常会采用自定义 TCP 协议进行通信。
Packet 的一次完整旅行
介绍完自定义通信协议后,我们来看看 Packet 在一个 TCP 服务中是怎样经历一次完整的旅行的。
(1)定义指令集
在业务系统中,我们通常需要定义很多个指令,一个指令对应一个 Packet。Header 的 command 字段用来区分不同的指令。
在 Packet 的 Header 中,command 定义了 1 个字节,表示它支持 256 种指令。所以,我们可以定义一个最多包含 256 个指令的指令集 Commands,其定义方式如下:
/* * 指令集 */ public interface Command { /** * 心跳包 */ final Byte HEART_BEAT = 0; /** * 登录请求 */ final Byte LOGIN_REQUEST = 1; /** * 登录响应 */ final Byte LOGIN_RESPONSE = 2; /** * 消息请求 */ final Byte MESSAGE_REQUEST = 3; /** * 消息响应 */
final Byte MESSAGE_RESPONSE = 4;
}
当然,如果觉得 256 个指令不够,修改协议 Header 中 command 的字节数即可。
下面以心跳的 Packet 为例,首先定义一个 HeartBeatPacket:
public class HeartBeatPacket extends Packet { private String msg = "ping-pong"; public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } @Override public Byte getCommand() { return HEART_BEAT; } }
心跳包一般是由 TCP 客户端发起,经由 TCP 服务端接收后,进行响应并返回给客户端。它像心跳一样每隔固定时间发送一次,以此来告诉服务端,这个客户端还活着。
(2) 定义序列化方式
心跳包的内容很小,可以使用 JSON 进行解析。但是对于图片、视频、日志文件等比较大的内容,可能需要使用 Java 自带的序列化方式,或由 Kryo、Hessian、FST、Protobuf 等框架实现对象的序列化和反序列化。
因此,我们定义一个序列化方式的常量列表,代码如下:
* 定义序列化算法 */ public interface SerializeAlgorithm { /** * json序列化标识 */
byte json = 1;
byte binary = 2;
byte fst = 3;
}
上面的代码表示目前只支持这些序列化方式,后续可以不断添加新的序列化方式。
再定义一个序列化接口,每种序列化方式需要一个相应的实现,代码如下:
* * Serializer,用来指定序列化算法,用于序列化对象 */ public interface Serializer { /** * @return 序列化算法 */ byte getSerializerAlgorithm(); /** * 将对象序列化成二进制 * */ byte[] serialize(Object object); /** * 将二进制反序列化为对象 */T deSerialize(Class clazz, byte[] bytes); }
由于,存在多个序列化方式,可以考虑设计一个序列化的工厂类SerializerMap,通过工厂类来获取指令所需要的序列化实现。
private static final Map
serializerMap = new HashMap(); Serializer serializer = new JsonSerializer(); serializerMap.put(serializer.getSerializerAlgorithm(), serializer);
(3)定义 Packet 的工厂类
最初,我们将 Packet 抽象成 Header 和 Payload 两部分,因此 Packet 的生成也包含了两部分的生成。
前面,我们定义了一些客户端、服务端的指令,也知道不同的指令对应不同的 Packet。因此,可以通过指令来生成对应的 Payload。Header 中本身就包含了 command,唯一需要注意的就是序列化方式 serializeMethod,不同的 command 对应唯一的 serializeMethod。
下面是 Packet 的工厂类,用于生成 Payload 和 Header:
* * 编解码对象 */ public class PacketCodeC { /** * 魔数 */ public static final int MAGIC_NUMBER = 0x88888888; public static PacketCodeC instance = new PacketCodeC(); /** * 采用单例模式 */ public static PacketCodeC getInstance(){ return instance; } private static final Map> packetTypeMap; static { packetTypeMap = new HashMap >(); packetTypeMap.put(HEART_BEAT, HeartBeatPacket.class); packetTypeMap.put(LOGIN_REQUEST, LoginRequestPacket.class); packetTypeMap.put(LOGIN_RESPONSE, LoginResponsePacket.class); packetTypeMap.put(MESSAGE_REQUEST, MessageRequestPacket.class); packetTypeMap.put(MESSAGE_RESPONSE, MessageResponsePacket.class); } private PacketCodeC(){
}
}
(4)实现报文的 encode、decode
到了这里,我们的工作还差了报文的 encode、decode。我们可以定义一个报文的管理类 PacketManager,用于对报文进行 encode、decode。
/** * 编码 * * 魔数(4字节) + 版本号(1字节) + 序列化算法(1字节) + 指令(1字节) + 数据长度(4字节) + 数据(N字节) */ public ByteBuf encode(ByteBufAllocator alloc, Packet packet){ //创建ByteBuf对象 ByteBuf buf = alloc.ioBuffer(); return encode(buf,packet);
}
public ByteBuf encode(ByteBuf buf,Packet packet){
//序列化java对象
byte[] objBytes = serializer.serialize(packet);
//实际编码过程,即组装通信包
//魔数(4字节) + 版本号(1字节) + 序列化算法(1字节) + 指令(1字节) + 数据长度(4字节) + 数据(N字节)
buf.writeInt(MAGIC_NUMBER);
buf.writeByte(packet.getVersion());
buf.writeByte(serializer.getSerializerAlgorithm());
buf.writeByte(packet.getCommand());
buf.writeShort(serialNo);
buf.writeInt(objBytes.length);
buf.writeBytes(objBytes);
return buf;
}
public ByteBuf encode(Packet packet){
return encode(ByteBufAllocator.DEFAULT, packet);
}
/**
* 解码
*
* 魔数(4字节) + 版本号(1字节) + 序列化算法(1字节) + 指令(1字节)+序列 2
+ 数据长度(4字节) + 数据(N字节)
*/
public Packet decode(ByteBuf buf){
//魔数校验(handler单独处理)
buf.skipBytes(4);
//版本号校验(暂不做)
buf.skipBytes(1);
//序列化算法
byte serializeAlgorithm = buf.readByte();
//指令
byte command = buf.readByte();
//序列号
buf.readShort(serialNo);
//数据长度
int length = buf.readInt();
//数据
byte[] dataBytes = new byte[length];
buf.readBytes(dataBytes);
Class extends Packet> packetType = getRequestType(command);
Serializer serializer = getSerializer(serializeAlgorithm);
if(packetType != null && serializer != null){
return serializer.deSerialize(packetType,dataBytes);
}
return null;
}
encode() 方法是将 Packet 对象组装成 Netty 的 ByteBuf 对象,组装的方式完全是按照自定义的 TCP 协议来,顺序千万不能错,否则 decode() 无法解析。
需要注意的是,不同的报文可能会采用不同的序列化方式。需要从 Packet 的 Header 中读取 serializeMethod ,然后从工厂类 SerializerMap 中获取对应的序列化实现 serializer。
这样,客户端和服务端就可以进行交互了,TCP 的报文也可以在我们的 TCP 服务中完成一次完整的旅行。
TCP 服务端的设计
服务端采用 Netty 框架,我们使用的是 Netty 的主从多线程 Reactor 模型。Reactor 模型是 Netty 实现高性能的基础,Netty 的 Reactor 模型分为三种:
1.单线程模型、2.多线程模型、3.主从多线程模型。
主从多线程模型由多个 Reactor 线程组成,MainReactor 负责处理客户端连接的 Accept 事件,连接建立成功后将新创建的连接对象注册至 SubReactor。SubReactor 分配线程池中的 I/O 线程与其连接绑定,负责连接生命周期内所有的 I/O 事件。主从多线程模型可以利用 CPU 的多核来提升系统的吞吐量,因此这也是 Netty 推荐使用的模型。
我们需要在服务端定义 boss 和 worker 这两个 Reactor。其中,boss 是主 Reactor,worker 是从 Reactor。它们分别使用不同的 NioEventLoopGroup,主 Reactor 负责处理 Accept 然后把 Channel 注册到从 Reactor 上,从 Reactor 主要负责 Channel 生命周期内的所有 I/O 事件。
@ChannelHandler.Sharable public class Server { private static Logger logger = LoggerFactory.getLogger(Server.class); private static int port = 8888; public static void main(String[] strings){ port = StringUtil.isNullOrEmpty(System.getProperty("port")) ? port : Integer.parseInt(System.getProperty("port")); NioEventLoopGroup boss = new NioEventLoopGroup(); NioEventLoopGroup worker = new NioEventLoopGroup(); ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(boss,worker).channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer() { @Override protected void initChannel(NioSocketChannel nioSocketChannel) throws Exception { nioSocketChannel.pipeline().addLast(new ServerIdleHandler()); nioSocketChannel.pipeline().addLast(new MagicNumValidator()); nioSocketChannel.pipeline().addLast(PacketCodecHandler.getInstance()); nioSocketChannel.pipeline().addLast(LoginRequestHandler.getInstance()); nioSocketChannel.pipeline().addLast(HeartBeatHandler.getInstance()); nioSocketChannel.pipeline().addLast(AuthHandler.getInstance()); nioSocketChannel.pipeline().addLast(ServerHandler.getInstance()); } }); ChannelFuture future = bootstrap.bind(port); future.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture channelFuture) throws Exception { if (channelFuture.isSuccess()){ logger.info("server started! using port {} " , port); }else { logger.info("server start failed! using port {} " , port); channelFuture.cause().printStackTrace(); System.exit(0); } } }); } }
ChannelHandler 的使用
从上述代码中,可以看到 worker 处理了各种各样的 Handler。其中,ServerIdleHandler 继承 Netty 自带的 IdleStateHandler 类,用于检测连接的有效性。如果 150秒内没有收到心跳,则断开连接。
* 心跳检测,150s没收到心跳包的话,断开连接 */ public class ServerIdleHandler extends IdleStateHandler { private static Logger logger = LoggerFactory.getLogger(ServerIdleHandler.class); private static int HERT_BEAT_TIME = 150; public ServerIdleHandler() { super(0, 0, HERT_BEAT_TIME); } @Override protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) throws Exception { logger.info("{}内没有收到心跳,关闭连接...",HERT_BEAT_TIME); ctx.channel().close(); } }
MagicNumValidator:用于 TCP 报文的魔数校验。
public class MagicNumValidator extends LengthFieldBasedFrameDecoder { private static final int LENGTH_FIELD_OFFSET = 7; private static final int LENGTH_FIELD_LENGTH = 4; public MagicNumValidator() { super(Integer.MAX_VALUE, LENGTH_FIELD_OFFSET, LENGTH_FIELD_LENGTH); } @Override protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception { //魔数校验不通过 if(in.getInt(in.readerIndex()) != MAGIC_NUMBER){ ctx.channel().close(); return null; } return super.decode(ctx, in); } }
PacketCodecHandler:解析报文的 Handler。PacketCodecHandler 继承自 ByteToMessageCodec ,它是用来处理 byte-to-message 和 message-to-byte,便于解码字节消息成 POJO 或编码 POJO 消息成字节。
这一步非常关键。因为 TCP 作为传输层的协议,无法理解上层业务数据的具体含义,它根据 TCP 缓冲区的实际情况进行数据包的划分。在业务上认为是一个完整的包,很可能会被 TCP 拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包进行发送,这就是所谓的 TCP 粘包和拆包问题。在这一步,我们通过自定义的编解码器解决了粘包和拆包问题。
在这里,我们看到 PacketCodecHandler 使用上面提到的报文管理类 PacketManager 的 encode()、decode() 方法来完成编解码的过程。
@ChannelHandler.Sharable public class PacketCodecHandler extends MessageToMessageCodec{ private PacketCodecHandler(){} private static PacketCodecHandler instance = new PacketCodecHandler(); public static PacketCodecHandler getInstance(){ return instance; } protected void encode(ChannelHandlerContext ctx, Packet packet, List
HeartBeatHandler:心跳的 Handler,接收 TCP 客户端发来的"ping",然后给客户端返回"pong"。
public class HeartBeatPacket extends Packet { private String msg = "ping-pong"; public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } @Override public Byte getCommand() { return HEART_BEAT; } }
ResponseHandler:通用的处理接收 TCP 客户端发来业务指令的 Handler,可以根据对应的指令去查询对应的 Handler,并对这些命令进行响应。
最后,我们在 ResponseHandler 中,看到还有一个 ThreadPool,它是一个业务线程池。但是在我们所定义的 TCPServer 中, worker 本身使用了一个线程池,为何还需要一个业务线程池呢?
业务线程池的使用
Netty 的 Reactor 线程模型适合处理耗时短的任务场景,对于耗时较长的 ChannelHandler 来说,维护一个业务线程池是一个比较好的做法。将编解码后的数据封装成任务放入线程池中,避免 ChannelHandler 阻塞而造成 EventLoop 不可用。
如果有复杂且耗时的业务逻辑,我推荐的做法是在 ChannelHandler 处理器中自定义新的业务线程池,从而将这些耗时的操作提交到业务线程池中执行。
例如定义一个业务线程池,代码如下:
@Slf4j
public final class ThreadPoolFactoryUtils {
/**
* 通过 threadNamePrefix 来区分不同线程池(我们可以把相同 threadNamePrefix 的线程池看作是为同一业务场景服务)。
* key: threadNamePrefix
* value: threadPool
*/
private static final Map
private ThreadPoolFactoryUtils() {
}
public static ExecutorService createCustomThreadPoolIfAbsent(String threadNamePrefix) {
CustomThreadPoolConfig customThreadPoolConfig = new CustomThreadPoolConfig();
return createCustomThreadPoolIfAbsent(customThreadPoolConfig, threadNamePrefix, false);
}
public static ExecutorService createCustomThreadPoolIfAbsent(String threadNamePrefix, CustomThreadPoolConfig customThreadPoolConfig) {
return createCustomThreadPoolIfAbsent(customThreadPoolConfig, threadNamePrefix, false);
}
public static ExecutorService createCustomThreadPoolIfAbsent(CustomThreadPoolConfig customThreadPoolConfig, String threadNamePrefix, Boolean daemon) {
// 存在则取出,不存新建一个
ExecutorService threadPool = THREAD_POOLS.computeIfAbsent(threadNamePrefix, k -> createThreadPool(customThreadPoolConfig, threadNamePrefix, daemon));
// 如果 threadPool 被 shutdown 的话就重新创建一个
if (threadPool.isShutdown() || threadPool.isTerminated()) {
THREAD_POOLS.remove(threadNamePrefix);
threadPool = createThreadPool(customThreadPoolConfig, threadNamePrefix, daemon);
THREAD_POOLS.put(threadNamePrefix, threadPool);
}
return threadPool;
}
/**
* shutDown 所有线程池
*/
public static void shutDownAllThreadPool() {
log.info("call shutDownAllThreadPool method");
THREAD_POOLS.entrySet().parallelStream().forEach(entry -> {
ExecutorService executorService = entry.getValue();
executorService.shutdown();
log.info("shut down thread pool [{}] [{}]", entry.getKey(), executorService.isTerminated());
try {
executorService.awaitTermination(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
log.error("Thread pool never terminated");
executorService.shutdownNow();
}
});
}
private static ExecutorService createThreadPool(CustomThreadPoolConfig customThreadPoolConfig, String threadNamePrefix, Boolean daemon) {
ThreadFactory threadFactory = createThreadFactory(threadNamePrefix, daemon);
return new ThreadPoolExecutor(customThreadPoolConfig.getCorePoolSize(), customThreadPoolConfig.getMaximumPoolSize(),
customThreadPoolConfig.getKeepAliveTime(), customThreadPoolConfig.getUnit(), customThreadPoolConfig.getWorkQueue(),
threadFactory);
}
/**
* 创建 ThreadFactory 。如果threadNamePrefix不为空则使用自建ThreadFactory,否则使用defaultThreadFactory
*
* @param threadNamePrefix 作为创建的线程名字的前缀
* @param daemon 指定是否为 Daemon Thread(守护线程)
* @return ThreadFactory
*/
public static ThreadFactory createThreadFactory(String threadNamePrefix, Boolean daemon) {
if (threadNamePrefix != null) {
if (daemon != null) {
return new ThreadFactoryBuilder()
.setNameFormat(threadNamePrefix + "-%d")
.setDaemon(daemon).build();
} else {
return new ThreadFactoryBuilder().setNameFormat(threadNamePrefix + "-%d").build();
}
}
return Executors.defaultThreadFactory();
}
}
业务线程池的使用很简单,在 businessAction() 方法中, block 参数是一个 Lambda 表达式,用于执行耗时的业务逻辑,通过 Header 中的 command 来查找服务端所对应使用的 ChannelHandler ,并作出响应。
针对特别复杂的业务,还可以根据业务的特点拆分出多个业务线程池。这样做的好处是:即使某个业务逻辑出现异常造成线程池资源耗尽,也不会影响到其他业务逻辑,从而提高应用程序整体的可用性。也做到了线程池的隔离。
总结,只有充分理解各个硬件、各个软件系统可实现的功能,才能设计出合理的自定义协议。反之,理解了一些常用的中间件相关协议,也可以帮助我们深入理解这些中间件,甚至还可以实现各个中间件的代理功能。
实际上对于其他的高级语言实现自定义协议也是类似的。当你真正理解了自定义 TCP 协议,以后再遇到新的协议,例如自定义的串口协议,会更容易理解。