欢迎光临

用 Tapir 与 http4s 构建 Scala 后端服务:从端点描述到 OpenAPI 文档的完整实战

在 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 文档由端点定义自动生成,接口与文档永远不会脱节,前端团队可以放心基于生成的
    1
    openapi.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,

1
Endpoint

类型定义

tapir-http4s-server 把端点解释成 http4s 的

1
HttpRoutes
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))

这里有几个值得注意的细节:

  • 1
    base

    是一个共享错误响应的”骨架端点”,每个具体端点都从它派生,保证全服务错误格式一致。

  • 1
    query[Int]("limit").validate(max(100))

    把参数校验逻辑写进端点定义,校验失败时 Tapir 自动返回 400,无需在业务逻辑里手写。

  • 1
    path[Long]("id")

    同时声明了路径占位符名称和 Scala 类型,OpenAPI 文档会自动反映这个名称。

  • 1
    out(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)必须放到独立的阻塞线程池,否则会拖垮计算池。在
    1
    Dispatcher

    1
    Blocker

    上执行 JDBC 调用。

  • 连接池调优:HTTP 客户端(如 sttp 调用下游服务)与数据库连接池要分别配置上限,避免一个慢下游拖垮整个服务的资源。
  • JSON 编解码性能:Circe 默认基于反射派生编解码,性能优于 Play JSON 但仍低于手工编写。高 QPS 场景可考虑
    1
    circe-generic-extras

    或迁移到

    1
    jsoniter-scala

    ,性能提升 2-3 倍。

  • 可观测性:用
    1
    http4s-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 端点描述,先享受文档自动化,再逐步替换底层框架。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » 用 Tapir 与 http4s 构建 Scala 后端服务:从端点描述到 OpenAPI 文档的完整实战
分享到: 更多 (0)