做过嵌入式网络应用的朋友,大概都经历过这样的过程。
最初的需求很简单:客户端发一个请求,服务端返回结果。于是写一个 TCP Server,再约定一下消息格式。
很快,问题开始变多:
到最后,原本只想交换几条消息,却维护起了一套自己的网络框架。
如果我们不想额外部署消息中间件,又不愿继续手搓协议栈,可以看看 NNG。
NNG 是什么?
NNG 的全称是 nanomsg-next-gen。
仓库:https://github.com/nanomsg/nng/tree/stable 许可证: MIT 许可证 参考手册:https://nng.nanomsg.org/ref
它是一个使用 C 实现的轻量级、无 Broker 消息库,也是 nanomsg Scalability Protocols 的重写和延续。
所谓“无 Broker”,指的是通信节点可以直接建立连接,不需要先部署一个中心消息服务器。
所谓“消息库”,则意味着 NNG 处理的不只是字节流。它向应用提供完整消息,并在协议层处理连接管理、请求匹配、重试和消息路由等问题。
使用者主要做两件事:
- 选择通信模式提供连接地址
例如:
通信模式:REQ/REP
连接地址:tcp://127.0.0.1:8899
前者决定消息怎样流动,后者决定消息从哪里经过。
NNG 使用 MIT 许可证,可以构建为动态库或静态库,也可以裁剪后嵌入现有 C/C++ 项目。
第一个 NNG 程序
NNG 的构建要求不高:
- 支持 C11 的编译器CMake 3.15 或更高版本推荐使用 Ninja
下面使用项目内的安装目录,不需要把文件写入系统的 /usr/local。
第一步:编译 NNG
在项目根目录执行:
sudo apt update
sudo apt install -y build-essential cmake ninja-build
cmake -S . -B build -G Ninja
-DCMAKE_INSTALL_PREFIX="$PWD/install"
cmake --build build
ctest --test-dir build --output-on-failure
cmake --install build
完成后,库文件、头文件和 CMake Package 配置会被安装到当前项目的 install 目录。
第二步:编译 REQ/REP 示例
NNG 的 Demo 使用 find_package(nng) 查找已经安装的库,因此需要把刚才的安装目录传给 CMake:
cmake -S demo/reqrep -B demo/reqrep/build -G Ninja
-DCMAKE_PREFIX_PATH="$PWD/install"
cmake --build demo/reqrep/build
第三步:启动服务端
打开一个终端:
./demo/reqrep/build/reqrep
server tcp://127.0.0.1:8899
第四步:启动客户端
再打开一个终端:
./demo/reqrep/build/reqrep
client tcp://127.0.0.1:8899
可以看到类似输出:
这个 Demo 实现了一个简单的时间查询服务。
客户端发送一个 64 位 DATE 命令,服务端返回 UNIX 时间戳。传输内容使用网络字节序,而不是随意拼接字符串。
代码里还埋了一个小测试:服务端会故意跳过第六次响应,用来观察 REQ 协议的重新发送行为。
常见通信需求做成了协议
NNG 没有试图用一个万能接口覆盖所有场景,而是提供了多组语义明确的协议。
REQ/REP:请求与响应
客户端发出一个请求,服务端返回一个响应。
它适合:
REQ 协议会负责请求与响应的匹配,也可以在没有收到回复时重新发送请求。
PUB/SUB:发布与订阅
发布者发送一条消息,多个订阅者可以同时接收。
它适合:
发布者不需要维护一份订阅者列表,订阅关系由协议处理。
PUSH/PULL:任务管道
PUSH 端生产任务,PULL 端消费任务。
这个模式带有背压能力。当消费者处理不过来时,生产端不会无限制地继续灌入数据。
它适合简单的任务分发和流水线处理。
SURVEYOR/RESPONDENT:一问多答
一个节点向多个节点发起询问,并在限定时间内收集响应。
它适合:
此外,NNG 还提供 BUS 和 PAIR 等通信模式。
协议和传输
这是理解 NNG 最关键的一点。
协议负责定义消息语义:
REQ/REP
PUB/SUB
PUSH/PULL
SURVEYOR/RESPONDENT
BUS
PAIR
传输负责解决节点如何连接:
inproc
IPC
TCP
TLS
WebSocket
UDP
二者可以组合。
一个 REQ/REP 程序可以通过 TCP 跨机器通信,也可以改用 IPC 在本机进程之间通信。业务代码不需要重新实现一套请求匹配逻辑。
地址中的 URL Scheme 用于选择传输:
tcp://127.0.0.1:8899
ipc:///tmp/demo
inproc://demo
ws://127.0.0.1:8080/channel
在 NNG 内部,一个 Socket 对应一种协议。
Dialer 主动连接远端,Listener 等待连接。连接建立后形成 Pipe,消息最终通过 Pipe 在两个节点之间传递。
大多数应用只需要操作 Socket。只有需要读取连接信息、单独配置端点或实现代理时,才需要直接管理 Dialer、Listener 和 Pipe。
从应用代码看,NNG 做了什么?
一个典型的 NNG 2.x 程序会经历以下过程:
REQ 客户端使用 nng_req0_open() 创建 Socket,REP 服务端使用 nng_rep0_open()。
应用不会直接获得一段不完整的 TCP 字节流。NNG 对外提供的是完整消息:一条消息要么完整交付,要么不交付。
不过,这不代表所有协议都自动提供可靠、有序投递。
具体保证取决于协议本身。例如 REQ/REP 具备请求匹配和重试机制,而 PUB/SUB 更接近尽力而为的广播。
选择协议前,必须先弄清楚业务真正需要的语义。
NNG 还能用来做什么?
仓库里的 Demo 不只有 REQ/REP。
发布订阅转发器
demo/pubsub_forwarder 使用 Raw Socket 创建消息转发节点。
发布者连接转发器前端,订阅者连接后端。多个订阅者可以同时接收发布的数据。
项目自带的 nngcat 可以直接创建 PUB、SUB、REQ、REP 等节点,适合:
- 调试消息拓扑手工发送测试数据验证协议行为编写 Shell 集成测试
可以把它理解为 NNG 协议世界里的 netcat。
REST 与消息协议之间的网关
demo/rest 对外提供 HTTP 接口,内部通过 NNG REQ/REP 转发请求。
这种组合适合网关场景:
HTTP 客户端
↓
REST 接口
↓
NNG REQ
↓
内部 REP 服务
外部系统继续使用 HTTP,内部服务则使用消息协议通信。
这也说明 NNG 并不要求整个系统采用同一种通信技术。
进程内与进程间通信
同一个协议可以运行在不同传输上。
模块位于同一个进程时,可以使用 inproc;拆成不同进程后,可以改成 IPC;需要跨机器时,再切换到 TCP 或 TLS。
只要应用没有依赖特定传输的选项,协议层代码通常不需要跟着重写。
最值得学习
NNG 最值得研究的地方,并不是它实现了多少种消息模式,而是它如何控制一个异步 C 网络库的复杂度。
协议与传输正交组合
NNG 没有在 TCP 模块里写死 RPC,也没有为每种协议重新实现一遍连接逻辑。
协议和传输通过操作表连接。
增加一种传输时,REQ、PUB、BUS 等协议不需要修改;增加一种协议时,也不用重新实现 TCP、IPC 和 WebSocket。
这才是“可扩展”真正落到代码里的样子。
用一种异步原语贯穿所有层
如果同步、异步、协议和传输各自维护一套生命周期,C 项目很快就会陷入状态机地狱。
NNG 使用 AIO 贯穿应用接口、协议状态机、传输层和平台 I/O。同步操作只是异步操作的一种使用方式。
统一原语减少了重复逻辑,也让取消、超时和完成回调拥有一致的处理方式。
强类型句柄减少误用
nng_socket、nng_pipe、nng_listener 和 nng_dialer 并不是可以随意混用的裸整数,而是不同的结构体类型。
这样一来,把 Pipe 句柄传给 Socket API 之类的错误,有机会在编译阶段被发现。
对于 C API,这是一种成本很低、收益很实际的安全设计。
可裁剪构建
NNG 的协议和传输拥有独立的 CMake 开关。
资源受限的设备可以只保留需要的能力。例如只启用 REQ/REP、inproc 和 TCP,而不是把所有协议和传输都编进固件。
项目还支持静态库和动态库,这让它比较适合嵌入式 Linux、边缘设备和现有 C/C++ 服务。
哪些项目适合使用 NNG?
总结
两个程序交换消息,通常有两个极端方案。
一个是直接使用 TCP,自己维护消息边界、重连、超时和状态机。
另一个是部署完整的消息中间件,再处理服务器、监控和运维。
NNG 位于两者之间。
它保留了 C 库可嵌入、可裁剪的特点,又把 RPC、发布订阅、任务管道、自动重连和异步并发整理成了可复用的协议组件。
142