「検算する」をやる

エンジニアの仕事として、ギャップを見つけて改善する、があると思う。そもそも改善案は AS IS と TO BE を捉えて、その差分をどうやったら改善可能かを考えるところから生まれる。

例えば「テックリードが忙しい」という状態があったとする。これを改善可能な状態と見なし、「重要な判断がテックリードに集まっている」が問題だと捉えて(いや、そもそも「重要な技術判断を誰もが担えるチームでありたい」という理想があるのかもしれない)、じゃあ「任せる」範囲を増やしていこうとか、レビューのやり方を変えようとか、設計と判断を分けるために ADR を取り入れようとかがアクションになっていきそう。

もっと良い状態を想像して、そことのギャップから「何を変えるか」を考えていく。

時間軸を動かす

理想を考えるときに、時間軸を動かすのをオススメしたい。

理想的には「サービスを継続的に開発運用するにあたっての DX がよい」は開発チームとして目指したいのは誰も違和感を持たないのではないか。これを多少具体化して「フロントエンドとバックエンドが切り離されている」を置くとしよう。

これを少なくとも 3 年後には達成していたいよね。フロントエンドを分離していくなら 2030 年に HTML レンダリングをサーバサイドでやっていたくはないと思う。じゃあ途中の 1 年半後には何が達成されているといいか、という感じで具体化が進む。

逆に、現在をそのまま未来に伸ばしてみるのも良い。今のプロダクトの改善度合いをそのまま続けたとき、3 年後にはどうなっているだろうか。

「目指す未来」と「このまま進んだ未来」に差があるなら、今は問題になっていないけど手を入れ始めなきゃいけない必要があることに気づける。

そのアクションで本当にいいのか

陥りがちな罠として、考えているうちにゴールを見失っている、がある。

例えば「テスト時間が長い」って問題があったとしよう。現状はコードが古く、長年使っている技術がある。vitest じゃなく jest である。CI 改善プロジェクトの中で、vitest を導入する話が盛り上がっていく。ESM 親和性があり jest 時にあった問題は解決されているし、自分も jest を触っているよりテンションが上がる。採用にもいい影響がありそう。いいじゃん。

さて、そもそもの痛みであった「テスト時間が長い」はそれで本当に解決するんだっけ。(改善するかもだけど)

「せっかくなのでこれも直したい」「今ならこの技術を試したい」という色気によって、最初に何を解決したかったのかが曖昧になっていないか。

そのアクションで本当に届くのか

半年かかるプロジェクト。3 ヶ月経った時点でまだ 40% 程度の進捗である。チームでふりかえりをやったところ、「集中デー (ミーティングが無い日) を作る」という Try が出た。ミーティングが無くなれば作業時間は増え、コンテキストスイッチもなくなる。ヨサソウ。

さて、これだけで遅れを取り戻せるものなのだろうか。

ミーティングをまとめることで週 3h を作業に回せるようになり、さらに集中デーの 8h の作業効率が 1.2 倍になったとして、週 5h ぐらいが増えていることになる。残り 3 ヶ月だと 60h ほど増える。計算上はやれなくもないな。けどプロジェクト終盤にどうせタスクは増えることを考えると危ういか?バッファはもう無いわけだし……。

こういうのは雑なフェルミ推定で良い。概ね桁が合ってるなら、そのアクションを正解にするように行動することで多少のギャップは埋められると信じている。

ここで 300 時間ぐらい必要なのに 60 時間しか改善しないとしたら、アクションが足りないことが分かる。「アクションが足りない」は陥りがちな罠その 2 です。

アクションが足りるとは

「3年後にフロントエンドとバックエンドが切り離されている」を仮置きしたけど、これは実は「3年後に主要なページが切り離されている」かもしれない。目標には最低ラインとストレッチラインがある。許容可能なゴールを最低ラインと置きたい。

そして、現在から今の速度で積み上げていったときに「module A は終わる、module B の移行方法の確立まで進んで module C は手つかずっぽい」と分かったとしたら、このままでは 3 年では実現不可能だ。

手法を変える?人を足す?それとも最低ラインと置いたのが間違いで実現不可能だったとしてスケジュールを考え直す?

ストレッチラインに届かないけど最低ラインには届きそうであれば、これは正しくストレッチ目標なのでそのまま置いておけば良い。ストレッチラインに届くなら、他の色気を入れ込む余地がありそうですね。

人事的な目標設定も同じ

目標設定をするときも似た流れで進めている。

例えば、設計力が弱くて手戻りが多い人が居るとする。この人に期末までに、フィードバックを得ながらで良いので方針を収束させられるようになって欲しい。

目標を達成するためのアクションとして

  1. 明確に要件定義する
  2. プロトタイピングする
  3. 生煮え状態でチームメンバーと会話する

辺りをパッと思い付いた。このアクションで期待を達成できるかを確認してみる。

  • 1.は明確に要件定義するのは誰がやるのか、自分でやれるなら問題になっていないよね
  • 2.は常に実施することが現実的にできるのか
  • 3.は「手戻りを減らす」に対しては実現可能でヨサソウ
  • これだけで期末に方針を収束させられるようになりそうなのか

「生煮えで相談できるようになりましょう」は 1-2ヶ月で達成して欲しいマイルストーンっぽく思えてきたかも? じゃあこれは目標には物足りないな。もっといい目標がありそうなのでアクションを出し直そう。

人の場合はもう一つ考えることがあって、仕事をすることで成長するということだ。現在の仕事やこれから得られる機会 (経験資源) で最低ラインに現実的に届くのか。届かないのであれば、本人がもっと頑張るだけじゃ足りなくて、任せる仕事を変える、挑戦する機会を作る、支援を増やすといった介入が必要になる。それでも届かないなら目標として置いた期待が高すぎる。

まとめ

ここまで見てきたように

  • 有効性
  • 十分性
  • 実現可能性

を確認しているのだろう。本当にそのアクションで目標に効くのか、目標に届くのか、現実に実行可能なのか。考えた目標やアクションを元の目的に当てはめ直して確認する。

テストを提出する前に見直す体験に近いので、僕は「検算」と呼んでいる。

検算、要はセルフレビューですね。レビューするには、レビューポイントを知っておく必要がある。今回は「有効性、十分性、実現可能性」と置いてみたがどうか。

Kyoto.rb で basecamp/writebook を読んだ

毎月やっている (と言いつつ今回は3ヶ月ぶり) Kyoto.rb、たまにはコードリーディング回にすると面白かろうということで、basecamp/writebook を読む会をやった。

以下は Kyoto.rb で読んだものと、自分が事前に下読みしたときの感想が混ざっている。85980728d1 (2026-07-20) 時点のコードで読んでいます。

Writebook とは

Zenn の本 みたいなヤツってのが一番分かりやすい説明と思う。2024-07 に ONCE.com に出てきたので、その頃に読んでいた人も多いんじゃないか。

自分でも忘れていたけど当時の Kyoto.rb で 5-10 分ぐらい読んでいそう。

2024/7/27【オフライン】久しぶりに発表するぞ! - kyotorb

前提知識

コードの読み方はだいぶ変わったね。昨今は AI ってヤツがあるので「このコードの見所を教えて」って言うだけで全部教えてくれる。

とはいえ前提知識があると良くって、

とかを認識してから読むと答え合わせになる。

コードから概要を掴む

概観を掴む、普通のところを普通に眺める、普通じゃないところを眺める、という順番で進める。

  1. github-linguist とか tokei とか
    • Rails アプリというのを知ってはいるけど、どの言語が何行ぐらい書かれているのか、概観を掴みたい
    • linguist はリポジトリの右下に「Ruby 39.7%」とか書いてるアレ
    • TypeScript/TSX じゃなく JavaScript だねーとか、Ruby 5000 行程度かー、とかが分かる
  2. ls -alF とか tree -F -L2 とか
    • フロントエンドとサーバサイドがモノレポだったりもよくあるので、そもそもリポジトリとしての概観を掴みましょう的な感じ
    • ここで「普通の Rails アプリっぽいな」を掴める。たまに「これは普通じゃないぞ」と腰を据えて読む必要性が読み取れることがある
    • 今回は明らかに rails new しただけのバックエンド主体のリポジトリであろう
    • 普通の rails new 後と比較して以下のような感想を持つ
      • test/ なので RSpec じゃないな
      • AGENTS.md がある
      • Dockerfile-export って見覚え無いな
      • script/admin/ がある
  3. Gemfile を眺める
    • Rails リポジトリであれば、gem を見れば概ね機能が掴めるはず
    • resque, resque-pool があるので非同期処理がありそう
    • フロントエンドは propshaft, importmap-rails 等の Rails の標準構成っぽい。この辺は気になるなら読むか
    • redcarpet, rouge, front_matter_parser があるので Markdown をしっかり扱っていそう
    • gem "rails", github: "rails/rails" なのは頭がおかしいな(褒め言葉)
  4. config/routes.rb
    • resources :books ブロックが一番大きいので本が中心ドメインであろう
      • といっても 16 行しか無いけどね
    • session 周りは自前の実装だね。gem も無かったし
    • あまり見ない記法が幾つかあるのであとで戻ってこよう
      • direct とか、scope, namespace の使い分けとか
  5. db/schema.rb と app/models/*
    • create_tableactive_storage_* を含めて 14 個。だいぶ小さなアプリ
    • models も合わせて見るのは、DB だけだと FK 貼ってないパターンもよくあるから
      • AI に読ませる以外だと、tbls なり、rails-mermaid_erd なりで見ると良いかもねー
    • Leaf, Edit 周りが面白そう
      • delegated_typePage, Section, Picture を扱っている
      • 版管理は本の中心ドメインであろう

ぐらいが読み解ける。

コードだけからこれ以上を理解するのは難しそうなので、この辺で 1 回実際に触ってみる。

動かしてみる

  • 起動して http://localhost:3000/ にアクセスしたら /first_run に飛んだ
    • アカウント作成。admin アカウントを初回アクセスで作成する辺りが ONCE プロダクトっぽさ
  • Book 作成時に DELETE ALL が見えた
    • Access Delete All (0.1ms)  DELETE FROM "accesses" WHERE "accesses"."book_id" = ? AND "accesses"."user_id" != ?  [["book_id", 2], ["user_id", 1]]
      
    • 直前が INSERT INTO "accesses" ... ON CONFLICT ... DO UPDATE なので upsert。
    • 権限管理っぽいな。常に上書きで upsert しているんだろう
  • Page を追加すると Page と Leaf が追加される
  • Page を編集すると
    • Page が insert される(!?)
    • Edit が insert される

みたいな感じ。まぁ普通に markdown を入力して本にするアプリですね。日本語の編集はイケてない……。(isComposing チェック忘れてるよ〜)

概観を掴めたので、あとは気になったところの深掘り

Leaf とは何なのか

delegated_type を使っている。

Rails は遙か昔から STI (単一テーブル継承) に対応していた。STI でサブクラスを実現した場合、一部のサブクラスでしか使われないカラムは他のクラスでは null を入れることになる。これが非常にイマイチ。CTI (クラステーブル継承) ではこの問題が解決されている。delegated_type は CTI を実現するための機能で、Rails 6.1 から使える。

正確には CTI というには polymorphic に委譲しているのでちょっと違うんだけど。詳しくは PoEAA (エンタープライズアプリケーションアーキテクチャパターン) の 12 章を読んでくれ。

というわけで、Book has many Leaves、Leaf は Page|Section|Picture である。(Leaf#leafable としてアクセスできる)。

Page|Section|Picture をまとめて Leafable として扱っていることが分かると、routes.rb の direct の意味が分かってくる。

direct :leafable do |leaf, options|
  route_for "book_#{leaf.leafable_name}", leaf.book, leaf, options
end

direct :edit_leafable do |leaf, options|
  route_for "edit_book_#{leaf.leafable_name}", leaf.book, leaf, options
end

これは leafable なものを leafable のまま扱うために必要な抽象化。

編集するときに何が起きるのか (版管理)

Page と Edit があり、Edit を版管理に使っているっぽかったので見ていこう。

本文編集時、PagesController#update が呼ばれる。PagesController には存在していないので親クラスの LeafablesController#update が呼ばれる。

LeafablesController#update は以下しかない。非常に綺麗ですね。@leaf.edit が編集処理そのもの。

def update
  @leaf.edit leafable_params: leafable_params, leaf_params: leaf_params

  respond_to do |format|
    format.turbo_stream { render }
    format.html { head :no_content }
  end
end

@leaf.editLeaf::Editable module にあるこの処理。

module Leaf::Editable
  ...
  def edit(leafable_params: {}, leaf_params: {})
    if record_new_edit?(leafable_params)
      update_and_record_edit leaf_params, leafable_params
    else
      update_without_recording_edit leaf_params, leafable_params
    end
  end

この record_new_edit? が面白くて、前回の編集から 10 分経っていたら record_new_edit? である。

  def record_new_edit?(leafable_params)
    will_change_leafable?(leafable_params) && last_edit_old?
  end

  def last_edit_old?
    edits.empty? || edits.last.created_at.before?(MINIMUM_TIME_BETWEEN_VERSIONS.ago)
  end

じゃあ版管理しながら保存する方のメソッドである update_and_record_edit を眺めるとこうなっていた。

def update_and_record_edit(leaf_params, leafable_params)
  transaction do
    new_leafable = dup_leafable_with_attachments leafable
    new_leafable.update!(leafable_params)

    edits.revision.create!(leafable: leafable)
    update! leaf_params.merge(leafable: new_leafable)
  end
end

つまり、以下が版管理のためのコードである。

  • dup して new_leafable を作り、それを保存
  • 古い leafableedits.revision で参照可能にして保存
  • leaf から leafable への参照を new_leafable に付け替え

普通はバージョン管理と言えば変更前のオブジェクトの属性をまるっとシリアライズして保存するパターンを想像すると思う。そうではなく、Page そのものをもう 1 レコード作ってしまうというのは想像していなかった。

せっかく delegated_type でサブクラスごとにテーブルを分けたのだから、履歴についても nullable なカラムを設けたくない、と考えると Leafable そのものをもう 1 行作ってしまえという発想になるのかもしれない。

ところで dup_leafable_with_attachments の中身はトリッキーで、知らないと読めないと思う。reflections.each と attach とでやっていることの想像はできそう。

def dup_leafable_with_attachments(leafable)
  leafable.dup.tap do |new|
    leafable.attachment_reflections.each do |name, _|
      new.send(name).attach(leafable.send(name).blob)
    end
  end
end

Positionable

Leaf の並び替えは Leaf#position_score で行われる。position_score は float で保存されていて、例えば 1 ページ目 (position_score: 1.0) と 2 ページ目 (position_score: 2.0) の間に入れようとしたら、それは position_score: 1.5 になる。更に 1.0 と 1.5 の間に入れようとしたら 1.25 になる。

この半減していく処理自体はよくあるソート機能の実装方法だが、REBALANCE_THRESHOLD = 1e-10 を用意していて、1e-10 を超えて小さくなったときに、全部を整数で付け直しているのは丁寧で良い。

検索

これは SQLite に全文検索エンジンが載っているのを知っていれば分かる。

schema.rb に

create_virtual_table "leaf_search_index", "fts5", ["title", "content", "tokenize='porter'"]

とある。

あとは Leaf を保存するたびにデータを同期しておき

module Leaf::Searchable
  extend ActiveSupport::Concern

  included do
    after_create_commit  :create_in_search_index,   if: :searchable?
    after_update_commit  :update_in_search_index,   if: :searchable?
    after_destroy_commit :remove_from_search_index, if: :searchable?
    ...

検索処理は

@leaves = @book.leaves.active.search(params[:search]).favoring_title.limit(50)

で、これが SQL 文では以下になる。知っていればそのままだね。

SELECT
  ...
FROM "leaves"
  join leaf_search_index on leaves.id = leaf_search_index.rowid
WHERE "leaves"."book_id" = ?           -- @book.leaves
  AND "leaves"."status" = ?            -- .active
  AND (leaf_search_index match ?)      -- .search(params[:search])
ORDER BY bm25(leaf_search_index, 2.0)  -- .favoring_title
LIMIT ?                                -- .limit(50)

FirstRun

初回起動時に GET /first_run に飛ばされるが、そこでやっていることは面白かった。

FirstRun.create メソッドがあり、これを呼ぶと Account が作られ、admin User が作られ、デモ用の Book が作られる。こういう一連の処理を Model として表現しているのは、app/services を作らない強い気持ちが窺えて非常に良い。

Account はおそらく「テナント」を表現する model だが、Writebook は立てたい人が自分のサーバで立てるものなので Current.account は常に Account.first である。

Markdown 対応

# app/controllers/books_controller.rb
def show
  @leaves = @book.leaves.active.with_leafables.positioned

  respond_to do |format|
    format.html
    format.md
  end
end

Book や Page が .md 拡張子でアクセスすると markdown として render される。

# app/views/books/show.md.erb
---
title: "<%= @book.title %>"
author: "<%= @book.author %>"
url: "<%= book_slug_url(@book) %>"
---

<%= raw @book.markable %>

Markdown format への対応は Rails 8.1 で追加されている。

Markdown Rendering

Markdown has become the lingua franca of AI, and Rails has embraced this adoption by making it easier to respond to markdown requests and render them directly:

class Page
  def to_markdown
    body
  end
end

class PagesController < ActionController::Base
  def show
    @page = Page.find(params[:id])

    respond_to do |format|
      format.html
      format.md { render markdown: @page }
    end
  end
end

こういう更新入れてくるのが Rails の好きなところなんだよなー。それをシュッと使っている writebook も良いなと思う。

完走の感想

  • フロントエンドを吊るしのエディタで良いとすれば、開発合宿で作れるサイズだねー
    • 素振り済みであればこういうのをサクッと実現できるのが Rails のいいところ
  • SolidCable, SolidQueue 使えばいいのに
    • 特に redis/resque である理由は分からなかった
    • ONCE ならより SQLite に寄せたいんじゃないかな
  • delegated_type の使い方の見本っぽくてとても良かった
  • SQLite で全文検索をサラッと実現しているのは興味深い
  • 普通の Rails アプリかというと、「37signals の Rails アプリ」を読んだって感想
    • rails が最新なのは異常でカッコイイ
    • concern の使い方はかなり特徴がある
      • model も、controller も
      • 「機能の節理面でファイルを分ける」をちゃんとやっている、という印象
      • この辺は campfire や fizzy も読むと「っぽさ」として体感できると思う
    • Gemfile がシンプル
      • ユーザのログイン機能があり、Role を持つのであれば、そういうのはなんか使ってもいい
      • 版管理も何らかの gem でも良い
      • 自分ならこのサイズなら逆にどうとでもなるので何らかの gem を使いそう
    • Rails をよく知っている人が書いたコードだなぁって思う
      • これぐらいのメタプロを自分のプロダクトに入れられるかと言うと、もうちょっと手加減しそう
  • stimulus の使い方は参考になりそう。今回は読んでないけど
  • もっと「普通の Rails」としてのコードリーディングに寄せることもできなくはなかった
    • が、concern の使い方が普通じゃないので、そもそも設計が違うねってなるだろう
    • 設計に納得できていないときに細かなコードの書き方の話はしづらいだろう
  • dup 後の AR オブジェクトを update! したら結局 save! が呼ばれるの、そうなんだ。知らなかった
    • dup 時に id が無くなることは知ってたんだが、id 持ってないものに update を呼ぶ発想が無かった

管理者に「なる」ボタンを作った

管理画面を作るのが面倒な Web アプリケーションで、自分のユーザに admin フラグを立てておいて無双している。アプリケーションの画面上からスパム投稿を非表示にしたり、ユーザを BAN したり。手が滑って普通の投稿を削除すると怖いので confirm を挟んだりもしている。

任意の時間にできる機能なんかも定番ですね。月初に出るバナーが本当に出るのか、割引が本当に開始するのかを、来月の時間ということにしてアクセスして確認したい。

こういうのを、自分一人ならいいが、チームで作っていると、管理者には見えているので「来月のバナーが出ちゃってます!」って勘違いで慌てる、みたいな場面も何度か目にしてきた。「管理者にしか見えないので大丈夫です」って返事したことあるよね。

問題は一般ユーザのつもりで管理者ロールを引き受けてしまっていることにある。

そこで「管理者になる」を明示的に押したときだけ「管理者モード」に入るようにしました。

管理者モードを開始 / 管理者モードを終了

管理者モードに入っているときは派手にバナー表示し続ける。

実装の肝

管理者として使っているかどうかをユーザーの属性から切り離す、がやりたいことである。

User#is_admin? は「管理者になる資格がある」を表す属性であり、これはログインしている限り常に true を返す。その上で「今この瞬間、管理者として行動している」を別のフラグで表せば実現できる。これは「セッションの一時的な状態」と考えたので session を使った。

# 管理者モードの有効化: POST /admin_mode
session[:admin_mode_active] = true

# 管理者モードの無効化: DELETE /admin_mode
session.delete(:admin_mode_active)

今書きながら思ったけど、有効期限入れた方が良いな。管理者権限をうっかり持ち続けるリスクが更に下がる。(更に思ったけど、session ではなくレコードにして監査ログとするのも良いですね)

そして権限チェックは current_user.is_admin? && session[:admin_mode_active] になる。これは session のフラグだけで判定すると、is_admin を剥奪されたときに session が残っていると管理権限を持ったままになってしまうと考えた。

使ってみて

「管理者モードで作業するぞ!」という感覚を持ちやすくなったと思う。管理者は特別な操作をするための一時的な状態である。sudo を叩くときに一瞬だけ「本当に Enter を押していいのか?」と考えるのに近い。というかその瞬間だけ昇格するんだから本当に sudo に近いんだな。

管理者権限は「常に持っているもの」ではなく「必要なときだけ一時的に昇格するもの」だった。

株式会社はてなに入社しました

株式会社はてなに入社しました

株式会社はてなに入社しました - hitode909の日記

9 年目に入ります。自分が先頭となってものを作ってるかどうかがやはり楽しさの源泉だなぁ。 このときの「ものづくり」は「私が考える望ましい方向への変化」と言い換えられるけど、具体的なアクションを取るのが自分であることは手放せない。

Inertia.js (inertia-rails) を触っているので所感を書いておく

まだ触り始めて1ヶ月ぐらい。困りが発生する程度には使ってきたとは思う。

Inertia.js とは

inertiajs.com

主に Laravel コミュニティで管理されている OSS。ひと言で言うとフロントエンドをモダンに書けるようになるアダプタ……かなぁ。GitHub でも 7000 star 集めているぐらい。ちょうど先週 v3.0.0 が出ましたね。元気に開発されています。

概要は僕もこの後説明するけど、作者のブログ記事 を見るのが一番イメージ沸くと思う。

間違いなくいつもの MPA

Inertia.js は、古き良き Web アプリケーションフレームワークの View 層のみをモダンな UI フレームワークに差し替えられるもの、という理解をしている。決定的なのは Routing をサーバが持つこと、レスポンスをサーバが返しているかのような感覚で書けることだろう。

具体例は 公式サイトのファーストビュー が十分に分かりやすい。

いつもの Controller で、最後に Inertia::render。描画するコンポーネント (Users) と props (users) を渡している。

<?php
# UserController.php
class UsersController
{
    public function index()
    {
        $users = User::query()
            ->active()
            ->orderBy('name')
            ->get(['id', 'name', 'email']);

        return Inertia::render('Users', [
            'users' => $users,
        ]);
    }
}

View 側はいつもの Vue で、props を受け取っている。

<!-- Users.vue -->
<script setup lang="ts">
import { Link } from '@inertiajs/vue3'
defineProps<{ users: User[] }>()
</script>

<template>
    <div v-for="user in users" :key="user.id">
        <Link :href="`/users/${user.id}`">
            {{ user.name }}
        </Link>
        <p>{{ user.email }}</p>
    </div>
</template>

MPA の概念のまま、サーバから props を View に渡すところを受け持っているのが Inertia.js。フロントエンドは Vue, React, Svelte に対応しています。

フロントエンドの体験を良くしやすい

ここまでだと react-rails で render component: 'Users', props: { users: users } したのと同じでは、と思うかもしれない。

Inertia.js の体験が良いのは、サーバ側のアダプタであるだけでなく、「フロントエンドのフレームワーク」でもある点が大きい。

クライアントでは <Link> コンポーネントを使って遷移する。Link をクリックすると、XHR して SPA 遷移する。

render :inertia がリクエストの方法に応じて出し分けるようになっていて、

  • 初回 HTML だと <div id="app" data-page='{"component":"...","props":{...}, ...}'> という HTML を render し、component と props を渡す *1
  • XHR だと JSON のみを返す

JSON が返ってきたときにその component を render するのはフロントエンド側の Inertia.js 側が受け持っている。そしてこの仕組みは X-Inertia ヘッダの有無によってサーバが自動で render し分けている。シンプル。

Rails に長く触れてきた人には「Turbolinks のツラみを解消している」と言えば伝わるだろうか。PJAX を推し進めて、JSON を返し分けるようにしたことで、UI フレームワークとの間をもっと上手く取り持っている。

Inertia.js はサーバ側の規約と、クライアント側のフレームワークなんです。

主権はサーバにある

フロントエンドは routing を持たず、ただサーバにリクエストを投げる。

ページ遷移をするときは

import { Link } from "@inertiajs/react";

<Link href="/">Home</Link>;

で遷移する。これはサーバに fetch で request を投げ、サーバが response となる { component, props } を返して、クライアントはそれに従って描画する。

Flash 表示をしたければいつも通りに Flash に詰めたら、クライアント側でその Flash が描画される。

Form もサーバにクライアントサイドから POST される。サーバはいつも通り扱うだけでいい。validation についても以下のようにいつも通りに書くだけ。

class UsersController
  def create
    user = User.new(user_params)

    # いつもの感じで validation して
    if user.save
      redirect_to users_url, notice: "作成しました"
    else
      # 失敗したら errors を詰めて form に返す
      redirect_to new_user_url, inertia: { errors: user.errors }
    end
  end

これで Form 側では props に errors が入っているし、Form の state はクライアント側で維持されている。フロントエンドのフレームワークであるというのが効いていて、ページ遷移として POST しているのではなくクライアントサイドで request を投げているので、response を受け取ったときの動きが上手く統合されている。

deferred props という仕組みがあるのも面白い。重い処理は deferred にして page は先に render してしまい、クライアント側から追加で取りに行く。これをサーバ側の指定のみで書くことができる。

render inertia: "users/index", props: {
  users: User.all,
  stats: inertia.defer { HeavyQuery.run }
}

「component 名と props をサーバから渡す」から想像できるよりも、何を描画するか、どう描画するかの主権はずっとサーバ側にあると感じるんじゃないかな。これが一番の特徴だろう。

他の選択肢との比較

API + Frontend や、Next.js と比べたいって声は当然あると思う。

「モノリスである」「主権はサーバにあり page 単位で考える」が大きな違いと感じている。

例えば session はサーバ側でいつものように管理するし、データの fetch は page 単位 (=controller が常に起点)。バリデーションも認可もサーバ側でいつもの感じで書く。本当にいつも通りで最高。

co-location の書き味では GraphQL や RSC に大きく劣るだろう。欲しいデータをクライアント側から指定することはできない。page 単位で props が渡されるのを持ち回さないといけない。*2

API 境界がない (props を動的に受け渡しているだけだし、どんな props を受け渡すのかの静的定義は用意されていない) というのも違いになり得ると思う。が、後述するように明らかにツラすぎるのでここはまず定義することになる。

Inertia.js を採用するときの決め手は、サーバを API サーバとしてではなくページ遷移を中心として捉えるかどうかだと思う。 あくまでサーバが主で、クライアントは View と割り切れるチームだとちょうどいい。クライアントサイドでアプリケーションを構築するもの、サーバはデータを提供する API、と考えている人が多いチームだと厳しい。

フロントエンド専属チームを用意しない人数規模に最適だと思っていて、サーバ起点の設計を維持しながら、モダンフロントエンドフレームワークも組み込みたいときには強く選択肢に挙がると感じた。

Pjax/Hotwire/htmx との違いについては、サーバは HTML 断片ではなく props を渡すのでモダンフロントエンドと同居できる、が一番の違い。次に Hotwire というか Turbo Frames とは「HTML 断片ではなく page 全体を返す」という違いがある。部分更新ではないという意味では Turbo Drive に近いんだけど、かなりレンダリングに介入できるので Turbo Drive よりフロントエンドを扱いやすい。

その他、色々気になるポイント

クライアントとサーバの間で何を渡すのかの定義は揃える

フロントエンドに型が無いと書きづらいのは、これはさすがに当然そうです。文化がそうなっている。

型が無くても開発できるのは DB の型を知っているからというのはあって、フロントエンドには DB をそのまま露出するわけにもいかないので型定義がここで途切れる。露出するコンポーネント (DB からフロントエンドに Serializer 層で変換したもの) の型は定義すべき。

自分では OpenAPI Spec を書く (committee gem を使った validation はできないんだけれど) という選択肢を採った。将来的に API サーバに転生する未来はありそうだし、Spec を書いておいて損は無いだろう。 *3

逆に Serializer 層から型定義を生成するのは https://github.com/skryukov/typelizer を使うといいんじゃないか。私は使ったことないのでオススメ度合いも微妙なところなんだけれど。

reverse routing は必要

routes をサーバに持つ、ということでフロントエンド側で request path を組み立てる仕組みはデフォルトでは用意されていないんだけど、これもさすがに無いと生きるのがツラい。

サーバ側の routes を SSoT とした URL 組み立て (=named routes) をフロントエンドでも使える仕組みが必要になる。幾つか選択肢があるけど私は js-routes gem を使いました。

SSR がデフォルトで用意されていて助かる

https://inertiajs.com/docs/v3/advanced/server-side-rendering

  1. サーバサイドが HTML layout と component, props を返す
  2. SSR サーバが component, props を元に HTML を rendering する
  3. サーバサイドで SSR サーバからの response をマージした HTML にする
  4. SSR された HTML がクライアントに返る

みたいな感じ。

JSON を返す X-Inertia request に対しては SSR されないし、する必要も無いというのは非常に自然で好き。

ページ単位なので props が肥大化する

MPA として考えたら描画に必要なものだけなので特に差が無いんじゃないか。サーバで HTML をレンダリングしているという発想に戻せば困らない。

なんだけど、当然サイドバーを常に毎回描画し直す必要無いよねとかはあって、そういうのもちゃんと用意されているので工夫しましょう。*4

ドキュメントを LLM に渡しやすい

https://inertiajs.com/docs/ は各ページに「Copy page」ボタンがあり、クリックすると markdown がクリップボードに入る。

https://inertiajs.com/docs/llms.txt の内容ですね。

Evil Martians

これは Rails の人向け。不安を減らす理由の一つになるんじゃないか。

evilmartians.com

(日本語訳: Rails: Inertia.jsでRailsのJavaScript開発にシンプルさを取り戻そう(翻訳)|TechRacho by BPS株式会社)

inertia-rails 自体の積極開発もそうだけど、alba-inertiatypelizer も開発していて、意思が見えるので乗っかりやすい。

まとめ

Inertia.js はサーバ側は規約 (なので Laravel 以外にも Rails や Django やらなんやかんやがある)。フロントエンドは Pjax をかなり推し進めたもの。

あまりにもいつも通り書けるからビックリすると思う。Rails の 7 つのアクションをそのまま使っていて構わない。これでいいんだよ。

「画面を見れば DB 設計と RESTful URL がイメージ付く」ぐらいまで訓練されてしまった Rails 戦士にとって、Inertia.js を使ったアプリケーション開発は、いつも通りのサーバサイドを書きながらモダンフロントエンドを導入できるいい選択肢です。

*1:概念。実際は script tag に JSON を突っ込んでいる https://github.com/inertiajs/inertia/blob/v3.0.0/packages/core/src/domUtils.ts#L149-L161

*2:末端のコンポーネントからでも page props にアクセスはできるので、バケツリレーを避けることは可能

*3: 前職での体験 が良かったので似たようなものを実装した

*4:persistent layout に切り出したり、once props にしたり

Amazon Aurora DSQL を個人サービスのデータストアとして使う

データストアというかインフラ費が安くないと継続運用できないので、色んな (とはいえある程度メンテコストの低い) データストアを試しています。以前は SQLite3 + Litestream + S3 を試していました。

Aurora DSQL とは

PostgreSQL 互換の分散 DB です。マルチリージョンでのアクティブ・アクティブ構成ができる自動シャーディングされるデータストアで、イメージとしては Spanner みたいなヤツですね。

PostgreSQL との違い

公式ドキュメント に書いている以上にたくさん違います。(私が見ていた頃 (半年ぐらい前) からドキュメントだいぶ増量されている!)

書いてあるものとして

  • postgres database しか使えない
  • TRUNCATE が無い
    • DELETE FROMDROP TABLE を使え
  • 外部キー制約無い
  • Sequences が無い
  • CREATE INDEX が無い
    • CREATE INDEX ASYNC がある
  • 3,000行ずつ制限がある
    • A transaction can modify up to 3,000 rows, regardless of the number of secondary indexes

    • The 3,000-row limit applies to all DML statements (INSERT, UPDATE, DELETE)

他にも ALTER TABLE は制限がキツくて色々動かない。

  • ERROR: unsupported ALTER TABLE ALTER COLUMN ... DROP NOT NULL statement
    • NOT NULL 制約を後から外そうとしたらエラーになった
  • ERROR: unsupported ALTER TABLE DROP COLUMN statement
    • DROP COLUMN できない!!

ので、新しいスキーマでテーブルを作って、データを移して、RENAME TABLE するのがデフォルトかなーって所感です。

データを移そうとしたらこれもまた制限に引っかかったので、試行錯誤するのには本当に向いていないなと思います。オペレーションは慣れたら行けそうとも思う。実際そんなに困ってない。

  • 3000行制限: ERROR: transaction row limit exceeded
  • 10MB 制限: ERROR: transaction size limit 10mb exceeded

DSQL の先人としては以下のブログがありそうです。

ActiveRecord から扱う

幾つか違いはあるものの概ね postgres なので postgresql_adapter を拡張します。

特に困るものとしては

  • 接続時に IAM 認証が必須
  • DDL で CREATE INDEX が無くて CREATE INDEX ASYNC である
  • PK が……?

で、軽く探したらあったのでありがたく使わせて貰っています。

特に primary_keys は自分で対応を思いつけなかったので助かりました。

あと公式のサンプル。

面倒なのになんで使うの?

異常に安い。

まず無料枠がある。

https://aws.amazon.com/rds/aurora/dsql/pricing/#Free_tier

Each month, your first 100,000 DPUs and 1 GB of storage are free and automatically applied to your monthly bill.

DPU は多少しっかり使うと (毎時データをゴリッと読み書きするような Job 動かすとか) お金が掛かりますが、そうでないリクエスト数の少ないサービスなら無料なんじゃないか。

そして従量課金です。

私は普段は 無料〜$0.1 で、割とゴリゴリと使った月でも $5 とかで暮らしています。RDS や Aurora で postgres を立てるとこうはいかないので、少ないリクエストで動いている限りは従量課金は最高。

分散 DB の良いところ (マルチマスタを実現する技術) はまったく気にしていなくて 1 node として使っている目的外利用だろうから、今後どこかで値段が上がってウワーってなる気はしている。

まとめ

  • 安い DB を探していて Aurora DSQL に辿り着きました
  • 分散 DB なので高いかと思ったら逆に従量課金で安い
  • 幾つか非互換や制限はあるものの、まぁ乗りこなせる範囲じゃないか
  • 実際に私は運用できています

今年観た映画2025

ほとんどを TOHO シネマズ 二条 (自宅最寄り) と MOVIX 京都 (職場最寄りのシネコン) で見ている。T・ジョイ京都、イオンシネマ京都桂川アップリンク京都、出町座はそれぞれ 1-2 回ずつぐらい。京都は映画館が多くてメチャクチャ過ごしやすい。

44 本かー。去年が 43 本、一昨年が 46 本なので、このぐらいで安定かな。予告編を見ると見に行かなきゃって気持ちになるので割と継続的に行くことになっている。

一番見て良かったなぁって思ったのはベスト・キッドレジェンズだなー。これはもうこの作品自体がシリーズの集大成なのでほぼアベンジャーズです。

その後色々と調べたり映像見たりしたのはトワイライト・ウォリアーズ。九龍城砦つながりで GetBackers -奪還屋- を読み返したりもした。

多分私のまわりは見てないんだけどオススメしたかったのはロマンティック・キラー。

羅小黒戦記2はもっと話題になってくれ。秒速5センチメートル(実写)は API 納品する職業に就いた貴樹のシーンをたっぷり見られるのが良かったです。地獄の黙示録は今まで見たことがなかったので、劇場で見られて良かった。ダマガールは 2 年以上待っていたのでついに配給されて本当に良かったんだけど、どうしても「Kendama World Cup の映像をもっと見たい」って思ってしまった。アバターはいつも通りだけどいつもより良かった気がするな。ぜひ本物の黒で見てくれ。