如何基于Netty实现即时消息下发

614 阅读8分钟

想象一个场景,你与女友在网上聊天,她问了一句:你爱我吗?然后很忐忑地等你回答。可等了好一段时间,你才收到她的消息,赶紧回复了一句:爱,爱你一万年。又过了好久,你女友才收到你的回复。这时,你说的是什么已经不重要了,准备回去跪键盘吧。可以说这样的用户体验非常的糟糕。

采用轮询拉取消息就会出现上面的场景,而如果采用长连接的方式,服务器可以与客户端建立一条实时的连接,服务器有新消息可以直接推送给客户端,不需要等待客户端请求,这样既保证了实时性,整个系统的抗压能力也优于大量轮询的方式。

什么是长连接通信?

那么,什么是长连接呢?我们都知道短连接是什么,比如我们熟悉的 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 实现一个客户端,主要流程和服务端差不多,有一点需要注意的是,客户端需要定时发送心跳到服务端,以保证链路不会因为长时间空闲被系统断开。

测试流程如下:

  1. 启动服务端,端口在 1883
  2. 启动客户端,连接到服务端 127.0.0.1:1883
  3. 客户端订阅 Topic,名称为 demo
  4. 服务端每隔 1 秒向 demo 发送一个当前时间的消息

看下运行效果:

总结

以上就是我今天的分享,通过 Netty 和 MQTT,我们可以实现一个高性能的消息下发系统,当然,我今天讲的是最基本的功能实现,当连接数超过一台机器的上限时,就需要设计一个可扩展的架构。我把整体的知识点汇总成一张思维导图,供你参考。