KtorMiddleTechnical

Как Ktor поддерживает загрузку файлов через multipart?

Ktor обрабатывает multipart/form-data через call.receiveMultipart(). Метод возвращает MultiPartData, по которому итерируются части: PartData.FormItem для текста и PartData.FileItem для файлов.

Зависимости

// build.gradle.kts
dependencies {
    val ktor_version = "2.3.12"
    // multipart поддерживается в ядре, дополнительных зависимостей не нужно
    implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
    // для работы с файловой системой
    implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.4.0")
}

Базовая обработка загрузки файла

import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.io.File

fun Application.configureUpload() {
    routing {
        post("/upload") {
            val multipart = call.receiveMultipart()
            var fileName = ""
            var savedPath = ""

            multipart.forEachPart { part ->
                when (part) {
                    is PartData.FormItem -> {
                        // Текстовое поле формы
                        println("Field '${part.name}' = ${part.value}")
                    }
                    is PartData.FileItem -> {
                        fileName = part.originalFileName ?: "upload_${System.currentTimeMillis()}"
                        val uploadDir = File("uploads").also { it.mkdirs() }
                        val destFile = File(uploadDir, fileName)
                        part.streamProvider().use { input ->
                            destFile.outputStream().use { output ->
                                input.copyTo(output)
                            }
                        }
                        savedPath = destFile.absolutePath
                    }
                    else -> {}
                }
                part.dispose() // ОБЯЗАТЕЛЬНО освобождать ресурсы
            }

            call.respond(mapOf(
                "fileName" to fileName,
                "path" to savedPath,
                "status" to "uploaded"
            ))
        }
    }
}

Ограничение размера файла

import io.ktor.server.plugins.requestvalidation.*

fun Application.configureUploadWithLimit() {
    // Ограничение через конфигурацию Netty
    install(io.ktor.server.netty.NettyApplicationEngine) {}
    // Либо через middleware:
    routing {
        post("/upload-limited") {
            val contentLength = call.request.contentLength() ?: 0L
            val maxSize = 10 * 1024 * 1024L // 10 MB
            if (contentLength > maxSize) {
                call.respond(HttpStatusCode.PayloadTooLarge, "File too large")
                return@post
            }
            val multipart = call.receiveMultipart()
            multipart.forEachPart { part ->
                if (part is PartData.FileItem) {
                    val bytes = part.streamProvider().readBytes()
                    if (bytes.size > maxSize) {
                        call.respond(HttpStatusCode.PayloadTooLarge)
                        return@forEachPart
                    }
                    // сохраняем bytes
                }
                part.dispose()
            }
            call.respond(HttpStatusCode.OK)
        }
    }
}

Загрузка нескольких файлов

post("/upload-multiple") {
    val multipart = call.receiveMultipart()
    val uploadedFiles = mutableListOf<String>()

    multipart.forEachPart { part ->
                if (part is PartData.FileItem) {
            val name = part.originalFileName ?: "file_${uploadedFiles.size}"
            val dest = File("uploads/$name")
            part.streamProvider().use { it.copyTo(dest.outputStream()) }
            uploadedFiles.add(name)
        }
        part.dispose()
    }
    call.respond(mapOf("uploaded" to uploadedFiles))
}

Подводные камни

  • Вызов part.dispose() обязателен для каждой части — утечка без него приводит к исчерпанию памяти под нагрузкой.
  • originalFileName — значение из заголовка Content-Disposition, которое приходит от клиента; никогда не используйте его как путь к файлу без санитизации (path traversal атака).
  • Метод forEachPart читает тело лениво — повторный вызов receiveMultipart() вернёт пустой поток.
  • При использовании nginx как reverse-proxy нужно задать client_max_body_size — иначе nginx обрежет запрос до того, как Ktor его получит.
  • streamProvider() возвращает поток, который может быть прочитан только один раз; сохраняйте содержимое сразу.
  • Для очень больших файлов readBytes() загрузит всё в RAM; используйте потоковую запись через copyTo().
  • Content-Type multipart/form-data без boundary в заголовке вызовет исключение при receiveMultipart().

Common mistakes

  • Путать термин «multipart upload» с соседним механизмом Ktor.
  • Не называть границу lifecycle, transaction, thread или request для «multipart upload».
  • Игнорировать production-эффекты «multipart upload»: latency, SQL shape, memory, security или observability.

What the interviewer is testing

  • Попросить объяснить механизм «multipart upload» на минимальном примере.
  • Проверить, видит ли кандидат failure mode и диагностику для «multipart upload».
  • Уточнить, какие настройки или API меняют «multipart upload» в реальном сервисе.

Sources

Related topics