valid,invalid

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

git commit message でハッシュ # から始まるメッセージを残す

git commit コマンドでメッセージを入力する際、デフォルトでは # から始まる行は commit message から取り除かれる。この挙動を変更し、commit message にハッシュ # から始まるメッセージを残す方法について。

結論

git config で設定可能な以下のいずれかで対応できる。

  1. core.commentChar# 以外の文字、または auto を指定する
  2. commit.cleanup で cleanup mode をwhitespaceに指定する

StackOverflowの以下の質問に詳しい。

stackoverflow.com

なぜ消えるのか

これは cleanup と呼ばれる message の余分な文字列を取り除く仕組みによるもの。 cleanup が持ついくつかの mode のうち、ほぼデフォルトで使用されるstrip mode では空行や行末の空白やコメント行を取り除く。

cleanup mode は git config で設定するか、commit のたびにgit commit --cleanup=<mode>で指定することができる。

--cleanup=whitespaceを指定した場合の挙動はstrip mode とほぼ同じだがコメント行は残される。

利用可能な mode はman git-commit などで確認するとよい。

ref Git - git-config Documentation

コメント行とは

core.commentChar で指定した文字から始まる行はコメント行として認識される。core.commentCharのデフォルトは#

# 以外の文字を指定することで、#から始まる行はコメント行とみなされなくなるので commit message に残すことができる。

または auto を指定すると、git-commit は既存のコミットメッセージのどの行の先頭文字でもない文字を自動的に選択するようだ。賢い。

ref Git - git-config Documentation

個人的設定

global な.gitconfigに以下をcommentChar = ";"を設定した。たぶんセミコロン;始まりの commit message を書いたことがない…はず。

autoも面白そうだけど自動的に文字が選択されるのもちょっと挙動が読めなくて、問題が起きたときに気づけず混乱しそう…と日和った。

[core]
    commentChar = ";"

環境

$ git --version
git version 2.28.0

This article is for ohbarye Advent Calendar 2020.

StackOverflowを読める程度の英語力もとい問題解決能力

6年近く前の話だが「Quipperに入社して業務をこなすにはどの程度の英語力が必要か?」という問に対し、こんな回答を貰ったのを覚えている。

技術的な課題を解くためにググってStackOverflowが出てきたときになんとか読める程度の英語力、ないしは読もうとする気概や胆力があれば大丈夫

後に自分が採用担当になった折りには同じ質問を受ける立場となり、リーディングに関してはおおむね同じ回答をしてきた。(リスニング・スピーキングは年やチームによって状況が著しく異なったので一概にこう、とは説明できなかった)

振り返れば「StackOverflowを読める程度の英語力」というのは英語運用能力の多寡というより、問題解決能力を示す良いベンチマークだなと思えた。

なお、StackOverflowはたまたま例として挙げただけなので「〇〇の公式ドキュメントを読める」など各分野における任意の主要な情報ソース源をあてはめてもらってよい。 *1

"英語で検索ができる"ということ

悪名高い自動翻訳サイトによってちょっと状況は変わってしまったが…そもそも英語で検索をしないとStackOverflowが検索結果上位に出にくい。なので「StackOverflow読むぐらいの英語力ならあります」という場合、きちんと英語で検索するスキルがまずある。*2

"英語で検索ができる"ということの背景には、日本語と英語で得られる情報量の差への意識があるといえる。英語の情報のほうが圧倒的に多く、一次情報の大部分が英語で記述されていることは今でこそ当然に思えているが、初学者や非専門家と接するとこれは自明ではなく獲得された知識であることを知覚できる。

"領域-スタック-"..."突破-オーバーフロー-"...?」みたいに見たことも聞いたこともない感じだとちょっと不安になる。

英文読解力より問題解決能力

英語読解が苦手でも自分の検索ワードにマッチしててStackOverflowでupvoteが数十付いてたら「なんか答えに近いことがあるかも」と勘付き、翻訳サービスにとりあえず放り込んでみようと思い、放り込んだが意味がわからない箇所は単語を辞書で引きつつ原文を精読して訳を作ってみる。

「そもそも自分の検索ワードが間違っていた」「より適切な英語表現があることに気づく」なんてこともあり、そのたびに検索しなおす。

この辺はわからないことを1つずつ明らかにしていくプロセスであり、Google TranslateやDeepLなど現代のツールで得られるサポートを前提とすれば、求められるのは英語読解力よりも問題解決能力に近いと思う。

初手が日本語検索でもいいけど*3、「どうにも得たい情報が得られない」とか「埒が明かないので一次情報にあたる必要がある」と自分の現在地を把握し、ゴールに向けて軌道修正できるのはメタ認知スキル


そんなわけで「StackOverflowを読める程度の英語力があればよい」というのはそれなりの問題解決能力を備えているかどうかのベンチマークとして優秀だなと改めて思った話。

関連


This article is for ohbarye Advent Calendar 2020.

*1:最近はStackOverflow役に立たないと感じることもしばしば...

*2:小ネタだがSearch engineのデフォルトを英語にしておくとなお便利

*3:かくいう自分も「これぐらい日本語で情報あるだろ」と思い、まれによく日本語でググったりもする

Rack middlewareとスレッドセーフ

Rack Middleware で以下のようなイディオムを見かけることがあるが何のためにdupしているかを理解していなかったので調べてみた。

class MyMiddleware
  def call(env)
    dup._call(env)
  end

  def _call(env)
    # do whatever
  end
end

結論

Rack middleware の instance は rack server プロセスが最初にリクエストを処理するときに initialize され、同一プロセス内では使い回されるのでスレッドセーフでない。

スレッドセーフにするには以下の方法がある。

  1. 必要がないなら Rack middleware 内で mutation を行わない
  2. instance のライフサイクルがリクエスト単位になるようcall内でインスタンスを clone する
  3. mutation の際にconcurrent-ruby gem のようにスレッドセーフとなるライブラリを使う
  4. Rack middleware を freeze する

冒頭のコードは thread safety のためにdupしている、というのが答えになる。

野生のdup

まず、dupをしている実装例をいくつか見る。

f:id:ohbarye:20210202011744p:plain
勢いのある`call!!`

Rack::RecursiveRack::Lintのリンク先の commit log を見るとスレッドセーフのために Rack middleware instance をdupしているとのこと。

dupしたあとに呼ばれた_call内でのインスタンス変数の操作はdupされたインスタンスに対して行われる、というのは RubyObject#dupの挙動を知っていればわかる。

しかし、なぜそうしなければスレッドセーフにならないのか。それを知るには Rack middleware がどうやって生まれ、育ち、死んでいくのかを知る必要があった。

Rack middleware instance の"半生-ライフサイクル-"

Rack middleware の instance は rack server プロセスが最初にリクエストを処理するときに initialize され、使い回される。リクエストのたびにインスタンスを生成しているわけではない。

シンプルに以下のようなconfig.ruを書いてrackupするとリクエスト発生前の instance 生成状況がわかる。

# config.ru
require 'rack'

class MyMiddleware
  def initialize(app)
    "MyMiddleware is initilized"
    @app = app
  end

  def call(env)
    puts "==call=="
    puts "object_id: #{object_id}"
    puts "RackDemo instance count: #{ObjectSpace.each_object(RackDemo).count}"
puts "MyMiddleware instance count: #{ObjectSpace.each_object(MyMiddleware).count}"

    @app.call(env)
  end
end

class RackDemo
  def call(env)
    [200, {"Content-Type" => "text/plain"}, ["yay"]]
  end
end

puts "==Before run=="
puts "RackDemo instance count: #{ObjectSpace.each_object(RackDemo).count}"
puts "MyMiddleware instance count: #{ObjectSpace.each_object(MyMiddleware).count}"

use MyMiddleware
run RackDemo.new

puts "==After run=="
puts "RackDemo instance count: #{ObjectSpace.each_object(RackDemo).count}"
puts "MyMiddleware instance count: #{ObjectSpace.each_object(MyMiddleware).count}"

アプリケーションの instance はnewしてるので当然増えているが、use呼び出した時には rack middleware の instance はまだ存在しない。(渡したmiddlewareのnewproc内で行われていることが useの実装 からわかる)

(warmupにより予め生成しておくことはできる)

==Before run==
RackDemo instance count: 0
MyMiddleware instance count: 0
==After run==
RackDemo instance count: 1
MyMiddleware instance count: 0

curl http://localhost:9292してやると以下が表示されるので instance が生成されたことがわかる。また、何度リクエストを送っても instance 数は 1 のままであり、object_idも変わらないため、instance が使い回されていることがわかる。

MyMiddleware is initilized
==call==
object_id: 800
RackDemo instance count: 1
MyMiddleware instance count: 1

https://github.com/rack/rack/blob/5ce5b2ccb151c62652e25b4711218ede11497cc3/lib/rack/builder.rb#L148

multi-thread 環境では単一 instance に対して並行に読み書きが行われるので race condition の問題が起きる。だからdupしてリクエストのたびにインスタンスが生成されるようにしてやらないといけないのだった。

スレッドセーフの実現手法

スレッドセーフの実現方法は他にもある。

まず、Rack middleware 内でインスタンス変数を mutate しない場合はdupを呼び出す必要はない。なので第一の解決策はそもそもmiddleware 内で状態を mutate しないこと。

第二に、dupする代わりにconcurrent-rubyなどを使ってスレッドセーフなデータ構造を使うこと。

第三に、middleware を freeze すること。rack/rackは v2.1.0 からfreeze_appを提供している。これを利用する Rack middleware 内で mutation (=スレッドセーフでない操作) を行うとFrozenError: can't modify frozenraiseされる。

use (Class.new do
  def call(env)
    @val += 1
    @app.call(env)
  end
  freeze_app
end)

このコンセプト自体はrack v2.1.0 以前からrack-freezeとして存在していた (背景: Middleware should be frozen by default)。 なお、rack-freezeはアプリケーション全体にデフォルトで freeze をかけるポリシーを強制したいなら未だに有用とのこと。


まれに Rack middleware のコードを読み書きすると、一見シンプルなインタフェースに見えながらも深い理解がないとハマる問題がある。

今回は特定の問題に引っかかったわけではないがコードリーディングの最中にわからないことがわかってよかった。

環境

  • rack 2.1.0 ~ 2.2.3

参考記事


This article is for ohbarye Advent Calendar 2020.

React Adminの感想

marmelab.com

A frontend Framework for building data-driven applications running in the browser on top of REST/GraphQL APIs, using ES6, React and Material Design. Previously named admin-on-rest. Open sourced and maintained by marmelab.

  • React Admin 管理画面を作るのに最適化された React アプリケーションフレームワーク
  • France のMarmelab社によってメンテナンスされている OSS がコア
  • Enterprise Edition もある
    • OSS として公開していない便利な private modules が使えたり、開発サポートが受けられたり、改善要求を優先的に Development Roadmap に載せてくれたりする
    • 利用したことがないので存在に言及するだけにとどめる

どんなものが作れるかは公式の Demo サイトを見てみるとよいです。

marmelab.com

Demo サイトのコードも https://github.com/marmelab/react-admin/tree/master/examples/demo から参照できます。

あらすじ

前回 バックエンド Web API に管理画面/管理機能を追加するアーキテクチャパターン - valid,invalid にて書いたとおり、React Admin の所感を書きます。

最初は有用性を疑っていたフロントエンドの admin 系フレームワークについてもだいぶ評価を改めたので、その感想も近々書きたいと思います。

チュートリアルや How to 的な内容はスキップします。また、タイトルの通り複数のフレームワークの比較ではなく実際に開発・本番運用している React Admin に限定した感想です。

どうやって使っているか

アーキテクチャ上の位置づけとしてはこんな感じで、既存のバックエンド API にプレゼンテーションレイヤを持たせず、管理機能専用のフロントエンドを作ったパターンです (前回の記事でいうパターン 2)。

バックエンド Web API

バックエンド Web API は RESTish な実装であり、React Admin のSimple REST Data Providerを利用しています。

管理機能を実装するにあたりバックエンド Web API にも相当数のエンドポイントとデータモデルを追加しており、いずれも Admin namespace 配下にコードを置いています。エンドポイントの URL も /admin/v1/... のように区切っています。

顧客機能と管理機能が同一コードベース上にあるわけですが両者の間で完全に境界を引いているわけではないです。

予め書いておくと、React Admin は管理機能のフロントエンドの開発生産性に寄与するもので、バックエンドの開発コストは通常の Web API 開発と変わらないです。

Data Provider とは

バックエンドの Web API とのつなぎこみには Data Provider と呼ばれる機構を使う。

const resposen = await dataProvider.getOne("posts", { id: 123 });

Data Provider はこういうメソッド呼び出しを HTTP リクエストに変換して実行する API adapter*1

https://marmelab.com/react-admin/DataProviders.html より引用

  • adopter は React Admin がデフォルトで提供している
  • 自前で書くこともできる
  • コミュニティが作っているものも大量にある
    • Amplify や Hasura をバックエンドとする Data Provider もある

アダプタを注入できるので Data Provider を呼び出すコードではバックエンドが REST なのか GraphQL なのかなどを気にせず、必要に応じて差し替えたりできる。

デプロイメント

  • ビルドしたアセットを含む Nginx コンテナを Fargate で動かしている
  • CDN は使っていない
    • 利用者が限定されていてアセット配信のパフォーマンスは気にするまでもない
    • コンテナによる抽象化を活用することで他のサーバアプリケーションと同じように CI/CD パイプラインを組める

権限管理 in バックエンド

詳細は書きませんがざっくり。

  • ログイン機構と権限管理機構を顧客と管理者で完全に分離
    • 顧客が間違っても管理機能を使えることはない仕組みを意図
  • 管理者のみアクセスできる静的コンテンツは Reproxy を使って認証
    • バックエンド API で認証したあとにバックエンド API の手前にいるリバースプロキシを経由してクラウドストレージから配信
    • URL が露出したり誤ってコピペされても管理者としてログインしていないクライアントからは参照できない

SPA をシステムコンポーネントとしてどう扱っているかという話をしてしまったが、そろそろ React Admin の話を書く。

React Admin 良いところ

管理機能の開発コストを下げる

事業会社で働くエンジニアとしてこの手のフレームワークを評価する観点は「開発生産性が上がったのかどうか」「事業の目的を妥当なコストで達成することに寄与するのか」に尽きると思うのでその点をまず書きます。

個人の結論として「よくある CRUD 程度なら本当にシュッと書ける」「生産性が上がった」「管理機能の開発コストが相対的に低くなった」と感じているのですが、単に「上がった!最高!」みたいなことを書いても空中戦ないし提灯記事なので具体例として React Admin の Demo サイトの Order page の例を見ます。

  • Order 一覧 (コード)
    • 特定フィールドでの検索
    • ページネーション
    • ソート
    • 複数行選択での一括操作
    • CSV エクスポート
      • 画面に見えてない行も含めて全件
    • 行クリックで編集画面に遷移
  • Order 編集 (コード)
    • 編集
    • 保存
    • 保存後に一覧に遷移
      • この時点で HTTP リクエストはまだ飛んでいないが、対象データはすでに楽観的に更新されている
    • Undo
      • HTTP リクエストを飛ばす前であれば画面下部の Snackbar から取り消せる

コードを見るともっと細かいビジネスロジックがいろいろ入っているのですが、まぁよくありそうで一部めんどうくさそうな管理機能群です。これぐらいの機能を持った SPA の開発、どれぐらいかかりそうでしょうか。

半年弱、片手間で開発してみた体感としては、慣れてくればざっくり1 週間程度といった見積もりです。

これが早いのか遅いのかは読み手の練度によってだいぶ印象が変わると思いますが、少なくともフロントエンドエキスパートではない自分からすると使い始める前の予測よりずっと早いです。

実際のコードを見るとわかるように Redux store や非同期処理のことは Data Provider 丸投げなので全く気にしていないし、レイアウトやマークアップに関してもデフォルトのスタイルを使うので CSS をほぼ書いていません。過去に自分が SPA 開発で時間を費やしていた部分の多くが削減され、UI を宣言的に書くだけで画面が構築できる、その点に集中して開発ができている印象です。

ちなみに unit test は期待する API response データを与えたときに意図した attribute が意図したフォーマットで表示されているか、を見るぐらいです。というのもほとんどがフレームワークの提供する機能なので unit test としてカバーするのは自前で書いた宣言的 UI の部分に限定されてくるため。フレームワークが多くのめんどうを見る場合に testing のコストも減る好例と感じます。

testing の方針次第では増減あり、たとえば E2E をがっつり書いていくことになるともっとかかると思います。

フロントエンドのプラクティスが詰め込まれている

前回の記事に書いたとおりです。

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

上述の Demo でも一部見えているやつで、気に入っている便利 feature たち。

  • Optimistic UI
    • submit したらすぐに store と view が更新される
    • API コールに失敗したらロールバックされる
  • Undoable
    • submit した直後に undo ボタンが出る。Gmail みたい
  • Back forward cache
    • ブラウザバックしたときもリソースが store にあれば fetch する前に表示します

開発体験の良さ

React Hooks 時代に対応

React Hooks 対応しているので component 内で行うあらゆることが簡単になりました。

  • useTranslate
    • component 内で i18n
  • usePermission
    • ログインしているユーザーの permission を得る
  • useMutation
    • page に紐づく特定のリソースへの CRUD とは別に、任意の API コールを行いつつ React Admin の便利機能を使える

他にもいろいろあります。

フロントエンド資産が使える

React Admin に限らない話ですが、フロントエンドの各種資産が使える。

  • フロントエンドで印刷機能を作ったりグラフを描画したりするのにさほど苦労しない
  • サーバサイドでの Admin 系フレームワークではこうはいかない

TypeScript 時代に対応

権限

  • ログイン、auth 周りのサポートがある
  • ログインしたユーザのロールに応じて routes を動的に分けるテクニックが使える

React Admin悪いところ

テスト

Unit testing のサポートが薄い。

By default, react-admin acts as a declarative admin configuration: list some resources, define their controllers and, plug some built-in components or your own to define their fields or inputs. Thus, unit testing isn’t really needed nor recommended at first, because the internal API of the framework is already tested by its maintainers and each custom component can be tested by its own by mocking react-admin. https://marmelab.com/react-admin/UnitTesting.html

「こう宣言したらこう表示される」程度のテストはフレームワークの内部をテストしているようなものなので、確かに気持ちはわかる。

先述の通り、List や Show page のコンポーネントの unit test を書いているが、context provider でラップしてやらないといけない物も多いのでセットアップで工夫する。

react-testing-libraryの例を参考にこんな雰囲気の helper を書いている。

import "@testing-library/jest-dom";
import React, { FC, ReactElement } from "react";
import polyglotI18nProvider from "ra-i18n-polyglot";
import { render, RenderOptions } from "@testing-library/react";
import { TestContext, TranslationProvider } from "react-admin";
import { MuiThemeProvider } from "@material-ui/core";
import { getTheme } from "./my-theme";
import messages from "./my-translation-file";

const i18nProvider = polyglotI18nProvider(() => messages);

const withProviders = ({ initialState = {} }): FC => {
  return ({ children }) => {
    return (
      <TranslationProvider i18nProvider={i18nProvider}>
        <MuiThemeProvider theme={theme}>
          <TestContext initialState={initialState}>{children}</TestContext>
        </MuiThemeProvider>
      </TranslationProvider>
    );
  };
};

type Options = Omit<RenderOptions, "queries"> & {
  initialState?: Record<string, unknown>;
};

export const renderWithProviders = (
  ui: ReactElement,
  options?: Options
): ReturnType<typeof render> =>
  render(ui, {
    wrapper: withProviders({ ...options }),
    ...options,
  });

// re-export everything
export * from "@testing-library/react";

その他もろもろ

  • ちょっとした融通の効かなさ
    • 細かいけど DELETE メソッドには body を含められない
    • セマンティクス的には正しいが、諸事情によりセマンティクスから外れた HTTP API を呼ばなければいけないことも人生には、ある
  • 内部で利用しているライブラリの選定
    • react router, redux final form, redux-sagaなど、2021 年で主流かどうかあやしいライブラリ
    • 後述の通り、利用者からが意識しなくて良い程度に隠蔽されているので辛みは今のところない
  • 公式ドキュメントが JavaScript 時代のもの
    • 内部実装は書き換わったがドキュメントがまだ追いついていない

印象

良し悪しではなく「こうだなぁ」と思ったこと。

Simple よりも Easy

思想としては間違いなく Simple ではなく Easy です。

利用者が意識しないですむよう多くのことが隠蔽されていますが、全容を知ろうとすると complex で理解が及ばない箇所にぶつかります。

Easy は早期に成功体験を与え、はずみをつけるもの (momentum builder という表現が気に入っている) なので、フレームワークの性質としてスタートアップや新規事業の性質に適合しているといえると思う。

ohbarye.hatenablog.jp

React Admin が内部で使っているライブラリの知識がどれだけ必要になるか

内部的に利用されている主要なライブラリの知識がどれだけ必要になるか。使い方によっては印象が変わるかもしれない。

material-ui

react-roter

  • ちょっと必要
  • default ではHashRouterになっているがBrowserRouterにすぐ切り替えると思う

redux-final-form

  • ほとんど意識しない
  • React Admin が提供する edit/create 画面とは異なる独自 Form を作るときは必要
    • そういったシーンは少ない

redux

  • ほとんど意識しない
  • デバグのときに dev tool で store を覗いたりする程度
  • store にアクセスする hooks もあるがほとんど使わない

redux-saga

  • 全く意識しない
  • redux-saga をついに理解できないままだったので不安に感じながら react-admin を使い始めたが、これは素晴らしい

オブジェクトベース UI と相性がよい

上述の Demo のようにオブジェクトが中心にあり、その操作をあとに決めるような UI 設計と相性が良い。

オブジェクト、言い換えればリソースの再利用ができると Redux Store が活きるシーンが多い。逆に言えば「注文画面では User object はaddressee_nameを持つがユーザー一覧画面ではaddressee_namenull」みたいなリソース設計をしていると再利用が難しい。ユーザー一覧画面表示後に注文画面を表示するとき、同一リソースだと React Admin が気を利かせて Store データをフィルインしようとしてくれるのだが null 安全ではないので失敗したりする。

// 大統一ユーザー
interface User {
  first_name: string;
  last_name: string;
  addressee_name: string | null;
}

バックエンドでは同じテーブルのデータであっても別のリソースとして宣言し、エンドポイントも分けておくほうが React Admin 的にも扱いやすい。

// ユーザー一覧ページ用ユーザー
interface User {
  first_name: string;
  last_name: string;
}

// 注文一覧ページ用ユーザー
interface UserForOrderPage extends User {
  addressee_name: string;
}

管理機能はオブジェクトベース UI が向いている?

余談ですが、管理機能はオブジェクトベース UI とタスクベース UI のどちらが向いているのでしょうか。まぁ、これもまた…事業・プロダクト特性・ユースケースに依存するのでどちらともいえないと思います。

承認ワークフローのような機能を作るのであればステップに合わせたタスクベース UI が向いているでしょうし、同一リソースの表現が何種類も生まれる可能性が高い。

一方、カスタマーサポート/カスタマーサクセスが顧客の行動履歴を参照するために活用する管理機能が中心であれば、顧客のイベントリソースをオブジェクトベース UI で扱うのが向いているかもしれません。

同一アプリケーションでもシーンによってメンタルモデルは異なるので、まぁ答えはないと思います。

f:id:ohbarye:20210124145624p:plain
こんな雰囲気の一覧ページも作れる

つらそうなケース

つらいケースの開発をしてないのですが、こうだったらつらそうと思うシーン。

  • API が RESTish でないとき
    • 先述の「DELETE に body を含める」のような HTTP の標準や規約から逸れるような場合は React Admin の Data Provider ではサポートしづらい
      • API を呼び出す際に独自の hook を書いて個別にカバーする
    • API のあり方を常識的な範囲で矯正・強制するといえる
  • 既存の React, Redux アプリケーションにあとから足すとき
    • react-admin はフレームワークなのでアプリケーションの根っこから変えないといけない
    • routing や非同期処理・ストア設計などをマージしないといけないので、react-admin 単体で利用するときには隠蔽されていた redux-saga などの知識が要求される
  • 独自の UI デザインシステムごりごりやっていく場合
    • 共通の考え方・ツール・アセットが material-ui なので、良くも悪くも見た目は material-ui になる
    • Atomic Design などをちゃんとやっていくとなると material-ui を react-admin がラップしたコンポーネントをさらにラップしたものを作らないといけない

トータルで見て

多機能なのでまだ使いこなせていない機能もあるが、現時点では開発体験は良いし生産性高く管理機能を開発できていると感じている。

企業のフルタイム開発者がメンテナンスしているだけあり 2019 年〜の開発ペースを見るに安定している。

あと数年は生き残りそうなので React Admin よりもプロダクトの寿命が長くなるように頑張っていきたい。

環境

  • react-admin v3.11.3

参考リンク

*1:データストアを Web API にする場合。localStorage を Data Provider にする際はリクエストは行われない

バックエンド 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 系フレームワークについてもだいぶ評価を改めたので、その感想も近々書きたいと思います。

(2021-01-25 追記) 書きました React Adminの感想 - valid,invalid

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

参考記事


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.