Ngày 23 tháng 9 năm 2023 - Máy tính
Trong bài trước “Làm thế nào để xây dựng dịch vụ API RESTful bằng Spring Boot và Kotlin?”, chúng ta đã tìm hiểu cách Kotlin có thể tích hợp liền mạch với các framework Java Web hiện có để phát triển dịch vụ API. Ngoài ra, còn có một số toolkit web được phát triển trực tiếp bằng Kotlin như Ktor, http4k, v.v., sử dụng những toolkit này cho phép khai thác tối đa cú pháp và đặc điểm của lập trình hàm trong Kotlin. Bài viết này sẽ tập trung vào việc khám phá cách sử dụng http4k để phát triển dịch vụ API RESTful.
Trước tiên, hãy xem http4k là gì? Http4k là một công cụ HTTP hàm rất nhẹ nhưng đầy đủ tính năng được viết hoàn toàn bằng Kotlin, hỗ trợ viết đồng nhất cả server HTTP, client và mã kiểm thử.
Bài viết sẽ minh họa cách sử dụng http4k để phát triển dịch vụ API RESTful thông qua ví dụ thực tế về việc quản lý người dùng (tạo mới, sửa đổi, xóa, tra cứu). Dự án sử dụng Gradle làm công cụ quản lý phụ thuộc, áp dụng kiến trúc MVC truyền thống, sử dụng http4k ở lớp Controller, không có lớp DAO, không có thao tác cơ sở dữ liệu, lớp Service sử dụng List để mô phỏng lưu trữ dữ liệu và tự động tạo giao diện Swagger UI. Nội dung chính gồm ba phần: cấu hình dự án mẫu, viết mã nghiệp vụ và kiểm thử API.
Dưới đây là danh sách các thư viện và phiên bản được sử dụng khi viết bài này:
Gradle: 8.3
Kotlin: 1.9.10
JDK: Amazon Corretto 17.0.8
http4k: 5.8.1.0
1. Cấu hình dự án mẫu
Bài viết sử dụng Gradle làm công cụ xây dựng và quản lý phụ thuộc, cấu trúc tổng thể của dự án rỗng do Gradle tạo ra như sau:
http4k-restful-service-demo
|--- gradle/
|--- src/main/
| |--- resources/
| \--- kotlin/
| \--- com.example.demo.DemoApplication.kt
|--- src/test/kotlin/
| \--- com.example.demo.DemoApplicationTest.kt
|--- gradlew
|--- settings.gradle.kts
\--- build.gradle.kts
Đây là cấu trúc chuẩn của một ibet1668 game dự án Gradle.
Phần quan trọng cần chú ý là nội dung file miêu tả Gradle:
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm") version "1.9.10"
application
}
java {
sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
}
dependencies {
implementation(platform("org.http4k:http4k-bom:5.8.1.0"))
implementation("org.http4k:http4k-core")
implementation("org.http4k:http4k-contract")
implementation("org.http4k:http4k-format-jackson")
implementation("org.http4k:http4k-contract-ui-swagger")
implementation("com.google.inject:guice:7.0.0")
}
application {
mainClass.set("com.example.demo.DemoApplicationKt")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjvm-default=all"
jvmTarget = "17"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
Chúng ta sử dụng phiên bản Kotlin 1.9.10 và chỉ định lớp khởi động chương trình là com.example.demo.DemoApplication.kt
.
Các module http4k được sử dụng bao gồm:
- http4k-core: Module lõi chứa các chức năng cơ bản như HttpHandler, Filter.
- http4k-contract: Hỗ trợ cấu hình tham số chi tiết hơn và mô tả metadata OpenAPI, cấu hình Swagger.
- http4k-format-jackson: Hỗ trợ chuyển đổi giữa JSON và data class.
- http4k-contract-ui-swagger: Tự động tạo giao diện Swagger UI và tài nguyên tĩnh liên quan.
Ngoài ra, chúng ta cũng sử dụng Guice để thực hiện tiêm phụ thuộc vì nó khá nhẹ và phù hợp cho dự án demo.
2. Viết mã nghiệp vụ
Phần này chủ yếu tập trung vào việc viết mã xử lý nghiệp vụ liên quan đến việc quản lý User (tạo mới, sửa đổi, xóa, tra cứu). Dự án bao gồm các thành phần chính như lớp Controller, lớp Service, các Model và Enum Error Codes.
Sau khi phát triển xong, cấu trúc dự án trông như sau:
http4k-restful-service-demo
|--- src/main/
| |--- resources/
| \--- kotlin/
| \--- com.example.demo/
| |--- controller/
| | \--- UserController.kt
| |--- service/
| | |--- UserService.kt
| |--- code/
| | \--- ErrorCodes.kt
| |--- model/
| | |--- ErrorResponse.kt
| | \--- User.kt
| \--- DemoApplication.kt
...
|--- gradlew
\--- build.gradle.kts
Dưới đây là phân tích từng phần:
2.1 Lớp Controller
Lớp Controller chịu trách nhiệm nhận yêu cầu từ client và trả về phản hồi tương ứng, trong khi logic xử lý cụ thể được thực hiện tại lớp Service. Dự án này chỉ có một lớp Controller duy nhất là UserController.kt
, dùng để định nghĩa route và Handler xử lý yêu cầu cụ thể. Các Handler bên trong gọi đến lớp UserService
để thực hiện nghiệp vụ.
// src/main/kotlin/com/example/demo/controller/UserController.kt
package com.example.demo.controller
import com.example.demo.code.ErrorCodes
import com.example.demo.model.User
import com.example.demo.service.UserService
import jakarta.inject.Inject
import org.http4k.contract.ContractRoute
import org.http4k.contract.div
import org.http4k.contract.meta
import org.http4k.core.Body
import org.http4k.core.Method.*
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.CREATED
import org.http4k.core.Status.Companion.NO_CONTENT
import org.http4k.core.Status.Companion.OK
import org.http4k.core.with
import org.http4k.format.Jackson.auto
import org.http4k.lens.Path
import org.http4k.lens.long
class UserController @Inject constructor(
private val userService: UserService
) {
companion object {
private val usersLens = Body.auto<List<User>>().toLens()
private val userLens = Body.auto<User>().toLens()
}
val routes: List<ContractRoute> = listOf(
// listAll
"/users" meta {
summary = "Liệt kê tất cả người dùng"
returning(OK, usersLens to listOf(User(1, "Larry", 28)))
} bindContract GET to ::listAll,
// getById
"/users" / Path.long().of("id") meta {
summary = "Lấy người dùng theo ID"
returning(OK, userLens to User(1, "Larry", 28))
returning(ErrorCodes.USER_NOT_FOUND.status, ErrorCodes.USER_NOT_FOUND.toSampleResponse())
} bindContract GET to { id -> { req -> getById(req, id) } },
// update
"/users" meta {
summary = "Cập nhật người dùng"
receiving(userLens to User(1, "Larry", 28))
returning(NO_CONTENT)
returning(ErrorCodes.USER_NOT_FOUND.status, ErrorCodes.USER_NOT_FOUND.toSampleResponse())
} bindContract PATCH to { req -> update(req, userLens(req)) },
// save
"/users" meta {
summary = "Tạo mới người dùng"
receiving(userLens to User(1, "Larry", 28))
returning(CREATED)
returning(ErrorCodes.USER_ALREADY_EXISTS.status, ErrorCodes.USER_ALREADY_EXISTS.toSampleResponse())
} bindContract POST to { req -> save(req, userLens(req)) },
// deleteById
"/users" / Path.long().of("id") meta {
summary = "Xóa người dùng theo ID"
returning(NO_CONTENT)
returning(ErrorCodes.USER_NOT_FOUND.status, ErrorCodes.USER_NOT_FOUND.toSampleResponse())
} bindContract DELETE to { id -> { req -> deleteById(req, id) } }
)
private fun listAll(req: Request): Response {
val users = userService.listAll()
return Response(OK).with(usersLens of users)
}
private fun getById(req: Request, id: Long): Response {
val user = userService.getById(id)
return user?.let {
Response(OK).with(userLens of it)
} ?: ErrorCodes.USER_NOT_FOUND.toResponse()
}
private fun update(req: Request, user: User): Response {
userService.getById(user.id) ?: return ErrorCodes.USER_NOT_FOUND.toResponse()
userService.update(user)
return Response(NO_CONTENT)
}
private fun save(req: Request, user: User): Response {
val userStored = userService.getById(user.id)
if (userStored != null) {
return ErrorCodes.USER_ALREADY_EXISTS.toResponse()
}
userService.save(user)
return Response(CREATED)
}
private fun deleteById(req: Request, id: Long): Response {
userService.getById(id) ?: return ErrorCodes.USER_NOT_FOUND.toResponse()
userService.deleteById(id)
return Response(NO_CONTENT)
}
}
Một số điểm đáng chú ý:
UserController
phụ thuộc vàoUserService
và sử dụng Guice để tự động tiêm phụ thuộc.- Http4k sử dụng Lens (ví dụ:
Body.auto<User>().toLens()
) để ánh xạ và chuyển đổi giữa JSON và Model. - Http4k cho phép định nghĩa một tập hợp các route (ContractRoute) để chỉ định đường dẫn yêu cầu, metadata OpenAPI (dùng để tạo tài liệu Swagger), phương thức HTTP và Handler xử lý yêu cầu.
2.2 Lớp Service
Lớp Service chứa phần lớn logic xử lý nghiệp vụ. Trong dự án này, lớp Service chỉ có một lớp duy nhất là UserService.kt
. Để đơn giản hóa và tập trung vào việc sử dụng http4k, chúng ta không sử dụng lớp DAO hay cơ sở dữ liệu mà chỉ dùng một MutableList (val fakeUsers = mutableListOf(...)
) để lưu trữ dữ liệu.
// src/main/kotlin/com/example/demo/service/UserService.kt
package com.example.demo.service
import com.example.demo.model.User
interface UserService {
fun listAll(): List<User>
fun getById(id: Long): User?
fun update(user: User)
fun save(user: User)
fun deleteById(id: Long)
}
class DefaultUserServiceImpl : UserService {
private val fakeUsers = mutableListOf(
User(id = 1L, name = "Larry", age = 28),
User(id = 2L, name = "Stephen", age = 19),
User(id = 3L, name = "Jacky", age = 24)
)
override fun listAll(): List<User> {
return fakeUsers
}
override fun getById(id: Long): User? {
return fakeUsers.find { it.id == id }
}
override fun update(user: User) {
fakeUsers.filter { it.id == user.id }.forEach {
it.name = user.name
it.age = user.age
}
}
override fun save(user: User) {
getById(user.id) ?: [79king1](/post/4175/) fakeUsers.add(user)
}
override fun deleteById(id: Long) {
fakeUsers.removeIf { it.id == id }
}
}
2.3 Model
Dự án này có hai lớp Model: User.kt
và ErrorResponse.kt
. Lớp đầu tiên đại diện cho đối tượng User, lớp thứ hai đại diện cho phản hồi lỗi chuẩn.
// src/main/kotlin/com/example/demo/model/User.kt
package com.example.demo.model
data class User(val id: Long, var name: String, var age: Int)
// src/main/kotlin/com/example/demo/model/ErrorResponse.kt
package com.example.demo.model
data class ErrorResponse(val code: String, val description: String)
2.4 Enum Error Codes
Dự án thiết kế một Enum Class (ErrorCodes.kt
) để lưu trữ tất cả thông tin phản hồi lỗi.
// src/main/kotlin/com/example/demo/code/ErrorCodes.kt
package com.example.demo.code
import com.example.demo.model.ErrorResponse
import org.http4k.core.Body
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.core.with
import org.http4k.format.Jackson.auto
import org.http4k.lens.BiDiBodyLens
enum class ErrorCodes(val status: Status, private val code: String, private val description: String) {
USER_NOT_FOUND(Status.NOT_FOUND, "user_not_found", "Không tìm thấy người dùng"),
USER_ALREADY_EXISTS(Status.BAD_REQUEST, "user_already_exists", "Người dùng đã tồn tại");
fun toResponse(): Response =
Response(status).with(Body.auto<ErrorResponse>().toLens() of ErrorResponse(code, description))
fun toSampleResponse(): Pair<BiDiBodyLens<ErrorResponse>, ErrorResponse> =
Body.auto<ErrorResponse>().toLens() to ErrorResponse(code, description)
}
2.5 Lớp khởi động chương trình
Dưới đây là mã nguồn của lớp khởi động chương trình DemoApplication.kt
:
// src/main/kotlin/com/example/demo/DemoApplication.kt
package com.example.demo
import com.example.demo.controller.UserController
import com.example.demo.service.DefaultUserServiceImpl
import com.example.demo.service.UserService
import com.google.inject.AbstractModule
import com.google.inject.Guice
import org.http4k.contract.ContractRoute
import org.http4k.contract.ContractRoutingHttpHandler
import org.http4k.contract.contract
import org.http4k.contract.openapi.ApiInfo
import org.http4k.contract.openapi.v3.ApiServer
import org.http4k.contract.openapi.v3.OpenApi3
import org.http4k.contract.ui.swagger.swaggerUiWebjar
import org.http4k.core.*
import org.http4k.filter.CachingFilters
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.http4k.server.SunHttp
import org.http4k.server.asServer
class MainGuiceModule : AbstractModule() {
override fun configure() {
bind(UserService::class.java).to(DefaultUserServiceImpl::class.java)
}
}
fun createContractHandler(routes: List<ContractRoute>, descriptionPath: String): ContractRoutingHttpHandler {
return contract {
this.routes += routes
renderer = OpenApi3(
ApiInfo("User API", "v1.0"),
Jackson,
servers = listOf(ApiServer(Uri.of("localhost")))
)
this.descriptionPath = descriptionPath
}
}
val timingFilter = Filter { next: HttpHandler ->
{ req: Request ->
val start = System.currentTimeMillis()
val resp: Response = next(req)
val timeElapsed = System.currentTimeMillis() - start
println("[timing filter] request to ${req.uri} took ${timeElapsed}ms")
resp
}
}
fun main() {
// guice
val injector = Guice.createInjector(MainGuiceModule())
val userController = injector.getInstance(UserController::class.java)
// app
val app: HttpHandler = routes(
createContractHandler(userController.routes, "/openapi.json"),
"/swagger" bind swaggerUiWebjar {
url = "/openapi.json"
}
)
// start
val filteredApp: HttpHandler = CachingFilters.Response.NoCache().then(timingFilter).then(app)
filteredApp.asServer(SunHttp(8080)).start().block()
}
3. Kiểm thử API
Sau khi hoàn thành dự án, chúng ta có thể khởi chạy và kiểm thử.
Lệnh khởi chạy:
./gradlew run
Mở trình duyệt và truy cập Swagger UI để kiểm tra.
3.1 Liệt kê tất cả người dùng
Kiểm tra lệnh CURL:
curl -X GET
[{"id":1,"name":"Larry","age":28},{"id":2,"name":"Stephen","age":19},{"id":3,"name":"Jacky","age":24}]
3.2 Lấy người 9win dùng theo ID
Kiểm tra lệnh CURL:
curl -X GET
{"id":1,"name":"Larry","age":28}
3.3 Cập nhật người dùng
Kiểm tra lệnh CURL:
curl -X PATCH -H 'Content-Type: application/json' -d '{"id": 1, "name": "Larry2", "age": 19}'
3.4 Tạo mới người dùng
Kiểm tra lệnh CURL:
curl -X POST -H 'Content-Type: application/json' -d '{"id": 4, "name": "Lucy", "age": 16}'
3.5 Xóa người dùng
Kiểm tra lệnh CURL:
curl -X DELETE
Như vậy, chúng ta đã thành công trong việc xây dựng và kiểm thử dịch vụ API RESTful bằng http4k.