核心上傳函數對比
| 函數 | 參數 | 適用場景 |
ftp_put(conn, remote, local, mode) | 本地檔案路徑 | 小檔案、簡單上傳 |
ftp_fput(conn, remote, handle, mode, startpos=0) | 已開啟檔案指標 | 大檔案、斷點續傳、記憶體控制 |
ftp_nb_fput(...) | 非阻塞版本 | 進度條、長連線、並發控制 |
傳輸模式:必須正確選擇
FTP_BINARY (預設):**所有非純文本檔案** (圖片、壓縮包、PDF、執行檔、資料庫備份)
FTP_ASCII:**純文本檔案** (.txt, .csv, .sql, .php, .html, .css, .js) — 會自動轉換換行符 (CRLF ↔ LF)
錯誤後果:二進制檔用 ASCII 模式會損壞;文本檔用 Binary 模式換行符可能不符預期。
基礎上傳:ftp_put
<?php
function ftpUploadSimple(
string $host, string $user, string $pass,
string $localFile, string $remoteFile,
bool $ssl = false, int $mode = FTP_BINARY
): bool {
$conn = $ssl ? @ftp_ssl_connect($host) : @ftp_connect($host);
if (!$conn) throw new Exception("連線失敗: $host");
if (!@ftp_login($conn, $user, $pass)) {
ftp_close($conn);
throw new Exception("登入失敗");
}
ftp_pasv($conn, true);
// 可選:確保遠端目錄存在
$remoteDir = dirname($remoteFile);
if ($remoteDir !== '.') {
ftp_mkdir_recursive($conn, $remoteDir); // 需自行實現遞迴建立
}
$result = ftp_put($conn, $remoteFile, $localFile, $mode);
ftp_close($conn);
if (!$result) throw new Exception("上傳失敗: $localFile -> $remoteFile");
return true;
}
// 輔助:遞迴建立遠端目錄
function ftp_mkdir_recursive($conn, string $dir): bool {
$parts = explode('/', trim($dir, '/'));
$path = '';
foreach ($parts as $part) {
$path .= '/' . $part;
if (!@ftp_chdir($conn, $path)) {
if (!@ftp_mkdir($conn, $path)) return false;
@ftp_chdir($conn, $path);
}
}
return true;
}
進階:ftp_fput 大檔案/斷點續傳
<?php
function ftpUploadResume(
string $host, string $user, string $pass,
string $localFile, string $remoteFile,
bool $ssl = false, int $mode = FTP_BINARY
): bool {
$conn = $ssl ? @ftp_ssl_connect($host) : @ftp_connect($host);
if (!$conn) throw new Exception("連線失敗");
if (!@ftp_login($conn, $user, $pass)) throw new Exception("登入失敗");
ftp_pasv($conn, true);
// 1. 確保遠端目錄
ftp_mkdir_recursive($conn, dirname($remoteFile));
// 2. 檢查遠端已上傳大小 (續傳)
$remoteSize = ftp_size($conn, $remoteFile);
$localSize = filesize($localFile);
if ($remoteSize >= $localSize) {
ftp_close($conn);
return true; // 已完成
}
// 3. 開啟本地檔案,跳過已上傳部分
$fp = fopen($localFile, 'rb');
if (!$fp) throw new Exception("無法開啟本地檔案");
fseek($fp, $remoteSize);
// 4. 續傳上傳
$result = ftp_fput($conn, $remoteFile, $fp, $mode, $remoteSize);
fclose($fp);
ftp_close($conn);
if (!$result) throw new Exception("續傳上傳失敗");
return true;
}
非阻塞上傳:ftp_nb_fput (支援進度條)
<?php
function ftpUploadNonBlocking(
string $host, string $user, string $pass,
string $localFile, string $remoteFile,
bool $ssl = false,
callable $progressCallback = null
): bool {
$conn = $ssl ? @ftp_ssl_connect($host) : @ftp_connect($host);
if (!$conn) throw new Exception("連線失敗");
if (!@ftp_login($conn, $user, $pass)) throw new Exception("登入失敗");
ftp_pasv($conn, true);
ftp_mkdir_recursive($conn, dirname($remoteFile));
$fp = fopen($localFile, 'rb');
if (!$fp) throw new Exception("無法開啟本地檔案");
$totalSize = filesize($localFile);
$ret = ftp_nb_fput($conn, $remoteFile, $fp, FTP_BINARY, 0);
while ($ret === FTP_MOREDATA) {
if ($progressCallback) {
$uploaded = ftell($fp);
$progressCallback($uploaded, $totalSize);
}
$ret = ftp_nb_continue($conn);
}
fclose($fp);
ftp_close($conn);
if ($ret !== FTP_FINISHED) throw new Exception("非阻塞上傳失敗");
return true;
}
完整封裝類:支援多種模式、自動重試、日誌
<?php
class FtpUploader {
private $conn;
private $config;
public function __construct(array $config) {
$this->config = array_merge([
'host' => '', 'user' => '', 'pass' => '',
'ssl' => false, 'port' => 21, 'pasv' => true,
'timeout' => 90, 'maxRetry' => 3
], $config);
}
private function connect() {
for ($i = 0; $i < $this->config['maxRetry']; $i++) {
$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 && @ftp_login($this->conn, $this->config['user'], $this->config['pass'])) {
ftp_pasv($this->conn, $this->config['pasv']);
return;
}
@ftp_close($this->conn);
sleep(1 << $i);
}
throw new Exception("FTP 連線失敗,重試 {$this->config['maxRetry']} 次");
}
public function upload(
string $localPath,
string $remotePath,
int $mode = FTP_BINARY,
bool $resume = true,
callable $progress = null
): bool {
$this->connect();
// 確保遠端目錄
$this->mkdirRecursive(dirname($remotePath));
$localSize = filesize($localPath);
$remoteSize = $resume ? @ftp_size($this->conn, $remotePath) : -1;
if ($remoteSize >= $localSize) return true;
$fp = fopen($localPath, 'rb');
if (!$fp) throw new Exception("本地檔案不可讀: $localPath");
if ($remoteSize > 0) fseek($fp, $remoteSize);
$startPos = $remoteSize > 0 ? $remoteSize : 0;
$func = $progress ? 'ftp_nb_fput' : 'ftp_fput';
$ret = $func($this->conn, $remotePath, $fp, $mode, $startPos);
if ($progress && $func === 'ftp_nb_fput') {
while ($ret === FTP_MOREDATA) {
$progress(ftell($fp), $localSize);
$ret = ftp_nb_continue($this->conn);
}
}
fclose($fp);
@ftp_close($this->conn);
$this->conn = null;
if ($ret !== true && $ret !== FTP_FINISHED) {
throw new Exception("上傳失敗: $localPath -> $remotePath");
}
return true;
}
private function mkdirRecursive(string $dir): void {
$cwd = ftp_pwd($this->conn);
$parts = explode('/', trim($dir, '/'));
$path = '';
foreach ($parts as $part) {
$path .= '/' . $part;
if (!@ftp_chdir($this->conn, $path)) {
@ftp_mkdir($this->conn, $path);
@ftp_chdir($this->conn, $path);
}
}
@ftp_chdir($this->conn, $cwd);
}
public function __destruct() {
if ($this->conn) @ftp_close($this->conn);
}
}
// 使用
$uploader = new FtpUploader([
'host' => 'ftp.example.com',
'user' => 'backup_user',
'pass' => 'secret',
'ssl' => true,
]);
$uploader->upload(
'/local/backup/db_20240101.sql.gz',
'/remote/backups/db_20240101.sql.gz',
FTP_BINARY,
true, // 斷點續傳
function($up, $total) {
echo "\r上傳進度: " . round($up/$total*100, 1) . "%";
}
);
echo "\n上傳完成\n";
常見問題排查表
| 現象 | 可能原因 | 解決方案 |
| 上傳 0 位元組 / 損壞 | 二進制檔用 FTP_ASCII | 強制 FTP_BINARY |
| 連線逾時 | 主動模式被防火牆阻擋 | ftp_pasv(true) |
| 權限拒絕 | FTP 用戶無寫入權限 | 檢查伺服器權限、目錄擁有者 |
| 中文檔名亂碼 | FTP 協議編碼不一致 | 檔名 URL encode / Base64 / 改用 SFTP |
| 大檔案記憶體爆掉 | 用 ftp_put 讀整檔入記憶體 | 改用 ftp_fput / ftp_nb_fput 流式 |
| 續傳失敗 | 伺服器不支援 REST/APPE | 檢查 ftp_raw($conn, 'REST 0') 或改用 lftp |
生產環境建議
- 改用 SFTP (ssh2_sftp / phpseclib):單通道、加密、標準化、無模式困擾
- 異步佇列:上傳任務入 Redis/RabbitMQ,Worker 處理,API 秒回應
- 完整性校驗:上傳後比對 MD5/SHA256,確保傳輸無損壞
- 限速與並發控制:避免佔滿頻寬影響其他業務
- 詳細日誌:檔案、大小、耗時、速度、結果、錯誤碼,便於審計排查