门店智能设备间「通信」原理

作者:张义飞

背景

古茗门店的后厨放着有很多设备,功能不一样,类型不一样,而且通信机制也不一样。

像最基本的前端和服务端通信,我们需要定义各种接口去实现我们的业务功能,然后依赖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 标头。

客户端请求

服务端响应

设计一个简单的通信协议

上面我们说了什么是通信协议,那么一个通信协议需要具备那些细节?

通信协议设计核心

  1. 解析效率高
  2. 可扩展

协议设计细节

  1. 数据帧完整性判断
  2. 序列化,反序列化
  3. 协议版本,兼容性
  4. 协议安全,加密,防攻击
  5. 数据压缩

消息完整性判断

  1. 固定大小

以固定大小字节来进行分界,比如每个消息是8个字节,不足8个就进行补0处理

  1. 以特定符号分界

比如使用\r\n表示一个消息传输完毕

  1. 固定消息头和消息体

也就是我们说的header + body

  1. 按照某个时间间隔未收到消息表示完整性

这个是很扯淡的设计,很不推荐。

举个🌰

请求包(部分内容)

序号 标识 数据类型 长度(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让远程调用就像本地调用一样。

就像我们本地调用一个函数一样,但是它存在以下几个问题

  1. 本地函数调用的结果是可预测的,而 RPC 需要经过网络传输,数据在中途可能因为各种原因丢失。
  2. RPC 调用有可能超时,编写程序时需要考虑该情况。
  3. 重试一个失败的 RPC 调用有可能造成数据重复,需要考虑幂等。
  4. 由于传输数据时需要序列化和反序列化,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),服务端响应了我们就将结果返回,如果超过某个时间未返回,我们就告诉客户端超时了,如果请求过程出错了,我们就告诉客户端错误信息。

核心实现

  1. 构造待请求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;
  }
  1. 发送请求
kotlin 复制代码
class _Request {
  final String? method;
  final Completer completer;
  //也可以在这里进行超时处理
  
  _Request(this.method, this.completer);
}
  1. 响应给客户端
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()}");
  }
}
}

几个关键点

  1. 保证每次请求的key值唯一,也就是json-rpc中的参数id, 使用递增id。
  2. 在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、如何区分是客户端请求还是服务端响应?

这个协议上无法区分出是我们请求获得的响应,还是服务端主动的请求得到的数据。

解决方案:

  1. 通过是否在客户端的请求列表中来判断是否是客户端发起的请求
csharp 复制代码
var request = _pendingRequests.remove(key);
  if (request == null) {
    // 服务端主动过来的
  } else{
    //客户端主动请求的
  }
}
  1. 相同key值取消发送
kotlin 复制代码
//如果连续发送了两个请求一致,把第一个取消掉
if (_pendingRequests[key] != null) {
  _Request? request = _pendingRequests.remove(key);
  request?.completer.completeError({"error": "任务取消了"});
  logger.i("取消当前命令:$cmd,当前通道:$targetAddress");
}

当然以上还有很多更好的解决方案:

  1. 可以通过控制协议中的远程帧:来区分是否是请求还是响应。
  2. 端上控制,防止并发,或者如果产生并发问题,可在用一个队列去缓存相同key值的请求,后续进行发送。

架构图

这个目前是我们设备间通信的一个架构图,其中中间件还是待完善的,目前的mock和缓存,重试逻辑都还在应用层进行管理。

最后提个小问题:大家认为RPC在前端领域里有哪些用处呢?

参考链接

zhuanlan.zhihu.com/p/348473362

mp.weixin.qq.com/s/9frk_VS8V...

mp.weixin.qq.com/s/MVGT_J5ql...

最后

📚 小茗文章推荐:

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~

相关推荐
吃杠碰小鸡5 分钟前
commitlint校验git提交信息
前端
虾球xz36 分钟前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇42 分钟前
HTML常用表格与标签
前端·html
疯狂的沙粒1 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员1 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐1 小时前
前端图像处理(一)
前端
程序猿阿伟1 小时前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒1 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
javaDocker1 小时前
业务架构、数据架构、应用架构和技术架构
架构
瑞雨溪1 小时前
AJAX的基本使用
前端·javascript·ajax