dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

Frontrend Vol.10 - 夏の終わりに納涼パフォーマンス話 に行ってきた

行ってきました。
絶対ブログ書く人類枠だったのでブログ書きます。

ハッシュタグ#frontrend。また動画が FRESH! で観られます。

https://freshlive.tv/tech-conference/151511

FRESH! クライアントサイドパフォーマンス改善

FRESH! 鈴木雅佳 ( @sutiwo_ ) さん

1. SW で静的アセットのキャッシュ
  • 設計方針と実装方法
    • キャッシュがあればキャッシュを返し、なければフェッチ
    • リリース単位でキャッシュさせるもの(Circle CI の build_id に紐づく)
    • 意図的に更新させないとキャッシュされ続けるもの(version.json に任意の日付を入力)
  • SW のディレクトリ構成
service-worker/
  - assets.js // Web フォント・CSS・JS ファイルのホワイトリストを作成
  - index.js  // イベントハンドラの登録
  - register.js // SW がインストールされているかの確認

最終的に browserify で service-worker.js へ

  • ローカルで確認するときはブラウザでスーパーリロードも使えるが、環境変数にフラグを用意するのをオススメ DISABLED_SW_CACHE: bool


2. Intersection Observer で遅延ロード
  • 遅延ロード:スクロールなどをトリガーとして画像を非同期ロード
    • FRESH! での例:コンテンツのランキング画面
      • ランキング画面には 50 位ぐらいまで表示する必要があるが、常に見えているのは 6 個ぐらい
  • Intersection Observer


3. SVG スプライトやめてプログレッシブなロード
  • これまで:アイコンはSVGファイルを結合してた
  • HTTP/2 対応により通信多重化や並行リスエストが可能になり、スプライトをやめた


パフォーマンスを改善して本を売る!「読書のお時間です」の取り組みについて

ブックテーブル 小林正弘( @masahiro_koba ) さん

※資料公開されたら貼る

  • SPA + SSR
  • 技術選定
    • jQuery, handlebars, grunt から React, Flux, webpack などのモダンなツール・ライブラリへ
  • 旧システムのページと共存、行き来できる設計
    • #! (ハッシュバン)から / (パスルーティング)へ
  • 1 ページに大量の書籍の表紙画像を表示する必要がある
    • サイズ ◯ px × ◯ px、圧縮率 ◯ % でクライアントからリクエスト -> Image Magick で圧縮
  • 事業 KPI との関連を可視化


アメブロ: Isomorphic Application のパフォーマンス・チューニング

アメーバブログ 侯斌 ( @houbin217jz ) さん

  • アメブロ SP リニューアル 2016.8
    • Java -> Node.js へ
    • SSR -> Isomorphic App (SSR + SPA)
  • チューニング内容
    • バックエンドのキャッシュ
    • 遅延ロード&コード分割
    • Service Worker&アセットのプリキャッシュ
バックエンドのキャッシュ
  • Perf (SSR React) < Perf (Template e.g. Handlebars.js)
  • バックエンドキャッシュの設計
    • 問題点を洗い出す
      • 何をキャッシュする
      • 保存・削除するタイミング
  • ReactDOMServer.renderToString() の結果をキャッシュしたい
    • クライアントによって動的に変わる内容があるので、生成されたHTMLをそのままキャッシュするのではなく、ユーザーの情報に依存しないテンプレート部分のみキャッシュ
  • キャッシュ削除タイミング
    • 記事の投稿時に前の記事・次の記事のリンクとかも更新しないといけない
      • 削除漏れこわい
    • -> Namespace (Ameba Id) ごと削除
    • Fallback としてキャッシュの TTL を設定
    • さらにトランザクションの問題があるのでキャッシュにバージョンId もつける


遅延ロード & コード分割
  • SSR の流れ:
    • Path マッチング、コンポーネントと必要な処理を取り出す
    • Redux Store 準備:API コールを含め、必要な処理を行う
    • Redux Store + ReactDOMServer.renderToString()
  • コード分割
    • 必要ないコンポーネントをロードしないようにして JS 評価時間が短くなるように
    • 分割粒度: Atomic デザインの Organisms 毎
  • webpack の dynamic import で
    • Lazy Load にしたい場合 const loadExample = () => import('./ExampleComponent')
  • リビルドが遅いのでローカル開発ではコード分割を無効化する
SW & アセットのプリキャッシュ


余談

HTTP/2 にしてないと細かく分割した JS はブラウザの同時接続数の問題になりそう...という予想は当たっていたが
SSR だと少なくとも(一番読み込みファイル数が多くなるであろう)初回表示でそういったことは問題にならないのか。なるほど。


おまけ

話に出てきてあまり理解できなかったところの補足。私的メモに近い。

Service Worker・PWA

Service Worker というか PWA については Google for Mobile Workshop Day でやった Google のコードラボがわかりやすかった気がする。

コードラボ
(はじめてのプログレッシブ ウェブアプリ)

で、やってみるとわかるが、Service Worker を使うためにブラウザが対応しているかチェックしたり起動ほか Service Worker のライフサイクルメソッドを手で実装するのはめんどくさいので
そのあたりよしなにやってくれるライブラリを利用する。

コードラボで紹介されていたのは sw-precache というライブラリだったが、最後の発表で紹介されてた workbox もおそらく似たようなことができるんだろう。
(どちらも触ってはいない)

また、Service Worker 自体はプッシュ通知など様々なことが行えるんだけど、とりあえず導入してその恩恵を受けるのにまずは静的アセットのプリキャッシュを試してみるといいですよ〜というのはコードラボでも Google の人が話してた気がする。


Intersection Observer・遅延ロード(lazy load)

Intersection Observer については前々から聞いてはいたもののどういったものなのか知らなかったが、jxck さんの記事が参考になった。
Intersection Observer を用いた要素出現検出の最適化 | blog.jxck.io

また、lazy load については以前弊社の勉強会で外部の方がこういう LT をしてくれたんだが
そのときは難しくてわからなかった。。。今見たら多少は理解できるだろうか。

みんなにやさしいlazy loadと Reactそしてredux-observable - Google スライド


パフォーマンス測定ツール・指標

会の終わりに
「パフォーマンス測定のためのツールや見るべき指標などは、サイバーエージェント内で全体的に統一されているんですか?」
という質問をした。
(すいません、懇親会出られなかったので最後の発表内容とあまり関係ない質問をしてしまいました。。。)

で、

ということだったので帰り際 @1000ch さんにちょっとだけお話を伺うことができたんだけど、発表にも出てきた通り

を多くのプロダクトでは使用しているとのこと。

SpeedCurve というサービスは使ったことがなかったんだけど、特定の URL のページ表示速度を継続的に測定してくれるサービスみたい。
ただしこれはサーバーの場所やネットワーク速度などが固定された場所からの定期アクセスであり、実際のユーザーの環境ではない。
なので実際のユーザーがアクセスした時のパフォーマンスがどうだったのかは、測定結果を Google Analytics に送ることで収集しているらしい。

また Speed Index という指標についてはこのあたりを読むと勉強になった。


感想

SSR + SPA、Service Worker、HTTP/2 などなど次々に実プロダクトに投入していて、そのあたりはさすがサイバーエージェントさんだなあというのが率直な感想です。

私は業務では Salesforce というプラットフォーム上でアプリ開発をしているので
正直 SSR とか Service Worker とかを業務で使うことはなさそうなんだけど、フロントエンドに関わる人間として知っておかないといけない技術だと思うし
今回 3 名とも事例という形でリアルな話を聞けたのは非常に有意義でした。
(正直めちゃめちゃ難しかったんですが)

また、個人的には最後の発表にあった、JS のコード分割を Atomic Design の Organisms 単位にしているという話が興味深かったです。
Atomic Design の考え方をそこに利用できるのねーという素朴な感想と、Atomic Design も導入して全てうまくいくようなものではなくて何が Atoms/Molecules/Organisms なのかはチームで共通認識を作る必要があると思っているんだけども、
その共通認識の形成や言語化をめんどくさがらずにちゃんとやっておくと今回のような一見関係ない話題でもメンバー同士の会話がしやすいんだろうなあと。

あとこれ。


追記:合わせて読みたい

発表の中で、パフォーマンスと事業 KPI とを関連づけるという話がありましたが、それについてはこういった記事があるようです。
WebパフォーマンスとプロダクトKPIの相関を可視化する話

また、ちょうど今日(9/11)HTML5 Experts.js からタイムリーな記事が。読まねば。
「最近のWebパフォーマンス改善について知っておきたいコト」についてあほむに聞いてきた | HTML5Experts.jp

IDDD本もくもく読書会メモ#3(第4章 アーキテクチャ)

過去メモ

教材

流れ

  • レイヤアーキテクチャ
    • UI 層・アプリケーション層・ドメイン層・インフラストラクチャ層
  • 依存性逆転の法則 (DIP)
    • 上位が下位に依存する従来の形をやめ、抽象が詳細に依存するのではなく、実装が抽象に依存するべき
    • 実装例:
  • ヘキサゴナルアーキテクチャ
    • ドメインを中心に捉え、入出力は差し替え可能な外部インターフェースとして扱う
    • ポート&アダプタ とも呼ばれる
  • サービス指向アーキテクチャSOA:Service Oriented Architecture)
  • REST
  • コマンドクエリ責務分離(CQRS:Command Query Responsibility Segregation)
    • オブジェクトの状態を変更するメソッドは「コマンド」であり、値を返してはならない
    • 何らかの値を返すメソッドは「クエリ」であり、オブジェクトの状態を変更してはならない
    • 参照透過性
  • イベント駆動アーキテクチャ

4.2 レイヤアーキテクチャ

f:id:dackdive:20170904050826p:plain

アプリケーションを関心ごとにいくつかの層に分割したアーキテクチャ

  • UI 層
  • アプリケーション層
    • アプリケーションサービス(ドメインロジックを持たないサービス)を持つ
  • ドメイン
  • インフラストラクチャ層

依存関係逆転の法則(DIP

Robert C. Martin が提唱した原則。

上位のモジュールは下位のモジュールに依存してはならない。どちらのモジュールも、抽象に依存すべきである。抽象は、実装の詳細に依存すべきではない。実装の詳細が、抽象に依存すべきである。

下位レベルのサービスを提供するコンポーネント(インフラストラクチャ)は、上位レベルのコンポーネント(UI やアプリケーション、ドメイン)が定義するインターフェースに依存すべきという考え。
以下の記事やスライドがわかりやすかった。
(スライドは依存性の注入(DI)まで言及してる)

依存性反転の原則について : アシアルブログ

たとえばドメイン層とインフラストラクチャ層の間で考えたとき、

  • インフラストラクチャ層に特定のデータベースを扱うクラスが存在する
  • ドメイン層からそのクラスのメソッドを直接呼び出すような実装をしている

と、利用するデータベースを変更することになってインフラストラクチャ層を別のクラスに置き換えようとしたときに、実装しているメソッドの名前や引数の型・数が異なると上位レイヤであるドメイン層にまで改修が必要になる。

これを、あらかじめインフラストラクチャ層のインターフェースを定め、

  • 上位レイヤであるドメイン層ではそのインターフェースを実装したクラスを何でも扱えるようにしておく
  • 下位レイヤであるインフラストラクチャ層はそのインターフェースを必ず実装する

としておくと、下位レイヤのクラスは上位レイヤから見ていつでも交換可能となり、実装に依存しなくなる。
また上位レイヤも下位レイヤもインターフェース(抽象)に依存した状態となっている。


4.3 ヘキサゴナルアーキテクチャ

レイヤアーキテクチャではドメインに対して上位・下位という非対称な構成だったが、

「アプリケーション(ドメイン)層を中心に捉え、ユーザー操作/自動テストといった入力側もデータベース/モックといった出力側も、全てまとめて差し替え可能な外部インターフェイスとして扱う」という考え方

http://codezine.jp/article/detail/9922?p=2 より引用)

をヘキサゴナルアーキテクチャと言う。

システムを、外部内部 の二つの領域に分ける考え方だ。外部が、さまざまなクライアントからの入力を受け付ける。また、永続化されたデータを取得する仕組みを提供したり、アプリケーションの出力をデータベースなどに格納したり、メッセージングなどのその他の方法で出力を送信したりする。

f:id:dackdive:20170904055134p:plain

たとえば入力側としては、ブラウザからの入力や REST API でのアクセスといった異なる入力も、それぞれ専用のアダプターを用意することでアプリケーション(ドメイン)を同じように扱うことができる。

また出力側は、永続化機能として種々のデータベースに対応したアダプターを用意したり、あるいはテスト用のモックを扱うアダプターを用意することもできる。

ポートとアダプタの役割の違いや、どちらがより外側に位置するという扱いなのかはよくわからなかった。

ヘキサゴナルアーキテクチャを探る


4.6 CQRS

データの「参照(クエリ)」と「更新(コマンド)」を分解するアーキテクチャパターン

  • コマンド(ライト):オブジェクトの状態を変更するメソッドは値を戻してはいけない。戻り値の型は void
  • クエリ(リード):メソッドが何らかの値を返すのなら、オブジェクトの状態を変更してはいけない

f:id:dackdive:20170904062109p:plain

CQRS についてはこの記事が非常にわかりやすかった。

CQRSとイベントソーシングの使用法、または「CRUDに何か問題でも?」 | プログラミング | POSTD

図を見てもわかるように、クエリモデル用とコマンドモデル用で別々のデータストアを用意することもできる。

CQRS のメリット

参照の際は、ユーザーのロール(一般ユーザー、マネージャー、管理者)に合わせて必要な別々のクエリモデルを用意しておくことができる。
表示したい内容の変更にはビューの変更だけで済む。 (現実的には RDBMS の場合、それぞれをビューとして定義しておく?)

更新の際は、「ユーザー情報を更新する」ではなく「メールアドレスを更新する」「請求先情報を変更する」というように目的に合わせたコマンドを用意することで、パーミッションチェックは特定のコマンドが実行可能かどうかだけをチェックすればよく、エンティティのどのフィールドが更新可能なのかという混乱がなくなる?

CQRSを深く考えずに実装するなら、単純にcreate、update、deleteのコマンドを用意すればよさそうです。しかしこれは、大事なことを見落としています。読み込みに使うデータモデルとコマンドを「別のもの」として明示的に切り離すということは、データを問い合わせる際に使うUserモデルとコマンドを実行するときに使うモデルとが違っていてもかまわないということです。ユーザー情報を更新するというのではなく、「メールアドレスを変更する」「請求先情報を変更する」なとどいうコマンドを考えることができるのです。CQRSなら、エンティティのどのフィールドが更新可能なのかといった混乱はなくなります。コマンドには、そのコマンドに関連するフィールドだけを含めることになるからです。パーミッションの考えかたも簡単になります。呼び出し元が変更しようとしているエンティティのフィールドが本当に変更してよいかどうかをチェックするのではなく、呼び出し元に特定のコマンドを実行する権限があるかどうかだけをチェックすればいいのです。

http://postd.cc/using-cqrs-with-event-sourcing/ より引用)

イベント駆動アーキテクチャ

イベントを待機し、起こったイベントに応じて処理を行うプログラムスタイル。


イベントソーシング

起こったすべてのイベントを順にデータベースに保存する。銀行の取引処理なんかがそう。

ある時点から発生したイベントを順に再生することで状態がいつでも復元できるため、監査やバグ調査において有効。

参考:イベント・ソーシングを知る

また先程の CQRS と相性がよい。CQRS の記事でも言及されているので参考になる。


おわりに

第4章は紹介されている1個1個のアーキテクチャがかなり骨太な感じで、かつ DDD との関連もあまりよくわからないままざっと紹介だけされた印象を受けた。

第5章「エンティティ」も実は先日終わっているんだけど、早いとこ復習したい。
ちなみに次回は今週水曜日です。


資料

[Node.js]ExpressとPassport.jsでOAuth2 (3)認証済みユーザー情報をMongoDBに保存する

はじめに

Passport.js を使ってみる回その3です。
以前の記事はこちら:

前回は express-session を使い、認証したユーザーの情報をセッションに保存するところまでやりました。
そのときの TODO として、セッションの保存先がデフォルトではサーバー側のメモリ(MemoryStore)になっていたのですが、これは production 環境では推奨されないようです(参考(Warning のところ))。

そこで今回はセッションの保存先として MongoDB を使うように設定します。


リポジトリ


MongoDB のインストール

詳しくは
dackdive.hateblo.jp
を見ていただきたいのですが、Mac の場合 Homebrew からインストールできます。

$ brew install mongodb

# 以後、PC の起動時にプロセスを自動起動
$ brew services start mongodb

# 起動確認
$ mongo
MongoDB shell version v3.4.7
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.7
...
> 


各種ライブラリのインストール

MongoDB を Node.js および Express で使うために必要なライブラリをインストールします。

mongoose

Node.js から MongoDB を扱うためのライブラリには node-mongodb-native という MongoDB 公式のドライバもありますが、今回は mongoose というライブラリを使います。
mongoose は「object data modeling(ODM)tool」あるいは「O/R マッパーのように使えるライブラリ」などと説明されています。

$ yarn add mongoose
connect-mongo

こちらは Express のセッションストアとして MongoDB を使うために必要なライブラリです。
express-session の compatible session stores にも記載されています。

$ yarn add connect-mongo


コード


User モデルの作成

はじめに、データベースに保存する User データのモデルを定義します。

// models/user.js
import mongoose from 'mongoose';

const userSchema = mongoose.Schema({
  _id: String,
  displayName: String,
  image: String,
});

export default mongoose.model('User', userSchema);

mongoose.Schema() に渡すオブジェクトでデータの型を定義します。
今回、データを一意に識別するための id には Google のユーザー Id を使用するため、 _id: String を指定します。
(何も指定しないと MongoDB の ObjectId 型になるみたいです)

mongoose.model() の第一引数はコレクション名を決めるもので、今回のように User とした場合、コレクション名は自動的に先頭小文字+複数形の users になります。

参考:Compiling your first model

The first argument is the singular name of the collection your model is for. Mongoose automatically looks for the plural version of your model name. Thus, for the example above, the model Tank is for the tanks collection in the database.


MongoDB および connect-mongo の設定

サーバー側のエントリーポイントである app.js に、以下のように追記します。

 import express from 'express';
 import session from 'express-session';
+import mongoose from 'mongoose';
+import connectMongo from 'connect-mongo';
 import path from 'path';
 import passport from 'passport';

 (略)
+
+const DATABASE_URI =
+  process.env.MONGOLAB_URI ||
+  process.env.MONGOHQ_URL ||
+  'mongodb://localhost/myapp';
+
+mongoose.connect(DATABASE_URI, {
+  useMongoClient: true,
+});



+const MongoStore = connectMongo(session);
 app.use(session({
   secret: 'keyboard cat',
   resave: false,
   saveUninitialized: true,
+  store: new MongoStore({ mongooseConnection: mongoose.connection }),
   cookie: {
     maxAge: 1000 * 60 * 60 * 24 * 30,
   },
}));

前半では mongoose.connect() メソッドを使い MongoDB へ接続します。
DATABASE_URI環境変数を使っているのは、将来的に Heroku などの PaaS でも動かすことを想定しています。
Heroku の公式ドキュメント を参考にしています。
localhost/myappmyapp の部分は任意の文字列で、これが DB 名になります。

また、 useMongoClient: true というオプションについては http://mongoosejs.com/docs/connections.html#use-mongo-client
理由はちゃんと理解していませんが、バージョン 4.11.0 以降はこれを指定しないと起動時に warning が出るようです。

(node:47913) DeprecationWarning: `open()` is deprecated in mongoose >= 4.11.0, use `openUri()` instead, or set the `useMongoClient` option if using `connect()` or `createConnection()`. See http://mongoosejs.com/docs/connections.html#use-mongo-client


後半では express-session 用に MongoStore を作成し、 store オプションに指定することでセッションの保存先を MongoDB に変更しています。

なお、mongoose のセットアップを行った際のコネクションを再利用するため、 mongooseConnection オプションを指定しています。
これは connect-mongo の README に記載がありました。
https://github.com/jdesboeufs/connect-mongo#re-use-a-mongoose-connection


認証完了時に User をデータベースに保存する

routes/auth.js を以下のように更新します。

 passport.use(new GoogleStrategy({
   clientID: process.env.CLIENT_ID,
   clientSecret: process.env.CLIENT_SECRET,
   // FIXME: Enable to switch local & production environment.
   callbackURL: 'http://localhost:8080/auth/google/callback',
   accessType: 'offline',
-}, (accessToken, refreshToken, profile, cb) => {
+}, (accessToken, refreshToken, profile, done) => {
   // Extract the minimal profile information we need from the profile object
   // provided by Google
-  cb(null, extractProfile(profile));
+  User.findByIdAndUpdate(profile.id, extractProfile(profile), {
+    upsert: true,
+    new: true,
+  }, (err, user) => {
+    console.log(err, user);
+    return done(err, user);
+  });
 }));

ここでは mongoose の findByIdAndUpdate() メソッドを使っています。
upsert: true を指定すると、文字通り DB にデータが存在しなかったときに insert してくれます。
また new: true を指定していますが、これは true の場合更新後のオブジェクトを、false の場合更新前のオブジェクトを返すようにするというオプションです。


セッションには Id のみ保存し、デシリアライズ時に User 情報を復元する

最後に、routes/auth.js の serializeUser()deserializeUser() メソッドを以下のように更新します。

 passport.serializeUser((user, done) => {
   console.log('serializeUser', user);
-  done(null, user);
+  done(null, user.id);
 });
-passport.deserializeUser((obj, done) => {
-  console.log('deserializeUser', obj);
-  done(null, obj);
+passport.deserializeUser((id, done) => {
+  console.log('deserializeUser', id);
+  User.findById(id, (err, user) => {
+    if (err || !user) {
+      console.log('Cannot find user', id);
+      return done(err);
+    }
+    console.log('Found user', user);
+    done(null, user);
+  });
 });

今までは User 情報を丸ごとセッションに保存していたのですが、Id だけを保存するように serializeUser() を修正しました。
そうすると deserializeUser() 側の引数も Id になるので、今度はこの Id を使って users コレクションから User オブジェクトを検索します。


リダイレクト処理を入れる

これはおまけですが、 / にアクセスしたときに req.user が存在するかどうか、つまり認証済みかどうかをチェックし
認証が済んでいない場合は Google の認証画面にリダイレクトするようにしておきます。

 app.get('/', (req, res) => {
-  console.log('user', req.user);
+  console.log('/', req.user);
+  if (!req.user) {
+    return res.redirect('/auth/login');
+  }
   res.sendFile(path.join(__dirname, 'public', 'index.html'));
 });


動作確認

http://localhost:8080 にアクセスすると Google の認証画面にリダイレクトされ、許可するとトップに戻ってきます。
このとき、MongoDB の中身はどうなっているかというと

$ mongo
MongoDB shell version v3.4.7
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.7
Server has startup warnings:
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten]
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten] ** WARNING: Access control is not enabled for the database.
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten] **          Read and write access to data and configuration is unrestricted.
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten]
> show dbs
admin  0.000GB
local  0.000GB
myapp  0.000GB
> use myapp # myapp に切り替え
switched to db myapp

# sessions と users コレクションが作成されている
> show collections
sessions
users

# セッションの中身
> db.sessions.find()
{ "_id" : "kdBCv3ceCC_ufClCCuekOoeHtU-X_vMV", "session" : "{\"cookie\":{\"originalMaxAge\":2592000000,\"expires\":\"2017-10-02T02:50:00.016Z\",\"httpOnly\":true,\"path\":\"/\"},\"passport\":{\"user\":\"112492058384636445846\"}}", "expires" : ISODate("2017-10-02T02:50:00.038Z") }

# ユーザー情報
> db.users.find()
{ "_id" : "112492058384636445846", "__v" : 0, "displayName" : "Shingo Yamazaki", "image" : "https://lh6.googleusercontent.com/-jwkJLbJL4wE/AAAAAAAAAAI/AAAAAAAACI/1MbHlZlCL5w/photo.jpg?sz=50" }
>

というわけで、無事に保存されました。


まとめと TODO

認証済みのユーザー情報をデータベースに保存するため、MongoDB および mongoose を導入しました。
エラーハンドリングなど詰めが甘いところは多々ありますが、基本的な認証のしくみとしては整ったかと思います。

この後実際に GoogleAPI を利用しようとすると、アクセストークンやリフレッシュトークンを使う必要があります。
そのため、おそらくこれらもユーザー情報にひもづけて保存しておく必要がありますね。。。

と、ここまでやってみて気づいたんですが、 GoogleAPI を利用するのであれば専用の Node.js Client がライブラリとして提供されていました。

最初からこっちを使っていればよかったかも。。。
まあ、 Google 以外のサービスでも使える Passport.js の基本的な使い方がわかったから良しとするか。

(追記)

続編書きました。

(追記ここまで)

わからないこと

期限切れになったセッションの削除方法。
セッションの有効期限が切れると再度認証画面にとばされますが、そこで認証を行って戻ってくると sessions コレクションに新しいセッション情報がセットされます。
古くなったセッションは削除してもよさそうですが、方法がわからず。。。


リファレンス

[Node.js]ExpressとPassport.jsでOAuth2 (2)認証済みユーザー情報をセッションに保存する

はじめに

Passport.js を使ってみる回その2です。
前回はこちら:[Node.js]ExpressとPassport.jsでOAuth2 (1)インストールと認証までのフローを作る - dackdive's blog

前回まででなんとか認証先のサービス(今回は Google)で認証した後、ユーザー情報を取得してアプリに戻ってくるところまでできました。
今回は取得したユーザー情報をセッションに保存し、使い回せるようにします。

リポジトリ

該当のコミットは e0ecd4 あたり


express-sesion のインストール

セッションを利用するために express-session が必要になるのでインストールしておきます。

$ yarn add express-session


コード

今回新たに追加するコードです。

// routes/auth.js(抜粋)
 // Configure the Google strategy for use by Passport.js.
 //
 // OAuth 2-based strategies require a `verify` function which receives the
 // credential (`accessToken`) for accessing the Google API on the user's behalf,
 // along with the user's profile. The function must invoke `cb` with a user
 // object, which will be set at `req.user` in route handlers after
 // authentication.
 passport.use(new GoogleStrategy({
   clientID: process.env.CLIENT_ID,
   clientSecret: process.env.CLIENT_SECRET,
   // FIXME: Enable to switch local & production environment.
   callbackURL: 'http://localhost:8080/auth/google/callback',
   accessType: 'offline',
 }, (accessToken, refreshToken, profile, cb) => {
   // Extract the minimal profile information we need from the profile object
   // provided by Google
   cb(null, extractProfile(profile));
 }));

+passport.serializeUser((user, done) => {
+  console.log('serializeUser', user);
+  done(null, user);
+});
+passport.deserializeUser((obj, done) => {
+  console.log('deserializeUser', obj);
+  done(null, obj);
+});
// app.js(抜粋)
 import express from 'express';
+import session from 'express-session';
 import path from 'path';
 import passport from 'passport';
 
 import auth from './routes/auth';

 (略)

+app.use(session({
+  secret: 'keyboard cat',
+  resave: false,
+  saveUninitialized: true,
+  cookie: {
+    maxAge: 1000 * 60 * 60 * 24 * 30,
+  },
+}));

 app.use(passport.initialize());
+app.use(passport.session());

-// Serve static files
-// app.use(express.static(path.join(__dirname, 'public')));
 
+// Application Root
+app.get('/', (req, res) => {
+  console.log('user', req.user);
+  res.sendFile(path.join(__dirname, 'public', 'index.html'));
+});

routes/auth.js には GoogleStrategy の初期化処理の下に serializeUserdeserializeUser という2つのメソッドを定義しました。
これらは、ユーザー情報をセッションに保存するときや取り出すときにそれぞれ実行します。

参考:http://passportjs.org/docs#sessions

In order to support login sessions, Passport will serialize and deserialize user instances to and from the session.

ドキュメントの例では、

passport.serializeUser(function(user, done) {
  done(null, user.id);
});

セッションにはユーザーオブジェクトそのものではなくユーザーの ID だけを保存するようにしています。

また、app.js では上から順に以下を行っています。

express-session ミドルウェアの設定

app.use(session({ ... }) の箇所です。各オプションについてはこちらを見るといいです。
https://expressjs.com/en/resources/middleware/session.html

なお、サイトによっては cookie-parser を有効にしているコードも見られますが、express-session のドキュメントを読むと

Note Since version 1.5.0, the cookie-parser middleware no longer needs to be used for this module to work.

とあるので、最新版では不要のようです。


Passport.js 側のミドルウェアも有効化

app.use(passport.session()); で Passport.js のセッション用ミドルウェアも有効にしています。
express-session の初期化処理は passport.session() だけでなく passport.initialize() よりも前に行わないといけないので注意


アプリのエントリーポイント(/)に対するミドルウェアを定義

express.static() を指定してしまうと / に来たアクセスをミドルウェアで処理できなかったので書き方を変えました。
今回は / でも req.user をログに出力しているだけですが、ここで

if (!req.user) {
  return res.redirect('/auth/login');
}

などとしてやれば「アクセス時、認証済みかどうかを判定し、未認証の場合は認証画面にリダイレクト」が実現できそうです。


Passport.js や Express におけるセッションの扱いについて

一応ここまでで動くようになりましたが、Passport.js および Express でセッションがどう扱われているのかわかってなかったのでメモ。


Passport.js でセッションを使う/使わないという設定はどうなっているの?

http://passportjs.org/docs#disable-sessions

After successful authentication, Passport will establish a persistent login session. This is useful for the common scenario of users accessing a web application via a browser. However, in some cases, session support is not necessary

という記載から、デフォルトで認証したユーザー情報をセッションに保存するような動きになっていると理解しました。
その上で

http://passportjs.org/docs#middleware

If your application uses persistent login sessions, passport.session() middleware must also be used.

Note that enabling session support is entirely optional, though it is recommended for most applications. If enabled, be sure to use express.session() before passport.session() to ensure that the login session is restored in the correct order.

なので、passport.session() および express.session() は有効にする必要があると。


セッション情報はどこに保存されているの?

express-session 側のドキュメントを読むと
https://github.com/expressjs/session#store

The session store instance, defaults to a new MemoryStore instance.

なので、オプションを特に指定しなかったときの保存先は MemoryStore つまりサーバー側のメモリ上(なので再起動のたびにクリアされる)、
オンメモリではなくデータベース上に永続的に保存したい場合は store オプションで指定する必要があるみたいです。


セッション情報はどのように保存されているの?

こちらも express-session 側のドキュメントを読むと
https://github.com/expressjs/session#sessionoptions

Note Session data is not saved in the cookie itself, just the session ID. Session data is stored server-side.

というわけで、セッション情報は基本的にサーバー側に保存され、その ID だけをブラウザの Cookie に保存しているようです。
実際に今回のコードで Cookie を見ると、ログイン後に connect.sid という名前の Cookie が保存されていることがわかります。

f:id:dackdive:20170827165044p:plain

試しにこちらを削除してリロードすると、再度ログイン画面にリダイレクトされます。


まとめと TODO

というわけで前回取得した認証済みユーザー情報をセッションに保存するところまでできました。
しかしながら express-session#session(options) の項を読むとわかるように

Warning The default server-side session storage, MemoryStore, is purposely not designed for a production environment. It will leak memory under most conditions, does not scale past a single process, and is meant for debugging and developing.

セッションの保存先としてサーバーのメモリを使用するのは本番環境では推奨されないようです。
次回はこれを MongoDB に保存する方法を調べます。

(追記)

書きました。

リファレンス

[Node.js]ExpressとPassport.jsでOAuth2 (1)インストールと認証までのフローを作る

はじめに

Express で TwitterGoogleAPI を使った web アプリを作りたいと思い、
Node.js 用のそのような認証・認可のためのライブラリを探したところ Passport.js というのが有名そうだとわかりました。

既に日本語でも多くの記事があるものの、チュートリアルのような段階的に作り上げていく手順としてまとまったドキュメントがなくて最初戸惑ってしまったので
実装の手順をメモしておきます。

なお、今回は連携先に Google を選びました。

※OAuth に対して「認証」という言葉を使うのは適切でなく「認可」と呼ぶべきなのかもしれませんが、(自分自身理解しきれていないこともあり)ここでは特に区別せず「認証」という言葉を使っています。


リポジトリ

該当のコミットは 27d4b253a35d あたり。


参考にしたリポジトリ

Node.js を使用したユーザーの認証  |  Node.js  |  Google Cloud Platform

GCP の Node.js チュートリアルで Passport.js を使用した認証についても触れており、こちらが非常に分かりやすかったです。
リポジトリはこちら。

https://github.com/GoogleCloudPlatform/nodejs-getting-started


手順

Google Cloud Console でプロジェクトを作成し、client id と client secret を発行する

https://console.cloud.google.com
にアクセスして新規プロジェクトを作成し、[API とサービス] > [認証情報] から新規 OAuth 2.0 クライアント ID を作成します。
こちらの公式ドキュメントを参考にすると良いと思います。
OAuth 2.0 の設定 - Cloud Platform Console ヘルプ

アプリケーションの種類は「その他」で問題ありませんでした。
Google+ API を有効化しておく必要があるので注意

最後に表示されるダイアログの クライアント ID と クライアントシークレット を控えておきます。
またはダイアログを閉じた後も、クライアント ID 一覧の右のダウンロードアイコンからダウンロードできます。

f:id:dackdive:20170824031450p:plain


Express アプリケーションのひな形作成

express-generator で作成したものでも何でもいいです。自分は webpack-dev-server を組み込んだテンプレート を用意していたのでそれを使いました。


Passport.js のインストール
$ yarn add passport passport-google-oauth20

で Passport.js 本体と、Google と連携するための Strategy をインストールします。
Passport.js の構成として、本体の他に連携サービスごとにライブラリが用意されており(Strategy と呼ぶ)、必要なものを追加でインストールします。

passport-google-oauthpassport-google-oauth20 があるが、前者は OAuth 1.0 用の Strategy と OAuth 2.0 用の Strategy の両方が使えるものっぽいです(未確認)。


セットアップ

初期化処理などを書いていきます。まず、エントリーポイントとなる app.js は以下のようになります。
(babel-node を使っているため ES Modules 形式になっています)

// app.js
import express from 'express';
import path from 'path';
import passport from 'passport';

import auth from './routes/auth';

const app = express();
const port = process.env.PORT || 8080;

app.use(passport.initialize());

// Routing
app.use('/auth', auth);

// Serve static files
app.use(express.static(path.join(__dirname, 'public')));

app.listen(port, (err) => {
  if (err) {
    console.error(err);
  } else {
    console.info(`==> 🌎  Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`);
  }
});

Passport の初期化として app.use(passport.initialize()); を追加したぐらいでしょうか。

また、ログインやコールバックなどの URL は /auth という URL 以下にまとめるようにしました。
routes/auth.js の中身は以下です。

// routes/auth.js
import express from 'express';

// [START setup]
import passport from 'passport';
import passportGoogleOauth2 from 'passport-google-oauth20';

const GoogleStrategy = passportGoogleOauth2.Strategy;

function extractProfile(profile) {
  let imageUrl = '';
  if (profile.photos && profile.photos.length) {
    imageUrl = profile.photos[0].value;
  }
  return {
    id: profile.id,
    displayName: profile.displayName,
    image: imageUrl,
  };
}

// Configure the Google strategy for use by Passport.js.
//
// OAuth 2-based strategies require a `verify` function which receives the
// credential (`accessToken`) for accessing the Google API on the user's behalf,
// along with the user's profile. The function must invoke `cb` with a user
// object, which will be set at `req.user` in route handlers after
// authentication.
passport.use(new GoogleStrategy({
  clientID: process.env.CLIENT_ID,
  clientSecret: process.env.CLIENT_SECRET,
  // FIXME: Enable to switch local & production environment.
  callbackURL: 'http://localhost:8080/auth/google/callback',
  accessType: 'offline',
}, (accessToken, refreshToken, profile, cb) => {
  // Extract the minimal profile information we need from the profile object
  // provided by Google
  cb(null, extractProfile(profile));
}));

// TODO: ルーティング

GoogleStrategy というオブジェクトに先ほどの client id や client secret を渡して初期化しています。
今回、これらの情報は .env に記述し、node-foreman などを使って起動時に読み込む想定でこのような書き方になっています。

また、 extractProfile() は参考リポジトリのコードをそのまま利用しました。
認証が完了した後のレスポンスとして渡されてくる 生の profile (→ 正確にはレスポンスを このあたり で一度加工してるぽい)をこの関数で加工して、アプリで使う認証済みユーザー情報としている。

ログイン処理を実装する

Google の認証画面に遷移するための URL を定義します。先ほどの routes/auth.js の下に追記します。

// routes/auth.js
const router = express.Router();

router.get('/login',
  passport.authenticate('google', { scope: ['email', 'profile'] }),
);

export default router;

基本的にはこれだけ。
この状態で http://localhost:8080/auth/login にアクセスすると Google の認証画面にリダイレクトされます。
ただしコールバック URL /auth/google/callback に対応するミドルウェアを定義していないので、このままでは認証を許可してアプリに戻ってきたときにエラーになります。

リダイレクト処理を実装する

routes/auth.js にもう1つミドルウェアを追加します。

router.get(
  // OAuth 2 callback url. Use this url to configure your OAuth client in the
  // Google Developers console
  '/google/callback',

  // Finish OAuth 2 flow using Passport.js
  passport.authenticate('google'),

  // Redirect back to the original page, if any
  (req, res) => {
    console.log('Succssfully logged in:', req.user);
    const redirect = req.session.oauth2return || '/';
    delete req.session.oauth2return;
    res.redirect(redirect);
  },
);

passport.authenticate('google') をもう1回呼び出して OAuth2 のフローを完了させた後、認証後の処理としてはとりあえずログを出力して / にリダイレクトするだけとしています。
もう一度 /auth/login にアクセスすると、今度は

Succssfully logged in: { id: '112....5846',
  displayName: 'Shingo Yamazaki',
  image: 'https://lh6.googleusercontent.com/-jwkJLL4wE/AAAAAAAAAAI/AAAAAAAAACI/1MbHlZlCL5w/photo.jpg?sz=50' }

のようなログが出力されます。というわけで認証したユーザー情報は req.user で取得できることがわかります。
またこの値は先ほどの extractProfile() で処理した結果になっています。


まとめと TODO

というわけでまずは Passport.js を使った基本的な OAuth2 のフローの実装方法がわかりました。
が、今のままでは認証したユーザー情報を持ち回ることができていないので、次回はこの情報をセッションに保存する方法について調べます。

実際に書いてみてわかりましたが、連携するサービスを変えたい場合はほとんど Strategy の差し替えだけで済みそうで非常に便利。

(追記)

続編書きました。


トラブルシューティング

/auth/login にアクセスして認証した後、

Error
    at /Users/yamazaki/workspace/node_modules/passport-google-oauth20/lib/strategy.js:95:21
    at passBackControl (/Users/yamazaki/workspace/node_modules/oauth/lib/oauth2.js:132:9)
    at IncomingMessage.<anonymous> (/Users/yamazaki/workspace/node_modules/oauth/lib/oauth2.js:157:7)
    at emitNone (events.js:91:20)
    at IncomingMessage.emit (events.js:188:7)
    at endReadableNT (_stream_readable.js:975:12)
    at _combinedTickCallback (internal/process/next_tick.js:80:11)
    at process._tickDomainCallback (internal/process/next_tick.js:128:9)

というエラーが出てしばらく悩みましたが、結果的には Google+ API を有効化していないのが原因でした。

MacにMongoDB(3.4.7)をHomebrewでインストールする

メモ。
3 年前にも書いたが、今これを読みながらインストールしたらいろいろ情報が古くなっていたので改めて。

環境

  • Mac OS X Yosemite 10.10.5
  • Homebrew 1.3.1
  • MongoDB 3.4.7


MongoDB の特徴

↑の記事を参照。


インストール手順

基本的には公式ドキュメントに従ってインストールすれば OK。
Install MongoDB Community Edition on OS X — MongoDB Manual 3.4

1. Homebrew をアップデートする
$ brew update
2. MongoDB をインストール
$ brew install mongodb

でインストールされる。

3. プロセスの起動

先ほどのインストールの最後に

To have launchd start mongodb now and restart at login:
  brew services start mongodb
Or, if you don't want/need a background service you can just run:
  mongod --config /usr/local/etc/mongod.conf

というメッセージが表示される。
Homebrew Services という機能を使って、再起動後もプロセスが自動起動するように設定するには

$ brew services start mongodb

を実行する。

そうではなく、一回限りの起動を行う場合は

$ mongod --config /usr/local/etc/mongod.conf

を実行する。

4. mongo コマンドの実行

3 でプロセスを起動した状態で

$ mongo

を実行する。

$ mongo
MongoDB shell version v3.4.7
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.7
Server has startup warnings:
2017-08-19T23:23:55.973+0900 I CONTROL  [initandlisten]
2017-08-19T23:23:55.973+0900 I CONTROL  [initandlisten] ** WARNING: Access control is not enabled for the database.
2017-08-19T23:23:55.973+0900 I CONTROL  [initandlisten] **          Read and write access to data and configuration is unrestricted.
2017-08-19T23:23:55.973+0900 I CONTROL  [initandlisten]
> show dbs # データベース一覧を表示
admin  0.000GB
local  0.000GB


注意

ちょっとハマった点について。

config ファイルの場所

↑のコマンドからもわかるように、/usr/local/etc/mongod.conf という設定ファイルが作成されている。
中身はこんな感じ。

systemLog:
  destination: file
  path: /usr/local/var/log/mongodb/mongo.log
  logAppend: true
storage:
  dbPath: /usr/local/var/mongodb
net:
  bindIp: 127.0.0.1

ログ・ファイルや DB ファイルへのパスが指定されていることがわかる。

注意点として、 mongod コマンド実行時にデフォルトでこの設定ファイルが読み込まれるわけではない

DB ファイルのパス

設定ファイルに /usr/local/var/mongodb と書かれているのでこれがデフォルトかと思ったら違うらしい。

/data/db

というパスのようだ。

参考:https://docs.mongodb.com/manual/reference/configuration-options/#storage.dbPath

また、このディレクトリは自動的に作られるわけではないので、mongod コマンドを --config オプションなしで実行する場合は事前に

$ mkdir -p /data/db

ディレクトリを作成しておく必要がある。 Read/Write 権限も必要。
これをやらずに mongod コマンドだけを実行すると

2017-08-19T23:43:14.258+0900 I CONTROL  [initandlisten] MongoDB starting : pid=47588 port=27017 dbpath=/data/db 64-bit host=yama.local
2017-08-19T23:43:14.259+0900 I CONTROL  [initandlisten] db version v3.4.7
2017-08-19T23:43:14.259+0900 I CONTROL  [initandlisten] git version: cf38c1b8a0a8dca4a11737581beafef4fe120bcd
2017-08-19T23:43:14.259+0900 I CONTROL  [initandlisten] OpenSSL version: OpenSSL 1.0.2l  25 May 2017
2017-08-19T23:43:14.259+0900 I CONTROL  [initandlisten] allocator: system
2017-08-19T23:43:14.259+0900 I CONTROL  [initandlisten] modules: none
2017-08-19T23:43:14.259+0900 I CONTROL  [initandlisten] build environment:
2017-08-19T23:43:14.259+0900 I CONTROL  [initandlisten]     distarch: x86_64
2017-08-19T23:43:14.259+0900 I CONTROL  [initandlisten]     target_arch: x86_64
2017-08-19T23:43:14.259+0900 I CONTROL  [initandlisten] options: {}
2017-08-19T23:43:14.263+0900 I STORAGE  [initandlisten] exception in initAndListen: 29 Data directory /data/db not found., terminating
2017-08-19T23:43:14.263+0900 I NETWORK  [initandlisten] shutdown: going to close listening sockets...
2017-08-19T23:43:14.263+0900 I NETWORK  [initandlisten] shutdown: going to flush diaglog...
2017-08-19T23:43:14.264+0900 I CONTROL  [initandlisten] now exiting
2017-08-19T23:43:14.264+0900 I CONTROL  [initandlisten] shutting down with code:100

というようにエラーで落ちる。

brew services start mongodb では config ファイルは読み込まれているのか?

自動起動の方のコマンドを使った場合、/data/db がなくても特にエラーにならない。ということで config ファイルが読み込まれているのだろうと推測できるが、

$ brew services list
Name    Status  User     Plist
mongodb started yamazaki /Users/yamazaki/Library/LaunchAgents/homebrew.mxcl.mongodb.plist
redis   stopped

で homebrew.mxcl.mongodb.plist を見てみると

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>homebrew.mxcl.mongodb</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/opt/mongodb/bin/mongod</string>
    <string>--config</string>
    <string>/usr/local/etc/mongod.conf</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <false/>
  <key>WorkingDirectory</key>
  <string>/usr/local</string>
  <key>StandardErrorPath</key>
  <string>/usr/local/var/log/mongodb/output.log</string>
  <key>StandardOutPath</key>
  <string>/usr/local/var/log/mongodb/output.log</string>
  <key>HardResourceLimits</key>
  <dict>
    <key>NumberOfFiles</key>
    <integer>4096</integer>
  </dict>
  <key>SoftResourceLimits</key>
  <dict>
    <key>NumberOfFiles</key>
    <integer>4096</integer>
  </dict>
</dict>
</plist>

plist ファイルの読み方はわからないが、 ProgramArguments の後にそれらしい記述が見られるので読み込まれているのだと理解した。

React Hot Loader 3 と webpack 3 でHot Module Replacement(react-hmreからの移行)

経緯

dackdive.hateblo.jp

これまで webpack-dev-server の Hot Module Replacement を React/Redux でも有効にするために babel-preset-react-hmre を使っていたが、いつの間にか GitHubリポジトリがなくなっていて deprecated ぽいので別のプラグインを探していた。

公式ドキュメント を読む限り React Hot Loader が一番有力のようなので、そちらへ移行する。

サンプルコード(GitHub

先に、今回の移行後のコードが確認できる GitHub リポジトリを載せておく。

PR は #4, #6


設定手順

ここを読む。
Getting Started · React Hot Loader

※以下、(Step X) は上記ドキュメント中の Step と対応している

React Hot Loader のインストール

2017/08/09 時点で v3 はベータ版のようなので、 @next をつけてインストールする。

$ npm install --save-dev react-hot-loader@next


webpack 側の設定をする (Step1)

ここについては webpack-dev-server をどのように利用しているかによってやることが異なる。

(Option 1) webpack-dev-server をスタンドアロンなサーバーとしてそのまま利用している場合

これまで通り --hot オプションつきで実行すれば OK。

// package.json
"scripts": {
    "start": "webpack-dev-server --hot"
  },
(Option 2) webpack-dev-server をカスタマイズして使っている場合

未検証。ドキュメントの通りにやればいいのでは。

(Option 3) の webpackDevMiddleware, webpackHotMiddleware として Express に組み込んで使っている場合

自分の場合はこれ。
参考:webpack-dev-serverをExpressに組み込んで使う(webpack-dev-middleware, webpack-hot-middleware) - dackdive's blog

ドキュメントは TODO になっていたが、以下ができていれば特に設定変更は不要だった。

  • webpack.config.js の entry'webpack-hot-middleware/client' を追加する
  • webpack.config.js の pluginsnew webpack.HotModuleReplacementPlugin() を追加する
  • Express 側で以下のように middleware を組み込む
import express from 'express';
import path from 'path';

const app = express();
const port = process.env.PORT || 8080;

if (process.env.NODE_ENV !== 'production') {
  /* eslint-disable global-require, import/no-extraneous-dependencies */
  const webpack = require('webpack');
  const webpackDevMiddleware = require('webpack-dev-middleware');
  const webpackHotMiddleware = require('webpack-hot-middleware');
  const config = require('./webpack.config.dev.babel').default;
  const compiler = webpack(config);

  app.use(webpackHotMiddleware(compiler));
  app.use(webpackDevMiddleware(compiler, {
    noInfo: true,
    publicPath: config.output.publicPath,
  }));
}

// Serve static files
app.use(express.static(path.join(__dirname, 'public')));

console.log(`Served: http://localhost:${port}`);
app.listen(port, (err) => {
  if (err) {
    console.error(err);
  } else {
    console.info(`==> 🌎  Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`);
  }
});

詳細は リポジトリ を参照。


Babel または webpack に react-hot-loader を追加する (Step 3 の 2)

2 通りの方法がある。
.babelrc

{
  "plugins": [
    "react-hot-loader/babel"
  ]
}

を追加するか、または webpack.config.js 側に

    module: {
        loaders: [{
            test: /\.js$/,
            loaders: ['react-hot-loader/webpack', 'babel'],
            include: path.join(__dirname, 'src')
        }]
    }

を追加する。

いずれも、 NODE_ENV === development のときだけ、とは書かれていなかった。


アプリのエントリーポイントに react-hot-loader/patch を追加する (Step 3 の 3)

こちらも 2 通りの方法がある。
アプリのルートコンポーネントとなる JS ファイルの1行目に

import 'react-hot-loader/patch';

を追加するか、または webpack.config.js の entry

  entry: [
    'react-hot-loader/patch', // これを追加
    './scripts/index' // アプリのエントリーポイント
  ]

を追加する。

自分は前者の方にした。


ルートコンポーネントに HMR の仕組みを入れる (Step 2)

最後に、アプリのルートコンポーネントに HMR の仕組みを入れる。

https://github.com/zaki-yama/redux-express-template/blob/master/public/index.js

import 'react-hot-loader/patch';
import React from 'react';
import { Provider } from 'react-redux';
import { render } from 'react-dom';
import { AppContainer } from 'react-hot-loader';

import configureStore from './store/configureStore';
import App from './containers/App';

import './styles/index.scss';

const store = configureStore();

const rootEl = document.getElementById('root');

render(
  <AppContainer>
    <Provider store={store}>
      <App />
    </Provider>
  </AppContainer>,
  rootEl,
);

if (module.hot) {
  module.hot.accept('./containers/App', () => {
    render(
      <AppContainer>
        <Provider store={store}>
          <App />
        </Provider>
      </AppContainer>,
      rootEl,
    );
  });
}

babel-preset-react-hmre のときはこのあたり自前で実装する必要はほぼなかったんだけど、React Hot Loader だと if (module.hot) { ... } 部分の処理が必要になるらしい。

<Provider store={store}>
  <App />
</Provider>

部分は Redux を使っているときのお決まりの書き方だが、これをさらに React Hot Loader が提供している <AppContainer /> というコンポーネントでラップする。


余談:ハマったポイント

ドキュメントでは

const render = Component => {
  ReactDOM.render(
    <AppContainer>
      <Component />
    </AppContainer>,
    document.getElementById('root')
  );
}

render(RootContainer);

if (module.hot) {
  module.hot.accept('./containers/rootContainer', () => { render(RootContainer) });
}

というように記載されていたので、最初は以下のようにした。

// index.js
import 'react-hot-loader/patch';
import React from 'react';
import { render } from 'react-dom';
import { AppContainer } from 'react-hot-loader';

import Root from './containers/Root';

const renderApp = (Component) => {
  render(
    <AppContainer>
      <Component />
    </AppContainer>,
    document.getElementById('root'),
  );
};

renderApp(Root);

if (module.hot) {
  module.hot.accept('./containers/Root', () => {
    renderApp(Root);
  });
}
// ./containers/Root.js
import React from 'react';
import { Provider } from 'react-redux';

import configureStore from '../store/configureStore';
import App from './App';

import '../styles/index.scss';

const store = configureStore();

export default class Root extends React.Component {
  render() {
    return (
      <Provider store={store}>
        <App />
      </Provider>
    );
  }
}

が、Redux の store が絡むとうまくいかないのか、上記のようにすると HMR 時にこのようなエラーが発生した。

does not support changing store on the fly. It is most likely that you see this error because you updated to Redux 2.x and React Redux 2.x which no longer hot reload reducers automatically. See https://github.com/reactjs/react-redux/releases/tag/v2.0.0 for the migration instructions.

調べてみたところ
Hot reload not working · Issue #502 · reactjs/react-redux
こちらの Issue が見つかり、さらにそこから redux-devtools の TodoMVC の example更新されていた のを見つけたので
example のコードを確認し、解決した。

また React Hot Loader の Starter Kit で紹介されている大量のリポジトリの中から

なんかを読むと、(src/client/index.js の部分)

import 'react-hot-loader/patch';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Provider } from 'react-redux';
import { AppContainer } from 'react-hot-loader';

import configureStore from './store/configureStore';
import App from './containers/App';

import './styles/index.scss';

const store = configureStore();

const rootEl = document.getElementById('root');
const renderApp = () => {
  render(
    <AppContainer>
      <Provider store={store}>
        <App />
      </Provider>
    </AppContainer>,
    rootEl,
  );
};

renderApp();

if (module.hot) {
  module.hot.accept('./containers/App', () => {
    setImmediate(() => {
      unmountComponentAtNode(rootEl);
      renderApp();
    });
  });
}

というように一旦アンマウントしてから再描画でもうまくいくらしい。本当にこれが正しいのかは不明。