概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Keep up with the Tines: Rails frontend revamp — Martian Chronicles, Evil Martians’ team blog
- 原文公開日: 2020/06/03
- 著者: Rita Klubochkina、Andy Barnov
- サイト: Evil Martians -- ニューヨークやロシアを中心に拠点を構えるRuby on Rails開発会社です。良質のブログ記事を多数公開し、多くのgemのスポンサーでもあります。
日本語タイトルは内容に即したものにしました。
成熟したRailsアプリのフロントエンドを最新にリニューアルする方法(翻訳)
Tinesはコード不要のセキュリティ自動化プラットフォームであり、世界でも指折りのセキュリティーチームが、時間のかかる手動タスクやセキュリティの脅威への迅速な対応を自動化してくれます。Tinesのサービスでは、外部システムからのさまざまなアラートを、いくつもの(ときに数百に達する)「エージェント」と呼ばれる互いに接続されたステップを含む単一のワークフロー(オートメーションストーリー)内にまとめられます。
弊社とTines様との詳しい共同作業(弊社が実装したあらゆるUX、フロントエンド、バックエンドの改善)については完全なケーススタディ記事をぜひご覧ください。弊社の実装によって、Tinesのサービスが多くの顧客を集め、対象領域のシステムパフォーマンスを100倍に高めることに成功いたしました。
以下では、ダジャレを出力する「ストーリー」を例に用いています。
ダジャレを話す「ストーリー」
今回の私たちのタスクは、長年実績を積んだこのインターフェイスに新しい息吹を吹き込み、かつ成熟したRailsのフロントエンド(マジェスティックモノリス)を現代化することでした。その対象がTines様のアプリケーションです。
急速に成長するスタートアップ企業で社運をかけた開発を進める現実世界では、リファクタリング作業が専任の「フルタイムジョブ」となることはめったにありません。今回の事例も例外ではありませんでした。私たちは新機能の開発を進めると「同時に」、フロントエンドもモダンなスタックに書き換えました。このとき私たちが選んだ戦略は次のとおりです。まず(ロジックの変更も含めて)古いスタックを尊重しながらコンポーネント全体を再設計し、いったんproductionにリリースします。そして「その作業が完了したら」すべてを廃棄し、新しいスタイルでスクラッチから書き直します。もちろんこの戦略ではコミット数が「倍増」することになりますが、それと引き換えに改修中もシステムが引き続き正常稼働できるようになります。
この戦略を選んだおかげで、思いがけない嬉しい出来事もありました。500行ものコードを一気にすっきりと消し去るプルリクを出せたのです!もし皆さんが「自社で」同じような困難に立ち向かっているのであれば、この先をぜひお読みいただき、皆さんのところでも自由に使える具体的なコード例をご覧ください。
「小さな」ダイアグラム
Tinesサービスのダッシュボードの威力は、そのダイアグラムにあります。サービスの顧客はダイアグラムを用いてセキュリティワークフローを可視化できます。過去の実装では、エージェントのダイアグラムはバックエンドで事前計算し、Graphvizで静的な画像としてレンダリングしていました。
改修前のダイアグラム画面デザイン
システムの新しい設計案ができたら、次は技術スタックを決定します。今回の主な難関は、既存のシステム、つまりRailsテンプレートビューやアセットパイプライン(旧式のSprockets方式)で提供されるアセットでできているダッシュボードに、新しいJavaScriptコードを統合することでした。
新デザインのダイアグラム画面
点と点をつなぐ
そういうわけで、実際に動作する「プルーフ・オブ・コンセプト」をvanilla JS(素のJavaScript)で実装してみた後、このモダンなフロントエンドスタックを(フロントエンドとバックエンドに明快な境界線のない)「古典的な」Railsアプリケーションの縛りの中に段階的に持ち込むことが次の目標となりました。
ありがたいことに、昨今のRailsのエコシステムはモダンなフロントエンド技術を手厚くサポートしています。
- Webpacker
- WebpackerはRailsでさくっと動きます(Rails 5.2から導入され、Rails 6.0からデフォルトになりました)。Webpackerのデフォルト設定は、カスタムWebpackのセットアップで時間を使わずに済むのがありがたい点です。あらゆる種類の標準アセットをすぐに利用でき、拡張も簡単です。
- React
- 私たちが商用プロジェクトで選択しているフレームワークです。Reactは広く普及しているので、この次にフロントエンドを書き換えるときが来てもコードが「レガシー」にならずに済みます。
- TypeScript + ESLint
- vanilla JavaScriptに型を導入することで、統合開発ツールの力を借りてリファクタリングをスムーズに行えます。アプリケーションの古い部分と新しい部分が、コードを実行する前であっても正しいデータをやりとりできるようになります。
- MobX
- 一般にはReduxの方が人気ですが、まったく白紙からの開発でない場合は単一のイミュータブルストアのメンテナンスが難しくなる可能性があります。MobXではマルチプルストアを利用できますし、使い方もいたってシンプルです。
- GraphQL
- graphql-requestおよびgraphql-codegenと組み合わせることで、強く型付けされた本当に必要なデータだけをサーバーからフェッチできるようになります。
- Tailwind
- Tailwindは今回メインで用いるスタイリングツールであり、複雑なアニメーションやカスタムスタイルにCSSモジュールも使います。私たちが今回のタスクのために開発した画面デザインシステムはマイクログリッドをベースにしており、スペーシングなどあらゆるサイズを3px(TailwindなどのCSSシステムにおける最小単位)の倍数に設定できるので、今回のアプローチとの相性も完璧です。WebpackerのおかげでCSSモジュールとPostCSSをすぐ使えるので、コンポーネントの内部できれいに分離されたスタイルを大気圏脱出速度並の爆速で書けるようになります。
GraphQLとRubyについては以下のEvil Martiansブログをどうぞ。
ヘルパーは一度にひとつで十分
既に数千ものユーザーがセキュリティタスクのためにTinesサービスをproduction環境で使っています。したがって、まったくの白紙から書き直すという選択肢は決してありえません。既存のコードベースに新しい機能をひとつひとつ辛抱強く、段階的に導入する必要があります。
最初に、すべてのvanilla JSコードを独立したバンドルにまとめてWebpackerで提供できるようにしました(RailsとWebpacker界隈ではこのバンドルを「pack」と呼んでいます)。ダイアグラムのコアとなるHTMLと、その周りのダッシュボード用要素は、これまでどおりRailsサーバーがレンダリングするビューテンプレートの中に留まります。
こうすることで、Reactを導入する前であってもグローバル関数やRailsヘルパーを頼りに新しいフロントエンドを提供できるようになります。
// frontend/components/diagram/index.js
const renderDiagram = function renderDiagram(agents, story) {
// ダイアグラムのロジックが数千行
};
export default renderDiagram;
グローバル関数の定義には、新しいダイアグラムで使うvanilla JSのソースが含まれます。
// frontend/packs/diagram.tsx
import renderDiagram from "../components/diagram";
// ...
window.renderDiagram = renderDiagram;
サーバー側でレンダリングされる各ビューで、以下のようにこれらをすべて読み込みます。
<!-- app/views/diagrams/show.html.erb -->
<script type="text/javascript">
window.addEventListener('DOMContentLoaded', function() {
renderDiagram(
<%= raw(@agents.to_json) %>,
<%= raw(@story.to_json) %>,
);
});
</script>
他にもカスタムパスヘルパーを作成しました。これは、アプリケーションの他の部分からリソースへのリクエストがあった場合に自分たちのJSバンドルからRails標準のルーティングを利用するためのものです。
// frontend/api/paths.ts
export function eventPath(id: string): string {
return `/events/${id}`;
}
Reactを導入する
新しいコードがproduction環境で確実に動くことを確認できたので、いよいろReactコンポーネントの段階的導入に手を付け始めました。最初は、ダイアグラムの各エージェントを制御するボタングループのような小さなUIから手掛けました。
エージェントパネルのみReact、その他は改修前
// frontend/packs/diagram.tsx
import * as React from "react";
import { render } from "react-dom";
render(<Panel />, document.getElementById("diagram-panel"));
パネル上部のコントロールにはclick
イベントハンドラがあり、これはUIの「非React化部分」に影響する(まだCoffeeScriptで書かれたものが残っている)ので、まだMobXストアを使える状態になっていません。
そこでつなぎの策としてJavaScriptアセットの直下にevents/ディレクトリを作り、イベントをdocument
オブジェクトにディスパッチする関数をそこに保存することにしました。ここでは、多くのブラウザでサポートされている CustomEvent
インターフェイスを用いています(残念ながらIEは別です)。
// frontend/events/diagram.ts
export function deleteAgent(): void {
const event = new CustomEvent("diagramDeleteAgent");
document.dispatchEvent(event);
}
// frontend/components/panel.tsx
import * as React from "react";
import { deleteAgent } from "../../events/diagram";
export default function Panel() {
return <button onClick={deleteAgent}>Delete Agent</button>;
}
detail
プロパティを持つカスタムイベントには任意のデータも追加できます。
// frontend/events/diagram.ts
export function agentNameChanged(guid: string, id: string, name: string): void {
const newEvent = new CustomEvent('agentNameChanged', {
detail: { guid, id, name }
});
document.dispatchEvent(newEvent);
}
これは既存のCoffeeScriptアセットでも使えるので、events/ディレクトリに.cofee
ファイルを作成しておきさえすれば、書き換えを先延ばしにできます。
// app/assets/javascripts/components/utils.js.coffee
newEvent = new CustomEvent("dryRunModalLoaded", {
detail: { json: newEventJSON },
});
document.dispatchEvent(newEvent);
これで、アプリのカスタムイベントがどこにあってもハンドラを追加できるようになります。簡単ですね!
// frontend/components/diagram/index.js
document.addEventListener("diagramDeleteAgent", () => {
// エージェントのロジックを削除
});
document.addEventListener("agentNameChanged", (event) => {
const { guid, id, name } = event.detail;
// エージェント名のロジックを変更
});
// アプリのどこでもよい
document.addEventListener("dryRunModalLoaded", (event: Event): void => {
const { json } = event.detail;
// Dry run logic
});
ストアには何が入るのか
ここまでにできるようになったのは、カスタムブラウザイベントを発火およびキャッチすることだけです。最終的に行いたいのは、適切なステートマネージャをアプリに配置することです。
もしサーバー側でレンダリングされるHTMLと、フロントエンド側のJavaScriptの一部が癒着したままだと、何か動的なことをするたびにDOMをいちいち手動で操作しなければならなくなってしまいます(ユーザーがダイアグラムのエージェントをクリックした「後で」リンクを変更するなど)。
const link = document.getElementById("agent-action-run");
link.setAttribute("href", "<%= run_agent_path(@agent) %>");
link.removeAttribute("disabled");
このアプローチではうまくスケールできなくなりますし、このままフロントエンドを「JSX化」し続けると今後もひたすらややこしくなってしまいます。そこでいよいよ、アプリケーションの現在のステートを保存する共通の場所を導入し、コンポーネントをそこにサブスクライブして自動アップデートできるようにするようにしましょう。これについては既に、MobXのルートストアからすべてのagent
やstory
にアクセスできるようになっています(おさらい: 「ストーリー」はエージェントのダイアグラムです)。私たちがMobXを選んだ理由は、この柔軟性が欲しかったのと、アーキテクチャの選定を不必要に硬直化させずに済むためです。
// frontent/store/index.ts
export class Store {
agents: Agent[] = [];
story: Story;
setInitialData(agents: Agent[], story: Story): void {
this.agents = agents;
this.story = story;
}
}
export default new Store();
この時点ではまだすべてのフェッチャーが揃ったわけではありませんし、RailsからのデータはすべてrenderDiagram
という素のJavaScript関数呼び出しだけを経由してやってきていますので、ここで初期ステートをセットアップするのは一見直感に反しているように思えるかもしれません。しかしMobXの大きな強みはここにこそあるのです。ストアはどんなファイルにでもimport
できますし、ストアのどのメソッドでも使えます(プロパティによっては再宣言すら可能です)。
// frontend/components/diagram/index.js
import store from "../../store";
const renderDiagram = function renderDiagram(agents, story) {
store.setInitialData(agents, story);
// ...
};
MobXだとこんなに簡単にやれる理由は、(Reduxと違って)改変可能な構造体も扱えることと、配列やオブジェクトやクラスなど多くのものをストアで扱えるからです。これで、frontend/store/index.tsで宣言したStore
クラスの同一インスタンスをアプリのあらゆる部分から参照できるようになります。
この極めてシンプルなストアには、次のプロパティを追加できます。ひとつはエージェントID用のobservable
プロパティで、もうひとつのcomputed
プロパティはアプリケーションの他の場所で用いられる全エージェントのデータを保持します。
// frontent/store/index.ts
import { observable, computed } from "mobx";
export class Store {
// ...
@observable selectedAgentId?: number;
@computed get selectedAgent(): Agent | undefined {
if (this.selectedAgentId === undefined) {
return undefined;
}
return this.agents.find((agent) => agent.id === this.selectedAgentId);
}
}
これで以下のように、ストア全体をシンプルなプロパティとして、レンダリングされたReactコンポーネントにimport
できます。
// frontend/packs/diagram.tsx
import * as React from "react";
import { render } from "react-dom";
import store from "../store";
render(<Panel store={store} />, document.getElementById("diagram-panel"));
同様に、ストアのメソッド呼び出しやプロパティの再定義も、素のJSファイルを含めたどんな場所でも直接行えるようになります。
// frontend/components/diagram/index.js
import store from "../../store";
const renderDiagram = function renderDiagram(agents, story) {
store.setInitialData(agents, story);
// ...
const selectAgent = (id) => {
store.selectedAgentId = id;
};
};
変更はすべてこのストアに反映されるようになります。このときアクションのcreatorやreducerといったものは不要です。このアーキテクチャはJSのクラスをひとつ持つ1個のファイルに収まっています。これをobserver
にラップすれば、あらゆるReactコンポーネントがそのストアのプロパティを受動的に監視(reactively observe)できるようになるので、明示的なDOM操作を一切行わずに「URLが変更されるリンクの作成」や「エージェントのステータス変更の有効化」を行えるようになります。
// frontend/components/panel.tsx
import * as React from "react";
import { observer } from "mobx-react";
import { Store } from "../../store";
import { runAgentPath } from "../../api/paths";
interface Props {
store: Store;
}
export default observer(function Panel({ store }: Props) {
return (
<a
href={store.selectedAgentId && runAgentPath(store.selectedAgentId)}
disabled={!store.selectedAgentId}
>
Run Agent
</a>
);
});
ときにはさらに複雑怪奇なReactコンポーネントに遭遇するかもしれません。そんなときはReactのContextを用いてストアのデータを引き回せれば、ネストしたコンポーネントだけをobserverとして宣言しやすくなり、プロパティがぐちゃぐちゃになることを避けられるでしょう。
// frontend/store/context.tsx
import * as React from "react";
import rootStore from ".";
const StoreContext = React.createContext(rootStore);
export default StoreContext;
// frontend/packs/diagram.tsx
import * as React from "react";
import { render } from "react-dom";
import store from "../store";
import StoreContext from "../store/context";
import Panel from "../components/panel";
render(
<StoreContext.Provider value={store}>
<Panel />
</StoreContext.Provider>,
document.getElementById("diagram-panel")
);
// frontend/components/panel.tsx
import StoreContext from "../../store/context";
export default observer(function Panel() {
const store = React.useContext(StoreContext);
// ...
});
ここまでをひとつにまとめる
MobXのもうひとつ素晴らしい点は「ストアを欲しいだけいくつでも作れる」ことです。たとえば、アプリ内の通知ロジックを分離されたストアに移行することもできます。ただしindexストアの一部のコアプロパティは他のストアからアクセスしなければならないこともありますし、rootストアもこれらを参照する必要があるかもしれません。これは以下のような方法でエレガントに解決できます。
- サブストア用のクラスを別途作成する
- rootストアのコンダクタ内でサブストアごとのインスタンスを1つずつ作成し、
this
(つまりrootストア)を引数として渡して、サブストアのコンダクタ内で利用する
// frontend/store/notifications.ts
import { Store } from ".";
export default class NotificationsStore {
rootStore: Store;
constructor(rootStore: Store) {
this.rootStore = rootStore;
}
showError = (text: string): void => {
// Logic to show error in the UI
};
}
// frontent/store/index.ts
import NotificationsStore from "./notifications-store";
export class Store {
notificationsStore: NotificationsStore;
constructor() {
this.notificationsStore = new NotificationsStore(this);
}
}
これで、共通のバンドル内のあらゆるファイルがnotificationsStore
にアクセスできるようになり、アプリ内エラーもトリガされるようになります(今はこれらのエラーをReactでレンダリングすることもできます)。
// frontend/components/diagram/index.js
import store from "../../store";
const renderDiagram = function renderDiagram(agents, story) {
// ...
store.notificationsStore.showError("Something went wrong");
};
Tinesは変化し続ける
訳注: 原文見出し「Tines They Are a-Changin'」は、ボブ・ディランの歌のタイトルのもじりです。
参考: ボブ・ディランの「The Times They Are a-Changin’」の中の[a-]ってどんな意味?
ここまでは、ほぼダイアグラムページひとつだけを対象に改修してきました。しかし古いインターフェイスには次なる書き換え候補となるコンポーネントがまだいくつか控えています。書き換えの時期が来たらWebpackerの「pack」を使えば、面倒な設定なしに多種多様なスタンドアロンReactアプリをいくつでも好きなだけ作れます。app/javascript/packs
の下に新しい入力ファイルをひとつ作成して、Railsビューでそれをインポートすれば完了です。
新旧入り交じったインターフェイス内のJSONEditorender
を呼ぶコンポーネントラッパーのあたりでdata-attributes
を使うことも可能です。
<!-- app/views/agents/_form.html.erb -->
<div
id="agent-options-editor"
name="agent[options]"
data-options="<%= JSON.pretty_generate(agent.options) %>"
></div>
// frontend/packs/jsoneditor.tsx
import * as React from "react";
import { render } from "react-dom";
import JSONEditor from "../components/json-editor";
const optionsEl = document.getElementById("agent-options-editor");
if (optionsEl) {
const value = options.getAttribute("data-options") || "{}";
render(<JSONEditor value={value} />, optionsEl);
}
古いフロントエンドと新しいフロントエンドが平和に共存するのは無理だと言ったのは一体誰でしょう。Railsで両者を共存させることはまったくもって可能なのです!
私たちがTines様のために編み出したこのアプローチは、一瞬のダウンタイムも許容されない、あらゆる成熟したRailsアプリで利用できます。ビジネス資産やユーザーの支持や技術リソースを犠牲にすることなく、フロントエンド全体を段階的にモダンなスタックに書き換えられるのです。既存のエンジニアリング文化を根底からリニューアルする必要もありません。インクリメンタルなアプローチを採用することで、変更の実装を外部コンサルタントに依頼してもチームの結束を引き続き維持できます。
私たちがTines様の案件で積んだその他の実績については、つい先頃公開したばかりの以下のケーススタディ記事をぜひご覧ください。
急成長中のスタートアップ企業で火星人の力を借りたくなりましたら、ぜひお気軽にEvil Martiansのフォームにてお知らせください。
Evil Martiansでは、火星式の製品開発およびご相談を承っております。