摘要:一個簡易的proxy程序的開發過程。這個例子,主要是運用了一些編程的技術,比如,socket編程,信號,進程,還有一些unix socket編程的較高級論題。當然,這些都不是主要的,重要的是,體驗一下集市的開發方式 1.引言 很多人都看過Eric Steven Raymond寫的《The Cathedral and the Bazaar》 (大教堂與集市) 這篇文章吧。這篇文章講述了傳統的開發小組開發方式和基於Internet的分散的開發方式(Linux的開發方式,GNU軟件的開發方式)的區別,並且根據自己的一個程序的開發例子來講述了The Bazaar開發方式的若干條重要原則。 不過,國內很多程序員,工作的時候還是采用的傳統的開發方式,很難有機會在工作中體驗這些原則。那麼,這個例子就給了大家又一個體驗這些原則的過程。 這個例子,主要是運用了一些編程的技術,比如,socket編程,信號,進程,還有一些unix socket編程的較高級論題。當然,這些都不是主要的,重要的是,體驗一下集市的開發方式。 2.開發這個proxy程序的背景 我工作的時候,處在一個比較封閉的網絡環境中。我的機器在局域網 (LAN) 之中,與外界的Internet相連采用了代理的方式,有若干台unix服務器作為代理服務器,運行squid作為http的代理,運行socks作為socks 5代理。應該說,這樣的待遇,還算不錯,:-), 要浏覽網站,squid夠用了;要運行ICQ, OICQ之類的程序,用socks也夠了。但是,我遇到了一個比較麻煩的問題,在這樣的網絡環境中,我沒有辦法用Outlook等工具收取非來自公司郵件服務器的郵件(比方說,@linuxaid.com.cn, @163.net, @sina.com.cn 等等);也沒有辦法用Gravity等工具來收取USENET上的討論。當然,折衷的辦法還是有,我可以用linux下的一些支持socks的郵件客戶端軟件和新聞組閱讀軟件。但是,這樣勢必造成一些麻煩( 實際上我也這樣做過 ),當我需要收取郵件或者閱讀新聞組的時候,我必須重新啟動機器轉換到linux操作系統中去,而當我要辦公的時候,我又不得不重新啟動機器再轉換到windows操作系統中來 ( 我不得不說,linux作為辦公的桌面還是不如windows, 雖然這句話肯定會惹惱很多linux fan :-) )。作為一個程序員,我當然不能忍受這種麻煩。我必須想辦法來解決這個問題。經過考慮,我有了一個好的想法。 這體現了The Bazaar原則一: Every good work of software starts by scratching a developer's personal itch. 每一個軟件的開發都是帶有開發者自己的烙印。 3.初期設計 我需要的是一個程序,他能夠做"二傳手"的工作。他自身處在能同時連通外界目標服務器和我的機器的位置上。我的機器把請求發送給他,他接受請求,把請求原封不動的抄下來發送給外界目標服務器;外界目標服務器響應了請求,把回答發送給他,他再接受回答,把回答原封不動的抄下來發送給我的機器。這樣,我的機器實際上是把他當作了目標服務器( 由於是原封不動的轉抄,請求和回答沒有被修改 )。而他則是外界目標服務器的客戶端( 由於是原封不動的轉抄,請求和回答沒有被修改 )。我把這種代理服務程序叫做"二傳手"。 原理圖如下: ---------- -------------- ------------ -------> ------> 我的機器 代理服務程序 目標服務器 <------- <------ ---------- -------------- ------------ 4.例子重用 The Bazaar原則二: Good programmers know what to write. Great ones know what to rewrite (and reuse). 好的程序員知道寫什麼。而偉大的程序員知道重寫和重用什麼。 基於這個原則,我當然不會從頭來寫這個程序(其實,這個程序是一個很小的程序,沒有必要一定要這麼做。但是,為了給大家,同時也是給我自己一個集市化的開發方式的體驗,我還是這麼做了,我先是寫出來了一個簡單的程序---附在本文最後----然後才想起來去找找有沒有類似的程序 :-), 結果浪費了很多時間)。 在網上找了找,花了大概半個小時( 和我寫出第一個簡單程序所花的時間差不多 :-) ),找到了這個程序。 程序如下: ( 這個程序來自水木清華BBS精華版 ) 看了這個程序,我細化了我的初步設計: 程序監聽服務端口,接受客戶端連接,派生出子進程處理連接,同時連接遠程機器的服務端口,然後開始完成"二傳手"的工作。 當然,這個小程序也有不足的地方: 他只能監聽一個服務端口,只能連接一個遠程機器的服務端口。 他采用了子進程的方式,如果客戶端連接很多,就會給服務器造成比較大的壓力。 他只能監聽tcp,而不能作為udp的代理服務器 ( 廣大 OICQ 用戶都知道,這個程序不能用來做 OICQ代理)。 他只能用命令行的方式讀入服務端口,遠程服務器地址和端口,不能用配置文件的方式。 所以,我還是決定繼續完善我自己的程序,而不是用他。 The Bazaar原則三: Plan to throw one away; you will, anyhow. 5.第一版的代碼 我的小程序,第一版本如下: 下面簡單解釋一下程序。對 socket 網絡編程比較熟悉的就不要看了。:-) bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_port = htons(7000); servaddr.sin_addr.s_addr = INADDR_ANY; 給出一個sockaddr_in結構,定義了服務器的端口號和地址。 listenfd = socket(AF_INET, SOCK_STREAM, 0); socket()函數返回一個socket類型的描述字,類型為AF_INET ( IPv4 ), SOCK_STREAM ( TCP ) . if(listenfd < 0) { printf("socket error"); exit(-1); } 如果socket()函數返回值為小於0, 則表示出錯。 if( bind(listenfd, (strUCt sockaddr *)&servaddr, sizeof(servaddr)) < 0 ) { printf("bind error"); exit(-1); } 綁定描述字和服務器地址端口。如果bind()函數返回值為小於0, 則表示出錯。 signal(SIGCHLD, waitchild); 指定SIGCHLD信號的處理函數為waitchild()。當主進程fork()出的子進程結束的時候,主進程會收到一個SIGCHLD信號,內核發送這個信號的目的是為了讓主進程有機會能夠檢查子進程的退出狀態,並做一些清理工作( 如果必要的話 )。如果主進程不處理SIGCHLD信號,子進程將會變成僵屍進程,直到主進程退出,被init進程接管,被init進程清理掉。 waitchild() 函數如下: void waitchild(int signo) { int status; pid_t childpid; if( (childpid = waitpid(-1, &status, WNOHANG)) < 0 ) { printf("wait error"); exit(1); } printf("child %d quitted", childpid); return; } 注意:signal處理函數必須定義成 void func(int)形式。 waitpid(-1, &status, WNOHANG)等待子進程退出,並且獲取子進程的退出狀態保存到status裡。 printf("child %d quitted", childpid); 打印子進程的進程號。 if( listen(listenfd, 5) < 0 ) { printf("listen error"); exit(-1); } 啟動監聽,指定等待隊列長度為5。如果listen()函數返回值為小於0, 則表示出錯。 for(;;) { connfd = accept( listenfd, (struct sockaddr *)&clientaddr,&clientlen ); if( connfd < 0 ) { printf("accept error"); exit(-1); } if( (chpid = fork()) == -1 ) { printf("fork error"); exit(-1); } if( chpid == 0 ) { close(listenfd); do_proxy(connfd); exit(0); } if( chpid > 0 ) { close(connfd); } } 在for(;;){}這個無限循環中,進程阻塞於accept。 accept( listenfd, (struct sockaddr *)&clientaddr,&clientlen ) 等待客戶端連接,如果連接成功,則在clientaddr中返回客戶端的IP地址以及端口號,協議類型等信息,同時clientaddr的長度存於clientlen中。accept返回socket連接描述字connfd.如果accept()函數返回值為小於0,則表示出錯。 連接成功,主進程采用fork()派生子進程。如果FORK()函數返回值為小於0, 則表示出錯。 在主進程中( chpid > 0 ),關閉connfd描述字,並繼續for(;;){}循環。在子進程中( chpid == 0 ),關閉listenfd監聽socket描述字,並調用do_proxy()函數 ( 稍候介紹,用於完成proxy的工作 )。等待do_proxy()函數返回,並且退出子進程。 注意:fork() 函數是調用一次,返回兩次,一次返回在主進程中,一次返回在子進程中。 下面介紹do_proxy()函數。 bzero(&rout, sizeof(rout)); rout.sin_family = AF_INET; rout.sin_port = htons(7001); rout.sin_addr.s_addr = inet_addr("127.0.0.1"); 定義連接的遠程服務端口。由於這個程序是基於測試目的,為了方便,我把遠程服務定義為本機的7001端口( 也就是說,實際上走的是loopback interface )。 if( (outfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) { printf("socket error"); exit(-1); } socket()函數返回一個socket類型的描述字,類型為AF_INET ( IPv4 ), SOCK_STREAM ( TCP ) . 如果socket()函數返回值為小於0, 則表示出錯。 if( connect(outfd, (struct sockaddr *)&rout, sizeof(rout)) < 0 ) { printf("connect error"); exit(-1); } connect()函數連接遠程服務地址和端口,如果connect()函數返回值為小於0, 則表示出錯。 在while(1) { } 無限循環中: FD_ZERO(&set); 清空fd_set FD_SET(infd, &set); FD_SET(outfd, &set); 把infd ( 是從主程序中傳進來的,就是連接描述字connfd ), outfd ( 連接遠程服務的描述字 )放進fd_set。 maxfd = max(outfd, infd); 取兩個描述字的最大值。 max() 函數定義如下: int max(int i, int j) { return i>j?i:j; } 很簡單,就不用解釋了。 if( select(maxfd + 1, &set, NULL, NULL, NULL) < 0 ) { perror("select error:"); exit(-1); } 阻塞於 select() 函數, 等待infd, outfd中任意描述字可讀。 這裡稍微解釋一下:maxfd + 1, select函數要求第一個參數是集合中描述字最大值加1 ( 很多人常常忘記了加上1,結果導致select函數出錯 ) 。我把可寫,異常兩個集合都定義為空,因為我們不必關心這兩個集合。超時設置為NULL, 這表示如果沒有描述字不可讀,將永遠阻塞在select 函數中。( 在以後的版本裡面,我將修改這一函數調用,以增強程序性能。如果select()函數返回值為小於0, 則表示出錯。 if( FD_ISSET(infd, &set) ) { n = read(infd, (void *)buf, count); if( n <= 0) break; if( write(outfd, (const void *)buf, n) != n ) { printf("write error"); continue; } } 如果select返回值大於0,檢測是否infd可讀。如果可讀,則從infd中讀出數據,並寫回到outfd中。這裡,如果read返回值小於或者等於0,表示服務器寫入了終止符號或者服務器停止服務 ( 這裡的情況比較復雜,需要注意。 )如果read出錯,則終止循環。如果write寫入outfd的字節數不為n則表示write出錯 ( 原因可能是客戶端終止或者其他異常情況 )。 但是,需要注意的是,當write出錯的時候,我們並不退出,而是繼續 while(1) { }循環。 if( FD_ISSET(outfd, &set) ) { n = read(outfd, (void *)buf, count); if( n <= 0) break; if( write(infd, (const void *)buf, n) != n ) { printf("write error"); continue; } } 如果select返回值大於0,檢測是否outfd可讀。如果可讀,則從outfd中讀出數據,並寫回到infd中。這裡,如果read返回值小於或者等於0,表示服務器寫入了終止符號或者服務器停止服務 ( 這裡的情況比較復雜,需要注意。 )如果read出錯,則終止循環。如果write寫入outfd的字節數不為n則表示write出錯 ( 原因可能是客戶端終止或者其他異常情況 )。 但是,需要注意的是,當write出錯的時候,我們並不退出,而是繼續 while(1) { }循環。 這一部分就是初步設計中的思想的實現。就是這兩段程序完成了"二傳手"的工作。 close(infd); close(outfd); 當循環因為服務端或者客戶端終止或者其他出錯退出,則關閉兩個描述字,並返回。 6.測試第一版的程序 為了測試我的小程序是否能夠按希望的方式運行並且得到正確的結果,我寫了另外一個小程序用來輔助測試的工作。 程序清單如下: 這個程序比較簡單,功能是把客戶端輸入的字符返回給客戶端。當客戶端終止時,則停止子進程。 程序解釋完了,我們來看一下運行結果。 首先,編譯這兩個程序。 gcc -o sp sp.c gcc -o echos echos.c 運行. ./sp ./echos 看看程序初始化的時候端口的狀態。 [alan@ariesram proxy]$ netstat -na grep 700 tcp 0 0 0.0.0.0:7000 0.0.0.0:* LISTEN tcp 0 0 0.0.0.0:7001 0.0.0.0:* LISTEN sp, echos分別監聽兩個端口,7000 和 7001。 啟動一個客戶端,連接sp的服務端口7000。 [alan@ariesram proxy]$ telnet localhost 7000 Trying 127.0.0.1... Connected to ariesram. Escape character is '^]'. 再來看看端口的狀態。 [alan@ariesram alan]$ netstat -na grep 700 tcp 0 0 0.0.0.0:7000 0.0.0.0:* LISTEN tcp 0 0 0.0.0.0:7001 0.0.0.0:* LISTEN tcp 0 0 127.0.0.1:32769 127.0.0.1:7000 ESTABLISHED tcp 0 0 127.0.0.1:7001 127.0.0.1:32770 ESTABLISHED tcp 0 0 127.0.0.1:32770 127.0.0.1:7001 ESTABLISHED tcp 0 0 127.0.0.1:7000 127.0.0.1:32769 ESTABLISHED 其中, tcp 0 0 0.0.0.0:7000 0.0.0.0:* LISTEN tcp 0 0 0.0.0.0:7001 0.0.0.0:* LISTEN 仍然處在監聽狀態。 而 tcp 0 0 127.0.0.1:32769 127.0.0.1:7000 ESTABLISHED 是我啟動的telnet連接到sp服務端口的連接。 同時,sp發起了一個到目的服務端口7001的連接。 tcp 0 0 127.0.0.1:32770 127.0.0.1:7001 ESTABLISHED 另外, tcp 0 0 127.0.0.1:7000 127.0.0.1:32769 ESTABLISHED tcp 0 0 127.0.0.1:7001 127.0.0.1:32770 ESTABLISHED 分別是sp代理服務程序連接客戶端和遠程目標服務端口連接代理服務程序的連接。如果是remote方式的話,是看不到這兩個連接的。 在telnet客戶端輸入字符串做測試,看是否能夠把輸入字符串原樣返回。 [alan@ariesram proxy]$ telnet localhost 7000 Trying 127.0.0.1... Connected to ariesram. Escape character is '^]'. asdf asdf sadf sadf asdfasdfasdfasfd asdfasdfasdfasfd 結果顯示,我們的程序是成功的。:-) 退出telnet客戶端,再來看看端口的狀態。 [alan@ariesram proxy]$ netstat -na grep 700 tcp 0 0 0.0.0.0:7000 0.0.0.0:* LISTEN tcp 0 0 0.0.0.0:7001 0.0.0.0:* LISTEN tcp 0 0 127.0.0.1:32769 127.0.0.1:7000 TIME_WAIT tcp 0 0 127.0.0.1:32770 127.0.0.1:7001 TIME_WAIT 我們可以看到,由 telnet 客戶端發起的連接和代理服務程序sp發起的連接都處於close過程的TIME_WAIT狀態。該狀態的持續時間是最長分節生命周期 MSL ( maximum segment lifetime ) 的兩倍,有時候稱作2MSL。 存在TIME_WAIT狀態的兩個理由: 實現終止TCP全雙工連接的可靠性。 允許老的重復分節在網絡中消逝。 而其中, tcp 0 0 0.0.0.0:7000 0.0.0.0:* LISTEN tcp 0 0 0.0.0.0:7001 0.0.0.0:* LISTEN 仍然處在監聽狀態, 直到echos, sp兩個程序退出。 7.小結 以上講述了第一版的開發以及測試過程。我們看到,我的初步設想是能夠實現的。接下來需要做的是將代理服務程序修改成為一個可用的版本。需要做的事情是: 修改程序運行方式,使其能從命令行讀入 option,設定監聽端口和所要連接的遠程服務地址以及端口。 使程序能夠以後台方式運行 ,而不是前台方式,成為一個真正的服務程序。( 現在的版本當用戶退出控制台的時候會終止運行。) 使程序能夠監聽多個端口,並且連接多個遠程服務。 使程序能夠從配置文件中讀取設定監聽端口和所要連接的遠程服務地址以及端口以滿足多種服務並存的需要。 這些工作我將在下一部分文章中描述。 有什麼問題、意見,可以通過電子郵件和我聯系。