10. 文件上传与下载:Multipart处理、文件流式传输、断点续传、静态文件服务

文件上传下载,几乎是每个后端系统都绕不开的坎。我记得刚做Ktor项目那会儿,第一个需求就是让用户能上传头像。心想这还不简单?结果一上手才发现,Multipart解析、流式传输、大文件断点续传……坑一个接一个。今天咱们就把这些知识点掰开揉碎,一次性讲清楚。

本章核心脉络:从最简单的单文件上传,到Multipart表单处理,再到流式传输和断点续传,最后是静态文件服务。说白了,就是让你能搞定从「传个小图片」到「传几个G的视频」的所有场景。

文件上传与下载知识体系 Ktor 文件处理 Multipart 表单 流式传输 断点续传 静态文件服务 receiveMultipart() PartData.FileItem ByteReadChannel writeChannel() Range 请求头 206 Partial Content staticResources() staticFiles() 从表单上传到流式处理,再到断点续传和静态服务,层层递进

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支持传入startlength参数,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表单,到流式传输,再到断点续传和静态文件服务,每个场景都有对应的最佳实践。你在实际项目中遇到什么问题,欢迎随时交流。


公众号:蓝海资料掘金营,微信deep3321