在 Scala 生态中,构建 REST API 长期以来面临着”类型安全”与”文档同步”之间的取舍。传统的 Play Framework、Akka HTTP 路由写法将路由、序列化、文档三者割裂,任何一处变更都需要手动同步其余部分,导致接口契约很容易在迭代中失真。Tapir(Typed API Description)的出现改变了这一局面——它把”端点”提升为一等公民,用纯 Scala 类型描述一次,即可派生出服务端路由、客户端 stub、OpenAPI 文档和 Mock 服务器。本文以一个用户管理服务为例,完整演示从端点建模、http4s 集成、JSON 编解码、认证到 OpenAPI 文档生成的全流程。
一、为什么选择 Tapir + http4s
http4s 是 Scala 生态中最成熟的函数式 HTTP 框架,基于 Cats Effect 提供纯粹的
1 | IO |
语义,天然支持资源安全(
1 | Resource |
)与并发(
1 | Fiber |
)。而 Tapir 的核心价值在于”端点即数据”——一个
1 | Endpoint[A, I, E, O, R] |
值完整描述了输入类型
1 | I |
、错误类型
1 | E |
、输出类型
1 | O |
,编译器会全程把关路由与业务逻辑的类型对齐。
这种”描述一次、多处解释”的设计带来三个直接收益:
- 类型贯穿全链路:从路由参数到 JSON 字段、再到数据库 DTO,任何一处类型变更都会被编译器捕获,杜绝运行时才发现的字段错配。
- 文档零成本同步:OpenAPI 文档由端点定义自动生成,接口与文档永远不会脱节,前端团队可以放心基于生成的
1openapi.yaml
生成 TypeScript 客户端。
- 客户端与服务端共享契约:同一份端点描述既能解释成服务端路由,也能解释成客户端请求,真正实现了”契约即代码”。
相比之下,直接用 http4s 的
1 | HttpRoutes.of[IO] |
写 DSL 虽然灵活,但路由解析、查询参数提取、响应序列化都需要手写,且 OpenAPI 文档通常需要额外维护一套 Swagger 注解,长期来看维护成本高昂。Tapir 把这些样板全部消除。
二、项目搭建与依赖配置
我们使用 Scala 3 与 sbt 构建。新建
1 | build.sbt |
,引入 Tapir 核心库、http4s 服务端、Circe JSON 编解码以及 OpenAPI 文档生成器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 ThisBuild / scalaVersion := "3.3.3"
lazy val root = (project in file("."))
.settings(
name := "user-service",
libraryDependencies ++= Seq(
"com.softwaremill.sttp.tapir" %% "tapir-core" % "1.10.7",
"com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % "1.10.7",
"com.softwaremill.sttp.tapir" %% "tapir-json-circe" % "1.10.7",
"com.softwaremill.sttp.tapir" %% "tapir-openapi-docs" % "1.10.7",
"com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % "1.10.7",
"org.http4s" %% "http4s-ember-server" % "0.23.27",
"io.circe" %% "circe-generic" % "0.14.7",
"org.typelevel" %% "cats-effect" % "3.5.4"
),
javaOptions ++= Seq("-Xmx512m")
)
几个关键依赖的职责说明:
| 依赖 | 作用 | ||
|---|---|---|---|
| tapir-core | 端点描述 DSL,
类型定义 |
||
| tapir-http4s-server | 把端点解释成 http4s 的
|
||
| tapir-json-circe | 集成 Circe,自动派生 JSON 编解码并接入 Tapir 的 Codec 体系 | ||
| tapir-openapi-docs | 从端点列表生成 OpenAPI 3.0 文档对象 | ||
| tapir-swagger-ui-bundle | 内置 Swagger UI 静态资源,挂载一个查看文档的 HTML 路由 |
注意 Tapir 1.x 系列同时支持 Scala 2.13 与 Scala 3,API 几乎一致;本文示例基于 Scala 3 的
1 | given |
语法,但 Scala 2.13 用户用
1 | implicit |
即可等价替换。
三、定义领域模型与端点
我们先定义领域模型。一个用户包含 ID、用户名、邮箱、注册时间四个字段。为了体现 Tapir 的错误建模能力,我们再定义一个错误响应类型
1 | ApiError |
,用于业务异常(如用户不存在、邮箱冲突)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47 package com.example.api
import io.circe.generic.semiauto.*
import io.circe.{Decoder, Encoder}
import java.time.Instant
final case class User(
id: Long,
username: String,
email: String,
createdAt: Instant
)
final case class CreateUserRequest(
username: String,
email: String,
password: String
)
final case class UpdateUserRequest(
email: Option[String],
password: Option[String]
)
sealed trait ApiError
object ApiError:
final case class NotFound(id: Long) extends ApiError
final case class Conflict(field: String, message: String) extends ApiError
final case class ValidationError(errors: List[String]) extends ApiError
object Codecs:
given Encoder[User] = deriveEncoder
given Decoder[User] = deriveDecoder
given Encoder[CreateUserRequest] = deriveEncoder
given Decoder[CreateUserRequest] = deriveDecoder
given Encoder[UpdateUserRequest] = deriveEncoder
given Decoder[UpdateUserRequest] = deriveDecoder
given Encoder[ApiError] = Encoder.instance {
case ApiError.NotFound(id) => io.circe.Json.obj("error" -> s"user $id not found".asJson)
case ApiError.Conflict(f, m) => io.circe.Json.obj("field" -> f.asJson, "message" -> m.asJson)
case ApiError.ValidationError(errs) => io.circe.Json.obj("errors" -> errs.asJson)
}
given Decoder[ApiError] = Decoder.instance(c =>
c.downField("errors").success match
case Some(_) => c.downField("errors").as[List[String]].map(ApiError.ValidationError.apply)
case None => Left(io.circe.DecodingFailure("ApiError", c.history))
)
注意上面
1 | ApiError |
的 Encoder 用了模式匹配手工编写,因为 sealed trait 的多个 case class 字段差异较大,自动派生会让 JSON 结构耦合实现细节。手工编写可以精确控制对外暴露的 JSON 形状,这对前端契约稳定性至关重要。
接下来定义端点。Tapir 的端点描述是纯值,没有任何副作用,可以放在一个 object 里集中管理,便于服务端、客户端、文档生成器共享:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49 package com.example.api
import sttp.tapir.*
import sttp.tapir.json.circe.*
import sttp.tapir.generic.auto.*
import sttp.tapir.codec.berp.* // 用于 Instant 编解码
import com.example.api.Codecs.{given, *}
object Endpoints:
// 基础端点:统一错误响应类型为 ApiError
val base = endpoint.errorOut(jsonBody[ApiError])
// POST /users —— 创建用户
val createUser: Endpoint[Unit, CreateUserRequest, ApiError, User, Any] =
base.post
.in("users")
.in(jsonBody[CreateUserRequest])
.out(jsonBody[User])
.description("创建新用户,邮箱唯一")
// GET /users/{id} —— 查询单个用户
val getUser: Endpoint[Unit, Long, ApiError, User, Any] =
base.get
.in("users" / path[Long]("id"))
.out(jsonBody[User])
.description("按 ID 查询用户")
// GET /users —— 分页查询
case class Page(limit: Int, offset: Int)
val listUsers: Endpoint[Unit, Page, ApiError, List[User], Any] =
base.get
.in("users")
.in(query[Int]("limit").default(20).validate(max(100)))
.in(query[Int]("offset").default(0))
.out(jsonBody[List[User]])
.description("分页查询用户列表,limit 上限 100")
// PATCH /users/{id} —— 更新用户
val updateUser: Endpoint[Unit, (Long, UpdateUserRequest), ApiError, User, Any] =
base.patch
.in("users" / path[Long]("id"))
.in(jsonBody[UpdateUserRequest])
.out(jsonBody[User])
// DELETE /users/{id} —— 删除用户
val deleteUser: Endpoint[Unit, Long, ApiError, Unit, Any] =
base.delete
.in("users" / path[Long]("id"))
.out(statusCode(NoContent))
这里有几个值得注意的细节:
-
1base
是一个共享错误响应的”骨架端点”,每个具体端点都从它派生,保证全服务错误格式一致。
-
1query[Int]("limit").validate(max(100))
把参数校验逻辑写进端点定义,校验失败时 Tapir 自动返回 400,无需在业务逻辑里手写。
-
1path[Long]("id")
同时声明了路径占位符名称和 Scala 类型,OpenAPI 文档会自动反映这个名称。
-
1out(statusCode(NoContent))
让 DELETE 返回 204 而非 200,符合 RESTful 规范。
四、实现业务逻辑与 http4s 集成
端点只是描述,业务逻辑需要单独实现。我们用一个简单的内存 Map 模拟数据库,实际项目中替换为 Doobie 或 Skunk 即可。逻辑层返回
1 | IO[ApiError | User] |
,用
1 | EitherT |
风格处理错误:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36 package com.example.service
import cats.effect.{IO, Ref}
import com.example.api.*
import java.time.Instant
import java.util.concurrent.atomic.AtomicLong
class UserService(state: Ref[IO, Map[Long, User]]):
private val idGen = new AtomicLong(0)
def create(req: CreateUserRequest): IO[Either[ApiError, User]] =
state.get.flatMap { users =>
if users.values.exists(_.email == req.email) then
IO.pure(Left(ApiError.Conflict("email", "already exists")))
else
val user = User(idGen.incrementAndGet(), req.username, req.email, Instant.now)
state.update(_ + (user.id -> user)).as(Right(user))
}
def get(id: Long): IO[Either[ApiError, User]] =
state.get.map(_.get(id).toRight(ApiError.NotFound(id)))
def list(limit: Int, offset: Int): IO[Either[ApiError, List[User]]] =
state.get.map(_.values.toList.slice(offset, offset + limit)).map(Right.apply)
def update(id: Long, req: UpdateUserRequest): IO[Either[ApiError, User]] =
state.get.flatMap { users =>
users.get(id) match
case None => IO.pure(Left(ApiError.NotFound(id)))
case Some(u) =>
val updated = u.copy(email = req.email.getOrElse(u.email))
state.update(_ + (id -> updated)).as(Right(updated))
}
def delete(id: Long): IO[Either[ApiError, Unit]] =
state.update(_ - id).as(Right(()))
把端点和逻辑粘合起来是 Tapir 最简洁的部分——
1 | Http4sServerInterpreter |
把每个
1 | Endpoint |
加上一个
1 | serverLogic |
函数,就得到了一个
1 | HttpRoutes[IO] |
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 package com.example
import cats.effect.{IO, IOApp}
import org.http4s.HttpRoutes
import org.http4s.server.Router
import org.http4s.ember.server.EmberServerBuilder
import sttp.tapir.server.http4s.Http4sServerInterpreter
import com.example.api.*
import com.example.service.UserService
object Main extends IOApp.Simple:
def run: IO[Unit] =
Ref.of[IO, Map[Long, User]](Map.empty).flatMap { ref =>
val service = new UserService(ref)
val interpreter = Http4sServerInterpreter[IO]()
import Endpoints.*
val routes: HttpRoutes[IO] = interpreter.toRoutes(
List(
createUser.serverLogic(service.create),
getUser.serverLogic(service.get),
listUsers.serverLogic(p => service.list(p.limit, p.offset)),
updateUser.serverLogic((id, req) => service.update(id, req)),
deleteUser.serverLogic(service.delete)
)
)
val httpApp = Router("/" -> routes).orNotFound
EmberServerBuilder.default[IO].withHttpApp(httpApp).build.useForever
}
这里
1 | serverLogic |
的签名要求返回
1 | IO[Either[E, O]] |
,正好和我们的 service 返回类型对齐,编译器会自动校验——如果端点输出类型和逻辑返回类型不一致,根本无法编译通过。这就是 Tapir”端点即契约”的核心价值。
五、生成 OpenAPI 文档与 Swagger UI
项目上线后,前端、移动端、第三方集成方都需要 API 文档。Tapir 把文档生成做成了端点的另一个”解释器”,无需任何注解或额外描述:
1
2
3
4
5
6
7
8
9
10
11
12
13 import sttp.tapir.openapi.circe.yaml.*
import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter
import sttp.tapir.swagger.SwaggerUIOptions
def docsRoutes: HttpRoutes[IO] =
val docs = OpenAPIDocsInterpreter().toOpenAPI(
List(createUser, getUser, listUsers, updateUser, deleteUser),
"User Service API",
"1.0.0"
).toYaml
val swagger = SwaggerUI[IO](docs, SwaggerUIOptions.default)
Http4sServerInterpreter[IO]().toRoutes(swagger)
把
1 | docsRoutes |
合并进主路由后,访问
1 | http://localhost:8080/docs |
即可看到交互式 Swagger UI。所有端点的路径、参数、校验规则、错误响应结构都由端点定义自动反映,无需手动维护一份独立的 Swagger YAML。当端点签名变更时,文档立刻同步,杜绝”文档说有这个字段,接口却返回 null”的尴尬。
六、加入认证:基于 JWT 的端点保护
真实服务几乎都需要认证。Tapir 用
1 | EndpointInput |
组合子描述认证输入,
1 | serverSecurityLogic |
处理令牌校验,
1 | serverLogicSuccess |
处理已认证后的业务逻辑。我们以 JWT 为例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 import pdi.jwt.{Jwt, JwtAlgorithm}
import scala.util.{Success, Failure}
val authHeader: EndpointInput[Option[String]] =
sttp.tapir.header[Option[String]]("Authorization")
case class AuthContext(userId: Long, roles: Set[String])
def verifyToken(token: String): IO[Either[ApiError, AuthContext]] =
Jwt.decode(token, secretKey, JwtAlgorithm.HS256) match
case Success(claim) =>
val uid = claim.content.hcursor.get[Long]("uid").getOrElse(0L)
val roles = claim.content.hcursor.get[List[String]]("roles").getOrElse(Nil).toSet
IO.pure(Right(AuthContext(uid, roles)))
case Failure(e) =>
IO.pure(Left(ApiError.ValidationError(List("invalid token"))))
val secureBase = base.securityIn(authHeader)
val deleteUserSecure: Endpoint[Option[String], Long, ApiError, User, Any] =
secureBase.delete
.in("users" / path[Long]("id"))
.out(jsonBody[User])
// 组装:先校验令牌,再执行业务
val route = interpreter.toRoute(
deleteUserSecure
.serverSecurityLogic(tokenOpt => tokenOpt match
case Some(t) => verifyToken(t)
case None => IO.pure(Left(ApiError.ValidationError(List("missing token")))))
.serverLogicSuccess { (ctx: AuthContext, id: Long) =>
service.delete(id) // 这里可以检查 ctx.roles 是否包含 "admin"
}
)
这种
1 | serverSecurityLogic |
+
1 | serverLogicSuccess |
的两段式写法,把”认证”和”授权后的业务”清晰地分开,认证失败直接短路返回错误,业务逻辑无需重复处理令牌。OpenAPI 文档会自动在受保护端点上标注 Bearer 认证,前端联调时 Swagger UI 也能直接填入令牌测试。
七、性能与生产部署要点
把 Tapir + http4s 服务推向生产,需要关注以下几个工程细节:
- 线程池隔离:http4s 默认使用 cats-effect 的计算线程池处理 IO,但阻塞式数据库调用(如 JDBC)必须放到独立的阻塞线程池,否则会拖垮计算池。在
1Dispatcher
或
1Blocker上执行 JDBC 调用。
- 连接池调优:HTTP 客户端(如 sttp 调用下游服务)与数据库连接池要分别配置上限,避免一个慢下游拖垮整个服务的资源。
- JSON 编解码性能:Circe 默认基于反射派生编解码,性能优于 Play JSON 但仍低于手工编写。高 QPS 场景可考虑
1circe-generic-extras
或迁移到
1jsoniter-scala,性能提升 2-3 倍。
- 可观测性:用
1http4s-server
的中间件接入 OpenTelemetry,把每个 Tapir 端点的路径模板作为 span 名,便于按端点聚合延迟指标。
容器化部署时,建议把 JVM 堆内存(
1 | -Xmx |
)设置为容器内存上限的 75%,并启用
1 | -XX:MaxRAMPercentage=75.0 |
,避免容器 OOM 时 JVM 还没触发 GC。配合 Docker 的 healthcheck 指向
1 | /health |
端点,K8s 滚动更新可以平滑切换流量。
八、总结
Tapir + http4s 的组合在 Scala 函数式生态中代表了一种”以类型为中心”的 API 设计范式。端点描述作为单一事实来源,把路由、序列化、校验、文档、客户端代码统一在编译器的视野之下,从根本上消除了”接口与文档不一致”的整类 bug。虽然初学者需要适应
1 | Endpoint[A, I, E, O, R] |
这种类型签名与
1 | serverSecurityLogic |
两段式逻辑组合的心智成本,但一旦上手,开发速度和重构安全性都会显著优于传统框架。
对于新启动的 Scala 后端项目,Tapir + http4s + Circe 是当前最值得推荐的组合:函数式纯度足够、生态成熟、文档零成本、类型贯穿全链路。如果你正在维护一个用 Akka HTTP 或 Play 的老项目,也可以通过
1 | tapir-akka-http-server |
或
1 | tapir-play-server |
渐进式迁移到 Tapir 端点描述,先享受文档自动化,再逐步替换底层框架。
汤不热吧