让我们回到 smtp/pop3 等网络命令上来. 前面的文章已经说过了大多数的网络命令都是基于网络命令行的,我们就先来研究一行命令本身.
读取一行命令,在前面的 java 语言示例中实现很简单:
String s = br.readLine();
也就是说 java 中直接实现了读取一行的功能. 这个实现其实也没初学者想象的那么简单,甚至是网络编程中一个很易错的地方.换到直接操作 socket 的大多数环境中来,我们仍然以 C 语言为示例.我们以前面代码中的 RecvBuf() 来读取一行,这在真实的环境中是不正确的.如果大家测试过我们之前的示例一定会发现一个奇怪的现象:怎么有时候会一次收到好几行的内容呢?
这就涉及到一个很重要的概念:字节流. 在网络中传输的数据并不是我们发送多少对方就能接收到多少,更不是我们发送一次就对应对方的一次接收,而是我们发送的内容作为一个整体的字节流在网络中传输,如果我们发送了两次后对方才开始接收,那么对方就会一次性收到两次发出的命令.字节流的情况下还有一个更严重的问题:发送一次命令,对方有可能会花很多次接收调用后才能收取完! 很多程序员是不懂得这一点的,甚至很多公司的代码里长期存在这种缺陷的代码! 这种现象在现在这种网络环境超级好的中国国内是比较难重现的,在我们以前的拨号环境中就很常见,估计这也是现在的程序员不了解这种情况的原因之一吧. 既然比较难给出一个实例让大家去测试这种情况,那么让我们从原理上来解释吧.
我们先来看看 C 语言示例中的 RecvBuf() 函数实现.代码如下:
lstring * RecvBuf(SOCKET so, struct MemPool * pool) { char buf[1024+1]; int r = 0; lstring * s = NewString("", pool); memset(&buf, 0, sizeof(buf)); r = _recv(so, buf, sizeof(buf)-1, 0); //留下一个 #0 结尾 if (r > 0) { LString_AppendCString(s, buf, r); } if (r == 0) //一般都是断开了 { MessageBox(0, "recv error.[socket close]", "", 0); return s; } return s; }//
抛开字符串内存池的相关代码,实际上主要调用的是系统的 recv() socket 函数,而这个函数的原型为
int recv(SOCKET s, char *buf, int len, int flags);
其中 flags 一般是 0 可以不理会,而 s 就是网络连接也可以不用理会. buf 是接收到的数据要存放的缓冲区, len 则是缓冲区的大小,接收到的数据是不会大于 len 的(C语言的程序员都知道,那样就内存溢出了). 好了,让我们来模拟一下一个命令要接收两次的情况吧: 我们发送一个 4K 长的命令的话,我们 RecvBuf 一下, 眼尖的程序员一定看出来了,缓冲区只有 1024 啊. 很显然要接收多次才行嘛! 有的读者马上就会说了,你这不对嘛,谁让你缓冲区这么小的,来个 4K 的缓冲区! 这里有几个问题:你怎么知道 4K 就一定能接收完一个命令? 如果我是 1M 长的命令行呢? 那就开 1M 的 ... 如果我发送了一个 G 的文件过来呢? 先不考虑系统能不能开出这么大的缓冲区,先想一想 1G 长的文件或者命令要在网络中传送多久,那么这个 recv 函数是要等这一个 G 的数据都传输完了才返回吗? 很显然不是,要不我们就不会看到下载文件时的进度条了. 所以 recv 是在有数据到达时就返回了的,而不管收取到了多少,如果返回时收取了两个命令的数据那就会象我们示例中的那样显示出两个命令的内容.如果只收取到半个命令,就会出现过去拨号环境下的那种要多次收取才能得到一行命令的情况. 实际上数据在互联网中传输要经过很多设备,设备给每个连接的缓冲都是有限的,不可能指望对方发送的内容我们一次性就能收取. 所以在所有正规的 socket 的封装中都是要先用 recv 把数据先收取到程序中的一个缓冲区,一般来说会把每个连接设计成一个类,然后开一个 buf ,系统有空闲时就不停的调用 recv 直到 buf 收满为止,然后当程序要 readLine 一行时就在缓冲区取出一行的内容给它. 这就是 java 或者类似环境的 readLine 函数的实现原理和原因.
但是假如我们在 C 语言的测试中也这样做的话就比较困难,一是 C 语言没有类,二是 C 语言的内存管理不那么好做,要把这两个问题都解决好了再进行测试的话就太繁琐了,而且代码量太多的话也不好重用和维护. 所以我们可以先做一个简单的实现:每次读取一个字节,拼到字符串中,读取到行结束符时返回就可以了.这个代码很容易实现,但在实际的环境中效率是比较低的,可以在测试完成后实现一个更好一点的版本:每次读取一个缓冲区的内容,函数返回当前的命令和余下的内容,下次收取时再将余下的内容与新收取的内容合并就可以了.
根据以上思想就可以得到这样收取一行的函数:
//收取一行,可再优化 lstring * RecvLine(SOCKET so, struct MemPool * pool, lstring ** _buf) { int i = 0; int index = -1; lstring * r = NULL; lstring * s = NULL; lstring * buf = *_buf; for (i=0;i<10;i++) //安全起见,不用 while ,用 for 一定次数就可以了 { //index = pos("\n", buf); index = pos(NewString("\r\n", pool), buf); if (index>-1) break; s = RecvBuf(so, pool); buf->Append(buf, s); } if (index <0 ) return NewString("", pool); r = substring(buf, 0, index); buf = substring(buf, index + 2, Length(buf)); *_buf = buf; return r; }
因为这个示例中我已经实现了一个简单的字符串内存池,所以按第二种先读取到缓冲区的方法实现了这个读取一行的命令.如果大家在 c++ 环境里可以自己用 std::string 来实现第一种一次读取一个字节的实现方法(对于客户端接收命令来说,其实效率也没那么差,因为一般客户端就几个连接在工作嘛).
对比我们之前的结果,可以看到二者的区别.如图:
有经验并且眼尖的读者一定看到了 RecvLine 里的读取循环是 for 了一定的次数而不是用的 while 到成功后再跳出,这是因为长期的服务端开发我发现因为开发周期紧或者开发人员经验不足或者考虑不周等情况,在 while 里出现问题的话很容易造成死循环导致服务器不响应.所以我习惯在需要循环或者递归的地方设置一定的次数,如果超出这些次数就认为是出错了强制跳出.如果大家的服务器也不太稳定可以考虑也加入这种机制,还是很有效的.
大家可能对我读取一行的方法可能有疑虑,那我们来看看现有的语言是怎样实现的吧,java 的源码不是太方便看,我还是先用下 delphi 的吧:
function TIdTCPConnection.ReadLn(ATerminator: string = LF; const ATimeout: Integer = IdTimeoutDefault; AMaxLineLength: Integer = -1): string; var LInputBufferSize: Integer; LSize: Integer; LTermPos: Integer; begin if AMaxLineLength = -1 then begin AMaxLineLength := MaxLineLength; end; // User may pass '' if they need to pass arguments beyond the first. if Length(ATerminator) = 0 then begin ATerminator := LF; end; FReadLnSplit := False; FReadLnTimedOut := False; LTermPos := 0; LSize := 0; repeat LInputBufferSize := InputBuffer.Size; if LInputBufferSize > 0 then begin LTermPos := MemoryPos(ATerminator, PChar(InputBuffer.Memory) + LSize, LInputBufferSize - LSize); if LTermPos > 0 then begin LTermPos := LTermPos + LSize; end; LSize := LInputBufferSize; end;//if if (LTermPos - 1 > AMaxLineLength) and (AMaxLineLength <> 0) then begin if MaxLineAction = maException then begin raise EIdReadLnMaxLineLengthExceeded.Create(RSReadLnMaxLineLengthExceeded); end else begin FReadLnSplit := True; Result := InputBuffer.Extract(AMaxLineLength); Exit; end; // ReadFromStack blocks - do not call unless we need to end else if LTermPos = 0 then begin if (LSize > AMaxLineLength) and (AMaxLineLength <> 0) then begin if MaxLineAction = maException then begin raise EIdReadLnMaxLineLengthExceeded.Create(RSReadLnMaxLineLengthExceeded); end else begin FReadLnSplit := True; Result := InputBuffer.Extract(AMaxLineLength); Exit; end; end; // ReadLn needs to call this as data may exist in the buffer, but no EOL yet disconnected CheckForDisconnect(True, True); // Can only return 0 if error or timeout FReadLnTimedOut := ReadFromStack(True, ATimeout, ATimeout = IdTimeoutDefault) = 0; if ReadLnTimedout then begin Result := ''; Exit; end; end; until LTermPos > 0; // Extract actual data Result := InputBuffer.Extract(LTermPos + Length(ATerminator) - 1); // Strip terminators LTermPos := Length(Result) - Length(ATerminator); if (ATerminator = LF) and (LTermPos > 0) and (Result[LTermPos] = CR) then begin SetLength(Result, LTermPos - 1); end else begin SetLength(Result, LTermPos); end; end;//ReadLn
这是 delphi 中著名的 indy 组件的实现,虽然代码比较长,大家对 delphi 语法可能也不熟,不过还是可以比较清楚的看到它也是先保存到一个缓冲区的.
改进后的完整代码如下(相关的依赖文件见文章末尾处):
#include <stdio.h> #include <windows.h> #include <time.h> #include <winsock.h> #include "lstring.c" #include "socketplus.c" #include "lstring_functions.c" //vc 下要有可能要加 lib //#pragma comment (lib,"*.lib") //#pragma comment (lib,"libwsock32.a") //#pragma comment (lib,"libwsock32.a") //SOCKET gSo = 0; SOCKET gSo = -1; //收取一行,可再优化 lstring * RecvLine(SOCKET so, struct MemPool * pool, lstring ** _buf) { int i = 0; int index = -1; lstring * r = NULL; lstring * s = NULL; lstring * buf = *_buf; for (i=0;i<10;i++) //安全起见,不用 while ,用 for 一定次数就可以了 { //index = pos("\n", buf); index = pos(NewString("\r\n", pool), buf); if (index>-1) break; s = RecvBuf(so, pool); buf->Append(buf, s); } if (index <0 ) return NewString("", pool); r = substring(buf, 0, index); buf = substring(buf, index + 2, Length(buf)); *_buf = buf; return r; } void main() { int r; mempool mem, * m; lstring * s; lstring * rs; lstring * buf; //-------------------------------------------------- mem = makemem(); m = &mem; //内存池,重要 buf = NewString("", m); //-------------------------------------------------- //直接装载各个 dll 函数 LoadFunctions_Socket(); InitWinSocket(); //初始化 socket, windows 下一定要有 gSo = CreateTcpClient(); r = ConnectHost(gSo, "newbt.net", 25); if (r == 1) printf("连接成功!\r\n"); s = NewString("EHLO\r\n", m); SendBuf(gSo, s->str, s->len); printf(s->str); s->Append(s, s); printf(s->str); s->AppendConst(s, "中文\r\n"); printf(s->str); //-------------------------------------------------- rs = RecvLine(gSo, m, &buf); //只收取一行 printf("\r\nRecvLine:"); printf(rs->str); printf("\r\n"); rs = RecvLine(gSo, m, &buf); //只收取一行 printf("\r\nRecvLine:"); printf(rs->str); printf("\r\n"); rs = RecvLine(gSo, m, &buf); //只收取一行 printf("\r\nRecvLine:"); printf(rs->str); printf("\r\n"); //-------------------------------------------------- // rs = RecvBuf(gSo, m); //注意这个并不只是收取一行 // // printf("\r\nRecvBuf:\r\n"); // printf(rs->str); // // rs = RecvBuf(gSo, m); //注意这个并不只是收取一行 // printf("\r\nRecvBuf:\r\n"); // printf(rs->str); //-------------------------------------------------- Pool_Free(&mem); //释放内存池 printf("gMallocCount:%d \r\n", gMallocCount); //看看有没有内存泄漏//简单的检测而已 //-------------------------------------------------- getch(); //getch().不过在VC中好象要用getch(),必须在头文件中加上<conio.h> }
不知不觉这篇内容又占了很大的篇幅,这也是没办法,因为感觉确实有这么多要讲的,生怕哪个地方没说清楚又上大家走了弯路.如果大家看着觉得啰嗦,那就请多见谅吧!
--------------------------------------------------
本想上传依赖的相关文件到 github,到自己的账号里一看原来那个字符串的类已经传过了.所以补充了 socketplus.c 就好了. 大家可以到以下网址下载:
https://github.com/clqsrc/c_lib_lstring
也可以到之前同系列的文章中去复制,不过两者内容略有差异. 用 github 上的较好,因为以后有可能更新.
本系列文章已授权百家号 "clq的程序员学前班" . 文章编排上略有差异.