設計ナイト2020 を受けて、今どんなアーキテクチャを選ぶべきかという話をしたくなったのだ。
設計ナイトで高ぶった結果1時間コースの発表資料が完成したので供養場所を探しています。聞いてくれ!!!
— Takafumi ONAKA (@onk) 2020年11月1日
お前誰よ
をやってきた人間。数百万〜数億行のデータ、月間数千万〜数十億 imp 程度を主戦場にしています。
今日の話
昂ぶって 15,000 字ぐらい書いてしまった。
DDD と PofEAA から学ぶパターン/アンチパターン
- 作者:Eric Evans
- 発売日: 2013/11/20
- メディア: Kindle版
DDD も PofEAA も 2002 年出版だけど、ほとんど今でも通用する話なので、まずココを出発点とするのが良い。
利口な UI (Smart UI) アンチパターン
- エリック・エヴァンスのドメイン駆動設計に書いてある
- KENT WEB 時代ぐらいの Perl や PHP の CGI を思い出すと良いはず
- 画面ごとの .cgi ファイルに、上の方にデータロードのコードが、下の方に UI のコードが書いてある。何なら混ざり合ってる
- もちろん良いこともある
- 画面ごとに分かれているので、影響が局所化される
これを少し改善したものが トランザクションスクリプト
- 分かりやすいのは ISUCON のコード
- template とは分離された、依然として手続き的なコード
- 各 Action 間でのコピペは引き続き横行している
PofEAA でのデータソースのアーキテクチャに関するパターン
- 作者:マーチン・ファウラー
- 発売日: 2016/02/19
- メディア: Kindle版
第10章 データソースのアーキテクチャに関するパターン
テーブルデータゲートウェイ
https://bliki-ja.github.io/pofeaa/TableDataGateway/
行データゲートウェイ
https://bliki-ja.github.io/pofeaa/RowDataGateway/
- 行単位のインスタンス
- insert/update/delete は行インスタンス自身が知っている
- アクティブレコードパターンと類似しているが、アクティブレコードパターンは何らかのドメインロジックもレコードに持たせるところが違う。行データゲートウェイはただのゲートウェイ
私は、トランザクションスクリプトを使用する場合に、行データゲートウェイを使用する頻度が最も高い。この場合、行データゲートウェイでデータベースアクセスコードを適切に抜き出し、別のトランザクションスクリプトで容易に再使用できるようにする。 私は、ドメインモデルを使用する場合には行データゲートウェイを使用しない。シンプルなマッピングを実行する場合には、コードレイヤを追加しなくても、アクティブレコードが同じ役割を果たす。
...
トランザクションスクリプトを行データゲートウェイとともに使用する場合、複数のスクリプトで繰り返されるビジネスロジックこそが、行データゲートウェイに必要なロジックであることがわかるだろう。ロジックを移動することによって、行データゲートウェイは段階的にアクティブレコードへと変化し、ビジネスロジックの重複を軽減する効果をもたらす。
(PofEAA より
アクティブレコード
https://bliki-ja.github.io/pofeaa/ActiveRecord/
- 行データゲートウェイのところで書いた通り、トランザクションスクリプトからロジックをゲートウェイ自身に持たせたら ActiveRecord パターンになる
データマッパー
https://bliki-ja.github.io/pofeaa/DataMapper/
- レイヤー化しようと思うとデータマッパーパターンになりがち
- だけど、密結合してエイッてやるともっと楽だよというのが Rails が示した道 (後述
- 各レイヤー間を疎結合にしようとすると DTO による詰め直しが必要になる
この辺りは一昔前 (2000年代前半) に SSH (Struts, Spring, Hibernate) アーキテクチャが流行っていた頃を思い出す。Hibernate は二次キャッシュで DB アクセスを隠蔽して、例えば
# 概念コード my $entry_1 = EntryRepository->find_all_by_author_id(author_id => 1)->[0] my $entry_2 = EntryRepository->find_by_id(id => 2)
で読み込む $entry_1
, $entry_2
はメモリ上も同じインスタンスであるべきという考え方で、片方に変更を加えると (同じインスタンスであるので) もう片方にも影響する。
- 同じデータなんだから 1 つなのは当然だし、レースコンディションも絶対に発生しない
- DB からロード済みだと SQL は発行しない (Identity Map を持つ)、というのをデータマッパー上でできるので、うまく使うとパフォーマンスが向上する
- 我々の道具で言うと Apollo Client のキャッシュに似たイメージ
PofEAA や DDD で語られたパターンの詳しい話は texta.fm で id:t-wada と id:Yasaichi が話しているのでぜひ聞いてください!
話はトランザクションスクリプトに戻る
トランザクションスクリプトをどこに置くかは、レイヤをどのように体系化するかによって異なる。
...
トランザクションスクリプトを複数のクラスに体系化する方法は2つある。 最も一般的な方法は、複数のトランザクションスクリプトを1つのクラスに入れ、各クラスが関連するトランザクションスクリプトの対象エリアを定義する方法である。この方法は最も簡単で一般的な手法である。 もう1つの方法は、トランザクションスクリプトごとに独自のクラスを持たせ、「コマンドパターン」を使うというものだ。
(PofEAA より
- 何もレイヤー化しないときはすべてを Controller に書く
- Fat Controller, Skinny Model
- むしろモデルは存在しなくてデータソースだけの場合もある
- ISUCON のコードでよく見るパターン
- 最も一般的な方法=テーブル単位でクラスを作ること
- もう一つの方法=
<動詞>Service
ドメインロジックを構築する方法 3 種類
ドメインロジックを構築する方法は以下の 3 つが PofEAA に書かれている。(第2章 ドメインロジックの構築)
それぞれのコストはこう図示される
- ここでフレームワークがあるとテーブルモジュール/ドメインモデルは作るのが非常に簡単になるので初期コストが下がる
- 例えば ActiveRecord
- 集合をファーストクラスコレクションとして表現したもの=テーブルモジュールという理解をしています
- この 3 つに実装上の差があるかというと無いというのが id:onk の考えで、結局 PofEAA にはレールは無い
- また、ドメインオブジェクトを抽出しても手続き的なコードも必要になる。
ドメインで扱う概念の中には、1つの機能や処理が単体で存在していて、もの(オブジェクト)として扱うのが不自然なものもある。そうしたものは、サービスという形でユビキタス言語に組み込む。サービスは基本的に状態をもたない(stateless)。
ドメインにおける重要なプロセスや変換処理が、エンティティや値オブジェクトの自然な責務でない場合は、その操作は、サービスとして宣言される独立したインターフェイスとしてモデルに追加すること。モデルの言語を用いてインターフェイスを定義し、操作名が必ずユビキタス言語の一部になるようにすること。サービスには状態を持たせないこと。
...
このコードを見てもらうと、"なんだ ドメインモデルを入出力にとる関数じゃないか" と思うでしょう。そのとおりです。
ここまでのまとめ
- もっともコピペが横行していて、その分労働集約的に並列作業できる、他には影響を与えずに改修できるのが Smart UI パターン
- データロードと Template を分離するトランザクションスクリプト
- まずは ISUCON コードを思い浮かべると良い
- 各 Action の中を設計しようと思うと、無限の可能性が広がっている。実装のレールは (PofEAA には) 無い
- データと振る舞いを 1 箇所に集めようとしているのが「ドメイン」
はい。まだ 1/3 ぐらいだよ!
Rails によって発見された、密結合で速く走れるソフトウェア
和田:Ruby on Rails自身は疎結合の設計に対してNoを言っている。密結合にすることによって疎結合な設計以上の開発スピードが生まれる。少なくともスタートアップ企業にとってスピードは本当にクリティカルな力なので、もし密結合の状態でも速く走れるソフトウェアの構造があるのであれば、それはゆっくり安定して継続的に歩いていく疎結合のソフトウェア設計より強いということをRailsはある程度証明していたわけですね。そしていま、その構造のまま大きくなるとすごく大変になるということも証明している。
マニアが潰したテスト駆動開発〜『健全なビジネスの継続的成長のためには健全なコードが必要だ』対談 (5) | by Takeshi Kakeda | 時を超えたプログラミングの道
DHH がどのように密結合を作り上げていったのか、については以下のスライドが詳しい。
- RESTful ルーティングと ActiveRecord パターンによって、URL で表されるリソースから DB 上のテーブルまでが密結合する構造を作った
- ActiveRecord パターンとその Validations/Callbacks によって、ビジネスロジックとその組み立て処理を全て Model に書けるようにした
この密結合は、間違いなく最速の設計技法である。じゃないとあんなにスタートアップ界隈で採用されなかったし、十分にワークすることは歴史が証明している。
Rails の MVC が標準となっていった歴史は最近だとここでも語られている。
この頃に同時に起こったのがRuby on Railsに代表されるLLの躍進とそれに伴うテンプレートエンジンの簡素化です。
これによりASP.NETやJSFは所謂Web界隈と呼ばれるようなコンシューマよりへの拡大はもちろん、主戦場であるエンタープライズ領域すらLL言語にフロントエンド系を中心に浸食されていきました。
またこれらのFWは細かい理由は知りませんが結果的にコンポーネント指向ではなく、シンプルなMVC Model 2を採用しておりテンプレートエンジンはループや条件分岐、変数をバインディングしたりレイアウトを作れる程度の簡素なものでイベントドリブンなどは採用さていません。一部、ClickやWicketなんかは採用していましたが、まあ普及していませんし?
僕の感覚としても、シンプルに Rails をベースラインにしていると幸せ。
- Rails の ActiveRecord パターンは設計が揃いやすい
- ドメインがある v.s. ドメインがない
- ActiveRecord は行データゲートウェイではなくドメインである
- ドメインロジックが書かれる
- ロジックが無くなり DAO として扱われると、ドメインモデル貧血症 に陥る
- Life is beautiful: Ruby on Railsの「えせMVC」の弊害
- 手続き的なコードが必要になることがある、とは言ったが、ドメインモデルを作らずにサービスを作ってはいけない
このコードを見てもらうと、"なんだ ドメインモデルを入出力にとる関数じゃないか" と思うでしょう。そのとおりです。最初はその程度の認識でよいと思いますが、ここで一点だけいいたいのは、乱用は禁止ということです。
...
従属するエンティティや値オブジェクトがないということで早期あきらめてしまい、なんでもかんでもドメインサービスにするというのもの違うのです。後者の場合は、振る舞いがあるべきドメインモデルから振る舞いを奪うことになるので、ドメインモデル貧血症の温床になる可能性があるのです。
素朴な密結合 MVC では限界がある
Rails は最速ではあるが、「その構造のまま大きくなるとすごく大変になる」フレームワークでもある。
- ApplicationModel のある風景 / Rails with ApplicationModel - Speaker Deck の前半に書かれている
- Ruby on Railsの正体と向き合い方 / What is Ruby on Rails and how to deal with it? - Speaker Deck の第2部に書かれている
- Builderscon Tokyo 2019 で「ビジネスの構造を扱うアーキテクチャとユーザとの接点を扱うアーキテクチャ」というタイトルで登壇してきました #builderscon - assertInstanceOf('Engineer', $a_suenami) にも書かれている
素朴な MVC では限界があるというのを皆が発表している。
Rails は少人数スタートアップで小〜中規模 *1 なアプリケーションを作るために最適化されたフレームワークなので、ギャップはある。
ただ僕の体感としては、複雑なのはごく一部 (10%程度) で、ほとんどの要件はシンプルに扱える。
複雑さをベースにしたアーキテクチャは不当に難しい。シンプルな要件のときに大仰に見せたくないので、複雑なところは例外的に見えるようなアーキテクチャであると良い。
本当に複雑なものと、複雑ではあるが工夫で対処できるもの
ActiveRecord を前提として、解決方法はいくつも語られてきた
- 7 Patterns to Refactor Fat ActiveRecord Models - Code Climate
- Rails で fat model を避けるための、あまり知られていない方法について - おもしろwebサービス開発日記
また、「アプリケーションサービス」はよく導入される(が、間違えやすい)
トランザクションスクリプトの延長にあるこの Service クラス (コマンドパターン) で戦う方法がよく採られている。
間違えないように Operation に持って行く=Trailblazer
そもそものはじまりは、作者のNick Sutterer氏がRailsのMVC抽象レイヤーのあり方に疑問を持ったこと。Railsの手軽さを認める一方、ModelやControllerの肥大カオス化により、のちの保守性が下がることを問題視されたそうです(Nickさんの本意訳)。
概要は Rails のアーキテクチャ設計を考える - Qiita を読むと掴みやすいかもしれない。
Trailblazer: Operation Overview
- Model の validation に当たるものを
contract
に持っていく- 契約による設計 と同じ語彙
- Operation の中身は本質的に手続き的なものなので
step
で処理する- このときに step の連続である=state を持っている、という点も扱いやすさに繋がっている
- クラスメソッドではなく、インスタンスを作って状態を持つと楽になる、というのが PofEAA でも語られている。
この手法のメリットは、スクリプトのインスタンスを実行時にオブジェクトとして扱える点であるが、トランザクションスクリプトを使ってドメインロジックを体系化するようなシステムでは、このメリットを活かす必要性はほとんどない。もちろん多くの言語では、クラスを完全に無視してグローバル関数だけを使うこともできる。しかし、新たなオブジェクトをインスタンス化することでスレッドの問題が解決できる場合もある。データの分離が簡単になるからだ。
(PofEAA より
間違えないように Interactor に持って行く=Hanami(=Clean Architecture)
Hanami is based on two principles: Clean Architecture and Monolith First.
Architecture: Interactors | Hanami Guides
Overview の次に Interactors の章が置かれる程度には Interactor が主軸なフレームワーク。
Interactor も Operation と同じく initialize
と call
のみを持つクラスである。Iterator の考え方は HanamiはRubyの救世主(メシア)となるか、愚かな星と散るのか を読むと良いかな。
イマイチ流行らなかった rectify
https://github.com/andypike/rectify
- FormObject, Command パターンがあるのはもう前提として、僕は書き味の話をしたい!
- Controller が Controller に必要な本質だけを追究できる世界
Controller に必要な本質は、これも PofEAA に書かれている。
ドメインロジックの扱いで最も難しいのは、人々が思っているように、何がドメインロジックで何が他のロジックかを見極めることだろう。私が好きな非公式のテストは、Webアプリケーションにコマンドラインインタフェースを追加するときのように、まったく異なるレイヤをアプリケーションに追加することを想像するというものである。この追加を行うときに機能を複製する必要があれば、それはドメインロジックがプレゼンテーションの中にはみ出していることを示している。
(PofEAA より
Web アプリケーションとしてのインタフェース (View 以外) が Controller の役目で、「同機能の CUI コマンドを作るときに重複が無い」という基準で考えると良い。
rectify を使ったときの Controller は
def create @form = RegistrationForm.from_params(params) RegisterAccount.call(@form) do on(:ok) { redirect_to dashboard_path } on(:invalid) { render :new } on(:already_registered) { redirect_to login_path } end end
と、ビジネスロジックで起きたイベントを on
で捕捉して、redirect や render を行う。これは CUI にするときにまったく重複しないまさしく Controller の役目だし、見た目も美しい。
とまぁ僕は好きなんだけど、ガッツリ実務で使ったわけではないので見た目の美しさにのみ囚われている可能性はある。
Rectify::Command、コマンドオブジェクトの結果をコールバックっぽくうけとれるんだけど、Command のやれることを小さく分割していくと、コールバック地獄に簡単になるな…
— えむ。 (@takkanm) 2019年5月31日
いずれも Controller or Action と 1:1 対応する新しい層 という考え方
- つまりいずれも CQS に行き着いている
- なお id:onk の 2017 年当時の意見は FormObject (ActiveModel::Model や Reform) のみ導入すれば十分、です
本当に複雑なもの
以上、だいたいのことは工夫で解決できる程度の複雑性、と置いた。じゃあ本当に複雑なものは何かというと、SoR だろうと思う。
- System of Record と System of Engagement - Speaker Deck
- バイモーダルITとは何か? 企業がITの「2つの流儀」を使い分ける方法 小野和俊 「次世代IT企業」への道|ビジネス+IT
ただ僕の目には SoE=モード2 で十分なものがほとんどに見えている。
すべて SoE 的手法でもいけるんじゃないか
https://speakerdeck.com/naoya/system-of-record-to-system-of-engagement#25
バランスを欠いていたと id:naoya は振り返っているが、p20 の表にあるようなシステム領域はあまり普段の開発=イテレーションを回しながら不確実性に対処していく中には出てこないので、だいたい SoE と捉えていくのが Web アプリケーション開発の実情じゃないかな。
今求められているアーキテクチャ
SoE の戦場はクライアント側に移っている。
- ビジネスにおいて、(ネイティブ or Web SPA) クライアントの UX が必須な時代なので、 API 開発の手綱もサーバからクライアントに移していくような流れが生まれています
- GraphQL も BFF も、クライアント側で制御していこうという発想
- 新しい概念「クエリ」を入れたのが GraphQL。クライアントはともかく、サーバは新規開発になる
- クライアントごとの中間レイヤーを作ろうというのが BFF。途中からでも導入しやすい
【エンジニアブログ】ダイニーのエンジニアリング3カ条|dinii(ダイニー)公式|note では Hasura を用いると GraphQL の query 側はほとんど開発が要らないという話をしている。
「フロントエンド領域」を再定義する - Speaker Deck でもフロントエンジニアの領域がサーバ側に広がっている。 GraphQL や BFF をフロントエンジニアが扱うことで、ブラウザがバックエンドからデータを取得する、データソースのアーキテクチャはフロントエンジニアのものになった。
プレゼンテーション優位な技術駆動アーキテクチャを選択する場合は、むしろ立派な実装パターンであると言える。
という話をしている。
まさに Smart UI パターンは再評価されるポイントに来ている。クライアントが、表示の都合でクエリを都度書く世界になった。
ので、Smart UI の痛みも少なく実装することができる。
そしてバックエンドは、SoE では ActiveRecord で表現できないほど複雑な「ドメイン」は無いと位置づけて良く、サーバサイドの仕事は GraphQL の schema を提供したら loader を考えるだけの仕事になっている、というのがイマココです。
(誤解がありそうなのでちょっとだけ言っておくと、GraphQL の schema を考える仕事は RESTful API の schema を考える仕事とほとんど変わんない感覚です。「DB を露出している」と捉えるのは筋違いかな)
Mutation 側は?
Mutation はアプリケーションサービスと非常に親和性が高く、コマンドパターンの Service クラスを無思考で作ってしまいがち。
ただ今までの話であったように、安易に Service に全てを押し込めるとドメインモデル貧血症に陥ります。ActiveRecord の機能をちゃんと使って、適切に validation, callback を使った上で、それでも複雑だから Service クラスが必要になったというのが歴史です。まずはオブジェクト指向的に育てることを忘れないでください。
シンプルな Muation はシンプルに扱える。もしナイーブに実装し過ぎたとしても、改善方法が確立されているので大丈夫。
オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方
- 作者:Sandi Metz
- 発売日: 2016/09/02
- メディア: Kindle版
なんか言及する隙が無かったものあれこれ
*1:複雑さは色んな指標があると思うけど、雑な一つの基準として、100 テーブル以内ぐらいだと考えると良いと思う