【手撸IM】通讯协议设计与实现

【手撸IM】专栏导读
1.《【手撸IM】消息ID设计与实现》https://blog.csdn.net/camelials/article/details/136558285
2.《【手撸IM】通讯协议设计与实现》https://blog.csdn.net/camelials/article/details/136879608

1. 背景

之前说过要手撸一个 IM 需要考虑的东西太多了,以至于前期做的很多工作与 IM 本身其实并没有太多直接的关系,例如:分布式策略、接入服务等。上一篇抛砖引玉的介绍了消息ID的设计与实现,现在谈下如何设计与实现一个私有的通讯协议。

在 IM 系统中常用的开放协议有:XMPP(Extensible Messaging and Presence Protocol)、SIP(Session Initiation Protocol)、MQTT(Message Queuing Telemetry Transport),那么为什么不直接使用已有的开放协议非要自己去搞一个私有协议呢?原因如下:

  • 定制化需求:IM应用通常具有特定的功能和业务需求,这些需求可能无法完全通过已有的开放协议来满足。为了实现特定的功能和业务逻辑,开发者可能需要设计和实现自己的通讯协议。

  • 性能优化:自定义的私有协议通常可以更好地满足应用的性能需求,包括减少通讯延迟、降低带宽消耗等。通过优化协议设计,可以提升系统的通讯效率和用户体验。

  • 安全考虑:一些IM应用可能有较高的安全需求,需要采取特定的安全措施来保护用户数据和通讯隐私。使用自定义的私有协议可以更灵活地实现安全功能,如加密通讯、身份验证等。

  • 竞争优势:自定义的私有协议可以成为IM应用的竞争优势之一。通过独特的通讯协议设计,可以为用户提供独特的功能和体验,从而吸引更多用户和提升市场竞争力。

  • 控制权:使用自定义的私有协议可以使开发者对通讯协议有更多的控制权,可以根据实际需求和市场变化灵活地调整和优化协议设计,而不受外部协议规范的限制。

举个例子,早期很多 IM 应用,直接选择 XMPP ,虽然上手快,但是XMPP是一种基于XML的开放式即时通讯协议,首先的问题就是消息体臃肿,对于减少通讯延迟、降低带宽消耗极其不友好(XML标记臃肿性无法规避的问题)。另外,XML 的直接可读性又导致消息安全性几乎是裸奔:随便一个抓包,一切尽收眼底。

2. 私有通讯协议设计

2.1 设计思路

要设计一个私有的通讯协议,其实大致上走的就是一个传统套路:协议头如何表达和组织,协议体如何序列化。纵观那些常用的开放协议都是如此,例如下面的一个SIP消息示例:

INVITE sip:alice@example.com SIP/2.0
Via: SIP/2.0/UDP client.example.com:5060;branch=z9hG4bKnashds7
Max-Forwards: 70
To: Alice <sip:alice@example.com>
From: Bob <sip:bob@example.com>;tag=1928301774
Call-ID: a84b4c76e66710
CSeq: 314159 INVITE
Contact: <sip:bob@client.example.com>
Content-Type: application/sdp
Content-Length: 142

v=0
o=bob 2890844526 2890844526 IN IP4 client.example.com
s=-
c=IN IP4 client.example.com
t=0 0
m=audio 49217 RTP/AVP 0
a=rtpmap:0 PCMU/8000

请求行:指定请求方法(INVITE)、请求的URI(目标用户的SIP地址)和SIP协议的版本。
Via:指定了发送请求的SIP终端的地址信息。
Max-Forwards:指定了请求可以被转发的最大次数。
To:指定了目标用户的SIP地址。
From:指定了发送者的SIP地址和标签。
Call-ID:指定了当前呼叫的唯一标识符。
CSeq:指定了请求的序列号和请求方法。
Contact:指定了发送者的联系地址。
Content-Type:指定了消息体的类型。
Content-Length:指定了消息体的长度。
消息体(SDP格式):指定了会话描述协议(SDP)内容,包括会话的属性、媒体类型和媒体参数。

从上面的例子可以看出,对于一个SIP协议的消息,光是消息头就几十个字节出去了。其实早年的飞信和微软MSN用的就是一个非标的SIP协议,然后称之为:SIP-C协议(本人在飞信服务端干了7年,后期主导了和飞信服务端的建设),其中的一个改动就是对SIP协议头的Key做了精简:例如:将“From”精简为“F”。目的其实也就是给消息体瘦身嘛,在那个智能手机刚刚兴起的年代人们尚且如此,现在更应当如此。

于此同时,我们也能发现:将常用的开放协议进行改造其实是设计和实现一个私有通讯协议的捷径。例如:CMPP(China Mobile Peer-to-Peer)中国移动短信协议其实就看作是一个非标的SMPP协议(Short Message Peer-to-Peer:一种专门用于短信消息传输的协议,最初由欧洲电信标准化机构(ETSI)制定)。

2.2 设计参照选型

既然将常用的开放协议进行改造是设计和实现一个私有通讯协议的捷径,那么放眼望去MQTT协议无疑是一个非常精简的协议,毕竟SMPP之类的太过于追求消息荷载的紧凑序列化了,PB或者其他基于TLV的序列化方式它不香吗?现在不是流行说:不是羽绒服买不起,而是军大衣更有性价比!
在这里插入图片描述
从上图中MQTT协议消息体结构中可以看出其固定头(FixHeader)仅仅只有2个字节,这对于传统的SIP简直太香了,毕竟基于应用开发的我们做到bit位级的精简已经是极致了!对于可变头(VariableHeader)来说包括的信息也比较少和紧凑(较常的应用是做为包的标识),对于消息荷载(Payload)来说,那么就是自己爱怎么定就怎么定就行了。

2.3 通讯协议设计思路

2.3.1 消息类型

标准的MQTT协议中的消息类型由固定头第一字节的高4位表达,那么限制了其范围最多为:2的4次方,即16种。标准MQTT消息类型为:
Reserved1(0);CONNECT(1);CONNACK(2);PUBLISH(3);PUBACK(4);PUBREC(5);PUBREL(6);PUBCOMP(7);SUBSCRIBE(8);SUBACK(9);UNSUBSCRIBE(10);UNSUBACK(11);PINGREQ(12);PINGRESP(13);DISCONNECT(14);Reserved2(15);

其实回过头来想想我们的实际需求就会发现好像16种表达足够用了:

  • 连接相关:连接,断连,重连
  • 信令相关:发消息专用一类(PUBLISH & PUBACK)、其他信令用共用一类(QUERY & QUERYACK),同时结合可变头中的Topic。
  • 保活相关:Ping & Pong

2.3.2 剩余长度

标准的MQTT协议中第2字节为剩余长度,官方解释为:固定头的第二字节用来保存变长头部和消息体的总大小的,但不是直接保存的。这一字节是可以扩展,其保存机制,前7位用于保存长度,后一部用做标识。当最后一位为 1时,表示长度不足,需要使用二个字节继续保存。

这些官方的描述比较绕口,白话就是:使用变长方式表达消息长度,剩余长度字段的长度可以是一个字节,也可以是多个字节,取决于消息的实际长度。这无疑是一种非常好的做法。因此在实际的方案中将消息体长度约定为:用最多3个字节的无符号变长Int表达。3字节无符号Int的范围为:2的24次方 -1 = 16,777,215 字节,即:≈ 16 MB;因为对于一般系统和 IM 系统来说16 MB其实足够用了:

  • 文本消息:16MB那太够了。
  • 富媒体消息:用外链去表达富媒体、同时限制富媒体缩略图的上限。
  • 拉消息:限制一批最多拉多少条或者多少MB即可。例如:200条或者10MB。

2.3.3 校验和(Checksum)

在标准的MQTT协议中没有明确定义校验和(Checksum),但是在TCP协议中,校验和(Checksum)是一种错误检测机制,用于检测数据在传输过程中是否发生了损坏或修改。TCP校验和的计算方式是对TCP报文段中的数据字段和部分首部字段进行计算,然后将结果添加到TCP报文段的首部中。接收方会根据校验和对接收到的数据进行验证,以确保数据的完整性和可靠性。介于Checksum的重要性,决定使用协议固定头第2字节用来表达校验和(Checksum)

2.3.4 设计结果

最终基于MQTT协议的私有通讯协议可以简要表达为下面的描述,其中第1字段与标准MQTT协议完全一致,荷载则决定使用protostuff序列化方式,原因是.proto文件的编写有点恶心,并且它与Protocol Buffer性能几乎接近,另外的原因是为了和《Java版Akka-Rpc实现与应用》https://blog.csdn.net/camelials/article/details/123327236 的实现保持一致。

  • 非标MQTT协议定义
类型位置说明
固定头第1字节MsgType (消息类型:4 bits);DUP (重传标记:1 bits);QoS (质量等级:2 bits);RETAIN (保留位:1 bits)
固定头第2字节校验和(Checksum):固定头第1字节与剩余长度进行按位异或
可变头第3-J字节剩余长度:最多3字节的变长Int
可变头第K-L字节signature(鉴权信息) 、topic(可以理解为信令名)、targetId(目标用户ID)
荷载第 M-N 字节消息体Bytes
  • MessageType改造
public enum MqttMessageType {

    /**
     * connect
     */
    CONNECT(1),
    CONNACK(2),
    DISCONNECT(14),

    /**
     * publish
     */
    PUBLISH(3),
    PUBACK(4),

    /**
     * query
     */
    QUERY(5),
    QUERYACK(6),
    QUERYCON(7),

    /**
     * reconnect
     */
    RECONNECT(8),
    RECONNECTACK(9),

    /**
     * ping
     */
    PINGREQ(12),
    PINGRESP(13),

    /**
     * reserve
     */
    RESERVE1(0),
    RESERVE2(15);

    private final int value;

    MqttMessageType(int value) {
        this.value = value;
    }

    public int getValue() {
        return this.value;
    }

    /**
     * valueOf
     *
     * @param i
     * @return
     */
    public static MqttMessageType valueOf(int i) {
        for (MqttMessageType t : MqttMessageType.values()) {
            if (t.value == i) {
                return t;
            }
        }

        throw new IllegalArgumentException("Invalid MqttMessageType value: " + i);
    }
}
  • QoS(与标准MQTT一致)
public enum QoS {

    AT_MOST_ONCE(0),

    AT_LEAST_ONCE(1),

    EXACTLY_ONCE(2),

    DEFAULT(3);

    private int value;

    QoS(int value) {
        this.value = value;
    }

    public int getValue() {
        return this.value;
    }

    /**
     * valueOf
     *
     * @param i
     * @return
     */
    public static QoS valueOf(int i) {
        for (QoS q : QoS.values()) {
            if (q.value == i) {
                return q;
            }
        }

        throw new IllegalArgumentException("Invalid QoS value: " + i);
    }
}

3. 通讯协议主要实现

1、系统接入服务打算用Netty实现,因此通讯协议消息编解码器基于Netty实现。
2、消息体荷载打算使用基于TLV的protostuff序列化方式,不过介于荷载怎么样都可以,因此测试代码中仅使用Utf8字符串(protostuff的序列化与反序列化不需要在这里证明)。

3.1 消息头(MqttMessageHeader)

package cn.bossfriday.im.protocol.core;

import cn.bossfriday.im.protocol.enums.MqttMessageType;
import cn.bossfriday.im.protocol.enums.QoS;

/**
 * MqttMessageHeader
 * <p>
 * | MsgType (消息类型:4 bits) |  DUP (重传标记:1 bits)  |   QoS (质量等级:2 bits)  |  RETAIN (保留位:1 bits)  |
 *
 * @author chenx
 */
public class MqttMessageHeader {

    private MqttMessageType mqttMessageType;
    private boolean retain;
    private QoS qos = QoS.AT_MOST_ONCE;
    private boolean dup;

    public MqttMessageHeader(MqttMessageType mqttMessageType, boolean retain, QoS qos, boolean dup) {
        this.mqttMessageType = mqttMessageType;
        this.retain = retain;
        this.qos = qos;
        this.dup = dup;
    }

    public MqttMessageHeader(byte flags) {
        this.retain = (flags & 1) > 0;
        this.qos = QoS.valueOf((flags & 0x6) >> 1);
        this.dup = (flags & 8) > 0;
        this.mqttMessageType = MqttMessageType.valueOf((flags >> 4) & 0xF);
    }

    public MqttMessageType getType() {
        return this.mqttMessageType;
    }

    public MqttMessageType getMqttMessageType() {
        return this.mqttMessageType;
    }

    public boolean isRetained() {
        return this.retain;
    }

    public QoS getQos() {
        return this.qos;
    }

    public boolean isDup() {
        return this.dup;
    }

    public void setMqttMessageType(MqttMessageType mqttMessageType) {
        this.mqttMessageType = mqttMessageType;
    }

    public void setRetain(boolean retain) {
        this.retain = retain;
    }

    public void setQos(QoS qos) {
        this.qos = qos;
    }

    public void setDup(boolean dup) {
        this.dup = dup;
    }

    /**
     * encode
     * <p>
     * MsgType (消息类型:4 bits)
     * DUP (重传标记:1 bits)
     * QoS (质量等级:2 bits)
     * RETAIN (保留位:1 bits)
     *
     * @return
     */
    public byte encode() {
        byte b = 0;
        b = (byte) (this.mqttMessageType.getValue() << 4);
        b |= this.retain ? 1 : 0;
        b |= this.qos.getValue() << 1;
        b |= this.dup ? 8 : 0;

        return b;
    }

    @Override
    public String toString() {
        return "MqttMessageHeader{" + "mqttMessageType=" + this.mqttMessageType + ", retain=" + this.retain + ", qos=" + this.qos + ", dup=" + this.dup + '}';
    }
}

3.2 消息基类(MqttMessage)

package cn.bossfriday.im.protocol.core;

import cn.bossfriday.im.protocol.enums.MqttMessageType;
import cn.bossfriday.im.protocol.enums.QoS;

import java.io.*;

/**
 * Message
 *
 * @author chenx
 */
public abstract class MqttMessage {

    private final MqttMessageHeader header;
    private byte headerCode;
    private int lengthSize = 0;

    protected MqttMessage(MqttMessageType mqttMessageType) {
        this.header = new MqttMessageHeader(mqttMessageType, false, QoS.AT_MOST_ONCE, false);
    }

    protected MqttMessage(MqttMessageHeader header) {
        this.header = header;
    }

    /**
     * getMessageLength
     *
     * @return
     */
    protected abstract int getMessageLength();

    /**
     * writeMessage
     *
     * @param out
     * @throws IOException
     */
    protected abstract void writeMessage(OutputStream out) throws IOException;

    /**
     * readMessage
     *
     * @param in
     * @param msgLength
     * @throws IOException
     */
    protected abstract void readMessage(InputStream in, int msgLength) throws IOException;

    /**
     * read
     *
     * @param in
     * @throws IOException
     */
    public final void read(InputStream in) throws IOException {
        int msgLength = this.readMsgLength(in);
        this.readMessage(in, msgLength);
    }

    /**
     * write
     *
     * @param out
     * @throws IOException
     */
    public final void write(OutputStream out) throws IOException {
        this.headerCode = this.header.encode();
        out.write(this.headerCode);
        this.writeMsgCode(out);
        this.writeMsgLength(out);
        this.writeMessage(out);
    }

    /**
     * toBytes
     *
     * @return
     */
    public final byte[] toBytes() {
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        try {
            this.write(byteArrayOutputStream);
        } catch (IOException e) {
            throw new MqttException("Message.toBytes() error!");
        }

        return byteArrayOutputStream.toByteArray();
    }

    /**
     * toUtfBytes
     *
     * @param s
     * @return
     */
    public final byte[] toUtfBytes(String s) {
        if (s == null) {
            return new byte[0];
        }

        try (ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
             DataOutputStream dos = new DataOutputStream(byteOut)) {
            dos.writeUTF(s);
            dos.flush();

            return byteOut.toByteArray();
        } catch (IOException e) {
            throw new MqttException("MessageObfuscator.writeString() error!");
        }
    }

    /**
     * 消息长度为变长Int(为了省那么点字节)
     */
    public final int getLengthSize() {
        return this.lengthSize;
    }

    public void setRetained(boolean retain) {
        this.header.setRetain(retain);
    }

    public boolean isRetained() {
        return this.header.isRetained();
    }

    public void setQos(QoS qos) {
        this.header.setQos(qos);
    }

    public QoS getQos() {
        return this.header.getQos();
    }

    public void setDup(boolean dup) {
        this.header.setDup(dup);
    }

    public boolean isDup() {
        return this.header.isDup();
    }

    public MqttMessageType getType() {
        return this.header.getMqttMessageType();
    }

    /**
     * readMsgLength
     */
    private int readMsgLength(InputStream in) throws IOException {
        int msgLength = 0;
        int multiplier = 1;
        int digit;
        do {
            digit = in.read();
            msgLength += (digit & 0x7f) * multiplier;
            multiplier *= 128;
        } while ((digit & 0x80) > 0);

        return msgLength;
    }

    /**
     * writeMsgLength
     */
    private void writeMsgLength(OutputStream out) throws IOException {
        int val = this.getMessageLength();

        do {
            this.lengthSize++;
            byte b = (byte) (val & 0x7F);
            val >>= 7;
            if (val > 0) {
                b |= 0x80;
            }

            out.write(b);
        } while (val > 0);
    }

    /**
     * writeMsgCode
     */
    private void writeMsgCode(OutputStream out) throws IOException {
        int val = this.getMessageLength();
        int code = this.headerCode;

        do {
            byte b = (byte) (val & 0x7F);
            val >>= 7;
            if (val > 0) {
                b |= 0x80;
            }
            code = code ^ b;
        } while (val > 0);

        out.write(code);
    }
}

3.3 可重试消息基类(RetryableMqttMessage)

package cn.bossfriday.im.protocol.core;

import cn.bossfriday.im.protocol.enums.MqttMessageType;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import static cn.bossfriday.im.protocol.core.MqttConstant.FIX_HEADER_LENGTH;

/**
 * RetryableMqttMessage
 *
 * @author chenx
 */
public abstract class RetryableMqttMessage extends MqttMessage {

    /**
     * 在MQTT协议栈中,消息序号(messageSequence)通常用于标识消息的顺序和唯一性。这里的视线中消息序号是一个2字节的字段(最大可以表达无符号整型65535),用于发布(publish)消息和发布确认(publishAck)消息的对齐。
     * 具体而言,消息序号在协议栈中的用途包括:
     * 1、消息排序和唯一性:每个消息都有一个唯一的序号,用于在通信中标识消息的顺序和确保消息的唯一性。这在处理分片消息或者确保消息到达的顺序十分重要。
     * 2、重传机制:当消息需要重传时,消息序号可以用于标识需要重传的消息。例如,在发布消息(publish)时,如果没有收到确认,发送方可能会重新发送该消息,而消息序号可以确保接收方能够识别重传的消息。
     * 3、质量等级为1和2的消息传递:在MQTT中,质量等级(QoS)为1或2的消息需要确认。消息序号可以用于匹配发布消息和发布确认消息,从而实现消息的可靠传输。
     * 消息序号在MQTT协议栈中扮演了重要的角色,用于确保消息的顺序性、唯一性和可靠传输。
     */
    private int messageSequence;

    protected RetryableMqttMessage(MqttMessageHeader header) {
        super(header);
    }

    protected RetryableMqttMessage(MqttMessageType mqttMessageType) {
        super(mqttMessageType);
    }

    @Override
    protected int getMessageLength() {
        return FIX_HEADER_LENGTH;
    }

    @Override
    protected void writeMessage(OutputStream out) throws IOException {
        int id = this.getMessageSequence();
        int lsb = id & 0xFF;
        int msb = (id & 0xFF00) >> 8;
        out.write(msb);
        out.write(lsb);
    }

    @Override
    protected void readMessage(InputStream in, int msgLength) throws IOException {
        int msgId = in.read() * 0x100 + in.read();
        this.setMessageSequence(msgId);
    }

    public void setMessageSequence(int messageSequence) {
        this.messageSequence = messageSequence;
    }

    public int getMessageSequence() {
        return this.messageSequence;
    }
}

3.4 可重试消息实现示例(PublishMessage)

package cn.bossfriday.im.protocol.message;

import cn.bossfriday.im.protocol.core.MqttMessageHeader;
import cn.bossfriday.im.protocol.core.RetryableMqttMessage;
import cn.bossfriday.im.protocol.enums.MqttMessageType;

import java.io.*;

import static cn.bossfriday.im.protocol.core.MqttConstant.FIX_HEADER_LENGTH;

/**
 * PublishMessage
 *
 * @author chenx
 */
public class PublishMessage extends RetryableMqttMessage {

    private String topic;
    private byte[] data;
    private String targetId;
    private long signature;
    private int date;
    private boolean isServer;

    public PublishMessage(String topic, byte[] data, String targetId, boolean isServer) {
        super(MqttMessageType.PUBLISH);
        this.topic = topic;
        this.targetId = targetId;
        this.data = data;
        this.signature = 0xffL;
        this.isServer = isServer;
    }

    public PublishMessage(MqttMessageHeader header, boolean isServer) {
        super(header);
        this.isServer = isServer;
    }

    @Override
    protected int getMessageLength() {
        int length = FIX_HEADER_LENGTH + Long.BYTES;
        if (this.isServer) {
            length += Integer.BYTES;
        }

        length += this.toUtfBytes(this.topic).length;
        length += this.toUtfBytes(this.targetId).length;
        length += this.data.length;

        return length;
    }

    @Override
    protected void writeMessage(OutputStream out) throws IOException {
        DataOutputStream dos = new DataOutputStream(out);
        dos.writeLong(this.signature);

        if (this.isServer) {
            this.date = (int) (System.currentTimeMillis() / 1000);
            dos.writeInt(this.date);
        }

        dos.writeUTF(this.topic);
        dos.writeUTF(this.targetId);
        dos.flush();
        super.writeMessage(out);
        dos.write(this.data);
        dos.flush();
    }

    @Override
    protected void readMessage(InputStream in, int msgLength) throws IOException {
        DataInputStream dis = new DataInputStream(in);
        int pos = 0;

        this.signature = dis.readLong();
        pos += 8;

        this.date = dis.readInt();
        pos += 4;

        this.topic = dis.readUTF();
        pos += this.toUtfBytes(this.topic).length;

        this.targetId = dis.readUTF();
        pos += this.toUtfBytes(this.targetId).length;

        super.readMessage(in, msgLength);
        pos += 2;

        this.data = new byte[msgLength - pos];
        dis.read(this.data);
    }

    public String getTopic() {
        return this.topic;
    }

    public byte[] getData() {
        return this.data;
    }

    public String getTargetId() {
        return this.targetId;
    }

    public int getDate() {
        return this.date;
    }
}

3.5 Netty自定义消息编码器(MessageEncoder)

package cn.bossfriday.im.protocol.codec;

import cn.bossfriday.im.protocol.core.MqttException;
import cn.bossfriday.im.protocol.core.MqttMessage;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;

import java.util.Objects;

/**
 * MessageEncoder
 *
 * @author chenx
 */
public class MessageEncoder extends MessageToByteEncoder<MqttMessage> {

    @Override
    protected void encode(ChannelHandlerContext ctx, MqttMessage msg, ByteBuf out) throws Exception {
        this.encode(msg, out);
    }

    /**
     * encode
     *
     * @param msg
     * @param out
     */
    public void encode(MqttMessage msg, ByteBuf out) {
        if (Objects.isNull(msg)) {
            throw new MqttException("msg is null!");
        }

        if (Objects.isNull(out)) {
            throw new MqttException("outByteBuf is null!");
        }

        byte[] data = msg.toBytes();
        data = MessageObfuscator.obfuscateData(data, 2 + msg.getLengthSize());
        out.writeBytes(data);
    }
}

3.5 Netty自定义消息解码器(MessageDecoder)

package cn.bossfriday.im.protocol.codec;

import cn.bossfriday.im.protocol.core.MqttMessage;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

import static cn.bossfriday.im.protocol.core.MqttConstant.FIX_HEADER_LENGTH;
import static cn.bossfriday.im.protocol.core.MqttConstant.MAX_MESSAGE_LENGTH_SIZE;
import static cn.bossfriday.im.protocol.core.MqttException.BAD_MESSAGE_EXCEPTION;
import static cn.bossfriday.im.protocol.core.MqttException.READ_DATA_TIMEOUT_EXCEPTION;

/**
 * MessageDecoder
 *
 * @author chenx
 */
public class MessageDecoder extends ByteToMessageDecoder {

    private final long timeoutMillis;
    private final String userId;
    private final boolean isServer;

    private volatile long lastReadTime;
    private volatile ScheduledFuture<?> timeout;

    private boolean closed;

    public MessageDecoder(long timeoutMillis, String userId, boolean isServer) {
        this.timeoutMillis = timeoutMillis;
        this.userId = userId;
        this.isServer = isServer;
    }

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        Object decoded = this.decode(ctx, in);
        if (decoded != null) {
            out.add(decoded);
        }
    }

    /**
     * readTimedOut
     *
     * @param ctx
     */
    protected void readTimedOut(ChannelHandlerContext ctx) {
        if (!this.closed) {
            this.timeout.cancel(false);
            this.timeout = null;
            this.closed = true;
            ctx.fireExceptionCaught(READ_DATA_TIMEOUT_EXCEPTION);
            ctx.close();
        }
    }

    /**
     * decode
     *
     * @param ctx
     * @param buf
     * @return
     * @throws IOException
     */
    public Object decode(ChannelHandlerContext ctx, ByteBuf buf) throws IOException {
        if (buf.readableBytes() == 0) {
            return null;
        }

        if (buf.readableBytes() < 3) {
            this.resumeTimer(ctx);
            return null;
        }

        buf.markReaderIndex();
        // read away header
        int first = buf.readByte();
        int second = buf.readByte();
        int digit;
        int code = first;
        int msgLength = 0;
        int multiplier = 1;
        int lengthSize = 0;
        do {
            lengthSize++;
            digit = buf.readByte();
            code = code ^ digit;
            msgLength += (digit & 0x7f) * multiplier;
            multiplier *= 128;
            if ((digit & 0x80) > 0 && !buf.isReadable()) {
                this.resumeTimer(ctx);
                buf.resetReaderIndex();
                return null;
            }
        } while ((digit & 0x80) > 0);

        if (code != second) {
            this.close(ctx, buf);
            return null;
        }

        if (lengthSize > MAX_MESSAGE_LENGTH_SIZE) {
            this.close(ctx, buf);
            return null;
        }

        if (buf.readableBytes() < msgLength) {
            this.resumeTimer(ctx);
            buf.resetReaderIndex();
            return null;
        }

        byte[] data = new byte[FIX_HEADER_LENGTH + lengthSize + msgLength];
        buf.resetReaderIndex();
        buf.readBytes(data);
        this.pauseTimer();

        data = MessageObfuscator.obfuscateData(data, FIX_HEADER_LENGTH + lengthSize);
        MqttMessage msg = MessageInputStream.readMessage(new ByteArrayInputStream(data), this.isServer);
        if (msg == null) {
            this.close(ctx, buf);
        }

        return msg;
    }

    /**
     * resumeTimer
     *
     * @param ctx
     */
    private void resumeTimer(ChannelHandlerContext ctx) {
        this.lastReadTime = System.currentTimeMillis();
        if (this.timeoutMillis > 0 && (this.timeout == null || this.timeout.isCancelled()) && !this.closed) {
            this.timeout = ctx.executor().schedule(new ReadTimeoutTask(ctx), this.timeoutMillis, TimeUnit.MILLISECONDS);
        }
    }

    /**
     * pauseTimer
     */
    private void pauseTimer() {
        if (this.timeout != null) {
            this.timeout.cancel(false);
        }
    }

    /**
     * close
     */
    private void close(ChannelHandlerContext ctx, ByteBuf buf) {
        if (this.timeout != null) {
            this.timeout.cancel(false);
        }
        this.timeout = null;
        this.closed = true;
        buf.skipBytes(buf.readableBytes());
        ctx.fireExceptionCaught(BAD_MESSAGE_EXCEPTION);
        ctx.close();
    }

    public String getUserId() {
        return this.userId;
    }

    /**
     * ReadTimeoutTask
     */
    private final class ReadTimeoutTask implements Runnable {

        private final ChannelHandlerContext ctx;

        ReadTimeoutTask(ChannelHandlerContext ctx) {
            this.ctx = ctx;
        }

        @Override
        public void run() {
            if (!this.ctx.channel().isOpen()) {
                return;
            }

            long currentTime = System.currentTimeMillis();
            long nextDelay = MessageDecoder.this.timeoutMillis - (currentTime - MessageDecoder.this.lastReadTime);
            if (nextDelay <= 0) {
                // Read timed out - set a new timeout and notify the callback.
                MessageDecoder.this.timeout = this.ctx.executor().schedule(this, MessageDecoder.this.timeoutMillis, TimeUnit.MILLISECONDS);
                try {
                    MessageDecoder.this.readTimedOut(this.ctx);
                } catch (Throwable t) {
                    this.ctx.fireExceptionCaught(t);
                }
            } else {
                // Read occurred before the timeout - set a new timeout with
                // shorter delay.
                MessageDecoder.this.timeout = this.ctx.executor().schedule(this, nextDelay, TimeUnit.MILLISECONDS);
            }
        }
    }
}

3.7 主要测试代码

package cn.bossfriday.im.protocol.test;

import cn.bossfriday.im.protocol.codec.MessageDecoder;
import cn.bossfriday.im.protocol.codec.MessageEncoder;
import cn.bossfriday.im.protocol.codec.MessageInputStream;
import cn.bossfriday.im.protocol.message.PublishMessage;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

/**
 * PublishMessageTest
 *
 * @author chenx
 */
@RunWith(MockitoJUnitRunner.class)
public class PublishMessageTest {

    @Mock
    private ChannelHandlerContext mockCtx;

    @Before
    public void mockInit() {

    }

    /**
     * 消息读写测试
     */
    @Test
    public void readWriteMessageTest() throws IOException {
        String topic = "topic-1";
        String targetId = "targetId-1";
        byte[] data = "中文abc1234!@#$".getBytes(StandardCharsets.UTF_8);
        boolean isServer = true;
        PublishMessage pubMsg1 = new PublishMessage(topic, data, targetId, isServer);
        pubMsg1.setMessageSequence(123);

        // MqttMessage -> bytes
        byte[] msgData = pubMsg1.toBytes();

        // bytes -> MqttMessage
        PublishMessage pubMsg2 = (PublishMessage) MessageInputStream.readMessage(new ByteArrayInputStream(msgData), isServer);
        System.out.println("topic: " + pubMsg2.getTopic());
        System.out.println("targetId: " + pubMsg2.getTargetId());
        System.out.println("dataString: " + new String(pubMsg2.getData(), StandardCharsets.UTF_8));

        Assert.assertEquals(pubMsg2.getTopic(), topic);
        Assert.assertEquals(pubMsg2.getTargetId(), targetId);
        Assert.assertEquals(new String(pubMsg2.getData(), StandardCharsets.UTF_8), new String(data, StandardCharsets.UTF_8));
    }

    /**
     * 消息编解码器测试
     */
    @Test
    public void messageCodecTest() throws IOException {
        ByteBuf buf = Unpooled.buffer();

        String topic = "topic-1";
        String targetId = "targetId-1";
        byte[] data = "中文abc1234!@#$".getBytes(StandardCharsets.UTF_8);
        boolean isServer = true;
        PublishMessage pubMsg1 = new PublishMessage(topic, data, targetId, isServer);
        pubMsg1.setMessageSequence(123);

        // 编码
        MessageEncoder msgEncoder = new MessageEncoder();
        msgEncoder.encode(pubMsg1, buf);

        // 解码
        MessageDecoder msgDecoder = new MessageDecoder(6000L, targetId, isServer);
        PublishMessage pubMsg2 = (PublishMessage) msgDecoder.decode(this.mockCtx, buf);

        System.out.println("topic: " + pubMsg2.getTopic());
        System.out.println("targetId: " + pubMsg2.getTargetId());
        System.out.println("dataString: " + new String(pubMsg2.getData(), StandardCharsets.UTF_8));
        Assert.assertEquals(pubMsg2.getTopic(), topic);
        Assert.assertEquals(pubMsg2.getTargetId(), targetId);
        Assert.assertEquals(new String(pubMsg2.getData(), StandardCharsets.UTF_8), new String(data, StandardCharsets.UTF_8));
    }
}

单元测试运行结果通过可以基本判定:消息读写 & 消息编解码器 大致没有问题(MD,这篇Blog是三两白酒下肚后的醒酒之作,谁能保障没问题??),后续计划基于此,使用Netty去实现一个接入服务。完整代码参考:https://github.com/bossfriday/bossfriday-nubybear/tree/master/cn.bossfriday.im.protocol
在这里插入图片描述

4. 【手撸IM】AI 生成LOGO欣赏

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 10
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
TIO(Terminal Input/Output)是一个在线的终端仿真器,它可以让用户在Web浏览器中运行命令行程序。要实现IM即时通讯,您需要编写一个支持网络连接的命令行程序,并使用TIO提供的WebSocket API来实现与浏览器的通信。 以下是实现IM即时通讯的一些基本步骤: 1. 设计数据交换协议: 在IM系统中,数据交换协议非常重要。您需要设计一种能够在客户端和服务器之间传输数据的协议,例如JSON格式。该协议应该定义数据类型、字段和操作。 2. 实现服务器端:您需要编写一个服务器程序,它可以监听客户端的连接请求,并使用WebSocket协议与客户端通信。当客户端连接到服务器时,服务器应该将客户端添加到客户端列表中,并通知其他客户端有新用户加入。 3. 实现客户端:您需要编写一个命令行程序,它可以连接到服务器,并使用WebSocket协议与服务器通信。当客户端连接到服务器时,客户端应该将自己的用户名发送给服务器,并接收其他客户端发送的消息。 4. 实现功能:您可以根据需要实现不同的功能,例如发送和接收消息、创建和加入聊天室、查看在线用户列表等。 5. 测试:最后,您需要测试IM系统,确保它能够正常工作。 总之,使用TIO实现IM即时通讯需要一定的编程和网络知识。如果您是初学者,建议您先学习命令行编程和网络编程的基础知识,然后再尝试实现IM系统。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

BossFriday

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值