摘要:一个简易的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,设定监听端口和所要连接的远程服务地址以及端口。 使程序能够以后台方式运行 ,而不是前台方式,成为一个真正的服务程序。( 现在的版本当用户退出控制台的时候会终止运行。) 使程序能够监听多个端口,并且连接多个远程服务。 使程序能够从配置文件中读取设定监听端口和所要连接的远程服务地址以及端口以满足多种服务并存的需要。 这些工作我将在下一部分文章中描述。 有什么问题、意见,可以通过电子邮件和我联系。
(出处:http://www.sheup.com)