蛙蛙推荐:自己写个IIS玩-协议解析篇
这里不是说用System.Web.Hosting.ApplicationHost和System.Net.HttpListener做的那种web server,而是直接用socket api做一个简单的能收发HTTP包的网络服务器,当然也不会完全实现RFC 2616,主要学习探索用。
我们先来看HTTP协议解析部分,做一个HTTP协议栈-HttpStatck,大概看一下HTTP协议基础,
1、消息头和消息体中间用两个\r\n(0x0d0x0a)来分割,
2、消息头之间用\r\n分割,
3、消息头的个数不定,但有最大数,
4、消息体的大小根据Content-Length头来确定,
5、消息头的名字和值用英文半角冒号分割
6、消息头的第一行用来标识协议是request还是response,及协议的版本,请求的方法,应答码,应答描述
协议了解了,协议栈就好写了,如果我们能一次读取一个完整的包,那我们把整个包读出来,解析成字符串,然后用IndexOf,Split等函数很快的就能解析出一个个都HttpRequest和HttpResponse,但是真是的网络中,你可能只能解析到半个半个多包,没准连消息头的第一行都分两次才能接受到,甚至像一个中文字符也有可能会收两次才能包才能解析成字符串。我们要想提高效率,尽量避免把bytes解析成字符串,另外我们只解析出header给上层应用就行了,body的话暴露成一个Stream就行了,因为你不知道Body的格式,由应用去做处理吧,asp.net也是这样的,有对应的InputStream和OutStream。
下面是具体的性能方面的分析。
1、在Stack收到异步读取的网络包后,首先继续调用BeginReceive方法,然后再解析收到的包,这是为了防止在解析包的时候出错,或者线程挂起而造成无法接受剩下的包,当然每次尽量多读取一些字节,读取次数多也会降低性能,buffer可以设置的稍微大一些,这个可能要经过具体平台的测试才能确定最合适的值。这点有不同意见,说不要在刚收到异步读取回调后就先BeginReceive,应该把包收完再BeginReceive,否则如果本次没收完包,剩下的包只能在其它的IOCP线程里接收,影响性能,这个我不确认,但是一次接受完缓冲区的所有数据是可以做到的,用Socket.IOControl(FIONREAD, null, outValue)或者socket.Available可以获取接受缓冲区有多少数据,然后把这些数据收完;但是微软反对使用这些方法去探察socket的接受数据大小,因为执行这个方法系统需要内部使用锁锁定数据计算这个值,降低socket效率。关于接受包这里的最佳实践,欢迎大家讨论。
2、按理说收到包后先放队列里,再调用解析包方法,解析包的方法顺序从队列里取包解析,但解析包和接受包可以都在一个线程里,没有必要引入单独的解析包线程,最后还是考虑不使用队列,每次直接把收到的字节数组进行解析。原则是我们尽量让一个线程只适用本线程的私有数据,而不去用全局共享的数据,如果要使用别的线程的数据,就给那个线程发个消息,让那个线程自己去处理自己线程的数据,而不要直接操作不属于自己的数据,那样的话那个数据就得用加锁之类的线程同步了。线程模型的确定很重要。
3、按理说解析网络包推荐用Encoding.UTF8.GetDecoder().GetChars()方法,该方法会维持utf8解析状态,在收到不能解析成一个完整的unicode字符的包的字节数组的时候它可以保存剩下的半截儿包,和下次收到的包一起解析,而不会造成包丢失。但是该方法的参数只能传入一个char数组,然后我们有可能把多个char数组进行内存拷贝,这就浪费了性能,所以不考虑了。如果该方法能把解析出来的char数组自动填充到一个字节环形链表里,我们就可以考虑用它。我们尽量使用.NET自己提供的功能,但是如果不满足我们的需求的时候,我们就得自己实现去,当然可以反射.NET程序集,借鉴他的做法。
4、我们应该尽量避免把收到的字节数组解析成字符串,然后再按包的规则进行解析,因为把字节数组转换成字符串也是个耗时的过程,像一些解析包的标志位如分割消息头和消息体的\r\n\r\n,分割多个消息头的\r\n,其对应的字节表示值是固定的,如0d0a0d0a,0d0a,我们直接对字节数组进行解析就能区拆出来消息头字节数组和消息体字节数组。
5、对字符串的操作我们可以用正则表达式,用string类的方法等,但对字节数组就没这么多的API了,但是我们可以去了解一下正则表达式的原理,先写出正则正则表达式,再推导出对应的NFA算法,再推导出对应的DFA算法,就可以写出针对字节数组的算法了。典型的场景是我们需要读取到字节数组里的0d0a0d0a的token,或者我们知道了表示消息头的字节数组,我们要把这些字节数组按照0d0a分割成多个子数组,然后再对每个子数组进行utf-8.getstring,这应该比把整个header字节数组转换成字符串再split性能好一些,因为split会临时生成多个小字符串,引起很多对象分配操作。其实我们并不应该把大字节数组分割成小字节数组,我们就找到0d0a的位置,然后用utf-8.getstring(bytes,index,length)来按段儿来提取每一行的消息头。
6、为了防止对接受到的字节数组进行内存拷贝,我们应该把接受到的字节数组放到一个链表里,因为我们是顺序插入字节,解析的时候也是顺序访问字节数组,所以我认为这里应该用链表,而且链表的API完全满足消息解析的要求,如果构建一个环形的字节数组,操作起来比链表复杂,而且性能应该也不会比字节链表好。
7、在字节链表上,我们只要找到对应的包的开头、结尾节点,然后我们就可以把这段儿链表赋值给包对象,然后包对象自己去把这段儿链表换算成一个字节数组,进行相应的处理,比如转换成字符串,进一步解析每行的header,但有的服务只解析出header就可以处理这个包,比如转发给另一个服务,那么body就不需要转换成字节数组,更不用转换成字符串,直接把属于Body的那段儿字节链表(可以进一步封装成Stream)传出去就行了。
8、刚开始我在收到字节数组后要先把字节数组fill到字节链表里,这个过程会无谓的消耗一些性能,所以我又优化了一下,把字节链表改成了字节数组链表,但改成字节数组链表后,遍历起来很麻烦,有的链表节点上的字节数组有半截儿已经解析给上个包了,下次解析要接着上次解析的地方去解析,所以每个字节数组节点还要保存一个有效数组段儿的开始位置和结束位置,比第一次的代码更复杂了一些,但是性能要好于前者,
9、还有就是在收到一个半截header或者半截body的情况下,下一次收到包解析的时候尽量避免回溯,比较好的算法是尽量遍历一次就匹配出所有规则,DFA就是这样,但得加更多的标志位来保存解析状态。
10、在解析header的时候也避免先把字节数组链表转换成字节数组,会造成字节数组拷贝,应该一次字节数组链表的遍历就直接解析出所有header,当然可能会跨越多个字节数组节点,但比把多个字节数组节点合并成一个大的字节数组再解析header性能要好不少。
下面来具体看下代码
BytesLine,表示header中的一行,因为消息头不会出现中文,所以直接用ASCII编码,除了header的第一行,消息头都分为name,value部分,这里用String1和String2表示
Code
BytesNode,该类表示字节数组链表中的一个节点,其中Next属性指向链表中的下一个节点,其余的都是一个帮助性的方法和属性,比如该节点已经解析到什么位置了,有效字节的结束为止,及如何把自己切成两个,获取有效字节数组,把有效字节数组解析成字符串等方法。该类尽量做成不变类,成员能用readonly就用readonly,这样可以在多线程的时候防止加锁。
Code