org.apache.commons.net.ftp.FTPClient 是 Java 操作 FTP 的標準客戶端。核心生命週期:
connect(host, port)login(user, pass)logout() + disconnect()| 配置 | 方法 | 說明 | 生產建議值 |
|---|---|---|---|
| 控制編碼 | setControlEncoding() | 命令/回應文本編碼,影響列表解析 | UTF-8 |
| 傳輸模式 | setFileType() | ASCII(文本) vs BINARY(二進制) | BINARY_FILE_TYPE |
| 被動模式 | enterLocalPassiveMode() | 客戶端主動發起數據連線,穿透防火牆 | true (必須) |
| 連線超時 | setConnectTimeout() | TCP 建立連線最長等待 | 10-15 秒 |
| 數據超時 | setDataTimeout() | 數據傳輸閒置最長等待 | 60-300 秒 |
| 緩衝區大小 | setBufferSize() | 內部 IO 緩衝區,影響大文件吞吐 | 8192 / 65536 |
// 上傳:storeFile / storeFileStream / appendFile / appendFileStream
boolean storeFile(String remote, InputStream local)
OutputStream storeFileStream(String remote) // 需手動 completePendingCommand()
// 下載:retrieveFile / retrieveFileStream
boolean retrieveFile(String remote, OutputStream local)
InputStream retrieveFileStream(String remote)
// 斷點續傳關鍵
void setRestartOffset(long offset) // 設置下次傳輸起始位置
long getRestartOffset()
public class FtpOperator implements AutoCloseable {
private final FTPClient ftp = new FTPClient();
private final String encoding;
public FtpOperator(String host, int port, String user, String pass,
String workDir, String encoding, boolean passive) throws IOException {
this.encoding = encoding;
ftp.setControlEncoding(encoding);
ftp.setDefaultTimeout(30000);
ftp.setConnectTimeout(10000);
ftp.setDataTimeout(120000);
ftp.setBufferSize(65536);
ftp.connect(host, port);
checkReply("connect");
ftp.login(user, pass);
checkReply("login");
ftp.setFileType(FTP.BINARY_FILE_TYPE);
if (passive) ftp.enterLocalPassiveMode();
if (workDir != null) ftp.changeWorkingDirectory(workDir);
}
private void checkReply(String op) throws IOException {
if (!FTPReply.isPositiveCompletion(ftp.getReplyCode())) {
throw new IOException(op + " 失敗,回應碼:" + ftp.getReplyCode() + " " + ftp.getReplyString());
}
}
// 編碼安全的文件名轉換
private String encodeName(String name) throws UnsupportedEncodingException {
return new String(name.getBytes(encoding), "ISO-8859-1");
}
public boolean upload(String remoteName, InputStream in) throws IOException {
return ftp.storeFile(encodeName(remoteName), in);
}
public boolean download(String remoteName, OutputStream out) throws IOException {
return ftp.retrieveFile(encodeName(remoteName), out);
}
// 斷點續傳上傳
public boolean uploadResume(String remote, File local) throws IOException {
long remoteSize = getFileSize(remote);
if (remoteSize >= local.length()) return true;
try (RandomAccessFile raf = new RandomAccessFile(local, "r")) {
raf.seek(remoteSize);
ftp.setRestartOffset(remoteSize);
return ftp.storeFile(encodeName(remote),
new BufferedInputStream(Channels.newInputStream(raf.getChannel())));
}
}
private long getFileSize(String remote) throws IOException {
FTPFile[] files = ftp.listFiles(encodeName(remote));
return (files.length == 1) ? files[0].getSize() : 0;
}
public FTPFile[] listFiles(String path) throws IOException {
return ftp.listFiles(encodeName(path));
}
public boolean deleteFile(String remote) throws IOException {
return ftp.deleteFile(encodeName(remote));
}
public boolean makeDirectory(String dir) throws IOException {
return ftp.makeDirectory(encodeName(dir));
}
@Override
public void close() {
try { if (ftp.isConnected()) { ftp.logout(); ftp.disconnect(); } }
catch (IOException ignored) {}
}
}
// Try-with-resources 自動關閉
try (FtpOperator ftp = new FtpOperator("10.0.0.1", 21, "user", "pass",
"/data", "UTF-8", true)) {
// 上傳
try (FileInputStream in = new FileInputStream("/local/report.pdf")) {
ftp.upload("report.pdf", in);
}
// 大文件斷點續傳
ftp.uploadResume("backup.tar.gz", new File("/data/backup.tar.gz"));
// 下載
try (FileOutputStream out = new FileOutputStream("/local/report.pdf")) {
ftp.download("report.pdf", out);
}
// 列表
for (FTPFile f : ftp.listFiles("/data")) {
System.out.printf("%s %12d %s%n",
f.isDirectory() ? "DIR" : "FILE", f.getSize(), f.getName());
}
}
| 異常/現象 | 可能原因 | 排查步驟 |
|---|---|---|
| Connection refused | IP/端口錯誤、防火牆阻擋、FTP 服務未啟動 | telnet host port、檢查安全組/iptables |
| 530 Login incorrect | 用戶名/密碼錯誤、用戶被鎖定、權限不足 | 驗證憑證、檢查 vsftpd/proftpd 配置 |
| 425 Can't open data connection | 主動模式下服務器無法連回客戶端 | 強制 enterLocalPassiveMode() |
| 550 Failed to change directory | 目錄不存在、權限不足、路徑編碼錯誤 | 檢查編碼轉換、絕對路徑 vs 相對路徑 |
| 中文文件名亂碼/無法訪問 | 編碼轉換不一致 | 統一 UTF-8 → ISO-8859-1 雙向轉換 |
| 上傳大文件失敗/卡死 | 超時設置過短、緩衝區過小、網絡抖動 | 調大 dataTimeout、bufferSize、啟用斷點續傳 |
public class FtpClientPool {
private final GenericObjectPool pool;
public FtpClientPool(FtpConfig config, int maxTotal, int maxIdle) {
PooledObjectFactory factory = new PooledObjectFactory() {
@Override public PooledObject makeObject() throws Exception {
return new DefaultPooledObject<>(new FtpOperator(
config.getHost(), config.getPort(), config.getUser(), config.getPass(),
config.getWorkDir(), config.getEncoding(), config.isPassive()));
}
@Override public void destroyObject(PooledObject p) {
try { p.getObject().close(); } catch (Exception ignored) {}
}
@Override public boolean validateObject(PooledObject p) {
try { return p.getObject().ftp.sendNoOp(); } catch (Exception e) { return false; }
}
@Override public void activateObject(PooledObject p) throws Exception {
p.getObject().ftp.sendNoOp(); // 保活
}
@Override public void passivateObject(PooledObject p) throws Exception {}
};
pool = new GenericObjectPool<>(factory);
pool.setMaxTotal(maxTotal);
pool.setMaxIdle(maxIdle);
pool.setMinIdle(2);
pool.setTestOnBorrow(true);
pool.setTestWhileIdle(true);
}
public FtpOperator borrow() throws Exception { return pool.borrowObject(); }
public void returnObj(FtpOperator ftp) { pool.returnObject(ftp); }
}
掌握 FTPClient 核心在於:編碼雙向轉換、被動模式強制啟用、二進制模式固定、超時與緩衝區調優、斷點續傳 API 正確使用、連線池複用。將上述最佳實踐封裝為 FtpOperator + FtpClientPool,即可獲得生產級穩健的 FTP 操作能力。