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
: 可选,指定用户 IDoffset
: 起始索引,默认 0count
: 每页数量,默认 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