小小 MQTT 拿下

avatar
@古茗科技

前言

MQTT 全称为 Message Queuing Telemetry Transport,中文名称为【消息队列遥测传输】,是一种轻量级的发布/订阅消息传递协议,广泛用于物联网(IoT)和其他需要可靠消息传输的场景,比如下面的一些场景: 设备之间的通信

人体传感器 -> MQTT Broker -> 订阅者 (如智能灯泡)

实时消息传递

  用户A发消息 -> MQTT Broker -> 用户B接收消息

交通管理

  车辆A发布刹车警告 -> MQTT Broker -> 车辆B、C订阅接收警告

MQTT 是一个应用层的协议,学习和了解这个协议,有助于我们学习和了解如何设计一个协议,也可以更好的帮助我们去了解同样基于 TCP 流的 HTTP、WebSocket 等协议是如何设计。

在本文中,我们会先从流的概念入手,然后开始介绍 MQTT 协议的整体流程,然后针对不同的流程进行介绍,每一节结尾都有本节可以执行的 TS 代码,可以放到本地进行执行和调节,详细的注释也在代码中。

什么是流

流是一种数据处理的机制。能够在数据可用时逐块处理数据的机制,而不是一次性将所有数据加载到内存中。流可以用来读取、写入、转换、过滤以及组合数据。这样讲述有点抽象,举个例子: 如果要在两个水池子之间运送水,使用传统的方式,就是 A 池子的水通过桶装一桶,使用九牛二虎之力提到 B 水池,再倒进去。 image.png 在这个例子中,两个池子分别是数据的发送者和接收者,水是数据,桶是装载数据的变量。 代码的示例如下:

const fs = require('fs');

fs.readFile('poolA.txt', 'utf8' , (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  // 这个示例的 data 变量可以理解成水桶,data 的内容理解成水
  // 这个只是用来说明传送两个数据,在实际应用的场景需要判断 data 的大小,并进行切割处理
  fs.writeFile('poolB.txt', data, (err) => {
    if (err) {
      console.error(err);
      return;
    }
  });
});

那如果是用流呢,那么流就是两个水池之间的水管,流的定义就可以这样来理解,水管是一种的处理机制。能够在可用时逐块处理的机制,而不是一次性将所有装载到水池中。水管可以用来流出、流入、转换、过滤以及组合**水。**经过转换,就好理解了很多,转换关系如下:

  数据 ---> 水
  内存 --> 水池
  流  --> 水管

使用数据流之后,如果要在两个水池子之间运送水,只需要在两个池子之间接一根水管,那么 A 池子的水会自动的流到 B 池子之间,而且水管还起到了控制流量的作用。 image.png 在这个例子中,两个池子依然是数据的发送者和接收者,水也依然是数据,变的只是从桶变成了水管。 代码的示例如下:

var fs = require('fs');

var readable = fs.createReadStream('poolA.txt');
var writable = fs.createWriteStream('poolB.txt');

readable.pipe(writable); //将输入流导向输出流,这里的 pipe 就类似于水管

为什么要使用流实现协议

从流的特性考虑

  1. 内存效率:流是通过将大文件或数据分成较小的 "chunk" 并依次处理它们来完成其工作的,每个数据块被单独处理,无需将整个文件或全部数据加载到内存中,极大地减少了内存的压力。

比如在上面的例子中,如果需要在两个池子之间运送水的同时,还需要将水进行净化,如果不使用流,则需要每装一次水,在桶里净化一次,而每 “净化” 一次,需要等待,“净化” 完了再将水倒入 B 池子。而如果使用流,那么只需要在水管中间加一个净化装置,那么水流到 B 水池,自然就是已经 “净化” 干净的水,这也就是现代的净水器的原理。流过管道的水,就是需要处理数据的一小部分。 image.png

  1. 背压控制:背压是一种缓冲机制,当读(接收)数据的速度慢于写(发送/处理)数据的速度时,数据会被积压在缓冲区,并停止写入。
  2. 管道操作:流的强大之处在于可以将流通过管道(Pipe)连接到一起。例如,可以将一个读取流直接连到一个写入流上。

从开源库的使用方面考虑

  1. Node.js 中 http 库使用的也是数据流
  // initiate connection
  if (this.agent) {
    this.agent.addRequest(this, optsWithoutSignal);
  } else {
    // No agent, default to Connection:close.
    this._last = true;
    this.shouldKeepAlive = false;
    let opts = optsWithoutSignal;
    if (opts.path || opts.socketPath) {
      opts = { ...optsWithoutSignal };
      if (opts.socketPath) {
        opts.path = opts.socketPath;
      } else if (opts.path) {
        opts.path = undefined;
      }
    }
    if (typeof opts.createConnection === 'function') {
      const oncreate = once((err, socket) => {
        if (err) {
          process.nextTick(() => this.emit('error', err));
        } else {
          this.onSocket(socket);
        }
      });

      try {
        const newSocket = opts.createConnection(opts, oncreate);
        if (newSocket) {
          oncreate(null, newSocket);
        }
      } catch (err) {
        oncreate(err);
      }
    } else {
      debug('CLIENT use net.createConnection', opts);
      // 使用 net.createConnection 创建连接数据流
      this.onSocket(net.createConnection(opts));
    }
  }
  1. Node-Redis:实现了 Redis 协议并提供高性能的 Node.js 客户端。在建立与 Redis 服务器的连接时,它使用 net.connect(和 net.createConnection 是同一个函数,只是有两个名字而已)
    #createNetSocket(): CreateSocketReturn<net.Socket> {
        return {
            connectEvent: 'connect',
            // 使用 net.connect 创建连接数据流
            socket: net.connect(this.#options as net.NetConnectOpts) // TODO
        };
    }

    #createTlsSocket(): CreateSocketReturn<tls.TLSSocket> {
        return {
            connectEvent: 'secureConnect',
            // 使用 tls.connect 创建连接加密数据流
            socket: tls.connect(this.#options as tls.ConnectionOptions) // TODO
        };
    }
  1. Mysql2:这个库在 Node.js 上提供了 MySQL 协议的实现。在与 MySQL 服务器的连接创建过程中,它采用了 net.connect。
if (!opts.config.stream) {
      if (opts.config.socketPath) {
        this.stream = Net.connect(opts.config.socketPath);
      } else {
        this.stream = Net.connect(
          opts.config.port,
          opts.config.host
        );

        // Optionally enable keep-alive on the socket.
        if (this.config.enableKeepAlive) {
          this.stream.on('connect', () => {
            this.stream.setKeepAlive(true, this.config.keepAliveInitialDelay);
          });
        }

        // Enable TCP_NODELAY flag. This is needed so that the network packets
        // are sent immediately to the server
        this.stream.setNoDelay(true);
      }
      // if stream is a function, treat it as "stream agent / factory"
    } else if (typeof opts.config.stream === 'function')  {
      this.stream = opts.config.stream(opts);
    } else {
      this.stream = opts.config.stream;
    }

MQTT 协议

什么是协议

协议简单来讲就是明文的规则,不只是存在于技术的世界,比如两个熟人见面,会微笑和握手,打招呼,这就是协议。用专业一点的说法来说,协议是规定两个或者多个设备之间如何交流以及交流的规则。这些规则包括数据如何编码和解码,数据如何发送和接收,以及数据如何进行错误处理。比如我们熟悉的 HTTP 协议、TCP 协议、UDP 协议都是属于协议。

什么是 MQTT 协议

MQTT 全称为 Message Queuing Telemetry Transport,中文名称为【消息队列遥测传输他是基于 TCP/IP(也可以基于 WebSocket,在各个小程序端的 MQTT 协议就是基于 WebSocket 的),采用发布/订阅的范式的消息协议。协议的英文文档:英文文档,中文文档:Introduction · MQTT协议中文版,我们将按照文档来进行实现我们的代码。

MQTT 协议的客户端和服务端的整体交互流程如下所示,我们接下来的按照不同的阶段进行代码的实现:

如何进行设计

根据上一节的介绍,我们知道了 MQTT 的各个阶段,也简单了解了流的定义和特性,接下来我们需要进行协议的设计,在协议设计之前,我们先通过一个简单的代码模型,了解如何通过流来自动的处理服务端和客户端两端的数据处理,实现异步非阻塞性地解析数据。

let completeParse = null

const writable = new Writable({
	write(buf, _, done) {
		console.log('writable stream :: 解析 buffer, buffer 内容: ', buf)
		// 模拟数据包解析过程
		console.log('parser :: 解析完成的数据:', buf.toString())
		// 缓存到队列中
		packets.push(buf.toString())

		// done 表明一帧数据处理完
		completeParse = done
		work()
	},
})

// 模拟将文件内容流式写入到 writable 流中
const readable = fs.createReadStream(path.join(__dirname, '1.txt'), {
	highWaterMark: 1, // 内部缓冲区的大小, 默认为 64 * 1024, 这里设置为 1kb
})
// 通过 pipe 函数将内容流入 writable 可写流中
readable.pipe(writable)

在上面的代码中,我们使用 new Writable 实例化一个可写流。在可写流中,有一个 _write 函数,_write 函数中有一个callback 函数 donedone 表明一帧数据处理完进行回调。这样我们就可以通过这个回调来驱动下一帧数据的处理。

const work = () => {
	const packet = packets.shift()
	console.log('work :: 在队列中获取下一包的数据: ', packet)
	if (packet) {
		// 模拟处理数据包,并在完成时调用 nextTickWork
		console.log('处理数据包:', packet)
		nextTickWork()
	} else {
		console.log('work :: 队列中没有数据')
		const done = completeParse
		completeParse = null
		if (done) done()
	}
}

在这段代码中,我们将回调进行处理,因为我们在前一段代码中已经将解析好的数据写入到 packets 中,那么在 work 函数中,我们只需要将 packets 数据取出来,并进行处理即可,如果 packet 为空,则执行 _write 的回调函数,让可写流写入下一帧数据。通过这个流程,我们接收的数据和处理的数据就有序了,而且整个流程通过可写流进行驱动进行。

const nextTickWork = () => {
	if (packets.length) {
		// 如果还有数据,则在下一个事件循环开始前执行 work,确保在当前函数结束后,下一次事件循环开始前,work 函数会被执行
		// 这样就可以【异步非阻塞性地处理数据】
		process.nextTick(work)
	} else {
		const done = completeParse
		completeParse = null
		console.log('nextTickWork :: done')
		if (done) done()
	}
}

最后通过 nextTickWork,将数组中 packets 的数据处理完,但是执行是放在 process.nextTick 函数中,用来确保在当前函数结束后,下一次事件循环开始前执行 work 函数,这样我们就异步非阻塞地处理了数据。 整个模型的代码如下:

import fs from 'fs'
import path from 'path'
import { Writable } from 'stream'

const packets = []

const nextTickWork = () => {
	if (packets.length) {
		// 如果还有数据,则在下一个事件循环开始前执行 work,确保在当前函数结束后,下一次事件循环开始前,work 函数会被执行
		// 这样就可以【异步非阻塞性地处理数据】
		process.nextTick(work)
	} else {
		const done = completeParse
		completeParse = null
		console.log('nextTickWork :: done')
		if (done) done()
	}
}

const work = () => {
	const packet = packets.shift()
	console.log('work :: 在队列中获取下一包的数据: ', packet)
	if (packet) {
		// 模拟处理数据包,并在完成时调用 nextTickWork
		console.log('处理数据包:', packet)
		nextTickWork()
	} else {
		console.log('work :: 队列中没有数据')
		const done = completeParse
		completeParse = null
		if (done) done()
	}
}

let completeParse = null

const writable = new Writable({
	write(buf, _, done) {
		console.log('writable stream :: 解析 buffer, buffer 内容: ', buf)
		// 模拟数据包解析过程
		console.log('parser :: 解析完成的数据:', buf.toString())
		// 缓存到队列中
		packets.push(buf.toString())

		// done 表明一帧数据处理完
		completeParse = done
		work()
	},
})

// 模拟将文件内容流式写入到 writable 流中
const readable = fs.createReadStream(path.join(__dirname, '1.txt'), {
	highWaterMark: 1, // 内部缓冲区的大小, 默认为 64 * 1024, 这里设置为 1kb
})
// 通过 pipe 函数将内容流入 writable 可写流中
readable.pipe(writable)

连接阶段

在连接的阶段,我们需要解决数据的解析与生成,并通过 net.createConnection(port, host) 发起 TCP 连接,构建数据流,并通过数据流的驱动数据的解析与回应。

比特流数据解析与生成

流在传输时传输的数据单位是 Buffer,也就意味着我们在发送数据时,需要将指令和数据转换成 Buffer,当接收数据时,将接收到的 Buffer 数据转换成我们人能够直接阅读的文本数据。因为今天我们主要是从宏观层面去实现 MQTT 协议,所以底层数据的处理我们 使用一个【开源库】去进行数据的打包与解析。

生成 Buffer 数据

const mqtt = require('mqtt-packet');
// 发送的指令,指令可以参考协议文档:https://mcxiaoke.gitbooks.io/mqtt-cn/content/mqtt/0303-PUBLISH.html
const object = {
  cmd: 'publish',  // 要发送的指令
  retain: false, // 保留指令,为 true 时,服务端要保留数据,为 false 时不保留
  qos: 0, // 标识服务质量,0 最多分发一次,1 至少分发一次,2 只分发一次
  dup: false, // 重发的标识,为 true 表明是之前的报文请求,为 false 表明是新的报文请求
  length: 10, // 数据的长度
  topic: 'test', // 订阅的主题,
  payload: 'test' // 消息的内容
};
const opts = { protocolVersion: 4 }; // 协议的版本,我们今天的实现也是这个版本

console.log(mqtt.generate(object))
// 打印出来的内容如下:

解析 Buffer 数据

const mqtt = require('mqtt-packet');
// 协议版本,协议版本不一样,解析的规则不一样,可以简单类比成 http  1.0 和 1.1,因为版本不一样,功能和解析的规则就不一样
const opts = { protocolVersion: 4 }; 
const parser = mqtt.parser(opts);

// 订阅 packet 事件,当获取到数时候,进行解析
parser.on('packet', packet => {
  console.log(packet)
  // 打印出来的内容如下:
  // {
  //   cmd: 'publish',
  //   retain: false,
  //   qos: 0,
  //   dup: false,
  //   length: 10,
  //   topic: 'test',
  //   payload: <Buffer 74 65 73 74>
  // }
})

发起连接

发起 TCP 连接

我们知道了如何生成 Buffer 数据,也知道了如何解析 Buffer 数据,那么接下来就可以开始写代码了,我们从最源头的发起 TCP 连接开始,通过 Node.js net.createConnection(port, host) 函数开始,创建一个 TCP 连接流,详细代码如下:

  private createConnectionStream = (opts) => {
		// 连接的端口号
		opts.port = opts.port || 1883
		// 主机名
		opts.hostname = opts.hostname || opts.host || 'localhost'

		const { port } = opts
		const host = opts.hostname

		this.log('端口 %d and 地址 %s', port, host)
    // 进行连接
		return net.createConnection(port, host)
	}

构建数据流

发起了 TCP 连接,连接完成之后我们就需要构建我们的数据发送 Buffer 流,我们之前有说过使用 mqtt.generate(object) 来构建 Buffer 流,但是在这里我们使用另外一个函数 mqttPacket.writeToStream,因为这个函数不只是进行了数据解析,还自己处理了将数据写入流中,方便简洁,十分好用

	private _writePacket(packet: Packet, cb?: DoneCallback) {
		// 这个是打印的函数,方便我们进行调试
		this.log('_writePacket :: 要发送的数据包:%O', packet)
		this.log('_writePacket :: 发送数据包')

		this.log('_writePacket :: 发布数据发送 `packetsend`')
		this.emit('packetsend', packet)

		// 将数据解析成 Buffer,并写入到流中
		const result = mqttPacket.writeToStream(
			packet,
			this.stream,
			this.options,
		)

		this.log('_writePacket :: 写入数据流结果: %s', result)
	}

进行连接

前面的准备工作做好之后,我们就可以开始进行连接了,连接之前,我们需要先初始化一个可写流,并将这个可写流与 TCP 的数据流连接起来(即 createConnectionStream),这样当有数据响应时,就会写入到这个流中,具体可以看代码:

	connect() {
		// 根据参数进行数据包解析的订阅
		const parser = mqttPacket.parser(this.options)
		// 连接流
		this.stream = this.createConnectionStream(this.options)
		// 解析回调
		let completeParse = null

		// 这里是数据从可写流中解析完毕之后执行
		const work = () => {
			const packet = packets.shift()
			this.log('work :: 在队列中获取下一包的数据: ', packet)
			if (packet) {
				this.log('work :: 从队列中获取到一包数据')
				this._handlePacket(packet)
			} else {
				this.log('work :: 队列中没有数据')
			}
		}

		// 实例化一个可写流
		const writable = new Writable()

		// 这里需要解释一下,这里是监听可写流的数据的写入,就是当有数据回传回来的时候,会触发这里的回调,并进行数据的解析
		// 这里还有一个关键是 _write 的参数 done,表示的是解析完毕之后的回调
		writable._write = (buf, _, done) => {
			this.log('writable stream :: 解析 buffer, buffer 内容: ', buf)
			// 在这里进行解析,调用这个函数之后,解析完会触发 parser.on('packet'),得到的就是解析完的数据
			parser.parse(buf)
			// 这里很关键,表示的是解析完毕之后的回调
			completeParse = done
			// 执行指令处理
			work()
		}

		// 将可写流和解析器进行绑定
		this.stream.pipe(writable)

		// 将接收并且解析到的数据包推送到数组中
		const packets = []

		parser.on('packet', (packet) => {
			this.log('parser :: 将数据 push 到数组中.数据内容:%s', packet)
			packets.push(packet)
		})

		// 连接指令
		const connectPacket: IConnectPacket = {
			cmd: 'connect',
			protocolId: this.options.protocolId,
			protocolVersion: this.options.protocolVersion,
			clean: this.options.clean,
			clientId: this.options.clientId,
			keepalive: this.options.keepalive,
		}

		// 发送数据
		this._writePacket(connectPacket)
	}

上面的 connect 函数中,有特别关键的一段代码:

		// 这里是数据从可写流中解析完毕之后执行
		const work = () => {
			const packet = packets.shift()
			this.log('work :: 在队列中获取下一包的数据: ', packet)
			if (packet) {
				this.log('work :: 从队列中获取到一包数据')
				this._handlePacket(packet)
			} else {
				this.log('work :: 队列中没有数据')
			}
		}

		// 实例化一个可写流
		const writable = new Writable()

		// 这里需要解释一下,这里是监听可写流的数据的写入,就是当有数据回传回来的时候,会触发这里的回调,并进行数据的解析
		// 这里还有一个关键是 _write 的参数 done,表示的是解析完毕之后的回调
		writable._write = (buf, _, done) => {
			this.log('writable stream :: 解析 buffer, buffer 内容: ', buf)
			// 在这里进行解析,调用这个函数之后,解析完会触发 parser.on('packet'),得到的就是解析完的数据
			parser.parse(buf)
			// 这里很关键,表示的是解析完毕之后的回调
			completeParse = done
			// 执行指令处理
			work()
		}

这里着重阐述一下,这段代码是整个 MQTT 数据流回传处理的核心。 当可写流得到数据时,进行解析,调用 parser.parse,解析完成之后将 _write 的回调函数 done 赋值给 completeParse 变量,然后调用 work 进行处理,在 work 函数中,如果队列(用数组表示)获取到一包数据,则对数据进行处理,_handlePacket 函数将会是客户端所有处理函数的集合。在这一节中,我们只是发布一下 connect 事件,提供给外界调用,让使用者来确定,进行下一个流程的处理。

	private _handlePacket(packet) {
		// 取出一条命令
		const { cmd } = packet
		this.log('_handlePacket :: 开始处理命令: %s', cmd)

		switch (cmd) {
			case 'connack':
				// 发布 connect 事件,提供外界调用,外界可以使用:client.on('connect', () => {}) 来监听 connect 事件
				this.emit('connect', packet)
				break
			default:
				this.log('_handlePacket :: 未知命令: %s', cmd)
				this._noop()
		}
	}

本节代码

github.com/showCode327…

主题订阅阶段

在主题订阅阶段,我们会接着上期的 “连接”处理,进行主题订阅请求的发起,并针对主题的响应进行处理

什么是主题

在上述的连接阶段,我们客户端发起了连接,并且在可写流中读取到了响应的数据,接下来我们就要进行主题的订阅,主题订阅可以简单理解我们在日常生活中的沟通交流,需要目前需要沟通什么,就比如我们现在沟通“技术”,“政治”,“生活”,这其中的“技术”、“政治”、“生活”三个就是主题,我们相互之间的沟通就在这个主题下进行的。

如何进行主题订阅

在上节中,我们进行连接之后,通过 _handlePacket 对客户端所有的响应进行处理,上一节的 _handlePacket代码是这样的:

	private _handlePacket(packet) {
		// 取出一条命令
		const { cmd } = packet
		this.log('_handlePacket :: 开始处理命令: %s', cmd)

		switch (cmd) {
			case 'connack':
				// 触发 connect 事件,提供外界调用,外界可以使用:client.on('connect', () => {}) 来监听 connect 事件
				this.emit('connect', packet)
				break
			default:
				this.log('_handlePacket :: 未知命令: %s', cmd)
				this._noop()
		}
	}

我们在 _handlePacket 中,通过将 this.emit('connect', packet),外部就可以去监听 CONNECT 事件,这就是我们继承了EventEmitter 函数的好处。那么外部监听了 CONNECT 事件之后,就可以通过如下的代码进行函数主题的订阅了:

const client = new Client(options)
const TOPIC = 'presence'

client.on('connect', () => {
	console.log('连接成功啦')
	client.subscribe(TOPIC, (err) => {
		if (!err) {
			console.log(`订阅主题:${TOPIC} 成功`)
		}
	})
})

接下来就来实现 subscribe 函数的代码:

	public subscribe(topic: string, callback?: ClientSubscribeCallback) {
		// 避免为了防止用户不传入参数导致报错
		callback = callback || this.noop

		// 服务质量,为了篇幅和简单起见,我们目前只支持 0
		const defaultOpts: Partial<IClientSubscribeOptions> = {
			qos: 0,
		}

		const subs = [
			{
				topic,
				qos: defaultOpts.qos,
			},
		]

		// 构造订阅指令
		const packet: ISubscribePacket = {
			cmd: 'subscribe',
			subscriptions: subs,
			messageId: this._nextId(),
		}

		// 发送订阅指令
		this._writePacket(packet)
		callback(null, subs)

		return this
	}

subscribe 函数的代码还是比较简单的,为了代码的健壮性,处理一下 callback,接下来构建订阅指令,订阅指令中有一个 messageId,这个是消息 ID,用来标识一次 subscribe 订阅。构建完订阅之后就可以进行指令的发送了。指令发送完毕后进行回调的处理,因为下一个阶段需要回调确认,所以可以将发送的 subs 数据传入回调中。最后 return 当前实例,以方便链式调用。

如何处理订阅响应

发送完订阅,就可以在响应处理函数 _handlePacket 中进行订阅的处理了

	private _handlePacket(packet, done) {
		// 取出一条命令
		const { cmd } = packet
		this.log('_handlePacket :: 开始处理命令: %s', cmd)

		switch (cmd) {
			case 'connack':
				// 发布 connect 事件,提供外界调用,外界可以使用:client.on('connect', () => {}) 来监听 connect 事件
				this.emit('connect', packet)
				done()
				break
			case 'suback':
				if (!packet.granted) {
					this.log('suback :: 订阅失败')
					this.emit('error', packet)
				} else {
					this.log('suback :: 订阅成功')
				}
				done()
				break
			default:
				// eslint-disable-next-line no-case-declarations
				const msg = `_handlePacket :: 未知命令: ${cmd}`
				this.log(msg)
				this.emit('error', msg)
				done()
		}
	}

在订阅主题的处理中,需要判断下回传的数据是否有 granted,检查是否授权。

本节代码

github.com/showCode327…

消息发送阶段

主题订阅之后,我们可以根据主题进行消息的发送,消息的发送可以是客户端发给服务端,也可以服务端发送到客户端。

什么是消息

消息指的是从客户端向服务端或者服务端向客户端传输一个应用消息,用来客户端和服务端两端之间进行通信。消息是基于主题的,所以我们在进行消息发送时,需要将传递消息的主题带上。

如何实现消息发送

因为消息需要基于主题,所以需要在主题订阅成功之后,我们就可以进行消息的发送。

const client = new Client(options)
const TOPIC = 'presence'

client.on('connect', () => {
	console.log('连接成功啦')
	client.subscribe(TOPIC, (err) => {
		if (!err) {
			console.log(`订阅主题:${TOPIC} 成功`)
			client.publish('presence', 'Hello mqtt')
		}
	})
})

接下来我们就来实现 publish 的代码

	public publish(topic: string, message: string, callback?: PacketCallback) {
		this.log('publish ::   向主题 `%s` 发送消息 `%s`', topic, message)

		const packet: IPublishPacket = {
			cmd: 'publish',
			topic, // 订阅的主题
			payload: message, // 要发送的消息
			qos: defaultQOS, // 服务质量,默认为 0
			dup: false, // 重发标识,默认为 false
			retain: false, // 保留标识,默认为 false
		}

		// 发送消息
		this._writePacket(packet)

		return this
	}

发送的代码也比较简单,主要也就是构造消息发送的指令,有几个重要的参数含义如下: qos: 代表 "Quality of Service",即服务质量,用来保证消息能够被成功的传递。在 MQTT 协议中,qos 有三种等级,如下: qos 值的含义:

  • 0,表示最多分发一次(这是最低的 qos 等级,协议将只尝试分发消息一次。 如果连接不稳定,可能导致消息丢失)
  • 1,表示至少分发一次(利用这种等级,MQTT协议保证消息至少分发一次给接收者。但是在有些情况下,可能会导致消息的重复)
  • 2,表示表示只分发一次(这是最高的等级。MQTT 协议将保证消息只分发一次给接收者。这个过程涉及到4个步骤的握手,所以会有更多的开销)
  • -,表示保留位(为了以后的协议版本保留的内容)

dup: 重发标识,默认为 false,表示第一次发送的消息,如果为 true ,就需要服务端重新发送消息。 retain: 保留标志,默认为 false,表示如果有新的主题订阅客户端,不给他发送消息,如果为 true ,则需要将历史的消息发给新订阅的客户端。

如何处理消息响应

消息响应只需要获取响应的数据,并发布 message 事件就可以了

	private _handlePacket(packet, done) {
		// 取出一条命令
		const { cmd } = packet
		this.log('_handlePacket :: 开始处理命令: %s', cmd)

		switch (cmd) {
			case 'connack':
				// 发布 connect 事件,提供外界调用,外界可以使用:client.on('connect', () => {}) 来监听 connect 事件
				this.emit('connect', packet)
				done()
				break
			case 'suback':
				if (!packet.granted) {
					this.log('suback :: 订阅失败')
					this.emit('error', packet)
				} else {
					this.log('suback :: 订阅成功')
				}
				done()
				break
			case 'publish':
				// eslint-disable-next-line no-case-declarations
				const topic = packet.topic.toString()
				// eslint-disable-next-line no-case-declarations
				const message = packet.payload
        // 发布 message 事件,提供外界调用
				client.emit('message', topic, message as Buffer, packet)
				done()
				break
			default:
				// eslint-disable-next-line no-case-declarations
				const msg = `_handlePacket :: 未知命令: ${cmd}`
				this.log(msg)
				this.emit('error', msg)
				done()
		}
	}

本节代码

github.com/showCode327…

心跳阶段

心跳是客户端和服务端保活的一种手段,在有些业务系统中,也会提供一个 health 接口让客户端轮训请求服务端来检查系统是否正常,也是一种心跳的手段

什么是心跳

心跳是客户端和服务器之间的定时信号,用于在没有实际通信时维护活动连接,检查连接是不是正常,是不是在线。就像是朋友之间,都需要经常沟通沟通、交流交流,时刻检查双方友谊常在。

如何实现心跳

心跳说白了就是启一个定时器,设置一个标志位,时间到了之后就发送一个消息给到服务端,服务端响应一个消息,确认服务是正常的。服务如果是正常的,修改标志位,重新计时,循环往复。


	// 做心跳检查
	private _checkPing() {
		this.log('_checkPing :: 检查心跳中 ...')
		if (this.pingResp) {
			this.log(
				'_checkPing :: 心跳已经收到 `pingresp` 指令,可以发送 `pingreq`',
			)
			this.pingResp = false
			this._writePacket({ cmd: 'pingreq' })
		} else {
			this.emit('error', new Error('Keepalive timeout'))
			this.pingTimer = null
		}
	}

	// 清空定时器,走下一个周期
	private _cleanPingTimer = () => {
		clearTimeout(this.pingTimer)
		this.pingTimer = null
		this._reschedule()
	}

	private _reschedule() {
		clearTimeout(this.pingTimer)
		// 设置定时器,检查心跳
		this.pingTimer = setTimeout(() => {
			// 启动检查
			this._checkPing()
			if (this.pingTimer) {
				// 准备下一次检查
				this._cleanPingTimer()
			}
		}, this.options.keepalive * 1000)
	}

	// 设置定时器和心跳检查
	private _setupPingTimer() {
		this.log(
			'_setupPingTimer :: 保活设置 %d (seconds)',
			this.options.keepalive,
		)
		if (!this.pingTimer && this.options.keepalive) {
			// 默认设置标识为 true
			this.pingResp = true
			this._reschedule()
		}
	}
}

首先在连接成功时,调用 _setupPingTimer 函数,设置定时器和心跳检查,并设置 pingResptrue,定时器到达设置的阈值之后,会调用 _checkPing 进行心跳检查,检查完成之后重新计时下一个周期。如果下一个周期发现 pingResp 还没被调整,则说明上一个心跳已经超时了,则进行报错处理。

如何处理心跳的响应

在响应处理中,只需要重置全局的 pingResp 标志位即可,因为在 _checkPing 函数中,会去检查这个标志位,如果这个标志位没有被重置,则说明上一次心跳已经超时,进行错误处理。代码如下:

	private _handlePacket(packet, done) {
		// 取出一条命令
		const { cmd } = packet
		this.log('_handlePacket :: 开始处理命令: %s', cmd)

		switch (cmd) {
			case 'connack':
				// 发布 connect 事件,提供外界调用,外界可以使用:client.on('connect', () => {}) 来监听 connect 事件
				this.emit('connect', packet)
				// 启动心跳
				this._setupPingTimer()
				done()
				break
			case 'suback':
				if (!packet.granted) {
					this.log('suback :: 订阅失败')
					this.emit('error', packet)
				} else {
					this.log('suback :: 订阅成功')
				}
				done()
				break
			case 'publish':
				// eslint-disable-next-line no-case-declarations
				const topic = packet.topic.toString()
				// eslint-disable-next-line no-case-declarations
				const message = packet.payload
				// 发布 message 事件,提供外界调用,外界可以使用:client.on('message', (topic, message) => {}) 来监听 message 事件
				this.emit('message', topic, message as Buffer, packet)
				done()
				break
			case 'pingresp':
				this.pingResp = true
				this.log('pingresp :: 收到 PINGRESP 指令')
				done()
				break
			default:
				// eslint-disable-next-line no-case-declarations
				const msg = `_handlePacket :: 未知命令: ${cmd}`
				this.log(msg)
				this.emit('error', msg)
				done()
		}
	}

本节代码

github.com/showCode327…

断开连接阶段

断开连接是 MQTT 协议的最后阶段,客户端发起断开连接指令之后,服务端响应断开,切开整个连接

什么是断开

断开由客户端发起,主动告知服务端最要进行连接的断开,并在收到服务端的断开响应之后,进行善后处理。

如何实现断开

断开的处理相对来说简单,主要就是关闭心跳的定时器,构造断开指令,并将指令发送到服务端

	end() {
		this.log('end :: 关闭连接')

		// 关闭心跳定时器
		if (this.pingTimer) {
			clearTimeout(this.pingTimer)
			this.pingTimer = null
		}

		// 构造断开指令
		const packet: IDisconnectPacket = { cmd: 'disconnect' }
		// 发送指令
		this._writePacket(packet)

		return this
	}

如何实现断开善后

断开的善后在收到服务端明确的 disconnect 指令,触发流的 close 事件,移除 close 事件,对流进行销毁,最终发布 disconnect 事件,代码如下:

	private _handlePacket(packet, done) {
		// 取出一条命令
		const { cmd } = packet
		this.log('_handlePacket :: 开始处理命令: %s', cmd)

		switch (cmd) {
			case 'connack':
				// 发布 connect 事件,提供外界调用,外界可以使用:client.on('connect', () => {}) 来监听 connect 事件
				this.emit('connect', packet)
				// 启动心跳
				this._setupPingTimer()
				done()
				break
			case 'suback':
				if (!packet.granted) {
					this.log('suback :: 订阅失败')
					this.emit('error', packet)
				} else {
					this.log('suback :: 订阅成功')
				}
				done()
				break
			case 'publish':
				// eslint-disable-next-line no-case-declarations
				const topic = packet.topic.toString()
				// eslint-disable-next-line no-case-declarations
				const message = packet.payload
				// 发布 message 事件,提供外界调用,外界可以使用:client.on('message', (topic, message) => {}) 来监听 message 事件
				this.emit('message', topic, message as Buffer, packet)
				done()
				break
			case 'pingresp':
				this.pingResp = true
				this.log('pingresp :: 收到 PINGRESP 指令')
				done()
				break
			case 'disconnect':
				this.stream.on('close', done)
				this.stream.removeListener('close', done)
				this.stream.destroy()
				client.emit('disconnect', packet)
				done()
				break
			default:
				// eslint-disable-next-line no-case-declarations
				const msg = `_handlePacket :: 未知命令: ${cmd}`
				this.log(msg)
				this.emit('error', msg)
				done()
		}
	}

本节代码

github.com/showCode327…

总结

本文通过继承 EventEmitter 并基于 net.createConnection 实现了一个简单的 MQTT 协议,虽然代码只有四百多行,但是麻雀虽小,五脏俱全。通过这个代码,我们了解了流的处理机制,并通过流来驱动对数据的解析和处理。

通过继承 EventEmitter,我们可以很方便的通过 emit 去发布一个事件,在外部实例中,也可以非常方便的使用 xxx.on('xxx') 进行事件的订阅。

通过学习和了解本文,我们可以尝试去了解更多的协议源码,从宏观的层面来讲,实现的思路差异不是特别大,可以参考这些的实现: GitHub - websockets/ws: Simple to use, blazing fast and thoroughly tested WebSocket client and server for Node.js node/lib/_http_client.js at main · nodejs/node

虽然我们实现了一个 MQTT 协议,但是为了让我们主要抓住协议的核心,我们只实现了协议的核心部分,我们的协议还有下面的部分未实现:

  1. 底层多协议的未适配:小程序平台和 Web 平台未实现(基于 WebSocket)
  2. 本文中的协议只实现 MQTT 4.0,其他版本(3.0、5.0)未实现
  3. 本文中的协议未实现 QoS 1 和 2 的情况
  4. 本文中的协议未实现重连
  5. 本文中的协议未实现消息的缓存
  6. 本文中的协议未实现加密通信和用户名和密码访问