Ftp服务是最常用的网络服务之一,虽然在www风行的今天,Ftp已经远不如以前使用得广泛,但是在许多大学等科研单位,Ftp仍然是最常用的文件交换方式。
构建一个Ftp服务器要比构建一个Ftp客户端来得简单,因为服务器不需要复杂的图形界面。相比传统的C/C++,使用Java的多线程和网络编程能令我们更轻易地开发出稳定可靠的Ftp服务器。
Ftp协议简介
File Transfer Protocol,文件传输协议,顾名思义,Ftp就是用于文件的传输,Ftp协议是基于TCP协议的,因此,在一个Ftp会话开始前,客户端和服务器必须首先建立一个TCP连接,这个TCP连接通常被称作控制连接,客户端通过此连接向服务器发送FTP命令,服务器处理命令后,将返回一个响应码。
每个命令必须有最少一个响应,如果是多个,要易于区别。FTP响应由三个数字构成,后面是一些文本。数字带有足够的信息,客户端程序不用知道后面的文本就知道发生了什么。文本信息与服务器相关,不同的用户,不同的服务器可能有不同的文本信息。文本和数字以空格间隔,文本后以换行符(\n)结束。如果文本多于一行,第一行内要有信息表示这是多行文本,最后一行也要标记为结束行。比如客户端发送获取当前目录的命令“PWD”,服务器的响应可能是:
200 /pub/incoming
响应码的三位数字都有明确的含义:
•1xx 确定预备应答,这类响应用于说明命令被接受,但请求的操作正在被初始化,在进入下一个命令前等待另外的应答。
•2xx 确定完成应答,要求的操作已经完成,可以执行新命令。
•3xx 确定中间应答,命令已接受,但要求的操作被停止。
•4xx 暂时拒绝完成应答,未接受命令,但错误是临时的,过一会儿可以再次发送消息,比如服务器忙。
•5yz 永远拒绝完成应答,此类响应码一般表示错误,如拒绝登陆。
第二位数字代表的意义:
•
x0x 格式错误;
•
x1x 此类应答是为了请求信息的;
•
x2x 此类应答是关于控制和数据连接的;
•
x3x 关于认证和帐户登录过程;
•
x4x 未使用;
•
x5x 此类应答是关于文件系统的;
常见的相应有:
•
200 命令执行成功;
•
202 命令未实现;
•
230 用户登录;
•
331 用户名正确,需要口令;
•
450 请求的文件操作未执行;
•
500 命令不可识别
•
502 命令未实现
一个Ftp会话过程中,始终有一个控制连接,如果客户端请求文件,则会有一个数据连接,但FTP协议规定:只要关闭了控制连接,数据连接(如果有)也必须关闭。
不同的FTP服务器对FTP命令的支持程度可能不同,但是TCP标准定义了所有FTP服务器都必须实现的命令,我们的目标就是构建一个实现这个最小命令集的FTP服务器。
前面讨论了基本的FTP协议和会话,下面我们用Java来开发一个简单的Ftp服务器。
为了简单起见,我们只设计两个类:一个FtpServer类用于监听,一个FtpConnection类代表一个用户连接,每个连接都使用一个线程。
FtpServer负责初始化ServerSocket并监听用户连接,它接受一个参数来初始化Ftp服务器的根目录:
package jftp;
import java.net.*;
public class FtpServer extends Thread {
public static final int FTP_PORT = 21; // default port
ServerSocket ftpsocket = null;
public static void main(String[] args) {
if(args.length!=1) {
System.out.println("Usage:");
System.out.println("java FtpServer [root dir]");
System.out.println("nExample:");
System.out.println("java FtpServer C:\\ftp\\");
return;
}
FtpConnection.root = args[0];
System.out.println("[info] ftp server root: " + FtpConnection.root);
new FtpServer().start();
}
public void run() {
Socket client = null;
try {
ftpsocket = new ServerSocket(FTP_PORT);
System.out.println("[info] listening port: " + FTP_PORT);
for(;;) {
client = ftpsocket.accept();
new FtpConnection(client).start();
}
}
catch(Exception e) { e.printStackTrace(); }
}
}
每当有一个客户连接,就创建一个新的FtpConnection线程以便为用户服务,你可以很方便地限制最大连接数以确保Ftp服务器负担不会过重。
下面我们要处理用户连接,也就是FtpConnection类。Ftp连接本质上是一个状态机,当FtpConnection接收到用户命令后,根据当前状态决定响应及下一个状态。不过我们不需要考虑实现一个复杂的状态机,只须监听/接收/处理/响应即可:
package jftp;
import java.net.*;
import java.io.*;
import java.util.*;
import java.text.*;
public class FtpConnection extends Thread {
/** 主目录 */
static public String root = null;
private String currentDir = "/"; // 当前目录
private Socket socket;
private BufferedReader reader = null;
private BufferedWriter writer = null;
private String clientIP = null;
private Socket tempSocket = null; // tempSocket用于传送文件
private ServerSocket pasvSocket = null; // 用于被动模式
private String host = null;
private int port = (-1);
public FtpConnection(Socket socket) {
this.socket = socket;
this.clientIP = socket.getInetAddress().getHostAddress();
}
public void run() {
String command;
try {
System.out.println(clientIP + " connected.");
socket.setSoTimeout(60000); // ftp超时设定
reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
response("220-欢迎消息......");
response("220-欢迎消息......");
response("220 注意最后一行欢迎消息没有“-”");
for(;;) {
command = reader.readLine();
if(command == null)
break;
System.out.println("command from " + clientIP + " : " + command);
parseCommand(command);
if(command.equals("QUIT")) // 收到QUIT命令
break;
}
}
catch(Exception e) { e.printStackTrace(); }
finally {
try {
if(reader!=null) reader.close();
}catch(Exception e) {}
try {
if(writer!=null) writer.close();
}catch(Exception e) {}
try {
if(this.pasvSocket!=null) pasvSocket.close();
}catch(Exception e) {}
try {
if(this.tempSocket!=null) tempSocket.close();
}catch(Exception e) {}
try {
if(this.socket!=null) socket.close();
}catch(Exception e) {}
}
System.out.println(clientIP + " disconnected.");
}
//FtpConnection在run()方法中仅仅是获得用户命令/处理命令,当收到QUIT时,关闭连接,结束Ftp会话。
//先准备几个辅助方法:
private void response(String s) throws Exception {
// System.out.println(" [RESPONSE] "+s);
writer.write(s);
writer.newLine();
writer.flush(); // 注意要flush否则响应仍在缓冲区
}
// 生成一个字符串
private static String pad(int length) {
StringBuffer buf = new StringBuffer();
for (int i = 0; i < length; i++)
buf.append((char)' ');
return buf.toString();
}
// 获取参数
private String getParam(String cmd, String start) {
String s = cmd.substring(start.length(), cmd.length());
return s.trim();
}
// 获取路径
private String translatePath(String path) {
if(path==null) return root;
if(path.equals("")) return root;
path = path.replace('/', '\\');
return root + path;
}
// 获取文件长度,注意是一个字符串
private String getFileLength(long length) {
String s = Long.toString(length);
int spaces = 12 - s.length();
for (int i = 0; i < spaces; i++)
s = " " + s;
return s;
}
//接下来便是处理用户命令,这个方法有点长,需要重构一下,我只是把LIST命令单独挪了出来:
private void parseCommand(String s) throws Exception {
if(s==null || s.equals(""))
return;
if(s.startsWith("USER ")) {
response("331 need password");
}
else if(s.startsWith("PASS ")) {
response("230 welcome to my ftp!");
}
else if(s.equals("QUIT")) {
response("221 欢迎再来!");
}
else if(s.equals("TYPE A")) {
response("200 TYPE set to A.");
}
else if(s.equals("TYPE I")) {
response("200 TYPE set to I.");
}
else if(s.equals("NOOP")) {
response("200 NOOP OK.");
}
else if(s.startsWith("CWD")) { // 设置当前目录,注意没有检查目录是否有效
this.currentDir = getParam(s, "CWD ");
response("250 CWD command successful.");
}
else if(s.equals("PWD")) { // 打印当前目录
response("257 \"" + this.currentDir + "\" is current directory.");
}
else if(s.startsWith("PORT ")) {
// 记录端口
String[] params = getParam(s, "PORT ").split(",");
if(params.length<=4 || params.length>=7)
response("500 command param error.");
else {
this.host = params[0] + "." + params[1] + "." + params[2] + "." + params[3];
String port1 = null;
String port2 = null;
if(params.length == 6) {
port1 = params[4];
port2 = params[5];
}
else {
port1 = "0";
port2 = params[4];
}
this.port = Integer.parseInt(port1) * 256 + Integer.parseInt(port2);
response("200 command successful.");
}
}
else if(s.equals("PASV")) { // 进入被动模式
if(pasvSocket!=null)
pasvSocket.close();
try {
pasvSocket = new ServerSocket(0);
int pPort = pasvSocket.getLocalPort();
String s_port;
if(pPort<=255)
s_port = "255";
else {
int p1 = pPort / 256;
int p2 = pPort - p1*256;
s_port = p1 + "," + p2;
}
pasvSocket.setSoTimeout(60000);
response("227 Entering Passive Mode ("
+ InetAddress.getLocalHost().getHostAddress().replace('.', ',')
+ "," + s_port + ")");
}
catch(Exception e) {
if(pasvSocket!=null) {
pasvSocket.close();
pasvSocket = null;
}
}
}
else if(s.startsWith("RETR")) { // 传文件
String file = currentDir + (currentDir.endsWith("/") ? "" : "/") + getParam(s, "RETR");
System.out.println("download file: " + file);
Socket dataSocket;
// 根据上一次的PASV或PORT命令决定使用哪个socket
if(pasvSocket!=null)
dataSocket = pasvSocket.accept();
else
dataSocket = new Socket(this.host, this.port);
OutputStream dos = null;
InputStream fis = null;
response("150 Opening ASCII mode data connection.");
try {
fis = new BufferedInputStream(new FileInputStream(translatePath(file)));
dos = new DataOutputStream(new BufferedOutputStream(dataSocket.getOutputStream()));
// 开始正式发送数据:
byte[] buffer = new byte[20480]; // 发送缓冲 20k
int num = 0; // 发送一次读取的字节数
do {
num = fis.read(buffer);
if(num!=(-1)) {
// 发送:
dos.write(buffer, 0, num);
dos.flush();
}
} while(num!=(-1));
fis.close();
fis = null;
dos.close();
dos = null;
dataSocket.close();
dataSocket = null;
response("226 transfer complete."); // 响应一个成功标志
}
catch(Exception e) {
response("550 ERROR: File not found or access denied.");
}
finally {
try {
if(fis!=null) fis.close();
if(dos!=null) dos.close();
if(dataSocket!=null) dataSocket.close();
}
catch(Exception e) {}
}
}
else if(s.equals("LIST")) { // 列当前目录文件
Socket dataSocket;
// 根据上一次的PASV或PORT命令决定使用哪个socket
if(pasvSocket!=null)
dataSocket = pasvSocket.accept();
else
dataSocket = new Socket(this.host, this.port);
PrintWriter writer = new PrintWriter(new BufferedOutputStream(dataSocket.getOutputStream()));
response("150 Opening ASCII mode data connection.");
try {
responseList(writer, this.currentDir);
writer.close();
dataSocket.close();
response("226 transfer complete.");
}
catch(IOException e) {
writer.close();
dataSocket.close();
response(e.getMessage());
}
dataSocket = null;
}
else {
response("500 invalid command"); // 没有匹配的命令,输出错误信息
}
}
// 响应LIST命令
private void responseList(PrintWriter writer, String path) throws IOException {
File dir = new File(translatePath(path));
if(!dir.isDirectory())
throw new IOException("550 No such file or directory");
File[] files = dir.listFiles();
String dateStr;
for(int i=0; i dateStr = new SimpleDateFormat("MMM dd hh:mm").format(new Date(files[i].lastModified()));
if(files[i].isDirectory()) {
writer.println("drwxrwxrwx 1 ftp System 0 "
+ dateStr + " " + files[i].getName());
}
else {
writer.println("-rwxrwxrwx 1 ftp System "
+ getFileLength(files[i].length()) + " " + dateStr + " " + files[i].getName());
}
}
String file_header = "-rwxrwxrwx 1 ftp System 0 Aug 5 19:59 ";
String dir_header = "drwxrwxrwx 1 ftp System 0 Aug 15 19:59 ";
writer.println("total " + files.length);
writer.flush();
}
}
基本上我们的Ftp已经可以运行了,注意到我们在FtpConnection中处理USER和PASS命令,直接返回200 OK,如果需要验证用户名和口令,还需要添加相应的代码。
如何调试Ftp服务器?
有个最简单的方法,便是使用现成的Ftp客户端,推荐CuteFtp,因为它总是把客户端发送的命令和服务器响应打印出来,我们可以非常方便的看到服务器的输出结果。
另外一个小Bug,文件列表在CuteFtp中可以正常显示,在其他Ftp客户端不一定能正常显示,这说明输出响应的“兼容性”还不够好,有空了看看Ftp的RFC再改进!:)