🏢

フロントエンドの段階的モダナイズ、のための自走設計 (株式会社スタディスト様)

2024/12/06に公開

株式会社スタディスト様の依頼で、フロントエンド傭兵として、Rails 内の巨大SPA の段階的なモダナイズの提案を行った事例紹介です。

https://gist.github.com/mizchi/b803f6872035248f86309057d6c97ee9

いつもはパフォーマンス視点で仕事にかかるのですが、今回はマクロな設計視点でソースコードを読んでいきます。一旦は中期ゴールを提案しつつ、その作業の必要性を通して、なぜその変更が必要なのかという解説をしていきました。

コスパが良い部分からやりたいですね。でもコスパ感覚は人それぞれです。あくまでフロントエンド専門家の自分が優先度付けるなら、という観点でやっていきます。

今回の仕事にあたっていくつかの技術的な課題を取り上げますが、それはスタディスト様に問題があるという話ではありません。むしろ問題を修正しようという意思が強く、実際1ヶ月の期間中にいくつかの修正をマージすることもできています。

以下、敬称略。注意点として、今回の内容は中の人達が見返すための記述が多いので、この記事単独では意図が伝わりづらいかもしれません。


相談内容

パッケージアップデート、Vue compositionAPIへの移行、脱WebPack(-> Vite)など想定している作業は色々とあるが、他に改善すべき点は何があるのか見てほしい。中長期的に自分たちで改善していけるように修正方針や考え方を伝えてほしい
その過程で無理のない範囲で実際に課題を潰してほしい

大まかな技術構成

  • Rails
  • Node/TypeScript
  • yarn v1
  • Vue Router の SPA
  • OpenAPI(SwaggerUI)
  • Storybook + RegSuite の VRT を実施

Studist Engineering Entrance Book: 技術スタック

ゴール設定

  • 安心して開発できる状態をつくる
  • 現在発生している問題を洗い出す
  • 中期的に問題を解决するゴールを作る
  • 自走できる状態を作る

一旦全部やってみる

一旦手元で、自分が思う理想状態に向けて素振りしてみる。マージしない前提で、テストが壊れても気にせずやる。(あとで綺麗にPR作る前提)

  • ファーストインプレッション
    • 数年前のベストプラクティスをベースに、部分的に追従
    • ツールチェインの更新が間に合っていない部分があり、一部で何かを上げたら何かが壊れる、というデッドロックが発生している
    • ぱっと見 Rails に依存していないようにみえるが、エントリポイントは Webpacker の javascript_packs_tag 相当。その意味は?
  • これは静的SPAなのか?を実装的に確認して、切り離せるか確認する(後述)
  • 依存管理: yarn v1 から yarn v4 に上げられるか
    • まずはバージョン管理の準備のため、パッケージマネージャ本体を更新したかった
    • yarn v4 を選ぶ理由は、 CI内に yarn <cmd> のyarnのパス解決依存のタスクが多いので、一旦 yarn 系のままバージョンを上げるのが安全と判断
    • 問題: yarn install --modules-folder /usr/local でデフォルトのモジュールパスをハックしている
      • これに対応しているのは yarn v1 のみで、移行のブロッカー
      • Docker が遅いことに対するハックとして存在
    • 問題: yarn install --ignore-optional でバイナリ依存を無視して、ネイティブ依存が動いていない
      • 高速なツールチェイン(Rust系)が一式動かない (optional dependencies で選択的に入るが、全部無視される)
      • これはローカル開発とRailsコンテナとの整合性取ろうとした結果に見える
  • yarn upgrade-interactive でツールチェインを更新 (v1/v4両方でやってみる)
    • 問題: 依存を更新する Swagger(OpenAPI) スキーマからのモック生成が再現できない
      • 調べたら内部依存の js-yaml に問題がありそう (後述)
    • redoc-cli (deprecated) -> @redocly/cli に上げると、同じスキーマ定義をパースできなくなった
      • $ref の JSONPath(./reference/account.yml#/path/~1foo~1/bar) の方言を解决する speccy の挙動が再現しなくなった
    • vitest 0.34 -> 2.x の最新版で落ちたり落ちなかったり、安定しない
  • 依存からバイナリ依存が省かれてる理由を調査
  • OpenAPI 定義あったので、クライアントコード生成を試している
    • examples メタデータから Storybook 用のモックインスタンスを生成している
    • 試しにコード生成を試すと、何らかのスキーマ方言が多く、なかなか動かない。。。
    • バージョン自体を上げたら、そもそも動かない

ということを試し切りで大雑把に構成を理解した。

一旦この状態から、持続性のあるフロントエンド開発状態に辿り着く経路を考えることにした。

ゴール設定

  • 依存管理を行うための、パッケージマネージャを最新かつ、きれいな状態にする
    • yarn(v1) install --ignore-optional --modules-folder="..." -> yarn(v4) install
    • 特定バージョンへのハックをやめて、最新のプレーンな状態にする
  • yarn v4 で package.json にバイナリ依存を含められるようにして、更新
    • 基本的に SPA なのでビルド自体が成功すればOK
    • 一昔前はともかく、Rust依存が多い今は、バイナリ依存を避け続けるのは限界
  • 開発環境の Rails と Node.js のコンテナを切り離す
    • Rails Docker コンテナと同居しており、ここで様々な(フロントエンドと無関係な)ノイズが発生している
    • Docker とNode.js のコンテナを分離する (Nodeはローカル実行するか、docker-compose 上の別コンテナで実行)
  • 本番環境のフロントエンドを、静的SPAとしてデプロイできるようにする
    • Rails から配信されているフロントエンド部分を、静的SPAに切り離す
    • そのために OpenAPI 定義をクライアント・サーバー間の契約とし、コード生成する
    • 実際に切り離すかどうかは要確認だが、疎結合な状態の保証を取れる状態にする

Rails と Node.js を切り離す

開発コンテナ分離を分離する、その目的

フロントエンドが複雑でない前提のサーバーサイドでは、Node.js は補助的な扱いであり、依存の一つとして扱われる。

それで済むケースではいいが、大規模なフロントエンドを構築するように発展した場合、Rails と Node.js の複雑性を、同じコンテナに押し込むのが難しくなる。

たとえば、今回は一つのDockerコンテナに押し込めようとして、ロックファイルの再現性を担保するために yarn v1 にしかない --ignore-optional を使ったことで、ネイティブ依存を含むものへアップデートできない状態に陥っていた。

ネイティブを含むロックファイルの生成は、完璧とは言えないが最新の npm/pnpm/yarn ならある程度の再現性がある。一旦ツールチェインが最新になっている状態を目指したい。単に yarn v1-> v4 は速度4倍ぐらい速くなる。

Mac 上の Docker が遅いことに対するハックは、Orbstack のようなMacで高速な環境を採用、そもそもフロントエンドはDockerをやめる、という対処の選択肢がある。

そもそも問題が発生した起点は、Rails は文化的に Docker で動かすことが多く、また数年前まで Webpacker + yarn がデフォルトだったので、Rails の構成上依存の一つで Node.js を同居させたい引力があったこと。

ただ、今の Rails 8 は Webpacker 既に廃止済みかつ、デフォルトのフロントエンドバンドラとしてオールインワンな Bun を推奨している。 (個人的には、ここは将来的の新たな負債になると予想している。そこまでBunがベストプラクティスになる未来が見えない)

実際にこのプロジェクトで発生している問題は、Rails 内 Node.js で実行される Webpack が Docker 上でパフォーマンス問題とロックファイル不整合を起こすという点にあるように見える。

複雑化したフロントエンドが実際に存在していて、前提のミスマッチがあるこの状況設定なら、自分は「Rails から Node.js を分離する」が正攻法と考えた。なのでここを検討する価値がある。

実際のコンテナ分離方法の提案

Node.js の責務を確認したところ、これだけということを確認

  • public/* 以下にファイルを生成
  • OpenAPI の定義から、Storybook用のモックとドキュメントを生成

一旦 Rails 内でサブディレクトリを切って、 frontend を押し込む。

実際にバンドル処理を行うコンテナも分離する。
その上で、Rails の app/javascript/ 以下を frontend 以下に押し込んでみる。

app/
  javascript/ # 空
frontend/
  package.json # build すると ../public/dist にアセットを生成 or CDN リリース直通
  Dockerfile # Option
public/
  dist/
docker-compose.yaml # frontend の下のタスクを呼ぶ
Dockerfile # Option
Gemfile.lock

(これを行うPRは用意したが、実際にはコンフリクトが予想されるので、あくまでデモという位置付け)

このとき、Docker はオプションという扱いが望ましい。ローカル環境では基本的に使わない。「仮想化やオーバーヘッドがあるが無設定で動くやつ」を、CIで動作確認はしている、ぐらいの扱いにする。

タスクを定義するときも、Docker を経由するかをオプションとして扱う。

というのを Makefile で定義する例

DOCKER ?= 0
DOCKER_IMAGE = alpine:latest
# 環境に合わせる。docker-compose run かも
DOCKER_RUN = docker run --rm -v $(PWD):/app -w /app $(DOCKER_IMAGE)

ifeq ($(DOCKER),1)
    RUN = $(DOCKER_RUN)
else
    RUN =
endif

build:
	${RUN} echo 1
# 普通はローカル環境で実行
$ make build
1
# DOCKER 経由で実行
$ DOCKER=1 make build
docker run --rm -v $pwd:/app -w /app alpine:latest echo 1
Unable to find image 'alpine:latest' locally
latest: Pulling from library/alpine
da9db072f522: Pull complete 
Digest: sha256:1e42bbe2508154c9126d48c2b8a75420c3544343bf86fd041fb7527e017a4b4a
Status: Downloaded newer image for alpine:latest
1

これでホストでそのまま動かしたい人の需要、Dockerでやりたい人の需要、タスクが複雑化して間違って Docker 内 Dockerを呼んでしまう問題に対処できる。

というわけで、コンテナ分離ができることを確認。(実際には泥臭いCIタスクの修正がいっぱいあるが、そこはあとで)

Rails から 静的サイト分離ができるかの確認

今回は、基本的には素朴な Vue rouer の SPAだが、エントリポイントのみ Rails になっている。

Webpacker は使われていないが、Webpacker と同じ仕様の WebpackManifest のメタデータに依存している。

Webpacker には javascript_include_tag を拡張した javascript_pack_tag という機能がある(あった)。

<%= javascript_pack_tag 'main' %>

これは、Webpackが生成するファイルのビルド前後のソース対応を、ビルド時に生成する manifest.json を経由して解決する。

例。

{
  "main": "./dist/main-<hash>.js"
}

Webpacker の概要 - Railsガイド

The Manifest | webpack

Webpacker は使われていなかったが、同等のこの仕様だけが残っていた。

この仕組みは一応 Vite にもあるのだが、Webpackと同じフォーマットかどうか、調査する必要がある。

ビルドオプション | Vite

あえてこの仕組みに依存する必要もないので、安心して Vite に移行するためにも、この仕組みに載ること自体をやめる方向で考える。

本当にRails上のアセットパス解決が必要なのか?

直感的は不要という認識があった。

というのも、Vue Router を使ってる以上はエントリポイントの払い出しは Router とバンドラが制御していて、逆にRailsにその能力はないはず。

// 常に読み込まれるルーター本体
const router = createRouter({
  routes: [
    {
      // ブラウザのURLパターンに応じて
      // 動的に./views/UserDetails.vue に対応するJSがロードされる
      path: '/users/:id',
      component: () => import('./views/UserDetails.vue') },
  ],
})

https://router.vuejs.org/guide/advanced/lazy-loading.html

で、ランタイムで確認したところ、ほぼすべてのページで常に同じ Router が含まれる Chunk を間接的に解決して起動していた。

自分の手元だけでは不安なので、より本番に近い環境で動かせる人に「ここをビルド済みのメインチャンクのパス main-<hash>.js を直接指定したらどうなります?」と試してもらって、無事動くことを確認した。

というわけで調査の結果、「RailsViewからみて、常にJSのエントリポイントはルーター含むチャンクで固定」ということを確認。つまり、特定の認証処理を除き、本来的には静的サイトとしてデプロイ可能なアプリケーションだった。

必要かも?と思っていた javascript_pack_tag は Webpacker の亡霊みたいなものだった。

CSRF Token の扱いを決める

じゃあこれで静的に分離できるかというと、もう一つだけ問題があった。それが CSRF トークン。

<meta name="csrf-token" content="...sometoken">

https://techracho.bpsinc.jp/hachi8833/2021_11_26/46891

Rails は View HTML の展開時、クライアントに「動的に」固有のCSRFを付与する。
クライアントは、 fetch/xhr のリクエストヘッダにこれを付与して送り返す。

これによって、セッションハイジャックされていないかを検証することができる。

セッションハイジャック - Wikipedia

これは必要な仕組みなんだろうか。結論から言うと、現代のブラウザは SameSite: Lax; がデフォルトであり、これに対して意図的に SameSite: None; で無効化しない限り、攻撃者はセッションハイジャックを行うことはできない。

https://developer.mozilla.org/ja/docs/Web/Security/Types_of_attacks

じゃあそのままCSRFトークンの処理を抜けるかと言うと、今回は SameSite: None; が指定されていた。こうなっている理由を確認すると、サイトを iframe として埋め込みたい、という要求があることを確認した。

つまり単にCSRFトークンを抜いて静的サイト分離すると、セッションハイジャックが発生する懸念がある。セッションハイジャックとどれぐらい真面目に向き合うかはさておき、技術的にこの懸念を払拭するには、 X-Frame-OptionsSameSite を同時に制御すればよい。

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/X-Frame-Options

正直、ここから先は仕様をどう決めるかになる。そもそも埋め込み可能なiframeのコンテンツをどう制御するかという話で、たとえば「埋め込みURLは専用に発行する」が比較的安全な選択肢になる。しかしそうなると、既に埋め込まれたリンクの互換性の話になってくる。

理論上は可能なことのデモと、懸念点を共有したうえで、次の調査に移った。

OpenAPIからクライアントコードを生成したい

なぜやるか

いきなり主張強めの意見になるのだが、そもそもフロントエンドでTSが普及しているのは、JSONをテストできるAPI層と違って人間向けの複雑な構造の UIインスタンスをチェックするのが、E2E 視点で大変だから、という認識。

で、このプロジェクトでは頑張って Vue の UI 層 setup lang=ts でに型を入れようとしていた。確かにそのアプローチもありなのだが、今回は OpenAPI の定義がある。これを使わないのは、非常にもったいない。

なので、API通信部分から型が生成できれば、それを参照するようにしていけば天下り的に型が付くはず。

プロジェクト内にあった JSONSchema の例。

paths:
  /:
    get:
      summary: 指定日アーカイブ一覧
      tags:
        - 指定日アーカイブ
      responses:
        '200':
          description: ''
          content:
            application/json:
              schema:
                type: object
                properties:
                  archive_schedule_size:
                    type: number
                  archive_schedules:
                    type: array
                    items:
                      $ref: ../models/ArchiveSchedule.yml
                required:
                  - archive_schedule_size
                  - archive_schedules
              examples:
                example-1:
                  value:
                    archive_schedule_size: 1
                    archive_schedules:
                      - id: 327
                        datetime: '2021-06-30T15:00:00Z'
                        status: before

型生成はしてないが、ここの examples を取り出して、Storybook のモックとして使っていた。賢い。

というわけで、RPC層のコードを生成するデモをしようとした。

コード生成チャレンジ

このプロジェクトでは、途中まで https://stoplight.io/ というGUIツール(SwaggerUIと思われる)で OpenAPI 定義を生成していたが、途中からこのツールが使いづらくなってツールが生成した yaml を手で修正するようになっていった(らしい)。

その結果、機械で生成した特有の、見慣れない出力になっていた。エントリポイントに含まれる特殊な定義の例。

  /groups:
    $ref: reference/groups.yml#/paths/~1

この仕様を調査したところ、(あまりドキュメントがないのだが) ググって断片的に見つかる情報をつなぎ合わせると、どうやらこういう仕様らしい。

で、stoplight の生成する内容を、 redoc-cli で展開していた。

https://www.npmjs.com/package/redoc-cli

で、この redoc-cli が deprecated。最新の @redocly/cli を使うようにと警告される。

なので redocly を使ってドキュメントを生成しようとしたところ、そのままでは動かない。どうやら、これが元の方言の仕様を落としているように見える。

過去の redoc-cli の実装をみると speccy という実装を使っていたが、今は使ってない。

https://www.npmjs.com/package/speccy

これは 5年前からメンテされてない。その上、試しに speccy と最新 redocly を共存させた環境を作ると、なぜかその内部依存の React の peerDeps でコンフリクトしはじめて、エラーが置き始める。(ここは単にspeccy/redocのフロントエンド的な出来が悪そうに見える)

ここで一旦、ライブラリを通さず手作業でこの独自 JSONPath を解決するリゾルバを書いてみて、動いた。しかしコード量が多くなって、これをコミットするのもな…となって、だいぶ悩んだ。

とはいえ、ここで仮で動くものができたので、(最終的には捨てた)方言が少ない OpenAPI からコード生成を試みた。

Orval でコード生成

今の状態からコードを生成するには、二段階の実装が必要。

  • Speccy の JSONPath(の拡張方言) の仕様を実装して一つのファイルに展開
  • そこからOpenAPIのドキュメントとコードを展開

これが出来たら、コード生成に辿り着ける

今回は Swagger UI をベースに想定していた上でorval が生成するコードが一番合致した。Speccy の拡張方言も解決できた。(とはいえ、本当にここに依存していいのか?という懸念は残る)

ストア層(pinia)に生成コードで型を付けるデモを用意

既存のコードを元に、生成したコードを使うデモを用意した。

import { defineStore } from 'pinia'

// 注: models/user.yml を変換したら UserYml になってしまい
// リネーム制御ができなかった。改善余地あり
import { getUser, type UserYml } from '@/$gen'

type UserStoreType = {
  user: UserYml | null
}

export const useUserStore = defineStore('user', () => {
  const state = reactive<UserStoreType>({
    user: null,
  })
  async function loadUser(id: number) {
    // 元々は await axios.get("/user")
    // 例外処理などは略
    state.user = await getUser(id)
  }
  return { state, loadUser }
});

これでクライアントステートの上流に型がつく。あとはVueのコンポーネント層の props や use*Store で、これを参照するように修正していけば、アプリケーション全体に自然と型が付くようになる。

今まではレスポンスを any として受け取って、サーバークライアント間のスキーマがあやふやでそれぞれが自前でバリデートして値に格納していた。この状態が解消する。

あとは、個別の axios.get をスキーマから生成されたコードに書き直すと型がつくので、それに合わせて型違反を確認していく、という流れになる。

JSONSchema 違反 / Mock が再現しない

というのは最終的に実現できたが、実際にはすんなり言っていない。

stoplight が生成したであろうコードをそのまま orval に食わせると、色々なエラーが出た。

いくつか例を出す

ex1:
  # items 指定で暗黙の array とするのを orval は許可しない
  items:
    type: string
ex2:
  # yaml 実装の解釈次第で、文字列の 'null' か値の `null` かブレる
  type: null
ex3:
  type: string
  # JSONSchema v4 の仕様で、現行の v7 にはない。orval はエラー
  nullable: true
# 定義自体が null。スキーマ違反
ex4: null
ex5:
  # js-yaml 特有の処理で DateTime 文字列をパース時に JS Date オブジェクトに変換する
  # https://yaml.org/spec/ では
  # https://github.com/nodeca/js-yaml?tab=readme-ov-file#load-string---options-
  created_at: '2021-06-15T11:04:50'
ex6:
  # Dateフォーマットの違反、あるいは何かの方言
  # js-yaml は文字列として解釈
  created_at: '2021-06-15 11:04:50'
# operation 定義
pathsEx1:
  # JS 関数にできない operationId
  operationId: head-xxx

これらは Swagger 2.0/OpenAPI 3.0/OpenAPI3.1 でどうも参照する JsonSchema のバージョン…というより想定する実装が違う。おそらくv4/v7の仕様の差だが、何を許して何を許さないかは、正直実装方言としか言えない感じではあった。

この辺は機械的に直せるものだけ修正して、迷ったものはコード生成出来る範囲で最新仕様を満たす曖昧な型表現に寄せておいたのを、PRとして作っておいた。


伝えたかったこと

これらの内容を、それぞれPRとして作成しつつ、週次のMTGで口頭で意図を説明した。というのが今回の仕事内容。

とはいえ、自分が傭兵として途中に入っても一時的によくなるだけで、そもそも開発組織がどこを目指すか、何がいい状態かのビジョンを持つには、どうしたらいいだろうという話で考えてくる必要が出てくるように感じた。

これは本質的に難しい問題だが、とりあえず自分がやってることを紹介する(した)。

  • 毎週 https://jser.info/ をフワッと眺めておく
    • 全部読む必要はないが、紹介タイトル一覧だけでも目を通す。トレンド傾向がわかる
  • State of JavaScript を毎年確認
    • 自分が使っているものがトレンドから外れてないか確認
    • 顕著にダウントレンドの場合、乗り換えを検討
  • https://daily.dev/ で自分に必要なタグでフィードを作って購読

これらを認識した上で、定期的に同じ構成+気になってる構成で、理想的な小さなプロジェクトを作ってみよう、という提案をした。

たとえば、今回の仕事の範囲で作った検証プロジェクトの例

とはいえ、自分はフロントエンドのオタクなのでやっているところもあるので、各自でコスパが良い範囲で取捨選択は必要だと思う。自分個人としてはフロントエンドのみ横に広いのが価値だが、自社プロジェクトを制御する範囲なら、別にオタクになる必要はない。とはいえ、理想的には広く見て必要なものを払い出したい。

そこのバランスをどう取るかは、その時どれだけの問題を抱えてるかの、温度感次第。

まとめ

個別の問題の解き方も大事だが、まずゴールをどう認識するか、何を目指すかを決めるのが大事。逆に言うとそれさえあれば適切な実装アプローチは自然とついてくることが多い。

そのうえで、そういう発想を起点にバージョン管理ポリシー、サーバー/フロントエンドの分離、コード生成というテーマで改善を提案し、実装した。

Discussion