作者:张义飞
背景
古茗门店的后厨放着有很多设备,功能不一样,类型不一样,而且通信机制也不一样。
像最基本的前端和服务端通信,我们需要定义各种接口去实现我们的业务功能,然后依赖http服务进行通信。但是当两台设备需要通信时,我们必须以物理方式或无线方式将它们连接起来,接着再去通信。
我们经常使用的通信方式有:串口通信(USB、CAN)、蓝牙通信、TCP通信。虽然通信方式各种各样,但是原理上基本一致。
作为通信中最为复杂,但也是最被人所熟知的网络通信,这里先大概讲解一下网络通信的原理,那么再讲其他通信方式时也就很好理解了。
一个简单的网络
假如你是一个电脑:
这个时候你很想找个人聊聊天,这个时候你发现了电脑B,于是你们都开了一个网口,用一个网线连接了起来,于是你们就愉快地聊了起来。
这个时候又来了一位C同学,他也想加入到你们的聊天中去,于是你们又开了一个网口,相互连接起来。
随着加入的同学越来越多,你发现需要开的口越来越多了。
随着加入聊天的人越来越多,你身上被开了n个端口,于是你开始思考,可不可以找个中间人,去帮你们进行传话,这样你们就不用彼此相连了。
这个中间人就叫做集线器。
这个时候你再想和同学B说话,你只需要把消息发给中间的设备就行,这个设备会将信息转发到所有设备。
那同学B如何知道是别人在和自己说话呢?于是你们就做了个约定,这个约定就是我们在交流时带上自己的名字,对方的名字以及要说话的内容。
但是这个中间人不够智能,每次帮你们转话时,都要通知到每个人。
这个时候聪明的你发现,这样很不安全也很浪费资源,那有没有一种机器可以将想说的话直接发送给某个人呢?
这个机器就是交换机。
我们只需要让这个机器维护一个映射表,将mac地址和端口映射起来,这样它就能清楚的知道你要找谁说话了。
mac地址 | 端口 |
---|---|
aa | 1 |
bb | 2 |
cc | 3 |
dd | 4 |
再后来人又变多了,交换机也被插满了,聪明的你发现多找几个传话人,让他们分别负责几个人,这样就轻松地解决了这个问题。
再后来交换机也无法维护这么多mac地址了。于是就有了我们的微型计算机------路由器。
路由器也有自己的mac地址,它会为每个连接它的设备通过DHCP或者手动配置的方式分配一个ip地址,并且记录ip地址和设备mac地址的关系(路由器可以通过ARP或网络拓扑的技术来获取和自己相连交换机上的设备的mac地址)。
到此,你已经有了自己的小圈子了(局域网),但是你想要和别的小圈子进行交流就需要更庞大的网络了。
上面说了很多关于ip地址,mac地址,端口。都是在说一个问题:通信双方如何找到对方。下面我们再来看下,找到对方后,如何让对方彼此明白所传信息的含义。
互联网是如何工作的
HTTP 是互联网的基础协议 ,用于客户端与服务器之间的通信,它规定了客户端和服务器之间的通信格式,包括请求与响应的格式。基本的工作方式如下图所示:
什么是通信协议?
为了能够进行通信,双方(无论是软件、设备、人员等)需要进行一个约定:
- 语法(数据格式和编码)utf-8 gbk
- 语义(控制信息和错误处理)crc
- 语速(速度匹配和排序)mtu
好比两个人交流时,我们首先要确定的是语言,我们是使用中文还是英文进行沟通。
然后就是组成这些句子的含义,如果你用中文来说突然来了一句"打渣子",对方肯定一脸懵逼。所以你们需要商量好用什么规范进行沟通,比如我挥挥手,你就知道要和我再见了。
另一个就是传输量的控制,你如果上来就是8000字作文上来,我还是一脸懵逼,我的脑CPU容量只能一次接收十个字的话,多了我就理解不了了,你可以多次和我说,但是一次不要说太多。
应用层级协议HTTP
HTTP 是一种建立在TCP/IP (一种通信协议)之上的应用程序级协议
从网络上获取信息
HTTP 协议为客户端提供了一些东西来向服务器表达其含义:URI、HTTP 方法和 HTTP 标头。
客户端请求
服务端响应
设计一个简单的通信协议
上面我们说了什么是通信协议,那么一个通信协议需要具备那些细节?
通信协议设计核心
- 解析效率高
- 可扩展
协议设计细节
- 数据帧完整性判断
- 序列化,反序列化
- 协议版本,兼容性
- 协议安全,加密,防攻击
- 数据压缩
消息完整性判断
- 固定大小
以固定大小字节来进行分界,比如每个消息是8个字节,不足8个就进行补0处理
- 以特定符号分界
比如使用\r\n表示一个消息传输完毕
- 固定消息头和消息体
也就是我们说的header + body
- 按照某个时间间隔未收到消息表示完整性
这个是很扯淡的设计,很不推荐。
举个🌰
请求包(部分内容)
序号 | 标识 | 数据类型 | 长度(Byte) | 含义 | 说明 |
---|---|---|---|---|---|
0 | head | uint8_t | 1 | 包头 | 保密🤐 |
1 | option | uint8_t | 1 | 选项 | |
2 | seq | uint32_t | 4 | 包序列号 | |
3 | cmd | uint8_t | 1 | 命令字 | |
4 | device_id | uint64_t | 8 | 设备序列号 | |
5 | head_cs | uint16_t | 2 | 头部 CRC16 | |
6 | data_len | uint16_t | 2 | 数据部分长度 | |
7 | data_cs | uint16_t | 2 | 数据部分CRC16 | |
8(data) | seq_spec | uint64_t | 8 | 数据部分 seq | |
str | char[n] | n - 8 | 数据部分 json-rpc 字符串 |
数据包例子
因为涉及到内部实现,这里不方便给出说明,总之这个就是比较经典的head + body 的设计方式,实现起来比较简单一些,我们主要封装好头部。
body部分还是用json来进行处理,这对业务来说就很简单了,业务层只需要定义好具体请求参数就能得到相应的json信息。
比如: 获取设备信息
请求
json
{
"id" : "123", // 用来区分此次请求id,使用自增id
"jsonrpc":"2.0", // 指定JSON-RPC协议版本的字符串,必须准确写为"2.0"
"method" :"ability.getInfo", // 包含所要调用方法名称的字符串。
"params": null // 调用方法所需要的结构化参数值,该成员参数可以被省略。
}
响应
json
{
"id" :"123",
"code":0,
"result" :{ // 返回数据体
}
}
响应中必须要含有id,这个id表示对客户端那个请求的响应。
至此我们只需要按照JSON-RPC协议进行传输就行了。JSON-RPC是一个无状态且轻量级 的远程过程调用(RPC)协议。
客户端是如何发起一个请求和接收响应的
简化一下上述流程
谈谈RPC的设计与实现
RPC(Remote Procedure Call)
在远程必须先定义这个方法,然后才可以通过RPC框架调用该方法。远程调用不仅可以传参数、获取到返回值,还可以捕捉调用过程中的异常。RPC让远程调用就像本地调用一样。
就像我们本地调用一个函数一样,但是它存在以下几个问题
- 本地函数调用的结果是可预测的,而 RPC 需要经过网络传输,数据在中途可能因为各种原因丢失。
- RPC 调用有可能超时,编写程序时需要考虑该情况。
- 重试一个失败的 RPC 调用有可能造成数据重复,需要考虑幂等。
- 由于传输数据时需要序列化和反序列化,RPC 在传输复杂对象时会不太方便。
客户端向服务端要数据时
ini
const userInfo = rpc.getUserInfo(params);
服务端向客户端要数据时
csharp
server.registerMethod('getUserInfo', (Parameters params) {
return {"name": "bill"};
});
RPC基本模型
对应的代码结构就是
gateway_client: 就是client stub
gateway_server: 就是server stub
gateway_data_util: 就是负责decoding/encoding
socket_base: 就是transport
说到这里总结起来就是基于JSON-RPC为基础的自定义的通信协议,以socket为通信传输基础实现的一套RPC。
异步转同步
基于端口通信最大的问题就是业务处理起来比较麻烦,如果将异步转换成同步调用,那处理业务就很方便了。
dart
socket.on('message', (data) {
if(data.method == 'userInfo') {
//获取到了userInfo
}
print('Message from server: $data');
});
下面这个就好用了
ini
var userInfo = await getUserInfo();
我们只需将每个请求看做是一个future(promise),服务端响应了我们就将结果返回,如果超过某个时间未返回,我们就告诉客户端超时了,如果请求过程出错了,我们就告诉客户端错误信息。
核心实现
- 构造待请求map
scss
Future send({
String? method,
int cmd = GatewayCmd.dataPost,
Map<String, dynamic>? parameters,
}) async {
Completer completer = Completer();
//超时处理
completer.future.timeout(const Duration(seconds: timeoutSeconds),
onTimeout: () {
completer.completeError(RpcException.timeout());
});
int id = GatewayDataUtil().seqIncrement();
_send(deviceId, cmd, method, parameters, id);
_pendingRequests[id] = _Request(method, completer, Chain.current());
return completer.future;
}
- 发送请求
kotlin
class _Request {
final String? method;
final Completer completer;
//也可以在这里进行超时处理
_Request(this.method, this.completer);
}
- 响应给客户端
dart
void onReceiveSocketData(dynamic response) {
int? id;
//解析
Map? jsonMap = response;
if (jsonMap != null) {
id = int.tryParse(jsonMap["id"]);
}
if (id == null) return;
//寻找request
if (_pendingRequests.containsKey(id)) {
// 从map中移除请求
_Request request = _pendingRequests.remove(id)!;
if (jsonMap != null) {
//将数据返回给客户端
request.completer.complete(jsonMap);
} else {
request.completer.complete();
}
} else {
logger.i("未在代请求列表中:$id,请求列表:${_pendingRequests.toString()}");
}
}
}
几个关键点
- 保证每次请求的key值唯一,也就是json-rpc中的参数id, 使用递增id。
- 在soket端口中需要区分出是服务端响应给客户端的数据,还是服务端向客户端请求的数据,协议层区分
举个🌰
上面说到的socket是基于tcp实现的。客户端和服务器之间首先建立TCP连接,然后通过Socket发送和接收数据 tcp在网络模型中属于传输层协议,那如果我们要使用数据链路层这块进行通信的话,应该如何设计?
CAN协议
CAN是一种用于局域网的串行通信协议,最初设计用于汽车电子系统中的通信
看着有没有像我们上面说的集线器,所有设备会连接到can总线上,如果两个设备要通信,就往总线上进行发送,所有设备都会收到,不过这些设备带有过滤器,可以过滤掉不是发给自己的数据。
那如果线上两个人同时说话了不就有冲突了吗,can协议中有个仲裁段,id越小越优先进行传输。
这和我们上面所说的通信协议中的规则一样以固定帧开始,已固定帧结束。标准帧:11位,也就是一个字节多点
扩展帧可以达到29位也就是四个字节不到。然后最大能传输的数据是8个字节,如果要传输的数据超过8个字节就要进行分包了。
我们在某款设备中使用的是扩展帧:
位 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
含义 | 命令字 | 源地址,指发送方的地址 | 目的地址,指接受方的地址 | 当前包序号 | 总包数 |
我们可以看到协议里面包含了源地址和目的地址,这29位的值越小越容易获取到总线的使用权,所以我们应该利用这个特性来定义一些命令和关键ECU的地址。
所以看到这个数据协议我们想下如何实现我们的异步转同步:
1、如何定义我们的map中的[key]值:
使用目的地址+命令+当前包序号来确定[key]。但是存在相同命令发送重复的问题。
2、如何区分是客户端请求还是服务端响应?
这个协议上无法区分出是我们请求获得的响应,还是服务端主动的请求得到的数据。
解决方案:
- 通过是否在客户端的请求列表中来判断是否是客户端发起的请求
csharp
var request = _pendingRequests.remove(key);
if (request == null) {
// 服务端主动过来的
} else{
//客户端主动请求的
}
}
- 相同key值取消发送
kotlin
//如果连续发送了两个请求一致,把第一个取消掉
if (_pendingRequests[key] != null) {
_Request? request = _pendingRequests.remove(key);
request?.completer.completeError({"error": "任务取消了"});
logger.i("取消当前命令:$cmd,当前通道:$targetAddress");
}
当然以上还有很多更好的解决方案:
- 可以通过控制协议中的远程帧:来区分是否是请求还是响应。
- 端上控制,防止并发,或者如果产生并发问题,可在用一个队列去缓存相同key值的请求,后续进行发送。
架构图
这个目前是我们设备间通信的一个架构图,其中中间件还是待完善的,目前的mock和缓存,重试逻辑都还在应用层进行管理。
最后提个小问题:大家认为RPC在前端领域里有哪些用处呢?
参考链接
zhuanlan.zhihu.com/p/348473362
mp.weixin.qq.com/s/9frk_VS8V...
mp.weixin.qq.com/s/MVGT_J5ql...
最后
📚 小茗文章推荐:
关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~