通过面向对象设计串口协议

原创 精选
开发
要想通过串口传递具有特定意义的数据时,通常需要对二进制数据加以区分、组合、编码,以赋予其表达复杂数据结构的能力 —— 串口通信协议。

作者 | 廖桉冬

背景

自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码)

数据部分则由消息头和多组消息数据组成:

(1)关键字段(如ID、Code、Version)都是固定类型、固定长度的数据;

(2)而数据字段(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的拼装,并不足够表达业务含义:

(1)对于某一项指令功能(Function)的使用者来说

  • 他不关心下层数据如何被序列化、如何被发送
  • 他只关心业务数据是否正确的被设置和接收(set/get)

(2)对于某一条消息数据(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”,输入输出明确,用起来是非常舒服且高效的。但一旦涉及到串口通信部分就需要费一些心思了:

(1)串口的读写口是不一样的:

  • 写口发送数据后,需要等待并监听读口接收数据
  • 但Listener模式大多是多线程的,需要引入额外的同步组件来控制

(2)串口连接是长链接,且没有容错机制,可能出现丢包、断线等情况:

  • 一般会额外设计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的外部程序

(1) 虚拟串口:Windows和Linux上有比较成熟的串口调试工具

我使用的是Windows Virtual Serial Port Driver,因为通过虚拟串口直接写入(二进制)数据并不是很方便,所以我创建了两个虚拟串口A - B分别模拟Client(发送方-一体机)和Server(接收方-POS)的串口,并连接到一起以便相互通信。与Option 1类似,启动两个线程分别扮演发送方、接收方并连接对应的串口,一个发一个收来模拟E2E的交互场景。

(2) USB转串口芯片(稍微硬核)

刚好家里有一台树莓派,本身是自带串口接口的,可以用来扮演POS系统。然后我从某宝购入了一块USB转TTL的串口芯片(因为我的电脑已经没有九针接口了),插入到Windows主机上,使其可以通过USB向外发送串口数据。将树莓派和TTL的Read/Write引脚反接,类似Option 2的测试方式,只是两个线程变成了两台独立主机。

CH340芯片

Option 3:使用测试机

IoT设备相对复杂,一般供应商都会提供相应的测试机器和测试环境。

但由于沟通原因,我们的测试机器很晚才拿到;因为疫情,开发人员并不能接触到POS测试机,只能通过Zoom远程指导式调试。因此我们需要尽早、尽快的在相对准确的环境下,验证SDK的功能是完备的。

也因为我们提前准备的多层测试,在拿到测试机后仅花费了1小时就完成了实机集成测试。

后记(脑补)

本文主要以“面向对象”的编程思想,从新审视了串口协议的设计和实现。利用“封装、继承、多态”的特性,构建出更健壮、强扩展、易维护的SDK。但“面向对象”也并不是唯一解—

“抽象 —— 编程的本质,是对问题域和解决方案域的一种提炼”

笔者认为,“抽象”可能是一种更通用的编程思想。选择合适的角度和层级分析问题,找寻共性并获得答案,将解决问题的过程抽象为模型、方法论、原则,并推行到更多的场景和领域才是编程的核心。代码实现仅是一个“翻译”工作而已。

随着抽象层级的不同,软件从代码、模块的复用,上升到系统、产品的复用。就像文中的串口协议一样,只基于下层服务给出承诺和约定,上层应用专注在当前待解决的问题领域。因此,上文虽然是阐述对串口协议的开发设计,但抽象的思维模式依然可以在不同的领域产生共鸣:

  • 高级语言 是对 汇编指令 的抽象和封装
  • Deployment 是对 Kubernetes多个资源 的抽象和封装
  • 云服务 是对 软/硬件服务 的抽象和封装
责任编辑:赵宁宁 来源: Thoughtworks洞见
相关推荐

2022-07-30 23:41:53

面向过程面向对象面向协议编程

2013-04-17 10:46:54

面向对象

2012-06-07 10:11:01

面向对象设计原则Java

2021-11-23 20:41:05

对象软件设计

2023-01-10 09:38:09

面向对象系统

2010-06-17 16:06:18

串口协议

2013-06-07 11:31:36

面向对象设计模式

2021-07-16 10:23:47

Python设计对象

2010-06-18 11:28:14

2024-05-10 09:28:57

Python面向对象代码

2010-06-10 10:03:42

UML面向对象

2011-07-05 16:05:43

面向对象编程

2021-07-02 14:14:14

Python对象设计

2009-09-27 14:12:12

面向对象设计单一职责

2012-12-25 10:51:39

IBMdW

2010-07-08 10:47:42

UML面向对象

2011-07-05 15:22:04

程序设计

2011-07-05 15:59:57

面向对象编程

2010-06-17 17:57:10

UML面向对象分析与设

2023-12-08 07:59:41

对象设计设计模式软件设计
点赞
收藏

51CTO技术栈公众号