xmppmini 項目詳解:一步一步從原理跟我學實用 xmpp 技術開發 4.字符串解碼秘笈與消息包

    這一節寫得比較長,不過如果您確實要手工解碼 xmpp 消息,還是建議您仔細看看,而且事實上並不複雜。

    登錄成功後,我們就可以收發消息了。消息包的格式大致如下:

1 <message id="JgXN5-33" to="[email protected]" from="[email protected]/Spark" type="chat">
2   <body>hi,你好啊。</body>
3 </message>

    其實消息包也有兼容性問題,最多的就是各個客戶端或者服務器會加入自己的一些擴展節點。其實從簡化協議出發,這些擴展放到消息體本身更好,還可以兼容其他通信協議,我們 xmppmini 項目就是這樣做的。不過這都是後話,我們目前的當務之急是收到消息時如何解碼這個消息包。
    先給大家一個驚喜和定心丸:我保證只要一個函數就可以完成這個解碼。真的!我們先來看看傳統的消息解碼是怎樣的。一般的消息流解碼,特別是 xml 或者開發語言的源代碼解析用的比較多的會先從位元組流中分隔出各個節點,然後根據各自的規則形成一個樹形結構。這種做法一是比較複雜,從開源的角度來看,個人開發者去實現太耗時了,這也是為什麼只要是涉及到編解碼的一般都會上第三方庫。另外還有一個非常大的缺點,就是 xml 解碼的二義性太嚴重了,包括 json 解碼庫也是,包括很多著名的庫直到現在還是有很多特殊情況下無法正確解碼的情況。在寫這篇文章不久前我還看到一個 golang 開發組的 bug 報告,就是關於某種情況下還無法正確解碼 xml 的情況。
    另外還有一種常見的方法就是上正則表達式,我個人是非常鄙視正則表達式的。先不說它的解碼錯誤和二義性也很嚴重,那一串每次重寫都要去查一下正則語法的規則字符串,我一看就倒胃口,可以說用正則表達式實現的代碼維護性是非常差的。實際上這些看上去貌似很複雜的字符串,只要用一個字符串分隔函數就可以實現。下面我們就來具體介紹。
這個函數是我在多年前編寫郵件客戶端程序 《eEmail》 時實現的,目的就是用來解碼 smtp/pop3 的消息。其實也是可以用於 xmpp/xml 包的。而且原理簡單易懂,非常值得給大夥仔細介紹一下
    首先,我們考慮下收取到以下格式的消息時如何取得裏面的內容:

1 key=value
2 Key=value
3 key=value;
4 Key=value;

注意 「key」 中有大小寫的情況,因為這在網絡包中是非常覺的現象,各個實現對某個標誌的大小寫並不一致。另外再注意 「value」 後有時會有 「;」 符號。有 web 前端開發的讀者應該非常熟悉這種情況了。

而我們要設計的這個處理函數需要達到以下的這種效果:

1 get_value('key=value', '=', ';');  //應當為 value
2 get_value('key=value', '', '=');   //應當為 key
3 get_value('key=value;', '=', ';'); //應當為 value
4 get_value('key=value;', '', '=');  //應當為 key

    實際上就是取兩個分隔符號之間的字符串,而這兩個分隔符號還不是相同的。同時就考慮了沒有第一個分隔符號或者是沒有第二個分隔符的情況。大家先不用思考以上的結果,因為確實這時候還是有點複雜,特別是沒有某個分隔符的情況下是比較難處理的。
    最初我設計出這個函數後很是好用,基本上再配合一些常規的字符串查找、切割函數就可以解決解碼問題了。但這個函數有個問題,就是設計得太過精巧,當多年後 golang 語言出現,我要移植代碼時發現,當年的處理思想我已經忘記了,重新再寫一次的時候,處理的結果並不完全一樣!這顯然不合適,因為這個 xmpp 的庫還得出 C#、java、純C等等多個版本,我自己都實現不好,還怎麼介紹方法給別人實現?因此苦苦思索怎樣切分成幾個簡單邏輯的處理函數去組合完成相同的任務。經過近兩天的折騰,功夫不負有心有,我確實發現它的處理過程可以拆分成幾個簡單的函數。更棒的這些簡單函數最後再都可以用一個更簡單的函數來組合完成。
    這層窗戶紙捅破一點也不稀奇,只要一個最簡單的字符串分隔函數就可以了。比如將字符串 「123abc456」 分割成兩個字符串 「123」、」456」 就可以了。不好!眼尖的讀者一定發現了什麼。您這個不就是字符串分割函數嘛,不用寫啦,所有的開發語言幾乎都有嘛!沒錯!不過這些分割函數是有很多問題的,而且相互之間居然也有兼容性問題!
    以我最早實現的 Delphi 版本和最後實現的 golang 版本為例。Delphi 的分隔函數默認情況下會在你不知情的情況下把空格、tab字符也當做分隔符號。所以當您用它的默認字符串分隔函數時會出現很多意料之外的結果讓你苦苦調試而不得其所以然。而 golang 的分隔也有這樣的問題。還有些語言是用正則表達式實現的,其結果有時更是天馬行空。原因其實也很簡單,因為它們這些分隔函數本身是用來分隔多個字符串片段,而且還考慮了常用的分隔符號的情況。而我們需要的是一個明確的只分隔指定分隔符,而且只將字符串分為兩段的函數。
    再考慮以下字符串:

1,2,3,4,5

    經過我們自己寫的函數後它需要分成 「1」、」2,3,4,5」 兩個部分。而如果是 golang 的默認實現就有可能是 「1」、」2」。而後面的不見了,因為它把第二個 「,」也當做了分隔符。
實際上我們要實現的這個字符串分隔函數功能更簡單,它只用處理第一個分隔符就行了。所以手工實現是非常簡單的,任何一個程序員都可以做到。
    具體的實現那就很簡單了,先在源字符串中查找分隔符字符串的位置,然後切割後再來去掉分隔符號本身就可以了。這隻要利用開發語言都會有的字符串查找和分隔功能就可以完成了。非常的簡單,偽碼如下。不過要注意的是不同語言對字符串位置的表達並不完全一樣,大多數語言將字符串的第一個起始位置定為 0 ,而有些則是 1;在切割字符串的時候也要注意,有些開發語言在長度超過或者不足時會做出不同的處理,有些返回整個字符串,有些返回空,有些則是有多少就返回多少。具體的就需要大家實現時多留心了。

 1 //一個字符串根據分隔符的第一個位置分隔成兩個
 2 void sp_str_two(string in_s, sp, string out s_left, string out  s_right)
 3 {
 4   //開始複製的位置
 5   Int find_pos;      //查找到的位置
 6   Int left_last_pos; //左邊字符串的最後一個字符的位置
 7 
 8   find_pos = pos(lowercase(sp), lowercase(in_s)); //不要區分大小寫
 9 
10   if (Length(sp)<1 ) find_pos = 0; //沒有分隔符就當做沒找到處理
11 
12   if find_pos <= 0        //沒找到分隔符號,立即返回,這時左邊是原字符串,右邊是空字符串,類似於分隔成數組後的 【索引1】 和 【索引2】 中的內容
13   {
14     s_left = in_s;
15     s_right = '';
16     return;
17 
18   };
19 
20   left_last_pos = find_pos - 1; //因為結束符號本身是不需要的,所以查找到的位置向前移一位才是我們要的最後一個字符
21 
22   //取左邊
23   s_left = copy(in_s, 1, left_last_pos); 

/* 因為delphi 字符串位置是從 1 開始計算的,所以字符所在的位置就是包含它的整個字符串的長度了,不需要再加 1 或者減 1 這樣的計算
其它的語言要根據實際情況修改這部分代碼。大多數開發語言一般是要從 0 開始計算字符串位置的。 */ 24 25 //---- 26 //取右邊 27 find_pos = find_pos + (length(sp)); //起始位置還要跳過分隔符號的長度 28 s_right = copy(in_s, find_pos, length(in_s)); //先去掉起始分隔符號之前的部分(分隔符本身也不要) 29 30 }

    這裡有個地方是值得注意的:這樣分隔出的字符串是不再包含分隔符了的。但在實際的工作中,其實有時候是需要帶上分隔符號的。我本想加上一個默認參數來決定是否在結果中帶在分隔符。但在實際工作中發現這樣並不方便,首先多了一個參數,你在工作中看到這個函數時都會中斷一下斷思路心想這其中的區別(雖然很細微的停頓)。這在行雲流水的工作過程中是個大忌(至少對我來說)。再說了,現在新興的語言比如 java、golang 等為了避免二義性是不支持默認參數的。當然可以再拆分成多個函數來解決,但這樣的話打斷思路的問題仍然是存在的。所以最後我決定還是保持它的簡單性,分隔的時候我們肯定是知道分隔符是什麼的,在需要的地方再給它加回去就行了。雖然這種方法看上去有點傻,不過在實際的開發中得以保持了思維邏輯上的清晰性和簡單性。
    有了這個函數,可以很容易的實現出取一個字符串分隔符左邊部分的函數,以及一個取字符串分隔符右邊部分的函數。偽碼如下:

 1 //將字符串分隔成兩半,不要用系統自帶的分隔字符串為數組的函數,因為那樣的話無法處理字符串中有多個分隔符號的情況
 2 //這個函數是在字符串第一次出現的地方進行分隔,其他的地方再出現的話不再理會,這樣才能處理 xml 這樣標記多層嵌套的情況
 3 //b_get_left 取分隔後字符串的左邊還是右邊
 4 string sp_str(string in_s, sp, bool b_get_left)
 5 {
 6   String s_left;         //左邊的字符串
 7   String s_right;        //右邊的字符串
 8 
 9   sp_str_two(in_s, sp, s_left, s_right);
10 
11   //----
12   result = s_left;
13   if (False = b_get_left)  result = s_right;
14 
15   return result;
16 };
17 
18 //分隔字符串取左邊
19 string sp_str_left(string in_s, sp)
20 {
21   return sp_str(in_s, sp, true);
22 
23 }
24 
25 //分隔字符串取右邊
26 string sp_str_right(string in_s, sp)
27 {
28   return sp_str(in_s, sp, false);
29 
30 }

好了,最後我們要實現 get_value() 函數本身了。這裡是要特別注意的。有了前面的基礎函數後,要實現 get_value() 也是很簡單的。但完成後一定要用前述的函數操作預計的結果作為測試用例來測試一下,以下的代碼中調用順序細微的變化就可能引起結果的不同。代碼如下:

 1 string get_value_sp(string in_s, b_sp, e_sp)
 2 {
 3   Result = in_s;
 4 
 5   if (Length(b_sp)<1) //左邊分隔符號為空就表示只要右分隔符號之前的
 6   {
 7     Result = sp_str_left(Result, e_sp);
 8     Return result;
 9   };
10 
11   if (Length(e_sp)<1) //右邊分隔符號為空就表示只要左分隔符號之後的
12   {
13     Result = sp_str_right(Result, b_sp);
14     Return result;
15   };
16 
17   //兩者都有就取分隔符號之間的
18   Result = sp_str_right(Result, b_sp);
19   Result = sp_str_left(Result, e_sp);
20   //Result = sp_str_left(Result, b_sp);
21 
22   return result;
23 }

    有了這些函數後,讓我們來看看如何簡單的就可以解碼文章最開始時的那個消息包。
    首先我們要確定字符串中已經包括完整的消息包。這個用前幾章中的函數直接 FindStr()查找是否包含有子字符串 「/message>」 就可以了。
    第二步,確定緩衝區中的內容含有完整消息包,就可以直接調用 get_value() 取得消息包了。

1 s = get_value(gRecvBuf, '<message', '</message >');
2 
3 msg = get_value(s, '<body>', '</body>');

這時 s 的內容就是

「
id="JgXN5-33" to="[email protected]" from="[email protected]/Spark" type="chat">
  <body>hi,你好啊。</body>

而 msg 的內容則是

「
hi,你好啊。
」

要注意的是第一個調用位置的起始分隔符號是 「<message」,而不是 「<message>」 ,這是因為 message 包中還附帶有屬性節點。而這些地節點不存在的情況下,用分隔符「<message 」也一樣能取得需要的字符串。這些節點包括發送者的地址,使用 get_value() 函數也很容易取得:

1 from = get_value(s, ' from="',  '"');

    大家要仔細看這行代碼,第一個分隔符之前是必須有加上一個空格。因為不加的話就可能取到 「afrom」或者「bfrom」這些節點的內容。
    可以看到我們很容易的就解碼了這一 xmpp 的消息節點。因為 xmpp 的消息比較規範整齊所以這樣處理就可以了。如果是用來解碼手寫的 xml 文件的話則可以加上一些預先處理:比如去除連續的空格;將 tab、回車、換行轉換為空格等等,當然還要考慮 「message」 有多層次的情況。其實也都不難,不過 xmpp 中並沒有這種情況,我們就按下不表了。
    這種解碼方式其實還有一個問題:就是解碼效率。主要是字符切割再分配內存會影響一些處理速度。這裡一來我們主要是說原理,二來讀者大部分肯定開發的是客戶端,沒必要太優化執行速度。如果是服務端的開發者,那麼優化的方向就是直接實現出 get_value,不過如果是我本人優化我不會改用這種方式,因為我覺得代碼可維護性更重要。如果是 C 語言,可以將以上用的函數都改為不需要再分配內存的版本,全部用指針來實現。類似於 golang 中的切片操作是基於同一塊內存的原理。
    說到優化,忍不住有一些有趣的事情與大家分享。早年我們剛開始學習編程和計算機時,一提到優化實際上大多數指的是對編譯出來的代碼的優化,那時候的優化大多會說什麼換哪個彙編指令或者函數改內嵌會加快代碼執行等等這樣的。特別是看到那些折騰彙編的,一下子感覺這種工作距離自己好遙遠。這主要是由於相關資料太少了,有也得全英文,對母語非英語的開發要去改動彙編優化代碼,可能性真的很小。工作多年後在工作中發現,其實不是這樣的,實際上一個算法或者處理方法的改動就有可能讓代碼執行速度有千百倍的躍升!真的,而且我還是按保守說的。舉一個最簡單的例子,在國內(其實國外也是)很多開發並不是計算機軟件或者相關專業出來的,有個非常常見的問題就是他們不知道什麼是二分查找(甚至沒聽說過),這就讓他們在設計數據庫和容器數據結構時不明白索引和排序的重要性。在設計時就常常忽略掉,而給系統(特別是服務器類型的系統)加一個簡單的二分搜索就能指數級的提高性能。這些算法大多是固定的,比如有網友分析 nginx 源碼時就說其中的紅黑樹算法(不太記得了,總之是一種二分樹)與經典教程中的一模一樣。這種類型的模塊是就象彙編一樣,不太可能去修改它的 – 你覺得你會寫出一個比快速排序更快的算法嗎?當然不是說這完全不可能,而是說我們的日常的開發中代碼優化的角度不應當放在這個地方。但也不是完全就要按傳統的來,再舉 nginx 的例子,它的列表容器並不是傳統的鏈表,而是分出的一大塊內存,在裏面存指針。這在 Delphi 中也是一樣的,當年我查看到 Delphi 的這部分代碼實現時驚訝得不得了,因為從來沒有見過或是聽說過是這樣實現列表的。這種列表在數量量不大(1萬以下)時,速度非常驚人,因為整塊操作這塊內存就是對整個列表進行操作了 – 多個操作只需要一個內存複製代碼。但多年後我負責重新維護一個 Delphi 版本的服務器時發現數目到 2 萬這個級別時性能會急劇下降,這時候想在裏面刪除一個元素會非常慢 – 因為這時候這塊內存已經太大了。Nginx 的解決辦法很簡單,它又回到了傳統算法上來 – 如果數目太多,它就再分配一塊內存,用鏈表連接起來,這樣它同時得到了二者的好處。不過最後我並沒有用 nginx 的做法,一來是複雜了點,更重要的是我當時只需要優化刪除的情況。我的做法是將最後一個元素的位置與被刪除者交換就可以了,因為總數已經減小了1,這個被移動到最後的元素是永遠不會被訪問到了的。我舉的這些例子是想告訴大家,優化沒有那麼難,大膽地去做。同時也要多學習更多專業的知識;同時也要明白自己不能做什麼;同時也要明白,雖然有很多現在我還不能做的,但在我能做的範圍內同樣是能讓性能成百上千萬倍的提升的。
    讓我們回到字符串優化的問題上來,為什麼「專家」們操作字符串時都會說在同一塊內存上操作,不要用多個內存加來減去?大多數開發是知道這個優化方式的。不過原理是什麼呢,大多數人就不清楚了,而且更多的人不會知道,系統對內存分配上其實也是做有很多優化的,所以很多時候也不用太擔心。學過操作系統,或者對操作系統運行有一定了解的應該會知道,分配內存就是操作系統的一項重要的基本操作。大家不知道的是,即便是發展到了這個時代,操作系統分配內存的速度其實真的不快。在開發語言中(至少 C/C++、delphi 肯定是)都是先取一大塊內存,再在程序需要分配時提供的。相當於用自己的內存分配算法來代替了操作系統提供的內存分配函數。甚至有好幾個內存分配的 C/C++ 開源項目,目的就是為了提高 malloc/new 操作的速度而已,可見提高分配內存速度的重要性。這當然也會造成不同系統下的速度可能會有很大差異,既然這麼困難,那我不分配內存不就是最快的了 – 沒錯!這就是字符串操作使用所謂不重新分配內存的 stringbuf 代替 string 的理論基礎。在 java 和新版本的 golang 中甚至有專門的這樣的「字符串緩衝類」。知道了這一點,我們也可以知道,並不是所有的地方都需要替換,不會產生頻繁操作內存的地方也沒那個必要。而且現代的字符串實現中其實已經帶有緩衝了。
    大家聽明白了嗎,其實我想說的是,一般我們的開發環境中對內存分配已經做有優化,而且字符串也帶有一定的緩衝,所以我們的代碼中直接用 string 其實問題也不是太大。
    說到內存分配管理,忍不住再分享一個故事。還是多年前,我供職於一家自稱是國內數一數二的期貨軟件供應公司 – 它們的自稱有可能是可信的,因為我之前在另外一家自稱同行業號稱第一的公司里聽說過它們的軟件。有一天他們需要給程序加上先分配內存的功能,原因是他們的客戶會運行很多的客戶端,這時候在多個客戶端切換時有可能會提示內存不足。如果恰好輪到我們的客戶端時客戶就會投述說,你們的客戶端怎麼彈出對話框說內存不足了 … … 好了,為了避免這種情況我們老闆要求程序一進去就先把需要的內存都撈到手。這個看似無厘頭的功能其實是可能實現的,研究了一番後我發現也不難,只要重寫內存分配器就可以了。其實也不難,大概沒幾天吧就弄好了。但是有個大問題:速度太慢,說真的至少慢了 10 到 100 倍,特別是內存使用量大了以後。最後的解決辦法是仔仔細細研究了原版的內存分配器,其實就是按內存用量的大小統計大概在哪幾個區間,然後對用量比較大的區間分配好固定大小的好幾種內存塊就行了。而塊間的連接也是最簡單的雙向鏈表。其實折騰時間最多的就是內存區間的尺寸,比如第一檔應該是 100k 還是 1m 這樣的。不能憑想像,得用統計結果進行配置才行。最後這個預分配內存的分配器速度和原版是一樣的(當然我是想讓它更快一些好虛榮一下的,不過確實原版的速度也已經是很不錯了的)。
有趣的是,機緣巧合後來我又回到了這家公司。發現他們看不懂這些代碼,已經放棄了。其實我編寫代碼時是很習慣把思路都全部寫清楚的 – 最主要的是我寫的代碼太多了,生怕自己以後也看不懂 – 我寫的注釋應該還是有用的,至少他們很「輕鬆」的替換回了默認的內存分配機制。
    所以優化與否,要看實際的情況。也要結合自身的能力作出決定和選擇。
    另外還有一點:測試用例真的非常重要。如果沒有以上的測試用例,我在改寫成其他語言時就發現了不那些細微差異造成的錯誤了,這會產生嚴重的 bug !golang 的 mime 解碼模塊源碼中就帶有很多容易出錯的測試用例,這對於這樣複雜的功能的模塊修改是非常必要的,否則你做了一個自以為很重大的改進結果卻產生 bug 時就會留下嚴重的後患。