書きたいことを詰め込んだらタイトルが長くなってしまいました。
インフラ、フロントエンドと記事を書いてきたのでようやくバックエンドに触れます。
今回もサンプルを用意していますので最後までお付き合いください。
対象読者
- マイクロサービス化を見据えたモノリシックアプリケーションを検討されている方
- 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 App や Beyond The Twelve-Factor App が有名ですが、各項目に適合するために始めからマイクロサービスを実践することは開発組織の習熟度や所属エンジニアのスキルなど中々ハードルも高く、まずはモノリシックなアプリケーションから始めたい...と感じられているエンジニアも多いのではないかと思います(それが私です)。
GitHub の元CTO のJason Warner氏も 開発初期はモノリスが最適 という主旨の発言を X(Twitter) でされています。
I'm convinced that one of the biggest architectural mistakes of the past decade was going full microservice
— Jason Warner (@jasoncwarner) November 14, 2022
On a spectrum of monolith to microservices, I suggest the following:
Monolith > apps > services > microservices
So, some thoughts
とはいえ、闇雲にモノリスなアプリケーションを作ってしまうと、たとえ依存関係に気を遣っていても気が付くと密結合なアプリケーションになってしまいます。
将来的なサービス分割に備えたモノリスなアプリケーションのアーキテクチャとしては モジュラモノリス などの手法がありますが、本記事では1つの例としてモジュールに分割しないモノリスアプリケーションとしてヘキサゴナルアーキテクチャを採用した実装サンプルをご紹介します。
ヘキサゴナルアーキテクチャによる疎結合アプリケーション構成
ヘキサゴナルアーキテクチャはレイヤードアーキテクチャの一種です。
疎結合なコンポーネントにアプリケーションを分割するという性質上、異なるテクノロジを効率的に管理しつつサービス分割を見据えたアプリケーションの実装に適したデザインパターンであると言えます。
設計手法そのものについては本記事では詳しく説明しませんが、サンプルがどのようにヘキサゴナルアーキテクチャを用いて GraphQL と REST API を表現しているかを解説します。
全てを説明すると長くなり過ぎてしまうため記事ではポイントだけの解説にとどめ、基本的にはソースコードを読むようにしていただければと思います。
ユースケースとして、以下の2つの API をフロントエンドに公開することを考えます。
- 自社アプリケーションからコールされる GraphQL(認可あり)
- 外部ベンダー向けに自社サービスの一部機能を公開する REST API(認可なし)
下の図はサンプルアプリケーションの全体構想を表現したものです。
ヘキサゴナルアーキテクチャではアプリケーションは大きく3つの層で構成され、クライアントからのリクエストを処理するインバウンド(Primary)、設定やビジネスロジックを記述するアプリケーション(Application)、データベースや外部サービスに向けてリクエストを発行するアウトバウンド(Secondary) に分けられます1。
サンプルではそれぞれの API はキャラクターのデータを操作するビジネスロジックである CharactersService
を参照し、 CharactersService
はデータストアに接続する CharactersRepository
を介してキャラクターの永続データを操作することとします。
関係性を分かりやすくフローで表現すると以下のようになります。
フローのそれぞれの箱をインターフェースで抽象化し依存性を逆転させることで、ハードコーディングしたモック(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
パッケージを指定している通り、定義はあくまで実装に依存しないインターフェースを参照しています。
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)
}
...
}
2つの 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
である。」
という宣言をしていることとなり、実装するコードに制限を加えながら依存性を定義することができます。
フローをもう一度思い出してみましょう。
下図ではレイヤーとの対応を分かりやすくし発生するエラータイプについても補足しました。
あらためて CharactersPublicApiLive
を見てみましょう。
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
なので、サンプルでは Spring ユーザーにも馴染み易いように AppContext
と命名しています。
ZIO における ZIO.service[T]
は Spring における @Autowired
のような感覚で扱いますが、 エラーハンドリングやコンポーネントの階層的な依存関係の制御にまで踏み込んでいる ZIO はよりレイヤードアーキテクチャに適したライブラリと言えるでしょう(個人の感想)。
ZLayer.Debug.tree
や ZLayer.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.gqlLayer
と AppContext.restLayer
でそれぞれのサーバーにレイヤーを Provide
し 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
localhost:8088/graphiql
にアクセスすると GraphQL の IDE である GraphiQL
を表示できます。
クエリを実行するとキャラクターの一覧を取得できます。
localhost:9000/docs
にアクセスすると Swagger UI
を表示できます。
Try it out
を実行することでこちらもキャラクターの一覧を取得できます。
まとめ
かなり説明を端折ってしまいましたが、ヘキサゴナルアーキテクチャでハイブリットなゲートウェイを構築するサンプルをご紹介しました。
ゲートウェイと呼んではいますが、サンプル自体はモノリシックに全ての機能を詰め込んだサーバーアプリケーションです。
アプリケーションが成長しサービスの分割が見えてきた段階で Application
内の Services
をマイクロサービス化し、セカンダリアダプターにマイクロサービスのクライアントを実装することで BFF (Backend For Frontends) のようなゲートウェイとして機能させることが可能です。
最後に、ZIO は タイプセーフ を超えて レイヤーセーフ5 にアプリケーションを構築できる非常に素晴らしいライブラリなので、情報が少なく学習に苦労するかもしれませんが是非興味を持っていただければと思います。
公式ドキュメントは更新が遅かったりするので、学習には以下のリポジトリを活用するのが良いと思います。
本記事は以上です。
-
Inbound/Outbound は Driver/Driven とも呼ばれます ↩
-
https://zio.dev/reference/di/dependency-injection-in-zio/#dependency-injection-when-writing-services ↩
-
https://zio.dev/reference/di/automatic-layer-construction/#zlayer-debugging ↩
-
記事を書いていて思いついた造語でありそのような用語は(たぶん)ありません ↩