FTP (File Transfer Protocol) 仍是企業級文件交換主流協議。Java 實現兩大主流方案:
| 方案 | 核心類 | 依賴 | 特點 |
|---|---|---|---|
| JDK 原生 | sun.net.ftp.FtpClient | 無額外依賴 | 輕量、但屬內部 API,不穩定、功能有限、中文處理麻煩 |
| Apache Commons Net | org.apache.commons.net.ftp.FTPClient | commons-net.jar | 標準庫、功能完整、活躍維護、生產環境首選 |
強烈建議生產環境使用 Apache Commons Net。
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.9.0</version> <!-- 最新版 -->
</dependency>
public class FtpUtils {
private static final String DEFAULT_ENCODING = "UTF-8";
// 連線配置
public static FTPClient connect(String host, int port, String user, String pass,
String workingDir, boolean passiveMode) throws IOException {
FTPClient ftp = new FTPClient();
ftp.setControlEncoding(DEFAULT_ENCODING); // 關鍵:控制連線編碼
ftp.connect(host, port);
int reply = ftp.getReplyCode();
if (!FTPReply.isPositiveCompletion(reply)) {
ftp.disconnect();
throw new IOException("FTP 連線失敗,回應碼:" + reply);
}
if (!ftp.login(user, pass)) {
ftp.disconnect();
throw new IOException("FTP 登入失敗");
}
ftp.setFileType(FTP.BINARY_FILE_TYPE); // 二進制模式,支援所有文件
if (passiveMode) ftp.enterLocalPassiveMode(); // 被動模式,穿透防火牆/NAT
if (workingDir != null && !ftp.changeWorkingDirectory(workingDir)) {
throw new IOException("無法切換到工作目錄:" + workingDir);
}
return ftp;
}
// 上傳文件
public static boolean upload(FTPClient ftp, String remoteFileName, InputStream localInputStream)
throws IOException {
// 關鍵:文件名編碼轉換,解決中文亂碼
String encodedName = new String(remoteFileName.getBytes(DEFAULT_ENCODING), "ISO-8859-1");
return ftp.storeFile(encodedName, localInputStream);
}
// 下載文件
public static boolean download(FTPClient ftp, String remoteFileName, OutputStream localOutputStream)
throws IOException {
String encodedName = new String(remoteFileName.getBytes(DEFAULT_ENCODING), "ISO-8859-1");
return ftp.retrieveFile(encodedName, localOutputStream);
}
// 斷點續傳上傳
public static boolean uploadResume(FTPClient ftp, String remoteFile, File localFile)
throws IOException {
long remoteSize = getFileSize(ftp, remoteFile);
long localSize = localFile.length();
if (remoteSize >= localSize) return true; // 已完成
try (RandomAccessFile raf = new RandomAccessFile(localFile, "r")) {
raf.seek(remoteSize);
ftp.setRestartOffset(remoteSize); // 設置斷點
String encoded = new String(remoteFile.getBytes(DEFAULT_ENCODING), "ISO-8859-1");
return ftp.storeFile(encoded, new BufferedInputStream(new FileInputStream(raf.getFD())));
}
}
// 獲取遠程文件大小
private static long getFileSize(FTPClient ftp, String remoteFile) throws IOException {
FTPFile[] files = ftp.listFiles(new String(remoteFile.getBytes(DEFAULT_ENCODING), "ISO-8859-1"));
return (files.length == 1) ? files[0].getSize() : 0;
}
// 安全關閉
public static void closeQuietly(FTPClient ftp) {
if (ftp != null && ftp.isConnected()) {
try { ftp.logout(); } catch (IOException ignored) {}
try { ftp.disconnect(); } catch (IOException ignored) {}
}
}
}
// 上傳
try (FTPClient ftp = FtpUtils.connect("192.168.1.100", 21, "user", "pass", "/upload", true);
FileInputStream in = new FileInputStream("/local/path/report_中文.pdf")) {
boolean ok = FtpUtils.upload(ftp, "report_中文.pdf", in);
System.out.println("上傳:" + (ok ? "成功" : "失敗"));
}
// 下載
try (FTPClient ftp = FtpUtils.connect("192.168.1.100", 21, "user", "pass", "/download", true);
FileOutputStream out = new FileOutputStream("/local/path/report_中文.pdf")) {
boolean ok = FtpUtils.download(ftp, "report_中文.pdf", out);
System.out.println("下載:" + (ok ? "成功" : "失敗"));
}
// 斷點續傳大文件上傳
File bigFile = new File("/data/backup_10GB.tar.gz");
try (FTPClient ftp = FtpUtils.connect("192.168.1.100", 21, "user", "pass", "/backup", true)) {
FtpUtils.uploadResume(ftp, "backup_10GB.tar.gz", bigFile);
}
// 僅展示核心差異,完整代碼見原文
FtpClient ftp = new FtpClient("host");
ftp.login("user", "pass");
ftp.binary(); // 設置二進制
TelnetOutputStream os = ftp.put("remote.txt"); // 上傳
TelnetInputStream is = ftp.get("remote.txt"); // 下載
// 缺點:無被動模式支援、無編碼控制、無斷點續傳、屬 sun.* 內部 API 可能隨版本變動
根因:FTP 協議 (RFC 959) 規定路徑/文件名使用 ISO-8859-1 (Latin-1)。伺服器檔案系統實際編碼可能是 UTF-8、GBK、BIG5 等。
統一解法:
// 發送端(上傳/下載/刪除/重命名等命令參數)
String encoded = new String(originalName.getBytes(serverCharset), "ISO-8859-1");
// 接收端(列表解析)
String decoded = new String(receivedBytes, "ISO-8859-1").getBytes(serverCharset);
FTPClient.setControlEncoding("UTF-8") 僅處理控制連線(命令/回應文本),不處理文件名編碼,需手動轉換。
| 項目 | 建議配置 |
|---|---|
| 連線池 | 使用 GenericObjectPool 複用連線,避免頻繁建立/關閉開銷 |
| 被動模式 | 強制 enterLocalPassiveMode(),適應防火牆/NAT/雲環境 |
| 超時設置 | setConnectTimeout(10000)、setDefaultTimeout(30000)、setDataTimeout(60000) |
| 二進制模式 | 強制 setFileType(FTP.BINARY_FILE_TYPE),避免文本模式損壞二進制文件 |
| 大文件支援 | 啟用 ftpClient.setRestartOffset() 斷點續傳,分片上傳合併 |
| 安全協議 | 敏感數據必須用 FTPSClient (FTP over SSL/TLS) 或 SFTP (SSH) |
| 異常重試 | 網絡抖動時指數退避重試 3 次,記錄失敗入死信隊列人工介入 |
| 監控指標 | 上傳/下載成功率、平均耗時、併發連線數、錯誤碼分布 |
@ConfigurationProperties(prefix = "ftp")
@Data
public class FtpProperties {
private String host; private int port = 21;
private String username; private String password;
private String workingDir; private boolean passiveMode = true;
private String encoding = "UTF-8";
}
@Component
public class FtpTemplate {
@Autowired private FtpProperties props;
private final ObjectPool pool;
public FtpTemplate(FtpProperties props) {
this.props = props;
this.pool = new GenericObjectPool<>(new FtpClientFactory(props));
}
public boolean upload(String remoteName, InputStream in) {
try (FTPClient ftp = pool.borrowObject()) {
return FtpUtils.upload(ftp, remoteName, in);
} catch (Exception e) { throw new FtpException(e); }
}
// download, list, delete, mkdir 等封裝...
}
FTP 看似簡單,實則坑多(編碼、被動模式、斷點續傳、連線管理、安全性)。棄用 JDK 原生 API,全面擁抱 Apache Commons Net + 連線池 + 統一編碼轉換 + 連線池監控,才是企業級穩健之道。對於新專案,優先考慮 SFTP (JSch/Apache MINA SSHD) 或 對象存儲 (S3/MinIO/OSS) 替代傳統 FTP。