作者 | 廖桉冬
背景
自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多個資源 的抽象和封裝
- 云服務 是對 軟/硬件服務 的抽象和封裝