(KMP-Net进阶)第二篇: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 到底有什么区别?
普通 POST:
POST /user
Content-Type:
application/json
Body:
{
"name": "Tom",
"age": 18
}
本质:
一个 Request
↓
一个 Body
↓
整个 Body 是 JSON
Multipart:
POST /upload
Content-Type:
multipart/form-data
Body 不再只是一个 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 中有很多 Part:
Part A
Part B
Part C
HTTP 就必须知道:
前一个 Part 在哪里结束,下一个 Part 从哪里开始?
于是需要:
boundary
例如:
Content-Type:
multipart/form-data;
boundary=WebAppBoundary
Body 大概可以理解成:
--WebAppBoundary
Content-Disposition:
form-data; name="description"
Ktor logo
--WebAppBoundary
Content-Disposition:
form-data;
name="image";
filename="ktor_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;
boundary=abc123
如果:
Header boundary
和:
Body 真正使用的 boundary
不一致,服务器就无法正确拆分 Part。
所以通常:
让
MultiPartFormDataContent自己生成并管理 Multipart 的 Content-Type 与 boundary,不要手动只写一个裸的multipart/form-data。
MultiPartFormDataContent 本身就是 Ktor 用于生成 multipart/form-data OutgoingContent 的类型,并会携带相应 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
↓
组成 List<PartData>
例如:
formData {
append(
"name",
"Tom",
)
append(
"age",
18,
)
}
可以理解成:
Part #1
name = name
value = Tom
Part #2
name = age
value = 18
当前 Ktor formData {} 返回的就是用于构建 Multipart 的 List<PartData>。
七、文件为什么还需要 filename?
例如:
append(
"image",
imageBytes,
Headers.build {
append(
HttpHeaders.ContentDisposition,
"filename=\"avatar.png\"",
)
},
)
这里有两个名字:
image
和:
avatar.png
它们不是一回事。
image:
Form Field Name
对应后端可能写:
@RequestPart("image")
而:
avatar.png
是:
File Name
服务器获得文件后,可以知道上传文件叫什么名字。
可以记:
name
↓
这个 Part 在接口里叫什么
filename
↓
这个文件本身叫什么
八、文件还应该有 Content-Type
例如图片:
image/png
image/jpeg
PDF:
application/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 ->
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
↓
图片
Metadata:
@Serializable
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
例如 JVM:
val 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 Upload
InputProvider 当前是一个可重复创建 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-io
Ktor 官方当前 Multipart 示例还提供了 Multiplatform 文件系统写法:
InputProvider {
SystemFileSystem
.source(
Path(
"ktor_logo.png"
)
)
.buffered()
}
也就是说,在支持文件系统访问的平台,可以使用 kotlinx-io:
Path
↓
SystemFileSystem
↓
Source
↓
InputProvider
↓
Multipart
而不必把:
java.io.File
带进 commonMain。
十九、但 Web 文件仍然需要单独考虑
浏览器里的文件通常来自:
<input type="file">
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,
->
println(
"uploaded=$bytesSent " +
"total=$totalBytes"
)
}
}
onUpload 是 HttpRequestBuilder 当前提供的上传进度监听入口。
二十一、计算百分比
例如:
onUpload {
bytesSent,
totalBytes,
->
if (
totalBytes != null &&
totalBytes > 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 / totalBytes
ViewModel:
再转成
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
↓
ApiResponse<T>
例如:
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:
List<PartData>,
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,
->
onProgress(
sent,
total,
)
}
}
block()
}
}
}
这样:
NetworkClient.upload
↓
仍然复用原来的:
Connectivity
ExceptionMapper
ApiResponse<T>
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?) -> 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,
->
if (
total != null &&
total > 0
) {
onProgress(
sent.toFloat() /
total
)
}
},
) {
timeout {
requestTimeoutMillis =
120_000
}
}
}
整个流程:
业务参数
+
文件
↓
formData
↓
PartData
↓
MultiPartFormDataContent
↓
multipart/form-data
↓
onUpload
↓
Engine
↓
HTTP
↓
ApiResponse<UploadResult>
↓
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 Header
Multipart 需要:
boundary
最好让:
MultiPartFormDataContent
管理。
坑四:Logging 打完整 Multipart Body
尤其上传:
图片
视频
PDF
通常应该:
Binary / Multipart
↓
Skip Body Logging
坑五:上传失败自动 Retry
Client 没收到 Response
不代表:
Server 没收到文件
必须考虑幂等性和 Body 可重放性。
坑六:把上传 Progress 做进 UI 逻辑
NetworkClient 只应该:
bytesSent
contentLength
ViewModel / UI 再决定:
百分比
进度条
文字
坑七:看到上传就创建 uploadClient
如果只是:
Timeout 不一样
使用:
Request Override
即可。
只有形成独立网络配置域以后,再考虑拆 Client。
三十八、本篇总结
普通 JSON Request:
DTO
↓
ContentNegotiation
↓
JSON
↓
HTTP Body
Multipart:
普通字段
+
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
ApiResponse<T>
Provider
Engine
依然继续工作。
这正是之前把网络基础架构搭完整的价值:
新增“文件上传”只是增加一种 Request Body 能力,而不是重新设计一套网络层。
下一篇
(KMP-Net进阶)第三篇:文件下载与 Progress——大文件为什么不能直接 body<ByteArray>()?
下一篇会从:
GET /file
↓
Response Body
继续深入:
小文件下载
大文件流式下载
ByteReadChannel
onDownload
Content-Length
下载进度
取消下载
写入本地文件
Android / iOS / Web 文件保存差异
为什么大文件不能一次性全部读进内存
下载是否应该单独设计 DownloadClient
也就是把这一篇的:
Upload Stream
反过来理解:
Download Stream
真正进入 Ktor 大数据流式传输。
更多推荐



所有评论(0)