通过六边形架构实现混合 GraphQL + REST API 网关

當我試著寫下我想寫的內容時,標題變得很長。
因為我以前寫過基礎設施、前端和文章,所以終於有機會接觸後端。
這次我也準備了一個示例,請您一直陪伴到最後。

适合的读者

    • マイクロサービス化を見据えたモノリシックアプリケーションを検討されている方

 

    • GraphQL サーバーと REST API サーバーのコードを共通化したい方

 

    • 中規模以上のプロジェクト / プロダクトの技術選定を任された方

 

    Scala や ZIO、関数型プログラミングに興味がある方

请留意下列要点

    • サンプルはドメイン駆動設計(DDD)を厳密に実践していません

 

    • サンプルではドメインロジックの関心の分離ではなくテクノロジの分離にフォーカスしています

 

    本来 DTO や DPO で扱うべき部分を簡略化しているためレイヤー間のデータの受け渡しについては密結合を許容しています

太长不看

这是一个示例代码。我们使用 anyenv 来设置语言和构建环境。

 

我们正在使用的技术如下:

    • Scala3

 

    • ZIO2

 

    • Caliban (GraphQL)

 

    Tapir (REST API)

Scala 已支持 LTS 版本3.3+。

为什么会有一个巨型石碑?

今次的样品是以考虑规模为前提的整体式现代应用开发的想法。
作为现代应用的最佳实践,《The Twelve-Factor App》和《Beyond The Twelve-Factor App》是很有名的,但是为了符合每个要素,从一开始就实践微服务可能对于开发组织的熟练程度和工程师的技能等都有相当高的门槛,所以对于很多工程师而言,他们可能更想从整体式应用开始……我觉得这样的工程师也不少(我就是其中之一)。
GitHub的前CTO Jason Warner先生也在Twitter上发表了类似的观点,即在开发的早期,整体式应用最合适。

我相信过去十年最大的建筑错误之一就是全面采用微服务。在从单体架构到微服务的谱系中,我建议如下排序:单体架构 > 应用程序 > 服务 > 微服务。所以,我有一些想法。

然而,尽管我们非常注意依赖关系,但盲目地创建单一应用程序后,可能会发现它会变成紧密耦合的应用程序。
作为未来服务分割的单一应用程序架构,有诸如模块单体之类的方法,但在本文中,我们将介绍采用六边形架构的实现示例,作为不将其分割为模块化的单一应用程序。

通过六边形架构进行松耦合的应用程序结构。

六边形架构是分层架构的一种。由于其将应用程序分解为松散耦合的组件,可以高效管理不同的技术并实现面向服务分解的应用程序。因此,可以说六边形架构是适用于实现应用程序的设计模式。本文不详细介绍设计方法本身,但会解释示例如何使用六边形架构来表示GraphQL和REST API。由于详细介绍会变得过长,因此本文只介绍关键点,并建议您主要以阅读源代码的方式来理解。

考虑将以下两个API公开给前端作为使用案例。

    • 自社アプリケーションからコールされる GraphQL(認可あり)

 

    外部ベンダー向けに自社サービスの一部機能を公開する REST API(認可なし)
hexagonal_architecture.png
hexagonal_architecture_flow.png

由Caliban和Tapir实现的端点实现。

在六边形架构中,将与外部连接的组件表示为适配器。
GraphQL 和 REST API 的适配器在文件夹中定义如下。

.
└── adapters/
    └── primary/
        ├── graphql/
        │   ├── apis
        │   ├── schemas
        │   └── GraphqlResolver.scala
        └── rest/
            ├── apis
            ├── endopints
            └── RestResolver.scala

在示例中,GraphQL 的实现使用 Caliban,REST API 的实现使用 Tapir 库。这两个库都可以在 Scala 代码库中编写模式和输入/输出参数,并且可以将定义和实现分离(值得一提的是,Caliban 内部使用了 Tapir)。此外,为了充分利用后文提到的 ZIO,我们还使用了 tapir-zio 模块。

以下的代码是定义部分的描述。根据 import 语句指定了 ports 包,该定义引用了与实现无关的接口。

import com.example.ports.primary.CharactersApi

object CharactersSchema {
...
  val api =
    graphQL(
      RootResolver(
        Queries(
          args => CharactersApi.getCharacters(args.origin),
          args => CharactersApi.findCharacter(args.name)
        ),
        Mutations(args => CharactersApi.deleteCharacter(args.name)),
        Subscriptions(CharactersApi.deletedEvents)
      )
    )
...
}
import com.example.ports.primary.CharactersPublicApi

object CharactersPublicEndpoint {
...
  val charactersLogic = charactersEndpoint.zServerLogic {
    origin => CharactersPublicApi.getCharacters(origin)
  }
...
}

两个 API 的实现 xxxApiLive.scala 都依赖于 CharactersService,但只是引用了接口,因此需要注入实际的依赖关系。
这就是 Scala 的 ZIO 库的作用。

使用 ZIO 来解决复杂的依赖关系和错误处理

ZIO 是一个支持 Scala 函数式编程的库,用于处理错误、管理资源以及进行异步和并发处理。

 

关于 ZIO 的详细说明在本文章中将被省略,但在这里我们将介绍其作为强大的 DI 引擎和六边形架构的良好兼容性。

首先,让我们来看一下先前API的实现。
在CharactersPublicApiLive.scala中,使用ZLayer[-RIn(Requirements),+E(Error),+ROut(Value)]进行声明,表达了自身的依赖关系。

object CharactersPublicApiLive {

  val layer: ZLayer[CharactersService, PrimaryError, CharactersPublicApi] = ...

}

上述的源代码声明了“CharactersPublicApiLive 使用 CharactersService 来实现 CharactersPublicApi。发生的错误是 PrimaryError。”,我们可以在实现代码中定义依赖性并加入限制。

hexagonal_architecture_flow2.png
object CharactersPublicApiLive {

  val layer: ZLayer[CharactersService, PrimaryError, CharactersPublicApi] = ZLayer {
    for {
      svc   <- ZIO.service[CharactersService]
    } yield new CharactersPublicApi {
      def getCharacters(origin: Option[Origin]): IO[PrimaryError, List[Character]] =
        svc.getCharacters(origin)
          .mapError(_ => InternalServerError)
      def findCharacter(name: String): IO[PrimaryError, Option[Character]] =
        svc.findCharacter(name)
          .mapError(_ => InternalServerError)
    }
  }

}
// Primary Layer
sealed trait PrimaryError(errorCode: String, message: String) extends Throwable {
  def code = errorCode
}
//401 Unauthorized
case object UnAuthorizedError extends PrimaryError("UNAUTHORIZED_ERROR", "You are not authenticated. You need to create an account or accept an invitation.")
//403 Forbidden
case object ForbiddenError extends PrimaryError("FORBIDDEN_ERROR", "Permission denied for this resource. Add the roles you need to access the resource.")
//500 Internal Server Error
case object InternalServerError extends PrimaryError("INTERNAL_SERVER_ERROR", "An Internal Server Error has occurred. Please contact support.")

在CharactersPublicApiLive中,使用ZIO.service[CharactersService]来返回CharactersPublicApi的实例,因此与之前的ZLayer的声明匹配。

积分点数

for {…} yield … は For Comprehensions という Scala の特徴的な表記法で、関数型プログラミングのアプローチで複数の生成結果を評価して1つの結果として返すことができます

ZIO.service[T] で依存するサービスの実体を取得することができます 2 3

如果从上面的代码中删除ZIO.service[CharactersService]或添加其他ZIO.service[T],则由于与ZLayer的声明不同,会导致编译错误。
此外,在业务逻辑中发生的错误被视为DomainError,但在主适配器中,我们施加了约束以将其转换为前端返回的错误类型PrimaryError。
在ZIO中,我们使用.mapError方法来转换错误,但如果不按声明要求转换为PrimaryError,这也会导致编译错误。
通过这种方式,我们可以对每个层(组件)的依赖关系和发生的错误施加约束,以便适当地进行控制。

注入依存性的描述是下面的代码:

object AppContext {
...
  def restLayer: ZLayer[Any, Throwable, RestApp] = ZLayer.make[RestApp](
    // Inbound
    CharactersPublicApiLive.layer,

    // Application
    restServerConfig,
    CharactersService.layer,

    // Outbound
    CharactersRepositoryMock.layer
  )
...
}

由于在ZLayer中声明了“CharactersPublicApiLive使用CharactersService”,所以如果从上述代码中删除CharactersService.layer,这里也将产生编译错误。
此外,您还可以将CharactersRepositoryMock切换到CharactersRepositoryLive,并切换到连接到数据存储的生产代码。

余談
在运行于JVM上的最主流的Web应用程序框架Spring中,用于控制DI容器的对象是ApplicationContext,因此在示例中将其命名为AppContext,以使Spring用户更容易理解。

在ZIO中,ZIO.service[T]的使用方式类似于Spring中的@Autowired,但是ZIO更进一步涉及错误处理和组件的层次化依赖关系控制,因此可以说ZIO是更适合分层架构的库(个人观点)。

通过使用ZLayer.Debug.tree或ZLayer.Debug.mermaid,可以以清晰的树状结构显示依赖关系,因此ZIO也推荐给那些在DI容器之间纠结依赖关系的人(在ZIO1中是使用名为zio-magic的模块,在ZIO2中已内置)。

使用ZLayer.Debug.mermaid生成的Mermaid Live Editor链接如下所示。
GraphQL Mermaid Graph
RestAPI Mermaid Graph

使用并行处理的方法将API公开为混合式的。

使用Scala的http4s作为HTTP服务器/客户端库来公开每个API。当前的示例中,在运行时参数中没有进行任何处理,但是可以通过参数来控制只启动一个服务器。使用AppContext.gqlLayer和AppContext.restLayer在每个服务器上提供相应的layer,然后使用zipPar并行启动它们。

object Main extends ZIOAppDefault {  
...
  val graphql = (args: Chunk[String]) =>
    ZIO
      .runtime[AppContext.GqlApp]
      .flatMap(implicit runtime =>
        for {
          config      <- ZIO.service[ServerConfig]
          interpreter <- GraphqlResolver.api.interpreter
          _           <- EmberServerBuilder
          .default[GqlAuthzTask]
          .withHost(Host.fromString(config.host).getOrElse(host"localhost"))
          .withPort(Port.fromInt(config.port).getOrElse(port"8088"))
          .withHttpWebSocketApp(wsBuilder =>
            Router[GqlAuthzTask](
              "/api/graphql" -> 
                CORS.policy(
                  AuthzMiddleware(
                    Http4sAdapter.makeHttpService(
                      HttpInterpreter(
                        withErrorCodeExtensions[AppContext.GqlApp](interpreter)
                      )
                    )
                  )
                ),
              "/ws/graphql" ->
                CORS.policy(
                  AuthzMiddleware(
                    Http4sAdapter.makeWebSocketService(wsBuilder,
                      WebSocketInterpreter(
                        withErrorCodeExtensions[AppContext.GqlApp](interpreter)
                      )
                    )
                  )
                ),
              "/graphiql" -> Kleisli.liftF(StaticFile.fromResource("/graphiql.html", None))
            ).orNotFound
          )
          .build
          .toScopedZIO *> ZIO.never
        } yield ()
      )
      .provideSomeLayer[Scope](AppContext.gqlLayer)

  val rest = (args: Chunk[String]) =>
    ZIO
      .runtime[AppContext.RestApp]
      .flatMap(implicit runtime =>
        for {
          config      <- ZIO.service[ServerConfig]
          _           <- EmberServerBuilder
          .default[RIO[RestResolver.Apis, *]]
          .withHost(Host.fromString(config.host).getOrElse(host"localhost"))
          .withPort(Port.fromInt(config.port).getOrElse(port"9000"))
          .withHttpApp(
            Router("/" -> RestResolver.routes).orNotFound
          )
          .build
          .toScopedZIO *> ZIO.never
        } yield ()
      )
      .provideSomeLayer[Scope](AppContext.restLayer)

  override def run =
    (for {
      args <- getArgs
      _    <- graphql(args) zipPar rest(args)
    } yield()).exitCode

}

我们来试试看吧。

sbt run
swagger-ui.png

总结

我很抱歉简化了解释,但是我给您展示了一个使用六边形架构构建混合网关的示例。
虽然称之为网关,但该示例实际上是一个将所有功能集成到单体服务器应用程序中的样本。
当应用程序成长并且服务分离变得明显时,可以将应用程序内的服务转化为微服务,并通过实现微服务客户端于次级适配器中,使其作为类似于BFF(面向前端的后端)的网关来发挥作用。

最后,ZIO这个库非常出色,它不仅仅是类型安全的,而且还能在构建应用程序时保证层次安全(Layer-Safe)到第五层。虽然信息有限可能会给学习带来困难,但我希望你们能产生兴趣。由于官方文档更新较慢,我认为最好利用以下的存储库来学习。

这篇文章到此结束。

 

Inbound/Outbound也被称为Driver/Driven。 ↩https://zio.dev/reference/service-pattern/ ↩

https://zio.dev/reference/di/dependency-injection-in-zio/#dependency-injection-when-writing-services ↩

https://zio.dev/reference/di/automatic-layer-construction/#zlayer-debugging ↩

这是我在撰写文章时想到的一种创造性词汇,(可能)并不存在这样的术语。 ↩