外部サービスのリソースをJSONシリアライザブルなActive Modelとして表現する

RailsでWeb APIを作っていて、外部のサービスからリソースを取得し、DBには保存しないもののレスポンスに含めてクライアントに返したい、ということがありました。このとき次のモジュールが役立ったので紹介します。

モジュールの説明

ActiveModel::Model はActive Recordとも互換性のあるインタフェースでRails上のモデルとして扱える機能がいろいろ入るモジュールです。ActiveModel::AttributesAttributes APIを提供するモジュールです。最後の ActiveModel::Serializers::JSON は有名なActiveModelSerializers gemと名前は似ているのですが別物の、rails/railsactivemodel gem内に含まれるモジュールです。

実現方法

POROに ActiveModel::Serializers::JSON をincludeして render :json に渡すと、Attributes APIの attribute で定義した属性からなるJSONをシリアライズできるようになります。これを利用して、次のようなクラスを作りました。

まず、外部サービスから得られるリソースを表すクラスを作り、上述したモジュールをincludeします。

class ExternalServiceResource
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serializers::JSON
end

次に、ExternalServiceResource をもとに具体的なリソースを表すクラスを作ります。ここでは外部ブログサービスから記事リソースを取得しているとします。

class Article < ExternalServiceResource
  attribute :title, :string
  attribute :content, :string
  attribute :status, :integer
  attribute :created_at, :datetime

  DRAFT = 0
  PUBLIC = 1
  DELETED = 2

  def unavailable?
    status.in?([DRAFT, DELETED])
  end
end

class Blog
  # ...

  def articles
    raw_response = client.articles
    raw_articles = JSON.parse(raw_response)['articles']

    raw_articles
      .map { |raw_article| Article.new(raw_article) } # 生JSONをモデルオブジェクトへ変換
      .reject(&:unavailable?)
  end

  private

  def client
    # 外部サービスのWeb APIを叩いてJSONを返すクライアント
  end
end

これらを使うと、外部サービスのリソースも含んだJSONを返すエンドポイント自体は、シンプルに実装できます。

class ArticlesController < ActionController::API
  # articles#index で外部ブログ記事のうちpublicなものを取得できる
  def index
    render json: { articles: blog.articles }
  end

  private

  def blog
    # Blogのインスタンスを返す
  end
end

pros/cons

この方法の利点は、サードパーティgemのActiveModelSerializersを使うのに比べると、Railsのモジュールだけで実現できてシンプルというところや、モデルとして外部リソースを表現できるので、そのリソースに関するロジックをモデルに置くことができるという点が挙げられます。

逆に、欠点として、自前実装になるのでうまくやらないと負債になりそうな点や、リソースの構造がネストするときに、Attributes APIでは ActiveModel::Type.register を使って自前の型を定義していくと実装がやや面倒という点です。ただし、ネストしているリソースについて型を定義せず、次のように型を指定せずに属性を定義することもできるにはできます。

# Article#author に入れるためのモデル
class Author < ExternalServiceResource
  attribute :name
  # ...
end

class Article < ExternalServiceResource
  attribute :title, :string
  attribute :content, :string
  attribute :status, :integer
  attribute :created_at, :datetime

  # Authorのオブジェクトを入れるつもりだが、型指定はとくになし
  attribute :author

  # ...
end

Active Recordでstring型属性を暗号化するためのRailsプラグインを作った

複数プロジェクトで、Active Recordのstring型を拡張して透過的に文字列を暗号化/復号できる型をattributes API(ActiveRecord::Attributes) を使って書く場面を目撃したり、自分でも書く機会があったので、Railsプラグインに切り出してみました。

github.com

使い方は次のとおりです。

# users.tokenはstring型
class User < ActiveRecord::Base
  attribute :token, :encrypted_string
end

# 環境変数かconfigで設定する
ENV['ENCRYPTED_STRING_PASSWORD'] = 'password'
ENV['ENCRYPTED_STRING_SALT'] = SecureRandom.random_bytes
# ActiveRecord::Type::EncryptedString.encryption_password = 'password'
# ActiveRecord::Type::EncryptedString.encryption_salt = SecureRandom.random_bytes

# DB内では暗号化されているがオブジェクト経由で取り出すと復号されている
user = User.create(token: 'token_to_encrypt')
ActiveRecord::Base.connection.select_value('SELECT token FROM users') #=> "eVZzbUlXME1xSlZ5ZWZPQnIvY..."
user.token #=> "token_to_encrypt"

内部的には ActiveSupport::MessageEncryptor を使う、よくある実装になっています。gemにすることですぐに使えるようになって便利ですね。

OpenAPI 3ドキュメントも使えるSchemaConformist 0.3.0をリリースした

rubygems.org

これまでのバージョンの差分はOpenAPI 3ドキュメントが使えるようになった点です。OpenAPI 3に対応したCommittee v3の機能を使うことで、integration test/request spec実行中にOpenAPI 3ドキュメント中のスキーマに基づいたJSONレスポンスのバリデーションを自動実行できるようになりました。

OpenAPI 3についてはRubyKaigi 2019での@ota42yさんの発表資料や、WEB+DB PRESS Vol.108の特集1が参考になります。

speakerdeck.com

gihyo.jp

以上の対応に伴って、Committeeの機能を利用して自動でドキュメントフォーマットが判別できるようになったので、オプション driver の指定は不要になりました。また、オプション schema_path については必須とすることにしました。これは、schema_path を指定しなかったときにデフォルトで入るパス(public/swagger.json など)がとくに一般的なものではないことと、driver をオプションとして渡さなくなったことによります。

他には、ignored_api_paths は正規表現だけでなくふつうのStringも渡せるようにしました。渡された文字列と前方一致するパスを検索して、そのパスで表されるエンドポイントについてはテスト実行時にバリデーションをスキップします。

もし仮に使っている人がいれば、なにかおかしいところがあったら教えてください。

"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(原文ママ)

アソシエーションのscopeを使いつつLIMITっぽいことをやる

細かすぎて伝わらないかもしれませんが、ハマったので共有します。

問題

ユーザーが免許を持つ次のようなモデルを考えます。has_one :licenseは免許更新を重ねてきたなかで最新の免許を取得したくて書いたと思ってください。

class User < ApplicationRecord
  has_many :licenses
  has_one :license, -> { order(created_at: :desc).limit(1) }
end

「Userが2件のLicenseを持つ」というセットを3件作って、それぞれのUserの最新のLicenseが取りたいので、licenseをeager loadしてlicenseをそれぞれのUserに対して呼んでみると、次のような結果になります。

User.includes(:license).map(&:license)
#=> [nil,
#    nil,
#    #<License:0x00007fb67bfbbbf0
#     id: 6,
#     code: "4934",
#     user_id: 3,
#     created_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00>]

それぞれのUserの最新のLicenseが取りたかったのですが、nilが入っており期待と違う結果になりました。このときにActive Recordが生成するクエリは次のような感じです。licensesテーブルの該当レコードをすべて取得したあとにLIMITしてしまうので、該当レコードすべての中の最新レコード1件しか取れていないようです。

SELECT "users".* FROM "users"
SELECT "licenses".* FROM "licenses" WHERE "licenses"."user_id" IN (?, ?, ?) ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 1], ["user_id", 2], ["user_id", 3], ["LIMIT", 1]]

一方、次のようにlicenses(複数形)をincludesすると取得結果は正しくなりますが、ORDER BY DESCを使うクエリとそうではないクエリに分かれてしまい、N+1問題が発生してしまいます。

User.includes(:licenses).map(&:license)
#=> [#<License:0x00007fb680000288
#     id: 2,
#     code: "326",
#     user_id: 1,
#     created_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00>,
#    #<License:0x00007fb67bfe7c28
#     id: 4,
#     code: "8028",
#     user_id: 2,
#     created_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00>,
#    #<License:0x00007fb67bfdf4d8
#     id: 6,
#     code: "4934",
#     user_id: 3,
#     created_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00>]
SELECT "users".* FROM "users"
SELECT "licenses".* FROM "licenses" WHERE "licenses"."user_id" IN (?, ?, ?)  [["user_id", 1], ["user_id", 2], ["user_id", 3]]
SELECT  "licenses".* FROM "licenses" WHERE "licenses"."user_id" = ? ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
SELECT  "licenses".* FROM "licenses" WHERE "licenses"."user_id" = ? ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 2], ["LIMIT", 1]]
SELECT  "licenses".* FROM "licenses" WHERE "licenses"."user_id" = ? ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 3], ["LIMIT", 1]]

ある解決策

licensesのscopeでソートしつつ、インスタンスメソッド化したUser#licenseのなかでActiveRecord::Associations::CollectionProxy#takeすると、期待どおり動くようです。

class User < ApplicationRecord
  has_many :licenses, -> { order(created_at: :desc) }

  def license
    licenses.take
  end
end

License取得時はlicenses(複数形)をeager loadします。

User.includes(:licenses).map(&:license)
#=> [#<License:0x00007fb680000288
#     id: 2,
#     code: "326",
#     user_id: 1,
#     created_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00>,
#    #<License:0x00007fb67bfe7c28
#     id: 4,
#     code: "8028",
#     user_id: 2,
#     created_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00>,
#    #<License:0x00007fb67bfdf4d8
#     id: 6,
#     code: "4934",
#     user_id: 3,
#     created_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00>]

licensesに対してORDER BY DESCするクエリを1回だけ発行して、Ruby側では取得済みレコードに対してtakeでデータを取るので、N+1問題を回避できています。

SELECT "users".* FROM "users"
SELECT "licenses".* FROM "licenses" WHERE "licenses"."user_id" IN (?, ?, ?) ORDER BY "licenses"."created_at" DESC  [["user_id", 1], ["user_id", 2], ["user_id", 3]]