背景
自Java语言流行以来,其主打的面向对象编程也成为了家喻户晓的一种程序设计思想:
“封装、继承、多态”、“易维护、易复用、易扩展”,“解耦、隔离”
而以过程为中心的“面向过程编程”,通常会优先分析出解决问题所需的步骤,然后用函数依次实现这些步骤,最后串联起来依次调用即可,是一种基于顺序的思维方式。
常见的支持面向过程的编程语言有 C语言、COBOL 语言等,被广泛的应用在系统内核、IoT、物联网等领域。其中一个比较典型的案例是串口通信协议的集成开发(驱动、SDK),虽然大多数的Web应用都已经跨入了“Json Free”的时代,但大量的嵌入式设备使用仍是串口协议,以获得能耗、体积和效率等方面的优势。而现有的驱动大多由C,使用面向过程的方式编写的。
举个栗子 ,当我们的应用需要提供线下的服务:用户在门户店可以使用一体机访问我们的服务,可以选择使用线下POS机进行刷卡支付(类比肯德基)。我们不仅要在网页后台计算出订单价格,还要通知POS机开始“接单”,完成刷卡操作并及时返回交易数据。
然而,当打开POS机“附赠”的接口文档时,晃眼的二进制案例、复杂的数据结构却让我们手足无措 —— 所有的数据都需要通过那根RS232串口线,以“01010101”的数据与相连的一体机进行交互。
PS:一体机是一台Windows物理机,通过COM接口(RS232、9针线)连接POS机设备
令人头晕的二进制
不同于我们日常所使用的HTTP协议:
- 具有标准的报文结构和数据编码
- 完备的SDK和生态链工具,可以很容易实现CS(Client-Server)架构的数据传输
- 无需关注应用层(ISO Application Layer)以下的技术细节
而串口更贴近于ISO的物理层:通过指定频率(Baud 波特率)的高低电平(0/1)来传输数据。
因此要想通过串口传递具有特定意义的数据时,通常需要对二进制数据加以区分、组合、编码,以赋予其表达复杂数据结构的能力 —— 串口通信协议。例如一个典型(但又稍显复杂)的串口协议报文:
一个串口消息的数据结构(使用16进制表示字节流示例)
串=“串行”,数据在传输过程中都是按顺序写入、读出的,因此需要准确的告诉服务方:
- StartToken / EndToken,标记当前消息何时开始何时结束
- Length,当前欲读取的数据长度
为了提升协议的易用性,将不同目的的数据通过类型加以区分,具有不同的序列化规则
- Hex(十六进制)
- BCD(二进制化整数)
- ASC(ASIIC码)
数据部分则由消息头和多组消息数据组成
- 关键字段(如ID、Code、Version)都是固定类型、固定长度的数据
- 而数据字段(Data)在不同的Field Code(不同场景下)是不同的是一个变长数据,因此也需要Len在前,声明数据长度发送、读取时都要通过Field Code动态推断
按照面向过程的方式按顺序依次构建,创建一条消息并不是一件困难的事。然而不同的功能指令(Function Code)所包含的消息数据(Field Data)是完全不一样的,但其发送流程、序列化方式又是一致的。如果我们面向过程,以一条功能指令为单位进行开发,不仅会出现大量重复冗余的序列化代码,而且会丢失上层的Function、Field的业务含义, 代码难以理解和维护。
public void decodeMsgData(byte[] msgDataBlocks, int index) throws PaymentException {
Int start = 0;
for(int i = 0; i < msgDataBlocks.Length; ++i) {
byte[] fieldCodeByte = new byte[]{msgDataBlocks[start], msgDataBlocks[start + 1]};
String fieldCode = new String(fieldCodeByte);
byte[] lenByte = new byte[]{msgDataBlocks[start + 2], msgDataBlocks[start + 3]};
int len = CommonUtil.convertBCDToInt(lenByte);
byte[] data = new byte[len];
System.arraycopy(msgDataBlocks, start + 4, data, 0, len);
if (!fieldCode.equals("M1") && !fieldCode.equals("HB")) {
if (fieldCode.equals("J4")) {
handleJ4(data);
}
} else if (fieldCode.equals("X5")) {
handleX5(data);
} else if ...
}
}
解析某一种指令的序列化代码,充斥着难以理解的变量和混乱的处理逻辑
二进制数据的转换、枚举值的配置、业务逻辑的处理耦合在同一个类,甚至同一个方法中,想要梳理出代码的执行流程都已经很困难,更不要说进一步的维护和更新了。
轮子不行就造一个。
“封装,他使用了封装!”
那应该如何设计既能够适配串口数据,又能保证较高的可扩展性和可维护性呢?
遇事不决,量子力学(No )
遇事不决,面向对象(Yes)
面向对象的一大特点就是封装 —— 高内聚低耦合。
首先,我将三个基本类型进行了封装:BCD、ASC、Hex,将上层模型(Message)对二进制的依赖逐渐转移成对基本类型BCD/ASC/Hex的依赖。同理,Start/End Token、分隔符、Length等通用数据类型也被抽取成了单独的数据类型。
接着,祭出面向对象第二法宝 —— 多态(接口多态),定义Attribute接口来描述“如何由基本类型序列化/反序列化为二进制数据”,并由各个基本类型加以实现。
此时,上层的Message和“0101”已完全解耦,变成了含有多个"基本"字段类型的POJO类。就和我们平时所写的Class一样,直接使用String、Int、Double而无需担心他们在内存的具体地址。
{
"message": {
"startToken": "Hex(08)", // Control.STX
"length": "BCD(128)", // calculate(this)
"header": {
"id": "ASC(000000000001)",
"function": "ASC(01)"
},
"data": [
{
"field": "ASC(M1)",
"length": "BCD(27)",
"value": "ASC(Hello, World)",
"separator": "Hex(1C)" // Control.SEP
}
],
"endToken": "Hex(09)", // Control.ETX
"checksum": "Hex(35)" // calculate(this)
}
}
以对象描述消息结构,以类型标明字段信息
消息对象与“基本类型”的关系
一层一层又一层
封装之后的Message易于使用了,但开发时仍需要基于业务指令来拼装数据,只是从对二进制的拼装变成了对Attribute的拼装,并不足够表达业务含义:
- 对于某一项指令功能(Function)的使用者来说他不关心下层数据如何被序列化、如何被发送他只关心业务数据是否正确的被设置和接收(set/get)
- 对于某一条消息数据(Message)的传输者来说他不关心上层数据的业务含义他只关心二进制数据的在串口正确的传输
多重施法! —— 就像Attribute隔离基本类型与二进制,我们再抽象一个Field接口来隔离业务字段和消息数据。
对于指令使用者(应用开发者)来说,对某一条指令的操作更贴近命令式编程,而下层的消息组装、序列化以及数据传输都被封装到了“基本字段 Field”和“基本类型 Attribute”中。因为使用了继承和多态,其他组件通过统一的接口类型进行协作,保证单向依赖和数据的单向流动,大大增加了可扩展性和可维护性。
@FieldDef(code = "49", length = 12)
class TransactionAmount implements Field {
Bigdecimal amount;
}
@FieldDef(code = "51", length = 25)
class AcquirerName implements Field {
String name;
}
… … … … … …
{
"request": {
"id": "000000000001", // -> message.header.id
"function": "CREDIT_CARD", // -> message.header.function
"transactionAmount": "20.00", // message.data[]{ field:"49", value:"20.00", ... }
"acquirerName": "VISA" // message.data[]{ field:"51", value:"VISA", … }
}
}
基于消息对象再抽象一层,构建出更贴近业务的Request/Response
对指定指令 (function) 的开发和使用与底层数据结构是解耦的
当我们要支持新的指令时,我们只需要实现新的Field即可 —— function 层以上
当我们要更新序列化规则时,我们只需要修改协议层Attribute —— protocol 层以下
全景
SDK架构 + 数据序列化流向 + 串口异步监听
测试
Of course,为了避免破坏已经构建好的功能,测试也是开发过程中需要慎重对待的环节(毕竟对于二进制数据来说,前面错漏一个bit,解码出来的消息可能完全不一样…)
对于协议层(protocol),TDD是最佳的测试和开发方式。“A->B”,输入输出明确,用起来是非常舒服且高效的。但一旦涉及到串口通信部分就需要费一些心思了:
- 串口的读写口是不一样的写口发送数据后,需要等待并监听读口接收数据但Listener模式大多是多线程的,需要引入额外的同步组件来控制
- 串口连接是长链接,且没有容错机制,可能出现丢包、断线等情况一般会额外设计ACK/NACK的握手机制(类似TCP)来保证通信,以触发重试
Option 1:构造多线程测试环境
- 创建Stub Server
使用了PipedInputStream、PipedOutputStream,将对串口的读写流包装并导向创建的管道流中,再通过另一个线程来模拟终端POS机消费里面的数据,以实现接收请求、返回数据,验证数据传输和序列化的正确性。
val serverInputStream = PipedInputStream()
val serverOutputStream = PipedOutputStream()
val clientInputStream = PipedInputStream(serverOutputStream)
val clientOutputStream = PipedOutputStream(serverInputStream)
val serialConnection = StreamSerialChannel(clientInputStream, clientOutputStream)
val mockServer = Thread {
// 1. wait for client
Thread.sleep(50)
// 2. read request in server side
serverInputStream.read(ByteArray(requestBytes.size))
// 3. send ack to client
serverOutputStream.write(Acknowledgement.ACK.getBytes())
// 4. notify client - simulate comm listener
serialConnection.onDataAvailable()
// 5. send response to client
serverOutputStream.write(responseBytes)
// 6. notify client - simulate comm listener
serialConnection.onDataAvailable()
// 7. wait for client
Thread.sleep(50)
// 8. read ack in server side
serverInputStream.read(ByteArray(1))
}
左右互搏,模拟上下游的字节流进行数据传输
Option 2:使用Fake的外部程序
- 虚拟串口:Windows和Linux上有比较成熟的串口调试工具
我使用的是Windows Virtual Serial Port Driver,因为通过虚拟串口直接写入(二进制)数据并不是很方便,所以我创建了两个虚拟串口A – B分别模拟Client(发送方-一体机)和Server(接收方-POS)的串口,并连接到一起以便相互通信。与Option 1类似,启动两个线程分别扮演发送方、接收方并连接对应的串口,一个发一个收来模拟E2E的交互场景。
- USB转串口芯片(稍微硬核)
刚好家里有一台树莓派,本身是自带串口接口的,可以用来扮演POS系统。然后我从某宝购入了一块USB转TTL的串口芯片(因为我的电脑已经没有九针接口了),插入到Windows主机上,使其可以通过USB向外发送串口数据。将树莓派和TTL的Read/Write引脚反接,类似Option 2的测试方式,只是两个线程变成了两台独立主机。
CH340芯片
Option 3:使用测试机
- IoT设备相对复杂,一般供应商都会提供相应的测试机器和测试环境
但由于沟通原因,我们的测试机器很晚才拿到;因为疫情,开发人员并不能接触到POS测试机,只能通过Zoom远程指导式调试。因此我们需要尽早、尽快的在相对准确的环境下,验证SDK的功能是完备的。
也因为我们提前准备的多层测试,在拿到测试机后仅花费了1小时就完成了实机集成测试。
后记(脑补)
本文主要以“面向对象”的编程思想,从新审视了串口协议的设计和实现。利用“封装、继承、多态”的特性,构建出更健壮、强扩展、易维护的SDK。但“面向对象”也并不是唯一解—
“抽象 —— 编程的本质,是对问题域和解决方案域的一种提炼”
笔者认为,“抽象”可能是一种更通用的编程思想。选择合适的角度和层级分析问题,找寻共性并获得答案,将解决问题的过程抽象为模型、方法论、原则,并推行到更多的场景和领域才是编程的核心。代码实现仅是一个“翻译”工作而已。
随着抽象层级的不同,软件从代码、模块的复用,上升到系统、产品的复用。就像文中的串口协议一样,只基于下层服务给出承诺和约定,上层应用专注在当前待解决的问题领域。因此,上文虽然是阐述对串口协议的开发设计,但抽象的思维模式依然可以在不同的领域产生共鸣:
- 高级语言 是对 汇编指令 的抽象和封装
- Deployment 是对 Kubernetes多个资源 的抽象和封装
- 云服务 是对 软/硬件服务 的抽象和封装
“以不变 —— 编译原理、工程化理论、敏捷实践…
应万变 —— 不同的编程语言、不同的软件架构、不同的项目更迭…”
文/Thoughtworks廖桉冬
原文链接:https://insights.thoughtworks.cn/oo-design-serial-protocol/
更多精彩洞见,请关注微信公众号Thoughtworks洞见。
如若转载,请注明出处:https://www.xiezuozhinan.com/2782.html