2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ヘキサゴナルアーキテクチャでハイブリッドな GraphQL + REST API ゲートウェイを実現する

Last updated at Posted at 2023-08-15

書きたいことを詰め込んだらタイトルが長くなってしまいました。
インフラ、フロントエンドと記事を書いてきたのでようやくバックエンドに触れます。
今回もサンプルを用意していますので最後までお付き合いください。

対象読者

  • マイクロサービス化を見据えたモノリシックアプリケーションを検討されている方
  • GraphQL サーバーと REST API サーバーのコードを共通化したい方
  • 中規模以上のプロジェクト / プロダクトの技術選定を任された方
  • Scala や ZIO、関数型プログラミングに興味がある方

注意事項

  • サンプルはドメイン駆動設計(DDD)を厳密に実践していません
  • サンプルではドメインロジックの関心の分離ではなくテクノロジの分離にフォーカスしています
  • 本来 DTO や DPO で扱うべき部分を簡略化しているためレイヤー間のデータの受け渡しについては密結合を許容しています

TL;DR

サンプルコードです。言語やビルド環境のセットアップには anyenv を使用しています。

使用している技術は以下の通りです。

  • Scala3
  • ZIO2
  • Caliban (GraphQL)
  • Tapir (REST API)

Scala は LTS となった 3.3+ に対応しています。

Why モノリス

今回のサンプルは スケールを見据えたモノリシックなモダンアプリケーション開発 をイメージしています。
モダンアプリケーションのベストプラクティスとしては The Twelve-Factor AppBeyond The Twelve-Factor App が有名ですが、各項目に適合するために始めからマイクロサービスを実践することは開発組織の習熟度や所属エンジニアのスキルなど中々ハードルも高く、まずはモノリシックなアプリケーションから始めたい...と感じられているエンジニアも多いのではないかと思います(それが私です)。
GitHub の元CTO のJason Warner氏も 開発初期はモノリスが最適 という主旨の発言を X(Twitter) でされています。

とはいえ、闇雲にモノリスなアプリケーションを作ってしまうと、たとえ依存関係に気を遣っていても気が付くと密結合なアプリケーションになってしまいます。
将来的なサービス分割に備えたモノリスなアプリケーションのアーキテクチャとしては モジュラモノリス などの手法がありますが、本記事では1つの例としてモジュールに分割しないモノリスアプリケーションとしてヘキサゴナルアーキテクチャを採用した実装サンプルをご紹介します。

ヘキサゴナルアーキテクチャによる疎結合アプリケーション構成

ヘキサゴナルアーキテクチャはレイヤードアーキテクチャの一種です。
疎結合なコンポーネントにアプリケーションを分割するという性質上、異なるテクノロジを効率的に管理しつつサービス分割を見据えたアプリケーションの実装に適したデザインパターンであると言えます。
設計手法そのものについては本記事では詳しく説明しませんが、サンプルがどのようにヘキサゴナルアーキテクチャを用いて GraphQL と REST API を表現しているかを解説します。
全てを説明すると長くなり過ぎてしまうため記事ではポイントだけの解説にとどめ、基本的にはソースコードを読むようにしていただければと思います。

ユースケースとして、以下の2つの API をフロントエンドに公開することを考えます。

  • 自社アプリケーションからコールされる GraphQL(認可あり)
  • 外部ベンダー向けに自社サービスの一部機能を公開する REST API(認可なし)

下の図はサンプルアプリケーションの全体構想を表現したものです。
hexagonal_architecture.png
ヘキサゴナルアーキテクチャではアプリケーションは大きく3つの層で構成され、クライアントからのリクエストを処理するインバウンド(Primary)、設定やビジネスロジックを記述するアプリケーション(Application)、データベースや外部サービスに向けてリクエストを発行するアウトバウンド(Secondary) に分けられます1

サンプルではそれぞれの API はキャラクターのデータを操作するビジネスロジックである CharactersService を参照し、 CharactersService はデータストアに接続する CharactersRepository を介してキャラクターの永続データを操作することとします。
関係性を分かりやすくフローで表現すると以下のようになります。
hexagonal_architecture_flow.png
フローのそれぞれの箱をインターフェースで抽象化し依存性を逆転させることで、ハードコーディングしたモック(Mock)や本番用コードである実装(Live)に切り替えられるようにしています。
このようにアプリケーションを疎結合なコンポーネントに分割することで、テスト容易性を確保し将来的なサービス分割(マイクロサービス化)に備えることができます。

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 パッケージを指定している通り、定義はあくまで実装に依存しないインターフェースを参照しています。

adapters/primary/graphql/schemas/CharactersSchema.scala
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)
      )
    )
...
}
adapters/primary/rest/endpoints/CharactersPublicEndpoint.scala
import com.example.ports.primary.CharactersPublicApi

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

2つの API の実装 xxxApiLive.scala はどちらも CharactersService に依存していますが、インターフェースを参照しているだけですので実体である依存性を注入する仕組みが必要となります。
そこで登場するのが Scala の ZIO ライブラリです。

複雑な依存関係とエラーハンドリングを ZIO で解決する

ZIO はエラーハンドリング、リソース管理、非同期および並行処理をサポートする Scala の関数型プログラミングライブラリです。

ZIO に関する詳細な説明は本記事では割愛しますが、ここではパワフルな DI エンジンとしての機能とヘキサゴナルアーキテクチャとの相性の良さについてご紹介します。

まず、先ほどの API の実装について見てみます。
CharactersPublicApiLive.scala では ZLayer[-RIn(Requirements), +E(Error), +ROut(Value)] という宣言で自身の依存関係を表現しています。

adapters/primary/rest/apis/CharactersPublicApiLive.scala
object CharactersPublicApiLive {

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

}

上記ソースコードは
CharactersPublicApiLiveCharactersService を用いて CharactersPublicApi を実装する。発生するエラーは PrimaryError である。」
という宣言をしていることとなり、実装するコードに制限を加えながら依存性を定義することができます。

フローをもう一度思い出してみましょう。
下図ではレイヤーとの対応を分かりやすくし発生するエラータイプについても補足しました。
hexagonal_architecture_flow2.png
あらためて CharactersPublicApiLive を見てみましょう。

adapters/primary/graphql/apis/CharactersApiLive.scala
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)
    }
  }

}
application/constants/Errors.scala
// 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 に変換しないとこちらもコンパイルエラーとなります。
これにより、各レイヤー(コンポーネント)の依存関係や発生するエラーに制約を設けて適切に制御することができます。

依存性を注入している記述は以下のコードとなります。

application/core/AppContext.scala
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 を削除するとこちらもコンパイルエラーとなります。
また、CharactersRepositoryMockCharactersRepositoryLive に切り替えてデータストアに接続する本番用コードに切り替えることもできます。

余談
JVM 上で動作する最もメジャーな Web アプリケーションフレームワークである Spring では DI コンテナを制御するオブジェクトは ApplicationContext なので、サンプルでは Spring ユーザーにも馴染み易いように AppContext と命名しています。
ZIO における ZIO.service[T] は Spring における @Autowired のような感覚で扱いますが、 エラーハンドリングやコンポーネントの階層的な依存関係の制御にまで踏み込んでいる ZIO はよりレイヤードアーキテクチャに適したライブラリと言えるでしょう(個人の感想)。
ZLayer.Debug.treeZLayer.Debug.mermaid を利用することで依存関係を綺麗なツリー構造で表示できる4ので DI コンテナ同士の依存関係に悩んでいる方にも ZIO はお勧めです(ZIO1では zio-magic というモジュールでしたが ZIO2 で組み込まれました)。
ZLayer.Debug.mermaid を使用して自動生成された Mermaid Live Editor のリンクは以下の通りです。
GraphQL Mermaid Graph
RestAPI Mermaid Graph

並列プロセスでハイブリッドに API を公開する

Scala の HTTP サーバー / クライアントライブラリである http4s を使用してそれぞれの API を公開します。現在のサンプルでは実行時の引数で何も処理していませんが、例えば引数によって片方のサーバーだけを起動する、といった制御も可能です。
AppContext.gqlLayerAppContext.restLayer でそれぞれのサーバーにレイヤーを ProvidezipPar で並列に起動します。

Main.scala
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

localhost:8088/graphiql にアクセスすると GraphQL の IDE である GraphiQL を表示できます。
クエリを実行するとキャラクターの一覧を取得できます。
graphiql.png
localhost:9000/docs にアクセスすると Swagger UI を表示できます。
Try it out を実行することでこちらもキャラクターの一覧を取得できます。
swagger-ui.png

まとめ

かなり説明を端折ってしまいましたが、ヘキサゴナルアーキテクチャでハイブリットなゲートウェイを構築するサンプルをご紹介しました。
ゲートウェイと呼んではいますが、サンプル自体はモノリシックに全ての機能を詰め込んだサーバーアプリケーションです。
アプリケーションが成長しサービスの分割が見えてきた段階で Application 内の Services をマイクロサービス化し、セカンダリアダプターにマイクロサービスのクライアントを実装することで BFF (Backend For Frontends) のようなゲートウェイとして機能させることが可能です。

最後に、ZIO は タイプセーフ を超えて レイヤーセーフ5 にアプリケーションを構築できる非常に素晴らしいライブラリなので、情報が少なく学習に苦労するかもしれませんが是非興味を持っていただければと思います。
公式ドキュメントは更新が遅かったりするので、学習には以下のリポジトリを活用するのが良いと思います。

本記事は以上です。

  1. Inbound/Outbound は Driver/Driven とも呼ばれます

  2. https://zio.dev/reference/service-pattern/

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

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

  5. 記事を書いていて思いついた造語でありそのような用語は(たぶん)ありません

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?