想象一个场景,你与女友在网上聊天,她问了一句:你爱我吗?然后很忐忑地等你回答。可等了好一段时间,你才收到她的消息,赶紧回复了一句:爱,爱你一万年。又过了好久,你女友才收到你的回复。这时,你说的是什么已经不重要了,准备回去跪键盘吧。可以说这样的用户体验非常的糟糕。
采用轮询拉取消息就会出现上面的场景,而如果采用长连接的方式,服务器可以与客户端建立一条实时的连接,服务器有新消息可以直接推送给客户端,不需要等待客户端请求,这样既保证了实时性,整个系统的抗压能力也优于大量轮询的方式。
什么是长连接通信?
那么,什么是长连接呢?我们都知道短连接是什么,比如我们熟悉的 HTTP 协议,就是使用短连接的方式来请求数据的,它先是建立连接,然后进行数据传输,最后关闭连接。而且只能由客户端主动发起请求,数据传输之后,连接就关闭了,服务端无法主动给客户端发送数据。
而长连接是和短连接相对的,它的过程是:建立连接—>数据传输...(保持连接)……数据传输—>关闭连接。客户端与服务端建立连接之后,客户端和服务端保持住连接不断开,就可以一直在这个连接上传输数据,直到一方主动关闭连接。
如何建立长连接通信?
那怎么建立长连接通信呢?我们常见的网络服务例如 Tomcat、Apache 等主要都是面向短连接的,对长连接支持不是很好。而且长连接需要服务端长期保持连接,如果有大量的连接同时在线,服务端的压力会非常大,所以,就需要一套高性能的网络框架来支撑。幸运的是,有 Netty 这样的异步网络框架来帮助我们管理连接。
你可能多少了解过 Netty,它是基于事件驱动的,易开发、易维护、高性能,完全满足长连接通信的需求。我们使用 Netty 实现我们的服务端,当然也可以实现客户端,但是我们的客户端一般会根据不同的平台采用不同的实现方案。
有服务端,客户端之后,我们还不可以进行通信,因为缺少一个通信协议。我们知道,进行短连接通信的时候采用的是 HTTP 协议,而这次我们要采用 MQTT,一个物联网的标准信息传输协议。它是一个十分轻量级的发布/订阅模型协议,占用网络带宽极小,因为它的固定消息头只占 2 字节,已经被广泛应用于电信、汽车、工业制造等领域。
服务端、协议、客户端,我们都已经知道采用的方案了,来看下整体系统结构:
现在,我们一起动手实现这样一个消息下发服务端吧。我们只需要引入 Netty 的 jar 包即可,代码如下:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.54.Final</version>
</dependency>
启动一个 Netty 服务,如同我们正常启动 Java 程序一样:
public static void main(String[] args) {
int port = 1883;
if (args.length >= 1) {
port = Integer.parseInt(args[1]);
}
NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
NioEventLoopGroup workerGroup = new NioEventLoopGroup(8);
try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 100)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(MqttEncoder.INSTANCE);
pipeline.addLast(new MqttDecoder());
//处理MQTT消息
pipeline.addLast(MyMqttHandlers.INSTANCE);
}
});
//启动服务
ChannelFuture future = serverBootstrap.bind(port).sync();
System.out.println("MQTT server start success,port=" + port);
future.channel().closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
通过这段代码我们启动了 Netty 长连接服务,服务端口是 1883,客户端可以通过这个端口连接到服务端,Netty 本身已经实现了 TCP 连接的建立、管理以及 MQTT 协议的编码和解码等,我们只需要按照需求实现自己的业务逻辑即可。上述代码中,我们只需要实现 MyMqttHandlers.INSTANCE,来完成我们对客户端连接的认证、Topic 订阅、消息发布、心跳检测等等。
上面的 MyMqttHandlers 类,是我们自定义的 Netty Handler,用来处理 MQTT 业务数据,它继承自 Netty 的适配器 SimpleChannelInboundHandler,仅需覆写我们关心的方法 channelRead0,根据不同的 MQTT 报文类型做处理。
.....
@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) {
switch (msg.fixedHeader().messageType()) {
case CONNECT:
connect(ctx, (MqttConnectMessage) msg);
break;
case SUBSCRIBE:
subscribe(ctx, (MqttSubscribeMessage) msg);
break;
case PINGREQ:
pingReq(ctx);
break;
//...处理其他报文
default:
}
}
....
通信报文处理
怎么处理这些报文呢?MQTT 采用的是发布订阅模式的消息通信协议,通过交换预定义的 MQTT 控制报文来通信。这里简单介绍下 MQTT 协议的内容,因为在我们进行编码的时候需要解析消息内容、回复 ACK 消息、发布消息,了解消息结构,可以更好地编码。
MQTT 控制报文由固定报头、可变报头、有效载荷三部分组成,具体格式如下表:
根据 MQTT 3.1.1 规定,固定报头的控制报文类型共有 14 种,我们这次主要使用 CONNECT(连接服务端)、SUBSCRIBE(订阅主题)、PUBLISH(发布消息)、PINGRESP(心跳响应)这四种报文以及对应的 ACK 报文。
CONNECT 报文如何处理?客户端到服务端的网络连接建立后,客户端发送给服务端的第一个报文必须是 CONNECT 报文,这个报文传输设备标识、用户标识、密码等信息,通过这个报文,服务端需判断要不要和客户端连接,常用的方法就是鉴权。如果校验失败,就可以在 ACK 报文中设置状态码 CONNECTION_REFUSED_xxx;检验成功,则设置为 CONNECTION_ACCEPTED。鉴权成功之后,我们就可以把该设备的信息入库保存,实际场景中,我们把设备的实时状态维护在 Redis 中,保证高的吞吐量。
代码如下:
private void connect(ChannelHandlerContext ctx, MqttConnectMessage msg) {
String clientIdentifier = msg.payload().clientIdentifier();
String userName = msg.payload().userName();
String password = new String(msg.payload().passwordInBytes());
//此处可以鉴权
System.out.println(clientIdentifier + " " + userName + " " + password);
//此处保存用户和连接之间的关系
ChannelManager.saveChannelMapping(clientIdentifier, ctx.channel());
MqttFixedHeader connAckFixedHeaderRes = new MqttFixedHeader(MqttMessageType.CONNACK, false, MqttQoS.AT_MOST_ONCE, false, 0);
//连接成功设置为MqttConnectReturnCode.CONNECTION_ACCEPTED,失败可以返回其他状态码
MqttConnAckVariableHeader connAckVariableHeader = new MqttConnAckVariableHeader(MqttConnectReturnCode.CONNECTION_ACCEPTED, false);
MqttConnAckMessage ackMessage = new MqttConnAckMessage(connAckFixedHeaderRes, connAckVariableHeader);
ctx.channel().writeAndFlush(ackMessage);
}
SUBSCRIBE 报文一般作为 CONNECT 之后的下一个报文,客户端上报它需要的 Topic,服务端可以根据客户端的订阅情况,针对性地推送消息,这个可以是广播的 Topic(所有用户都可以收到同一个消息的副本),也可以是点对点的(只有一个用户收到此消息)。同时,服务端需要存储 Topic 到 Channel 的关系。代码如下:
private void subscribe(ChannelHandlerContext ctx, MqttSubscribeMessage msg) {
List<MqttTopicSubscription> topics = msg.payload().topicSubscriptions();
//存储客户端订阅的主题
ChannelManager.saveTopics(ctx.channel(),
topics.stream().map(MqttTopicSubscription::topicName).collect(Collectors.toList()));
System.out.println("订阅成功:" + topics);
MqttFixedHeader header = new MqttFixedHeader(MqttMessageType.SUBACK, false, MqttQoS.AT_LEAST_ONCE, false, 0);
MqttMessageIdAndPropertiesVariableHeader variableHeader = new MqttMessageIdAndPropertiesVariableHeader(msg.variableHeader().messageId(), null);
MqttSubAckPayload payload = new MqttSubAckPayload();
MqttSubAckMessage ackMessage = new MqttSubAckMessage(header, variableHeader, payload);
ctx.writeAndFlush(ackMessage);
}
PUBLISH 报文是我们最终的目标报文,服务端需要根据客户端订阅的 Topic 发送这个报文,由于在处理订阅消息时,已经保存了 Topic 和 Channel 的映射,所以推送消息就简单了,只需要找到 Topic 下所有的 Channel,就可以直接写消息到 Channel 中即可,代码如下:
List<Channel> channels = ChannelManager.listChannels(topic);
channels.forEach(channel -> {
MqttPublishVariableHeader variableHeader = new MqttPublishVariableHeader(topic, 0);
ByteBuf payload = Unpooled.copiedBuffer(messageData, StandardCharsets.UTF_8);
MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PUBLISH, false, MqttQoS.AT_LEAST_ONCE, false, 0);
MqttPublishMessage mqttPublishMessage = new MqttPublishMessage(fixedHeader, variableHeader, payload);
channel.writeAndFlush(mqttPublishMessage);
});
为何要处理 PINGREQ 报文?链路上如果长时间没有数据传输,可能会被运营商把链路回收了,所以设备需要在保活期间内至少发送一个报文,如果没有实际的数据需要传输,那么较小的 PINGREQ 就是最佳选择。连接保活时间的取值范围一般为 30 秒~1200 秒。这个可以根据实际情况,不断调整这个值,我的选择是是 60 秒,如果网络环境好,可以设置 500 秒以上。
实际在线上运行的时候,我发现有些客户端就是一直无法连接上,这时可以再结合短轮询做个备用方案,当多次尝试之后,无法连接上 MQTT 服务,可以暂时启动短轮询,保证用户可以收到消息。
好了,到目前我们已经处理完核心功能了,其它类型的控制报文和处理流程也类似,这里就不再赘述了,总体报文交换流程如下图:
长连接通信测试
我们来试一下效果吧,首先我们需要模拟一个客户端,同样的,也可以使用 Netty 实现一个客户端,主要流程和服务端差不多,有一点需要注意的是,客户端需要定时发送心跳到服务端,以保证链路不会因为长时间空闲被系统断开。
测试流程如下:
- 启动服务端,端口在 1883
- 启动客户端,连接到服务端 127.0.0.1:1883
- 客户端订阅 Topic,名称为 demo
- 服务端每隔 1 秒向 demo 发送一个当前时间的消息
看下运行效果:
总结
以上就是我今天的分享,通过 Netty 和 MQTT,我们可以实现一个高性能的消息下发系统,当然,我今天讲的是最基本的功能实现,当连接数超过一台机器的上限时,就需要设计一个可扩展的架构。我把整体的知识点汇总成一张思维导图,供你参考。