如何在Kotlin(Ktor)中创建通用Multipart Form Data请求类并实现验证
我之前在Ktor项目里正好碰到过一模一样的需求——想把multipart请求的处理变得像JSON请求那样简洁,不用在每个路由里重复写解析和验证代码。下面是我打磨出来的通用实现方案,亲测好用:
第一步:定义注解标记字段与文件
先搞两个自定义注解,用来标记请求类里哪些属性对应multipart的普通字段,哪些对应文件,还能配置验证规则:
import kotlin.reflect.KProperty1 // 标记普通表单字段,可配置是否必填 @Target(AnnotationTarget.PROPERTY) annotation class MultipartField(val required: Boolean = true) // 标记文件字段,支持配置必填性、允许的MIME类型、最大文件大小 @Target(AnnotationTarget.PROPERTY) annotation class MultipartFile( val required: Boolean = true, val allowedContentTypes: Array<String> = [], val maxSizeInBytes: Long = Long.MAX_VALUE ) // 辅助数据类,缓存反射信息提升性能 private data class MultipartPropertyInfo( val name: String, val property: KProperty1<*, *>, val fieldAnnotation: MultipartField?, val fileAnnotation: MultipartFile? ) // 缓存类的属性信息,避免重复反射 private val multipartPropertyCache = mutableMapOf<KClass<*>, List<MultipartPropertyInfo>>()
第二步:实现通用的Multipart解析扩展函数
写一个ApplicationCall的扩展函数,自动解析multipart请求并映射到目标类,同时完成验证逻辑:
import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* import kotlin.reflect.full.createInstance import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.full.findAnnotation import kotlin.reflect.jvm.isAccessible suspend inline fun <reified T : Any> ApplicationCall.receiveMultipart(): T { val multipartData = this.receiveMultipart() val requestInstance = T::class.createInstance() // 从缓存获取或反射类的属性信息 val propertyInfos = multipartPropertyCache.getOrPut(T::class) { T::class.declaredMemberProperties.map { prop -> MultipartPropertyInfo( name = prop.name, property = prop, fieldAnnotation = prop.findAnnotation(), fileAnnotation = prop.findAnnotation() ) } } // 遍历所有multipart部分,映射到属性 multipartData.forEachPart { part -> val info = propertyInfos.firstOrNull { it.name == part.name } ?: run { part.dispose() return@forEachPart } when (part) { is PartData.FormItem -> { info.fieldAnnotation ?: run { part.dispose() return@forEachPart } info.property.isAccessible = true // 支持单字段和多字段列表 val currentValue = info.property.get(requestInstance) if (currentValue is MutableList<*>) { @Suppress("UNCHECKED_CAST") (currentValue as MutableList<String>).add(part.value) } else { info.property.set(requestInstance, part.value) } } is PartData.FileItem -> { val fileAnnotation = info.fileAnnotation ?: run { part.dispose() return@forEachPart } info.property.isAccessible = true // 验证文件类型 val contentType = part.contentType ?: throw MultipartValidationException( "File '${info.name}' missing content type" ) if (fileAnnotation.allowedContentTypes.isNotEmpty() && contentType.toString() !in fileAnnotation.allowedContentTypes ) { part.dispose() throw MultipartValidationException( "File '${info.name}' invalid type. Allowed: ${fileAnnotation.allowedContentTypes.joinToString()}" ) } // 验证文件大小 if (part.size > fileAnnotation.maxSizeInBytes) { part.dispose() throw MultipartValidationException( "File '${info.name}' exceeds max size (${fileAnnotation.maxSizeInBytes} bytes)" ) } // 支持单文件和多文件列表 val currentValue = info.property.get(requestInstance) if (currentValue is MutableList<*>) { @Suppress("UNCHECKED_CAST") (currentValue as MutableList<PartData.FileItem>).add(part) } else { info.property.set(requestInstance, part) } } else -> {} } part.dispose() } // 验证必填字段/文件 propertyInfos.forEach { info -> info.property.isAccessible = true val currentValue = info.property.get(requestInstance) info.fieldAnnotation?.let { anno -> if (anno.required && currentValue == null) { throw MultipartValidationException("Required field '${info.name}' is missing") } } info.fileAnnotation?.let { anno -> if (anno.required) { when (currentValue) { null -> throw MultipartValidationException("Required file '${info.name}' is missing") is List<*> -> if (currentValue.isEmpty()) throw MultipartValidationException("Required file '${info.name}' is missing") } } } } return requestInstance } // 自定义验证异常,方便统一处理 class MultipartValidationException(message: String) : Exception(message)
第三步:编写具体的请求类
像写JSON请求类一样,定义你的multipart请求类,用注解标记属性:
import io.ktor.request.PartData // 创建带头像的群组请求 class CreateGroupWithAvatarRequest { @MultipartField(required = true) lateinit var name: String @MultipartField(required = false) var description: String? = null @MultipartField(required = false) var visibility: String = "PUBLIC" @MultipartFile( required = true, allowedContentTypes = ["image/jpeg", "image/png"], maxSizeInBytes = 5 * 1024 * 1024 // 5MB ) lateinit var avatar: PartData.FileItem } // 创建带多张图片的帖子请求 class CreatePostWithImagesRequest { @MultipartField(required = true) lateinit var content: String @MultipartField(required = true) lateinit var authorId: String @MultipartFile( required = true, allowedContentTypes = ["image/jpeg", "image/png"], maxSizeInBytes = 10 * 1024 * 1024 // 单文件10MB ) var images: MutableList<PartData.FileItem> = mutableListOf() }
第四步:在路由中简洁使用
现在处理multipart请求就和处理JSON请求一样简洁了:
import io.ktor.http.HttpStatusCode import io.ktor.server.routing.Route import io.ktor.server.routing.post import io.ktor.server.routing.route fun Route.groupRoutes() { route("groups") { post("create-with-avatar") { val request = try { call.receiveMultipart<CreateGroupWithAvatarRequest>() } catch (e: MultipartValidationException) { call.respond(HttpStatusCode.BadRequest, e.message ?: "Invalid multipart request") return@post } // 处理业务逻辑:比如保存头像到存储 request.avatar.streamProvider().use { inputStream -> // 写入本地文件/OSS等 } call.respond(HttpStatusCode.OK, "Group created successfully with avatar") } } } fun Route.postRoutes() { route("posts") { post("create-with-images") { val request = try { call.receiveMultipart<CreatePostWithImagesRequest>() } catch (e: MultipartValidationException) { call.respond(HttpStatusCode.BadRequest, e.message ?: "Invalid multipart request") return@post } // 处理多张图片 request.images.forEachIndexed { index, fileItem -> fileItem.streamProvider().use { inputStream -> // 处理第index张图片 } } call.respond(HttpStatusCode.OK, "Post created with ${request.images.size} images") } } }
额外优化建议
- 如果你的项目是高并发场景,可以考虑进一步缓存反射后的属性访问器,减少反射开销
- 可以扩展注解的功能,比如添加字段格式验证(比如邮箱、手机号)
- 可以把
MultipartValidationException和你现有的SharedDomainException整合,统一异常处理逻辑
内容的提问来源于stack exchange,提问作者nfont
相关产品推荐
相关产品推荐

