前言
以前在一个项目中遇到了内外网分离的问题,内网和外网不能直接通信,项目中外网的机器需要从内网拿数据,因此中间配置了一台FTP服务器,内网产生的数据形成文件,然后传到中间FTP服务器上,外网定时去FTP服务器上取文件。你也许会问,外网怎么知道内网产生了哪些文件呢?真是一个好问题,这说明你反映很快,一下子就融入到应用场景中去了。停下来想一下,如果是你会怎么做呢?
用一个简单的方法吧,设置一个中间文件,内网把产生的文件信息(主要是文件名)追加到一个固定的文件中,然后传到FTP服务器上,外网机器先去FTP服务器取到中间文件,与上次最后取的文件信息比较,判断有没有新文件生成,如果有新文件,就通过FTP协议取下来,如果没有就等下次时间间隔到了,再如上判断。
这里还有一个问题,外网机器怎样取文件呢?首先会想到,可以用一个第三方的FTP客户端程序,写一个脚本定时启动获取文件,如果这样做了,那你就不会看到这篇文字了。为了显得更专业一些,也为了程序集成在一起好控制,项目组决定自己写程序获取文件。
怎样实现呢
如果自己写程序,就遇到了一个问题,要自己写代码实现FTP协议,乍一看好像很简单,不就是实现几个FTP命令吗?当然最快速的方法就是去问一下其他人,看是否有人做过这方面的东西,也好参考一下,问了一圈项目组里的同事,没一个人做过。你们让我做的时候,不是都说很简单的东西吗,怎么就没人研究过呢?看来空闲时间都用来玩游戏了,也不干点有用的事情。抱怨是不能解决问题的,只能自己先抓只螃蟹来吃了。
困难可以激发斗志,先从FTP协议开始研究,下载了RFC959文件,看了一遍,还是一头雾水,可能很多人跟我一样,读这样的英文文档有些吃力,已经来不及补英文了,暗自懊恼,以前的时间都干嘛了,怎么就没好好用功把英语学好呢。看来读懂协议再写程序,这条路太耗时,项目还等着用呢,也不能让其他人看了笑话。
到了考验你解决问题能力的时候了,不能慌也不能急,没吃过猪肉,还没见过猪跑吗?不是也经常用FTP传文件吗?先来考察它都干了些什么?
首先要有一个IP地址,这个没有问题,毕竟FTP是基于TCP/IP协议的,端口默认是21,从以前对FTP的了解知道还要用到一个端口20,用于传送数据,到底什么时候用到20端口,这个还不清楚,以后再说吧。然后要通过TCP/IP协议连接到上面的IP地址和21端口,接着要输入用户名和密码来验证授权,后面就是设置通信模式,再后面就是输入命令,传送文件了。
先把流程搞清楚,不是有现成的FTP服务器程序和客户端程序吗?好吧,自己先写一个假的服务器程序,只接收客户端的命令,看看客户端都做了些什么动作,然后按顺序记录下来。接着写一个客户端程序,按照假的服务器程序接收到的信息,按步骤发送给已有的FTP服务器程序,看看服务器发送回来什么样的应答,这样就把开始的交互流程理清楚了。
说到传输数据,从协议中看到,FTP传送数据有两种模式主动模式,也叫端口模式(port)和被动模式(passive)。这里的主动和被动是相对于FTP服务器来说的,这两种模式分别对应两种场景,尤其是在机器中部署了防火墙软件时。下面分别来描述这两种情况。
主动模式是服务器端主动去连接客户端的监听建立连接,然后传输数据。FTP有两条链路在工作,一条是控制链路,这条在21端口,一条是数据链路,就是我们现在要描述的传输数据的通道。客户端程序连接服务器的21端口,建立了控制链路,以后所有的控制命令都通过这条连接交互(后面我们会区分哪些是控制命令,哪些是数据命令)。连接成功后我们在客户端就得到了一个随机分配的端口(如果不明白socket的五元组,可以看后面附录的解释),我们把这个端口叫做N,然后我们把这个端口加1,就是N+1端口,在这个端口上用客户端的IP地址建立一个监听,然后使用PORT命令把客户端监听的IP和端口N+1通过控制链路发送给FTP服务器。服务器接到PORT命令,就知道客户端要求传输数据了,于是就解析出客户端的IP地址和端口,然后连接到客户端的监听,这样就建立了一条数据链路,后面的数据传输就可以通过这条链路进行了。假设后面客户端输入了一个GET命令(对应着FTP中的RETR <filename>),那么服务器就打开这个文件,读入数据,通过数据链路发送给客户端,经过多次交互,传输文件的动作就完成了。上面是一个连贯的过程,但中间还有一个小插曲,现在让我们回过头来看一下,在服务器端主动连接客户端监听的时候,服务器还要做一个动作,就是要指定自己的端口为20,而不是让系统随机分配一个,在写程序的时候可以通过bind()函数来指定20端口,现在我们明白为什么FTP要预留21和20两个端口了。
被动模式是服务器建立监听,客户端去连接来建立数据链路的方式。客户端连接FTP服务器建立了控制链路后,得到了一个本地的随机分配端口N,再加1又得到一个端口N+1,这个端口在客户端连接服务器建立数据链路时,指定为客户端的数据端口。随后客户端通过控制链路给服务器发送一个PASV,告诉服务器要采用被动模式,服务器以一个应答码227作为回应,后面跟着服务器指定的连接信息,就是IP地址和端口,格式是(h1,h2,h3,h4,p1,p2)。客户端解析出IP地址和端口后,连接到服务器建立数据链路(别忘了连接前要绑定N+1端口)。然后客户端就可以发布数据命令传输数据了。
下面看看FTP命令都有哪些,这些是FTP协议中列出的命令。
|------|----------------------|
| ABOR | 异常中断数据连接 |
| ACCT | 账户权限信息 |
| ALLO | 为服务器上的文件存储分配空间 |
| APPE | 在服务器文件中追加数据 |
| CDUP | 改变服务器上当前目录到上一级目录 |
| CWD | 改变服务器当前目录到一个新目录 |
| DELE | 删除服务器上的文件 |
| HELP | 返回命令的帮助信息 |
| LIST | 显示服务器上的文件或目录 |
| MODE | 改变传输模式 |
| MKD | 在服务器上建立目录 |
| NLST | 列出服务器上的文件或子目录(不带属性) |
| NOOP | 空指令 |
| PASS | 提供用户密码 |
| PASV | 设置被动模式 |
| PORT | 设置主动模式 |
| PWD | 显示服务器上的当前工作目录 |
| QUIT | 退出登录 |
| REIN | 重新初始化 |
| REST | 从指定的偏移量重启文件传送 |
| RETR | 从服务器传送文件到客户端 |
| RMD | 删除服务器上的目录 |
| RNFR | 指定需要重命名的文件 |
| RNTO | 指定重命名文件的新名称 |
| SITE | 提供服务器相关参数 |
| SMNT | 安装文件系统 |
| STAT | 返回文件或目录的状态信息 |
| STOR | 从客户端传送文件到服务器上 |
| STOU | 从客户端传送文件到服务器,不覆盖同名文件 |
| STRU | 指定文件结构 |
| SYST | 返回服务器的系统信息 |
| TYPE | 指定文件类型 |
| USER | 指定登录用户名 |
FTP命令都以ASCII码形式发送,命令以CR、LF结尾,也就是以回车+换行表示一个完整命令,大部分的命令后面会带着参数,参数和命令之间用空格分开。这里大部分是控制命令,不需要建立数据链路。传输数据的命令有文件操作的命令,上传和下载文件(如STOR,STOU,RETR),还有LIST和NLST命令。详细的命令参数,参照一下FTP协议好了,这里不一一列出了。
还有一个问题是要了解服务器端返回的响应码格式,FTP的响应码是三个数字,第一个数字代表响应的好坏,第二个数字代表响应的详细信息,第三位预留做其他信息。第一个数字是1到5这些值,1表示在下一命令前等待应答;2表示完成应答,操作已完成,可以进行新命令;3表示命令已接受,但需要提供更多信息;4表示暂时拒绝应答,临时错误,可在以后再次发送命令;5表示永远拒绝应答。抄一个应答表放在下面作为参考。
|-----|------------------------------------------------------------------------|
| 110 | 重新启动标记应答。在这种情况下文本是确定的,它必须是:MARK yyyy=mmmm,其中yyyy是用户进程数据流标记,mmmm是服务器标记。 |
| 120 | 服务在nnn分钟内准备好 |
| 125 | 数据连接已打开,准备传送 |
| 150 | 文件状态良好,打开数据连接 |
| 200 | 命令成功 |
| 202 | 命令未实现 |
| 211 | 系统状态或系统帮助响应 |
| 212 | 目录状态 |
| 213 | 文件状态 |
| 214 | 帮助信息,信息仅对人类用户有用 |
| 215 | 名字系统类型 |
| 220 | 对新用户服务准备好 |
| 221 | 服务关闭控制连接,可以退出登录 |
| 225 | 数据连接打开,无传输正在进行 |
| 226 | 关闭数据连接,请求的文件操作成功 |
| 227 | 进入被动模式 |
| 230 | 用户登录 |
| 250 | 请求的文件操作完成 |
| 257 | 创建"PATHNAME" |
| 331 | 用户名正确,需要口令 |
| 332 | 登录时需要帐户信息 |
| 350 | 请求的文件操作需要进一步命令 |
| 421 | 不能提供服务,关闭控制连接 |
| 425 | 不能打开数据连接 |
| 426 | 关闭连接,中止传输 |
| 450 | 请求的文件操作未执行 |
| 451 | 中止请求的操作:有本地错误 |
| 452 | 未执行请求的操作:系统存储空间不足 |
| 500 | 格式错误,命令不可识别 |
| 501 | 参数语法错误 |
| 502 | 命令未实现 |
| 503 | 命令顺序错误 |
| 504 | 此参数下的命令功能未实现 |
| 530 | 未登录 |
| 532 | 存储文件需要帐户信息 |
| 550 | 未执行请求的操作 |
| 551 | 请求操作中止:页类型未知 |
| 552 | 请求的文件操作中止,存储分配溢出 |
| 553 | 未执行请求的操作:文件名不合法 |
主要的流程设计一下
我们这个FTP客户端程序是在Linux下用C语言开发的,所以我们的设计也用C语言的格式来描述,这样更清晰一些。
我们先来看一下主函数的流程。
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| int main(int argc, char **argv) { /* 解析命令行输入参数 */ parse_arguments(argc, argv); /* 连接到FTP服务器,完成登录 */ ftp_open(); /* 得到N+1 */ get_host_port(); /* 进入命令循环 */ While (loop) { /* 显示FTP提示符 */ fprintf(stderr, "ftp> "); /* 等待输入命令到命令缓冲区 cmdbuf */ /* 去掉命令中开始的空格和结尾的回车换行符 */ /* 分解出命令和命令参数 */ /* 在命令列表中查找已定义的命令,如果找到,通过相连的 * 命令处理函数执行命令动作 */ } /* 关闭与FTP服务器的连接,退出程序 */ } |
接下来我们看一下取文件的处理流程,这里用到了主动和被动两种模式。
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| int ftp_get(int sd, char *args) { /* 从参数args中取到文件名,如果没有指定目标文件名,以源文件名作为目标文件名 */ /* 准备好取文件的控制命令 RETR filename CR LF */ if (g_flag & FTP_FLAG_PASSIVE_ON) { /* 被动模式 */ /* 发送被动模式命令 PASV CR LF */ /* 接收应答,如果不是227,说明有错误 */ /* 解析227 后面的应答参数h1,h2,h3,h4,p1,p2 * 得到要连接的主机监听的端口 */ /* 上面的这些流程在ftp_passive()中实现 */ /* 发送获取文件的控制命令 */ /* 连接到FTP服务器监听端口,建立数据链路 */ /* 接收控制命令应答 */ } else { /* 主动模式 */ /* 建立一个INET流模式的socket * 绑定到本地控制连接端口N+1的端口 * 调用listen()监听 */ /* 准备PORT命令,把客户端IP地址和N+1端口 * 按照h1,h2,h3,h4,p1,p2的格式拼成参数 */ /* 发送PORT控制命令 */ /* 上面这些流程在create_listener()中实现 */ /* 发送获取文件的控制命令 */ /* 接收FTP服务器的连接,建立数据链路 */ /* 接收控制命令的应答 */ } /* 打开本地目标文件 */ /* 循环读取服务器端发送的文件数据,写入本地目标文件 */ while (1) { /* 调用recv_data()从数据链路上读取数据 */ /* 如果全部数据读取完毕(rc == 0),退出循环 */ /* 把读到的数据写入到本地文件中 */ } /* 关闭本地文件 */ /* 关闭数据链路 */ /* 如果是主动模式,关闭本地的监听连接 */ /* 接收文件传输完毕的控制应答 */ } |
其他的处理流程都是根据命令,写一个函数来处理,这里就不一一来描述了,源代码的逻辑也很简单,很容易看懂。
后语
你也许能看出来,这个文档是后写的。当初在项目中,也没有时间写这些东西,只是先把功能实现了,跑起来再说。现在把当时的代码拿来,改造成一个命令行的程序,等于把当时的情景和程序都重温了一遍,把代码也修改得更容易读一些。现在想一想,一些看似简单的东西,如果不去亲自做一下,也还是很有挑战的,当时项目组中的其他人不愿意去做,也可能觉得看起来简单,实际做起来也许不那么容易,做不出来,还很没面子。但是,程序员不应该惧怕挑战,只有克服了困难,才能提高,只有在程序跑通的刹那间,才能体会那种特有的喜悦。
这段时间想把以前做过的一些东西总结一下,有时候觉得很多东西在脑子里都是一盘散沙,以为当初工作中学到的东西都比较急迫,基本要求现学现用,所以有些也理解的不透彻,还有一些事完全没搞懂,只是工作应付过去了,也就放下了。后来想通过自己定义几个虚拟的项目,把一些东西写成文字,也通过写程序去实现它,做的过程中才发现好多东西也不确定,又重新查找资料,等于又重新学习了一遍。这样感觉也很好,孔子曰:温故而知新,很有道理,原来一些似是而非的地方总算搞清楚了。
总结的东西写成了文档,通过源代码实现了功能,觉得总算踏实了些,如果你也想总结一下自己过去的工作,不妨也这么来做。我注册了一个网站,把这些东西放到了上面,如果你想看一下,请访问:http://www.tomcoding.com
源代码可在www.tomcoding.com网站下载。