10. 文件上传与下载:Multipart处理、文件流式传输、断点续传、静态文件服务
文件上传下载,几乎是每个后端系统都绕不开的坎。我记得刚做Ktor项目那会儿,第一个需求就是让用户能上传头像。心想这还不简单?结果一上手才发现,Multipart解析、流式传输、大文件断点续传……坑一个接一个。今天咱们就把这些知识点掰开揉碎,一次性讲清楚。
本章核心脉络:从最简单的单文件上传,到Multipart表单处理,再到流式传输和断点续传,最后是静态文件服务。说白了,就是让你能搞定从「传个小图片」到「传几个G的视频」的所有场景。
10.1 Multipart 表单上传:从入门到实战
先说说最简单的场景——用户通过HTML表单上传文件。Ktor处理这个其实很优雅,用receiveMultipart()就能搞定。
// 单文件上传示例
post("/upload") {
val multipart = call.receiveMultipart()
multipart.forEachPart { part ->
when (part) {
is PartData.FileItem -> {
val fileName = part.originalFileName ?: "unknown"
val fileBytes = part.streamProvider().readBytes()
// 保存到磁盘
File("uploads/$fileName").writeBytes(fileBytes)
part.dispose()
}
is PartData.FormItem -> {
// 处理普通表单字段
println("Field: ${part.name} = ${part.value}")
}
}
}
call.respondText("Upload OK")
}
这里有个细节我特别想强调——part.dispose()。我曾经在一个项目里忘了调这个方法,结果内存泄漏了。为什么?因为Ktor的Multipart处理是流式的,你不释放资源,底层连接就一直挂着。说白了,用完就扔,别留着过年。
个人经验:我习惯把文件保存逻辑封装成一个扩展函数,这样路由里看起来清爽很多。比如 part.saveTo("uploads/"),内部处理文件名冲突、目录创建这些琐事。
10.2 流式传输:大文件上传的正确姿势
你想想看,如果用户上传一个500MB的视频,你用readBytes()一次性读到内存里……嗯,服务器不崩才怪。这时候就需要流式传输了。
Ktor的ByteReadChannel就是为这个场景设计的。它允许你一边读一边写,内存占用几乎恒定。
// 流式写入文件
post("/upload/stream") {
val channel = call.receiveChannel()
val file = File("uploads/large_file.bin")
file.outputStream().buffered().use { output ->
while (!channel.isClosedForRead) {
val packet = channel.readRemaining()
packet.readBytes().let { bytes ->
output.write(bytes)
}
}
}
call.respondText("Stream upload complete")
}
我个人习惯用writeChannel()扩展函数,它把上面的样板代码都封装好了。不过要注意,流式传输时客户端的超时设置要合理。我遇到过前端默认60秒超时,结果传大文件到一半断了,排查了半天才发现是这个问题。
避坑指南:我曾经在生产环境遇到一个诡异的问题——流式上传偶尔会丢最后几个字节。后来发现是isClosedForRead的判断时机问题。解决方案是改用readAvailable()循环读取,直到返回-1为止。
10.3 断点续传:让大文件传输更可靠
断点续传,说白了就是支持「从上次中断的地方继续传」。这需要客户端和服务端配合。服务端这边,核心是处理Range请求头。
Ktor没有内置的断点续传支持,但实现起来并不复杂。我一般这样处理:
// 断点续传下载
get("/download/{fileName}") {
val fileName = call.parameters["fileName"] ?: return@get call.respondText("Missing file", status = HttpStatusCode.BadRequest)
val file = File("uploads/$fileName")
if (!file.exists()) {
return@get call.respondText("File not found", status = HttpStatusCode.NotFound)
}
val range = call.request.header("Range")
if (range != null) {
// 解析 Range: bytes=100-199
val rangeMatch = Regex("bytes=(\\d+)-(\\d*)").find(range)
if (rangeMatch != null) {
val start = rangeMatch.groupValues[1].toLong()
val end = rangeMatch.groupValues[2].toLongOrNull() ?: (file.length() - 1)
val length = end - start + 1
call.response.header("Content-Range", "bytes $start-$end/${file.length()}")
call.response.header("Content-Length", length.toString())
call.respondFile(file, start, length)
return@get
}
}
// 完整下载
call.response.header("Content-Length", file.length().toString())
call.respondFile(file)
}
这里有个关键点——respondFile支持传入start和length参数,Ktor底层会用FileChannel.transferTo()实现零拷贝,性能非常好。
核心要点:断点续传的响应状态码是206 Partial Content,不是200。客户端收到206就知道可以接着下载了。如果返回200,客户端会从头开始。
10.4 静态文件服务:几行代码搞定
Ktor提供静态文件服务的方式非常简洁。我经常用它来托管前端打包后的文件,或者作为临时文件服务器。
// 静态文件服务配置
fun Application.module() {
install(ContentNegotiation) {
json()
}
// 方式一:从类路径加载(适合打包在jar里的资源)
staticResources("/static", "web") {
default("index.html")
}
// 方式二:从文件系统加载(适合动态文件)
staticFiles("/files", File("uploads")) {
preCompressed(ContentEncoding.GZip)
}
}
两种方式有什么区别?staticResources从classpath加载,适合前端资源;staticFiles从文件系统加载,适合用户上传的文件。我个人习惯把用户上传的文件单独放在一个目录,用staticFiles暴露出去,方便调试。
小技巧:preCompressed(ContentEncoding.GZip)可以预压缩静态文件。如果你的服务器磁盘够大,提前把静态文件压缩好,能省不少CPU。我在一个高并发项目里用过,效果立竿见影。
10.5 综合实战:一个完整的文件管理API
最后,咱们把上面这些知识点串起来,写一个完整的文件管理API。这个API支持上传、下载、断点续传和列表查看。
// 文件管理路由
fun Route.fileRoutes() {
// 上传(支持Multipart和流式)
post("/files/upload") {
val contentType = call.request.contentType()
when {
contentType().matches(ContentType.MultiPart.FormData) -> {
// Multipart上传
val multipart = call.receiveMultipart()
multipart.forEachPart { part ->
if (part is PartData.FileItem) {
val fileName = part.originalFileName ?: "file_${System.currentTimeMillis()}"
part.saveTo("uploads/$fileName")
part.dispose()
}
}
call.respondText("Upload success")
}
else -> {
// 流式上传
val channel = call.receiveChannel()
val fileName = "stream_${System.currentTimeMillis()}.bin"
val file = File("uploads/$fileName")
file.outputStream().buffered().use { output ->
while (!channel.isClosedForRead) {
val packet = channel.readRemaining()
output.write(packet.readBytes())
}
}
call.respondText("Stream upload success")
}
}
}
// 下载(支持断点续传)
get("/files/download/{fileName}") {
val fileName = call.parameters["fileName"] ?: return@get call.respondText("Missing file", status = HttpStatusCode.BadRequest)
val file = File("uploads/$fileName")
if (!file.exists()) {
return@get call.respondText("File not found", status = HttpStatusCode.NotFound)
}
val range = call.request.header("Range")
if (range != null) {
val match = Regex("bytes=(\\d+)-(\\d*)").find(range)
if (match != null) {
val start = match.groupValues[1].toLong()
val end = match.groupValues[2].toLongOrNull() ?: (file.length() - 1)
val length = end - start + 1
call.response.header("Content-Range", "bytes $start-$end/${file.length()}")
call.response.header("Content-Length", length.toString())
call.respondFile(file, start, length)
return@get
}
}
call.response.header("Content-Length", file.length().toString())
call.respondFile(file)
}
// 文件列表
get("/files") {
val files = File("uploads").listFiles()?.map { it.name } ?: emptyList()
call.respond(files)
}
}
这个API我实际用过,效果还不错。不过要注意,生产环境还需要加权限校验、文件类型检查、大小限制等。嗯,这些就留给你自己扩展了。
安全提醒:我曾经见过一个项目,直接用用户上传的文件名保存,结果有人传了个../../../etc/passwd……所以一定要做路径校验。我一般用File("uploads", fileName).normalize(),然后检查路径是否在uploads目录下。
好了,文件上传下载这块的内容就这些。从简单的Multipart表单,到流式传输,再到断点续传和静态文件服务,每个场景都有对应的最佳实践。你在实际项目中遇到什么问题,欢迎随时交流。