Rのつく財団入り口

ITエンジニア関連の様々な話題を書いているはずのブログです。

【感想】『マイクロサービスパターン 実践的システムデザインのためのコード解説』:中編

マイクロサービスパターン

 同書の読書記録と感想、長いので3回に分けた2回目です。

microservices.io

f:id:iwasiman:20210123161130p:plain
マイクロサービスパターン

Chapter 5 マイクロサービスアーキテクチャにおけるビジネスロジックの設計

 各サービス内のビジネスロジックのクラス群はたいてい相互に繋がりあっている問題、サービス間ではACID特性のIの分離性がないという2つの問題を抱えながら、DDDを活用してサービスの中のビジネスロジック部分の良い設計を考えていく章。

5.1 ビジネスロジック設計パターン

 大きく2つのパターンがあります。かのマーチン・ファウラー氏の『PofEAA』と同じ分類です。

<Transaction script>パターン
リクエストごとにひとつずつ手続き型でコードを書いていく。状態を格納クラスと動作を実装するクラスが完全に別になる。単純なロジック向け。Orderがオーダーのデータの入れ物専用クラス、OrderServiceOrderDaoが動作をするクラス。createOrderのリクエストが飛んで来たら、OrderService#createOrder()が処理する。

<Domain Model>パターン
状態と動作の両方を持ったオブジェクトモデルで構成する。例えばreviseOrderのリクエストが飛んで来たら、OrderService#reviseOrder()は起動するが実質処理なし。OrderRepositoryからDBを通してOrderインスタンスがロードされたら、このOrderクラスが状態と動作の両方を持つので処理していく。
付随して状態しか持たないクラスもある。設計が分かりやすくなり、テスタブルであり、デザインパターンの様々なテクニックが使える。

 このパターンで使うオブジェクト指向設計(OOD)を改良したアプローチとして、本書ではドメイン駆動設計(DDD)が効果的だと推奨しています。DDDで出てくるクラスの特徴が以下。

  • エンティティ:OrderのDBテーブル1レコード分のような、永続的なアイデンティティを持つオブジェクト。idなどで区別される。属性が同じでもidが違えばイコールではない。
  • 値オブジェクト:通貨名と金額から構成されるMoneyクラスのような値だけのオブジェクト。属性が同じだとイコールとして扱われる。
  • ファクトリ:複雑なオブジェクト生成を実装するオブジェクト。デザインパターンFactoryパターン。
  • リポジトリDBへのアクセスをカプセル化するオブジェクト。エンティティを返したりする。DDDに限らずよく使われるもの。
  • サービス:エンティティや値オブジェクトに属さないビジネスロジック。これもDDDに限らず普通によくあるやつ。

5.2 DDDのAggregateパターンを使ったドメインモデルの設計

 オブジェクト指向設計でクラス群を作っていくと、例えばOrderService1つのドメインモデルの中にはOrderクラス、Restaurantクラス、Courierクラス...など主要クラスがたくさんできて互いに関連します。
しかしこれらの境界が不明確だと、それぞれ別のトランザクションで値が不正に更新されたりする問題が発生してしまいます。

 そこで本書では「アグリゲート」という手法を紹介しています。

<Aggregate>パターン
アグリゲートとは、ひとかたまりの単位として捉えられる境界の範囲内にあるドメインオブジェクト(=クラス)の集合。そのまま整合性の単位となる。この幾つかのアグリゲートで1つのドメインモデルを構成する。

 例では1サービスの中のOrderRestaurantCourierクラスがそれぞれ<<アグリゲートルート>>となり、繋がった値オブジェクトがあればそれぞれのアグリゲート内に収まる、となっています。
 アグリゲートのルールは以下。

  • アグリゲートルートだけを参照する:外からはOrderクラスのメソッドしか呼ばない。すると一連の処理の中で繋がった値オブジェクトも一緒に更新されていき、不変条件を守れる。
  • アグリゲート間の参照は主キーだけ:OrderからRestaurantを参照するときはオブジェクト参照でなく、restaurantIdで参照。疎結合になり、idだけなのでサービスをまたがる場合も使える。永続化も単純になる。
  • 1つのトランザクションで1つのアグリゲートを更新:サービスAとサービスBをまたがった<サーガ>があったら、サービスBの中のトランザクションT1であるアグリゲートだけを更新、トランザクションT2で別のアグリゲートだけを更新……と進んでいく。
     RDBでもNoSQLでもこれに従うと、マイクロサービスアーキテクチャトランザクションモデルの制約にうまくフィットする。

例えばOrderConsumerクラスをひとつのアグリゲートにまとめるような設計もありですが、後で拡張していく際に分割で困るため、マイクロサービスアーキテクチャでは基本的に粒度は細かいほうが良いとしています。

 サービスと対応したひとつのドメインモデルの中をまた分けていくアグリゲート。この「境界線を明確にする」という話は、かのUncle Bob様の『Crean Architecture』や『Clean Code』などなどの書籍にもよく出てきて関連しています。何事も大きいものは適切に分割して互いに疎結合にしていくのが大事ということですね。

5.3 ドメインイベントのパブリッシュ

 イベントには様々な意味がありますが本書では「状態の変化」を表すとし、次のようなパターンを定義しています。

<Domain event> パターン
アグリゲートが作成されたり重要な変化が起きた時に、ドメインイベントをパブリッシュする。

 例えばOrderアグリゲートの中心となるOrderの状態を変えるイベントにはOrder Created, Order Cancelled, Order Shipped など。DB上で状態の変化が起こったらそのイベントをパブリッシュし、一連のサーガの中で他のサービスに知らせたり、画面の変化やメールでユーザーに知らせたり、他のアプリケーションに知らせたり、自分から外に伝えていく仕組み。

  • 動詞の過去分詞を使った「ドメインイベント」というクラスで表す。OrdreCreatedなど。
  • 親クラスから継承する形でイベントのIDや発行時刻や更新したユーザなど、各種メタ情報を持つ。例ではaggregateIdを持っている。基本は処理をせず状態だけ持つクラス。
  • イベントを伝えた先のサービスで詳細情報が必要になる場合もあるので、変化が起こったOrderインスタンスや紐づいた派生クラスなどもイベント内のメンバ変数で持ち、一緒に受け手に渡す。これを「イベントエンリッチメント」と言う。EventEnvelopeのようなクラスを継承したりして実現。

 パブリッシュの実装方法は2つを紹介していますが、本書では1つめのサービスクラスが処理する方法を推奨しています。

  1. アグリゲートルートのクラスが状態の変化時、戻り値でイベントを返す。例ではTicketクラスがaccept()したら戻り値でTicketAcceptEventを生成してリターン。これを受けた外側の呼び側であるKitchenServiceは、メンバ変数で持っているDomainEventPublisher.publish() でこのイベントをパブリッシュ。
  2. アグリゲートルートのクラスが状態の変化時、自分でパブリッシュ。例ではTicketクラスがaccept()したら、継承している親クラスのメソッドregisterDomainEvent()をコールしてパブリッシュ。
5.4 キッチンサービス/オーダーサービスのビジネスロジック

 作者さんが推しているEventuate Tramフレームワークでの実装例。FTGOの中のキッチンサービスを例にとった設計図があるのですが、繋がりの線も多くてなかなか複雑です。

  • 他のサービスから飛んできた通知を処理するのがイベントコンシューマー。例えばメニューが新しくなったよ~という知らせが来たらKitchenServiceEventConsumerクラスは所定のメソッドが動作。
    引数のEventクラスの中に変化が起こったRevisedMenuインスタンスの実体が入っているので、自分がメンバ変数で持っているRestaurantServiceのメソッドの引数に渡してコール、キッチン側で複製で持っているDB内を更新。
  • 他のサービスに通知を飛ばして返ってきたのを処理するのがコマンドハンドラー。同じように飛んできたEventの中にインスタンスが入っているので処理。戻り値で成功/失敗/承認を返すとFW側で処理される。
  • アグリゲートルート、例えばTicketクラス等はみな自身のstateを保持。cancel()など変化が起こったら中でstateによって処理を分岐、それぞれ戻り値でイベントを生成して返す。
  • 外側のサービス、例えば OrderService.createOrder()リポジトリ経由でアグリゲートルートのOrderクラスを生成、createOrderの処理させたらEventを受け取る。メンバ変数のDomainEventPublisher経由でパブリッシュしたり、メンバ変数で持っているサーガ管理用クラス経由でサーガの処理をしたり……

 設計図とコード例を見ていくと何となくは何をしているか分かるのですが、かなり難しく、ここまでやって初めて実現できるのか……!と思いました。
 このEventuate Tramフレームワーク、(自分の観測範囲ではですが)日本国内で使われている情報をあまり、というかほとんど見たことがないのですが、う~ん現実のマイクロサービスではどうなのでしょうね。
 クラウド上前提ならAWSならサービス本体はAPI Gateway + Lambdaのサーバーレス、サービス間のやりとりもメッセージを貯めるのはSQSCloudWatchDynamoDBの通知でLambdaが起動したりするあの辺の仕組み……で、もっとうまくやれるような気がなんとなくしました。

eventuate.io

Chapter 6 イベントソーシングを使ったビジネスロジックの開発

5章とはアプローチを変えて、イベントを中心に据えてビジネスロジックを書き、ドメインオブジェクトを永続化していく「イベントソーシング」を説明する章。

6.1 イベントソーシングを使ったビジネスロジックの開発

<Event sourcing>パターン
状態変更を表すドメインイベントの連続の形でアグリゲートを永続化していく方法のこと。

従来の1オブジェクト(インスタンス)=RDBの1レコードで永続化していく方法の欠点は以下。

  • オブジェクトとRDBインピーダンスミスマッチ:よく語られるもの。ORMフレームワークも色々あるが、根本的にRDBスキーマがオブジェクトの構造と合わないケースがある。
  • アグリゲートの履歴が失われる:UPDATEしていくと以前の状態は失われる。履歴を残したいのなら別テーブルで保持など、自力で開発しなければならない。
  • 監査ログの実装は単調な作業でエラーを起こしやすい:誰がいつ変更したかの記録の実装は時間がかかり、ビジネスロジックと剥離してバグの原因になりやすい。
  • イベントパブリッシングがビジネスロジックに後付けで追加される:RDB更新時に自動でメッセージをパブリッシュしたりする仕組みはなく、手動で追加がいる。

これを解決するイベントソーシングでは、Orderアグリゲートを[ORDER]テーブル1行にINSERT/UPDATEするのではなく、イベントが発生するごとに変化の内容を[EVENTS]テーブルに毎回INSERTしていきます。とても巨大な履歴テーブルがどかっと1つだけあるような感じです。

  • [EVENTS]テーブル全体で一意な event_id の他に event_type,entity_type, Order1件ごとに振られるentity_idを記録、event_dataという項目にJSON形式でOrder1件分の実体を入れていく。
  • アグリゲートのインスタンスの中では apply(event)applyEvent(event)のようなメソッドを呼んでこの処理を行う。
  • イベントは常に状態の変化を表す。OrderCreatedなど。イベントの中には状態の変化を表すデータも常に格納する。ステータスが変わったらその値、OrderCreatedイベントだったらOrder1件分のすべて。
  • アグリゲートのクラスのビジネスロジックはすべて実装変更になる。
    create()revise()など処理の種類ごとだったものが、処理のコマンドが来たら process(ReviseOrderCommand command)でイベントを生成してリターン。
    イベントが来たら apply(OrderRevisionProposalEvent event) で引数のイベント内に入っているOrderインスタンスのデータを使って自分を更新する形。
  • 複数の更新リクエストが同時にアグリゲートクラスに来るケースの対策として、RDBの更新回数やVERSIONのような列の値で楽観ロックの制御を行う。

  • 3章の非同期メッセージングではイベントを一時的に [OUTBOX] テーブルに保存していたが、[EVENTS] テーブルに永久保存しているのでポーリングは定期的にこちらを見に行けばよい。
    PUBLISHEDのような列でパブリッシュ済みかを記録、未パブリッシュのレコードを全件SELECT→メッセージブローカーにパブリッシュ→済んだレコードをUPDATE→次のSELECTでは対象から外れる、としていくと実現できる。

  • [EVENTS] テーブルにINSERTが行われたときのDBのトランザクションログを検知してパブリッシュする方法もある。
  • 大量のイベントが発生するアグリゲートは [EVENTS] テーブル全件検索だと大変なので、最近のものをスナップショットとして別テーブルに保存して使うテクニックがある。
  • サービスは外部から何回同じメッセージが飛んできても大丈夫なべき等の処理にすべき。RDBであれば [EVENTS] にINSERTの際、[PROCESSED_MESSAGES] のような別テーブルにメッセージIDをINSERTして区別できる。NoSQLではメッセージが飛んで来たら必ずイベントをパブリッシュ、メッセージIDを記録していく。

 このイベントソーシング方式の利点は以下を挙げています。

  • ドメインイベントの確実なパブリッシュ:アグリゲートの状態変更のたびに必ずイベントがパブリッシュされて、イベント駆動が実現される。
  • アグリゲートの履歴情報の保持:過去のイベントがすべて残る。
  • オブジェクトとRDBインピーダンスミマッチ解消:データの実体はJSONシリアライズしてある列に保存しているので大丈夫。
  • 開発者のタイムマシン:過去の記録が開発に役立つ。

 イベントソーシングの欠点は以下を挙げています。

  • 習得に苦労するまったく新しいプログラミングモデル:馴染みがないので習得に時間がかかる。
  • メッセージングベースのアプリケーションの複雑さ:メッセージブローカーは少なくとも1度届くことしか保証しないので対応が必要。べき等を保ったりイベントIDで区別したりで対応できる。
  • イベントの増加への対処の難しさ:Orderを表すスキーマが新しく変わったら、[EVNETS] テーブルの event_data に格納しているJSONの内容が途中から変わることになりコードで対応したり。イベントからロードする時点で変換を行うと良い。
  • データの削除の難しさ:削除済みからの列を設けてソフトデリート(=論理削除)するのがよい。また人の個人情報を消去する必要が出てきたら、レコードを消すのでなくメールアドレスなどを暗号化した値に更新する手がある。
  • イベントストアの検索の難しさ:顧客の過去データを検索する必要が出たりした際、複雑なSQL文が必要になる。NoSQLはそもそも主キーでしか検索できなかったりする。CQRSのアプローチが使える。

 こういう手もあるのか……!と思いました。RDBでは、varchar(max)のような巨大な文字列のカラムに中身が可変のJSON形式でデータを保存したりするやり方は、ふつうに正規化していく従来の設計だと一般的には悪手と言われています。『SQLアンチパターン』にも似たような話は出てきますね。
 この[EVENTS]テーブルのような全履歴を持ったものすごく巨大なテーブルでINSERTがメイン、event_idで一意、JSONで実体を保存……というと、おっやはりここはNoSQL、Amazon DynamoDBAzure CosmosDBの出番なのかな?と思いました。

SQLアンチパターン

SQLアンチパターン

  • 作者:Bill Karwin
  • 発売日: 2013/01/26
  • メディア: 大型本

6.2 イベントストアの実装方法

 具体的な実装方法の説。OSSではEvent StoreLagomAxon、作者さんの会社製のEventuateなどがあるそうです。 このうち、内部的には分散メッセージキューのApache KafkaRDBMySQLを使った、Eventuate Local の仕組みが以下。

  • [EVENTS]テーブルにイベント実体を保存、楽観ロック用にエンティティごとにバージョンを保持する[ENTITIES][SNAPSHOTS]というテーブル構成。
  • Apache Kafkaを使って作ったイベントブローカーに各アグリゲート用のトピックがある。MySQL上の更新をトランザクションログで検知して、イベントが発生してイベントブローカーに飛んでいく。

これを使い、アプリケーション部分の実装時に使うのがEventuate Clientフレームワーク

  • Orderなどのアグリゲートルートのクラスは規定の抽象クラスを継承し、引数にコマンドを取る process(XxComand command), 引数にイベントを取る apply(XxEvent event) を実装。
  • 命令を表すCreateOrderCommandクラスも規定の抽象クラスを継承して実装。
  • イベントを表すCreateOrderEventクラスも親クラスがあって同様。
  • 処理をするオーダーサービスのOrderServiceクラスは依存性の注入でメンバ変数にFW側のAggregateRepositoryクラスを持ち、これを利用してsave(), find(), update()でDBとやりとり。イベントソーシングは貯めるだけなのでdelete()はない。
  • OrderServiceEventHandlerのようなイベントハンドラークラスは、各メソッドにアノテーションをつけることでFW側の機能を呼んでイベントのサブスクライブができる。

 このイベントソーシング、普通の機能実装と比べるとだいぶ独特な感じがします。

kafka.apache.org

6.3 サーガとイベントソーシングの併用

5章までで述べてきたサーガの考えと、この6章のイベントソーシングの考えは一緒に使えるのか?という節。 イベントストアにNoSQLを使うよりRDBのほうが、ACIDトランザクションが使えるため統合難易度は下がるそうです。

サーガの中で各サービスが自力でイベントを発行して次のサービス伝えていく、「コレオグラフフィベース」の場合:
相性よく機能する。問題は、サーガの中でアグリゲートの状態変更が起こらなくてもイベントを発行しなくてはならないこと。複雑なサーガでは下のオーケストレーションを使うほうがよい。

サーガの中でイベント発行をオーケストレーターに任す、オーケストレーションベース」の場合:
イベントストアがRDBの場合は、例えば OrderService#createOrder() メソッドの中でRepositoryを呼んでレコード作成、Saga用のクラスを呼んでサーガ開始、の2つを一緒にやれる。両方が1つのトランザクションに入れるのでうまく動く。
イベントストアがNoSQLの場合はこうはいかず、イベントストアに保存、その後OrderCreatedイベントが発行されて消費されてSaga開始…とステップが増える。イベントのIDをうまく使う方法がある。

イベントストアがNoSQLでトランザクションが使えない場合の注意点は以下。

  • イベントが飛んできたらメッセージIDを見て処理済みでない場合のみ処理する、べき等の対応が必須。
  • コマンドがアグリゲートの状態を更新しないケースがあるので、サーガの参加サービスは継続的にリプライを送る。

イベントソーシングの考え方ベースで、サーガのオーケストレーターを実装するやり方は以下。

  • イベントの種類にサーガオーケストレーターの作成、更新を加え、これもイベントストアに保存。
  • サーガのコマンドもイベントストアに保存、イベントが発生したら受け手に渡して、At least onceのメッセージングを行う。
  • メッセージの受け手はメッセージIDを判別してべき等で処理して、Exactly onceのリプライを行う。

 このイベントソーシングを全面的に取り入れた開発は考え方も変わって確かに難しそうです。イベントのテーブルにはNoSQL推しなのか…と思わせて複数のサービスをまたいだ動きが絡んでくるとRDBの方が適していたり、RDBトランザクションはやはり偉大なのだな…と今更ながら思いました。

www.slideshare.net

Chapter 7 マイクロサービスアーキテクチャでのクエリーの実装

 問合せのクエリー処理もマイクロサービスでは複数サービスにまたがることがあり、RDBSQL文の工夫などのレベルだけでは足りなくなります。この実装方法を追求する章。

7.1 <API composition> パターンを使ったクエリー

 例えばオーダー1件を検索する findOrder(orderId) も、オーダーサービス、キッチンサービス、配達サービス、会計サービス....のように幾つかのサービスにそれぞれ検索しなければならない場合を解決するパターン。

<API composition>パターン
APIを介して複数のサービスそれぞれにクエリーを送り、データを結合してクエリーを実装する。

構成要素は2つあります。

  • APIコンポーザー:各サービスにクエリを送り、結合して返す処理をするところ。クライアントからはここを呼ぶ。HTTPプロトコルのRESTエンドポイントである場合が分かりやすい。
  • プロバイダサービス:データの一部を所有しているそれぞれのサービス。

どのコンポーネントAPIコンポーザの役割を担当させるかがポイントとしてあり、下の2つめ3つめを推奨しています。

  • 1:: サービスのクライアント:モバイルアプリや、ブラウザのフロントエンドから。しかしファイアウォールの外側にあったり遅いネットワークの場合には実用的でない。
  • 2:: アプリゲーションの外部APIを実装するAPIゲートウェイが担当する:クライアントからはここを呼ぶ。このAPIコンポーザの中で統合する。ファイアウォールの外側からアクセスしてきても実用的。
  • 3:: スタンドアローンのサービスの一つをAPIコンポーザーにする:他のサービスから内部的に呼ぶ場合はこれにする。

  • また、レイテンシーを抑えるために、可能な限り並列にプロバイダサービスを呼んだ方が良い。

 このAPI compositionパターンには以下の欠点がありますが、本書では多くのケースで役立つとしています。

  • オーバーヘッドの高さ:複数のリクエストで複数のDBへクエリーを投げるので、マシンとネットワーキングリソースはその分掛かってしまう。
  • 可用性が下がるリスク:プロバイダサービスのどれかが落ちていたらおじゃんになる。回避策としてAPIコンポーザがキャッシュを持つ、また不完全なデータで良いから返してUIの表示を保つという方法がある。
  • トランザクション的なデータ整合性が欠ける:あるサービスからはstatusACCEPTEDだけど別のサービスからはCANCELED...など違うデータが返ってくることがありえる。
7.2 <CQRS> パターンを使ったクエリー

 名前は時々聞くCQRSは、以下のアーキテクチャを一般化した概念。

<Command query responsibility segregation>パターン
イベントを介して複数のサービスが所有するデータを複製した読み取り専用ビューを作り、クエリーはこれを読む形で実装する。

CQRSが役に立つケースとして例を挙げています。

  • マルチサービスクエリー:たとえば findOrderHistory() で履歴を取ろうとして、オーダーサービスは引数で渡される様々な検索条件に対応できていても、他のサービスでは対応していない場合もある。
    APIコンポーザが大量のデータセットを取得後に自分の中で結合する手法、他のサービスからはid指定で何件も何件も取得する手もあるが、非効率。RDBのクエリ機能と同じようなことをもう一度実装することになってしまう。
  • 地理情報などの特殊なデータ:たとえば空いているレストランを探す findAvailableRestaurants() のような単一サービス内に閉じたクエリでも、近所のレストランを探すのに地理空間がいたりする場合。
     MongoDB, Postgres, MySQLは地理空間拡張を持っているがそうでない場合はDBのレプリカを持たないと実現できない。このケースもCQRSで解決できる。
  • 関心事の分離の必要性:クエリー操作が本来そのサービスでやるべき仕事でなく、別のチームがやるべきだったりすることもある。

 CQRSは以下のように分離を行います。サービスのRESTの入り口->ビジネスロジック->その奥にあるDBが1つだったのが、「コマンドサイドDB」と「クエリーDB」の2つに分かれます。ビジネスロジック部分も2つに分かれます。

「コマンド」:作成/更新/削除
CRUDCUD。HTTPメソッドのPOST/PUT/DELETE。コマンドサイドのドメインモデルが、アグリゲートルートのクラスからいつも通りビジネスロジックを処理。コマンドサイドDBを更新していく。データ変更時にドメインイベントがパブリッシュされる。

「クエリー」:問い合わせ
CRUDR。HTTPメソッドのGETなど。イベントのたびにクエリDBを更新し、問い合わせ処理にはこちらを使う。こちらにはビジネスロジックは実装しない。

また、オーダーサービスやキッチンサービス...などなど他サービスからイベントが飛んで来たらそのたびにイベントハンドラで受けて閲覧専用DBを更新して findOrder() などの外部からのリクエストに応える、「オーダーヒストリーサービス」のようにクエリー専用のサービスとして別出しする方法もある。

このCQRSの利点は以下。

  • 効率の良いクエリーの実装:複数サービスにまたがる場合パターンより効率的。
  • 多様なクエリーの効率の良い実装:NoSQLにはクエリーが貧弱なものもあったりする。クエリー専用DBを別に作ることで、1つのDBだけを使うときの限界を突破できる。
  • ベントソーシングのアプリケーションのサポート:イベントストアは主キーのクエリーしか発行できず、検索に弱いがCQRSで克服できる。殆どの場合にCQRSを併用する。
  • 関心事の分離の徹底:コマンドサイドとクエリーサイドが完全に別になり、より単純化されメンテナブルになる。開発チームを別にすることもできる。

CQRSの欠点は以下。

  • アーキテクチャが複雑になる:ビューを更新してクエリを実行するクエリーサイドも追加で開発が必要。管理、運用の手間が増える。
  • レプリケーションのタイムラグへの対処:コマンドサイドでのイベント発行から、クエリーサイドで見る閲覧専用DBの更新までタイムラグがあったりする。
     これはバージョン情報も一緒に返し、クライアント側でデータが古かったらポーリングして再取得することで対応できる。クライアント側で更新のコマンドを発行したら新規にクエリーを要求せず、その時のデータでローカル上のデータを更新する手法もある。

 RDBのみを使ったエンタープライズ領域の複雑な業務システム開発でも、めちゃんこ長くて複雑なSQL文を発行せざるを得ずSELECT文で速度が出ない場合、検索専用のテーブルを別に持つ、予め検索のSQLが発行されたのに近い状態のビューをメモリ上に持つ……といったテクニックは使われてきました。なるほどCQRSも同じような考えをベースに発展させたのですね。

7.3 CQRS ビューの設計

CQRSビューを持ったモジュールの設計の追求の節。
まずはビューのDBをどうするかの選択があります。

SQL(RDB)かNoSQLか:
NoSQLはトランザクションやクエリ-機能が限定的だが、データモデルが柔軟、スケーラビリティやパフォーマンスは高い。CQRS向け。地理空間データ型や複雑なクエリーなどで、SQLの方が向くケースもある。
 またイベントハンドラ経由で更新が入る。主キーベースの更新ならSQL/NoSQL双方大丈夫だが、外部キーベースの更新ではSQL以外では何らかの対応が必要になることもある。例えばAWSのDynamoDBは主キーベースしかサポートしないので、セカンダリインデックスが必要になる。

  • 主キーが検索条件でJSONオブジェクトを探す:MongoDBDynamoDBRedisなどのキーバリューストア
  • JSONオブジェクトが検索条件で探す:MongoDBDynamoDB
  • テキストの検索:Elasticsearchなどのテキスト検索エンジン
  • グラフ検索:Neo4jなどのグラフデータンベース
  • SQLベースのレポート作成やビジネスインテリジェンス(BI):ここはRDBの出番。

www.mongodb.com redis.io www.elastic.co neo4j.com

 次にこのモジュールの中で、データにアクセスする部分の設計が必要になります。

  • 同時並行処理:複数のアグリゲートからのイベントが同時に飛んでくる場合があるので、双方の更新を正しく反映しなければならない。悲観的ロックや楽観的ロックが必要になる。
  • べき等なイベントハンドラ:同じイベントが2回以上飛んできても大丈夫な仕組みが必要。SQLなら、処理済みイベントのIDを別テーブルに保存して判別すればよい。NoSQLなら更新したそのレコードに、対応済みイベントの最大値(≒最後に処理したイベント)を保持して判別する。
  • クライアントアプリケーションを結果整合性ビューに対応:クエリー結果に直前の更新イベントが反映されてないデータが返ってきたりするケースがある。更新イベントでイベントのIDをクライアント側に返し、クエリーを行う際にそのイベントも条件に含めると判別できる。
7.4 AWS DynamoDB による CQRS ビューの実装

 ではクラウド上ではどうするか…ということで、おなじみDynamoDBでの実装例。

イベントが飛んでくるとJavaのOrderHistoryEventHandlersクラス
->OrderHistoryDataAccessモジュールの中のOrderHistoryDAOクラス
->DynamoDBのテーブルへ
クエリが飛んでくるとOrderHistoryQueryクラス->同じくDataAccessモジュールを経由して検索...

という構成になっています。
 本題と外れますが、DBアクセスのクラス名はDAO(Data Access Object)なんですね。やはりクラス名やモジュール名に-Repositoryを使うのはDDDのドメインモデルの時に限るのが命名上の習わしなのだなと思います。

メインのビューはDynamoDB上に[ftgo-order-history]というテーブルを作っていきます。

  • 1項目(=RDBのレコード、行)が1つの履歴。主キーはorderId
  • グローバルセカンダリインデックスを別に定義して、(consumerId, orderCreationDate) を主キーにして orderId が拾えるようにする。これであるユーザに限定したオーダー履歴が新しい順にクエリできる。
  • 属性のステータスと一致するかで検索したり、ある属性が持つキーワードの集合に検索条件のキーワードが含まれるかの検索は、DynamoDBなら機能で可能。
  • クエリー結果をぺージで分割するのは、DynamoDBではpageSizeの指定、返ってくるLastEvaluateKeyを使って実現できる。
  • DynamoDBのAPI PutItem() は既存データなら更新になるが全ての属性のデータを準備する必要がある。
    UpdateItem() は指定された属性だけ更新になるのでこちらが向いている。イベントが同時に飛んできた場合の同時並行処理に関し、楽観的ロックを使わずともこの UpdateItem() で対応できる。
  • 重複イベントの検出については、アグリゲートのタイプ-アグリゲートの(イベントの)ID の組を属性として常に保持し続けている。更新時に「この属性がないか、属性があっても飛んできたイベントのIDの方が大きいなら更新」という条件で対応している。

 ある程度慣れていれば正規化したオーソドックスなテーブル設計でだいたい上手く行く(たぶん)なRDBに比べると、NoSQL DBは事前にちゃんと設計や検討が必要というのはよく聞くのですが、実例を見るとイメージが湧きます。
 こうしてきちんと用意されたDynamoDB等の助けがあればCQRSパターンも実現できるのですね。

dev.classmethod.jp

Chapter 8 外部APIパターン

 モダンなアプリケーションでは

  • クライアントがブラウザでWebアプリケーションのサーバーにリクエストを出してレスポンスで画面を表示する
  • クライアントが様々なモバイルデバイスでAPIのリクエストを送る
  • さらにマイクロサービスではこのAPIリクエストの先が複数あって……

と外部APIが複雑になっていきます。この解法を探る章。

8.1 外部APIを設計する時の問題点

 モノリシックなアプリケーションでは一度のリクエストですべての結果が返ってきたところ、マイクロサービスではモバイルアプリなど自身が<API composition>パターンのAPIコンポーザの役割を果たして、あちこちのサービスに何度もリクエストを投げる必要があります。この方式の問題点は以下。

  • ユーザエクスペリエンスが下がる:LANの中でなくファイアウォールの外側から、遅いインターネット経由で何回もリクエストを投げるとその分レイテンシーが掛かり、画面表示まで時間がかかる。バッテリー消費も早くなる。またAPI合成のコードをアプリ側に書くことになり、本来の仕事から外れる。
  • APIがカプセル化されていない:バックエンドのサービス側/フロントエンドのモバイルアプリ側双方で、片方に変更が生じたらもう片方も変更しなければならない。ブラウザでなくモバイルデバイスの場合は、アプリのアップグレードが大変という宿命あり。
  • クライアントが使いにくい通信メカニズムの場合もある:HTTPWebSocketでない場合もある。

さらにモバイルアプリ以外の場合の問題点は以下を挙げています。

  • サーバサイドのWebアプリケーションの場合はそれほど問題ない。通信もLAN内で完結するため。
  • フロントエンドのJavaScriptメインのアプリケーションの場合は、モバイルと同じくネットワークの問題がある。またデスクトップ用のUIは一般的にモバイル用よりもリッチなため、APIリクエストの結果をがんばって合成しなければならない。
  • サードパーティのアプリケーション用に各種APIの出入り口を公開している場合は、APIを下手に変えると途端に使えなくなり、長い期間下位互換性を保つのも負担になる。
8.2 <APIゲートウェイ> パターン

 これらの問題を解決するのが、APIゲートウェイのパターンです。

<API gateway>パターン
外部APIクライアントのために、各マイクロサービスへのエントリポイントとなるサービスを実装する。

  • 従来型のブラウザでのWebページ表示用のリクエストは、リクエスト→そのままWebアプリケーションのサーバーへ で変わらず。
  • ブラウザから/モバイルアプリから/サードパーティアプリから のAPIリクエストについて、この<APIゲートウェイ>で処理する。
  • モジュールとしての構成上の位置は常にファイアウォールの内側の、一番最初にリクエストを処理する場所。
  • 飛んできたリクエストを適切なサービスにルーティングするプロキシの仕事をする。
  • 各サービスとのやりとりの結果を一緒にしてレスポンスで返す、API合成の仕事をする。クライアントからは1回で1つのAPIをリクエストすればレスポンスが1回返ってくるだけで済む。
  • 外部クライアントとはHTTPでやりとりしRESTfulなAPIを提供、内側とは別のプロトコルでもやりとり……と必要ならプロトコル変換の仕事を行う。
  • iPhoneとAndroidで別の結果を返すなど、各クライアント固有のAPIが必要だったらこのAPIゲートウェイが受け持つ。
  • アプリケーションの端っこで実装するリクエスト処理機能、「エッジ機能」を受け持つ。
    認証、認可、リクエストの上限の制御、キャッシング、メトリクス収集、ロギングなどなど。奥のバックエンドのサービスの入り口やAPIゲートウェイのさらに手前に配置する手法もあるが、APIゲートウェイに置くことが多い。
  • 利点:アプリケーションの内部構造がカプセル化される。クライアントはこのAPIゲートウェイと1回通信するだけで済み、API合成も任せられる。
  • 欠点:APIゲートウェイの分コンポ―ネントが増えるのでデプロイや管理が必要になる。後述のBFFパターンのように、APIゲートウェイ自体を分割する手法もある。
  • GoFデザインパターンのFacadeパターンに似ている。(ただしFacadeパターンは出入り口をまとめる以外に自分自身では機能を持たないので、そこはちょっと違う)

docs.microsoft.com

ja.wikipedia.org

 全体で使い回す用途の、「場合によっては不要な結果も含まれてしまうような大きなAPI」をフリーサイズのTシャツになぞらえて one-size-fits-all, OSFA と称している言い回しが面白いです。このサイズが合ってないOSFAなAPIを無理やり使うより、APIゲートウェイが丁寧にサイズ感を合わせてクライアント固有の専用APIをあつらえてあげる方が良いよ、ということですね。

 またクラウド周りで時々出てくる「エッジ」の言葉の意味がよく分かっていなかったのですが、ここで良く分かりました。ファイアウォールなりクラウドの内側の世界の一番最初、端っこだからエッジなんですね。だからAWSのCloudFront にある機能のネーミングが Lambda@Edge なのか……

aws.amazon.com

APIゲートウェイのアーキテクチャは2層になっています。

  • 第1層:API層がモバイルAPI/ブラウザAPI/パブリックAPI をそれぞれ処理。
  • 第2層:共通層が全体で共通。

そして開発と運用をやっていくチームの分担は、Netflixの事例を紹介。

  • 例えばモバイルならモバイルクライアントと第1層のモバイルAPIまでが、モバイルクライアントのチーム担当
  • APIゲートウェイの第2層の共通層だけが、APIゲートウェイのチーム担当

とした方が良いとしています。チームがそれぞれのAPIを管理するようにできるためです。この考え方をさらに進め、担当者が曖昧になる問題を解決したのが時々名前を聞くBFF。フェイスブックやインスタにキラキラ投稿されるズッ友ハッシュタグ #BFFBest Friends Foreverの略ではなくて…

<Backends for frontends>パターン
クライアントタイプごとに独立したAPIゲートウェイを実装する。

  • APIゲートウェイ自体をクライアントごとに モバイルAPI用/ブラウザAPI用/パブリックAPI用 と分割し、モバイルクライアントなら「モバイルAPIゲートウェイ」を開発。
  • モバイルクライアントチームはクライアントと第1層までを担当。
  • 共通層はやはりAPIゲートウェイチームが担当で、モバイルAPI用/ブラウザAPI用/パブリックAPI用 それぞれに分割した共通層を全部見る。

 共通機能は共通ライブラリ化して、各APIゲートウェイは同じ技術で作った方が良いとしています。ここでもNetflixが紹介されているのですが、最初期はJavaのGroovyスクリプトでAPIゲートウェイを実装。しかしその後はBFFパターンに移行、Node.jsDocker、Netflix製の技術であるNetflix Falcorを使って実現しているそうです。

www.atmarkit.co.jp

設計上の留意点は以下。

  • 全リクエストがここを通るのでパフォーマンスとスケーラビリティが重要。同期I/Oモデルを使う方法と、非同期I/Oモデルを使う方法がある。
  • 中でのAPI合成するコードは、順番に処理するより同時並行の方が良い。リアクティブスタイルが登場。
  • 呼び出し先の各サービスが死んでいたりエラーを返した際の処理が必要。パターンでx回返ってこなかったら先に進む、など。
  • APIゲートウェイが各サービスの場所を知れることになるので、良き市民としてふるまう。(←この言い回しはちょっと良くわかりませんでした)

7章で出てくる「APIコンポジション」と「APIゲートウェイ」の違いが最初よく分からず混乱してしまったのですが、以下のような感じで理解しました。

  • APIコンポジシションは各APIの検索結果を集めるのでクエリー専用だが、APIゲートウェイは更新系など全体が対象。
  • APIコンポジションのモジュール的な位置はクライアントアプリ、サービス群の手前、サービス群のひとつ...と様々あるが、APIゲートウェイはサービス群の手前、ファイアウォールの内側のリクエストが届く先頭の場所固定。
  • APIコンポジションがやることはAPI合成だが、APIゲートウェイがやることはAPI合成以外にもエッジ機能など各種。APIゲートウェイの概念がその中にAPIコンポジションを内包している……でだいたい合ってるはず。

www.atmarkit.co.jp

8.3 APIゲートウェイの実装

実際に作っていく際の選択肢を述べていく節です。 既存のAPIゲートウェイ製品で有名なものが以下。

  • AWS API Gateway:フルマネージドでインストール不要、各種機能がありAPIゲートウェイの要件をだいたい満たす。しかしAPI合成がない。HTTP(S)のみでJSONがメイン。パターンのみサポートしているの形。
  • AWS ALB:バックエンドはEC2インスタンス固定。APIゲートウェイ的な基本機能がある。API合成や認証などがなく、APIゲートウェイの要件の一部しか満たしていない。
  • その他の製品:自分でインストールが必要、API合成はサポートしていない。

自分も勘違いしていたのですが、AWS API Gatewayは本書の定義によるところのAPI合成もしてくれるわけではないんですね。確かに機能をよく見ると載ってない……!(でも統合で似たようなことはできたような?)
 Microsoft Azureでは Azure API Managementが、GCPでは Cloud Endpoints が該当します。

aws.amazon.com

aws.amazon.com

 独自に作る際はルーティング定義やHTTPプロキシの実装が必要になり、フレームワークの導入を勧めています。

Netflix Zuul

  • ネトフリ製のFW。単独で使うより、OSSのSpring Could ZuulでSpringの中で使う方が便利。
  • パスベースのルーティングしかサポートしていない。

github.com

Spring Cloud Gateway

  • Spring Framework 5の一部になっているAPIゲートウェイのFW。
  • ルーティング、リクエストハンドラでAPI合成、認証などの機能あり。
  • API合成もJavaコードで書いていく。一見シーケンシャルに書いているだけに見えるが、Reactorプロジェクトが提供するMono(Java8のCompletableFutureやJSのPromiseのような仕組み)を使っており、見やすいコードで結合できる。

spring.io

 そしてもっと本格的にやるには「グラフベース」のAPIテクノロジーがあります。
円グラフなどのグラフではなくて、ノードというもので表されたモノとモノの間の関係性を表現できるグラフ構造を活用したやり方。Facebookのソーシャルグラフのグラフですね。どのデータを返すかクライアントからも決められるので複雑な結果も返すことができ、APIが柔軟になりつつ労力も減るそうです。

Netflix Falcor

  • サーバーサイドのデータを仮想JSONオブジェクトのグラフとしてモデリングする仕組み。
  • クライアントとFalcorサーバー両方の仕組みを備える。

netflix.github.io

GraphQL

  • Facebookが2015年にリリースしたグラフベースのAPIテクノロジ。
  • GraphQL自体は標準規格で、様々な言語で実装されている。有名なのがJavaScript(Node.js)で実装されたAppplo GraphQL。サーバーとクライアント両方を含み、強力な拡張機能を備える。サーバーサイドはJSのexpress フレームワークになる。
  • 重要部品が以下の3つ。
  • 1:: Graphスキーマ: type Order{orderId: xx, cunsumerId: Int, ...} のようにクラスのように定義して、各サービスから返ってくる顧客、オーダー、レストランなど1件分のデータをスキーマとして定義する。
  • 2:: リゾルバ関数:このtypeを使ってクエリを作り、サービスとマッピングして問い合わせる。データ構造が非常に柔軟に決められる。
  • 3:: プロキシクラス:各サービスとGraphQLの間を取り持つ。

graphql.org

出たー、追っている方も多いGraphQL! なんとなくRESTとの二項対立、RESTよりイケてる技術だよ的な文脈で見聞きすることもあったのですが、本書ではAPIゲートウェイの実装手段、効率的にAPI合成をやれるテクノロジーだよ、という形で説明されています。こういう順を追った紹介をしてくれると分かりやすいですね。

microservices.io

 
 
ハッシュタグBFF~♪ (関係ない) ということで3回目の後編に続きますよ→ iwasiman.hatenablog.com

SKY FULL of MAGIC(通常盤)

SKY FULL of MAGIC(通常盤)