"The Modular Monolith: Rails Architecture"を読んだ

Modular MonolithというアーキテクチャをRailsアプリケーションへ適用する記事を読みました。

medium.com

モノリスアーキテクチャとマイクロサービスアーキテクチャの中間に位置する、一つのモノリシックなアプリケーション内でドメインごとにモジュールに分解しつつ運用するためのアーキテクチャを、Railsでどのように実装するか、という内容です。

Modular Monolithとは

記事から引用します。

Rather than extracting microservices, we decided to first focus on making our app modular. Our goal was to identify good architectural boundaries before we extracted code out into independent services. This would set us up to be able to migrate to microservices in the future, by having the code structured in a way to make a smooth transition.

  • モノリスから複数のマイクロサービスを抽出するより、まずアプリ内をモジュラーにしていく
  • 独立したサービスとして抽出する前にアーキテクチャ上のよい境界を見つける。スムーズに移行しやすいようにコードを構成しておく

というものです。Railsで実現するための具体的な方法として次の項目が挙げられています。

  • app ディレクトリを持たず、コードはすべて gemsengines の下に置く
  • gems の下に置くコードははRailsに依存しないRubyのコードとなる
    • すべてステートレスであり、ストレージを使わない
    • Active Supportだけは使う
  • engines の下に置くコードはRailsに依存するRubyのコードとなる
    • mountable engineにする
    • Active Recordによってデータを永続化する
    • Action PackによってAPIやWebインタフェースを公開する

なぜModular Monolithを使うのか

rails new してRailsアプリケーションをふつうに作っていくとモノリスアーキテクチャになります。ユーザー管理、商品管理、決済など、さまざまなドメインの機能をまとめて app 配下などで管理しています。一方、ドメインごとにサービスを切り出して別のアプリケーションとして運用し、サービス間はWeb APIなどを通じて連携するマイクロサービスアーキテクチャも存在します。

Martin Fowler氏は、MonolithFirstという記事で、最終的にマイクロサービスにアーキテクチャを移行したいとしても、最初はモノリスから始めるのがよいと述べています。Monolith Firstではアプリケーションを成長させながらドメインの境界を見つけていき、またその必要があるならば、徐々にマイクロサービスとして切り出していきます。

Railsであれば、小〜中規模なアプリケーションならモノリスのままRailsの利点を活かしていくのがよいでしょう。成長して大きくなってきたアプリケーションは、モジュール間の依存が複雑になって変更時の影響範囲が読めなくなってきたりします。こういう場合は、まずモノリスの中をドメインごとにモジュールとして分解していくのが効果的といえ、今回読んだ記事ではModular Monolithを使うのがよいと述べています。

RailsにおけるModular Monolithアーキテクチャの実現

ここからは、記事で説明されているModular Monolithアーキテクチャを実現するための実装方法について、かいつまんで説明します。

コードを gemsengines に配置

前述したように、コードをすべて gemsengines の下に置きます。すべてgemとして構成するので、それぞれのディレクトリにはgemspecが存在します。Gemfileでは次のようにしてロードします。

Dir.glob(File.expand_path("../engines/*", __FILE__)).each do |path|
  gem File.basename(path), :path => path
end

Dir.glob(File.expand_path("../gems/*", __FILE__)).each do |path|
  gem File.basename(path), :path => path
end

やっていることは、enginesgems 配下のディレクトリについて gem メソッドで読み込むgemを指定しています。同一リポジトリ内にgemが存在するので path オプションを使っています。これを見るとわかるように、実質monorepoとなっています。記事では、プロダクトのコアとなるコードとgemのコードを同時に更新できるので、後方互換性が問題とならず便利であることを利点として挙げています。

モジュール構成のスタート地点

記事では、Admin, API, Domain というモジュールから始めたと述べています。AdminAPIDomain にそれぞれ依存する形です。ここから、Domain モジュールを分解していくのがよいだろうと述べています。

境界の遵守

Railsでgemをロードすると、あるクラスは他のクラスに自由にアクセスできてしまいます。これを防ぐために、Railsエンジンのテスト実行時にそのエンジン自体と依存先だけをロードできるようにGemfileの記述を工夫しています。テストはRailsエンジンのディレクトリで実行します。

if ENV["ENGINE"].nil?
  if Dir.pwd.split("/")[-2] == "engines"
    ENV["ENGINE"] = Dir.pwd.split("/").last
  end
end

Dir.glob(File.expand_path("../engines/*", __FILE__)).each do |path|
  engine = File.basename(path)
  gem engine, :path => "engines/#{engine}", :require => (ENV["ENGINE"].nil? || ENV["ENGINE"] == engine)
end

ENGINE 環境変数が設定されていなければ、Railsエンジン名を ENGINE 環境変数に保存しておきます。そして、engines 配下のディレクトリ名をトラバースしながら、ENGINE 環境変数を見て該当のRailsエンジンだけを require しています。この方法によって、あるモジュールが依存先として指定していないモジュールのクラスを使ってしまっているときはテストでエラーにできます。

元記事では、このロード方法をさらに発展させて、変更したgemとRailsエンジンだけテストが実行されるようにしていました。

循環依存の検出

Bundlerのおかげでモジュールの循環依存が検出できるという話です。

ある二つのモジュール間に循環依存があると、それらのモジュールは強く結合しており、実質一つのモジュールになっているといえます。Modular MonolithアーキテクチャでgemやRailsエンジンといったモジュールに分解することで、モジュール間に循環依存があると、それらのモジュールのロード時にBundlerがエラーとして検出してくれるようになります。

疎結合化のためのObserverパターン

Modular Monolithでは、モジュール間の依存方向に気をつける必要があることがわかってきました。モジュール間の依存方向を制御したいときにはObserverパターンが使えます。

# driving_scoreエンジンにある定期ジョブ
score = ScoringService.generate_score(user)
if score.eligible?
  QuoteService.generate_quotes(user)
end

上のコードは quoting (見積)エンジンの持つ QuoteService へ依存が発生しています。driving_score エンジンから quoting エンジンへの依存を作りたくない場合、次のようにすれば、モジュール間の依存を解消して疎結合にできます。

# driving_scoreエンジンにある定期ジョブ
score = ScoringService.generate_score(user)
if score.eligible?
  DRIVING_SCORE_PUB_SUB.publish(:eligible_score, :user_id => user_id)
end

# quotingエンジンにあるイベントsubscribe用コード
DRIVING_SCORE_PUB_SUB.subscribe(:eligible_score) do |user_id|
  QuoteService.generate_quotes(user_id)
end

driving_score エンジンでは eligible_score というイベントだけを発行し、だれがそのイベントを購読しているかに関心はありません。つまり、driving_score エンジンから quoting エンジンへの依存をなくすことに成功しています。

実際には、pub/subの実現にはKafkaやメッセージキュー用のミドルウェアなどが必要になります。私見としては、このあたりはRailsだとWhisperが使えそうだと思いました。

感想

独立したアプリケーションの機能をmountable engineに切り出すと便利というのはこれまでも言われていたことですが*1app 配下を廃してすべてのコードを gemsengines 配下に置くという割り切りや、マイクロサービスを意識して徐々にドメイン境界を見つけて切り出すという点が独特だと思いました。

また、書籍『マイクロサービスアーキテクチャ』では、モノリシックなアプリケーション内にモジュールを作ることは「実世界ではプロセス境界内でのモジュール分離の約束が守られてことはほとんどありません」*2と述べられています。この問題に対しては、Modular Monolithの設計方針であるgem/Railsエンジンへの分離やBundlerによる依存関係管理を用いた境界の遵守が、解決策の候補になるのではないかと思いました。

興味があれば元記事も読んでみてください。

*1:https://speakerdeck.com/kami/mountable-engine-for-small-team など

*2:『マイクロサービスアーキテクチャ』p.12(原文ママ)

このブログをHTTPS化した

はてなブログで独自ドメインを当てているときもHTTPS化できるようにとっくになっていたので、このブログもHTTPS化しました。

手順

こちらの記事を全面的に参考にさせてもらいました。

blog.jnito.com

まずはgemをインストールします。config.yml の準備もいい感じにやっておきます。

$ gem install hateblo_mixed_contents_finder

次に、mixed contentsが存在する記事を洗い出します。

$ hateblo_mixed_contents_finder validate_all
$ cat result.txt | cut -f 1 | sort -u > invalid_entries.txt # mixed contentsを含む記事のURLリストを出力する

ツールが invalid_entries.txt を参照してくれるので、再更新をかけます。

$ hateblo_mixed_contents_finder update_all

ふたたびmixed contentsがないかを確認します。

$ hateblo_mixed_contents_finder validate_all
$ cat result.txt | cut -f 1 | sort -u
http://blog.kymmt.com/entry/201601_read_booksq
http://blog.kymmt.com/entry/201602_read_books
http://blog.kymmt.com/entry/201603-read-books

ここまでやって、mixed contentsが残った記事のURLリストを出せたので、これらの記事については手作業でmixed contentsを取り除きました。具体的には、ベタ書きされたURLのプロトコルがHTTPだったので、次のように対応しました。

  • httphttps に置き換え
  • Amazonの画像URLは http://ecx.images-amazon.comhttps://images-na.ssl-images-amazon.com に置き換え

もう一度mixed contentsがないか確認たところ、そのような記事は見つかりませんでした。

$ hateblo_mixed_contents_finder validate_all
# ...
OK💚

最後に、はてなブログの設定画面からHTTPS配信を有効にしておしまいです。

『なるほどUnixプロセス』を読んだ

読みました。

tatsu-zine.com

どんな本か

主にRubyの Process モジュールを使いながらUnixプロセスについて知る本です。

Rubyでには Process モジュールを通じてUnixプロセスを操作することができます。つまり、プロセスに関するUnixのシステムコールをRubyから扱うことができます。この点で、Cから直接システムコールを発行するよりRubyプログラマにとっては読みやすいコードで、Unixプロセスについての解説を読むことができます。実際、Ruby製のWebアプリケーションでよく使われるResqueやUnicornも、Rubyでプロセスを操作しています。

原著の出版は2011年と少し古いですが、主題がそうそう古びるものではないので、出版年についてはほとんど問題にならないと思います。

具体的には次のようなことがらが説明されています。

  • プロセス自体の特徴
  • forkと子プロセスとの協調
  • シグナル
  • IPC(パイプ、Unixドメインソケット)
  • 外部コマンドを実行するプロセスの生成
  • 付録として実例の説明
    • Resque, Unicorn, Spyglass(この本オリジナルのpreforkサーバ)

なぜ読んだのか

最近はわりとアプリケーションレベルの知識に偏ってインプットしていたので、日常的にあまり意識しないOSレベルの知識についてインプットしたかったというのがあります。Rubyをふだん書いている身として、RubyでUnixのことについて説明しているところに興味を持ちました。また、ちょうど達人出版会でセール中だったからというのも理由のひとつです。

実際に読んでみると、1章1章がちょうどいい詳細度の説明になっていることと、自然な日本語に訳されていることから、たいへん読みやすかったです。

どうだったか

読んでみて興味深かった点をいくつか挙げます。

子プロセスの待機

Rubyでは fork(2) を呼び出す Kernel.#fork を使うと、子プロセスを作ることができます。そして、作った子プロセスが処理を終え、終了コードなどを返すのを待つには、waitpid(2) を呼び出すRubyの Process モジュールのAPIを使うことができます。しかも、子プロセスを待つシステムコールとしての waitpid(2) に対して、Ruby側のAPIとしては次のバリエーションがあるのがおもしろいところです。

このあたりは、ライブラリ利用時にできるだけ意図が明確になるようにする、という設計思想を感じます。

ゾンビプロセス

fork(2) を使って子プロセスを作ったあと、Unixカーネルは終了した子プロセスの情報をキューに入れて管理します。Rubyでは、この子プロセスの情報は Process.#wait またはそれに類するメソッドによって取り出すことができます。この情報が取り出されずに親プロセスが終了したり、親プロセスの処理がなかなか終わらないとき、キューに子プロセスの情報が残ったままになります。この場合、この子プロセスはゾンビプロセスと呼ばれます。これまでゾンビプロセスは「pstop を叩いたときに表示されるなにか」という意識しかなかったのですが、ここの説明で「なるほど」という気持ちになりました。

ゾンビプロセスはカーネルリソースの無駄使いとなるので、子プロセスを待たなくてよい場合、Rubyでは Process.#detach でスレッドを生成して、そちらのスレッドに待たせるのがよしとされているようです。

デーモンプロセス

デーモンといえば、バックグラウンドで立ち上がっているサーバの状態のことをそう呼んでいるという認識でした。たとえば、Rackサーバを次のように起動すると、デーモンプロセスとなり、起動後に制御が端末に戻ってきます。

$ rackup -D config.ru

プロセスをデーモンプロセスと呼ばれる端末から切り離されバックグラウンドで動くものにするには、次の手順を踏む必要があります。

  • forkしたあと、親プロセスで exit することで、子プロセスをわざと孤児にする
    • 親プロセスを終了させて、制御を端末に戻す
  • setsid(2) でその子プロセスをプロセスグループとセッショングループのリーダーにする
    • これをやらないと、端末などからSIGINTなどのシグナルが送られるとき、その子プロセスが終了してしまう
  • ふたたびforkして、親プロセスで exit することで、子プロセスをわざと孤児にする
    • この時点で、新たに作った子プロセスはセッションリーダーではなく、制御端末からも完全に切り離されている
  • カレントディレクトリが削除される場合の対策として、ルートディレクトリに移る
  • 標準ストリームをすべて /dev/null にする
    • 端末につながってないので持っていてもしかたない

また、Rubyでは Process.#daemon というメソッド一発でプロセスをデーモンにすることができます。そして、 Process.#daemon の中 ではCで上の手順をまさに実行しています。

デーモンプロセス化はもっとカーネルの機能一発で実現されているかのようなイメージを抱いていたので、このような細かい手順を踏んで実行することで成り立っているというのが、個人的には意外でおもしろかったです。

おわりに

プロセスというUnixにおいて動作するプログラムを抽象化した存在について知っておくことは、Rubyを書くこと以外の場面でも活きてくると思いますし、単純に世界が広がっておもしろいので、Rubyが読める人はこの本も読んでおくとよさそうだと思いました。サンプルコードで使われるライブラリはすべてRubyの組み込みライブラリか標準添付ライブラリの範疇に収まっているので、気軽に実行できます。実際に手を動かして読むと「なるほど」感が深まると思います。

また、preforkサーバの見本として付録についているSpyglassのコードを読んでみたところ、この本で出てきた知識をフル活用していて、かつ見本ということでシンプルなコードになっているので、やってることがわかるという感覚が得られたのがよかったです。コードはほとんどRubyで書かれています(HTTPパーサなど一部がC)。このサーバのコードをひととおり把握することで、Unicornなど本番で使われるWebサーバの内部の話題にもついていきやすくなりそうだと思いました。

原著者Storimer氏の続編として、TCPソケット編とRubyスレッド編があるようなので、日本語版はないものの、こちらにも手を出したいところです。

Books by Jesse Storimer | Short, focused technical books about system programming specifically for Ruby and Rails developers.

GraphQLナイトに参加した & 『GraphQLとスキーマファースト開発』についてLTした

connpass.com

五反田のfreeeさんでGraphQLナイトが開催されたので参加してきました。会社のSlackの開発者チャンネルで@hsbtさんが貼ったリンクを見て開催に気づけたので、ありがたい限りでした。

また、LT枠が急遽空いたので、前日に飛び入りでLTすることに決めました。LT資料はこちらです。

内容としては、これまでの記事から、あまり他の人と被りなくRubyに特化しすぎることもない次のネタをあらためて紹介した感じです。

blog.kymmt.com

どういう反応になりそうかあんまりわからなかったのですが、ある程度こういう話に興味がある人は多いのかな?という感想でした。

あと、いい機会なのでサンプルを作りました。

github.com

発表はScalaのSangriaが強そうだったり、AWS AppSyncが便利そうなことにあらためて気づけたりというのがよかったです。

また、懇親会では

  • 既存のREST APIなどをGraphQLで書き直すよりは新規開発でGraphQLを選ぶ、ということが多いしやりやすい
  • やっぱり本番運用しているところは多くない
  • スキーマどうやって決めているか → フロントが欲しいJSONに合わせる、など
  • ここ最近はRelayよりApolloの事例をよく聞くし、ドキュメントも充実していてよい
  • パフォーマンスが気になる → Apollo Engineで監視とかするのが便利そう
  • graphql-rubyを使っていると、やることが少なくなるのでRailsじゃなくてもいいのではという気持ちになる

などのお話を共有できました。イベント全体で見ると、言語ではScala/Node/Ruby, 技術領域ではフロントエンド/バックエンド/モバイルアプリのようにさまざまな界隈の人が混じっていたのが、ふだんRuby系のコミュニティに顔を出すことが多い人間としては新鮮でよかったです。

主催者の@htomineさんほか運営の方々、参加者の皆さん、ありがとうございました。

graphql-guardとポリシーオブジェクトをgraphql-ruby 1.8で使うための方法

これの続きです。

blog.kymmt.com

問題

graphql-guardはインラインでポリシーを書く方法の他に、ポリシーオブジェクトを定義して GraphQL::Guard.new に渡すという方法があります。使いかたはREADMEに書いてあるとおりです。

class GraphqlPolicy
  RULES = {
    Types::QueryType => {
      viewer: ->(obj, args, ctx) { !ctx[:current_user].nil? }
    }
  }

  def self.guard(type, field)
    type.introspection? ? ->(_, _, _) { true } : RULES.dig(type, field)
  end
end

MySchema = GraphQL::Schema.define do
  query Types::QueryType

  use GraphQL::Guard.new(policy_object: GraphqlPolicy)
end

Types::QueryType = GraphQL::ObjectType.define do
  name "Query"

  field :viewer, !Types::UserType do
    resolver ->(obj, args, ctx) { ctx[:current_user] }
  end
end

# 認証に失敗すると GraphQL::Guard::NotAuthorizedError がraiseされる

で、graphql-ruby 1.8でclass-based APIを使ってスキーマを書き直すと、残念ながらこのポリシーオブジェクトは動かなくなります。

class GraphqlPolicy
  RULES = {
    Types::Query => {
      viewer: ->(obj, args, ctx) { !ctx[:current_user].nil? }
    }
  }

  def self.guard(type, field)
    type.introspection? ? ->(_, _, _) { true } : RULES.dig(type.name, field)
  end
end

class MySchema < GraphQL::Schema
  query Types::Query

  use GraphQL::Guard.new(policy_object: GraphqlPolicy)
end

class Types::Query < Types::BaseObject
  field :viewer, Types::User, null: false

  def viewer
    context[:current_user]
  end
end

# 認証に失敗すると GraphQL::Guard::NotAuthorizedError がraiseされず、
# viewerのresolverの結果がnilになるので、次のエラーになってしまう
# {
#   "data": null,
#   "errors": [
#     {
#       "message": "Cannot return null for non-nullable field Query.viewer"
#     }
#   ]
# }

解決策

上の問題の原因はHash GraphqlPolicy::RULE のキーの等価比較にあります。

graphql-guardではフィールドのinstrumentationを使っており、GraphqlPolicy.guard に渡る type は、graphql-guardの差し込むinstrumentationで取得した、class-based API以前のオブジェクト型を表す GraphQL::ObjectType のインスタンスです。一方、RULE のキーはclass-based APIで導入された GraphQL::Schema::Object のインスタンスなので、クラスがそもそも一致していません。なので、これを GraphQL::ObjectType のような古いほうのクラスのインスタンスに変換するためのメソッド #to_graphql を呼ぶ必要があります。

また、GraphQL::ObjectType では eql? をオーバーライドしていないので、Hashのキーの比較にはオブジェクトIDが使われます。ここで、#to_graphql は内部で GraphQL::ObjectType.new しているので、これで作ったインスタンスと RULE のキーになっているインスタンスはオブジェクトIDが異なってしまいます。よって、キーが一致せず、RULE からguard Procを見つけられないので、望むような動作をしなくなっています。

GraphqlPolicy を次のように定義し直せば動きます。GraphQLの型をキーとするのは諦めて、その型の名前 name をキーとすることで、Hashのキー探索がうまくハマるようにしています。

class GraphqlPolicy
  RULES = {
    Types::Query.to_graphql.name => {
      viewer: ->(obj, args, ctx) { !ctx[:current_user].nil? }
    }
  }

  def self.guard(type, field)
    type.introspection? ? ->(_, _, _) { true } : RULES.dig(type.name, field)
  end
end

# 認証に失敗すると GraphQL::Guard::NotAuthorizedError がraiseされる

雑感

この件はgemのREADMEを改善したほうがよさそう。また、なにかもうちょっといい方法があれば教えてください。