第二篇:Ktor Multipart 文件上传——从普通 POST 到 multipart/form-data)
引言前面的常规网络请求我们大部分处理的是DTO ↓ kotlinx.serialization ↓ JSON ↓ HTTP Body例如client.post(/user) { setBody( CreateUserRequest( name Tom, age 18, ) ) }最终发送{ name: Tom, age: 18 }但真实项目很快就会遇到另一类需求上传头像 上传维修照片 上传 PDF 上传视频 文件 普通参数 文件 JSON 数据 多个文件同时上传这时候普通application/json就不够了。我们需要进入multipart/form-data当前 Ktor 3.5.x 官方提供两种常见 Multipart 上传方式submitFormWithBinaryData() 或者 post() MultiPartFormDataContent对于小文件可以直接使用ByteArray对于较大的文件官方更推荐MultiPartFormDataContent InputProvider进行流式读取并且可以配合onUpload监听上传进度。一、普通 JSON POST 和 Multipart 到底有什么区别普通 POSTPOST /user Content-Type: application/jsonBody{ name: Tom, age: 18 }本质一个 Request ↓ 一个 Body ↓ 整个 Body 是 JSONMultipartPOST /upload Content-Type: multipart/form-dataBody 不再只是一个 JSON。而是Part 1 ↓ description Part 2 ↓ file Part 3 ↓ 其它字段也就是说Multipart 的核心就是一个 HTTP Body 中包含多个独立 Part。二、什么叫 Part例如description 用户头像是一个 Part。文件avatar.png也是一个 Part。最终可以理解成Multipart Body ├── Part │ name description │ value 用户头像 │ ├── Part │ name userId │ value 1001 │ └── Part name file filename avatar.png Content-Type image/png binary data ...所以 Multipart 很适合文字 数字 JSON 文件同时发送。三、Boundary 是什么既然一个 Body 中有很多 PartPart A Part B Part CHTTP 就必须知道前一个 Part 在哪里结束下一个 Part 从哪里开始于是需要boundary例如Content-Type: multipart/form-data; boundaryWebAppBoundaryBody 大概可以理解成--WebAppBoundary Content-Disposition: form-data; namedescription Ktor logo --WebAppBoundary Content-Disposition: form-data; nameimage; filenamektor_logo.png Content-Type: image/png [二进制文件] --WebAppBoundary--所以boundary就是Multipart 中不同 Part 之间的分隔符。四、为什么不要自己随便写 Content-Type很多人第一次上传会直接header( HttpHeaders.ContentType, multipart/form-data )但 Multipart 的 Content-Type 通常还需要boundary例如Content-Type: multipart/form-data; boundaryabc123如果Header boundary和Body 真正使用的 boundary不一致服务器就无法正确拆分 Part。所以通常让MultiPartFormDataContent自己生成并管理 Multipart 的 Content-Type 与 boundary不要手动只写一个裸的multipart/form-data。MultiPartFormDataContent本身就是 Ktor 用于生成multipart/form-dataOutgoingContent 的类型并会携带相应 boundary。五、最简单的小文件上传假设我们已经拿到了val imageBytes: ByteArray可以val response client.submitFormWithBinaryData( url /upload, formData formData { append( description, User avatar, ) append( image, imageBytes, Headers.build { append( HttpHeaders.ContentType, image/png, ) append( HttpHeaders.ContentDisposition, filename\avatar.png\, ) }, ) }, )这里description是普通文本 Part。而image是二进制 Part。Ktor 官方当前就提供submitFormWithBinaryData()作为简单 Multipart 上传方式并指出它适合文件可以安全读入内存的场景。六、formData {} 到底是什么注意formData { ... }它不是直接发送 Request。它做的是创建 Multipart Part ↓ 组成 ListPartData例如formData { append( name, Tom, ) append( age, 18, ) }可以理解成Part #1 name name value Tom Part #2 name age value 18当前 KtorformData {}返回的就是用于构建 Multipart 的ListPartData。七、文件为什么还需要 filename例如append( image, imageBytes, Headers.build { append( HttpHeaders.ContentDisposition, filename\avatar.png\, ) }, )这里有两个名字image和avatar.png它们不是一回事。imageForm Field Name对应后端可能写RequestPart(image)而avatar.png是File Name服务器获得文件后可以知道上传文件叫什么名字。可以记name ↓ 这个 Part 在接口里叫什么 filename ↓ 这个文件本身叫什么八、文件还应该有 Content-Type例如图片image/png image/jpegPDFapplication/pdf普通二进制application/octet-stream例如Headers.build { append( HttpHeaders.ContentType, image/jpeg, ) append( HttpHeaders.ContentDisposition, filename\photo.jpg\, ) }这样服务器就知道这个 Part ↓ 是 JPEG而不是一段没有类型的二进制数据。九、更推荐理解 MultiPartFormDataContent相比submitFormWithBinaryData()我们前面的网络架构更适合理解client.post(/upload) { setBody( MultiPartFormDataContent( formData { ... } ) ) }例如val response client.post( /upload ) { setBody( MultiPartFormDataContent( formData { append( description, User avatar, ) append( image, imageBytes, Headers.build { append( HttpHeaders.ContentType, image/png, ) append( HttpHeaders.ContentDisposition, filename\avatar.png\, ) }, ) } ) ) }执行关系formData ↓ 创建 Part MultiPartFormDataContent ↓ 把所有 Part 编码成 multipart/form-data setBody ↓ 成为真正 Request Body HttpClient ↓ Engine ↓ HTTP十、文件 普通参数例如上传维修图片时同时需要repairOrderId remark file就可以formData { append( repairOrderId, 10001, ) append( remark, 电梯门异常, ) append( file, imageBytes, Headers.build { append( HttpHeaders.ContentType, image/jpeg, ) append( HttpHeaders.ContentDisposition, filename\fault.jpg\, ) }, ) }最终就是Multipart ├ repairOrderId ├ remark └ file这正是 Multipart 最典型的场景。十一、多个文件也只是多个 Part例如file1.jpg file2.jpg file3.jpg可以formData { files.forEach { file -gt; append( files, file.bytes, Headers.build { append( HttpHeaders.ContentType, file.contentType, ) append( HttpHeaders.ContentDisposition, filename\${file.fileName}\, ) }, ) } }最终files file1 files file2 files file3具体后端要求同一个 field name还是file1 / file2 / file3要看接口协议。Multipart 本身并不限制。十二、文件 JSON 怎么办例如接口要求metadata ↓ JSON file ↓ 图片MetadataSerializable data class UploadMetadata( val orderId: Long, val remark: String, )先val metadataJson json.encodeToString( UploadMetadata( orderId 1001, remark 电梯故障, ) )然后formData { append( metadata, metadataJson, Headers.build { append( HttpHeaders.ContentType, ContentType.Application.Json .toString(), ) }, ) append( file, imageBytes, Headers.build { append( HttpHeaders.ContentType, image/jpeg, ) append( HttpHeaders.ContentDisposition, filename\fault.jpg\, ) }, ) }于是Multipart │ ├ metadata │ ↓ │ JSON │ └ file ↓ JPEG十三、为什么这里不能简单 setBody(metadata)普通请求setBody( metadata )整个 Request Body 都是metadata JSON而 Multipart一个 Request Body ↓ 里面有很多 Part所以 JSON 只是其中一个 Part需要先把UploadMetadata ↓ JSON String然后再append ↓ Multipart Part所以这里要区分整个 Request Body 序列化和Multipart 中某一个 Part 的内容不是一个层级。十四、小文件可以 ByteArray但大文件要小心例如头像 100KB先readBytes() ↓ ByteArray通常问题不大。但如果视频 500MB你做File ↓ readBytes() ↓ 500MB ByteArray ↓ 放内存 ↓ 再上传显然非常不合理。可能导致巨大内存占用 GC 压力 OOM 上传开始前还需要等待整个文件读取完所以ByteArray 适合小文件大文件应该使用流式读取。Ktor 当前官方也明确区分了这两类场景submitFormWithBinaryData readBytes()更适合较小文件而MultiPartFormDataContent InputProvider更适合大文件或动态内容。十五、大文件InputProvider例如 JVMval file File( video.mp4 )可以val response client.post( /upload ) { setBody( MultiPartFormDataContent( formData { append( file, InputProvider( size file.length() ) { file .inputStream() .asInput() .buffered() }, Headers.build { append( HttpHeaders.ContentType, video/mp4, ) append( HttpHeaders.ContentDisposition, filename\video.mp4\, ) }, ) } ) ) }这里不是先把整个文件变成 ByteArray而是File ↓ Input ↓ Ktor 一边读取 ↓ 一边发送这就是Streaming UploadInputProvider当前是一个可重复创建Input的 Multipart 数据源并且可以提供文件大小估计。十六、KMP 为什么不能直接使用 java.io.File这是 KMP 上传真正需要考虑的问题。如果你在commonMain直接java.io.File那Android / JVM能用。但是iOS Web Wasm没有同一个 Java File API。所以commonMain 的上传接口不要把java.io.File当成公共模型。否则KMP 网络层直接被 JVM 类型绑死。十七、KMP 文件的核心应该是什么网络层真正需要的其实不是File 类而是文件名 Content-Type 文件大小 文件内容来源也就是Upload File │ ├ fileName ├ contentType ├ size └ content至于Android ↓ Uri / File iOS ↓ NSURL / NSData Web ↓ File / Blob应该在平台层转成网络层能够读取的内容。这和前十二篇的思想完全一样commonMain 定义能力和协议平台层处理平台文件来源。十八、现在 Ktor 已经支持 kotlinx-ioKtor 官方当前 Multipart 示例还提供了 Multiplatform 文件系统写法InputProvider { SystemFileSystem .source( Path( ktor_logo.png ) ) .buffered() }也就是说在支持文件系统访问的平台可以使用kotlinx-ioPath ↓ SystemFileSystem ↓ Source ↓ InputProvider ↓ Multipart而不必把java.io.File带进 commonMain。十九、但 Web 文件仍然需要单独考虑浏览器里的文件通常来自input typefile File Blob它与本地文件系统 Path不是完全同一种模型。当前 Ktor Multipart API 在 Web 平台也提供了appendBlob(...)用于把浏览器Blob添加为 Multipart Part。所以 KMP 实际项目里很可能形成commonMain ↓ Upload 抽象 Android / iOS ↓ File / Path / Source Web ↓ Blob不要为了追求所有平台一模一样而硬把不同平台文件模型揉成一个假的File。二十、上传进度怎么监听Ktor 当前直接提供onUpload { bytesSentTotal, contentLength - ... }例如client.post( /upload ) { setBody( multipartBody ) onUpload { bytesSent, totalBytes, -gt; println( uploaded$bytesSent total$totalBytes ) } }onUpload是HttpRequestBuilder当前提供的上传进度监听入口。二十一、计算百分比例如onUpload { bytesSent, totalBytes, - if ( totalBytes ! null amp;amp; totalBytes gt; 0 ) { val progress bytesSent .toFloat() / totalBytes println( progress$progress ) } }例如bytesSent 50MB contentLength 100MB那么progress 0.5 50%注意contentLength可能是null因为并不是所有上传内容都能提前知道最终大小。所以不能bytesSent / totalBytes!!无脑计算。二十二、进度应该由 NetworkClient 处理吗可以提供进度通道但 NetworkClient 不应该知道进度条 百分比文字 Dialog Compose State例如可以suspend fun upload( ..., onProgress: (Long, Long?) - Unit, )NetworkClient只报告 bytesSent / totalBytesViewModel再转成 0% 100%UI显示 ProgressBar仍然遵守网络层报告事实UI 决定怎么展示。二十三、上传 Timeout 通常和普通 API 不一样普通接口GET /orders ↓ 15 秒上传500MB Video显然不能也简单15 秒所以可以client.post( /upload ) { timeout { requestTimeoutMillis 120_000 } setBody( multipartBody ) }这样apiClient ↓ 仍然长期复用 当前 upload Request ↓ 单独覆盖 Timeout这正是前面讲过的Client 默认配置 Request 单次覆盖二十四、是不是上传就必须创建 uploadClient不一定。如果BaseUrl 一样 Auth 一样 公共 Header 一样 只是 Timeout 更长完全可以复用 apiClient Request Timeout Override没必要上传 ↓ 立刻新建一个 HttpClient二十五、什么时候 uploadClient 才值得独立如果上传域名不同 认证方式不同 Timeout 策略完全不同 并发控制不同 Retry Policy 不同 日志策略不同 TLS 配置不同这时候uploadClient就成为一个真正独立网络责任域于是拆 Client 才有意义。还是第十二篇的原则一个明确的网络配置域对应一个长期复用的 HttpClient。二十六、上传和 Logging 还有一个很重要的关系补充篇 9.1 已经讲过Multipart Binary ↓ 通常不要打印完整 Body想象上传 500MB 视频 Logging ALL如果日志系统试图读取整个 Multipart Body既没意义也可能增加巨大性能开销。所以上传 Client / Request 的 Logging 策略应该URL Method Headers脱敏 文件名 文件大小 Status 耗时而不是完整文件二进制内容这正是bodyFilter适合Multipart / Binary ↓ Skip的场景。二十七、文件上传能不能自动 Retry这里要非常谨慎。比如POST /upload ↓ 上传完成 ↓ Response 返回途中断网Client 看到Network Error但服务器可能已经收到文件如果自动 Retry再上传一次服务器可能产生两个文件所以Multipart Upload 本质仍然是 POST不能因为 Network Error / Timeout 就无脑 Retry。仍然要考虑接口是否幂等 服务端有没有 FileId 有没有 UploadId 有没有 Idempotency-Key Body 是否能够重新读取二十八、InputProvider 还有一个 Retry 相关细节注意InputProvider { ... }的思想是每次需要内容 ↓ 重新提供一个 Input所以如果真的需要Retry数据源必须可以重新打开而不是已经被读完的单次 Input这也是为什么Request Body 可重放性会影响 Retry 安全性。二十九、Multipart 和 ContentNegotiation 是什么关系Response 仍然完全可以ContentNegotiation ↓ JSON ↓ ApiResponseT例如Request ↓ multipart/form-data Response ↓ application/json两者完全没问题。所以Multipart只是在改变Request Body的表达方式。并不会意味着整个 NetworkClient的响应处理逻辑都要重写。三十、把 Multipart 加进我们的 NetworkClient前十二篇已经有get post put delete现在可以增加upload例如suspend inline fun reified T upload( path: String, formData: ListPartData, noinline onProgress: ((Long, Long?) - Unit)? null, noinline block: HttpRequestBuilder.() - Unit {}, ): T { return execute { client.post( path ) { setBody( MultiPartFormDataContent( formData ) ) if ( onProgress ! null ) { onUpload { sent, total, -gt; onProgress( sent, total, ) } } block() } } }这样NetworkClient.upload ↓ 仍然复用原来的 Connectivity ExceptionMapper ApiResponseT Auth Logging Timeout只是Request Body从JSON换成了Multipart三十一、ApiService 就可以很干净例如维修照片上传class RepairApiService( private val networkClient: NetworkClient, ) { suspend fun uploadPhoto( repairOrderId: Long, fileName: String, bytes: ByteArray, onProgress: (Long, Long?) -gt; Unit, ): UploadResult { val parts formData { append( repairOrderId, repairOrderId, ) append( file, bytes, Headers.build { append( HttpHeaders.ContentType, image/jpeg, ) append( HttpHeaders.ContentDisposition, filename\$fileName\, ) }, ) } return networkClient.upload( path /repair/upload, formData parts, onProgress onProgress, ) { timeout { requestTimeoutMillis 120_000 } } } }调用RepairApiService ↓ NetworkClient.upload ↓ apiClient ↓ Multipart ↓ Engine ↓ HTTP整个原有网络架构不需要推翻。三十二、小文件和大文件最好区分可以简单记小文件 ↓ ByteArray ↓ 简单 大文件 ↓ InputProvider / Source ↓ 流式读取不要为了统一 API所有文件 ↓ 先读 ByteArray否则大文件场景会非常危险。三十三、如果接口只上传纯二进制还需要 Multipart 吗不一定。例如服务器要求POST /upload Content-Type: application/octet-stream [整个 Body 就是文件]这时候根本没有多个 Part也就不需要 Multipart。Ktor 可以直接Binary Stream ↓ setBody(...)当前官方同样支持直接把二进制 Channel 作为 Request Body 上传。所以要区分纯文件 Body ↓ application/octet-stream和文件 参数 / 多文件 ↓ multipart/form-data三十四、什么时候应该使用 Multipart可以简单判断一个 Request ↓ 需要同时携带多个独立内容例如文件 文本 文件 JSON 多个文件使用multipart/form-data如果整个 Body 就是一段 JSON继续application/json如果整个 Body 就是一个纯二进制文件可以application/octet-stream不要看到上传就默认一定 Multipart。三十五、一个完整 Multipart 上传例子把前面的知识组合起来suspend fun uploadRepairFile( orderId: Long, remark: String, fileName: String, contentType: String, fileBytes: ByteArray, onProgress: (Float) - Unit, ): UploadResult { val parts formData { append( orderId, orderId, ) append( remark, remark, ) append( file, fileBytes, Headers.build { append( HttpHeaders.ContentType, contentType, ) append( HttpHeaders.ContentDisposition, filename\$fileName\, ) }, ) } return networkClient.upload( path /repair/files, formData parts, onProgress { sent, total, -gt; if ( total ! null amp;amp; total gt; 0 ) { onProgress( sent.toFloat() / total ) } }, ) { timeout { requestTimeoutMillis 120_000 } } }整个流程业务参数 文件 ↓ formData ↓ PartData ↓ MultiPartFormDataContent ↓ multipart/form-data ↓ onUpload ↓ Engine ↓ HTTP ↓ ApiResponseUploadResult ↓ NetworkClient ↓ UploadResult三十六、KMP 文件上传最终应该这样理解不要把问题理解成Ktor 怎么上传 File因为 KMP 里File本身就是平台相关概念。更准确的问题是不同平台如何把自己的文件对象转换成 Ktor Multipart 可以读取的数据源于是Android Uri / File ↓ ┐ iOS │ URL ├→ 文件内容来源 │ ↓ Web │ Multipart Blob ┘ ↓ HTTP而filename contentType size这些信息可以统一进入 common 网络层。三十七、本篇最容易踩的几个坑坑一上传文件全部 readBytes()小文件可以。大文件readBytes() ↓ 整个文件进入内存 ↓ 容易 OOM应该考虑流式读取。坑二commonMain 暴露 java.io.File这样Android 能用 iOS / Web ↓ 直接被 JVM 类型卡住公共网络接口应该围绕文件信息 内容来源设计。坑三手动写裸 multipart/form-data HeaderMultipart 需要boundary最好让MultiPartFormDataContent管理。坑四Logging 打完整 Multipart Body尤其上传图片 视频 PDF通常应该Binary / Multipart ↓ Skip Body Logging坑五上传失败自动 RetryClient 没收到 Response不代表Server 没收到文件必须考虑幂等性和 Body 可重放性。坑六把上传 Progress 做进 UI 逻辑NetworkClient 只应该bytesSent contentLengthViewModel / UI 再决定百分比 进度条 文字坑七看到上传就创建 uploadClient如果只是Timeout 不一样使用Request Override即可。只有形成独立网络配置域以后再考虑拆 Client。三十八、本篇总结普通 JSON RequestDTO ↓ ContentNegotiation ↓ JSON ↓ HTTP BodyMultipart普通字段 JSON 文件 多个文件 ↓ formData ↓ 多个 Part ↓ MultiPartFormDataContent ↓ multipart/form-data小文件ByteArray简单直接。大文件InputProvider Source / Input更适合流式上传。Ktor 当前官方也把MultiPartFormDataContent InputProvider作为大文件和动态内容的推荐方式并支持onUpload监听进度。KMP 最重要的一点则是commonMain ↓ 不要依赖 java.io.File而应该考虑文件名 Content-Type 大小 内容来源平台Android iOS Web各自把自己的File / Uri / URL / Blob转换成上传数据源。对于我们前十二篇建立的架构Multipart 并不需要推翻任何东西。只是从NetworkClient.post() ↓ JSON Body增加NetworkClient.upload() ↓ Multipart Body原来的Auth Timeout Logging ExceptionMapper Connectivity ApiResponseT Provider Engine依然继续工作。这正是之前把网络基础架构搭完整的价值新增“文件上传”只是增加一种 Request Body 能力而不是重新设计一套网络层。下一篇KMP-Net进阶第三篇文件下载与 Progress——大文件为什么不能直接 bodyByteArray()下一篇会从GET /file ↓ Response Body继续深入小文件下载 大文件流式下载 ByteReadChannel onDownload Content-Length 下载进度 取消下载 写入本地文件 Android / iOS / Web 文件保存差异 为什么大文件不能一次性全部读进内存 下载是否应该单独设计 DownloadClient也就是把这一篇的Upload Stream反过来理解Download Stream真正进入 Ktor 大数据流式传输。