valid,invalid

関心を持てる事柄について

バックエンド Web API に管理画面/管理機能を追加するアーキテクチャパターン

プレゼンテーションレイヤ、いわゆるフロントエンドがクライアントサイドで実装・実行されるアーキテクチャ (注 1) において、管理画面/管理機能をあとから追加する際にどのような実装パターンがあるのかを整理してみます。

注 1: Presentation Domain Separation の実践の中でも、物理的にプレゼンテーションロジックとドメインロジックを分離しているアーキテクチャです。

用語の整理

プレゼンテーションレイヤ

三層アーキテクチャにおける、システムの利用者へユーザインターフェイスを提供する層です。本記事では"フロントエンド"とほぼ同義で使います。

OSI 参照モデルの第六層ではないです。

バックエンド Web API とは

プレゼンテーションを持たない Web API (HTTP プロトコルを用いてネットワーク越しに呼び出すアプリケーション) とします。

プレゼンテーションレイヤを担うフロントエンドには JavaScript の SPA や iOS / Android などのモバイルがいたり、IoT デバイスがいたりするやつです。

ちなみにクライアントサイドとフロントエンド、サーバサイドとバックエンドが混同されるシーンがたまに見られますが、本記事は以下の定義に従って記述します。

https://speakerdeck.com/koichik/isomorphic-survival-guide?slide=12 より引用

バックエンド Web API の例をサーバサイドの Web application framework と絡めて言えば、RailsAPI mode を ON にしている API サーバとか、Django REST framework 使っているやつとか。近年増えているらしき Golang でのサーバサイド開発ももっぱらバックエンド Web API である印象をうけます。

細かい事を言うと、人間向けではなく対システム向けに構造化されたデータを出力する API の場合、構造化データの生成部分をプレゼンテーションレイヤと考えることもありますが、簡単のため本記事の埒外としておきます。

管理画面/管理機能

管理画面/管理機能とは、Web API が接続するデータストアの内容を参照/更新するアプリケーションを指します。事業の顧客ではなく内部ステークホルダーが利用するものです。

余談ですが、管理画面というとどうしてもブラウザで閲覧する Web アプリケーションやデスクトップアプリケーションぽいイメージがつきまとう気がしていますが自分だけでしょうか。iOS / Android プラットフォームで動く native apps でもよいしなんなら CLI ツールでも Slack app でもよいわけで、ざっくり管理機能と呼んだほうが適切かなと思っています。

追加する、とは?

新規サービスに携わったことがある方々は分かると思いますが、サービス初期は素早くユーザーに価値を提供し事業を成立させることが最優先であるため、どうしても管理画面をはじめ運営用ツールの開発は後回しになりがちです。

https://techlife.cookpad.com/entry/introduce-mart-on-call

最初は管理機能なしで API サーバとモバイルアプリだけをリリースし、運用で戦いながら管理機能を作っていくというパターン。あると思います。

管理機能がない状況ではその用途に特化した GUI アプリケーションではなく CLI か何かの開発者向けツールで DB を参照/更新することがほとんどだと思いますので、管理機能を追加する前の全体の構成としてはこんな感じになります。

アーキテクチャパターン

さて、本題の管理機能をシステムコンポーネントとして追加する実装パターンの話です。派生はいくつかありますが以下の 3 パターンが思いつきます。

  1. バックエンド API サーバにプレゼンテーションレイヤを追加する
  2. バックエンド API サーバを利用するクライアントアプリケーションを作る
  3. データストアにアクセスする別アプリケーションを作る

これから個々に説明していきます。予め言っておくと単一にして絶対な正解などないので状況や文脈にあわせて選択するものだと考えています。

1. バックエンド API サーバにプレゼンテーションレイヤを追加する

バックエンド API サーバに管理機能向けのプレゼンテーションレイヤを追加するパターンです。以下の図では管理機能向けのフロントエンドをサーバ側に足しています。

Rails でいえば ERB, Slim, Haml などの view template を使って管理機能ユーザー向けの HTML をサーバサイドで生成するイメージです。

管理機能の view をモバイルで提供する場合には、サーバサイドで生成された HTML を WebView でレンダリングすることになります。 (view をクライアント側に実装するなら後述の 2 のパターンになるため)

Good: 実装コストが抑えられる

おおよその場合には最小コストで実装できるパターンだと思います。

  • たいていの Web application framework には template engine やドメインロジックと view を繋ぐ便利 helper が用意されている
  • 元々のバックエンド developer のスキルセットで対応しやすい
  • CI/CD、インフラ等々の変更が少ない

また、Web application framework を持つような言語には大抵 admin 系のライブラリが用意されています (Rails Admin, administrate, active admin, django admin, Go admin etc.)。バックエンド開発者のスキルの延長線上の技術スタックを使いつつ、HTML/CSS マークアップも含めて開発工数を削減できるこれらのライブラリを素直に利用できるのはこのパターンのみでしょう。

ただ、admin 系のライブラリはブートストラップの局面では便利ですが、デフォルトの機能やデザインをカスタマイズしたくなったり、機能の一部を分離したくなったときに足かせになったり、拡張性・保守性の問題に高確率で向き合うことになります。そのため、この点についても Bad と捉える方もいるかもしれません。

Bad: Presentation Domain Separation のメリットを失う

最も大きい難点は物理的に Presentation Domain Separation が実現できているアーキテクチャを崩すことになる点です。

ドメインロジックに集中していた Web API に view 関連のコードが入り込むので、論理的な分割が必要になります。たいていフォルダなどのレベルではレイヤがきちんと分けられると思います。

しかし view 向けのロジックがモデル層に入り込んだり、view 向けには想定していなかったロジックがうっかり再利用される事態を完全に排除するのは難しいです。フレームワーク自体が密結合な設計・実装を推すようなつくりとなっている場合はなおさらです。論理的な分割を維持し続けるにはプログラマとチーム両方の練度が求められると感じます。

さらに Presentation Domain Separation で得られるメリットの裏返しで、テスタビリティの低下、プレゼンテーションのための依存関係またはプレゼンテーションのテストのための依存関係の追加の必要も発生します。

その他にも管理機能向けにリッチなフロントエンドの要求が生じてくると view template やフレームワーク標準のフロントエンドのサポートでは厳しくなってきます。

密結合を意識的に選択できるならあり

とはいえこれまで何度も議論が繰り返されているように「密結合=悪、疎結合=善」というわけではありません。管理機能に何を求めるかは事業・プロダクトの性質次第ですし、プレゼンテーションとの密結合、顧客向け機能周辺のコードとの密結合を意識的に選択できるならありな選択肢です。

人類は密結合を求めている

分業不要な規模でかつ性能問題が無い ならば常に密結合を選んできたのが人類です。 https://zenn.dev/koduki/articles/3f5215f2a79843#%E4%BA%BA%E9%A1%9E%E3%81%AF%E5%AF%86%E7%B5%90%E5%90%88%E3%82%92%E6%B1%82%E3%82%81%E3%81%A6%E3%81%84%E3%82%8B


密結合にしても問題になりにくい状況だったので、これと引き換えにスタートアップでは最も重要な開発速度を出せる設計にした

https://speakerdeck.com/yasaichi/what-is-ruby-on-rails-and-how-to-deal-with-it?slide=41

2. バックエンド API サーバを利用するクライアントアプリケーションを作る

バックエンド API にプレゼンテーションレイヤを持たせず、管理機能専用のフロントエンドを作るパターンです。

先述の通り管理機能向けフロントエンドのプラットフォームを限定する必要はないのですが、現実的には Web ブラウザであることが大半だと思います。一般的な理由は以下です。

  • モバイルアプリのようなリッチな体験が求められるシーンが少ない
  • モバイルアプリ開発のほうが開発・運用コストがかかりがち (特にマルチプラットフォームの場合に顕著)
  • 技術スタックがバックエンドと分離する可能性が高い

管理機能用のネイティブアプリを開発するぐらいなら事業のコアである顧客向け機能の開発にネイティブエンジニアの力を注ぎたい、というのは当然ですね。

なので以下のメリット/デメリットは、Web ブラウザで動作する Single Page Application (以下 SPA) を作ることを前提として話を進めます。

Good: Presentation Domain Separation の維持

パターン 1 とは対象的に、Presentation Domain Separation を維持できます。バックエンド API は複数のフロントエンドを持つことになりますが、プレゼンテーション層のコードが入り込むことはありません (アクロバティックな実装をすればもちろん可能ですが)。

プレゼンテーション関連の依存関係の追加も必要ありません。

SPA 開発は高コストという言説が一時期見られた気もしますが、実態としては年々かんたんになっていると個人的に思っています。特に管理画面の作成に限って言えばその用途に特化したフレームワークが存在しており、それらを利用するならば、(コンシューマ向けのリッチな体験を求められるアプリケーションに比べて)かなり低い学習コストで機能を開発できるようになっています。React AdminVue Element Adminです。

フロントエンドの admin 系ライブラリはバックエンド発祥の admin 系ライブラリに比べてフロントエンドのプラクティスの詰め込みが充実しているのが気に入っています。Optimistic UI, Partial loading, Undo, Back forward cache 等々、あると便利だけど自前で書くとすこし面倒な実装パターンたちも、フレームワークのレールに沿って書くだけで動くものができます。リッチな体験は不要かもしれませんが実質無料でついてくるのであればあるに越したことはありません。

HTML/CSS マークアップを頑張る必要がほぼないというのはパターン 1 と同じです。強いて言うなら、SPA ベースのフレームワークのほうが Material UI のようなモダンな UI をデフォルトで提供しています。

管理画面のことをダッシュボードと呼ぶこともあり、サービスのメトリクスを図やグラフで良い感じに表示することもあるかもしれません。その際にフロントエンドの資産を使えるとだいぶやりやすいです。

Bad: システムコンポーネント / レポジトリの増加によるオーバーヘッド

SPA 開発のコストはさほど高くないとは言いつつ、単一アプリケーション、単一コードベースにすべて詰め込むのに比べれば見過ごせないオーバーヘッドが生じます

フロントエンドアプリケーション用のデプロイパイプラインや CI/CD が必要ですし、フロントエンドとバックエンドを統合的に検証する E2E テストも整備が難しくなります (必要か?というのは別の議論)。

新たな管理機能を足す際にはバックエンド API とフロントエンド SPA の両方に手を加えなければならないので、monorepo でない場合は pull request を複数作らないといけない(ちょっとしたことに思えますが毎日繰り返すと馬鹿にできないコストです)。コンシューマ向けのモバイルアプリに比べれば可愛いものですが API後方互換性の面倒も見ないといけません。

チームの技術ポートフォリオに合致するならあり

SPA に代表されるようなリッチフロントエンドの経験を持つチームメイトがいない場合や、JavaScript を中心としたフロントエンド領域がチームの技術ポートフォリオに存在しない (今後も投資しない) 場合は採用が難しいパターンかもしれません。

SPA 開発の総合的な学習コストは下がりつつあるとはいえビルドシステムやデプロイパイプライン周りの面倒を見る必要はありますし、単一アプリケーションをデプロイメントするのに比べたら開発の手数や考えなければならないことは増えます。

そのあたりを呑み込んでやっていけるのであれば個人的には推したいパターンです。


バックエンド API の前段に API ゲートウェイを置いて SSR させたりバックエンド API との仲介をさせるパターンも思いつきましたが、既存の API をバックエンドとして据えつつ新たにフロントエンドを外側に構築するという点でパターン 2 の亜種と考えています。

3. データストアにアクセスする別アプリケーションを作る

管理機能に求められるのは顧客向け機能のデータストアの参照/更新なので既存のバックエンド API を解する必要はない、という視点に立てばバックエンドを担う別 API サーバを置けばよいのでは?という発想もできます。

派生案としてさらにフロントエンドを分割するなど。

Good: ドメインロジックの分離

パターン 1 でもパターン 2 でも、バックエンド API の中で顧客機能向けと管理機能向けのコードが同居するのを防ぐのはなかなか難しかったりします。

管理機能追加以前から Repository パターンにより永続化層を切り出していて管理機能向けのドメインロジックも独立して書けるようになっているとか、両ドメインをまたぐメソッド呼び出しを禁止するとか、モジュール分割を適切に行っていれば話は違うかもしれません。が、管理画面を後追いで作るほどに顧客への価値提供を優先している事業の開発で、初手から手の込んだ (悪く言えば過剰な)設計をしている可能性はあまり高くないと思います。

バックエンドのアプリケーション自体を分離することでドメインロジックの分離を強制できます。

Bad: Distributed monolith

Distributed monolith であり、すでに知られている多くの問題が生じます

一枚岩のシステムをネットワーク上にばらまいて、分散システムと同様のコストを払いながら、マイクロサービスの利点は享受できないという状態だ。異なる技術を組み合わせてサービスを構成できるという利点も失われ、また、組織や技術の疎結合化もできない。権威が許可なしでチームが技術的進歩を推進することができなくなるのだ。

https://www.infoq.com/jp/news/2016/03/services-distributed-monolith/

実装当初の要求次第では「とりあえず参照だけなら…」と手を出したくなるかもしれませんが、将来に渡って更新を一切行わない管理機能はほぼありえないと思います。

やがては更新系の機能が求められ、管理機能のためのデータモデル・テーブルが追加され、その際に Distributed monolith によるデータストアのスキーマ共有やスキーマ互換性などの問題が生じます。

利点としてドメインロジックの分離が可能と書きましたが、そもそも顧客向け機能と管理機能の土台となるドメイン (解決したい問題領域) は一緒なのではと思います。サブドメインや解決領域で差があれどコアドメインが一緒ならドメインモデルやそれを表現するコードも両者で再利用されるはず。

手のひらを返すようですが、結局似たような重ビジネスロジックが両者に散らばるのであれば分離できて嬉しいものはなんだろう…という話になってしまいます。

派生案: マイクロサービス

派生として、データストアに直接アクセスせずに既存のバックエンド API を介してデータを参照/更新させることもできます。管理機能専用のデータについては必要に応じて専用のデータストアを持つかもしれません。

distributed monolith ではなくマイクロサービスアーキテクチャに寄せた形です。ビジネスロジックの再利用ができる、スキーマ共有問題などが起きないなどのメリットがあるだけこちらのほうがだいぶマシではありますが、管理機能を作るためだけにマイクロサービスの労苦を背負うのは高コストすぎないかと感じます。

その他 補足

バックエンド API を使い回すパターン 1, 2 のどちらにしても顧客向けと管理機能向けでアプリケーションのプロセスは分けておいたほうが良いと思います。両者で使われ方や負荷傾向が異なるので個別にコントロールしたくなるんじゃないかな。

アプリケーションのコードベースを単一にしつつモジュールの境界を作るテクニックは言語やフレームワークによって異なると思いますが、Rails であればShopify のモジュラモノリスの例新規サービスの管理画面を短期間で見栄え良く実装するで紹介されているように mountable engine を使う方法があります。最低でも namespace は区切っておきたいところ。

また、顧客が管理機能向けのエンドポイントにアクセスできないようルーティングや権限まわりの制御も行う必要があります。

おわりに

先述の通りどれが正解と言いたいわけではないのでオチもないのですが個人的な体験の話をすると、直近のプロジェクトではパターン 2 を選択しています。最初は有用性を疑っていたフロントエンドの admin 系フレームワークについてもだいぶ評価を改めたので、その感想も近々書きたいと思います。

管理画面を"後追い"で足すという開発ロードマップは新規事業やスタートアップでは多々あると思うのですが、その際のアーキテクチャパターンについて語られることが多くないと感じていたので整理してみました。

参考記事


This article is for ohbarye Advent Calendar 2020.

壊れたルーティングを検出する route_mechanic gem と、その内部実装の話

壊れたルーティングの検出、routing specを自動化するroute_mechanic gem を作って公開しました。この gem の紹介と内部実装の話を書きます。

rubygems.org

背景

Rails 開発者のうちの N% は、Rails application のルーティングを検証するために以下のようなコードを書いたことがあるかもしれません。

Rails が提供する assertions を使うなら:

assert_routing({ path: 'photos', method: :post }, { controller: 'photos', action: 'create' })

rspec-rails なら:

expect(:get => "/articles/2012/11/when-to-use-routing-specs").to route_to(
  :controller => "articles",
  :month => "2012-11",
  :slug => "when-to-use-routing-specs"
)

はい。

検証したいことはわかるが、ルーティングを増やすたびに似たようなコードをほぼコピペで足していく作業は苦痛でやりたくない。似たようなコードをほぼコピペしているなら自動化できるのでは?ということで試みた顛末が本稿です。

※ そもそも routing specs は必要か?という論点については後述します。

RouteMechanic

できたものがroute_mechanic gem です。

github.com

gem を足したあとにテストを 1 つ書いておけば、RouteMechanic がぶっ壊れたルーティングを検出します。

gem を install し、RouteMechanic が提供する have_valid_routes matcher / assertion を使ったテストを 1 つ足すだけです。

RSpec の例

RSpec.describe 'Rails.application', type: :routing do
  it "fails if application does not have valid routes" do
    expect(Rails.application).to have_valid_routes
  end
end

miniTest の例

class RoutingTest < Minitest::Test
  include ::RouteMechanic::Testing::Methods

  def test_that_application_has_correct_routes
    assert_all_routes
  end
end

どんな風に検出するか?

上述の RSpec の例で、テスト失敗時には以下のようなエラーメッセージを出力します。

  0) Rail.application fails if application does not have valid routes
     Failure/Error: expect(Rails.application.routes).to have_valid_routes

       [Route Mechanic]
         No route matches to the controllers and action methods below
           UsersController#unknown
         No controller and action matches to the routes below
           GET    /users/:user_id/friends(.:format) users#friends
           GET    /users(.:format)                  users#index
           DELETE /users/:id(.:format)              users#destroy
     # ./spec/rspec/matchers_spec.rb:8:in `block (2 levels) in <top (required)>'

1 examples, 1 failure, 0 passed

RouteMechanic は 2 種類の「壊れたルーティング」を検出します。

1. Missing routes

controller と action method (controller 内の public method) があるが、routes.rbに対応する設定が記述されていないもの

2. Missing action methods

routes.rbで宣言しているが、対応する action method が存在しないもの

もし Missing routes と Missing action methods を個別に検証したければ以下の matchers, assertions を使うこともできます。

RSpec.describe 'Rails.application', type: :routing do
  it "fails if application has unused action methods" do
    expect(Rails.application).to have_no_unused_actions
  end

  it "fails if application has unused routes" do
    expect(Rails.application).to have_no_unused_routes
  end
end
class RoutingTest < Minitest::Test
  include ::RouteMechanic::Testing::Methods

  def test_that_application_has_no_unused_actions
    assert_no_unused_actions
  end

  def test_that_application_has_no_unused_routes
    assert_no_unused_routes
  end
end

routing spec は必要か?

合理的な質問です。大部分の Rails 開発者は request spec を書いていて、routing spec を書いたり自動化することに何の価値があるのか疑問に感じているのではと推察します。

個人的にも request spec や system test のようなレイヤでルーティングのテストをカバーできているのであれば routing spec は不要と考えています。

RSpec Rails のサイトでも以下のように述べられています。

Simple apps with nothing but standard RESTful routes won't get much value from routing specs, but they can provide significant value when used to specify customized routes, like vanity links, slugs, etc.

https://relishapp.com/rspec/rspec-rails/docs/routing-specs

RouteMechanic が有用なシーン

routing spec 自体について概ね不要との見解を持ちつつも、RouteMechanic が有用なシーンをいくつか思いつくことができます。

1. プロジェクトが古くなってきて、どのルートが生きていてどのルートが死んでいるのか誰にもわからなくなってきたとき

RouteMechanic でデッドコードを検出することができます。

2. アプリケーションに十分な request spec (それどころか controller spec も) がないとき

ルーティングが有効であることを確認するためのテストを増やすための良い出発点になります。個別にテストを足すのに比べたら RouteMechanic の導入のほうが容易です。

3. routes.rb の大きなリファクタリングをしようとしているとき

すべての request / controller specs を実行するのには時間がかかるので、routing のみをテストできれば小さいサイクルでの検証が可能になります。

Rails6.1 でroutes.rbをファイル分割できるようになったので、リファクタリングの機会はありそうです。*1

4. 何かの圧力で routing spec を書かざるを得ないとき

そういうこともあるでしょう

内部実装の話: どうやって「壊れた routes」を検出するか

RouteMechanic がどうやって Missing routes と Missing action methods を検出しているか?について、余談です。

あらためて言葉の定義を再掲します。

1. Missing routes

controller と action method (controller 内の public method) があるが、routes.rbに対応する設定が記述されていないもの

2. Missing action methods

routes.rbで宣言しているが、対応する action method が存在しないもの

基本的には「controller で定義している action methods の一覧」と「routes で定義されたルーティングの一覧」を比較しているだけなので、両者をどのように得ているかを説明します。

controllers, action methods の一覧

まずApplicationController.descendantsにより、ApplicationControllerを継承している class の配列を得ます。

:memo: ただしRAILS_ENV=testでは eager_load がデフォルトではfalseになっているのでテストの実行順序によってはApplicationController.descendantsは空の配列になってしまいますので、eager_load ぽいことを事前にやっておきます。

def eager_load_controllers
  load_path = "#{Rails.root.join('app/controllers')}"
  Dir.glob("#{load_path}/**/*.rb").sort.each do |file|
    require_dependency file
  end
end

controller class が得られれば action methods はかんたんに得られます。

controllers.each do |controller|
  controller.action_methods.each |action_method|
  end
end

routes で定義されたルーティングの一覧

まずRails.application.routesActionDispatch::Journey::Route (個別の route の情報を持っているオブジェクト) の配列を得ます。routes が読み込まれていなければ reload しておきます。

load_path = "#{Rails.root.join('config/routes.rb')}"
Rails.application.routes_reloader.paths << load_path unless Rails.application.routes_reloader.paths.include? load_path
Rails.application.reload_routes!

しかしながらこのままでは internal なものや、Rails がデフォルトで追加しているルーティングも含まれてしまいます。あくまでroutes.rbで定義されたものを対象にしたいのでこれらを除外しておきます。

def target_routes
  Rails.application.routes.routes.reject do |journey_route|
    # Skip internals, endpoints that Rails adds by default
    # Also Engines should be skipped since Engine's tests should be done in Engine
    wrapper = RouteWrapper.new(journey_route)
    wrapper.internal? || !wrapper.defaults[:controller] || !wrapper.defaults[:action] || wrapper.path.start_with?('/rails/')
  end
end

これで routes で定義されたルーティングの一覧が得られました。

比較処理

両者の一覧の比較処理は特筆すべきところなく、それぞれを iterate してもう一方に存在しないルーティングをエラーとして記録していくだけです。

def collect_unused_actions_errors(report_error)
  @controllers.each do |controller|
    controller_path = controller.controller_path
    controller.action_methods.each do |action_method|
      journey_routes = @routes.select do |route|
        route.defaults[:controller].to_sym == controller_path.to_sym && route.defaults[:action].to_sym == action_method.to_sym
      end

      if journey_routes.empty?
        @unused_actions_errors << { controller: controller, action: action_method } if report_error
      else
        wrappers = journey_routes.map { |r| RouteWrapper.new(r) }
        @controller_routes.concat(wrappers)
      end
    end
  end
end

def collect_unused_routes_errors(report_error)
  @routes.each do |journey_route|
    wrapper = RouteWrapper.new journey_route
    @config_routes << wrapper

    matched_controller_exist = @controller_routes.any? do |w|
      wrapper.controller == w.controller && wrapper.action == w.action && wrapper.path == w.path
    end

    @unused_routes_errors << wrapper if !matched_controller_exist && report_error
  end
end

念の為assert_generates

上述の処理で Missing routes も Missing action methods も検出できるのですが、さらに、テスト対象のルーティングが valid なものかどうかをassert_generatesで検証しています。

assert_generatesRails が提供する assertion で、与えた options で期待した URL が生成されるかを検証するものです。

# こんな定義がroutes.rbにあれば
get '/photos/:id', to: 'photos#show'

# こんな感じで検証する
assert_generates '/photos/1', { controller: 'photos', action: 'show', id: '1' }

ルーティングの制約を満たす valid な options を自動生成する、というのが地味に厄介なところでした。

上述の:idのような例であればてきとうに'1'とか与えておけばよいのですが、現実世界のroutes.rbには以下のように正規表現で制約を設けていたりします。

例えば以下の例では:locale'en''ja'でなければならないし、:idは大文字のアルファベット+ 5 桁の数字でなければいけません。

scope ':locale', locale: /en|ja/ do
  get '/photos/:id', to: 'photos#show', constraints: { id: /[A-Z]\d{5}/ }
end

正規表現からマッチする文字列を生成する… 想像しただけでけっこう骨が折れそうだったのですがさすが Ruby のエコシステム、すでに要求を満たす gem が存在しました。

regexp-examples を使うことで事なきを得ました。以下のコードにより、assert_generatesテストの自動生成ができました。

# @param [RouteMechanic::Testing::RouteWrapper] wrapper
# @raise [Minitest::Assertion]
def assert_routes(wrapper)
  required_parts = wrapper.required_parts.reduce({}) do |memo, required_part|
    dummy = if wrapper.requirements[required_part].is_a?(Regexp)
              wrapper.requirements[required_part].examples.last
            else
              '1'
            end
    memo.merge({ required_part => dummy }) # Set pseudo params to meets requirements
  end

  base_option = { controller: wrapper.controller, action: wrapper.action }
  url = routes.url_helpers.url_for(
    base_option.merge({ only_path: true }).merge(required_parts))
  expected_options = base_option.merge(required_parts)

  assert_generates(url, expected_options)
end

先行事例

作成前に検討した先行事例たちです。

いずれも内部実装は参考にさせてもらいつつ、ユースケースを絞り込むのにも役立たせてもらいました。

traceroute

  • やりたいことに最も近かったが、rake task ではなく RSpec や minitest で検証したかった

regressor

テストを自動生成する gem です。

  • controller 起点なので Missing action methods を検出できない
  • RAILS_ENV=test rails generate regressor:controllerを毎回 routes を足すたびに実行しないといけない
  • shoulda-matchers, fakerに依存している
  • gem 自体に routing spec 以外の用途もあるので too much
  • [minor] RSpec 限定

route_tractor

  • dead project
  • routes 側から検証しているので Missing routes を検出できない
  • rake task ではなく RSpec や minitest から検証したかった

ruby-jp

開発の中途で悩んでいるとき、ruby-jp Slack コミュニティに助言やフィードバックを貰えて大変助かりました。

2020年9月ぐらいに話していたのでログは既に流れてしまったかと思いますが、改めて感謝の意を示させてください。

おわり

フィードバックやバグレポートやスターお待ちしています。

github.com


This article is for ohbarye Advent Calendar 2020.

React AdminのExporter type definitionを修正した

React AdminにはExporterという機能がある。

Exporter feature

https://marmelab.com/react-admin/doc/3.10/List.html#exporter にならってjsonexportのようなライブラリと組み合わせれば数行〜数十行のコードでリストをCSV等で出力できる。

// in PostList.js
import { List, downloadCSV } from 'react-admin';
import jsonExport from 'jsonexport/dist';

const exporter = posts => {
    const postsForExport = posts.map(post => {
        const { backlinks, author, ...postForExport } = post; // omit backlinks and author
        postForExport.author_name = post.author.name; // add a field
        return postForExport;
    });
    jsonExport(postsForExport, {
        headers: ['id', 'title', 'author_name', 'body'] // order fields in the export
    }, (err, csv) => {
        downloadCSV(csv, 'posts'); // download as 'posts.csv` file
    });
};

const PostList = props => (
    <List {...props} exporter={exporter}>
        ...
    </List>
)

ドキュメント

型定義によれば ExporterPromise<void> を返す関数である。

# https://github.com/marmelab/react-admin/blob/9cc5e83252455b5e3e172306419431716f17a057/packages/ra-core/src/types.ts#L492-L501
export type Exporter = (
    data: any,
    fetchRelatedRecords: (
        data: any,
        field: string,
        resource: string
    ) => Promise<any>,
    dataProvider: DataProvider,
    resource?: string
) => Promise<void>;

また、jsonexportvoidを返す関数である。

# https://github.com/DefinitelyTyped/DefinitelyTyped/blob/40e88087e9ec5898250644da6e4653888905bd85/types/jsonexport/index.d.ts#L112-L116
declare function jsonexport(
    json: object | object[],
    userOptions: jsonexport.UserOptionsWithHandlers,
    cb: (err: Error, csv: string) => void,
): void;

なのでドキュメントの例のexporter関数は Exporter 型にマッチしない。Promiseでラップしてやる必要がある、というのに同僚が気付いてくれた。

ドキュメント修正

ドキュメントもpublic repositoryで管理されているのでcontribution chanceと思って修正pull requestを送ってみた。

github.com

すると、型定義がそもそも間違っているのでそっちを直してくれとのことだった。

型定義の修正

直した。次のpatch updateであるv3.11.3でshipされそうだ。

github.com

export type Exporter = (
    data: any,
    fetchRelatedRecords: (
        data: any,
        field: string,
        resource: string
    ) => Promise<any>,
    dataProvider: DataProvider,
    resource?: string
-) => Promise<void>;
+) => void | Promise<void>;

今回ドキュメントを修正する際に気付いたのだが、充実しているかに見えたReact Adminのドキュメントは2~3年前の記述が残っていたり、TypeScript時代に対応していなかったりする。

React Adminは2020年から型定義の提供や内部実装のTypeScript化を開始しており、まだ途上と思える箇所も色々見つかりそうだ。


This article is for ohbarye Advent Calendar 2020.

5分で終わるPrisma 2 Tutorial

「名前をよく聞くが実態がよくわかっていないものリスト」にいたPrismaだが、official tutorialが5分で終わるというのでやってみた。

www.prisma.io

"5分"!? と思ったがPrisma SchemaとPrisma Clientの説明が中心で、ORMの書き味を見てみる程度の内容なのでそんなものかもしれない。

補足説明を読んだりしながら進めたので5分以上かかったが、まったく追っていないJS/TS界のORMの進化をキャッチアップできてだいぶ面白かった。

学んだこと

Prisma 2はただのORM

まず、Hasuraと同列のプロダクトだと誤解していたけど違っていた。Prisma 2はただのORM

同列に語られがちだったのはどうやら今はメンテナンスモードに入っているPrisma 1の頃の話で、GraphQL DSLでデータモデルを定義したり、GraphQL APIサーバとしてCRUDする機能があったかららしい。

Prisma 1の時代のHasura official blogの記事Hasura vs Prisma (2018年10月) にも以下の記述がある。

Over the last few weeks, many people have asked me what the difference between Hasura & Prisma is.

Prismaのコンセプト

Prisma 2はどういうコンセプトなのか?を知るには以下のページが最もわかりやすかった。

Why Prisma? Comparison with SQL query builders & ORMs | Prisma Docs

Prisma 2のトッププライオリティは開発者の生産性へのフォーカス。その中には型安全性やエディタでの入力補完なども含まれる。

開発者はSQLそのものではなく、機能を開発するために必要なデータモデルを考えるべき。そういう意味では生SQL書くのも、SQL起点でデータを考えなければならないクエリビルダもPrisma的には生産性が低い。

Prisma自身も含めて)ORMは高い生産性をうむがobject-relational impedance mismatchの問題がある。「リレーショナルデータは簡単にオブジェクトにマッピングできる」という間違った前提に基づくと、オブジェクト指向では自然なコードがN+1のような問題をかんたんに引き起こしてしまう。

Prismaはcommon antipatternやpitfallを避けるための適切な制約を設けることで従来のORMに比して高い生産性を生むという、スタンス。

image

https://www.prisma.io/docs/concepts/overview/why-prisma より引用

適切な制約の一例はチュートリアルにも現れていたので後述する。

Prismaの構成要素

Prismaのプロダクトは以下の3つで構成されている。すべてをオールインワンで提供しているわけではなく使いたいものを選んで使う。

  • Prisma Client
    • Prisma Schemaに基づいて自動生成されるtype-safeなquery builder
    • Go実装もあるがEarly Accessという位置づけ
  • Prisma Migrate (preview)
    • Prisma Schemaに基づいてdatabase migrationを行えるツール
  • Prisma Studio
    • DBのviewer / editor

What is Prisma? (Overview) | Prisma Docs

Prisma Schema

Prismaのすべての中心。

モデルを定義するデータモデリング言語であり、データソースの定義やgeneratorの定義も含む。

データソースにはPostgreSQL, MySQL, SQLiteが使える。SQL Serverもすでにpreviewが出ているのでそのうち対応が完了しそう。

環境によってはハイライトされないが、VSCodePrisma extensionを使うと良い感じ。

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  published Boolean @default(false)
  author    User?   @relation(fields: [authorId], references: [id])
  authorId  Int?
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[]
}

Database features matrix (Reference) | Prisma Docsを見ながらこのようなDSLガリガリ書いていく。DBの機能としては存在するがPrisma Schemaではまだ対応していない機能もあるので留意する。

Prisma Client

こんな感じのコードを書く。

import { PrismaClient } from "@prisma/client"

const prisma = new PrismaClient()

async function main() {
  const allUsers = await prisma.user.findMany()
  console.log(allUsers)
}

main()
  .catch(e => {
    throw e
  })
  .finally(async () => {
    await prisma.$disconnect()
  })

ORMとして期待する通りこんな配列が得られる。

[
  { id: 1, email: "sarah@prisma.io", name: "Sarah" },
  { id: 2, email: "maria@prisma.io", name: "Maria" },
]

面白いことに、PrismaClientの返すデータにはすべて、発行するクエリに応じた適切な型が付いている。

(推論してくれるので自前で書く必要はないが)Prisma Schema DSLで定義したモデルをimportできる。

いわゆるeager load的に、関連するテーブルのオブジェクトを取得したときはどうなるだろうか。

const allUsers = await prisma.user.findMany({
  include: { posts: true },
});

これもちゃんとIntersection Typesとして型がつく。(User & { posts: Post[] })[] のような感じ。

select *をやめてcolumnを指定すると

const allUsers = await prisma.user.findMany({
  select: { id: true, email: true },
});

column nameでPickした型になる。

こりゃあすげぇ…!

型定義の出力場所

このすげぇ型たちはいったいどこにいるのかと調べたら、node_modules/.prisma/client/index.d.tsにいた。 ユーザーが定義したDSLに基づく成果物をnode_modules配下に吐くこともあるのか。

読めないことはないがなかなか厳しい、というか、開発者が頻繁に読むものではない。

prisma.user.findManyの後ろにはこんなのが控えている。

  export type UserGetPayload<
    S extends boolean | null | undefined | UserArgs,
    U = keyof S
      > = S extends true
        ? User
    : S extends undefined
    ? never
    : S extends UserArgs | FindManyUserArgs
    ?'include' extends U
    ? User  & {
    [P in TrueKeys<S['include']>]: 
          P extends 'posts'
        ? Array < PostGetPayload<S['include'][P]>>  : never
  } 
    : 'select' extends U
    ? {
    [P in TrueKeys<S['select']>]: P extends keyof User ?User [P]
  : 
          P extends 'posts'
        ? Array < PostGetPayload<S['select'][P]>>  : never
  } 
    : User
  : User

上述のたった少しのPrisma Schemaに対してこの雰囲気の型定義が3,000行。これを出力するPrismaのengineの実装はすごいことになっていそうだ。

Prisma Clientが課す制約

上述の型を見てわかるようにPrisma Clientが返すのはただのオブジェクトである。クラスのインスタンスではないのでモデルに関する操作を持つことはできずfat modelを作れないし、associationを辿ってN+1を発生させることはできない。

// posts を include していないので返り値は `User[]`
const allUsers = await prisma.user.findMany(); 

allUsers.forEach((user) => {
  // 型エラーになる
  // Property 'posts' does not exist on type 'User'.
  console.log(user.posts);
})

common antipatternやpitfallを避けるための適切な制約を設ける、という思想の一端が見える。

感想

やはり型の生成がすさまじく便利そうだ。

チュートリアルをやるときもあえてコピペではなく写経をしてみたのだが、入力補完や型チェックなどの支援が心強い。

複雑なクエリをどこまで組み立てられるのか?とか気になる点はまだあるが、Node.js + TypeScriptでサーバサイドアプリケーションを書く機会があれば積極的に検討してみたい。


This article is for ohbarye Advent Calendar 2020.

Rubyでemojiとcodepointsの変換

emoji から codepoints に変換

"👍".unpack("U*")
=> [128077]

"👍".codepoints
=> [128077]

# Convert to hexadecimal
"👍".each_codepoint.map {|n| n.to_s(16) }
=> ["1f44d"]

codepoints から emoji に変換

[128077].pack("U*")
=> "👍"

0x1f44d.chr('UTF-8')
=> "👍"

"\u{1f44d}"
=> "👍"

環境

Ruby 3.0.0

rubocopのNaming/MethodParameterName, Naming/BlockParameterNameで警告の指摘箇所がずれているのを修正してみた

ActiveRecord Association Extensionとwith_optionsを併用するとrubocop-railsのRails/HasManyOrHasOneDependent警告が出るので修正してみた - valid,invalid でrubocopの中身に少し興味を持ったのでシュッと倒せそうなissueがないかを拙作の goofi で検索してみたところ、過去に遭遇したことあるバグを見かけたので対応してpull requestを送ってみた。

事象

issueはこれ Faulty calculation in UncommunicativeName · Issue #8229 · rubocop-hq/rubocop · GitHub

Naming/MethodParameterName, Naming/BlockParameterName copsで警告する対象に _*といったprefixが付いていると警告の表示位置がずれる…、実際の挙動を見たほうがわかりやすい。

以下のファイルがあるとする。

def test1(_a); end

def test2(__a); end

def test3(*a); end

def test4(**a); end

def test5(**__a); end

rubocopを実行すると、警告箇所を示すlocation ^ が引数名の全体にかかっていないことがわかる。

$ rubocop test.rb
Inspecting 1 file
C

Offenses:

test.rb:3:11: C: Naming/MethodParameterName: Method parameter must be at least 3 characters long.
def test1(_a); end
          ^
test.rb:5:11: C: Naming/MethodParameterName: Method parameter must be at least 3 characters long.
def test2(__a); end
          ^
test.rb:7:11: C: Naming/MethodParameterName: Method parameter must be at least 3 characters long.
def test3(*a); end
          ^
test.rb:9:11: C: Naming/MethodParameterName: Method parameter must be at least 3 characters long.
def test4(**a); end
          ^
test.rb:11:11: C: Naming/MethodParameterName: Method parameter must be at least 3 characters long.
def test5(**__a); end
          ^

1 file inspected, 5 offenses detected

本来はこうあるべき。

$ rubocop test.rb
Inspecting 1 file
C

Offenses:

test.rb:3:11: C: Naming/MethodParameterName: Method parameter must be at least 3 characters long.
def test1(_a); end
          ^^
test.rb:5:11: C: Naming/MethodParameterName: Method parameter must be at least 3 characters long.
def test2(__a); end
          ^^^
test.rb:7:11: C: Naming/MethodParameterName: Method parameter must be at least 3 characters long.
def test3(*a); end
          ^^
test.rb:9:11: C: Naming/MethodParameterName: Method parameter must be at least 3 characters long.
def test4(**a); end
          ^^^
test.rb:11:11: C: Naming/MethodParameterName: Method parameter must be at least 3 characters long.
def test5(**__a); end
          ^^^^^

1 file inspected, 5 offenses detected

修正

Naming/MethodParameterName, Naming/BlockParameterName copsでincludeしているUncommunicativeName moduleにて、引数名の長さの計算が誤っていたようだ。

def check(node, args)
  args.each do |arg|
    # Argument names might be "_" or prefixed with "_" to indicate they
    # are unused. Trim away this prefix and only analyse the basename.
    name_child = arg.children.first
    next if name_child.nil?
    full_name = name_child.to_s
    next if full_name == '_'
    name = full_name.gsub(/\A(_+)/, '')
    next if allowed_names.include?(name)

    # name.size が location の range を決めている
    # name は prefix を除いた文字列
    range = arg_range(arg, name.size)
    issue_offenses(node, range, name)
  end
end

lengthを愚直に再計算して渡すようにした。

def check(node, args)
  args.each do |arg|
    # Argument names might be "_" or prefixed with "_" to indicate they
    # are unused. Trim away this prefix and only analyse the basename.
    name_child = arg.children.first
    next if name_child.nil?
    full_name = name_child.to_s
    next if full_name == '_'
    name = full_name.gsub(/\A(_+)/, '')
    next if allowed_names.include?(name)

-   range = arg_range(arg, name.size)
+   length = full_name.size
+   length += 1 if arg.restarg_type?
+   length += 2 if arg.kwrestarg_type?

+   range = arg_range(arg, length)
    issue_offenses(node, range, name)
  end
end

rubocopへのpull requestは初めてだったがシュッとマージしてもらえて良かった。

環境

  • rubocop 1.8.0

This article is for ohbarye Advent Calendar 2020.

ActiveRecord Association Extensionとwith_optionsを併用するとrubocop-railsのRails/HasManyOrHasOneDependent警告が出るので修正してみた

ActiveRecord Association extensionsでメソッドを追加する - valid,invalid で書いたActiveRecord Association Extensionだが、with_optionsと併用するとrubocop-railsRails/HasManyOrHasOneDependent copに警告されることがわかった。

with_options dependent: :destroy do
  has_many :foo do
  end
end
with_options dependent: :destroy do
  has_many :foo do
  ^^^^^^^^ Specify a `:dependent` option.
  end
end

原因調査

disableしてもよいのだがどのような原理で警告が出るのか気になったので調べて修正してみた。

github.com

対象のcopの実装を読みつつprint debugすると、Association Extensionのblockの存在有無によってS式の構造が変わることが原因のようだった。

まず、警告が出ないblockなしのケース。

with_options dependent: :destroy do
  has_many :foo
end
s(:block,                      # <= node.parent
  s(:send, nil, :with_options,
    s(:hash,
      s(:pair,
        s(:sym, :dependent),
        s(:sym, :destroy)))),
  s(:args),
  s(:send, nil, :has_many,     # <= node
    s(:sym, :foo))))

has_manyに対して:dependent optionが渡されるかどうかはnode.parentのnodeからwith_optionsを辿れば判断できる。

一方、blockありのケース。

with_options dependent: :destroy do
  has_many :foo do
  end
end

node.parentのnode下にはwith_options blockがない。

s(:block,                      # <= node.parent.parent
  s(:send, nil, :with_options,
    s(:hash,
      s(:pair,
        s(:sym, :dependent),
        s(:sym, :destroy)))),
  s(:args),
  s(:block,                    # <= node.parent
    s(:send, nil, :has_many,   # <= node
      s(:sym, :foo)),
    s(:args), nil)))

with_optionsの引数を参照するにはnode.parent.parentから辿らなければいけないのだが、node.parentが検査対象となってしまう。

# https://github.com/rubocop-hq/rubocop-rails/blob/9808efdb80078f1382da7cab8fe0b6a1917af047/lib/rubocop/cop/rails/has_many_or_has_one_dependent.rb#L64-L70
def valid_options_in_with_options_block?(node)
  return true unless node.parent

  n = node.parent.begin_type? ? node.parent.parent : node.parent

  contain_valid_options_in_with_options_block?(n)
end

修正

block付きの場合のS式にmatchしたらnode.parent.parentを参照して検証させるようにした。

+       def_node_matcher :association_extension_block?, <<~PATTERN
+         (block
+           (send nil? :has_many _)
+           (args) ...)
+       PATTERN

        def valid_options_in_with_options_block?(node)
          return true unless node.parent

-         n = node.parent.begin_type? ? node.parent.parent : node.parent
+         n = node.parent.begin_type? || association_extension_block?(node.parent) ? node.parent.parent : node.parent

          contain_valid_options_in_with_options_block?(n)
        end

rubocop-railsへの初pull requestであり雰囲気で書いているのでacceptされるかはわからない。

(2020-01-13 追記) mergeされた!

これまでrubocopの中身をちゃんと見たことがなかったので学びがあってよかった。

環境

  • rubocop 1.8.0
  • rubocop-rails 2.9.1

This article is for ohbarye Advent Calendar 2020.