Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

成熟したRailsアプリのフロントエンドを最新にリニューアルする方法(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

成熟したRailsアプリのフロントエンドを最新にリニューアルする方法(翻訳)

アセットパイプラインで提供される既存のjQueryやCoffeeScriptのフロントエンドを一切損なわずに、「React」「MobX」「GraphQL」「Tailwind CSS」「Webpacker」の複雑なUIを導入しました。私たちがTines様のセキュリティプラットフォームで複雑な機能を実装したときの経緯と、成熟したRailsモノリスでインクリメンタルなリファクタリングを行う方法をご紹介いたします。

Tinesはコード不要のセキュリティ自動化プラットフォームであり、世界でも指折りのセキュリティーチームが、時間のかかる手動タスクやセキュリティの脅威への迅速な対応を自動化してくれます。Tinesのサービスでは、外部システムからのさまざまなアラートを、いくつもの(ときに数百に達する)「エージェント」と呼ばれる互いに接続されたステップを含む単一のワークフロー(オートメーションストーリー)内にまとめられます。

弊社とTines様との詳しい共同作業(弊社が実装したあらゆるUX、フロントエンド、バックエンドの改善)については完全なケーススタディ記事をぜひご覧ください。弊社の実装によって、Tinesのサービスが多くの顧客を集め、対象領域のシステムパフォーマンスを100倍に高めることに成功いたしました。

以下では、ダジャレを出力する「ストーリー」を例に用いています。

ダジャレを話す「ストーリー」

ダジャレを話す「ストーリー」

実際には、メール添付ファイルのマルウェア検査や、メール内のリンクとフィッシングWebサイトの不許可リストとの照合といったさまざまなアクションを実行するノードが、その下のダイアグラムに多数配置されます。

今回の私たちのタスクは、長年実績を積んだこのインターフェイスに新しい息吹を吹き込み、かつ成熟したRailsのフロントエンド(マジェスティックモノリス)を現代化することでした。その対象がTines様のアプリケーションです。

急速に成長するスタートアップ企業で社運をかけた開発を進める現実世界では、リファクタリング作業が専任の「フルタイムジョブ」となることはめったにありません。今回の事例も例外ではありませんでした。私たちは新機能の開発を進めると「同時に」、フロントエンドもモダンなスタックに書き換えました。このとき私たちが選んだ戦略は次のとおりです。まず(ロジックの変更も含めて)古いスタックを尊重しながらコンポーネント全体を再設計し、いったんproductionにリリースします。そして「その作業が完了したら」すべてを廃棄し、新しいスタイルでスクラッチから書き直します。もちろんこの戦略ではコミット数が「倍増」することになりますが、それと引き換えに改修中もシステムが引き続き正常稼働できるようになります。

この戦略を選んだおかげで、思いがけない嬉しい出来事もありました。500行ものコードを一気にすっきりと消し去るプルリクを出せたのです!もし皆さんが「自社で」同じような困難に立ち向かっているのであれば、この先をぜひお読みいただき、皆さんのところでも自由に使える具体的なコード例をご覧ください。

「小さな」ダイアグラム

Tinesサービスのダッシュボードの威力は、そのダイアグラムにあります。サービスの顧客はダイアグラムを用いてセキュリティワークフローを可視化できます。過去の実装では、エージェントのダイアグラムはバックエンドで事前計算し、Graphvizで静的な画像としてレンダリングしていました。

改修前のダイアグラム画面デザイン

改修前のダイアグラム画面デザイン

私たちのタスクは、上の静的な画像を、インタラクティブにドラッグアンドドロップ可能なフロントエンド中心のものに一新することでした。

システムの新しい設計案ができたら、次は技術スタックを決定します。今回の主な難関は、既存のシステム、つまりRailsテンプレートビューやアセットパイプライン(旧式のSprockets方式)で提供されるアセットでできているダッシュボードに、新しいJavaScriptコードを統合することでした。

新デザインのダイアグラム画面

新デザインのダイアグラム画面

上のモックアップは、その基本的な実装ですら数千行ものJavaScriptが使われています。しかし当時のアセットパイプラインには、私たちが思い描くリッチなフロントエンドアプリケーションは構築されておらず、HTTPリクエスト-レスポンスサイクルとサーバーサイドでレンダリングされるアセットという古典的な時代の遺物が支配していました。アプリで使われている既存のフロントエンドコードはCoffeeScriptとjQueryで書かれていました。これは2010年代初頭では典型的なスタックでしたが、その後jQueryが忘却の彼方に追いやられていくうちにメンテが見る見るつらくなり、ES6のようなモダンなJavaScript構文の台頭によってCoffeeScriptも時代遅れになってきました。

点と点をつなぐ

そういうわけで、実際に動作する「プルーフ・オブ・コンセプト」をvanilla JS(素のJavaScript)で実装してみた後、このモダンなフロントエンドスタックを(フロントエンドとバックエンドに明快な境界線のない)「古典的な」Railsアプリケーションの縛りの中に段階的に持ち込むことが次の目標となりました。

ありがたいことに、昨今のRailsのエコシステムはモダンなフロントエンド技術を手厚くサポートしています。

Webpacker
WebpackerはRailsでさくっと動きます(Rails 5.2から導入され、Rails 6.0からデフォルトになりました)。Webpackerのデフォルト設定は、カスタムWebpackのセットアップで時間を使わずに済むのがありがたい点です。あらゆる種類の標準アセットをすぐに利用でき、拡張も簡単です。
React
私たちが商用プロジェクトで選択しているフレームワークです。Reactは広く普及しているので、この次にフロントエンドを書き換えるときが来てもコードが「レガシー」にならずに済みます。
PostCSSは、実はEvil MartiansのAndrey Sitnikが作ったことをご存知ですか?もうすぐ登場するPostCSS 8.0の新機能について詳しくはEvil Martiansブログの記事をどうぞ。
TypeScript + ESLint
vanilla JavaScriptに型を導入することで、統合開発ツールの力を借りてリファクタリングをスムーズに行えます。アプリケーションの古い部分と新しい部分が、コードを実行する前であっても正しいデータをやりとりできるようになります。
MobX
一般にはReduxの方が人気ですが、まったく白紙からの開発でない場合は単一のイミュータブルストアのメンテナンスが難しくなる可能性があります。MobXではマルチプルストアを利用できますし、使い方もいたってシンプルです。
GraphQL
graphql-requestおよびgraphql-codegenと組み合わせることで、強く型付けされた本当に必要なデータだけをサーバーからフェッチできるようになります。
Tailwind
Tailwindは今回メインで用いるスタイリングツールであり、複雑なアニメーションやカスタムスタイルにCSSモジュールも使います。私たちが今回のタスクのために開発した画面デザインシステムはマイクログリッドをベースにしており、スペーシングなどあらゆるサイズを3px(TailwindなどのCSSシステムにおける最小単位)の倍数に設定できるので、今回のアプローチとの相性も完璧です。WebpackerのおかげでCSSモジュールとPostCSSをすぐ使えるので、コンポーネントの内部できれいに分離されたスタイルを大気圏脱出速度並の爆速で書けるようになります。

マイクログリッド方式の画面デザイン

マイクログリッド方式の画面デザイン

ヘルパーは一度にひとつで十分

既に数千ものユーザーがセキュリティタスクのために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、その他は改修前

エージェントパネルのみReact、その他は改修前

ありがたいことに、Reactを完全なシングルページアプリケーション(SPA)として使わないといけないということはありません。UIの自律的なパーツをどこにでも好きなように注入できます。

// 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のルートストアからすべてのagentstoryにアクセスできるようになっています(おさらい: 「ストーリー」はエージェントのダイアグラムです)。私たちが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ビューでそれをインポートすれば完了です。

新旧入り交じったインターフェイス内のJSONEditor

新旧入り交じったインターフェイス内のJSONEdito

新しいバンドルでは、任意のページで特定要素の中にあるコンポーネント(複数可)をレンダリングできるので、古いjQuery + CoffeeScriptページに、アプリの改修済みの箇所にあるモダンなMonaco editorを埋め込むことすら可能です。コンポーネントに初期プロパティをいくつか渡す必要があるなら、React-DOMのrenderを呼ぶコンポーネントラッパーのあたりで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では、火星式の製品開発およびご相談を承っております。

関連記事

Railsでブラウザテストを「正しく」行う方法(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。