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

背景

自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多个资源 的抽象和封装
  • 云服务 是对 软/硬件服务 的抽象和封装

“以不变 —— 编译原理、工程化理论、敏捷实践...
应万变 —— 不同的编程语言、不同的软件架构、不同的项目更迭...”

Share

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据