詳細的FTP傳輸實現方案,包括完整代碼、安全實踐、性能優化和實際應用場景分析。
一、FTP傳輸類型對比表(增強版)
特性 | 非加密FTP | FTPS (FTP over SSL/TLS) | SFTP (SSH File Transfer Protocol) |
---|---|---|---|
協議基礎 | FTP (RFC 959) | FTP + SSL/TLS (RFC 4217) | SSH-2 (RFC 4253) |
默認端口 | 21 (控制), 20 (數據) | 990 (隱式), 21 (顯式) | 22 |
加密方式 | 無 | SSL/TLS加密 | SSH加密隧道 |
數據完整性 | 無 | SSL/TLS保證 | SSH保證 |
認證機制 | 用戶名/密碼 | 用戶名/密碼 + 證書 | 用戶名/密碼 + 密鑰對 |
防火墻友好 | 差 (需要多個端口) | 中等 (需要控制+數據端口) | 優 (單一端口) |
Android實現庫 | Apache Commons Net | Apache Commons Net | JSch/SSHJ |
性能開銷 | 最低 | 中等 (10-15%) | 中等 (8-12%) |
推薦場景 | 內部測試網絡 | 企業級文件傳輸 | 互聯網文件傳輸 |
二、完整實現方案
1. 項目配置
build.gradle (模塊級):
dependencies {// FTP/FTPS 實現implementation 'commons-net:commons-net:3.9.0'// SFTP 實現 (選擇其一)implementation 'com.jcraft:jsch:0.1.55' // 方案1implementation 'com.hierynomus:sshj:0.35.0' // 方案2(更現代)// 后臺任務implementation 'androidx.work:work-runtime:2.7.1'// 安全存儲implementation 'androidx.security:security-crypto:1.1.0-alpha03'// 日志implementation 'com.jakewharton.timber:timber:5.0.1'
}
AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /><!-- 存儲權限處理 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"tools:ignore="ScopedStorage" />
2. 非加密FTP實現(增強版)
import org.apache.commons.net.ftp.FTP;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPReply;import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.concurrent.TimeUnit;public class FtpUploader {private static final int CONNECT_TIMEOUT = 30; // 秒private static final int DATA_TIMEOUT = 120; // 秒public static FtpResult uploadFile(String server, int port, String username, String password, String localPath, String remoteDir, String remoteFileName) {FTPClient ftpClient = new FTPClient();FileInputStream inputStream = null;FtpResult result = new FtpResult();try {// 1. 配置客戶端ftpClient.setConnectTimeout((int) TimeUnit.SECONDS.toMillis(CONNECT_TIMEOUT));ftpClient.setDataTimeout((int) TimeUnit.SECONDS.toMillis(DATA_TIMEOUT));ftpClient.setControlEncoding("UTF-8");// 2. 連接服務器ftpClient.connect(server, port);int replyCode = ftpClient.getReplyCode();if (!FTPReply.isPositiveCompletion(replyCode)) {result.error = "FTP服務器拒絕連接。響應代碼: " + replyCode;return result;}// 3. 登錄認證if (!ftpClient.login(username, password)) {result.error = "FTP登錄失敗。請檢查憑據。";return result;}// 4. 配置傳輸模式ftpClient.enterLocalPassiveMode(); // 重要:應對防火墻/NATftpClient.setFileType(FTP.BINARY_FILE_TYPE);// 5. 創建遠程目錄(如果需要)if (remoteDir != null && !remoteDir.isEmpty()) {createDirectoryTree(ftpClient, remoteDir);}// 6. 上傳文件File localFile = new File(localPath);if (!localFile.exists()) {result.error = "本地文件不存在: " + localPath;return result;}inputStream = new FileInputStream(localFile);String remotePath = (remoteDir != null ? remoteDir + "/" : "") + remoteFileName;long startTime = System.currentTimeMillis();boolean success = ftpClient.storeFile(remotePath, inputStream);long duration = System.currentTimeMillis() - startTime;if (success) {result.success = true;result.fileSize = localFile.length();result.durationMs = duration;Timber.d("FTP上傳成功: %d bytes, 耗時: %d ms", result.fileSize, result.durationMs);} else {result.error = "文件存儲失敗。服務器響應: " + ftpClient.getReplyString();}} catch (Exception e) {result.error = "FTP異常: " + e.getMessage();Timber.e(e, "FTP上傳失敗");} finally {// 7. 清理資源try {if (inputStream != null) inputStream.close();if (ftpClient.isConnected()) {ftpClient.logout();ftpClient.disconnect();}} catch (IOException e) {Timber.e(e, "FTP清理資源時出錯");}}return result;}private static void createDirectoryTree(FTPClient ftpClient, String path) throws IOException {String[] pathElements = path.split("/");if (pathElements.length > 0 && pathElements[0].isEmpty()) {pathElements[0] = "/";}for (String element : pathElements) {if (element.isEmpty()) continue;// 檢查目錄是否存在if (!ftpClient.changeWorkingDirectory(element)) {// 目錄不存在則創建if (ftpClient.makeDirectory(element)) {ftpClient.changeWorkingDirectory(element);} else {throw new IOException("無法創建目錄: " + element);}}}}public static class FtpResult {public boolean success = false;public long fileSize = 0;public long durationMs = 0;public String error = null;}
}
3. FTPS實現(增強安全版)
import org.apache.commons.net.ftp.FTPSClient;
import org.apache.commons.net.util.TrustManagerUtils;import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.File;
import java.io.FileInputStream;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;public class FtpsUploader {public static FtpResult uploadFile(String server, int port, String username, String password, String localPath, String remoteDir, String remoteFileName, boolean explicit, boolean validateCert) {FTPSClient ftpsClient;if (explicit) {// 顯式 FTPS (FTPES)ftpsClient = new FTPSClient("TLS");} else {// 隱式 FTPSftpsClient = new FTPSClient(true); }// 配置SSL上下文try {SSLContext sslContext = SSLContext.getInstance("TLS");if (validateCert) {// 生產環境:使用系統默認信任管理器sslContext.init(null, null, null);} else {// 測試環境:接受所有證書(不推薦生產使用)TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() {public X509Certificate[] getAcceptedIssuers() {return new X509Certificate[0];}public void checkClientTrusted(X509Certificate[] certs, String authType) {}public void checkServerTrusted(X509Certificate[] certs, String authType) {}}};sslContext.init(null, trustAllCerts, null);}ftpsClient.setSSLContext(sslContext);} catch (NoSuchAlgorithmException | KeyManagementException e) {FtpResult result = new FtpResult();result.error = "SSL配置失敗: " + e.getMessage();return result;}// 設置其他參數ftpsClient.setConnectTimeout(30000);ftpsClient.setDataTimeout(120000);// 啟用服務器主機名驗證ftpsClient.setHostnameVerifier((hostname, session) -> true); // 生產環境應實現驗證try {// 連接服務器ftpsClient.connect(server, port);// 顯式模式需要發送"AUTH TLS"命令if (explicit) {ftpsClient.execPROT("P"); // 保護數據通道}// 登錄和文件傳輸邏輯與非加密FTP類似...// 參考FtpUploader的實現,添加以下安全步驟:// 登錄后啟用安全數據通道ftpsClient.execPBSZ(0); // 設置保護緩沖區大小ftpsClient.execPROT("P"); // 設置數據通道保護// ... 其余上傳邏輯與FtpUploader相同} catch (Exception e) {// 錯誤處理} finally {// 清理資源}// 返回結果...}
}
4. SFTP實現(使用SSHJ - 現代方案)
import net.schmizz.sshj.SSHClient;
import net.schmizz.sshj.sftp.SFTPClient;
import net.schmizz.sshj.transport.verification.PromiscuousVerifier;
import net.schmizz.sshj.xfer.FileSystemFile;import java.io.File;
import java.io.IOException;
import java.security.PublicKey;public class SftpUploader {public static SftpResult uploadFile(String server, int port, String username,String password, String localPath,String remoteDir, String remoteFileName,boolean verifyHostKey) {SSHClient sshClient = new SSHClient();SftpResult result = new SftpResult();try {// 1. 配置SSH客戶端sshClient.addHostKeyVerifier(new PromiscuousVerifier() {@Overridepublic boolean verify(String hostname, int port, PublicKey key) {if (verifyHostKey) {// 生產環境應驗證主機密鑰// 實現方式:將已知主機密鑰存儲在安全位置并比較return super.verify(hostname, port, key);}return true; // 測試環境跳過驗證}});sshClient.setConnectTimeout(30000);sshClient.setTimeout(120000);// 2. 連接服務器sshClient.connect(server, port);// 3. 認證sshClient.authPassword(username, password);// 可選:密鑰認證// sshClient.authPublickey(username, "path/to/private/key");// 4. 創建SFTP客戶端try (SFTPClient sftpClient = sshClient.newSFTPClient()) {// 5. 創建遠程目錄(如果需要)if (remoteDir != null && !remoteDir.isEmpty()) {createRemoteDirectory(sftpClient, remoteDir);}// 6. 上傳文件String remotePath = remoteDir + "/" + remoteFileName;File localFile = new File(localPath);long startTime = System.currentTimeMillis();sftpClient.put(new FileSystemFile(localFile), remotePath);long duration = System.currentTimeMillis() - startTime;result.success = true;result.fileSize = localFile.length();result.durationMs = duration;Timber.d("SFTP上傳成功: %d bytes, 耗時: %d ms", result.fileSize, result.durationMs);}} catch (Exception e) {result.error = "SFTP錯誤: " + e.getMessage();Timber.e(e, "SFTP上傳失敗");} finally {try {sshClient.disconnect();} catch (IOException e) {Timber.e(e, "關閉SSH連接時出錯");}}return result;}private static void createRemoteDirectory(SFTPClient sftp, String path) throws IOException {String[] folders = path.split("/");String currentPath = "";for (String folder : folders) {if (folder.isEmpty()) continue;currentPath += "/" + folder;try {sftp.lstat(currentPath); // 檢查目錄是否存在} catch (IOException e) {// 目錄不存在則創建sftp.mkdir(currentPath);}}}public static class SftpResult {public boolean success = false;public long fileSize = 0;public long durationMs = 0;public String error = null;}
}
5. 后臺任務管理(WorkManager增強版)
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;public class FileUploadWorker extends Worker {private static final String KEY_SERVER = "server";private static final String KEY_PORT = "port";private static final String KEY_USERNAME = "username";private static final String KEY_PASSWORD = "password";private static final String KEY_LOCAL_PATH = "local_path";private static final String KEY_REMOTE_DIR = "remote_dir";private static final String KEY_REMOTE_FILE = "remote_file";private static final String KEY_PROTOCOL = "protocol"; // "ftp", "ftps", "sftp"private static final String KEY_VERIFY_CERT = "verify_cert"; // 僅FTPS/SFTPpublic FileUploadWorker(@NonNull Context context, @NonNull WorkerParameters params) {super(context, params);}@NonNull@Overridepublic Result doWork() {Data inputData = getInputData();// 從輸入數據中提取參數String server = inputData.getString(KEY_SERVER);int port = inputData.getInt(KEY_PORT, 21);String username = inputData.getString(KEY_USERNAME);String password = inputData.getString(KEY_PASSWORD);String localPath = inputData.getString(KEY_LOCAL_PATH);String remoteDir = inputData.getString(KEY_REMOTE_DIR);String remoteFile = inputData.getString(KEY_REMOTE_FILE);String protocol = inputData.getString(KEY_PROTOCOL);boolean verifyCert = inputData.getBoolean(KEY_VERIFY_CERT, false);// 根據協議選擇上傳方法try {boolean success;switch (protocol) {case "ftps":// FTPS可以使用顯式或隱式模式boolean explicit = port == 21; // 通常顯式模式使用21端口FtpsUploader.FtpResult ftpsResult = FtpsUploader.uploadFile(server, port, username, password, localPath, remoteDir, remoteFile, explicit, verifyCert);success = ftpsResult.success;break;case "sftp":SftpUploader.SftpResult sftpResult = SftpUploader.uploadFile(server, port, username, password, localPath, remoteDir, remoteFile, verifyCert);success = sftpResult.success;break;case "ftp":default:FtpUploader.FtpResult ftpResult = FtpUploader.uploadFile(server, port, username, password, localPath, remoteDir, remoteFile);success = ftpResult.success;}return success ? Result.success() : Result.failure();} catch (Exception e) {return Result.failure();}}// 創建上傳任務的方法public static void enqueueUpload(Context context, String protocol, String server, int port, String username, String password, String localPath, String remoteDir, String remoteFile,boolean verifyCert) {Data inputData = new Data.Builder().putString(KEY_PROTOCOL, protocol).putString(KEY_SERVER, server).putInt(KEY_PORT, port).putString(KEY_USERNAME, username).putString(KEY_PASSWORD, password).putString(KEY_LOCAL_PATH, localPath).putString(KEY_REMOTE_DIR, remoteDir).putString(KEY_REMOTE_FILE, remoteFile).putBoolean(KEY_VERIFY_CERT, verifyCert).build();Constraints constraints = new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).setRequiresBatteryNotLow(true).build();OneTimeWorkRequest uploadRequest = new OneTimeWorkRequest.Builder(FileUploadWorker.class).setInputData(inputData).setConstraints(constraints).setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS).build();WorkManager.getInstance(context).enqueue(uploadRequest);}
}
6. 安全憑證管理(使用Android Keystore)
import android.content.Context;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;import java.nio.charset.StandardCharsets;
import java.security.KeyStore;public class SecureCredentialManager {private static final String PREFS_NAME = "secure_ftp_prefs";private static final String KEY_SERVER = "server";private static final String KEY_USERNAME = "username";private static final String KEY_PASSWORD = "password";public static void saveCredentials(Context context, String server, String username, String password) {try {MasterKey masterKey = new MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build();EncryptedSharedPreferences sharedPreferences =(EncryptedSharedPreferences) EncryptedSharedPreferences.create(context,PREFS_NAME,masterKey,EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);sharedPreferences.edit().putString(KEY_SERVER, server).putString(KEY_USERNAME, username).putString(KEY_PASSWORD, password).apply();} catch (Exception e) {Timber.e(e, "保存憑證失敗");}}public static Credentials getCredentials(Context context) {try {MasterKey masterKey = new MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build();EncryptedSharedPreferences sharedPreferences =(EncryptedSharedPreferences) EncryptedSharedPreferences.create(context,PREFS_NAME,masterKey,EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);String server = sharedPreferences.getString(KEY_SERVER, null);String username = sharedPreferences.getString(KEY_USERNAME, null);String password = sharedPreferences.getString(KEY_PASSWORD, null);if (server != null && username != null && password != null) {return new Credentials(server, username, password);}} catch (Exception e) {Timber.e(e, "獲取憑證失敗");}return null;}public static class Credentials {public final String server;public final String username;public final String password;public Credentials(String server, String username, String password) {this.server = server;this.username = username;this.password = password;}}
}
三、文件路徑處理(兼容Android 11+)
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.DocumentsContract;
import android.provider.MediaStore;import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;public class FileUtils {public static String getRealPath(Context context, Uri uri) {if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {return handleScopedStorage(context, uri);} else {return handleLegacyStorage(context, uri);}}private static String handleScopedStorage(Context context, Uri uri) {if (DocumentsContract.isDocumentUri(context, uri)) {final String docId = DocumentsContract.getDocumentId(uri);final String[] split = docId.split(":");final String type = split[0];if ("primary".equalsIgnoreCase(type)) {return Environment.getExternalStorageDirectory() + "/" + split[1];}} // 處理其他存儲提供程序...return null;}private static String handleLegacyStorage(Context context, Uri uri) {if ("content".equalsIgnoreCase(uri.getScheme())) {String[] projection = { MediaStore.Images.Media.DATA };try (Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null)) {if (cursor != null && cursor.moveToFirst()) {int columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);return cursor.getString(columnIndex);}}}// 處理文件URIelse if ("file".equalsIgnoreCase(uri.getScheme())) {return uri.getPath();}return null;}public static File createTempFile(Context context, InputStream inputStream, String fileName) throws IOException {File outputDir = context.getCacheDir();File outputFile = new File(outputDir, fileName);try (FileOutputStream outputStream = new FileOutputStream(outputFile)) {byte[] buffer = new byte[4 * 1024];int read;while ((read = inputStream.read(buffer)) != -1) {outputStream.write(buffer, 0, read);}outputStream.flush();}return outputFile;}
}
四、最佳實踐與高級主題
1. 安全實踐
- 證書固定:對于FTPS/SFTP,實現證書固定以防止中間人攻擊
- 雙因素認證:使用密鑰+密碼組合進行SFTP認證
- 連接復用:為頻繁傳輸建立持久連接
- 傳輸加密:即使使用SFTP,也可在應用層對敏感文件進行額外加密
2. 性能優化
- 分塊傳輸:大文件使用分塊上傳/下載
- 并行傳輸:多個文件同時傳輸
- 壓縮傳輸:在傳輸前壓縮文本/日志文件
- 增量同步:僅傳輸變化部分
3. 錯誤處理與重試
public class UploadManager {private static final int MAX_RETRIES = 3;private static final long RETRY_DELAY_MS = 5000;public static boolean uploadWithRetry(String protocol, /* 參數 */) {int attempt = 0;boolean success = false;while (attempt < MAX_RETRIES && !success) {try {switch (protocol) {case "ftp":success = FtpUploader.uploadFile(/* 參數 */).success;break;case "ftps":success = FtpsUploader.uploadFile(/* 參數 */).success;break;case "sftp":success = SftpUploader.uploadFile(/* 參數 */).success;break;}} catch (Exception e) {Timber.e(e, "上傳嘗試 %d 失敗", attempt + 1);}if (!success) {attempt++;if (attempt < MAX_RETRIES) {try {Thread.sleep(RETRY_DELAY_MS);} catch (InterruptedException ie) {Thread.currentThread().interrupt();}}}}return success;}
}
4. 協議選擇指南
場景 | 推薦協議 | 理由 |
---|---|---|
內部網絡,非敏感數據 | 標準FTP | 簡單、高效、低開銷 |
企業級文件傳輸 | FTPS (顯式) | 兼容性好,企業防火墻通常支持 |
互聯網文件傳輸 | SFTP | 單一端口,高安全性,NAT穿透性好 |
需要嚴格審計 | SFTP + 密鑰認證 | 提供強身份驗證和不可否認性 |
移動網絡環境 | SFTP | 更好的連接穩定性,單一端口 |
五、完整工作流程
六、常見問題解決方案
-
連接超時問題
- 增加超時設置:
ftpClient.setConnectTimeout(60000)
- 檢查網絡策略:確保應用不在后臺受限
- 嘗試被動/主動模式切換
- 增加超時設置:
-
文件權限問題
// 在AndroidManifest.xml中添加 <applicationandroid:requestLegacyExternalStorage="true"...>
-
證書驗證失敗
- 開發環境:使用
TrustManagerUtils.getAcceptAllTrustManager()
- 生產環境:將服務器證書打包到應用中并驗證
- 開發環境:使用
-
大文件傳輸穩定性
- 實現分塊傳輸
- 添加進度保存和斷點續傳
- 使用
WorkManager
的持久化工作
-
Android 12+ 網絡限制
- 在
AndroidManifest.xml
中添加:<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <service android:name=".FileTransferService"android:foregroundServiceType="dataSync" />
- 在
這個增強版實現方案提供了完整的FTP傳輸解決方案,包括安全實踐、性能優化和兼容性處理,適合在生產環境中使用。