FTP 協議在批量檔案同步、備份、跨伺服器部署等場景仍廣泛使用。本文提供一個生產級的 FTP 操作類,涵蓋連線管理、目錄遞歸掃描、檔案上傳下載、編碼轉換、斷點續傳與錯誤重試機制。
<?php
class FtpClient {
private $conn;
private $config;
private $passiveMode = true;
private $timeout = 30;
public function __construct(array $config) {
$this->config = array_merge([
'host' => '127.0.0.1',
'port' => 21,
'user' => 'anonymous',
'pass' => 'anonymous',
'ssl' => false,
'timeout' => 30,
], $config);
}
public function connect() {
$this->conn = $this->config['ssl']
? ftp_ssl_connect($this->config['host'], $this->config['port'], $this->config['timeout'])
: ftp_connect($this->config['host'], $this->config['port'], $this->config['timeout']);
if (!$this->conn) {
throw new RuntimeException("無法連線至 FTP 伺服器 {$this->config['host']}:{$this->config['port']}");
}
if (!@ftp_login($this->conn, $this->config['user'], $this->config['pass'])) {
throw new RuntimeException("FTP 登入失敗:用戶 {$this->config['user']}");
}
ftp_pasv($this->conn, $this->passiveMode);
ftp_set_option($this->conn, FTP_TIMEOUT_SEC, $this->config['timeout']);
return $this;
}
public function disconnect() {
if ($this->conn) {
ftp_close($this->conn);
$this->conn = null;
}
}
// 目錄遞歸掃描(支援深度限制)
public function scanDirectory($remotePath, $maxDepth = -1, $currentDepth = 0) {
if ($maxDepth >= 0 && $currentDepth > $maxDepth) return [];
$list = ftp_rawlist($this->conn, $remotePath);
if ($list === false) return [];
$result = ['dirs' => [], 'files' => []];
foreach ($list as $entry) {
$parsed = $this->parseRawListEntry($entry);
if (!$parsed) continue;
$fullPath = $remotePath . '/' . $parsed['name'];
if ($parsed['type'] === 'dir') {
$result['dirs'][$parsed['name']] = [
'path' => $fullPath,
'perms' => $parsed['perms'],
'date' => $parsed['date'],
'children' => $this->scanDirectory($fullPath, $maxDepth, $currentDepth + 1),
];
} else {
$result['files'][$parsed['name']] = [
'path' => $fullPath,
'size' => $parsed['size'],
'perms' => $parsed['perms'],
'date' => $parsed['date'],
];
}
}
return $result;
}
private function parseRawListEntry($entry) {
// 解析 ftp_rawlist 輸出(格式因伺服器而異,此處為 Unix 風格)
// drwxr-xr-x 2 user group 4096 Jan 15 10:30 dirname
// -rw-r--r-- 1 user group 1234 Jan 15 10:30 filename.txt
if (preg_match('/^([d-])([rwxsStT-]{9})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3}\s+\d{1,2}\s+[\d:]{4,5})\s+(.+)$/', $entry, $m)) {
return [
'type' => $m[1] === 'd' ? 'dir' : 'file',
'perms' => $m[2],
'size' => (int)$m[3],
'date' => strtotime($m[4]),
'name' => $m[5],
];
}
return null;
}
// 檔案下載(支援斷點續傳)
public function download($remoteFile, $localFile, $resume = false) {
$mode = FTP_BINARY;
$localSize = $resume && file_exists($localFile) ? filesize($localFile) : 0;
if ($localSize > 0) {
$remoteSize = ftp_size($this->conn, $remoteFile);
if ($remoteSize == $localSize) return true; // 已完整
if ($remoteSize < $localSize) $localSize = 0; // 遠端較小,重新下載
}
$fp = fopen($localFile, $localSize > 0 ? 'ab' : 'wb');
if (!$fp) throw new RuntimeException("無法開啟本地檔案:$localFile");
$result = ftp_nb_fget($this->conn, $fp, $remoteFile, $mode, $localSize);
while ($result == FTP_MOREDATA) {
$result = ftp_nb_continue($this->conn);
}
fclose($fp);
if ($result != FTP_FINISHED) {
throw new RuntimeException("下載失敗:$remoteFile -> $localFile");
}
return true;
}
// 檔案上傳(支援斷點續傳)
public function upload($localFile, $remoteFile, $resume = false) {
if (!file_exists($localFile)) throw new RuntimeException("本地檔案不存在:$localFile");
$mode = FTP_BINARY;
$remoteSize = $resume ? ftp_size($this->conn, $remoteFile) : 0;
$localSize = filesize($localFile);
if ($remoteSize >= $localSize) return true; // 已完整
$fp = fopen($localFile, 'rb');
if (!$fp) throw new RuntimeException("無法讀取本地檔案:$localFile");
if ($remoteSize > 0) {
fseek($fp, $remoteSize);
}
$result = ftp_nb_fput($this->conn, $remoteFile, $fp, $mode, $remoteSize);
while ($result == FTP_MOREDATA) {
$result = ftp_nb_continue($this->conn);
}
fclose($fp);
if ($result != FTP_FINISHED) {
throw new RuntimeException("上傳失敗:$localFile -> $remoteFile");
}
return true;
}
// 目錄同步(鏡像模式)
public function syncDirectory($localDir, $remoteDir, $deleteExtra = false, $dryRun = false) {
$localFiles = $this->scanLocal($localDir);
$remoteFiles = $this->scanRemote($remoteDir);
$actions = ['upload' => [], 'download' => [], 'delete' => [], 'skip' => []];
// 比對邏輯...
// 實際實現需比對 mtime、size、checksum
return $actions;
}
// 編碼轉換下載(適用於 legacy 系統)
public function downloadWithEncoding($remoteFile, $localFile, $fromEncoding = 'GBK', $toEncoding = 'UTF-8') {
$tempFile = tempnam(sys_get_temp_dir(), 'ftp_');
$this->download($remoteFile, $tempFile);
$content = file_get_contents($tempFile);
$content = iconv($fromEncoding, $toEncoding . '//IGNORE', $content);
file_put_contents($localFile, $content);
unlink($tempFile);
}
// 被動模式切換
public function setPassiveMode($enabled) {
$this->passiveMode = $enabled;
if ($this->conn) ftp_pasv($this->conn, $enabled);
}
// 錯誤重試裝飾器
public function withRetry(callable $operation, $maxRetries = 3, $delay = 1) {
$lastException = null;
for ($i = 0; $i <= $maxRetries; $i++) {
try {
return $operation($this);
} catch (Exception $e) {
$lastException = $e;
if ($i < $maxRetries) {
sleep($delay * pow(2, $i)); // 指數退避
$this->reconnect();
}
}
}
throw $lastException;
}
private function reconnect() {
$this->disconnect();
$this->connect();
}
public function __destruct() {
$this->disconnect();
}
}
$ftp = new FtpClient([
'host' => 'files.example.com',
'user' => 'backup_user',
'pass' => 'secure_password',
'ssl' => true, // FTPS
])->connect();
$structure = $ftp->scanDirectory('/data/exports', 2); // 最大深度 2
foreach ($structure['files'] as $name => $info) {
if (strpos($name, '.csv') !== false) {
$localPath = "/var/backups/exports/{$name}";
$ftp->withRetry(function($client) use ($info, $localPath) {
$client->downloadWithEncoding($info['path'], $localPath, 'BIG5', 'UTF-8');
});
echo "下載完成:$name\n";
}
}
$ftp->disconnect();
$ftp = new FtpClient([
'host' => 'deploy.example.com',
'user' => 'deployer',
'pass' => getenv('FTP_DEPLOY_PASS'),
])->connect();
$buildDir = '/workspace/build/dist';
$remoteBase = '/var/www/releases/' . date('Ymd_His');
$ftp->withRetry(function($client) use ($buildDir, $remoteBase) {
$client->syncDirectory($buildDir, $remoteBase, true);
});
echo "部署完成:$remoteBase\n";
$ftp->disconnect();
| 限制 | 影響 | 建議替代 |
|---|---|---|
| 明文傳輸(預設) | 帳號密碼、資料可被竊聽 | FTPS (SSL/TLS) 或 SFTP (SSH) |
| 主動模式防火牆穿透困難 | 需開放大量高位埠 | 強制被動模式 + 固定被動埠範圍 |
| 無完整性校驗 | 傳輸錯誤難以偵測 | 應用層 checksum (MD5/SHA256) 驗證 |
| 目錄列表格式不標準 | 解析脆弱 | MLSD 指令 (RFC 3659) 或改用 SFTP |
| 單連線吞吐量低 | 大檔案傳輸慢 | 多連線並行 / lftp / rsync over SSH |
// 使用 phpseclib (純 PHP SFTP)
use phpseclib\Net\SFTP;
$sftp = new SFTP('host', 22);
if (!$sftp->login('user', 'password')) {
throw new Exception('SFTP 登入失敗');
}
// 上傳
$sftp->put('/remote/path/file.txt', '/local/path/file.txt', NET_SFTP_LOCAL_FILE);
// 下載
$sftp->get('/local/path/file.txt', '/remote/path/file.txt');
// 目錄掃描
$files = $sftp->nlist('/remote/path');
FTP 類封裝應包含連線池、非阻塞傳輸、進度回呼、完整性校驗等企業級特性。新專案優先選擇 SFTP/rsync,既有 FTP 系統建議加裝 FTPS 與應用層校驗機制。