TLV 資料交換格式

當數位的資料需要「交換」的時候,就自然的產生了資料到底該如何儲存、解析等與「格式」有關的問題。 當資料格式第一次定下後,隨著程式的被使用,往往無法避免的需要變更舊有的資料格式, 因為沒有人能夠在一開始就知道未來新增的各種資料需求。 這個時候,資料格式的版本控制就成為一個非常重要的議題,也是傳統上許多的程式在其生命發展中期以後會面臨的棘手問題!
對於一支程式內部所儲存的資料我們一般無需擔心, 因為這類資料不對外公開,生命週期也很短,其通常會在程式結束執行以前消逝, 因此只要程式自己能正確解析自己寫的資料就可以了; 但當資料的填寫者和接收解析者為兩個不同的程式時,一方能不能看懂另一方的資料就顯得格外重要! 這樣的情境在平常其實很容易遇到, 比如說在網路的兩端有兩隻程式要互相交換資料、一支程式要解析由另一支程式所儲存的檔案等等。 這個時候就不能恣意變更原來的資料格式,因為另外一支程式並不隨時在自己的掌握之中; 甚至大部份的時候這所謂的另外一支程式並不是別人開發的程式,而是你自己所開發的程式, 也就是同一個程式的不同版本。 比如說你自己開發並發佈的一組 Client-Server 架構程式, 在後續的維護中你可能很難同時更新所有的伺服器和客戶端程式到最新版本, 此時就會產生不同版本 Client 和 Server 的溝通問題。 又比如說你自己開發的程式會儲存一些檔案, 那你又會面臨這個檔案能不能被新版程式辨識、或者檔案能不能被舊版程式正確解析的問題。
因此,本文將著重在資料格式的相容性、擴充性上面,介紹好用的 Type-Length-Value (TLV) 格式。 但為了能夠更理解這種格式的特別之處,在介紹 TLV 之前,我們還是先回顧傳統上最常見的資料格式。

傳統的固定欄位格式

對於一個二進位的資料包,以一個簡單的自訂聊天訊息為例,我們常常可以見到像這樣的格式敘述:
  • 一個聊天訊息封包為 16 位元組長。
  • 第 1 個位元組到第 2 個位元組為訊息發送者 ID。
  • 第 3 個位元組到第 16 個位元組為訊息內容。
當然,上面的格式敘述可能過於精簡,還缺乏諸如 ID 的 endian 格式、訊息內容是否為以 NULL 為結尾的字串等等, 但由於本文的目的不在於做出一個可以傳送聊天訊息的程式,因此相信適度的簡化一些不必要的內容是可以接受的。 若把上面的訊息格式以圖表的方式表示的話,可能就會像這樣:
在這類的格式中,資料中的每個欄位內容都出現在固定的位置上。 它的由來其實很簡單,因為這樣的資料和程式在記憶體中所處理的資料格式是一樣的, 以 C 語言來說,上面的格式其實就等同於下面這樣的資料結構定義:
    struct talkmsg_t
    {
        uint16_t sender;
        char message[14];
    };
這樣,程式只要把這個結構的資料整包送出去、整包收下來,就可以了!
固定欄位格式還有另外一種變形,會在某些欄位前加上欄位資料的長度,以便利不定長度的資料包裝。 我們可以把前述的聊天訊息格式稍加修改,讓文字訊息的部份變成不定長:
這種格式會需要解析資料的一方稍微辛苦一點,它們可能需要逐個欄位解析資料,而無法像先前一樣把整個結構複製過來。 因為在不定長欄位資料解析出來以前,無法知道下一個欄位資料的起始位置(如果還有下一個欄位的資料的話)。 然而,這種帶有變長欄位的格式,我仍將之歸類為固定欄位格式,因為他們的許多特性相似,特別是缺點和限制。

固定欄位格式的限制

固定欄位格式很簡單也很容易使用,但卻也存在最致命的缺陷,那就是難以應付未來的變化! 以上面所舉的範例,假設第二個含有變動欄位的格式是第一個格式的改良格式, 而網路上已經散佈出去的聊天程式又不可能一次全部更新(更新總有先後,甚至有些人可能打死不更新), 那麼就會產生新舊版本程式的溝通問題。 如果一個新版本程式收到了舊版程式所發出的訊息,那麼:
由上圖可以看出,資料 “He” 被當成欄位長度來解析,訊息完全被錯誤解讀,甚至解出來的訊息可能超過封包長度,產生緩衝區過讀的問題! 同理,當舊版程式收到新版程式所發出的訊息時也會遇到同樣的情況。 何況實際上一個程式在被使用的時間裡通常不會只更新一個版本, 隨者資料格式版本的變化,我們就必須面對各種版本格式的溝通轉換問題。
當然,一種簡單也常見的解決方法就是當軟體第一次發佈以後就不要再變更資料格式。 這時,制定第一個資料格式的人壓力便非常龐大,因為他必須要設想到所有未來的使用需求。 然而依據經驗,對於稍大一點的資料結構,沒有人能夠在一開始就定下能夠傳用千年的格式。 最終,嚴格奉行不可變更格式的準則往往只會在未來成為程式改良與發展的枷鎖。

固定欄位格式對增加擴展彈性的解決方案

有鑑於不變更資料格式的不可行,在未來的某個時間點可能需要加入新的需求(新的資料欄位)就成為一種普遍的需求。 為了使固定欄位格式能夠容納新的資料,傳統上主要有三種手段最常被使用。

保留欄位

這種方法很常見,就是在原有的資料格式中保留一些「足夠的」欄位空間給未來可能新增的資料, 並且這些欄位通常會被要求以某種預設的資料填滿(常見為零或空白),如下圖所示: 如下圖所示:
這樣,當未來有新的欄位需求時就往原來的保留欄位塞就對了! 以同一個範例為例,假設我們現在需要在每個聊天訊息加上訊息傳送時間:
當這個訊息被舊的程式接收時,舊的程式能解出它所認得的欄位,而忽略掉新增的資料; 當新的程式收到舊程式傳送的資料時,便會知道某些欄位的資料或無意義、或沒有資料、或為預設值。
這種預先配置保留欄位的做法中,最困難、也最神奇的地方就在於決定一個「適當大小」的保留空間。 如果保留空間預留的太少,隨著新欄位的加入,一段時間後就會面臨沒有空間能再加進新欄位資料的窘境。 那保留空間就是留愈多愈好嗎?答案是否定的! 若一開使為資料格式留下超大保留空間,則每一筆資料將要為了這些未來不曉得能不能用到十分之一的垃圾資料撐的肥大! 決定保留空間的最適當大小需要很多經驗、哲學、和美感,沒有一個標準答案。
固定欄位格式加上保留空間雖可舒緩新增欄位的需求,但卻無法刪減欄位, 事實上正是因為我們不去更動原有的欄位,才使得舊的程式能夠判讀這些資料。 而這就引發了一個問題:資料格式可能會在未來的新功能需求上攜帶很多無用的垃圾資料!
假設我們的聊天程式大受歡迎,使用者眾,大家都可以戶傳訊息, 這時有些人就會受不了訊息轟炸,希望只有部份的好友被允許傳送訊息給自己。 這時我們需要在原來的訊息格式裡加上「加入好友請求」、「加入好友回應」功能的相關資料。 更進一步,如果我傳送訊息給一個沒有加我為好友的人,我還希望在訊息被拒絕時可以收到「訊息被拒絕的通知」, 讓我不用痴痴地等對方回應。 最終,為了傳送加入好友的需求,我們可能需要傳送一方的暱稱、 為了回應加入好友的要求,我們可能需要一個接受或拒絕好友的旗標、 為了傳送訊息被拒絕的通知,我們可能需要訊息回應碼、和錯誤原因, 最後,我們的訊息格式會變成下面這樣:
就這樣,雖然暫時解決了迫切的需求,但我們的資料格式愈來愈複雜,處理起來愈來愈消耗精力。 並且你會發現當我們在傳送其中一種訊息的時候,其它的欄位根本只是佔空間而已。 當傳送一般聊天訊息時只用到 Sender、Message、Time 等 3 個欄位, 而回應時只用到 Sender、Time、Fail Flag、Reason; 要求加好友時只需要 Sender、Time、Name, 而回應時只需要 Sender、Time、Result。
我相信如果能夠重新設計一個可以用來滿足這些命令的資料需求的話,一定可以設計出更簡潔典雅的格式, 但因為大量的程式已經被散佈,使得這個方法不可行。 事實上依據經驗,非常多(我沒有做全面的統計,不能給出準確的數字) 的程式會在被使用一段時間以後需要加入原本所沒有設想到的工作種類, 並且這件事情經過一段不長不短的時間就會重複一次。

重用資料欄位

有鑑於許多新增的功能往往用不到舊功能所使用的資料欄位, 於是把這些欄位拿來在新的功能上重複利用就成為一種節省空間的方法, 以上面的功能增加範例來說,若不是只增加新欄位,而一部份採用欄位回收概念來設計的話, 資料格式可能會長成像下面這樣:
這樣,一來節省了許多可供未來使用的保貴保留空間; 二來格式解析器不需要做太大的變更,因為大部分的欄位格式仍是一樣的,表面上你只要把解出來的資料另做它用就好。 只不過我們的格式文件可能需要寫上更多的說明,比如說:
  1. 當 message type 欄位為零時表示這是一個普通的聊天訊息;
  2. 當 message type 為 1 時表示對方請求加入好友,此時的 message 欄位資料實際上是對方暱稱;
  3. 當 message type 為 3 時表示拒絕訊息發送, 此時的 time 欄位實際上不是時間而是錯誤碼、而 message 為錯誤訊息… 等等。
這種重用舊欄位的方法也有一些限制,你往往不能為每個新增的欄位找到舊的可用欄位對應,有時仍免不了需要往保留區塞資料。 除此之外它還有一些強烈的副作用,隨著程式的發展,格式資料的解析規則會變得愈來愈複雜。 並且它會迷惑你的程式開發人員,使這種格式成為蟲子的溫床,特別是在那些有一定複雜度、一定年歲的資料格式上。 以程式設計的觀點來看,這其實就是犯了變數名稱誤導的錯誤!

檢查格式版本

除了上面兩種最常見的資料擴充設計之外,還有第三種比較少見的手段,就是在資料格式裡加上記錄資料格式版本的資訊。
這樣,每一個版的資料格式都可以隨心所欲的發揮,它只要求需要俱備相同格式的資料標頭而已。 當程式收到訊息時,會先從標頭中檢查資料所使用的格式版本,若這個版本與程式所知到的版本不同,則它可以:
  • 直接拒絕不認識的(不論新舊)資料版本。
    這種做法最簡單了,但也很容易被使用者抱怨,通常只有在大版本更新的時候才會這樣對舊的格式直接不認帳。
  • 若發現資料版本比較舊,則將該版本的資料轉換為程式現行的格式再處理。
    這種做法對舊版程式的相容性會比較高,但對程式人員的額外負擔會很大, 程式人員需要小心處理每一個版本的轉換工作。 並且隨著版本的更新,不同版本間的大量的轉換工作還可能會消耗計算效能。

TLV 資料格式

在重新溫習傳統的固定欄位資料格式後,現在就來看看一種新的想法如何能夠更好的解決資料版本變更的問題。
如果說一個資料封包是由許多的資料欄位所構成的話,對於資料相容性的最大問題就是要正確的解析出所需要的資料欄位。 傳統固定欄位格式依賴資料欄位的絕對位置來識別資料,所以已定義的資料欄位絕不可做更動; 變動長度的資料欄位格式依賴資料的順序來識別資料,所以已定義的資料欄位不可更換位置,也不可取消不帶, 其終究與固定欄位格式一樣依賴著資料的位置來辨別資料。
那麼,另一個想法因應而生:如果我們以可變長欄位為基礎,在每一個資料欄位加上一個用來識別欄位的標籤呢? 這樣資料欄位自己就可以告訴讀取者它是什麼樣的資料,解析者不再需要靠著沒有彈性的位置順序來辨別資料。 而這種格式就是接下來要探討的主題,即 TLV 資料格式。
TLV 就是 Type-Length-Value 的縮寫,也有些地方會寫成 Tag-Length-Value。 TLV 格式的資料的最小單位是一個 TLV 資料元素,一個 TLV 資料元素由三部份所構成, 就是資料類型識別(type)、酬載資料的長度(length)、以及被酬載的資料(value),大概像下圖這樣:
基本上,只要資料元素是以 T、L、V 所組成的格式都叫作 TLV 格式,這是一種統稱。 至於 type 和 length 的詳細格式則由具體的資料格式所定義,較有名氣的有 Simple-TLV 和 BER-TLV 等資料格式。 通常來說,type 和 length 都會是一個整數的型態,甚至可能為變動長度的整數, 以取得較佳的擴展性和較彈性的資料空間消耗。 而 value 就是原來一個欄位的資料,其格式由最終的使用者自定。

元素群組

理想上一個 TLV 資料元素酬載一個我們從前所認知的一個欄位的資料, 所以一個資料包裡原來有多少個資料欄位,以 TLV 來呈獻的話就會有多少個 TLV 元素, 我們把這樣的資料稱為 TLV 群組。
在解析 TLV 群組時,會先解析到第一個元素的標籤,即可得知這個元素是什麼東西。 再往後讀就可以知道元素酬載的資料大小,並把酬載資料讀出。 此時不論解析者認不認得酬載資料的格式,都一樣可以正常的把整個 TLV 元素定位或讀出, 然後再以同樣的步驟讀出下一個 TLV 元素,直到整個 TLV 群組被解析完畢。
因為 TLV 元素可以表達自己是什麼,因此 TLV 元素的順序便可自由調動, 解析者可以準確的判斷資料包缺少什麼欄位,因此新程式可以正確解讀舊資料,或著有些資料可以選擇性的不發送; 由於解析者可以完全忽略不認識的欄位而不影響資料讀取,因此舊程式解讀新資料便不再困難; 甚至只是過手的中間資料處理器,也可以正確處理並轉拋資料,而不用認識所有的欄位資料。

資料標籤

由於 TLV 元素是由資料標籤來識別資料,因此在制定以 TLV 為基礎的資料格式時, 便需要為每一個資料欄位定義唯一的資料標籤,並維護之。 因此這些定義通常會成為一種資料標籤對應表,以前述的聊天程式為例則如:
TagFormatDescription
10132-bits int., B.E.Sender ID
10216-bits int., B.E.Message type
103stringChat message
10432-bits int., B.E.message send time
105stringSender name
10616-bits int., B.E.Response code
107stringResponse message
而此範例中的各種網路命令可能就會變成像這樣:
CommandMessage typeMandatory fieldsOptional fields
Command response1101, 102, 106107
Send chat message2101, 102, 103, 104105
Request firend add3101, 102, 104, 105

資料標籤的定義與管理

雖然許多的特性都章顯 TLV 格式無比強大的擴展彈性,但不代表在我們可以無壓力的隨意定義以 TLV 為基礎的格式, 並且不負責任的說「反正以後可以再改」。 TLV 格式的彈性仍有其限制,最明顯的就是「每一個資料欄位或種類都需要一個唯一的標籤」。 一個標籤被定義並發佈後就固定下來了,一直到世界末日都不能再變更,除非你願意捨棄前後兼容的特性。 因此,賦與每一個新增的資料類型一個適當的標籤就成為一項不可輕忽的工作。 一般來說,定義一個新的資料標籤所需注意之事情,依重要性排序如下:
  1. 標籤號碼的唯一性
    新的標籤號碼不能與過往已定義的任何號碼重複,因此標籤號不能由各個開發人員隨意自訂, 而需由單一的機構、部門、或負責人統一為之,並送交適合的資料庫做管理追蹤。
  2. 勿一種類多號碼
    除了不同的資料種類不可同號外,也應盡可能避免同一種資料類型被定義多個號碼的情況。 若這種狀況發生,浪費號碼空間事小,因為一般的號碼空間都很足夠;但造成後續開發者之疑惑或誤用則事大。 一種類多號碼的情況易發生於老專案新修改、或新舊開發人員交替的時候, 在新需求想要定義新類型,確未查清從前已有相同作用或極其類似的標籤定義時,一類多號就發生了! 要避免此種現象,就必須落實資料標籤的追蹤與管理機制,資料庫不是只用來查詢號碼不重複就沒事了!
  3. 標籤號碼所屬的區間
    這個限制只有在標籤為變動長度時才有意義。 在使用變動長度資料標籤的 TLV 格式中,不同的標籤號碼最終所需的資料空間可能不同, 新的資料標籤座落於何號碼範圍會影響該 TLV 元素所需佔用的資料空間大小。 一般通常佔用空間愈少的標籤號碼範圍愈有限,如此,妥善思考新標籤的號碼範圍分配就成為不能輕視的工作。
  4. 標籤號碼的順序
    有些人「喜歡」類型相近的標籤定義在一段連續的號碼區上、甚至要以某種順序排列, 這時就會需要多花功夫在號碼區間和保留範圍的安排上。

TLV 嵌套

TLV 資料的 type、length 都是在表示其酬載資料的型態、大小等屬性, 至於其酬載的資料本身(value)則是完全留白,由最終使用者自行定義處理。 既然一個 TLV 的酬載資料可以是任意型態,那麼當然也可以是其它的 TLV 群組, 也就是一個 TLV 元素本身可為 TLV 群組的容器,形成一種嵌套關係,示意如下:
TLV 的嵌套最終會讓資料元素形成一個樹狀結構,就好像平常常見的檔案系統一樣,這帶來了很多好處。 舉例說明,假設有一資料格式在不使用嵌套的情況下可能需要定義成這樣:
    |- Tag:100, 產品編號
    |- Tag:101, 製造機臺名稱
    |- Tag:102, 製造機臺編號
    |- Tag:103, 製造時間
    |- Tag:104, 檢驗者姓名
    |- Tag:105, 檢驗者編號
    |- Tag:106, 檢驗時間
    |- Tag:107, 出貨廠商名稱
    |- Tag:108, 出貨廠商編號
    `- Tag:109, 出貨時間
但如果使用了嵌套,則可能變成:
    - Tag:200, 產品資料
        |- Tag:100, 編號
        |- Tag:201, 機臺
        |   |- Tag:100, 編號
        |   |- Tag:101, 名稱
        |   `- Tag:102, 時間
        |- Tag:202, 檢驗
        |   |- Tag:100, 編號
        |   |- Tag:101, 名稱
        |   `- Tag:102, 時間
        `- Tag:203, 出貨廠商
            |- Tag:100, 編號
            |- Tag:101, 名稱
            `- Tag:102, 時間
總而言之,嵌套 TLV 的好處有:
  • 對於一個較大的資料包而言,成百上千的資料欄位可以不用擠在同一個群組下,而可以分門別類歸納至各自的子群組, 使得每一個資料層都是單純精簡的,有助於閱讀理解。
  • 資料的編碼解碼工作可以更容易且適當的分派給子程序,比較不容易出現一個龐大複雜的編碼或解碼程序。
  • 可以減少過於瑣碎的資料標籤定義需求。 由於資料經過了妥善的分配包裝,許多性質相同的資料型態可以使用一樣的標籤定義。

TLV 與 XML

到這裡,有些讀者可能會發現 TLV 用起來和 XML 十分相似,而事實上正是如此。 現存的網際網路資料交換格式裡,有不少使用了基於 XML (或 JSON 等類)的資料格式; 並且 TLV 格式做得到的 XML 也都做得到,甚至擁有更多對於節點屬性的支援。 那麼比起 XML,TLV 有什麼優勢呢?簡單來說就是低複雜性和高效能:
  • XML 是以文字為基礎的資料格式; 而 TLV 是以位元組資料為基礎的格式,有先天上的性能和資料大小優勢。
  • XML 格式編碼、解碼邏輯複雜,一般都使用第三方程式庫處理; 而 TLV 簡單純粹,自己實做編解碼機制很容易,且做出的編解器一般都超輕量。
因此比起 XML (或其他類似的格式),TLV 更適合講究效能的系統、或是嵌入式裝置的應用。

TLV 元素屬性的識別

此外,TLV 嵌套還有一個常見的議題:你怎麼知道一個 TLV 元素所酬載的資料是最終的用戶資料、還是更多的 TLV 元素? 以資料產生者和終端使用者來說,這不成問題,因為它們一定認得自己所需要的資料; 但對於通用型的 TLV 編解器、除錯用的資料窺探器等等應用上則會造成很大的困擾。 為了解析子元素而要在這些東西上實做所有已定義的標籤編解碼機制,需要消耗大量的開發資源、也不切實際; 而若直接給使用者看長長一串的十六進位碼則更失去了原有的意義。
對於辨識 TLV 元素屬性的問題,可以從資料標籤制定規則下手。 大部分實用的 TLV 格式都會要求資料標籤要符合某種規則,以方便通用編解器可以在分析資料標籤時就能判斷該元素是 TLV 容器還是終端資料; 甚至有些 TLV 格式還能區分資料元素更詳細的資料型態,是字串、整數、浮點數、還是時間…等等。 當然,這些機制都是選擇性的要求,如果有使用者懶惰的將所有標籤都註冊為 customised,那麼這也是一種選擇…

分析、比較、與總結

對於 TLV 的介紹說明以大致完成,雖然前面說了一大堆 TLV 的好處,但並非想把 TLV 捧上天。 各種格式都有各自的特色和擅長,獨尊其中任何一種都不是智慧的表現。 接下來我們就以分析前面所提過的三種資料格式(固定欄位、TLV、及 XML)來做為結束。 三種格式比較明顯的優缺點比較如下:
  • 固定欄位格式
    • 優點
      • 單純,編解碼機制極易實現。
      • 快速,因為資料排列與在記憶體中的呈獻可能相同或近似,因此處理速度極快。
    • 缺點
      • 在兼容的要求下擴展性低。
      • 在兼容的要求下調整彈性極低。
      • 在兼容的要求下,經過多次擴展後容易產生垃圾欄位或模糊欄位。
    • 推薦使用時機
      • 程式內部的資料交換。
      • 程式為了記錄某些狀態,只供自己使用、沒有交換需求、 並且在程式版本更新時會一併重設的檔案。
      • 某些極為單純、或極講究效率、並且可能萬年不變的資料格式,比如說 IP 封包標頭。
  • TLV 格式
    • 優點
      • 擴展性極高。
      • 調整彈性極高。
      • 沒有垃圾資料欄位。
      • 相較於 XML 格式,編解碼機制簡單易實現。
      • 相較於 XML 格式,資料編解速度快。
      • 相較於 XML 格式,資料佔用空間小。
    • 缺點
      • 相較於固定欄位格式,編解碼機制實做較費工。
      • 相較於固定欄位格式,資料編解需消耗較多的時間空間等資源。
      • 相較於 XML 格式,資料不易由人眼直接閱讀, 尤其是在沒有對照表工具的輔助下,數字型態的資料標籤不易理解。
    • 推薦使用時機
      • 大部份的資料交換需求皆推薦使用 TLV 格式,特別是在嵌入式裝置上。
  • XML 格式
    • 優點
      • 擴展性極高。
      • 調整彈性極高。
      • 沒有垃圾資料欄位。
      • 以文字為基礎的資料格式易於由人眼直接閱讀,對理解、除錯、或學習等需求很有幫助。
    • 缺點
      • 編解碼機制複雜,不易實做。
      • 編解碼速度慢。
      • 資料佔用空間大。
    • 推薦使用時機
      • 在運算資源豐富的環境上,對於要求人眼可以不透過解析工具而能直接閱讀資料的狀況下,推薦使用。

虹光大成就-密教灌頂(一)