作者 | 廖桉冬
背景
自Java語言流行以來,其主打的面向?qū)ο缶幊桃渤蔀榱思矣鲬魰缘囊环N程序設(shè)計(jì)思想:“封裝、繼承、多態(tài)”、“易維護(hù)、易復(fù)用、易擴(kuò)展”,“解耦、隔離”。
而以過程為中心的“面向過程編程”,通常會(huì)優(yōu)先分析出解決問題所需的步驟,然后用函數(shù)依次實(shí)現(xiàn)這些步驟,最后串聯(lián)起來依次調(diào)用即可,是一種基于順序的思維方式。
常見的支持面向過程的編程語言有 C語言、COBOL 語言等,被廣泛地應(yīng)用在系統(tǒng)內(nèi)核、IoT、物聯(lián)網(wǎng)等領(lǐng)域。其中一個(gè)比較典型的案例是串口通信協(xié)議的集成開發(fā)(驅(qū)動(dòng)、SDK),雖然大多數(shù)的Web應(yīng)用都已經(jīng)跨入了“Json Free”的時(shí)代,但大量的嵌入式設(shè)備使用仍是串口協(xié)議,以獲得能耗、體積和效率等方面的優(yōu)勢(shì)。而現(xiàn)有的驅(qū)動(dòng)大多由C,使用面向過程的方式編寫的。
舉個(gè)栗子 ,當(dāng)我們的應(yīng)用需要提供線下的服務(wù):用戶在門戶店可以使用一體機(jī)訪問我們的服務(wù),可以選擇使用線下POS機(jī)進(jìn)行刷卡支付(類比肯德基)。我們不僅要在網(wǎng)頁后臺(tái)計(jì)算出訂單價(jià)格,還要通知POS機(jī)開始“接單”,完成刷卡操作并及時(shí)返回交易數(shù)據(jù)。
然而,當(dāng)打開POS機(jī)“附贈(zèng)”的接口文檔時(shí),晃眼的二進(jìn)制案例、復(fù)雜的數(shù)據(jù)結(jié)構(gòu)卻讓我們手足無措 —— 所有的數(shù)據(jù)都需要通過那根RS232串口線,以“01010101”的數(shù)據(jù)與相連的一體機(jī)進(jìn)行交互。
PS:一體機(jī)是一臺(tái)Windows物理機(jī),通過COM接口(RS232、9針線)連接POS機(jī)設(shè)備;文章中內(nèi)含代碼示例,電腦端觀看效果更佳。
令人頭暈的二進(jìn)制
不同于我們?nèi)粘K褂玫腍TTP協(xié)議:
- 具有標(biāo)準(zhǔn)的報(bào)文結(jié)構(gòu)和數(shù)據(jù)編碼
- 完備的SDK和生態(tài)鏈工具,可以很容易實(shí)現(xiàn)CS(Client-Server)架構(gòu)的數(shù)據(jù)傳輸
- 無需關(guān)注應(yīng)用層(ISO Application Layer)以下的技術(shù)細(xì)節(jié)
而串口更貼近于ISO的物理層:通過指定頻率(Baud 波特率)的高低電平(0/1)來傳輸數(shù)據(jù)。
因此要想通過串口傳遞具有特定意義的數(shù)據(jù)時(shí),通常需要對(duì)二進(jìn)制數(shù)據(jù)加以區(qū)分、組合、編碼,以賦予其表達(dá)復(fù)雜數(shù)據(jù)結(jié)構(gòu)的能力 —— 串口通信協(xié)議。例如一個(gè)典型(但又稍顯復(fù)雜)的串口協(xié)議報(bào)文:
一個(gè)串口消息的數(shù)據(jù)結(jié)構(gòu)(使用16進(jìn)制表示字節(jié)流示例)
串=“串行”,數(shù)據(jù)在傳輸過程中都是按順序?qū)懭?、讀出的,因此需要準(zhǔn)確的告訴服務(wù)方:
- StartToken / EndToken,標(biāo)記當(dāng)前消息何時(shí)開始何時(shí)結(jié)束
- Length,當(dāng)前欲讀取的數(shù)據(jù)長度
為了提升協(xié)議的易用性,將不同目的的數(shù)據(jù)通過類型加以區(qū)分,具有不同的序列化規(guī)則:
- Hex(十六進(jìn)制)
- BCD(二進(jìn)制化整數(shù))
- ASC(ASIIC碼)
數(shù)據(jù)部分則由消息頭和多組消息數(shù)據(jù)組成:
(1)關(guān)鍵字段(如ID、Code、Version)都是固定類型、固定長度的數(shù)據(jù);
(2)而數(shù)據(jù)字段(Data)在不同的Field Code(不同場景下)是不同的:
- 是一個(gè)變長數(shù)據(jù),因此也需要Len在前,聲明數(shù)據(jù)長度
- 發(fā)送、讀取時(shí)都要通過Field Code動(dòng)態(tài)推斷
按照面向過程的方式按順序依次構(gòu)建,創(chuàng)建一條消息并不是一件困難的事。然而不同的功能指令(Function Code)所包含的消息數(shù)據(jù)(Field Data)是完全不一樣的,但其發(fā)送流程、序列化方式又是一致的。如果我們面向過程,以一條功能指令為單位進(jìn)行開發(fā),不僅會(huì)出現(xiàn)大量重復(fù)冗余的序列化代碼,而且會(huì)丟失上層的Function、Field的業(yè)務(wù)含義, 代碼難以理解和維護(hù)。
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
}
}
解析某一種指令的序列化代碼,充斥著難以理解的變量和混亂的處理邏輯
二進(jìn)制數(shù)據(jù)的轉(zhuǎn)換、枚舉值的配置、業(yè)務(wù)邏輯的處理耦合在同一個(gè)類,甚至同一個(gè)方法中,想要梳理出代碼的執(zhí)行流程都已經(jīng)很困難,更不要說進(jìn)一步的維護(hù)和更新了。
輪子不行就造一個(gè)。
“封裝,他使用了封裝!”
那應(yīng)該如何設(shè)計(jì)既能夠適配串口數(shù)據(jù),又能保證較高的可擴(kuò)展性和可維護(hù)性呢?
- 遇事不決,量子力學(xué)(No )
- 遇事不決,面向?qū)ο?Yes)
面向?qū)ο蟮囊淮筇攸c(diǎn)就是封裝 —— 高內(nèi)聚低耦合。
首先,我將三個(gè)基本類型進(jìn)行了封裝:BCD、ASC、Hex,將上層模型(Message)對(duì)二進(jìn)制的依賴逐漸轉(zhuǎn)移成對(duì)基本類型BCD/ASC/Hex的依賴。同理,Start/End Token、分隔符、Length等通用數(shù)據(jù)類型也被抽取成了單獨(dú)的數(shù)據(jù)類型。
接著,祭出面向?qū)ο蟮诙▽?—— 多態(tài)(接口多態(tài)),定義Attribute接口來描述“如何由基本類型序列化/反序列化為二進(jìn)制數(shù)據(jù)”,并由各個(gè)基本類型加以實(shí)現(xiàn)。
此時(shí),上層的Message和“0101”已完全解耦,變成了含有多個(gè)"基本"字段類型的POJO類。就和我們平時(shí)所寫的Class一樣,直接使用String、Int、Double而無需擔(dān)心他們?cè)趦?nèi)存的具體地址。
{
"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)
}
}
以對(duì)象描述消息結(jié)構(gòu),以類型標(biāo)明字段信息
消息對(duì)象與“基本類型”的關(guān)系
一層一層又一層
封裝之后的Message易于使用了,但開發(fā)時(shí)仍需要基于業(yè)務(wù)指令來拼裝數(shù)據(jù),只是從對(duì)二進(jìn)制的拼裝變成了對(duì)Attribute的拼裝,并不足夠表達(dá)業(yè)務(wù)含義:
(1)對(duì)于某一項(xiàng)指令功能(Function)的使用者來說
- 他不關(guān)心下層數(shù)據(jù)如何被序列化、如何被發(fā)送
- 他只關(guān)心業(yè)務(wù)數(shù)據(jù)是否正確的被設(shè)置和接收(set/get)
(2)對(duì)于某一條消息數(shù)據(jù)(Message)的傳輸者來說
- 他不關(guān)心上層數(shù)據(jù)的業(yè)務(wù)含義
- 他只關(guān)心二進(jìn)制數(shù)據(jù)的在串口正確的傳輸
多重施法!—— 就像Attribute隔離基本類型與二進(jìn)制,我們?cè)俪橄笠粋€(gè)Field接口來隔離業(yè)務(wù)字段和消息數(shù)據(jù)。
對(duì)于指令使用者(應(yīng)用開發(fā)者)來說,對(duì)某一條指令的操作更貼近命令式編程,而下層的消息組裝、序列化以及數(shù)據(jù)傳輸都被封裝到了“基本字段 Field”和“基本類型 Attribute”中。因?yàn)槭褂昧死^承和多態(tài),其他組件通過統(tǒng)一的接口類型進(jìn)行協(xié)作,保證單向依賴和數(shù)據(jù)的單向流動(dòng),大大增加了可擴(kuò)展性和可維護(hù)性。
@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", … }
}
}
基于消息對(duì)象再抽象一層,構(gòu)建出更貼近業(yè)務(wù)的Request/Response
對(duì)指定指令 (function) 的開發(fā)和使用與底層數(shù)據(jù)結(jié)構(gòu)是解耦的
- 當(dāng)我們要支持新的指令時(shí),我們只需要實(shí)現(xiàn)新的Field即可 —— function 層以上
- 當(dāng)我們要更新序列化規(guī)則時(shí),我們只需要修改協(xié)議層Attribute —— protocol 層以下
全景
SDK架構(gòu) + 數(shù)據(jù)序列化流向 + 串口異步監(jiān)聽
測試
Of course,為了避免破壞已經(jīng)構(gòu)建好的功能,測試也是開發(fā)過程中需要慎重對(duì)待的環(huán)節(jié)(畢竟對(duì)于二進(jìn)制數(shù)據(jù)來說,前面錯(cuò)漏一個(gè)bit,解碼出來的消息可能完全不一樣...)
對(duì)于協(xié)議層(protocol),TDD是最佳的測試和開發(fā)方式。“A->B”,輸入輸出明確,用起來是非常舒服且高效的。但一旦涉及到串口通信部分就需要費(fèi)一些心思了:
(1)串口的讀寫口是不一樣的:
- 寫口發(fā)送數(shù)據(jù)后,需要等待并監(jiān)聽讀口接收數(shù)據(jù)
- 但Listener模式大多是多線程的,需要引入額外的同步組件來控制
(2)串口連接是長鏈接,且沒有容錯(cuò)機(jī)制,可能出現(xiàn)丟包、斷線等情況:
- 一般會(huì)額外設(shè)計(jì)ACK/NACK的握手機(jī)制(類似TCP)來保證通信,以觸發(fā)重試
Option 1:構(gòu)造多線程測試環(huán)境
創(chuàng)建Stub Server:
使用了PipedInputStream、PipedOutputStream,將對(duì)串口的讀寫流包裝并導(dǎo)向創(chuàng)建的管道流中,再通過另一個(gè)線程來模擬終端POS機(jī)消費(fèi)里面的數(shù)據(jù),以實(shí)現(xiàn)接收請(qǐng)求、返回?cái)?shù)據(jù),驗(yàn)證數(shù)據(jù)傳輸和序列化的正確性。
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))
}
左右互搏,模擬上下游的字節(jié)流進(jìn)行數(shù)據(jù)傳輸
Option 2:使用Fake的外部程序
(1) 虛擬串口:Windows和Linux上有比較成熟的串口調(diào)試工具
我使用的是Windows Virtual Serial Port Driver,因?yàn)橥ㄟ^虛擬串口直接寫入(二進(jìn)制)數(shù)據(jù)并不是很方便,所以我創(chuàng)建了兩個(gè)虛擬串口A - B分別模擬Client(發(fā)送方-一體機(jī))和Server(接收方-POS)的串口,并連接到一起以便相互通信。與Option 1類似,啟動(dòng)兩個(gè)線程分別扮演發(fā)送方、接收方并連接對(duì)應(yīng)的串口,一個(gè)發(fā)一個(gè)收來模擬E2E的交互場景。
(2) USB轉(zhuǎn)串口芯片(稍微硬核)
剛好家里有一臺(tái)樹莓派,本身是自帶串口接口的,可以用來扮演POS系統(tǒng)。然后我從某寶購入了一塊USB轉(zhuǎn)TTL的串口芯片(因?yàn)槲业碾娔X已經(jīng)沒有九針接口了),插入到Windows主機(jī)上,使其可以通過USB向外發(fā)送串口數(shù)據(jù)。將樹莓派和TTL的Read/Write引腳反接,類似Option 2的測試方式,只是兩個(gè)線程變成了兩臺(tái)獨(dú)立主機(jī)。
CH340芯片
Option 3:使用測試機(jī)
IoT設(shè)備相對(duì)復(fù)雜,一般供應(yīng)商都會(huì)提供相應(yīng)的測試機(jī)器和測試環(huán)境。
但由于溝通原因,我們的測試機(jī)器很晚才拿到;因?yàn)橐咔?,開發(fā)人員并不能接觸到POS測試機(jī),只能通過Zoom遠(yuǎn)程指導(dǎo)式調(diào)試。因此我們需要盡早、盡快的在相對(duì)準(zhǔn)確的環(huán)境下,驗(yàn)證SDK的功能是完備的。
也因?yàn)槲覀兲崆皽?zhǔn)備的多層測試,在拿到測試機(jī)后僅花費(fèi)了1小時(shí)就完成了實(shí)機(jī)集成測試。
后記(腦補(bǔ))
本文主要以“面向?qū)ο蟆钡木幊趟枷?,從新審視了串口協(xié)議的設(shè)計(jì)和實(shí)現(xiàn)。利用“封裝、繼承、多態(tài)”的特性,構(gòu)建出更健壯、強(qiáng)擴(kuò)展、易維護(hù)的SDK。但“面向?qū)ο蟆币膊⒉皇俏ㄒ唤狻?/p>
“抽象 —— 編程的本質(zhì),是對(duì)問題域和解決方案域的一種提煉”
筆者認(rèn)為,“抽象”可能是一種更通用的編程思想。選擇合適的角度和層級(jí)分析問題,找尋共性并獲得答案,將解決問題的過程抽象為模型、方法論、原則,并推行到更多的場景和領(lǐng)域才是編程的核心。代碼實(shí)現(xiàn)僅是一個(gè)“翻譯”工作而已。
隨著抽象層級(jí)的不同,軟件從代碼、模塊的復(fù)用,上升到系統(tǒng)、產(chǎn)品的復(fù)用。就像文中的串口協(xié)議一樣,只基于下層服務(wù)給出承諾和約定,上層應(yīng)用專注在當(dāng)前待解決的問題領(lǐng)域。因此,上文雖然是闡述對(duì)串口協(xié)議的開發(fā)設(shè)計(jì),但抽象的思維模式依然可以在不同的領(lǐng)域產(chǎn)生共鳴:
- 高級(jí)語言 是對(duì) 匯編指令 的抽象和封裝
- Deployment 是對(duì) Kubernetes多個(gè)資源 的抽象和封裝
- 云服務(wù) 是對(duì) 軟/硬件服務(wù) 的抽象和封裝