[實踐] 為原型系統設計簡單的純文字資料交換協議 之二

樑濤發表於2012-03-07

[參考書目]
TAOUP 5:1-3

[目的]
我們新組建了一個軟體公司,LZSoft,針對微型客戶需求快速開發小軟體。為了儘快驗證功能需求可行性、實現複雜度與滿足程度,需要不停地建立流程模型和原型程式、在各種實現上迭代微調。自然在該過程中使用著大量動態程式語言如Perl、Python、Ruby等,同時配合以標準管道化工具如Shell、sed、awk、grep等作為輔助開發手段。由於上述語言和工具間的微妙異構性,必須設計一套合理輕便的資料交換協議,以便在各子程式之間可靠通訊。

[需求]
1. 易讀:人眼可讀、可理解,不需要額外直譯器/格式化工具;
2. 易寫:可用各種標準Linux實用工具生成輸出,也可用標準Linux機上編輯工具(vi/emacs)手寫;
3. 邊界清晰:不同記錄間有明確的邊界,可輕易感知;
4. 無固定結構:不同記錄可以有不同結構,以描述不同應用資料;
5. 語法規則簡潔:任何人、任何程式均能快速理解記憶;
6. 相容能力強:不同語言編寫的程式可簡單地解析/合成記錄;
7. 層級簡單:可以在某種可接受的複雜程度上描述層級結構。

[設計]
考慮到原型系統中開發人員參與度非常高,資訊交換過程最關鍵之處是必須隨時可讀可寫,因此擯棄二進位制資料交換協議,初步定位為使用純文字資料交換協議。

開始時嘗試從現有資料交換協議中選取合適物件,有如下選擇:JSON,XML,CSV。三者均為純文字資訊交換協議,各有利弊:

  1. JSON優點有:
    a)非常輕量與緊湊,協議本身使用一頁A4紙即可描述清楚;
    b)天然具備層級和集合結構;
    c)天然面向Web開發;
    d)可自描述語義;
    e)格式化後人眼讀寫都很方便;
    f)相關外部工具眾多;
    g)有效資訊負載比大。
    缺點:
    a)多條記錄混排輸出時邊界不清晰,難以定位;
    b)緊湊輸出時人眼讀寫困難;
    c)程式設計時需要載入第三方解析/生成庫。

  2. XML優點有:
    a)天然具備層級和集合結構;
    b)可自描述語義;
    c)相容性與支援度最好;
    d)外部工具眾多,成熟、功能良好。
    缺點有:
    a)多條記錄混排輸出時邊界不清晰,人眼難以定位;
    b)手動編寫麻煩甚至困難;
    c)程式設計時需要載入第三方解析/生成庫;
    d)對文字資料限制過於嚴格;
    e)有效資訊負載比小。

  3. CSV優點有:
    a)以行作為記錄邊界,多條記錄混排輸出時便於人眼定位;
    b)可匯入Excel進一步處理,更利於多人協同辦公;
    c)手工編輯方便。
    缺點有:
    a)相容性較差;
    b)外部工具較少;
    c)語法結構稍微複雜;
    d)轉義序列複雜易錯;
    e)程式設計時需要載入第三方解析/生成庫;
    f)缺少自描述語義能力。

由於主要該協議主要使用在原型系統中,強調快速辨識、修改以應對需求和功能的變化,而XML並不合適快速編輯與分析,故放棄;在有限前提下JSON可以使用,但不經過適當格式化,閱讀編輯都相當困難,出於開發效率考慮,同樣放棄;而CSV最大問題有兩個,一是不能自描述語義,二是轉義規則複雜,同樣只適合在有限前提下使用,也不得不放棄;最後,影響三個協議均不能入選的最終理由是,程式語言相容性都不夠高,某些語言使用時必須載入第三方庫,從而產生較強的功能限制或類庫依賴性,導致使用風險。

[結論]
設計一個用於原型系統的純文字資料交換協議,應當具備以下特點:
1. 邊界清晰,任何記錄混排輸出時均可快速識別,不論接收方是人眼還是機器;
2. 結構儘可能簡單,轉義規則能少則少,層級儘可能壓縮在兩到三層以內;
3. 能夠自描述各部分結構的語義,至少要可以聯想;
4. 可以在記錄間隨意插入空行和註釋,幫助定位、補充描述;
5. 程式設計時不必刻意載入第三方庫,使用語言提供的原生函式即可處理;
6. 最好能便利地對映為物件/雜湊表/陣列。

[成果]
最終選擇以下描述的簡單純文字資料交換協議。
1. 以換行(\n,LF)作為記錄邊界,如資料中存在換行,可以用C轉義序列\n替換之,也可以完全禁止使用帶換行的資料;
2. 以管道符(|,BAR)作為欄位邊界,如資料中存在管道符,則以轉義序列\|替換之;
3. 如果有需要,也可以使用製表符(\t,TAB)代替管道符(甚至定製化);
4. 每個欄位由鍵名和值組成,之間以等號(=,EQUAL)分隔,鍵名兩邊可填入可選空白符,必要時值兩邊亦可填入可選空白符;
5. 鍵名中可以包括句點符(.,PERIOD)表示層級結構;
6. 對於陣列元素可以用索引號作為對應層級結構的鍵名部分(每個元素作為一個欄位),也可以簡單地以分號(;,COMMA)分隔後作為單獨一個欄位的值。

[例項]
cmd=test_output|name=Dota|age=4|desc=Cannot say anything.|lang=CN;EN
arr.0=ABCD|arr.1=TEST

[缺點]
1. 如果從物件/雜湊表/陣列中生成記錄,則欄位順序可能是亂序的,擁有相同欄位的物件生成的記錄排在一起時比較好看,反之則相當難看;
2. 資料超過一屏字元數時會折成多個邏輯行,記錄邊界定位難度上升;
3. 有效字元數與總字元數之比偏小,有效資訊負載低。

[典型應用場景]
1. 稠密資料輸出;
2. 配置檔案中的規則描述;
3. 臨時過濾用資料格式。

[Ruby實現]
#!/usr/bin/env ruby
# use encoding : utf-8

################################################################################  
# module : LZSoft::PSV  
# A class parses or generates one line flattened data with fields delimited by bar  
# signs.  
#  
# sample :  
# name=Tom|age=30|gender=male|aka.cn=LiangTao|fond=programming;kidding  
#  
# author :  
# Liang Tao  
#  
# created :  
# 2012-01-02  
#  
################################################################################  

module LZSoft  
    module PSV  
        FLD_DELI = %q{|}  
        NM_DELI  = %q{.}  
        ARR_DELI = %q{;}  
        KV_DELI  = %q{=}  

        NO_ORDER   = lambda { |arr| arr }  
        DICT_ORDER = lambda { |arr| arr.sort{ |a,b| a[0] <=> b[0] } }  

        def to_psv (opts = {})  
            obj = case self  
                  when Hash, Array  
                      self  
                  when String  
                      { self.class.name => self }  
                  else  
                      vars = instance_variables  
                      if vars.empty? then  
                          [ self.class.name, self.to_s ]  
                      else  
                          Hash[ vars.map do |v|  
                              [ v.to_s.sub(/^@/, %q{}),  
                              instance_variable_get(v) ]  
                          end ]  
                      end  
                  end  
            PSV.generate(obj, opts)  
        end # to_psv  

        def from_psv (str, opts = {})  
            obj = PSV.parse(str, opts)  

            case self  
            when Hash, Array  
                self.replace(obj)  
            when String  
                self.replace(obj[self.class.name])  
            else  
                obj.each_pair{ |k, v| instance_variable_set("@#{k}", v) }  
            end  
        end # from_psv  

        def self.generate (obj, opts = {})  
            if not (obj.is_a?(Hash) or obj.is_a?(Array)) then  
                raise TypeError, 'Expect a Hash or Array object.'  
            end  
            _gen_opts(opts)  
            _serialize(obj, opts)  
        end # self.generate  

        def self.parse (str, opts = {})  
            raise 'Expect a String object.' if !str.is_a?(String)  
            _gen_opts(opts)  
            _unserialize(str, opts)  
        end # self.parse  

        private  
        def self._gen_opts (opts)  
            opts[:fld_deli] = opts[:fld_deli] || opts['fld_deli'] || FLD_DELI  
            opts[:nm_deli ] = opts[:nm_deli]  || opts['nm_deli']  || NM_DELI  
            opts[:arr_deli] = opts[:arr_deli] || opts['arr_deli'] || ARR_DELI  
            opts[:kv_deli ] = opts[:kv_deli]  || opts['kv_deli']  || KV_DELI  

            opts[:fld_deli_re] = _gen_deli_re(opts[:fld_deli])  
            opts[:nm_deli_re]  = _gen_deli_re(opts[:nm_deli])  
            opts[:arr_deli_re] = _gen_deli_re(opts[:arr_deli])  
            opts[:kv_deli_re]  = _gen_deli_re(opts[:kv_deli])  

            opts[:fld_deli_tr] = _gen_deli_tr(opts[:fld_deli])  
            opts[:nm_deli_tr]  = _gen_deli_tr(opts[:nm_deli])  
            opts[:arr_deli_tr] = _gen_deli_tr(opts[:arr_deli])  
            opts[:kv_deli_tr]  = _gen_deli_tr(opts[:kv_deli])  

            ord = opts[:order]  
            opts[:order] = \  
                case ord  
                when Proc  then ord  
                when Array then lambda { |arr|  
                    hash = Hash[arr]  
                    rest = hash.keys - ord  
                    [ord, rest].flatten.map{|k| [k, hash[k]]}  
                }  
                else  
                    NO_ORDER  
                end  
        end # self.gen_opts  

        def self._escape (s, deli, tr); s.gsub(deli, tr); end  
        def self._unescape (s, tr, deli); s.gsub(tr, deli); end  

        def self._escape_val (v, opts)  
            t = _escape(v.to_s, opts[:fld_deli], opts[:fld_deli_tr])  
            t = _escape(t, opts[:arr_deli], opts[:arr_deli_tr])  
            t = t.gsub("\n", "\\n").gsub("\r", "\\r").gsub("\t", "\\t")  
        end # self._escape_val  

        def self._unescape_val (v, opts)  
            t = v.gsub("\\n", "\n").gsub("\\r", "\r").gsub("\\t", "\t")  
            t = _unescape(t, opts[:arr_deli_tr], opts[:arr_deli])  
            t = _unescape(t, opts[:fld_deli_tr], opts[:fld_deli])  
        end # _unescape_val  

        def self._gen_deli_re (deli);  
            str = "(?<![\\\\])[#{deli}]"  
            %r{#{str}}  
        end # self._gen_deli_re  

        def self._gen_deli_tr (deli); %Q{\\#{deli}}; end  

        def self._create_obj (obj, k)  
            (not obj.nil?) ? obj :  
            (k =~ /^\d+$/) ? []  : {}  
        end # self._create_obj  

        def self._unserialize (str, opts)  
            obj = nil  

            str.split(opts[:fld_deli_re]).each do |v|  
                key, val = v.split(opts[:kv_deli_re], 2)  
                val ||= %q{}  

                vals = val.split(opts[:arr_deli_re]).map{ |v| _unescape_val(v, opts) }  
                keys = key.split(opts[:nm_deli_re]).map{ |k|  
                    t = _unescape(k, opts[:nm_deli_tr], opts[:nm_deli])  
                    t[-1] == %q{:} ? t[0..-2].to_sym : t # process symbol  
                }  

                obj = _create_obj(obj, keys[0])  
                tmp, prev = obj, keys.shift  

                keys.each do |k|  
                    prev = prev.to_i if tmp.is_a?(Array)  
                    tmp = tmp[prev] = _create_obj(tmp[prev], k)  
                    prev = k  
                end  

                prev = prev.to_i if tmp.is_a?(Array)  
                tmp[prev] = case vals.size  
                            when 0 then %q{}  
                            when 1 then vals[0]  
                            else        vals  
                            end  
            end  

            obj  
        end # self._unserialize  

        def self._do_serialize (obj, opts, segments, prefix = %q{})  
            old_prefix = prefix  
            prefix += opts[:nm_deli] if not prefix.empty?  

            case obj  
            when Hash  
                obj.each do |k,v|  
                    k = k.is_a?(Symbol) ? k.to_s + ':' : k  
                    k = _escape(k.to_s, opts[:nm_deli], opts[:nm_deli_tr])  
                    _do_serialize(v, opts, segments, "#{prefix}#{k}")  
                end  
            when Array  
                need_concat = true  
                tmp = []  

                obj.each_index do |i|  
                    v = obj[i]  
                    _do_serialize(v, opts, tmp, "#{prefix}#{i.to_s}")  
                    need_concat = false if v.is_a?(Hash) || v.is_a?(Array)  
                end  

                if need_concat and not old_prefix.empty? then  
                    segments.push([ old_prefix,  
                        tmp.map{ |v| v[1] }.join(opts[:arr_deli])  
                    ])  
                else  
                    segments.concat(tmp)  
                end  
            else  
                segments.push([ old_prefix, _escape_val(obj.to_s, opts) ])  
            end  
        end # self._do_serialize  

        def self._serialize (obj, opts)  
            segments = []  
            _do_serialize(obj, opts, segments)  
            opts[:order].(segments).map{ |v|  
                v.join(opts[:kv_deli])  
            }.join(opts[:fld_deli])  
        end # self._serialize  
    end # module PSV  
end # module LZSoft  

相關文章