Hypernova で Vue.js のコンポーネントを Rails でレンダリングする

個人メモ。

Rearchitecting Wantedly's Frontend | Wantedly Engineer Blog の記事を読んで、副業先でも使えるかもと思い、Hypernova を触っていた。

Hypernova を使う個人的なモチベーション

リリースまで至った、それなりの規模の SPA ではないブラウザナビゲーションで遷移する Rails アプリを想定する。リッチな UI の実現と保守性の観点から JavaScript フレームワークの導入をおこなう。導入自体は Webpacker のような仕組みで簡単にできるようになったが、ビューのロジックがあちこちに存在するという課題が生まれる。ロジックとは、erb や haml などのテンプレートに書く(サーバーサイドで一度決定したら変わることはないという意味で)静的なロジックと、JavaScript フレームワークが担うUI操作などのイベントに応じて実行される動的なロジックのふたつである。
これらのファイル、あるいはテンプレートの仕様を行き来し( erb, haml <-> react, vue, angular )、頭を切り替えながら実装するのは個人的にはけっこうキツい。

JavaScript フレームワークに静的なロジックを担わせることもできるが、SEOや初期表示の観点から、どこまでをクライアントサイドでレンダリングするか、という問題が出てくる。 理想を言えば、JavaScriptコンポーネントのコードを Railshaml などのテンプレートエンジンと同様に扱えるとよい。Hypernova を使えば、これを実現することができる。

Node.js プロセスの面倒をみるという運用の手間が増えるが、エラーハンドリングの仕組みがよくできているので、最悪 Node.js のプロセスが落ちても、クライアントサイドのレンダリングにうまくフォールバックしてくれる。

Hypernova を Vue.js で使う

Hypernova、React に限らず SSR ができる JavaScript フレームワークならば何でも扱えそうな造りに見えつつ、Vue.js のコンポーネントを扱う話をあまり見かけない。こういう issueがあるということは多分やろうとしている人はいるんだろう、というのと、hypernova-react のコードを見たらファイルが一枚だけあって、これと同じことをするラッパーを用意すればよさそうだな、と思って書いた。こんなかんじ。

import Vue from 'vue'
import hypernova, { serialize, load } from 'hypernova';

export const renderVue = (name, component) => {
    const Component = Vue.extend(component);
    return hypernova({
        server() {
            return (props) => {
                const { createRenderer } = __non_webpack_require__('vue-server-renderer');
                const renderer = createRenderer();
                const vm = new Component({ propsData: props });
                return new Promise((resolve, reject) => {
                    renderer.renderToString(vm, (err, contents) => {
                        if (err) {
                            console.error(err);
                            reject(err);
                            return;
                        }
                        resolve(serialize(name, contents, props));
                    });
                });
            };
        },
        client() {
            const payloads = load(name);
            if (payloads) {
                payloads.forEach((payload) => {
                    const { node, data } = payload;
                    const vm = new Component({ propsData: data });
                    vm.$mount(node);
                });
            }
            return component;
        }
    });
};

サーバーとクライアント両方で、Webpack でビルドする前提。Webpack 依存のコード(__non_webpack_require__)が入っているのがイケてない。Webpack の設定でクライアントのビルドの場合は vue-server-renderer を除外してもよさそう。ここらへんの問題が解決できたら、hypernova-vueとして npm に公開したい。

使う時は、Vue コンポーネントのオプションオブジェクトを先のファイルで export している renderVue 関数でラップする。後は、Hypernova の設定で、name に応じて、ラップしたコンポーネントを返すようにする。

import {renderVue} from './hypernova-vue';
import Like from './Like.vue';

export default renderVue('like', Like);

Rails のテンプレートからコンポーネントを呼び出す場合は、以下のようになる。ヘルパー名が react 決め打ちなのがアレだけれども alias か自前で render_vue_component ヘルパーを定義すればいいと思う。

<%= render_react_component('like', liked: false) %>

nuxt-community/typescript-template から生成したプロジェクトに express の API server を組み込む

個人メモ。

GitHub - nuxt-community/typescript-template: Typescript starter with Nuxt.js から生成したプロジェクトで色々開発をしていて、途中で JSON を返す API Server を組み込みたくなった(別リポジトリにしたり、実行環境を分けるのも面倒だった)。

API: Nuxt(options) - Nuxt.js にあるように、Nuxt は nuxt コマンドを通さずに、プログラムからサーバーを起動することができる。また、インスタンスの render プロパティを express の middleware として登録することができる。
問題は API Server の TypeScript のコードのコンパイルだよなーと思って、色々探していたら、自分が求めていることを全部やっているテンプレートを見つけた。

github.com

backpack という Node.js 用の諸々入りのビルドツールを使っている。いったん、このテンプレートの backpack 周辺と server ディレクトリのコードを参考にして目的を達成した。

多分、TypeScript → JavaScriptコンパイルだけできればよいので、開発時は ts-node でサーバーを起動して、本番は tscコンパイルしたコードを動かすだけでよいので、backpack 使わなくてもいいんじゃないかな、という気もする。

認知的不協和を活用して、気が散るのを防ぐ

ここ数週間、職場で試してみて、良かったやつ。

自分はコーディングや文章を書いていると次々と関係のない考えが頭に浮かんでくる。要は気が散りやすい。気がついたら、本来集中しなければいけないことと別のことに本気を出していたりする。

最近はA4サイズのホワイトボードを活用して、これを防いでいる。

まず、ホワイトボードをディスプレイの近くに置く。ディスプレイから目を逸らすとホワイトボードの文字が目に入るくらいの距離感がよい。
ホワイトボードには、「集中したいタスク」と「それをいつまでやるか」を書く。自分の場合は、例えば「~11:30 ○○の△△の実装」と書いて、その後にタスクに取り組む。

気が散って、別のことをしそうになったら(あるいはし始めたら)ホワイトボードをじっと見る。すると、ホワイトボードに書かれた内容と実際の自分の行動が矛盾していることに気づいて、なんとも言えない違和感を感じる。脳は、この違和感を解消しようとするわけだけれども、ホワイトボードに書かれた内容のほうが具体性が高いので、気が散る原因になっている考えを脳は取り下げる(面白いアイディアだったらどこかにメモをしておいて後で見返せばいいかもしれない)。

このように認知的不協和を活用することでひとつのタスクに集中して取り組むことができる。
併せて、ホワイトボードに具体的なタスクを書くことで、近くの席の人に「あの人、ホワイトボードに書いてるのと違うことしてる...」と思われるのは避けたい、といった意識も働いているかもしれない。

この他に、そもそも関係のない考えが浮かんでこないように、こういった考えを出してくる脳のリソースを別のなにかで占有するという手もある。具体的には 論理的思考の放棄の具体的方法 - 登 大遊@筑波大学大学院コンピュータサイエンス専攻の SoftEther VPN 日記 に書かれているような磁石と鉄球のイメージが参考になる。

AWS Lambda で Cloud Firestore を使う

Webpack を利用した複数の Lambda 関数の管理 - kitak blog の続きで色々やっていました。

Cloud Firestore を Lambda から使おうとしたんですが、バンドルファイルを実行したらエラーに。grpc まわりでネイティブモジュールのビルドが必要で、firebase-admin パッケージは Webpack のバンドル対象から除外することにしました。( firebase-sdk-js を使うことも検討したんですが、こちらも結局同じ問題に )

webpack-node-externals

除外には、webpack-node-externalsを使います。これはデフォルトでは、node_modules 以下を全てバンドル対象から外しますが、正規表現で除外対象を絞ることができます。今回の場合、こんなかんじです。

const nodeExternals = require('webpack-node-externals');

module.exports = {
    mode: 'none',
    target: 'node',
    externals: /^firebase-admin/
 // ...
};

Docker を使ったネイティブモジュールのビルド

ネイティブモジュールのビルドのために Docker を使いました。以下のスクリプトを用意して、postbuild タスクで実行します(zip を作成する前に実行する)。
Lambda の node バージョンと同じバージョンの node のイメージを使っていますが、本当は Amazon Linux のイメージをベースにした node 入りのイメージを用意したほうがよさそうです。

#!/usr/bin/env node
const {promisify} = require('util');
const {exec} = require('child_process');

const execAsync = promisify(exec);

(async () => {
    console.log(`Install native module ...`);
    await execAsync(`docker run --rm -v "$PWD":/worker -w /worker node:8.10.0 npm i firebase-admin --prefix . --production`, {
        cwd: './dist/hoge',
        maxBuffer: 1000*1024,
    });
})();

今後

  • zip のファイルサイズが数十M以上になってしまったので、結局、S3 経由でデプロイする必要がありそう( firebase-admin とそれの依存するパッケージのファイルサイズが大きい... )
    • CodeBuild を使って、デプロイのパイプラインを整備する( CodeBuild で Docker が使えたはず )
    • Git push or コマンド実行 → CodeBuild で zip の作成 → S3 に zip を配置 → S3 経由で Lambda 関数の更新
  • 今回は firebase-admin だけ無視したけれども、webpack-node-externals で node_modules 以下を丸々無視してもいいかもしれない( アプリケーションのコードのみバンドルする )

昭和天皇物語2を読んだ

読んだ。

昭和天皇のお妃選び

下記の藩閥政治の話と絡むのだけど、妃選びが政争の具になるのを避けるために、貞明皇后自ら、積極的に妃を選ぼうとする話が描かれる。具体的には、女学院に乗り込んでいって、よさそうな女学生を探す。
なんとも行動力がある話だけれども、実際にこういうことやってたのかなぁ。この記事を書きながら、貞明皇后Wikipedia のページを読んでいたのだけど、宮中の古いしきたりを破壊したエピソードが多数なので、やりかねないな、と思った。

藩閥政治から政党政治

山縣有朋が皇太子妃を長州の息のかかった人物にしようと画策していて、あー、大正時代でもまだ薩摩と長州のこういう権力争いってやってたんだなぁ、という気持ちになった。
米騒動による寺内正毅の退陣と、その後に政党出身の原敬が首相に選ばれる(元老による推薦)過程が描かれていて、昭和天皇立太子からお妃選びのあたりがちょうど藩閥政治から政党政治へと移り変わっていく時期なのだな、と結びつけることができた。

東郷平八郎

この作品は東郷平八郎の心理描写が面白い。日露戦争の映画やドラマで無口でクールな描かれ方をされ、亡くなった後は神として祀られる東郷が、授業をする学者の発言にいちいち心を乱されたり、「明治が遠く感じる」と弱音を吐いていて、なんとも人間くさい。
また、2巻の時代に起きていた第一次世界大戦について、「テクノロジーの発達によって大量殺戮が可能になり、かつての戦争(日露戦争)に存在していた人間の尊厳が喪失した」と語っている場面も興味深かった。

Webpack を利用した複数の Lambda 関数の管理

こんなかんじでやってみたらどうでしょ、という話。

AWS Lambda を中心にいわゆるサーバーレスのアプリケーションを構築するには、複数の Lambda 関数を作成することになります。
普通のウェブアプリケーションであれば、ひとつのリポジトリでコードを管理して、起動時に実行環境でアプリケーションのコードをまとめて読み込みますが、Lambda 関数を組み合わせてアプリケーションを構築する場合は、アプリケーションの機能ごとに実行環境が分かれることになり、それに応じてコードも分割しなければいけない難しさがあります(その制約を受け入れるメリットのひとつは、リクエスト数が突然跳ねた場合にスケールすることです)。

各 Lambda 関数は「一つのことだけうまくやる」ことを意識して書きますが、設定や汎用的なロジック、API クライアントなど Lambda 関数に跨った共通のコードをどうやって扱うか、という課題があります。
関数単位や共通のロジックで複数のリポジトリに分けるアプローチは様々な作業が煩雑になる(開発時のリポジトリの行き来、イシューやバージョンの管理、各リポジトリの更新...)ので、なるだけ単一のリポジトリでいきたいです。

ひとつのやり方として、Webpack で各 Lambda 関数の index.js をバンドルファイルとして生成するのがいいんじゃないかと思ってます。

Webpack の設定はこんなかんじです。Webpack のバージョンは 4.5.0 です。

const path = require('path');

module.exports = {
    mode: 'none',
    target: 'node',
    entry: {
        foo: path.resolve(__dirname, './src/foo.js'),
        bar: path.resolve(__dirname, './src/bar.js'),
    },
    output: {
        filename: '[name]/index.js',
        path: path.resolve(__dirname, 'dist'),
        libraryTarget: 'commonjs2',
    }
};

Lambda 関数ごとに entry を定義する。 Lambda で動かすために特筆することとして、target オプションを node にするのと、output の libraryTarget オプションを commonjs2 にしていることです。
共通のロジックは別ファイルに分けて、各 entry のファイルでインポートします。

Webpack のようなモジュールバンドラを使う副次的な効果として、アップロード制限の回避があります。モジュールバンドラ無しで Lambda 関数をデプロイする場合、node_modules も含めて zip を作成することになるので、いくつかパッケージを依存に追加した程度でファイルサイズが膨れ上がり、アップロードできなくなります(S3経由でデプロイする必要がある)。
モジュールバンドラを使えば、node_modules から必要な内容だけ取り出して、ひとまとまりのファイルが生成されます。関数がよほど複雑にならなければ、制限に達することはなさそうです(達した場合は、関数分割をすべきタイミングかもしれません)。

Webpack でビルドしたファイルは、デプロイのために zip にする必要があります。
Webpack には zip を生成するサードパーティプラグインがありますが、今回は Webpack に過度に依存することを避けるため、あくまで JavaScript のモジュールバンドラとしての役割のみに徹することにします。
postbuild のタスクで以下のスクリプトを走らせて entry 毎に zip ファイルを生成します。

#!/usr/bin/env node
const path = require('path');
const {promisify} = require('util');
const {exec} = require('child_process');
const globby = require('globby');

const execAsync = promisify(exec);

(async () => {
    const paths = await globby(['dist/*'], {
        onlyDirectories: true,
    });
    // Lambda 関数の数が極端に多い場合は、一定数ずつ作成したほうがよさそう
    await Promise.all(paths.map((path) => {
        console.log(`Create zip in ${path}...`);
        return execAsync(`zip -r Lambda-Deployment.zip * -x *.zip`, {
            cwd: path,
        });
    }));
})();

あとは、生成された zip をウェブのコンソールでアップロードするか、CLI を経由してデプロイします。

API Gateway カスタムオーソライザーを使って、Firebase で認証する

組み合わせただけの話なのですが、個人用メモ。

ちょっと前に「AWSによるサーバーレスアーキテクチャ」を読んだり、手元で色々試してました。本では、認証に Auth0 というサービスを使っているんですが、本が書かれた頃から Auth0 の仕様が大きく変わっています。サンプルを直すのがつらそうだったのと、その章で説明したい内容はあくまでカスタムオーソライザーの設定で、正直、認証サービスはなんでもよさそうだったので、馴染み深い Firebase Auth を代わりに使ってみました。

AWSによるサーバーレスアーキテクチャ

AWSによるサーバーレスアーキテクチャ

Firebase の ID トークンの作成と確認

Firebase はクライアントサイドで認証が完結しますが、バックエンドの API サーバーでログインしているユーザーを知りたい場合があります。Firebase ではクライアントで ID トークンの発行をおこない、サーバーでこれを検証することで実現できます。手順は以下のようなかんじです。

  1. ログイン後に ID トークンを作成する(クライアント)
  2. API リクエストにトークンを付与する(クライアント)
  3. トークンを検証してユーザーの情報を取得する(サーバー)

各手順で使う Firebase の APIID トークンを確認する  |  Firebase の説明がわかりやすいです。1, 2 を簡単にコードで示すと以下のようになります。

firebase.auth().currentUser.getIdToken(true)
  .then((idToken) => {
    return fetch('https://XXX.amazonaws.com/dev/get-profile', {
      mode: 'cors',
      headers: {
        'Authorization': 'Bearer ' + idToken,
      }
    });
  }).then((response) => {
    return response.text();
  }).then((body) => {
     console.log(body);
  }).catch((error) => {
     console.error(error);
  });

3 は次で示します。

カスタムオーソライザーに設定する Lambda のコード

トークンの検証とポリシーの生成をおこなう Lambda 関数を用意します。あとはこの Lambda 関数を API Gateway のリクエストの認証に設定すれば終わりです。

const admin = require('firebase-admin');
const serviceAccount = require('./XXX.json'); // Firebase の管理画面からインストールできる鍵ファイル

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: 'https://XXX.firebaseio.com'
});

const generatePolicy = (principalId, effect, resource) => {
  const authResponse = {};
  authResponse.principalId = principalId;
  if (effect && resource) {
      var policyDocument = {};
      policyDocument.Version = '2012-10-17';
      policyDocument.Statement = [];
      var statementOne = {};
      statementOne.Action = 'execute-api:Invoke';
      statementOne.Effect = effect;
      statementOne.Resource = resource;
      policyDocument.Statement[0] = statementOne;
      authResponse.policyDocument = policyDocument;
  }
  return authResponse;
};

exports.handler = function(event, context, callback){
  if (!event.authorizationToken) {
    callback('Could not find authToken');
    return;
  }
  const token = event.authorizationToken.split(' ')[1];
  // ID トークンの検証
  admin.auth().verifyIdToken(token)
    .then((decodedToken) => {
      const policy = generatePolicy('user', 'Allow', event.methodArn);
      callback(null, policy);
    }).catch((error) => {
      console.log('Failed idToken verification: ', error);
      callback('Authorization Failed');
    });
};

つらかったこと、いまいちなこと

あんまり本質じゃないですが、CORS まわりでけっこうハマりました。

  • カスタムオーソライザーでポリシーの生成にしくじっても(実行時エラーにはならないが、ポリシーの内容に問題がある場合)、API レスポンスが正常(200)で返ってくる
    • エンドポイントに紐付いた Lambda 関数は実行されない
    • Lambda 関数が実行できなかったらエラーを返すか、CloudWatch Logs にエラーを出力してほしい
    • (自分が気づいていないだけでどこかにエラーが出ているかも)
  • 「Lambda プロキシ統合の使用」を使うと、API Gateway で「CORS の有効化」をしていても、CORS 関係のヘッダーが付与されない。Lambda 関数でレスポンスを返す際に明示的に指定する必要がある
  • CloudWatch Logs が見づらいのと、複数の Lambda 関数で処理を実現している場合のデバッグで、各 Lambda 関数のログを調べるのが大変
    • (追記) これは S3 にログを集約して Athena で分析することになりそう