いちばんシンプルにStimulus ControllerをMochaでテストする方法

Stimulus: A modest JavaScript framework for the HTML you already have.

Stimulusはテストしにくい

Stimulus は DOM の影響範囲を閉じ込めつつ、reactive system を強制せず、バックエンドとフロントエンドに無用な断絶を生まない、実に reasonable な選択肢として使い勝手がよいが、一方で生 DOM を使ったり DOM と Controller の bind がマジカルに解決されてしまい、ユニットテストを書きにくいという課題を抱えている。

StimulusのControllerを実際にテストする際に必要なもの

2021年9月現在、ライトな JavaScript のテスト環境としては Node.js を利用するのがよくあるパターンと思われる。しかし Stimulus は Virtual DOM を使わないので Node.js 上でテストするには以下の準備が必要になる。

※ なお、今回はテスティングフレームワークとして Mocha を利用する。これは Jest を使うよりどんな準備が必要かを分かりやすくするためである。

今回用意したバージョンは以下の通り。

  • Mocha 9.1.1
  • JSDOM v17
  • jsdom-global 3.0.2

生DOM部分のテストではjsdom-globalが便利だけど注意も必要

コードがシンプルになるのは以下のように実行して global に window, document オブジェクトを利用できるようにする方法になる。

mocha -r jsdom-global/register

こうすると

テストケースのコードの中ではどこでも documentwindow へアクセスすることができる

逆に言うと、

letvar で初期化してはいけない

ことになる。せっかく inject しておいた document や window が壊れてしまう。

jsdom-globalを利用することについて

賛否はあるとは思うが、DOM に依存するコードを書く際に、完全に scope を閉じ込めようとするとかえって都合が悪いこともある。

これは JavaScript を書くすべての人に通用するノウハウではないかもしれないが、3rd party のコードを inject しながらその機能を利用したコードを書くことはよくある。このような場合、現実世界では DOM は global で巨大な singleton であり、いくつも生成できるものではないし、3rd party のコードがそもそもその global な singleton であることに依存していたり、うっかり複数回初期化すると動作が壊れる可能性もある。(例えば Google の global site tag によって定義される gtag() をうっかり複数回呼ぶと動作が意図と食い違う可能性がある。)

ということで、この本物の DOM の特徴に合わせておくと DOM にアクセスする function を新たに定義してテストコードから呼びたいという場合にも特別なことをする必要がなくなる。

ただし、当たり前だけどこういうコードは並列実行できない。Facebook が Virtual DOM を作り Jest を用意してるのはこういう理由なんだね。

具体的なテストコード

jsdom-globalを利用しつつテスト対象のDOMを組み立てる

Mocha で BDD-style で書いている場合は以下のように document の中身を書き換えてしまうとよい。

beforeEach(() => {
  document.body.innerHTML = `
<div data-controller="some">
  <button data-action="some#upCount">up !</button>
</div>
`
})

Stimulusのアプリケーションをテストする方法

必要な準備は以下の二つ

  1. MutationObserver を Node.js の文脈で global access できるように
  2. DOM 書き換えと controller の register からの bind の queue をちゃんと待つ

1 については以下のコードで実現できる。

before(() => {
  global.MutationObserver = window.MutationObserver
})

これは Stimulus が DOM の MutationObserver に無条件に依存したコードになっているので、Node.js で動かす場合に global にぶら下げ直す必要があるからである。

2 については setTimeout(() => {}, 0) が必要ということ。具体的には以下のようなコードになる。

it('', () => {
  const app = Application.start()
  app.register('some', SomeController)

  setTimeout(() => {
    document.querySelector('button').click()
  }, 0)
})

まとめ

今回は Stimulus を利用したコードのテスト方法として

  • Mocha
  • jsdom-global

を利用したシンプルな方法を紹介した。DOM が gloal な singleton であることを前提にした方法だが、一方で並列実行には向いていないので、JavaScript が本当に大規模になったらこの方法は合わないかもしれない。

もっとも並列実行に向いていないのは DOM を直接書き換えている部分だけなので、それ以外を並列実行するように設定するという方法でもよいかもしれない。(Controller の中で I/O などに直接タッチしてるとダメだけど、これはちゃんと DI してテストの時に差し替えるとか、作り方を注意すればいいだけ。)

今さらWeb Components

名前だけ知ってたんだけど、なんとなく深入りせずにきていた Web Components について改めて調べてみた。

なお、前提条件として自分には以下の経験があるので、その辺の知識がベースになって差分で理解しようとしている。したがって以下はその辺の知識が説明なしに出てくる。

  • Vue.js ( v2 / v3 ) / Mithril など Virtual DOM 系フレームワーク
  • Angular.js ( v1 )
  • Backbone.js ( jQuery / Underscore )
  • Stimulus ( v2 )

Web Componentsは総称

MDN の解説によると

Web Components | MDN

以下の3つから成る。

  • Custom Elements
  • Shadow DOM
  • Template and Slot Element

Google の Web Fundamentals によると

Building Components  |  Web Fundamentals  |  Google Developers

  • Custom Elements (v1)
  • Shadow DOM (v1)

のようだ。

以下は基本的に MDN の解説をもとに実際に手を動かしながら確認していく。

Custom Elements

基本ルール

  1. HTML tag として文法に違反していなければどんな tag を書いてもよい
    • とりあえずスルーで Inner HTML が出力される
    • 挙動を追加したい場合は Custom Elements の定義を扱う JavaScript を追加する必要がある
  2. 既存の tag とぶつからないように hyphen が必要
  3. Shadow DOM が有効になると Element ではなく DOM Tree の親 ( Shadow Host ) に変身する
    • 与えた Inner HTML も含めてそのままでは何も表示されない
  4. built-in のものを継承した要素も作ることができる。その場合はデザインなどもそのまま継承される。
  5. life cycle callback が用意されており、要素のレンダリングや削除に応じた処理を追加できる

Autonomous 自律的な Element

HTML は定義済みの要素以外にも文法的にエラーにならなければ自由な要素を作って書くことができる。

<custom-element>
  JavaScript による定義があろうがなかろうがここはそのまま出力されます
</custom-element>

ただし、ほんとに素通しでただ中身が「見える」というだけ。

この勝手に作った要素に対して独自の定義を行うには ES2015 class を書き、customElements.define() で CustomElementRegistry に定義を追加する必要がある。

// class を作り
class <CustomElement> extends HTMLElement {
  constructor () {
    super() // need it
  }
}

// registry に登録する
customElements.define('custom-element', CustomElement)

このとき気をつけなければいけないのは constructor を書く場合は super() を呼ばなければいけないこと。これは custom element 云々ではなく JavaScript の class の制限。constructor は不要なら書かなくてもよい。

define() の引数の1つめが名前で、2つめが class になっている。ここで 名前には hyphen が必要 。この制限があるおかげで既存の要素とぶつかる心配がない。

この定義は HTML 上で実際に要素が書かれる前であっても後であってもよい。どちらでも動作する。既存の DOM ツリーに足して何らかの操作を加える jQuery 以前の世界観ではやや驚くものだが、JavaScript 側から DOM 側に対して何も干渉しておらず、ブラウザが自分で紐付けを行うのでこれで問題ない。

※ もちろん class 定義と define() を別ファイルに分けるのはよいアイディアなので、その場合は export / import を利用するが、ここでは割愛する。

この Custom Elements には life cycle callback が用意されている。これは Virtual DOM を利用する ViewFramework や Stimulus や Svelte など、多くのライブラリに採用されているアイディアであり、これを利用したことがある人には馴染みのあるものと言える。1

コンテンツを中心に見ている場合、確かに Custom Elements ではあるが、このままでは面白くもなんともない。div に class を当てたり style を当てたりするよりも明確な名前空間を持ってはいるが、それだけ。

恐らくこれは Shadow DOM を利用した独自のツリーを構築するためのものなので、その点を踏まえて後述する。

余談だが、hyphen を必要とするルールは他のフレームワークにも似たようなものが採用されている。

Vue のスタイルガイドにはコンポーネント名は複数単語で命名することが Essential Rule として定義されており、これによって必然的に単語の区切りが発生し、hyphen などを利用するインセンティブになる。(Vue のガイドでは kebab-case よりも PascalCase を推奨しているが、いずれにせよ絶対に衝突はしない。)

Customized built-in Element

ES2015 class を利用して定義を行い、customElements.define() を理由して定義を追加するのは同じだが、一部書き方が異なる。

class <CustomElement> extends HTMLDListElement {
  constructor () {
    super() // need it
  }
}

customElements.define('custom-element', CustomElement, { extends: 'dl' })
<dl is="custom-element">
</dl>
  1. class を書く際に継承したい要素を明示する
  2. customElements.define() の際に extends オプションを与える
  3. HTML 側は既存の要素をそのまま使いつつ is 属性に定義した名前を与える

3 は Element と言いつつ Attribute になってしまっているし、class にも define() にも extends を与えなければいけないし、使いにくい感じがする。

それでも既存の要素にそのまま life cycle callback 付きの処理を加えられることがメリットなら採用してもよいかもしれない。

デザインは普通に style 属性に値を与えることで変更できる。

this.setAttribue('style', background: black; color: white')

CSS と JavaScript をワンセットで定義として閉じ込めて再利用できる。

Shadow DOM

window.document 以下の DOM Tree から独立した DOM を実現する API.

Custom Element の定義に this.attachShadow() を与えると、その要素は Shadow DOM Tree を現実の document DOM Tree 上に実現するための Shadow Host になる。

作り方の一例は以下のようなもの。

export class OriginalElement extends HTMLElement {
  constructor () {
    super()

    // shadowRoot を設定するといったんすべて出力されなくなる
    // open か closed かは関係ない
    const shadowRoot = this.attachShadow({ mode: 'open' })

    // shadowRoot に tree を再構築する必要がある

    // この要素は要素ではなく tree の親になっているので、style の設定も
    // style 要素を追加する必要がある
    const style = document.createElement('style')
    style.innerText = `
      background: black;
      color: white;
    `
    const text = document.createTextNode(this.innerHTML)

    // 子要素に追加しないと出力されない
    shadowRoot.appendChild(style)
    shadowRoot.appendChild(text)
  }
}

Shadow Root 以下に子要素を追加してブラウザ上に意図した DOM Tree を実現しているが、この際

  1. mode に関わらず Shadow DOM の要素は querySelector での検索の対象にならない
    • 上の場合、document.querySelector('custom-element') の結果は存在する2custom-element style の場合の結果は null になる
  2. Shadow DOM が有効になった Custom Element は実際にはそれ自身が意味のある Element なのではなく空の Tree の Root になる
  3. mode: ‘open’ の場合は <element>.shadowRoot で Shadow DOM の Tree を取得できる

という状態になる。見て分かる通り、外からも中からもお互いに干渉しにくい。(手間が掛かる)

※ 中の DOM Tree を DOM API だけで構築しようとするとかなり見通しが悪くなってしまうので、innerHTML に template literal を与えるか、Template と Slot 要素を使うのが Web Components としての回答っぽい。

まとめ

Shadow DOM は

  • 外からは document.querySelector() で内部構造に対して検索できない
  • 内からは style 指定が外に影響しない3

が、逆に

  • 中から document.querySelector() で外の世界にアクセスできる
  • 外から style を指定することはできる4

を実現してくれる。

以上から、Custom Element に対して Shadow DOM を有効にすることで、component としてのカプセル化、独立性を高め、再利用性の高いパーツを実現することができる。

Template と Slot

template と slot の使い方 - Web Components | MDN

  • component とは直接関係しない、DOM の表示に何も影響しない要素
  • JavaScript からは丸見えなので Custom Element の定義内から参照することができる

最も簡単な例は以下のようなものになる。

<template id="custom-template">
  <p><slot name="content">template default</slot></p>
</template>
const template = document.querySelector('#custom-template').content
..
shadowRoot.appendChild(template.cloneNode(true))

このとき、shadowRoot に appendChild する際に slot の置換は自動的に行われる。

このように Template と Slot は component の再利用性に寄与できる要素になっている。

が、明からさまに問題を抱えている。それは、

template 要素は component のようにはカプセル化できておらず、DOM の中の global な要素になってしまっている

点である。

これを解消する方法があるなら、生の Template 要素を使うよりそちらを採用した方がよさそう。

もう一つ、Slot が置換済みの DOM Tree をブラウザの DevTools で確認することができなかった(Chrome 92 + Show user agent shadow DOM の設定を ON にした状態で)。これでは複雑な構造に対して細かいデザインの調整を行う際にやや面倒になってしまうのではないか。

実際に開発するに当たって

Custom Elements, Shadow DOM について HTML, JavaScript ともエディタ側のボキャブラリに入っていない可能性がある。その場合いちいち lint がエラーになったり警告色で表示されたりして面倒なことになる。

※ これが JavaScript の import / require を前提にしていれば問題ないので、以下に示すライブラリを使っていればそういう心配はない。

あとブラウザネイティブなのでやはり互換性が問題になる。有名なところは IE 11 だが、2021-08 時点の日本では Safari のシェアも高く、Safari はいろいろ対応が中途半端になっている場合があるので注意が必要。

ライブラリ

ブラウザネイティブの Web Components を見てきたが、やはり考え方はよいと思うものの、実際の制作、開発を行うに当たっては課題もある。そこで Custom Elements とそれに近いライブラリがいくつかあるので見ておこうと思う。

いずれも、制作、開発時に利用したライブラリは Custom Elements や HTML 片利用時にも必要になる。つまり、pure Custom Elements にしたい場合には使えない。

なお、ここに挙げた以外にも他にもいろいろあるのでベンチマークを貼っておく。

Web Components Benchmark

これ見ると Custom Elements の方が圧倒的に バンドルサイズは小さい反面、多くの処理が絡むようになってくると Native DOM より Virtual DOM の方が速いのがよく分かる。機能を持つというよりむしろカプセル化こそがその真価と言ってよさそう。

Lit

Lit

2020-08 リリースの v 1.3.0 時点の話。

  • Google の Polymer Project の後継
  • React のような考え方を取り入れた Custom Element を作る
    • #properties で reactive な property を定義
    • render() が出力される
    • それでいて標準の customElements.define() で登録可能
  • 独自の LitElement を継承しており、constructor を含むすべての life cycle callback で super.xxxxxCallback() が必要
  • default で Shadow DOM が有効になっている
  • class の中の render() が出力になることで Shadow DOM を扱う際の分かり難さが軽減されている
  • TypeScript の decorator を利用して define() をスキップすることもできるが、使わなくても動く
  • template に対して No custom syntax to learn と言っているが、@ を利用した event listener をはじめ、directive など案外と追加の学習が必要。
    • slot も持っている

render() に template を持たせることで Template 要素が DOM global になってしまう問題を回避できている。

導入のハードルは低そうだが、dirctive など意外に複雑。これだけでそれなりに複雑なものが作れるとは思うが、それならもう少し抽象度が高くてメジャーなものを採用した方がよいかもしれない。複雑な部分を利用せずに Custom Elements の薄い wrappert とて使うくらいだとコストパフォーマンスよさそう。

実際、

Shoelace: A forward-thinking library of web components.

など LitElement 前提の component ライブラリもある。

Lit はとにかくドキュメントが参考になる。MDN や Google 本家のドキュメントよりも現実的なドキュメントになっている。実際に使うためのヒントになる。

Stimulus

Stimulus: A modest JavaScript framework for the HTML you already have.

2020-12 リリースの v2 時点の話。

  • Custom Element を作るのではなく、既存の HTML をそのまま生かしつつスコープの限定と life cycle callback を実現したもの
  • Element ではなく Controller という位置付け
  • event と紐づいた action の dispatch もあるので単にカプセル化された component というよりは UI として機能させやすい
  • Shadow DOM の考え方は導入されていないので、外から無作法に生の DOM API で干渉することはできる(Stimulus Controller からは親子関係になっていないと干渉できない)
  • 独自の template の記法は存在せず、controllr 配下の要素に data-*-target という属性をセットすることで controller からその中身の書き換えを許可できる
  • reactive な property は存在しないが、data-* の中に監視できる value と、それに対応する xxxxValueChanged() メソッドを定義しておき、そこから target を書き換える処理を実行することで半自動的に re-render させることができる

マジカルさがなく、素朴な JavaScript のコードになりやすいと思う。

GitHub Catalyst

Catalyst

2021年夏リリースの v1 時点の話。

  • Stimulus と Lit の中間のような Custom Element を構築する
  • 継承もとはネイティブの Element
  • HTML 側に <template data-shadowroot> で template を持たせることができる。これは Shadow DOM 前提
  • data-* に action や target を設定する辺りは Stimulus に似ている
  • TypeScript の decorator に依存しているが、decorator を function にして使うことも可能(MobXっぽい)
  • @github/jtml から htmlrender() で出力する書き方もできる。この辺りの書き味は Lit に似ているので、React っぽいのが好みの場合は寄せることもできなくはない
  • css で style を書ける機能は実現されていないので、その部分は Lit と異なる

どちらかというといろいろなものをカプセル化できることよりも Shadow DOM の中に property など「観測できないパラメータが閉じ込められない」ことの方を重要視して、それを避けるような設計になっているように見える。

特に Lit を使った場合に reactive な property の挙動などは DevTools から観測できないので、開発難易度は上がっているように見える。

GitHub Catalyst はこの辺りはすべて生の Custom Elements のメソッドにそのまま依存することになっており、

  • observedAttributes
  • attributeChangedCallback

を利用する。

したがって Stimulus のように data-* に値を保存するようにしておけば自ずと意図通りの動作を実現できるし、data-* に保存されているので DevTools でそのまま値の変化を目視できる。

具体的には読み書きする値は

set <attr> (val) {
  this.setAttribute(`data-${attr}`, val)
}

get <attr> () {
  return this.getAttribute(`data-${attr}`)
}

みたいなイメージで property を用意してやるとよい。そのうえで

static get observedAttributes () {
  return []
}

で監視したい data-* をセットしておく。実際の動作は

  1. property の setter を呼ぶ
  2. data-* attribute に適用される
  3. attributeChangedCallback() が呼ばれるので、変更のあった値を見て target の書き換えなどを行う

という流れになる。こうして見るとほんとに Stimulus にそっくりなんだけど、時系列としては逆で、Stimulus が Custom Elements そっくりな動作を既存の HTML 既存の DOM 構造の中に実現されたものなのだということが分かる。

またこのように素の Custom Elements に近いので @github/catalyst の方が Lit よりも小さい。

data-* に反映されるので DevTools を利用した目視開発の難易度は低いが、そのうえで以下の拡張を追加するとさらに分かりやすく開発できると思う。

Web Component DevTools - Chrome Web Store

Vue

なんと、Vue component を Custom Element として利用することができる。(Custom Elements を Vue と組み合わせるのではなく、Vue component を Custom Element として利用する。)

Vue の Single File Component は Custom Elements + Shadow DOM を実現するのに現状いちばん適した記法になっていると思うので、Vue に慣れている人にとってこれは嬉しい。

Polyfill

Requirements – Lit

Custom Elements は Legacy Browser には対応していない。ES 2015 形式の class 構文をサポートしていないといけない。

しかし Polyfill で解決することはできるようだ。

ユースケースを考えてみた

Custom Elements Everywhere

にあるように、より Higher-level の API を持つライブラリ、フレームワークと衝突しない5ので、例えば Shoelace のような UI フレームワークを導入して、

  • 機能としての View Framework ( React / Vue / Angular など )
  • デザインとしての UI Framework ( Shoelace など )

を分離しつつ同居させるといったことができそう。

デザインと機能を独立して開発できるのは分業体制がしっかりできているところには有用そう。

ただ実際にはレガシー対応が含まれる場合はフルにコンパイルする View Framework に寄せる方が簡単そうではある。また、ある程度強いコミュニティのある React / Vue / Angular などの方がエディタ、IDE の対応、ブラウザ拡張による開発支援が手厚くなる。ひとりの Custom Elements に詳しい人が扱っているのならともかく、組織で採用する場合はここがボトルネックになる可能性はある。

※ 2021-08 時点では Custom Elements 周りでは Lit がいちばん強そうなので、そこに寄せるのがよさそうではある。個人的には書き味的には @github/catalyst の方が扱いやすそうに思うんだけど。ほぼ素の Custom Elements なので拡張の対応も容易だし。

上に挙げたような課題を Polyfill や IDE の拡張などで解消できる目処が立つなら、少なくとも大規模なアプリ、ページ内の要素が多くそれぞれがカプセル化できているメリットが大きい場合にはフットプリントの小ささが有効に作用するのではないかと思う。また Custom Elements に寄せておくと例えば React のようにバージョンアップの早いライブラリを採用するよりも Element ( Component ) の寿命を伸ばせそうなので、やはり規模の大型化や、複数のアプリ、サービスでパーツを使い回すといったニーズが強い場合にはよくマッチしそう。

逆に UI がシンプルであったり、アプリの数も少ないうちはそこまで Custom Elements にこだわらなくてもよくあるコンポーネントをそのまま使うのでも十分通用するだろうし、そこにちょっとした効果やイベントをセットするに当たり、既存の HTML にそのまま乗せられる Stimulus のようなアプローチが合うだろう。6

最近、自分はことあるごとに Virtual DOM は設計のコスト、実装のコストが高いと言っているのだけれど、Custom Elements にも似た部分はあるように感じている。ただ、Slot と外部に影響しない CSS は本当によいと思うので、世間でよく言われているパーツ、コンポーネントに精通し、それを利用できるように Shoelace などで練習しておくことで設計の練度を上げていくのは、将来を考えるととても重要なんじゃないかと思う。

まぁ自分が流行のツールより「標準」が好きというのもあるかもしれないけど、けっきょく「標準」がありつつその「周辺」でよくできたツールを作れる人たちが頑張ってくれているわけで、「標準」を知らないまま「周辺」のツールだけつまみ食いするのはちょっとあぶないかもなと思ったのでした。

  1. というか恐らく逆で、Web Components にもともとあったアイディアを各ツールが実現しただけなのかもしれない。歴史に詳しくないので、この辺りの前後関係は今回は調べていない。 

  2. style 属性か Shadow Root 以下の style が適用されるので 

  3. Shadow Host になっている 

  4. 例えば Bulma のような pure CSS Framework と組み合わせるとオリジナルのデザインを持つ Custom Elements を比較的ローコストで実現できる 

  5. vue web component wrapper を通した Custom Elements は結局 Vue に依存するので衝突しないとはだいぶ言い難いが 

  6. 少なくともサーバサイドがあってクライアントサイドに集中しきれないような状況は初期にはよくある 

Architectural Decision Records を知った

※ 正確には「知った」のは恐らく半年以上前なんだけど、ちょっと触ったりドキュメントを読んだりした。

"life is a succession of choices, what is yours?" by https://unsplash.com/@ivalex

設計の基準を明確にして残しておきたい

自分の問題意識としては「設計の決定のための検討、比較などの記録が issue や pull-req に埋れがち」というものがある。またこれらは実はそれほど検索しやすくないし、記録を遡ることもやりやすくない。

この問題は実はかなり長いこと意識としては持っていて、何かみんな困っているはずなんだけどなーと思いつつ、全然情報が見つからずに思い出しては放置、思い出しては放置していたのだが、今回の直接のきっかけは以下のツイート。

このもとは以下の記事で、

時間を区切って設計を打ち切るのはおすすめできない - hitode909の日記

この記事を読んでまさにちょっと前に自分たちのやったことを思い出していて1 、何回めかのトライでようやく ADRs という言葉にはたどり着いていたんだけど、時間がなくてちゃんと掘り下げられずにまた放置していた。

今回やっと時間が確保できたので、ちょっと調べてみてメモを起こしておく。

Technology Radar ではすでに 2017年末から Adopt

Lightweight という言い方がなされており、いわゆるライトウェイトプロセスの中で XP に寄せすぎると欠落しやすくなる「なぜその設計を採用したのか」を、ライトに残すためのドキュメンテーション テクニック のようだ。

Technology Radar では Wiki や Web サイトではなくコードそのものと同期させることができるので Source Control に store することを recommend してる。

※ example を漁ってみると分かるが、比較的仔細なレベルの設計の意思決定を記録しようとしているので、同じリポジトリに入っているのが扱いやすく、また技術的には「収集して再利用」しようと思うと Wiki ではなくファイルとして存在している方がよいので、Technology Radar の言う通りにするのがよさそう。手っ取り早く始めるには Wiki の方がよさそうに見えるけど。

基本のツールは MADR っぽい

おそらくドキュメントの読み書きが低コストで行えればなんでもよいが、昨今の流れだと大半のドキュメントは Markdown を基本に作成されているはずなので、Markdown で書く ADR ( Architectural Decision Records ) ということで MADR という呼び方があるようだ。

おそらく reST でも Asciidoc でも同じように考えられるはずだし、Word や Google Docs でも構わないように感じる。ただ、コードからの距離は遠くない方がよさそう。少なくとも詳細な実装に紐づく部分は。

Templates

ここまでの情報だと、意図していることはなんとなく分かるが、実際に何を書けばよいかがよく分からない。そこでまずは本家で紹介されている Template を漁ってみる。

RFC っぽい重厚さを持つ流派と、よりライトな流派があり、ライトな流派は以下のように似たような内容を扱っているようだ。

Nygard (2011)Y-StatementsMADRAlexandrian Pattern
   prologue
contextcontextcontextdiscussion ( context )
status   
 facingdecision drivers 
  consider options 
decisionwe decideddecision outcome ( consequences posi / nega )solution ( decision )
concequensesalternatives not chosen results ( consequences )
 to achieve  
 acceptingpros and cons of options 

だいたいどれも

  • context
  • options
  • decision
  • pros / cons of options

みたいな感じになっている。

adr.github.io がおよそ 3, 4 年前にリリースされており、この頃からこうしたドキュメンテーションの話は一部で盛り上がっているっぽい。しかし Nygard の post は 2011 年であり、もう10年前の話になっている。

XP やライトウェイトプロセス、アジャイルなどの盛り上がりが一段落し、そうは言っても実践的に役に立つドキュメントの手法についても意見が出始めたのがこの頃なんだろうか。

examples

こうして見るとけっこう細かいレベルの記述になっていることが分かる。

処理系

書くには

とりあえず sh script の以下のものが手頃で扱いやすい。これは Markdown を基本にしている。

いろいろな言語に移植されているが、けっきょくのところ決まったテンプレートに従って Markdown を書けばよいだけなので、あまり凝った道具は要らないと思う。

読むには

Markdown で書くと決めたら Markdown なのでそのまま読めなくもないが、

なんかでもよさそう。また多くの人が手軽に読めるようにするという意味では

などを使って Web サイトを起こすのもよいのではないかという気がしている。Wiki と違ってファイルなので、どこかでまとめたり、統一的な UI をもたせたりということもそれほど難しくないはず。

template の種類と組織の回し方と記録の残し方

ということで Lightweight Architecture Decision Records | Technology Radar | ThoughtworksArchitectural Decision Records | adr.github.io をベースにあれこれ触ってみたけど、恐らく template の決め方はいくつかパターンがあって、その判断基準には

  • ドキュメントベースでの意思決定に慣れているかどうか
  • 記録の運用が長期間に渡るかどうか

があると思う。

上に挙げた template はあまり status を項目に持っていないんだけど、これがドキュメントベースでレビューして合議で決定するようなスタイルだと status を持っているのは非常に重要になるだろう。また長期間運用されて設計が更新されていくような場合にも status : obsolete などが欲しくなるように思う。

また、

Getting Started with Architecture Decision Records | Blog

では

  • 社内の既存のルールに従っているだけの場合には ADR は書かなくてよい
  • メール、電話、ミーティングなどを挟んで合意にいたった場合、それを書き起こすことには価値がある
  • ほとんどの場合、ADR は他のドキュメントと同じように Wiki やドキュメントシステムに書く

としている。

恐らくドキュメントベースの議論はそれほど重視しておらず、あくまで結果を写し止めておくような印象を受ける。こっちの方がふつうの組織にはよりマッチしそうに見える。少なくとも「始めやすく、残しやすい」のは間違いなさそう。

ただこういうドキュメントは基本的にエンジニア以外は興味を持たないので、共有のドキュメントシステムにあると、メンテナンスや検索、オンボーディングの視点ではイマイチかもしれない。(プロジェクトをまたいで ADR だけを収集するような方法にも向いていない。)

参考

Architecture Decision Record Template: Y-Statements | ZIO’s Blog: Architectural Decisions, (Micro-)Services and More

から辿っていける話がとても面白い。この Y-Statements も original は medium の post である 2020 年ではなく、SATURN という Software Architecture Community の 2012 年の際に発表されたものらしい。

こうして見てみると ADRs だけではなく AD ( Architectual Decisions ) や AKM ( Architectural Knowledge Management ) という言葉も多く登場してくるのが分かる。ADR という言葉だけでなく、その辺も掘っていけるといいのかな。

(1) SATURN: A Software Architecture Community - YouTube

もう少し全体感がまとまったものは

Documenting Software Architecture – @hgraca

からたどっていける話も面白い。

あと日本語の事例は少ないけど、以下のような感じ。

割と MADR そのままが多いのかな。


  1. いくつかの仕組みが連携する際に、1) 連携元のアウトプット、2) 連携先のインプット、3) 連携先の振る舞いが関係するんだけど、完全な動作を前提にすると 1, 2, 3 の順に実装するしかない。しかし、実際には 3 が決まっていないと 1 が決まらない、なんてことはよくある。そこで 1 の設計を仮置きしておいて、2 はインプットできたものとして適当な値を fixture よろしく置いておいて 3 の実装に取り掛かって、そこから 1 を決定して 1, 2 を実装する、なんてことはよくある。この際、1 の仮置きをちゃんと参照しやすい形で残しておきたい、みたいな話。 

vite_ruby全体的にはとてもよい。けどちょっと注意も必要かも。

を見かけたので Vite 1 beta の頃に独自に Helper 書いて導入したものをリプレイスして v2 にしちゃおう作戦。

Home | Vite Ruby

前提

  • Vite を独自に導入済み。Webpack → Vite の migration で感動した!みたいな話はなし
  • Vite 1 beta を vite_ruby x Vite 2 に migrate.
  • Vite 1 beta を Ruby から利用してた際は Helper を独自実装していた

導入したのは vite_ruby 1.2.11

Vite 1 beta 利用時の課題

  • Vite の dev は Ruby 側の development で使う分にはいいが Ruby 側の test で使うと初回のいろいろな解決が走って重い、build を使ったら使ったで実際に build が走ってやはり重い
  • entry point が複数になる場合に vite.config.js を複数用意する必要があり、非常にダルい、というか現実のアプリだと entry point は複数になるでしょ

導入して良い点

  • vite_ruby の導入は Vite の抱えていた entry point 周りの課題をきれいに解決してくれて助かる
    • vite.config.js からの rollup の設定へどんどん深みにハマっていく必要がない
  • autoBuild を利用すると上記の重さはかなり改善される

導入するなら考えた方が良い点

  • Webpacker 同様 Helper を通して Ruby 側で asset 周りの reverse proxy を実現しているが、このおかげでもう WEBrick での開発は現実的でなくなる
    • ES Modules は Webpack と違ってブラウザ上で直接 import が動いて HTTP connection が爆発する
  • 同じく asset へのアクセスが大量に発生するので logger に工夫が必要かも
  • Webpacker 同様 Helper に新しい名前空間を用意し、そっちの利用を強制してくるのでちゃんと従おう

特に HTTP connection が増えて WEBrick では耐えられなくなる点は大きくて、恐らく Puma を使うのが素直な解決策1だが、Rails のようにライブラリが充実している環境でない、例えば Sinatra や Hanami を利用する場合、 Puma を development 環境で使うのはちょっと扱いにくいので注意が必要。

余談:Ruby外のasset bundlerとRubyを組み合わせる方法と注意点と自分の考えていること

ここからは完全に脱線なので、一つの考え方として参考になるかも。ならないかも。

個人的には Ruby バックエンドや静的サイトジェネレータと Webpack などの asset bundler の組み合わせはそこそこ試してて、

  1. Ruby 側の Asset Helper の実装
  2. Ruby で reverse proxy の実装
  3. Node.js で reverse proxy の実装

を何通りかやっている。2

そのうえで上で書いた Vite 1 beta 利用時の独自 Helper ではあえて reverse proxy を使わない Helper を書いていた3んだけど、vite_ruby は Webpacker を模倣してしまったので Ruby 側の負荷が爆発する形になってしまっている。

Webpack は bundle 済みの asset を serve するので reverse proxy が Ruby 側にあってもよいかもしれないけど、ES Modules は bundle せずに HTTP に丸投げになるので、かなり動作が違う。ここは真似する必要なかったのではないか。

まぁ reverse proxy を Ruby 側に持たせることが一概に悪いこととは言えないんだけど、シンプルな、「Rails を使うほどではないよなぁという開発」の場合に Rails のエコシステムを前提にした「なんでも揃ってる感覚」でやるといろいろハマりが多いので、できればそれほど Ruby の得意なわけではない大量の HTTP の処理が Ruby に回ってくる設計にはしない方がいいんじゃないかと個人的には考えている。

もちろん「デキアイのツールをただ組み合わせて実現できる」ものでやる方がいろいろ端折れて早いのも事実なので、鉄板構成を見つけて鉄板構成だけで押すという姿勢も大事なんだけど、それぞれの得意なこと不得意なことは知っておくといいよ、とも思った。

  1. thread ベースとか event ベースであればなんでもよい 

  2. 結局やるのは Node.js のサーバの URI の解決と manifest.json の解決だけなので、一度やってしまえば別に難しい課題じゃない。面倒なだけ。 

  3. HTML は Ruby へ、Asset は Vite Server へそれぞれ request する形 

Viteは静的サイトにも使えそう

Home | Vite

Vite の説明は割愛。

まとめ

2021-03 時点で、

伝統的な HTML を生成する使い方は Vite 単体では無理なので他のものと組み合わせる必要がある

具体的には

  • HTMLを個別に生成する静的サイトジェネレータ(例えば Jekyll)と組み合わせる
  • Vite に plugin を追加することでも案外手軽に Server-Side Generation が可能
    • この場合は HTML 全体を VDOM フレームワークで実現する必要がある

個人的にはそのうち各種ジェネレータ側の方で asset の処理を Vite に移譲する plugin も出てくるんじゃないかなぁという気がしている。

Viteにできること

以下は Vite 2.1.0 時点の話。

  • Vite はあくまで超高速アセットバンドラ
  • Vite 2 では build-in で複数ページ ( entry point ) を実現できている
  • HTML 側の共通化を諦めれば現状でもシンプルな静的サイト(ただし JAMStack ) の生成には使える

Vite の主眼はあくまで HTML ではなく bundle する asset の方なので、HTML を便利に作る方法は提供されていない。

HTMLを生成するには

SSG ( Server Side Generate ) を利用する。以下は Vue を使って SSG を行うもの。

antfu/vite-ssg: Server-side generation for Vite

セットアップにややクセがあるので注意が必要。Vue の createApp() を置き換えて自動的にセットアップする方式。

その他に

を利用して routing 情報をファイルシステムから自動的に構築してないとたぶんうまく動かない。

その他Viteに追加すると便利そうなもの

VDOM component を利用しないなら

Vueを利用するなら

Markdown を使うことで VitePress のようなものを作ることも可能。1

参考

つくってみた

wtnabe/example-vite-vue-ssg

全部入りスターターキット

antfu/vitesse: 🏕 Opinionated Vite Starter Template

これをマネするのが基本になると思う。

  1. 自分が試したところ VitePress は何が足りないのか分からないがうまく動かせなかった。vite-ssg + vite-plugin-pages は動作した。 

turbolinksで実現したかったことって、こういうことだったのか

Turbo: The speed of a single-page web application without having to write any JavaScript.

Stimulus ほどあれこれ考察はしていないけど、Turbo を軽く触ってみて結構よさげという印象を受けた。Google Analytics などの 3rd party のツールとの組み合わせの部分には注意が必要になるが、特に初期実装ではかなりの威力になりそうだ。

※ 今回は Turbo Stream は試していない。あくまで Turbo Drive と Turbo Frame の話。

Stimulusはturbolinksに欠けていたもの

まず あーありがち - Stimulus、悪くない の方を読んでほしいんだけど、Turbo は turbolinks が洗練されて戻ってきたものであることから始めると導入しやすいと思う。

細かく引用はしないが、turbolinks 登場時、最も困ったのは当時全盛と言ってよい jQuery を利用したコードと極めて相性が悪かった点であった。なんなら Rails 標準の JavaScript のライブラリが jQuery なのにどうなってんだこれは、という話である。

どういうことかというと、turbolinks を有効にしてしまうと jQuery を利用した以下のような典型的なコード

jQuery(function() {
  ..
})

が、初回のページロード時には動くが、ページ遷移やブラウザの「戻る」ボタンを押した際には動作しない、という問題があったのだ。というか今もこれはある。jQuery をやめて

document.AddEventListener('DOMContentLoaded', () => {
  ..
})

にしても問題は解決しない。なにせ turblinks も Turbo の Turbo Drive も

ページ遷移自体はせず、DOMの一部とHistoryへの変更であたかも遷移したフリをする

ものだからだ。

turbolinks や Turbo のために監視するイベントを増やしたりする方法もあるかもしれないが、それなら今なら潔く VDOM 系のツールで SPA を組む方が賛同を得られやすいだろう。ただし開発コストは大きく膨らむが。

しかし、Stimulus を使うことでこれは解消される。Stimulus ではこの部分は Application.start() の中に隠蔽されていて、DOM がゼロベースで構築完了したかバックグラウンドで必要な HTML を取得して一部の書き換えを行ったかに関わらず Controller の connect() メソッドは呼ばれる。

つまり、Stimulus を使うことでページの読み込みを監視する処理をそもそも自分で書く必要がなくなり、それによって Turbo を利用しているかいないかに関わらず安定して同じように動作するのだ。

これは非常に助かる。これまで turbolinks を off にするのは割とよくある話だったと思うが、フロントエンドエンジニアを多く確保しなくても Turbo と Stimulus を組み合わせることで、よくある多くのパターンの動的な DOM 構築に対応でき、明らかに体感速度は向上する。

実際にブラウザの Developer Tools で見てみると HTML を取得するたった一つの connection で、JavaScript も Stylesheet も読み込み直すことなくシュッと遷移するのは実に快感だ。

方法は簡単。例えば Webpack や Vite などの bundle ツールを利用していたら import するだけである。それだけで有効になり、ページ遷移は爆速になる。具体的には

import * as Turbo from '@hotwired/turbo'

かな。これだけでよい。使っていなくても大丈夫。CDN から script タグで読み込むだけで動く。

Turbo Frameはiframeを使わないiframeのような何か

これも非常によくあると思うのだが、HTMLの中の一部のコンテンツを遅延ロードしたいという場合、以下のように書くと実現できる。

読み込む側 /importer

<turbo-frame id="export" src="/exporter">
</turbo-frame>

読み込まれる側 /exporter

<turbo-frame id="export">
  ..
</turbo-frame>

これで importer 側の <turbo-frame> の中身が exporter 側の <turbo-frame> の中身に挿し変わる。

id でコンテンツを紐付けて対応する DOM を入れ替えてしまうという方法である。

同じことを JavaScript で実現する側として正解を挙げると、従来なら

  • サーバサイドで JSON を出力
  • クライアントサイドで JSON を取得
  • クライアントサイドで(サーバサイドとは異なる記法で)DOM を構築

という3ステップを要する。

しかし Turbo Frame を利用すれば全部端折ってサーバサイドで普通に HTML を書いて別な entry point を与えればよい。bot 避けに meta でも書いておけば変なお漏らしもないだろう。これだけで済む。

もちろん、そもそもコンテンツをどのように見せるかは import する側の責務なのでは? という指摘はあると思う。自分もそう思う。でもじゃあそのためにフロントエンドのツールでゼロから書きますか、というと「なんだかなぁ」という気がしないだろうか。DOM の書き換えだってせいぜい最初の一回しか起きないのに VirtualDOM とか必要ですか?という気持ちになるし、今書いているバックエンドのコードから完全に頭を切り変えてフロントエンドの海に潜らないといけない。

Turbo を使うとそこはずっとサーバというか HTML のままで実現できるので、スイッチングコストが発生しない。しかも Turbo Drive でのページ遷移と組み合わせると、遅延読み込みしたかどうかも人間にはよく分からない。

まとめ - Stimulus + Turboはよいぞ

以上のような形で

  • サーバ側の routing (あるいは単なる HTML のパス)で
  • サーバ側の HTML の生成で
  • JavaScript をいちいち書かずに

ユーザーの体験を大きく向上させるのが Turbo の狙いだ。

そしてより細かい部分や 3rd party のツールと組み合わせる際には Turbo 固有の event を利用すればよい。

Hotwire という名前や DHH という名前で大袈裟に構えることはない。なんてことのない、だが確実によく利く勘所を押さえたツールと言えそうだ。

フロントエンドのツールの理解、VirtualDOM や reactive という考え方はそれはそれで必要になるし、例えばタイマーのようなより細粒度、高頻度の更新が必要なものは JavaScript で閉じた方がよいと思うが、ほとんどサーバ側が正解の情報(HTMLなど)を持っているのに JavaScript から遠回りして DOM に反映しているケースも実は多い。

そういう場合に .js.erb のように無理やり JavaScript に情報を渡すのではなく、もうそのまま HTML を渡せばいいじゃんという考え方は一見大胆だし、細かくベンチマークを測るとそこまで速くもないかもしれないけど、そこそこ十分に速くて開発コストが大きく抑えられるのもまた事実。混ぜて使う際にはやや注意が必要かもしれないが、少なくとも初手でコストを掛けずにユーザーの体験をよくするためには大きく貢献してくれそうだ。

※ テストが難しそうというかエラーハンドリング周りの情報が全然ない1状態なのは確かに気になるので、そこは追試が必要。あと HTML を「直接」「普通に」取得するのでサーバサイドや昨今のフロントエンドを書く際に忘れていたキャッシュ周りに気をつけないとハマる可能性もある。あと地味にレビューが難しい。Stimulus も Turbo も生々しく自分で実装する量がグッと減るので、テストコードも恐らくかなり少なくなる代わりに、HTML 側の記述ミス防止は恐らく目視チェックになりやすいので、何か工夫があった方がよさそう。

  1. 普通に console にエラーが出るんだけど、その情報が十分に出揃ってない。 

Stimulus、悪くない

Stimulus: A modest JavaScript framework for the HTML you already have.

2020年末にリリースされた Stimulus 2.0 を試して気づいたことをメモしておく。Stimulus は Rails で有名な Basecamp のメンバーによって開発された生 DOM をベースに、できるだけ template に JS を書かずに動的な DOM の生成、変更を実現するためのライブラリである。

昨今流行りの React, Vue, Angular などの VirtualDOM 系のツールとは根本的に考え方が異なり、今からこれを採用するのはやや申し訳ないというか、正直に言うとよいイメージはなかったし、例えばフロントエンドエンジニアとしてスキルアップしたいぜーという人には合わないと思うが、ただ、どうせサーバサイドの開発が必要で、分業できるほどパワーに余裕のない現場においては VirtualDOM 系よりもかなり高い生産性を維持できると言えそうだ。

少なくとも多能工と多能工が組んでアジリティを確保していこうぜ、という2000年代の空気、Rails誕生前後の空気を再び感じることができる。実際に少ないコードでそこそこのものを高速に開発してオレツエー感を味わえるという意味ではかなりよい選択肢の一つと言えると思う。

StimulusはDOM操作の世界でのよいレール

Stimulusのイメージコード

<div data-controller="counter">
  <span data-counter-target="count"></span>
  <button data-action="click->counter#up">up !</button>
</div>
class CounterController extends Controller {
  static targets = ['count']
  static values = ['count']

  up () {
    this.countValue++
  }

  countValueChagend () {
    this.countTarget.innerText = countValue
  }
}

const app = Application.start()
app.register('counter', CounterController)

簡単に説明すると

  • data-controller で指定した DOM 要素と Controller インスタンスが紐づく1
  • Controller の中で変化を検知できる値として values があり、DOM 側からは data-*-value=<identifier> でアクセスできる
  • Controller 側から操作可能な DOM 要素として targets がある2
  • Controller のメソッドを呼ぶには data-action を利用する
    • event は default のものは省略することができ、この書き方を利用すると event を意識することなくメソッドを呼び出しているように見える

伝統的なDOM操作の世界でのStimulusのメリット

  • DOM の影響範囲を data-* で限定し、そこに効力を発揮する JavaScript を Controller と呼び、関係する処理をこの中に閉じ込めることができる
    • 要素が複数あった場合は自動的に別々の Controller インスタンスと bind されるのでお互いに干渉しない
  • event driven と reactive への習熟という、伝統的なプログラミングスタイルからの飛躍を必要としない
  • DOMContentLoaded 周りの繊細さがない
  • CDN から読み込んだだけで動作する(ES2015 と Proxy に対応しているブラウザが必要)
  • template 側に JS を一切書かずに済むので template 側は副作用込みの行儀悪いものにならない

伝統的なDOM操作コードの問題点とStimulusのアプローチ

残念ながらこの日記にはまとめていないんだけど、上のメリットについては自分も以前からとても頭を悩ませていた。

初歩的な jQuery や VanillaJS で書いたちょっとした装飾や効果、遅延読み込みを実現するコードはほぼ必ずと言っていいほど以下のような問題を抱えている。

  • (event が起きてから DOM の走査を行うので)どこまで影響が広がっているのか想像がつかない
  • やりたいことに名前がついていないので意図が分からない

これは本当に致命的な問題だと思っていた。3

この問題を解消するには 対象と操作を明示する名前を付けたコードの中で、明示した範囲内の DOM だけを操作対象にする しかないと考えていて、自分でそういうツールを書いたりもしていたんだけど4、Stimulus はほぼそのままの回答になっている。

VirtualDOM系ツールを使わないメリット

VirtualDOM系ツールを利用した開発時の課題

VirtualDOM と reactive という考え方は

  • もろもろの「操作」系のコードを省くことでおかしな副作用が途中で混ざり込んでしまう可能性を排除できる
  • event を stream として functional に処理できる

など、より複雑な処理により安全に対応するよい作法である。

しかし逆に、VirtualDOM 系のツールには以下のような問題がある。

  • サーバサイドで組み立てていた HTML の世界に一切歩み寄りがなく、サーバサイドを書いていた手を止めてクライアンドサイドの考え方とツール群に完全に持ち替えないとコードが書けない。完全に分業できる組織ならそれでもよいかもしれないが、特にプロダクトの初期実装時にとても扱いにくく感じる。
  • うまく作用する component を作ろうと思うととても小さいものになり、結果として DOM ツリー全体を構築するためには当初の想像の何倍もの数の component を作らなきゃいけなくなりがち
  • 数多くの component を書いているうちにすべて component だけで解決しなきゃいけないような気になる → 誤った設計を誘発しがち

簡単に言うと VDOM 系は基本的に高くつく。設計ミスも誘発する。そこまでしても速くなるのは DOM の書き換えだけであり、最初の表示は逆に遅くなったりするので、意外と最適化しないと体感速度は改善しない。

VirtualDOM系ツールに対するStimulusのメリット

Stimulus はこれらの問題のすべてを回避できる。改めてまとめ直すと、以下のようにかなり低コストに動的な DOM を利用した体験を実現できる。

  • VDOM 系のツールを動作させるセットアップが一切不要
  • サーバサイドとクライアントサイドの両方をまたぐ処理を書く際に起きる分断がほとんどなく、概ねサーバサイドのコードのような書き味で作れる
  • Stimulus の Controller は VDOM 系の component より少し大きな粒度になるので数をかなり減らせる

もっと細かく調整したり様々なイベントを扱いたいといったニーズはあると思うが、

ツールの大きな持ち替えで手が止まることなくだいたいのケースに対応できる

という実に Rails っぽい考え方でうまくまとまっていると思う。

もちろん、より複雑なロジックやグローバルなステートなどは Controller の守備範囲外だ。その辺りは当然の前提としたうえで考えると、実にニーズナブルでよい解決方法に見える。

そして、実はここが大きいんだけど

  • template 側でのデザインと Controller 側での処理の分業も行いやすい

これ。

VDOM 系のツールを使うと必要以上にデザイナが VDOM 系のツールへの習熟を必要としてしまい、例えばデザイナがツールの習熟度で詰まった際にバックエンドの処理を書いているエンジニアにヘルプがくるとそこでエンジニアの手が止まってしまう。

つまり、

VDOM 系のツールは実はフロントエンドにある程度余裕のあるエンジニアを確保できていないと、デザインの実装に対してもバックエンドの実装に対してもブレーキになり得る

という問題を抱えている。これを解消できる Stimulus というアプローチがとても助かる現場は多いと思う。

Stimulusのデメリット

もちろんデメリットもある。

  • テストしたければ結局フロントエンドのツールチェインの整備は必要
    • そりゃそうだ。もしかしたら頑張って Selenium 経由で console のエラーを拾う、みたいなこともできなくもないかもしれないけど、それはたぶんかなりつらいと思う。
  • Controller のユニットテストは難しい
    • 生 DOM と紐づいてこそ意味があるので、独立したテストは意味を成しにくい。純粋な関数部分のテストはできなくもないが、そもそも Controller に複雑な処理を入れるな、でよいと思う5
    • テストしたければ小さなテスト用の DOM と一緒にセットアップするコードを作る

総じて テストコードを書くモチベーションが下がる と思う。これは凝ったことをし始める時、量が多くなった時に突然牙を剥く可能性がある。

※ もっとも、生 DOM を使ったテストというものはセットアップさえ分かってしまえば書くこと自体はそんなに難しくはない。少なくとも Turbo のように DOM 外の resource へのアクセスが絡まないうちは。(Stimulus の場合は DOM を Controller が mount する部分で工夫が要るけど)

あとこれはデメリットじゃないけど、

VDOM + State Management の開発と異なり、生DOM相手なのでデバッグもブラウザの素の Developer Tools を利用する

ことになる。

参考

  1. DOM 側からは register した名前で紐づける 

  2. どのように操作するかは決まっていない。上の例のように直接書き換えてもいいし、何らかの JavaScript の template を挟んでそこでコンテンツを生成してもよい 

  3. 少なくとも jQuery をやめれば解決するという話ではない。jQuery 的な神オブジェクトを採用するすべての SDK がこの問題を抱えていると言ってよい。 

  4. しかも偶然にも class ベースで書かれている 

  5. 恐らくだが Controller へのアクセス方法を DOM の data-* だけに限定できていて「意図しない呼び出しが行われていない」ことを前提にした設計になっているので、そもそもカジュアルにインスタンスを生成して動かせないようになっていると予想される。 

ついにCloud FunctionsにRuby Runtime登場!

今さら感がないと言えば嘘になるけど、

Ruby comes to Cloud Functions | Google Cloud Blog

ついに、Cloud Functions で Ruby が使えるようになりました! いやー長かったな。

個人的には closed beta の段階で申し込みはしてて動かすことだけはできていたのに結局試す時間がないまま public beta になってしまって申し訳ないという気持ち半分、いやでも単純に嬉しいという気持ち半分です。

何はともあれ、これで Ruby の実行環境にカジュアルな FaaS の二つ目1が加わったわけです! いやめでたい。

というわけでこれまでの他の言語での経験と Ruby / Rack / Ruby版 Functions Framework の特徴をざっと眺めて、実際のプロダクトコードを書く際に気を付けることなどに触れて紹介に代えたいと、思います!

Functions Frameworkというものがあるよ

Ruby で Function を書く際には Functions Framework というものを利用する。

GoogleCloudPlatform/functions-framework-ruby: FaaS (Function as a service) framework for writing portable Ruby functions

closed beta の頃にはすでに存在してて、こいつが何をするものかというと、

  • Cloud Functions, Cloud Run または Knative ベースの仕組みの上で動かせる
    • Cloud Run は Knative ベース
  • local での開発にも利用できる

という代物で、先行している Node.js や Python 版と同様の役割を果たす。すでにこれらの言語で経験のある人には説明の必要はないんだけど、

特徴的なのは GCP 上のイベント(代表的なものは PubSub)に対応する関数も HTTP 関数も同等に扱える

点。

どういうことかと言うと、Functions Framework の中身を見ると分かるのだがイベントも頑張って Rack::Request に変換してくれているおかげで、対応するすべてのものを Rack アプリで処理できるようになっている。

つまり Ruby で HTTP のサーバサイドを扱う人には最も馴染みの方法で HTTP 関数だけでなく様々なアプリを書けるようになっているということです。素晴らしい。

Functions FrameworkやFunctionsの不便な点

ただ、Functions Framework は Node.js なんかもそうなんだけど、注力しているのはこの request の source の抽象化であって、実際のアプリ内の話については重視していない。そこで以下にいくつか不便な点を挙げていこうと思う。

reloaderの問題

Ruby では多くの場合でフレームワーク側で reloader を用意してくれていて、Sinatra も Rails も開発環境ではいい具合にソースコードの変更に応じて reload してくれるのだが、Functions Framework はそういう部分をケアしてくれるものではない。

ではどうするかと言うと、方法は大きく二つあって、

  1. Functions Framework を Sinatra など reloader を持つ framework と組み合わせる
  2. nodemon のような汎用の reloader ( restarter ) を利用する

1 の方がたぶん慣れている人には分かりやすい。具体的にどのように Function を書くかについては公式に記述があって、

File: Writing Functions — Functions

にあるように、Sinatra などと組み合わせることができる。使い方としては要は Rack アプリに対して Rack アプリが期待する env を渡してやるだけである。公式の Sinatra サンプルは class ベースの modular style アプリだが、class を定義しない classic style でも同様に利用できる。

そのうえで、Sinatra のアプリケーションコード内で sinatra/reloader を require してやれば普通に development 環境では reload が有効になる。

ただ、後述するが実際には典型的な人間向けの Web アプリの実装に向いているこれらのフレームワークを利用する機会はそんなに多くないように思う。その際はフレームワークではなくこのあとに述べる reloader と組み合わせるとよい。

nodemon

nodemon はもとは node コマンドを置き換えて

$ node app.js

の代わりに

$ nodemon app.js

のように使うとファイルの変更を検知して自動的にサーバを restart してくれるというものだったが、watch する拡張子と実行するコマンドを指定すると Ruby アプリでも問題なく利用できる。具体的には

$ nodemon -e rb --exec "functions-framework-ruby -t <func>"

のようにしてあげるとよい。ここに書いたものは npm run や bundle exec は省略してあるので必要に応じて適宜追加してほしい。

functions-framework-ruby 0.7時点でのNode.js版1.6との違い

functions-framework-nodejs は body-parser を含んでいたりするので、素の express の request, response を扱うこととは異なり、もう少し便利な機能が備わっている。

対して functions-framework-ruby 0.7 時点では今のところ追加の支援はなさそうなので、かなり生々しい Rack オブジェクトを扱うことになる。現実的なアプリを書くには、例えば比較的全部入りのアプリケーションフレームワークと組み合わせる2か、あるいは

を使いつつ

のような機能を追加して対応していく感じになるのかな。こっちのアプローチの方が express っぽいけど、Rails も Sinatra もあまり軽くはないので、特にリソースの制約の厳しい Functions 環境ではこれくらいの生々しさや軽量さへのこだわりはあってもいいかも。

cf.

Functions Framework独自の情報をどう引き回すか問題

これは Functions Framework が担う部分と従来の Web アプリケーション Framework の担う部分の違いの話。

Rails などの通常の Web アプリケーションフレームワークは request, response の入出力の方法からアプリケーションロジックに当たる部分、DBMS の読み書きの部分までトータルにサポートするのが一般的3と言える。

対して Functions Framework の責任の範囲はあくまで HTTP と CloudEvents を抽象化し、Rack 互換のアプリケーションで処理できるようにするための繋ぎの部分であって、それ以外は対象外となっている。

そのうえで、Functions Framework 固有の情報もある。例えば global, set_global というメソッドは Functions Framework 内だけで利用できる global 変数のようなもので、これを利用すると以下のように request を受けるたびに実行するには重たい初期化処理を行ったり、その結果の情報を保持しておくことができる。

FunctionsFramework.on_startup do |function|
  set_global :config, heavy_initializing_process()
end

FunctionsFramework.http <name> do |request|
  config = global :config
  ...
end

ただこれが通用するのは Functions Framework 内の話であって、例えば先ほど挙げたように Sinatra と組み合わせるような場合には Sinatra の中ではこの global にアクセスすることはできない。

そこでどうするかというと、Sinatra アプリに唯一渡せるのは rack env オブジェクトなので、この env の中に global をぶら下げる形になる。先ほどの例で言うと、

FunctionsFramework.http <name> do |request|
  config = global :config
  env = request.env
  env.config = config

  Sinatra::Application.call env
end

のようになる。

Functionを書く際に考えなければいけないことは意外に多くなる

これも別に言語は関係なくて、今まで FaaS を使っていた人にとっては割と当たり前の話。

実際に Cloud Functions を書く際には恐らく従来の人間向けの Web アプリよりも小規模で、かつ逆にインフラにより近いものを直接扱うようになると思う。インフラというのは例えば Cloud Storage, Firestore などのストレージだったり、PubSub や他の Function など、Google Cloud 上のフルマネージドインフラ。Functions を選ぶということは GCE ではないということで、それはつまりアプリケーションの実行環境から直接同じ OS 上で管理できるような伝統的なインフラが存在していない、という意味になる。だからフルマネージドインフラのお世話になるだろうという推測である。

で、そうなると Sinatra や Rails などのアプリケーションフレームワークの機能や構造の分け方などはあまり役に立たない。これらの便利機能は主に

  • Cookie や Session
  • HTML や response の文字列(JSONなど)の生成
  • (DBMSアクセスの抽象化)

は担ってくれるが、それ以外のインフラを扱う部分に対しては基本的には管轄外となってしまう。

例えば Memcached や Redis を利用する際にそのコードをどのように配置すべきかについては Rails には答えはなくて、利用用途がほぼ cache だろうから Rails.cache のバックエンドに置くことを支援してくれるまでに留まっている。実際には cache を扱うコードをどこに置くべきかについてはガイドがないので、作成および更新処理と読み込む処理が分散してコントロールが難しくなってしまうという問題を抱えていたりする。

こうした課題への対策には恐らくいわゆるアプケーションアーキテクチャを考えることになるだろう。いわゆるオニオンアーキテクチャやクリーンアーキテクチャと呼ばれる類のアレだ。

Functions は周知の通り実行時間や利用できるメモリに制限があり、あまり複雑な機能を実装することはできないが、代わりにいわゆる Web MVC のようなシンプルな構成とは異なり、アプリケーションの扱わなければいけないインフラが複雑になりやすい傾向があるので、その分で考えることは増える。

特に異なるインフラを扱うコードが密に結合してしまうと「本番でしかテストできません」みたいなことが容易に起き得るので注意が必要である。

注意深くインフラとロジックを分離し、依存の方向に気をつけて DI で組み立てていくようにする、そういうコードの量が増えるはず。

参考

  1. 一つ目はもちろん AWS Lambda 

  2. 当然、spin upが重すぎるのでFunctionsには向かない 

  3. Ruby を日常的に利用している人には当たり前の話になるが、基本的に request, response は Rack の作法に従うことでその部分の独自実装をあまり行わないようにするのが一般的であり、フレームワーク独自の部分はあまりない。 

About

例によって個人のなんちゃらです

Recent Posts

Categories

Tool 日々 Web Biz Net Apple MS ことば News Unix howto Food PHP Movie Edu Community Book Security Text TV Perl Ruby Music Pdoc 生き方 RDoc ViewCVS CVS Rsync Disk Mail FreeBSD Cygwin PDF Photo Zebedee Debian OSX Comic Cron Sysadmin Font Analog iCal Sunbird DNS Linux Wiki Emacs Thunderbird Sitecopy Terminal Drawing tDiary AppleScript Life Money Omni PukiWiki Xen XREA Zsh Screen CASL Firefox Fink zsh haXe Ecmascript PATH_INFO SQLite PEAR Lighttpd FastCGI Subversion au prototype.js jsUnit Apache Trac Template Java Rhino Mochikit Feed Bloglines CSS del.icio.us SBS qwikWeb gettext Ajax JSDoc Rails HTML CHM EPWING NDTP EB IE CLI ck ThinkPad Toy WSH RFC readline rlwrap ImageMagick epeg Frenzy sysprep Ubuntu MeCab DTP ERD DBMS eclipse Eclipse Awk RD Diigo XAMPP RubyGems PHPDoc iCab DOM YAML Camino Geekmonkey w3m Scheme Gauche Lisp JSAN Google VMware DSL SLAX Safari Markdown Textile IRC Jabber Fastladder MacPorts LLSpirit CPAN Mozilla Twitter OpenFL Rswatch ITS NTP GUI Pragger Yapra XML Mobile Git Study JSON VirtualBox Samba Pear Growl Mercurial Rack Capistrano Rake Win RSS Mechanize Sitemaps Android JavaScript Python RTM OOo iPod Yahoo Unicode Github iTunes God SBM friendfeed Friendfeed HokuUn Sinatra TDD Test Project Evernote iPad Geohash Location Map Search Simplenote Image WebKit RSpec Phone CSV WiMAX USB Chrome RubyKaigi RubyKaigi2011 Space CoffeeScript Nokogiri Hpricot Rubygems jQuery Node GTD CI UX Design VCS Kanazawa.rb Kindle Amazon Agile Vagrant Chef Windows Composer Dotenv PaaS Itamae SaaS Docker Swagger Grape WebAPI Microservices OmniAuth HTTP 分析基盤 CDN Terraform IaaS HCL Webpack Vue.js BigQuery Middleman CMS AWS PNG Laravel Selenium OAuth OpenAPI GitHub UML GCP TypeScript SQL Hanami Develop Document Jekyll