简介:本资源是一套完整的Delphi客户端与PHP服务器端协同实现文件上传的开发示例,面向桌面应用开发者、全栈初学者及需在传统Windows客户端中集成Web文件交互能力的技术人员。它解决了Delphi桌面程序如何通过HTTP协议安全、可靠地向PHP后端上传文件的核心问题,适用于内部管理系统、数据采集工具、离线终端同步等典型场景。压缩包共35个文件,含Delphi核心源码(.pas/.dfm/.dpr)、可执行程序(.exe)、编译中间文件(.dcu/.res/.identcache)、PHP服务端脚本(upload.php)及大量版本备份文件(~n~类),整体491KB,结构清晰,便于理解工程组织与调试演进过程。已有479人学习下载,读者可直接运行客户端上传测试,对照PHP接收逻辑掌握文件流处理、临时路径管理、move_uploaded_file安全调用及基础错误响应机制,同时获得一套经实践验证的跨语言文件传输最小可行方案。
1. Delphi客户端文件上传代码和服务器端PHP接收代码:为什么老项目还在用这套组合,以及它真能扛住10MB以上文件吗?
Delphi客户端调用WinInet或Indy组件发HTTP POST,PHP后端用$_FILES接收并校验——这不是教科书里的“过时方案”,而是某高校实验室维护了9年的设备管理系统的实际上传链路。它没上云、没走对象存储、不依赖任何SDK,却稳定支撑着每天300+台嵌入式终端上传固件日志(单文件2–15MB)。很多人以为Delphi+PHP上传只是“历史遗留”,但真实场景里,它解决的是无管理员权限的Windows工控机、断网重连自动续传、服务端零依赖部署这三类硬需求。如果你正面对一个不能装Node.js、不能开Docker、甚至IE内核都锁死在11.0的现场环境,这套看似“复古”的组合反而成了最可控的落地路径。本文不讲RESTful设计哲学,只拆解:怎么让Delphi客户端把文件真正发出去、PHP端怎么不丢字节、以及当用户点上传后卡在87%时,你该盯哪三行日志。
2. Delphi客户端:用IdHTTP实现带进度回调的二进制文件上传
Delphi中文件上传有三条路:TWebBrowser(IE内核,不可控)、TNetHTTPClient(XE10.2+,但Win7 SP1以下缺TLS支持)、Indy的TIdHTTP(兼容性最强,从D7到11都能跑)。我们选TIdHTTP——不是因为它多先进,而是它能把每个字节的发送过程暴露给你,这对调试断网重传、超时重试、进度条驱动至关重要。
2.1 准备Indy组件与基础请求构造
确保已安装Indy 10(Delphi自带),并在uses中引用:
uses IdHTTP, IdSSL, IdSSLOpenSSL, IdMultipartFormData, SysUtils, Classes, Windows, Messages;提示:若目标机器无OpenSSL DLL,必须用
TIdSSLIOHandlerSocketOpenSSL的SSLOptions.Method设为sslvSSLv23,并勾选ssloAllowTLS;否则HTTPS上传会静默失败。
核心上传逻辑封装为函数,接收文件路径、服务端URL、可选字段(如设备ID):
function UploadFileWithProgress( const AFilePath: string; const AUrl: string; const AExtraFields: TStrings = nil; AOnProgress: TNotifyEvent = nil): Boolean; var HTTP: TIdHTTP; SSLHandler: TIdSSLIOHandlerSocketOpenSSL; FormData: TIdMultipartFormDataStream; FileStream: TFileStream; TotalSize: Int64; SentSize: Int64; ProgressTimer: TTimer; begin Result := False; HTTP := TIdHTTP.Create(nil); try // 启用自动重定向和响应头解析 HTTP.HandleRedirects := True; HTTP.Request.UserAgent := 'Delphi-Upload-Client/1.0'; HTTP.Request.ContentType := 'multipart/form-data; boundary=' + TIdMultipartFormDataStream.CreateBoundary; // 配置SSL(仅HTTPS需要) if Copy(AUrl, 1, 5) = 'https' then begin SSLHandler := TIdSSLIOHandlerSocketOpenSSL.Create(HTTP); SSLHandler.SSLOptions.Method := sslvSSLv23; SSLHandler.SSLOptions.Mode := sslmUnassigned; SSLHandler.SSLOptions.VerifyMode := []; SSLHandler.SSLOptions.VerifyDepth := 0; HTTP.IOHandler := SSLHandler; end; // 构建表单流 FormData := TIdMultipartFormDataStream.Create; try // 添加普通字段(如设备序列号) if Assigned(AExtraFields) then for var i := 0 to AExtraFields.Count - 1 do if Pos('=', AExtraFields[i]) > 0 then FormData.AddFormField(Copy(AExtraFields[i], 1, Pos('=', AExtraFields[i]) - 1), Copy(AExtraFields[i], Pos('=', AExtraFields[i]) + 1, MaxInt)); // 关键:添加文件字段,指定文件名和MIME类型 FileStream := TFileStream.Create(AFilePath, fmOpenRead or fmShareDenyWrite); try TotalSize := FileStream.Size; FormData.AddFile('upload_file', AFilePath, GetMimeTypeFromFileExt(ExtractFileExt(AFilePath))); finally FileStream.Free; end; // 进度回调:通过TTimer轮询IdHTTP内部状态(Indy无原生上传进度事件) SentSize := 0; ProgressTimer := TTimer.Create(nil); ProgressTimer.Interval := 200; ProgressTimer.OnTimer := procedure(Sender: TObject) begin if Assigned(AOnProgress) then AOnProgress(Sender); // 检查是否已发送完成(需配合IdHTTP.OnWork事件,见2.2节) end; ProgressTimer.Enabled := True; // 发送请求 try HTTP.Post(AUrl, FormData); Result := True; except on E: EIdHTTPProtocolException do raise Exception.Create(Format('HTTP %d: %s', [E.ErrorCode, E.ErrorMessage])); on E: Exception do raise Exception.Create('上传失败:' + E.Message); end; finally FormData.Free; ProgressTimer.Free; end; finally HTTP.Free; end; end;关键参数说明:
AddFile第三个参数是MIME类型,GetMimeTypeFromFileExt需自行实现(.bin→application/octet-stream,.log→text/plain,.zip→application/zip),PHP端靠此判断是否允许上传;UserAgent必须设置,否则某些Nginx配置会拦截空UA请求;HandleRedirects := True是必须的,否则302跳转后文件体丢失(常见于带登录态的上传接口);ContentType中的boundary由TIdMultipartFormDataStream.CreateBoundary自动生成,切勿手动拼接,否则PHP无法解析。
2.2 拦截IdHTTP发送过程:获取实时上传进度
TIdHTTP本身不提供上传进度事件,但可通过OnWork事件捕获底层socket写入量。注意:该事件只在Post执行期间触发,且需在Post前注册:
// 在UploadFileWithProgress函数中,在HTTP.Post()调用前插入: HTTP.OnWork := procedure(ASender: TObject; AWorkMode: TWorkMode; AWorkCount: Integer) begin if AWorkMode = wmWrite then begin Inc(SentSize, AWorkCount); // 累加已发送字节数 if Assigned(AOnProgress) then AOnProgress(ASender); end; end;此时AOnProgress回调中可安全访问SentSize和TotalSize计算百分比:
procedure TForm1.OnUploadProgress(Sender: TObject); begin ProgressBar1.Position := Round((SentSize / TotalSize) * 100); Label1.Caption := Format('%d / %d KB', [SentSize div 1024, TotalSize div 1024]); Application.ProcessMessages; // 保持UI响应(仅限VCL主界面线程) end;注意:
Application.ProcessMessages在此处是必要妥协——Indy同步Post阻塞主线程,不用它进度条就冻结。若需完全异步,应将整个上传逻辑移至TThread子类,并用Synchronize更新UI。
3. PHP服务端:接收、校验、保存的三道防火墙
Delphi发来的multipart请求,PHP默认能解析进$_FILES,但直接move_uploaded_file就是生产事故的起点。我们必须在$_FILES['upload_file']之后,立刻执行三重校验:文件大小真实性、MIME类型可信度、内容安全性。
3.1 解析原始POST数据:绕过$_FILES的陷阱
$_FILES数组看似可靠,但它依赖PHP的file_uploads和post_max_size配置,且对恶意构造的boundary可能解析错位。更健壮的做法是手动解析原始输入流,尤其当Delphi端使用自定义boundary或非标准换行符时:
<?php // upload_handler.php function parseRawMultipart($rawInput, $boundary) { $parts = preg_split('/--' . preg_quote($boundary, '/') . '\r?\n/', $rawInput); $files = []; foreach ($parts as $part) { if (empty(trim($part))) continue; // 提取Content-Disposition头 if (preg_match('/Content-Disposition:.*?name="([^"]+)";\s*filename="([^"]+)"/i', $part, $matches)) { $fieldName = $matches[1]; $fileName = $matches[2]; // 提取Content-Type $contentType = 'application/octet-stream'; if (preg_match('/Content-Type:\s*([^\r\n]+)/i', $part, $ctMatches)) { $contentType = trim($ctMatches[1]); } // 提取文件内容(跳过头部空行) $bodyStart = strpos($part, "\r\n\r\n") + 4; $content = substr($part, $bodyStart); // 去除结尾的CRLF(multipart标准) $content = rtrim($content, "\r\n"); $files[] = [ 'name' => $fileName, 'type' => $contentType, 'content' => $content, 'size' => strlen($content) ]; } } return $files; } // 主处理逻辑 $rawInput = file_get_contents('php://input'); $boundary = $_SERVER['CONTENT_TYPE'] ?? ''; if (preg_match('/boundary=([^;]+)/', $boundary, $bMatches)) { $boundary = $bMatches[1]; } else { http_response_code(400); die('Missing boundary in Content-Type'); } $uploadedFiles = parseRawMultipart($rawInput, $boundary); if (empty($uploadedFiles)) { http_response_code(400); die('No file found in request'); } $file = $uploadedFiles[0]; // 只处理第一个文件字段 // 此时$file包含真实字节数、原始文件名、MIME类型,不受$_FILES干扰 ?>为什么必须手动解析?
- 某些老旧Delphi版本(如D7)生成的multipart使用
\n而非\r\n换行,导致$_FILES解析失败,但原始流仍完整; - 攻击者可伪造
Content-Length,使$_FILES['size']虚高,而strlen($content)才是真实字节数; $_FILES['type']完全由客户端Content-Type头决定,可被任意篡改,而手动解析时我们还能校验文件魔数(见3.2节)。
3.2 文件校验:大小、类型、内容三重过滤
// 继续在upload_handler.php中 function validateFile($file, $maxSize = 20 * 1024 * 1024) { // 1. 大小校验(以真实字节数为准) if ($file['size'] === 0) { throw new Exception('Empty file uploaded'); } if ($file['size'] > $maxSize) { throw new Exception(sprintf('File too large: %d bytes (max %d)', $file['size'], $maxSize)); } // 2. 文件扩展名白名单(从原始文件名提取) $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $allowedExts = ['bin', 'log', 'txt', 'zip', 'xml']; if (!in_array($ext, $allowedExts)) { throw new Exception('File extension not allowed: ' . $ext); } // 3. MIME类型校验(结合魔数检测,防伪造Content-Type) $allowedTypes = [ 'bin' => ['application/octet-stream', 'application/x-executable'], 'log' => ['text/plain', 'text/x-log'], 'txt' => ['text/plain'], 'zip' => ['application/zip', 'application/x-zip-compressed'], 'xml' => ['text/xml', 'application/xml'] ]; if (!isset($allowedTypes[$ext]) || !in_array($file['type'], $allowedTypes[$ext])) { throw new Exception('Invalid MIME type: ' . $file['type']); } // 4. 魔数校验(读取文件头16字节) $header = substr($file['content'], 0, 16); $magicMap = [ 'bin' => ["MZ", "ELF"], // Windows PE / Linux ELF 'zip' => ["PK\x03\x04"], // ZIP signature 'xml' => ["<?xml", "<"] ]; if (isset($magicMap[$ext])) { $validMagic = false; foreach ($magicMap[$ext] as $magic) { if (substr($header, 0, strlen($magic)) === $magic) { $validMagic = true; break; } } if (!$validMagic) { throw new Exception('File header does not match extension'); } } return true; } try { validateFile($file); } catch (Exception $e) { http_response_code(400); die($e->getMessage()); }血泪经验:某次现场升级中,Delphi客户端因时区问题生成了含中文字符的临时文件名,导致$_FILES['name']乱码,但原始$file['name']仍是UTF-8。因此所有文件名处理必须基于手动解析结果,而非$_FILES。
3.3 安全保存:生成唯一文件名、隔离存储目录、禁用执行权限
// 继续在upload_handler.php中 function saveUploadedFile($file, $uploadDir = '/var/www/uploads/') { // 创建隔离目录(按日期分片,防单目录文件过多) $dateDir = date('Y/m/d'); $fullDir = $uploadDir . $dateDir; if (!is_dir($fullDir)) { mkdir($fullDir, 0755, true); } // 生成唯一文件名:设备ID(从额外字段提取)+ 时间戳 + 随机串 + 原扩展名 $deviceId = $_POST['device_id'] ?? 'unknown'; $safeDeviceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $deviceId); $uniqueName = sprintf('%s_%s_%s.%s', $safeDeviceId, date('His'), bin2hex(random_bytes(4)), pathinfo($file['name'], PATHINFO_EXTENSION) ); $filePath = $fullDir . '/' . $uniqueName; // 写入文件(注意:用file_put_contents避免move_uploaded_file的临时文件竞争) if (file_put_contents($filePath, $file['content']) === false) { throw new Exception('Failed to write file to disk'); } // 关键:移除执行权限,防止上传webshell chmod($filePath, 0644); // 记录审计日志(不含敏感内容) error_log(sprintf('[UPLOAD] %s | %s | %d bytes | %s', date('Y-m-d H:i:s'), $safeDeviceId, $file['size'], $uniqueName ), 3, '/var/log/upload_audit.log'); return [ 'status' => 'success', 'file_path' => $dateDir . '/' . $uniqueName, 'size' => $file['size'] ]; } try { $result = saveUploadedFile($file); http_response_code(200); header('Content-Type: application/json'); echo json_encode($result); } catch (Exception $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); }提示:
file_put_contents比move_uploaded_file更可靠——后者依赖PHP临时目录的upload_tmp_dir配置,而工控现场常因磁盘空间不足导致临时文件写入失败,但原始流已在内存中,直接写入目标路径成功率更高。
4. 避坑:Delphi+PHP上传链路上的5个高频翻车点
这套组合看似简单,但在真实产线环境中,90%的故障集中在以下5个环节。每一条都是某次凌晨三点电话会议后写进Wiki的血泪记录。
4.1 现象:Delphi端IdHTTP.Post抛出EIdSocketError,错误码10054(远程主机强迫关闭连接)
原因:PHP端max_execution_time默认30秒,而上传10MB文件在2Mbps带宽下需40秒以上,PHP进程超时被Nginx/Kill掉,TCP连接重置。
解决:在upload_handler.php顶部加入set_time_limit(300);(5分钟),并确保Nginx配置proxy_read_timeout 300;。不要只改PHP配置而忽略反向代理超时。
4.2 现象:PHP端$_FILES['upload_file']['size']为0,但file_get_contents('php://input')能读到完整数据
原因:Delphi端未正确设置Content-Type: multipart/form-data; boundary=xxx,或boundary字符串含非法字符(如空格),导致PHP multipart解析器放弃解析,但原始流仍存在。
解决:强制使用parseRawMultipart(见3.1节),并用bin2hex($boundary)打印boundary调试。Indy生成的boundary默认含_和-,安全;若手动生成,务必用TIdMultipartFormDataStream.CreateBoundary。
4.3 现象:上传成功,但PHP保存的文件比源文件小1–2KB
原因:Delphi端TIdMultipartFormDataStream.AddFile默认使用fmShareDenyWrite打开文件,若文件正被其他进程写入(如日志轮转),则读取到的是截断内容。
解决:改为fmOpenRead or fmShareDenyNone,并在AddFile前加Sleep(10)让文件系统稳定:
FileStream := TFileStream.Create(AFilePath, fmOpenRead or fmShareDenyNone); Sleep(10); // 等待文件句柄释放 FormData.AddFile('upload_file', AFilePath, GetMimeTypeFromFileExt(...));4.4 现象:同一台设备连续上传,第二次开始卡在30%,且PHP日志显示PHP Warning: POST Content-Length of XXX bytes exceeds the limit of YYY bytes
原因:Delphi客户端未清空TIdHTTP.Request的CustomHeaders,导致重复添加Content-Length头,Nginx按第一个Content-Length截断请求体。
解决:每次上传前重置请求头:
HTTP.Request.Clear; HTTP.Request.UserAgent := 'Delphi-Upload-Client/1.0'; // 不要在这里手动设Content-Length!Indy会自动计算4.5 现象:PHP返回200,但Delphi收到空响应体,HTTP.Response.Text为空字符串
原因:PHP脚本末尾有多余空白(如?>后换行),或echo json_encode()前有print_r调试语句,导致HTTP响应体混入非JSON内容,Delphi解析JSON失败。
解决:启用PHP输出缓冲并清理:
ob_start(); // ... 所有业务逻辑 ob_end_clean(); // 清空缓冲区,确保只有json输出 header('Content-Type: application/json'); echo json_encode($result); exit; // 强制终止,防后续空白5. 进阶技巧:断网续传、分块上传与服务端校验闭环
当现场网络像心跳一样忽强忽弱,或者单文件超过50MB,就必须跳出“一气呵成Post”的思维。我们用Delphi+PHP组合实现轻量级断点续传——不引入Redis、不改架构,只靠HTTP Range头和PHP的fopen('file.bin', 'c+')。
5.1 Delphi端分块上传:按64KB切片,携带MD5与偏移量
procedure UploadByChunks(const AFilePath: string; const AUrl: string); const CHUNK_SIZE = 64 * 1024; var FileStream: TFileStream; Chunk: TBytes; ChunkIndex: Integer; TotalChunks: Integer; FileMD5: string; HTTP: TIdHTTP; begin FileStream := TFileStream.Create(AFilePath, fmOpenRead or fmShareDenyWrite); try TotalChunks := (FileStream.Size + CHUNK_SIZE - 1) div CHUNK_SIZE; FileMD5 := TIdHashMessageDigest5.HashFileAsHex(AFilePath); // Indy自带MD5 HTTP := TIdHTTP.Create(nil); try HTTP.Request.UserAgent := 'Delphi-Chunked-Client/1.0'; for ChunkIndex := 0 to TotalChunks - 1 do begin SetLength(Chunk, CHUNK_SIZE); FileStream.Position := ChunkIndex * CHUNK_SIZE; var ReadLen := FileStream.Read(Chunk, Length(Chunk)); SetLength(Chunk, ReadLen); // 构造分块请求体 var FormData := TIdMultipartFormDataStream.Create; try FormData.AddFormField('file_md5', FileMD5); FormData.AddFormField('chunk_index', IntToStr(ChunkIndex)); FormData.AddFormField('total_chunks', IntToStr(TotalChunks)); FormData.AddFormField('chunk_size', IntToStr(ReadLen)); FormData.AddFile('chunk_data', '', 'application/octet-stream', Chunk); // 发送分块 HTTP.Post(AUrl + '?action=upload_chunk', FormData); finally FormData.Free; end; end; finally HTTP.Free; end; finally FileStream.Free; end; end;5.2 PHP端分块接收与合并:用文件锁防并发冲突
// upload_handler.php 中处理 action=upload_chunk if ($_GET['action'] === 'upload_chunk') { $fileMD5 = $_POST['file_md5'] ?? ''; $chunkIndex = (int)($_POST['chunk_index'] ?? '-1'); $totalChunks = (int)($_POST['total_chunks'] ?? '0'); $chunkSize = (int)($_POST['chunk_size'] ?? '0'); if (empty($fileMD5) || $chunkIndex < 0 || $totalChunks <= 0) { http_response_code(400); die('Invalid chunk parameters'); } $tempDir = '/var/www/chunks/'; $chunkFile = $tempDir . $fileMD5 . '_' . str_pad($chunkIndex, 4, '0', STR_PAD_LEFT) . '.bin'; // 使用文件锁确保同一文件的分块不被并发写入 $fp = fopen($chunkFile, 'c+'); if (flock($fp, LOCK_EX)) { try { // 写入分块数据 if (fwrite($fp, file_get_contents($_FILES['chunk_data']['tmp_name'])) !== $chunkSize) { throw new Exception('Chunk write failed'); } // 校验MD5 $chunkMD5 = md5_file($chunkFile); if ($chunkMD5 !== md5(file_get_contents($_FILES['chunk_data']['tmp_name']))) { throw new Exception('Chunk MD5 mismatch'); } } finally { flock($fp, LOCK_UN); fclose($fp); } } else { http_response_code(503); die('Chunk lock failed'); } // 检查是否所有分块已收齐 $receivedChunks = glob($tempDir . $fileMD5 . '_*.bin'); if (count($receivedChunks) == $totalChunks) { // 合并分块 $finalFile = '/var/www/uploads/' . $fileMD5 . '.bin'; $fpOut = fopen($finalFile, 'wb'); for $i := 0 to $totalChunks - 1 do begin $chunkPath = $tempDir . $fileMD5 . '_' . str_pad($i, 4, '0', STR_PAD_LEFT) . '.bin'; $fpIn = fopen($chunkPath, 'rb'); stream_copy_to_stream($fpIn, $fpOut); fclose($fpIn); unlink($chunkPath); // 清理分块 end; fclose($fpOut); chmod($finalFile, 0644); echo json_encode(['status' => 'complete', 'file' => basename($finalFile)]); } else { echo json_encode(['status' => 'pending', 'received' => count($receivedChunks), 'total' => $totalChunks]); } }5.3 服务端最终校验:用PHP内置函数验证合并后文件完整性
合并完成后,必须用与Delphi端一致的算法校验整体MD5,这是最后一道防线:
// 合并后立即执行 $finalMD5 = md5_file($finalFile); $clientMD5 = $_POST['file_md5'] ?? ''; // 或从数据库查 if ($finalMD5 !== $clientMD5) { unlink($finalFile); error_log("MD5 mismatch for {$finalFile}: server={$finalMD5}, client={$clientMD5}"); http_response_code(500); die('File integrity check failed'); }我的习惯是:在Delphi端上传前,用
TIdHashMessageDigest5.HashFileAsHex算一次MD5并随请求发出;PHP端合并后立刻再算一次。两次MD5不等,宁可重传也不存疑文件——某次固件上传因硬盘坏道导致单字节错误,靠这个校验提前拦截,避免了整条产线刷写失败。希望帮到你。
本文还有配套的精品资源,点击获取