详细的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 (模块级):
gradle
dependencies {
// FTP/FTPS 实现
implementation 'commons-net:commons-net:3.9.0'
// SFTP 实现 (选择其一)
implementation 'com.jcraft:jsch:0.1.55' // 方案1
implementation '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:
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实现(增强版)
java
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(); // 重要:应对防火墙/NAT
ftpClient.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实现(增强安全版)
java
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 {
// 隐式 FTPS
ftpsClient = 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 - 现代方案)
java
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() {
@Override
public 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增强版)
java
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/SFTP
public FileUploadWorker(@NonNull Context context, @NonNull WorkerParameters params) {
super(context, params);
}
@NonNull
@Override
public 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)
java
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+)
java
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);
}
}
}
// 处理文件URI
else 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. 错误处理与重试
java
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 | 更好的连接稳定性,单一端口 |
五、完整工作流程
sequenceDiagram
participant A as Android应用
participant KS as Keystore
participant F as FTP/FTPS/SFTP服务器
A->>KS: 1. 获取安全凭证
activate KS
KS-->>A: 返回加密凭证
deactivate KS
A->>F: 2. 建立连接
activate F
alt 标准FTP
A->>F: 发送USER/PASS
F-->>A: 登录确认
else FTPS
A->>F: 发起SSL/TLS握手
F-->>A: 服务器证书
A->>A: 验证证书
A->>F: 发送USER/PASS
F-->>A: 登录确认
else SFTP
A->>F: SSH握手
F-->>A: 服务器主机密钥
A->>A: 验证主机密钥
A->>F: 用户认证(密码/密钥)
F-->>A: 认证成功
end
A->>F: 3. 准备传输(设置目录/模式)
F-->>A: 确认
A->>F: 4. 传输文件数据
F-->>A: 传输进度确认
A->>F: 5. 关闭连接
F-->>A: 断开确认
deactivate F
六、常见问题解决方案
-
连接超时问题
- 增加超时设置:
ftpClient.setConnectTimeout(60000)
- 检查网络策略:确保应用不在后台受限
- 尝试被动/主动模式切换
- 增加超时设置:
-
文件权限问题
java// 在AndroidManifest.xml中添加 <application android:requestLegacyExternalStorage="true" ...>
-
证书验证失败
- 开发环境:使用
TrustManagerUtils.getAcceptAllTrustManager()
- 生产环境:将服务器证书打包到应用中并验证
- 开发环境:使用
-
大文件传输稳定性
- 实现分块传输
- 添加进度保存和断点续传
- 使用
WorkManager
的持久化工作
-
Android 12+ 网络限制
-
在
AndroidManifest.xml
中添加:xml<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <service android:name=".FileTransferService" android:foregroundServiceType="dataSync" />
-
这个增强版实现方案提供了完整的FTP传输解决方案,包括安全实践、性能优化和兼容性处理,适合在生产环境中使用。