1. 简介

本文将演示如何使用 Spark 框架 结合 Kotlin 快速构建一个轻量级微服务。

我们将实现一个简易社交网络的核心功能:发布帖子、查询帖子以及修改点赞数。整个过程简洁明了,适合快速原型开发或小型项目落地。

通过这个例子,你会掌握 Spark 的基本用法,并理解如何在 Kotlin 中优雅地组织 RESTful 接口逻辑。✅


2. 准备工作

要运行本项目,首先需要引入必要的依赖项。

Maven 依赖配置

<dependency>
    <groupId>com.sparkjava</groupId>
    <artifactId>spark-kotlin</artifactId>
    <version>1.0.0-alpha</version>
</dependency>
<dependency>
    <groupId>com.sparkjava</groupId>
    <artifactId>spark-core</artifactId>
    <version>2.9.4</version>
</dependency>

⚠️ 注意:虽然 spark-kotlin 会传递依赖 spark-core,但它拉取的是旧版本。因此我们显式声明最新版以确保功能完整。

接下来是 JSON 处理支持,使用 Jackson:

<dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-kotlin</artifactId>
    <version>2.15.3</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.15.3</version>
</dependency>

这两个模块分别用于 Kotlin 数据类的序列化支持和 Java 8 时间类型的处理(如 Instant)。

主函数初始化

fun main() {
    val objectMapper = jacksonObjectMapper()
    objectMapper.registerModule(JavaTimeModule())
    objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
}

这里创建了一个全局可用的 ObjectMapper 实例,启用对时间类型的支持并禁用时间戳格式输出,保证日期字段以 ISO 格式返回(例如 "2023-11-08T09:27:40.949917Z"),提升可读性。✅


3. 数据模型与仓储层

在编写接口前,先定义数据结构和存储逻辑。

数据模型

data class Post(
    val id: String,
    val posterId: String,
    val posted: Instant,
    val content: String,
    val likes: Int
)

这是一个典型的不可变数据类,代表一条用户发布的帖子。

另外定义一个通用的分页结果包装类:

data class HitList<T>(
    val entries: List<T>,
    val total: Int
)

用于统一返回带总数的列表数据。

仓储实现(Repository)

class PostRepository {
    private val data = mutableListOf<Post>()

    fun create(posterId: String, content: String): Post {
        val newPost = Post(
            id = UUID.randomUUID().toString(),
            posterId = posterId,
            posted = Instant.now(),
            content = content,
            likes = 0
        )

        data.add(newPost)
        return newPost
    }
}

当前使用内存列表模拟持久化存储。实际生产中应替换为数据库操作,但接口保持一致,便于后期迁移。⚠️

初始化测试数据

main() 中添加实例并插入几条初始记录:

val repository = PostRepository()
println(repository.create("1", "This is my first post"))
println(repository.create("1", "And a second one"))
println(repository.create("2", "Hello, World!"))

打印生成的 ID 方便后续调试使用。踩坑提醒:不打印的话你根本不知道刚创建的帖子 ID 是啥,调接口直接 404 ❌


4. 根据 ID 查询帖子

现在开始编写第一个 HTTP 接口:通过 ID 获取单个帖子。

Repository 方法

fun getById(id: String): Post? {
    return data.find { it.id == id }
}

标准查找逻辑,找不到返回 null

Spark 接口定义

Spark.get("/posts/:id", { req, res ->
    val id = req.params("id")
    val post = repository.getById(id)

    if (post == null) {
        res.status(404)
    }

    post
}, objectMapper::writeValueAsString)

说明:

  • 路径 /posts/:id 使用占位符捕获 ID。
  • 查不到时设置状态码为 404。
  • 最后一个参数是响应转换器,自动将返回对象转为 JSON 字符串。

示例请求

GET /posts/ecc4c9a9-b4af-4da7-8353-f0274a6e65bb HTTP/1.1
Host: localhost:4567

正确响应示例

HTTP/1.1 200 OK
Content-Type: application/json

{
    "content": "Hello, World!",
    "id": "ecc4c9a9-b4af-4da7-8353-f0274a6e65bb",
    "likes": 0,
    "posted": "2023-11-08T09:27:40.949917Z",
    "posterId": "2"
}

⚠️ 常见问题:默认返回类型是 text/html,这显然不对!

解决方案:统一设置内容类型。由于所有接口都需要,我们用全局 before 过滤器解决:

Spark.before { _, res -> res.type("application/json") }

加这一行之后,所有响应都会正确标注为 application/json,客户端解析不再出错。✅


5. 列出所有帖子(支持分页和过滤)

除了查单条,还需要支持批量查询,且具备以下能力:

  • 支持按发布者过滤(posterId
  • 支持分页(offset + count)
  • 返回总数量以便前端分页控件使用

扩展函数:安全切片

private fun <T> List<T>.safeSubList(fromIndex: Int, toIndex: Int): List<T> =
    this.subList(fromIndex.coerceAtLeast(0), toIndex.coerceAtMost(this.size))

这是 Kotlin 的扩展方法技巧,避免越界异常,简化分页逻辑。

Repository 查询方法

获取全部帖子(按发布时间倒序):

fun getAll(offset: Int, count: Int): HitList<Post> {
    val page = data
        .sortedWith(compareByDescending { it.posted })
        .safeSubList(offset, offset + count)

    return HitList(entries = page, total = data.size)
}

按发布者查询:

fun getForPoster(posterId: String, offset: Int, count: Int): HitList<Post> {
    val total = data.count { it.posterId == posterId }
    val page = data
        .sortedWith(compareByDescending { it.posted })
        .filter { it.posterId == posterId }
        .safeSubList(offset, offset + count)

    return HitList(entries = page, total = total)
}

注意:total 需单独统计过滤后的数量,不能直接用原始列表长度。

接口实现

Spark.get("/posts") { req, _ ->
    val posterId = req.queryParams("posterId")

    val offset = req.queryParams("offset")?.toIntOrNull() ?: 0
    val count = req.queryParams("count")?.toIntOrNull() ?: 10

    if (posterId == null) {
        repository.getAll(offset, count)
    } else {
        repository.getForPoster(posterId, offset, count)
    }
}

✅ 参数说明:

  • posterId: 可选,指定用户 ID
  • offset: 起始索引,默认 0
  • count: 每页数量,默认 10

无需手动设置 Content-Type —— 前面的 before 过滤器已搞定。


6. 创建新帖子

使用 POST /posts 接收 JSON 请求体创建新帖。

请求体 DTO

data class CreatePostRequest(val posterId: String, val content: String)

接口实现

Spark.post("/posts") { req, res ->
    val body = objectMapper.readValue<CreatePostRequest>(req.bodyAsBytes())

    val post = repository.create(body.posterId, body.content)

    res.status(201)
    res.header("Location", "/posts/${post.id}")
    
    post
}

关键点:

  • ✅ 状态码设为 201 Created
  • ✅ 返回新资源的 Location 头,符合 REST 规范
  • 返回完整的 Post 对象供客户端确认

7. 删除帖子

使用 DELETE /posts/{id} 实现删除功能。

Repository 方法

fun deleteById(id: String) {
    data.removeIf { it.id == id }
}

✔️ 特性:无论是否存在都视为成功,保证接口幂等性(多次删除同一 ID 不报错)

接口实现

Spark.delete("/posts/:id") { req, res ->
    val id = req.params("id")
    repository.deleteById(id)
    res.status(204) // No Content
}

删除成功返回 204,无响应体。


8. 更新点赞数(PATCH)

仅更新部分字段时,推荐使用 PATCH 而非 PUT。我们采用 JSON Merge Patch 标准。

请求体定义

data class PatchPostRequest(val likes: Int)

客户端只需传入想改的字段。

Repository 更新逻辑

fun updateLikes(id: String, likes: Int): Post {
    val existing = data.find { it.id == id }!!
    data.remove(existing)
    val newPost = existing.copy(likes = likes)
    data.add(newPost)
    return newPost
}

由于 Post 是不可变类,只能通过复制方式更新。真实场景下可用数据库 UPDATE likes=? WHERE id=? 直接操作。

接口实现

Spark.patch("/posts/:id") { req, res ->
    val id = req.params("id")
    val body = objectMapper.readValue<PatchPostRequest>(req.bodyAsBytes())
    repository.updateLikes(id, body.likes)
}

示例请求

PATCH /posts/3a10160d-df0e-4113-af36-5545d3beb589 HTTP/1.1
Content-Length: 14
Content-Type: application/json
Host: localhost:4567

{
    "likes": 1
}

成功响应

HTTP/1.1 200 OK
Content-Type: application/json

{
    "content": "Hello, World!",
    "id": "3a10160d-df0e-4113-af36-5545d3beb589",
    "likes": 1,
    "posted": "2023-11-08T13:26:19.322158Z",
    "posterId": "2"
}

9. 总结

本文完整实现了基于 Spark + Kotlin 的 CRUD 微服务,涵盖:

  • ✅ 路由定义与参数提取
  • ✅ JSON 序列化配置
  • ✅ 分页与过滤
  • ✅ RESTful 设计规范(状态码、Location 头等)
  • ✅ 幂等删除与部分更新

虽然只是冰山一角,但已足够体现 Spark 的简洁高效。对于中小型项目或 MVP 快速验证,是非常合适的技术选型。

所有源码已托管至 GitHub:https://github.com/Baeldung/kotlin-tutorials/tree/master/kotlin-spark


原始标题:Using Spark with Kotlin to Create a CRUD HTTP API