clq

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

    这一节写得比较长,不过如果您确实要手工解码 xmpp 消息,还是建议您仔细看看,而且事实上并不复杂。


    登录成功后,我们就可以收发消息了。消息包的格式大致如下:

1 <message id="JgXN5-33" to="clq@127.0.0.1" from="ccc@127.0.0.1/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="clq@127.0.0.1" from="ccc@127.0.0.1/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 时就会留下严重的后患。

 

posted on 2020-11-06 16:03  clq  阅读(419)  评论(0编辑  收藏  举报