ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Delphi客户端文件上传与PHP接收:multipart/form-data实战与避坑指南

Delphi客户端文件上传与PHP接收:multipart/form-data实战与避坑指南 简介一套面向Delphi桌面端开发者与PHP服务端工程师的文件上传联调示例代码解决客户端提交文件、服务端接收存储的完整链路问题。示例覆盖通过Indy组件构造HTTP POST请求、封装二进制文件流以及在服务端PHP脚本中借助$_FILES与move_uploaded_file()完成接收、校验和目录存储等关键步骤适合刚接触C/S架构上传功能的初学者也便于有经验者快速复用。压缩包共35个文件、491KB以Delphi工程源文件.pas源码、.dfm窗体设计、.dproj工程配置、PHP服务端脚本及可执行程序为主另有多份开发历史备份便于对照关键改动。代码结构清晰客户端与服务端职责边界明确。目前已有479人学习浏览。阅读源码后可掌握TIdHTTP组件上传流程、请求头构造、文件流封装、服务端安全校验与move_uploaded_file()文件转移的标准写法并据此扩展多文件上传、进度条显示和断点续传等实用功能。1. delphi客户端文件上传和php服务器端接收一套能直接落地的CS上传组合先说结论delphi客户端文件上传和php服务器端接收代码这套资源解决的是桌面端把报表、照片、PDF 推到 Web 服务器的需求。如果你之前自己从零拼过 HTTP 报文大概率卡在 multipart/form-data 的分隔线和编码上。这套资源把两头都封装好了Delphi 侧用 Indy 组件按规范生成 multipart 请求PHP 侧用 $_FILES 接收、落盘、回 JSON客户端拿到结果后直接解析 code 字段判断成功或失败。适合谁维护 Delphi 老项目、写上位机或 CS 架构系统、需要把本地文件回传到 PHP 后端的开发者。新手照着代码套用即可熟手可以直接拿里面的超时参数、边界处理和避坑清单当参考。下面按“客户端封装 → 服务器端接收 → 排查踩坑 → PHP 8 兼容 → 接口验证”的顺序把关键点展开。2. Delphi客户端封装TIdMultiPartFormDataStream与multipart/form-data关键点2.1 为什么选Indy而不是WinInetDelphi 里做 HTTP 上传常见方案有 TIdHTTPIndy 10、WinInet、以及第三方组件库。我一般优先选 Indy理由是它对 multipart/form-data 的支持最直接TIdMultiPartFormDataStream 负责生成 boundary、Content-Disposition 和文件头开发时不用手动拼分隔线。WinInet 的 InternetWriteFile 回调适合底层控制但代码量大连接状态和回调时机不好排查。Indy 的另一个好处是部署简单目标机器装了对应 DLL 或者静态编译进 EXE 就行。Indy 10 里和上传配套的类是 TIdMultiPartFormDataStream它把所有字段和文件统一打包成一个请求体TIdHTTP.Post 直接发送。需要注意 Indy 10 有多个大版本部分旧版本对 AddFile 的参数名解析有差异建议使用 10.6.2 以上版本代码行为更一致。2.2 封装上传函数源码与参数说明下面这段是我常用的封装把文件上传和表单字段提交合并成一个函数调用方只需要传 URL、本地文件路径和附加字段。uses IdHTTP, IdMultipartFormData, System.JSON, System.SysUtils; function UploadFileToPHP(const AUrl, ALocalFile: string; const AFields: array of string): string; var LHttp: TIdHTTP; LMulti: TIdMultiPartFormDataStream; I: Integer; begin Result : ; if not FileExists(ALocalFile) then raise Exception.Create(local file not exists: ALocalFile); LHttp : TIdHTTP.Create(nil); LMulti : TIdMultiPartFormDataStream.Create; try LHttp.ReadTimeout : 30000; LHttp.ConnectTimeout : 10000; LHttp.HTTPOptions : LHttp.HTTPOptions [hoKeepOrigProtocol]; LMulti.AddFile(file, ALocalFile, application/octet-stream); // AFields 按 字段名值 成对传入 I : 0; while I Length(AFields) do begin LMulti.AddFormField(AFields[I], AFields[I 1]); Inc(I, 2); end; try Result : LHttp.Post(AUrl, LMulti); except on E: EIdHTTPProtocolException do Result : E.ErrorMessage; on E: Exception do Result : E.Message; end; finally LMulti.Free; LHttp.Free; end; end;逻辑说明AddFile 会把本地文件读取成流并生成Content-Disposition: form-data; namefile; filename文件名这一段请求体。AddFormField 用来附加普通字段比如 token、client_version、业务单号PHP 端在 $_POST 里能直接读到。循环里每次取两个参数按“字段名值”成对处理传参时顺序不能颠倒。参数方面要注意三个点。ReadTimeout 是等响应最长时间单位毫秒上传大文件时建议提高到 60 秒以上ConnectTimeout 只管 TCP 建连。hoKeepOrigProtocol这个选项防止 Indy 擅自改变 HTTP 协议版本某些 PHP 环境对 HTTP/1.0 和 1.1 的响应处理有差异保留原协议更稳。AddFile 的第三个参数是 Content-Type这里传application/octet-stream表示未知二进制类型如果你确定上传的是 PDF 或 PNG可以分别传application/pdf、image/png服务器端可以用它做初步判断但最终校验不能依赖这个字段。2.3 常见误用别用 POST 参数拼文件路径一个非常常见的误解是用TIdHTTP.Request.Params.AddValue(file, 文件路径)上传文件。这样 PHP 端确实能在 $_POST[file] 里拿到一个字符串但拿到的只是路径文本$_FILES 永远是空的因为请求体根本不是 multipart 格式。正确做法是让 TIdMultiPartFormDataStream 参与请求体构造。还有人喜欢手工拼 multipart 报文自己生成 boundary、自己拼\r\n和文件名。遇到纯英文短文件名还能跑一旦文件名带中文、路径带空格转义和编码就翻车。Indy 已经处理好这块没必要重复造轮子。如果你需要在一次请求里上传多个文件就在同一个 TIdMultiPartFormDataStream 上多次调用 AddFile字段名可以相同PHP 端会把同名字段解析成数组。大文件方面Indy 10 的 TIdMultiPartFormDataStream 内部按文件流处理不是一次性把整个文件读进内存几百 MB 的 PDF 上传时内存占用可控。但要注意 ReadTimeout 必须同步调大否则文件还没传完连接就被掐断。3. PHP服务器端接收$_FILES、目录权限与php.ini四件套3.1 接收代码检查、落盘、返回JSON服务器端我习惯把所有处理和响应集中在一个入口脚本里先做基础检查再移动文件最后输出统一结构的 JSON客户端解析起来不需要分支判断。?php header(Content-Type: application/json; charsetutf-8); $response [code 1, message , path ]; if (empty($_FILES[file])) { $response[message] no file field; echo json_encode($response, JSON_UNESCAPED_UNICODE); exit; } $upFile $_FILES[file]; if ($upFile[error] ! UPLOAD_ERR_OK) { $response[message] upload error code: . $upFile[error]; echo json_encode($response, JSON_UNESCAPED_UNICODE); exit; } $targetDir __DIR__ . DIRECTORY_SEPARATOR . uploads . DIRECTORY_SEPARATOR; if (!file_exists($targetDir)) { mkdir($targetDir, 0755, true); } if (!is_writable($targetDir)) { $response[message] dir not writable: . $targetDir; echo json_encode($response, JSON_UNESCAPED_UNICODE); exit; } $ext strtolower(pathinfo($upFile[name], PATHINFO_EXTENSION)); if (!in_array($ext, [pdf, jpg, png, xlsx, docx, zip], true)) { $response[message] ext not allowed: . $ext; echo json_encode($response, JSON_UNESCAPED_UNICODE); exit; } $newName date(Ymd_His) . _ . bin2hex(random_bytes(4)) . . . $ext; if (!move_uploaded_file($upFile[tmp_name], $targetDir . $newName)) { $response[message] move_uploaded_file failed; echo json_encode($response, JSON_UNESCAPED_UNICODE); exit; } $response[code] 0; $response[path] uploads/ . $newName; echo json_encode($response, JSON_UNESCAPED_UNICODE);这段代码的逻辑顺序很关键先判断表单字段存在再判断上传错误码然后检查目录可写接着做扩展名白名单最后才 move_uploaded_file。UPLOAD_ERR_OK是 0如果临时文件没有正常生成error 字段会是其他非 0 值比如 UPLOAD_ERR_INI_SIZE1或 UPLOAD_ERR_PARTIAL3提前拦截能避免后续误判。pathinfo(..., PATHINFO_EXTENSION)拿到的是小写扩展名但为了防止 Windows 上传的文件名像TEST.PDF统一再用 strtolower 转一次。落盘文件名用日期_时间_随机数拼出来这样服务端完全不信任客户端原始文件名既规避并发覆盖也减少文件名注入类问题。bin2hex(random_bytes(4))生成 8 位十六进制随机串重名概率已经非常低。响应里统一给code和message客户端只需要判断 code 是否等于 0。用JSON_UNESCAPED_UNICODE是为了让返回信息里的中文保持可读抓包排查时不用去翻译 \uXXXX 转义。3.2 php.ini和Nginx的配置要一起调上传遇到问题十有八九是配置文件没跟上。php.ini 里跟文件上传直接相关的参数有三个再加 Nginx 也有一个缺一不可。每次接手上传需求我都会先跑一遍php -i | grep upload_max_filesize看当前生效值因为 php.ini 可能在多个位置有的改完没重启服务根本不生效。参数默认值建议值说明upload_max_filesize2M64M单个文件大小上限post_max_size8M80M整个 POST 请求体上限必须大于 upload_max_filesizemax_execution_time30120上传和后续处理的最长执行时间memory_limit128M256M脚本内存上限处理大数组和图像时容易触碰client_max_body_sizeNginx1m64mNginx 请求体上限与 upload_max_filesize 保持一致注意post_max_size如果小于等于upload_max_filesize文件本身没超限但请求体会被 PHP 拒绝报错是POST Content-Length exceeds the limit这个限制针对的是整个请求包括普通表单字段和 multipart 边界符所以要留出冗余。Nginx 的client_max_body_size默认只有 1m这是个隐蔽的坑PHP 这边明明改成 64M超过 1M 的文件却还是传不上去因为 Nginx 在请求到达 PHP 之前就拦截了。改完配置后依次执行nginx -t、systemctl reload nginx、systemctl restart php8.3-fpm保证两层都生效。还有 PHP 8 开始Windows 下如果 FPM 或 CLI 报vcruntime140.dll不兼容通常是 VC 运行库版本不够装最新的 VC 2015-2022 运行库即可这跟 php.ini 没有关系也别在上传代码里找原因。3.3 客户端与服务端约定响应字段客户端和服务端最好提前约定一套统一的响应结构避免每个接口返回格式都不一样。我这套约定是code0表示成功非 0 表示失败失败时用message给用户弹窗提示成功时用path返回服务器上的相对路径方便客户端后续展示或下载。Delphi 端收到 HTTP 响应后用 TJSONObject.ParseJSONValue 解析读取V.Values[code].Value.ToInt不要写死从某个固定位置截取字符串。JSON 结构一旦约定好无论之后是 PHP 脚本还是 Java 接口客户端解析代码都不用动。字段命名上避免用successtrue/false这种布尔值因为中间态错误类型多后续加错误码会破坏约定。4. 上传文件实战排查五个最容易翻车的点与处理办法4.1 中文文件名在服务器端乱码现象Delphi 端上传“测试报告.pdf”服务器落盘文件名乱码或者 PHP 拿到的$_FILES[file][name]是乱码。原因Delphi 2010 之前的版本默认字符串是 AnsiString编码取决于本机代码页Indy 按 UTF-8 发送内容如果字符串来源是 GBK 编码发到 PHP 端再按 UTF-8 展示就变成乱码。更早的 Delphi 7 项目尤其常见。解决客户端在调用 AddFile 前把文件名转成 UTF-8 再拼路径。最省事的做法是服务器端根本不保存客户端传的原始文件名一律用随机名落盘保留原名的唯一目的是取扩展名做白名单判断。这样即使客户端编码不统一也不会影响服务器正常存储。4.2 超过1M就报HTTP 413现象几百 KB 的小文件上传正常超过 1MB 立即报413 Request Entity Too LargePHP 端根本收不到请求。原因Nginx 的client_max_body_size默认 1m代理层拦截了超大请求体不会向后端转发。php.ini 里upload_max_filesize怎么调都没用因为请求根本没到 PHP。解决把 Nginx 配置里的client_max_body_size改成 64m 或者更大和业务预期最大文件一致然后systemctl reload nginx。同时确认 php.ini 的post_max_size大于upload_max_filesize否则会在 PHP 层再报一次POST Content-Length exceeds the limit。4.3 PHP拿不到$_FILES字段现象Delphi 端 Post 执行完没有抛异常PHP 端$_FILES[file]却是空数组$_POST里也没有预期字段。原因最常见的是客户端没有真正以 multipart/form-data 格式发送。比如用了TIdHTTP.Request.Params.AddValue传文件路径服务器收到的只是普通表单文本PHP 不会把它当成文件上传。其次是字段名没对上客户端 AddFile 用的名字是filePHP 端却检查$_FILES[upload]。解决先用 Wireshark 或 Fiddler 抓包看请求头里Content-Type是不是multipart/form-data; boundary...以及 body 里有没有filename一眼就能定位问题出在客户端还是服务端。然后 PHP 端先执行print_r($_FILES);看字段结构确认字段名一致。4.4 move_uploaded_file失败现象PHP 没有报普通错误但move_uploaded_file返回 falseuploads 目录始终没有目标文件。原因运行 PHP-FPM 的系统用户通常 www-data对目标目录没有写权限。常见于把 uploads 目录上传到服务器后根目录属主还是 rootCentOS 环境还多一层 SELinuxhttpd_sys_content_t类型下 PHP 进程没有写权限。解决先ls -ld uploads确认属主和权限执行chown -R www-data:www-data uploads权限保持 0755 即可。SELinux 开启的 CentOS 还要执行restorecon -R uploads更新上下文不要一遇到权限问题就setenforce 0那是把整台服务器的防护都关掉了。另外检查upload_tmp_dir指向的临时目录是否可写PHP 上传先落到这个目录再靠 move_uploaded_file 搬到目标目录。4.5 并发上传同名文件互相覆盖现象两个客户端同时上传同一个文件名的文件先到的被后到的覆盖。原因服务器端直接用$upFile[name]当存档文件名没有做唯一化处理。客户端文件名不可信同名同时到达是正常场景。解决落盘名一律用时间戳加随机串拼接比如date(Ymd_His) . _ . bin2hex(random_bytes(4)) . .pdf即使同一秒上传上百个文件也不会撞名。附带的好处是原始文件名不参与存储路径客户端传../../evil.php这类带有路径含义的名字时服务端完全不受影响。下面附一张上传问题速查表遇到问题先对号入座现象检查位置处理建议413 Request Entity Too LargeNginx client_max_body_size调大并 reloadPOST Content-Length exceeds the limitphp.ini post_max_size调大到 upload_max_filesize 的 1.2 倍以上$_FILES 为空客户端 Content-Type确认使用 TIdMultiPartFormDataStreammove_uploaded_file false目录属主 / SELinuxchown www-datarestorecon上传后文件 0 字节upload_tmp_dir 权限检查临时目录可写性5. PHP 8环境下接收侧进阶多文件上传与安全验证5.1 $_FILES的结构在PHP 8下的兼容性PHP 8.0、PHP 8.3 里 $_FILES 的字段结构跟 PHP 7 一致每个文件有 name、type、tmp_name、error、size 五个键多文件上传时同一个键名对应的是数组。PHP 8 删除了不少老函数比如get_magic_quotes_gpc、set_magic_quotes_runtime在 8.0 起已移除老代码里如果有要先去干净但文件上传这条链路没有破坏性变化move_uploaded_file、is_uploaded_file 这些核心函数都保留。代码迁移时真正要注意的是老项目里可能混着mysql_escape_string这类已删除函数但这是另一条线。对上传功能来说PHP 8 环境下跑json_encode、random_bytes、finfo_file都没问题上面的接收代码可以直接迁移不需要为版本单独改写。5.2 多文件上传的两种写法多文件上传在客户端有两种实现一种是一次请求传一个文件Delphi 端循环调用上传函数另一种是一次请求里对同一个字段名多次 AddFile。我一般推荐前一种业务上每个文件独立成功或失败服务器端处理简单如果追求效率实时性是次要需求循环调用也足够。非要一次请求传多个文件Delphi 端可以这样写LMulti.AddFile(files[], ALocalFile1, application/octet-stream); LMulti.AddFile(files[], ALocalFile2, application/octet-stream);字段名带方括号后PHP 端收到的结构是数组处理时按索引遍历if (!empty($_FILES[files])) { $fileList $_FILES[files]; if (is_array($fileList[name])) { for ($i 0; $i count($fileList[name]); $i) { $error $fileList[error][$i]; $tmpName $fileList[tmp_name][$i]; $origName $fileList[name][$i]; // 每个文件单独走错误检查和落盘逻辑 } } }注意一个细节当files[]只传了一个文件时PHP 8 仍然会把它解析成数组这一点和旧版行为基本一致但为了稳妥代码里还是要用is_array($fileList[name])兜底防止某些环境下结构变成字符串导致遍历报错。每个子文件的 error、tmp_name、name 都是单独的下标不能混用。5.3 安全验证白名单、文件内容校验与目录防执行文件上传漏洞的核心成因就是信任客户端给出的文件名和 Content-Type。客户端说这是 PDF实际发上来的是改了扩展名的脚本文件。服务端有三层要做扩展名白名单、文件内容类型校验、上传目录禁止执行脚本。扩展名白名单就是前一章代码里的 in_array 判断白名单比黑名单可靠因为黑名单永远列不全。接着用 finfo 读文件真实 MIME 类型$finfo finfo_open(FILEINFO_MIME_TYPE); $realType finfo_file($finfo, $upFile[tmp_name]); finfo_close($finfo); $allowedMime [application/pdf, image/jpeg, image/png]; if (!in_array($realType, $allowedMime, true)) { // 返回失败拒绝落盘 }finfo_file是根据文件头部的 magic bytes 判断类型不是看文件名后缀。比如一个伪装成 pdf 的 PHP 文件改名为 abc.pdf 后finfo 读出的真实类型大概率是text/plain或application/x-php直接拦截。图片文件还可以用 getimagesize 再校验一次尺寸能顺带挡掉某些图片马。最后一层是部署层面上传目录不能执行 PHP。Nginx 环境在 server 块里限制location ^~ /uploads/ { location ~ \.php$ { return 403; } }Apache 环境在 uploads 目录放一个 .htaccess内容只写一行php_flag engine off。这样即使哪一层校验漏掉了一个脚本文件落到 uploads 目录也不会被当成 PHP 执行。6. 改造成REST接口后的验证技巧别只信“上传成功”6.1 用md5对比本地文件与服务端文件接口返回 code0 只代表服务器“接受了请求并落盘”不代表文件内容完整。上传过程经过 Nginx 转发、PHP 临时文件搬移、磁盘写入任何一层静默出错都可能造成文件损坏。验证方法客户端计算本地文件的 MD5服务端也把落盘文件的 MD5 回传客户端对比两串哈希。Delphi 端用 Indy 自带的 TIdHashMD5 计算不需要引第三方库uses IdHashMD5, System.Classes; function LocalFileMD5(const AFileName: string): string; var LMD5: TIdHashMD5; LStream: TFileStream; begin LMD5 : TIdHashMD5.Create; LStream : TFileStream.Create(AFileName, fmOpenRead or fmShareDenyNone); try Result : LMD5.HashStreamAsHex(LStream); finally LStream.Free; LMD5.Free; end; end;HashStreamAsHex直接返回小写十六进制字符串和 PHP 端md5_file()的输出格式一致。fmShareDenyNone允许其他进程同时读文件避免用户正开着 PDF 阅读器时上传报“文件被占用”。PHP 端在落盘成功后追加一行md5_file($targetDir . $newName)放进响应 JSON 的md5字段。客户端拿到 code0 后不急着提示成功先对比两份哈希。如果不一致说明上传链路有丢包或文件被改写直接提示用户重试。这个方法对大文件特别重要几十兆的文件传输过程中出现单比特错误服务端是感知不到的只有哈希对比能兜住。6.2 上线前十分钟的自检流程后来我把这个流程固定成一套动作先确认客户端代码里文件名已转 UTF-8再查 php.ini 四个参数和 Nginx 的 client_max_body_size然后确认 uploads 目录属主正确最后跑一次“上传 → 比对 md5 → 删除测试文件”的完整循环。整个流程十分钟左右能挡掉大部分常见的上传问题。上传功能表面简单真正的坑全藏在协议边界和配置边界上不验证完整链路生产环境迟早给你上一课。从那以后我每次接手上传需求都强制按上面这套顺序走一遍再写业务代码交付前也必定执行一次 md5 对比验证。希望帮到你。本文还有配套的精品资源点击获取
返回列表