1. 程式人生 > >xmppmini 專案詳解:一步一步從原理跟我學實用 xmpp 技術開發 4.字串解碼祕笈與訊息包

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 時就會留下嚴重的後患。

&n