dackdive's blog

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

[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();
    });
  });
}

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

React 16で導入されたError Boundaryについて

React 16 Beta がリリースされました。
Error Boundary という概念が導入されたそうなので公式ブログをざっくり読んでみます。

はじめに:React 15 までの問題

React 15 まではコンポーネントで発生したエラーをうまくハンドリングしたり、そこからリカバリするためのしくみが提供されてませんでした。
結果として

  • コンポーネントのどこかで JavaScript エラーが発生する
  • catch していない場合、知らないうちに React の内部状態(internal state)にも悪影響を及ぼす
  • 結果として開発者コンソール上はぱっと見原因がよくわからないエラーが発生する

みたいなことが起きてました。

(参考)
TypeError: Cannot read property '_currentElement' of null · Issue #4026 · facebook/react
Error: performUpdateIfNecessary: Unexpected batch number ... · Issue #6895 · facebook/react
Cannot read property 'getHostNode' of null · Issue #8579 · facebook/react


Error Boundary の導入

デモ:https://codepen.io/gaearon/pen/wqvxGa

Error Boundary は コンポーネントツリーで発生したエラーをハンドリングする ための React コンポーネントです。
ブログに記載されているサンプルコードを見てみます。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

注目すべきは componentDidCatch(error, info) という新しいライフサイクルメソッドで、これが実装されたコンポーネントのことを Error Boundary と呼ぶようです。

使うときは

<ErrorBoundary>
  <AddTodo />
  <VisibleTodoList />
  <FilterLinkList />
</ErrorBoundary>

のように、エラーをハンドリングしたいコンポーネントツリーをラップするようにして通常のコンポーネントと同じように記述します。

このようにすると、<ErrorBoundary> 以下のコンポーネントツリーで JavaScript エラーが発生したときに componentDidCatch() がエラーを捕捉してくれます。
レンダリング時だけでなく、コンストラクタやライフサイクルメソッド内で発生したエラーも捕捉できます)
try/catchcatch 句と同じですね。

そのため、あるコンポーネントツリーでエラーが発生したときもクラッシュせず
ログを送信したり、エラーメッセージ用のコンポーネントに差し替えるといった対応が可能になります。

注意

Error Boundary が捕捉するのは コンポーネントツリー内で発生したエラーのみで、自コンポーネント内で発生したエラーは捕捉できません。
つまり Error Boundary コンポーネント内のライフサイクルメソッドや render() の書き方をミスってエラーが発生した場合、それは componentDidCatch() で捕捉できません。


componentDidCatch(error, info) の引数の中身

デモ を確認した限り、

  • 第一引数の error は Error オブジェクト( messagestack をプロパティとして持つ)
  • 第二引数の infocomponentStack というプロパティを持つオブジェクト

のようです。
また componentStack には

in BuggyCounter (created by App)
in ErrorBoundary (created by App)
in div (created by App)
in App

のような情報が入っています。Component Stack Trace と呼んでいるようです。(後述)


Error Boundary はコンポーネントツリーのどこに配置すべきか

Error Boundary は複数配置することも可能なので、ツリーのどこに Boundary を設けるかは要件次第です。
アプリケーションのルートに「不明なエラー(Something went wrong)」というメッセージを表示する Boundary を1個だけ置いてもいいですし、
画面の一部分が壊れても他の部分は利用可能な状態を維持したいのであれば、その境界ごとに複数の Boundary を用意する可能性も考えられます。

例として、Facebook Messenger はサイドバーや info panel、チャット部分などはそれぞれ Error Boundary でラップしてるそうです。


Uncaught Error 時の挙動の変更

個人的にこれが大きな仕様変更だなと思いました。
React 16 から、どの Error Boundary でも捕捉されないエラー(Uncaught Error)が発生した場合 コンポーネントツリー全体が unmount され、非表示になります。

先ほどのデモを fork して試してみます。
https://codepen.io/zaki-yama/pen/dzYWNM

f:id:dackdive:20170729013405g:plain

一番下に追加したカウンター(インクリメントして 5 になるとエラー)は Error Boundary でラップしていないのですが、ここで発生したエラーのせいで画面全体がクリアされているのがわかります。


React 15 のときの挙動と比較

https://codepen.io/zaki-yama/pen/RZWVpK

f:id:dackdive:20170728125201g:plain

React 15 のときはコンソールでエラーが発生しているものの、画面自体はそのまま残っていることがわかります。


なぜこのような仕様に変更されたのか

コンポーネントツリーが unmount されて画面全体が操作不能になることより、エラーによって UI や内部状態がおかしくなったことに気づかず中途半端に動作し続けることの方がまずい、との結論に達したようです。
たとえば、商品の支払い画面の UI で実際に表示されている金額と内部で保持している金額が乖離したまま決済を実行されるリスクがあるのは危険ですね。


Component Stack Traces

React 16 ではレンダリング中に発生したエラーは必ずコンソールに出力されます。(in development とあるので NODE_ENV === development の時のみ?)
ログにはエラーメッセージと JavaScript の stack trace に加え、コンポーネントの stack trace も出力されるようになります。

f:id:dackdive:20170729020849p:plain

これにより、コンポーネントツリーのどのコンポーネントでエラーが発生したのか追いやすくなります。


さらにファイル名と行番号も出力する

component stack trace にファイル名と行番号を出力することもできます。create-react-app の場合はデフォルトで有効になっています。
creacte-react-app を使わない場合、babel-plugin-transform-react-jsx-source という Babel プラグインを追加する必要があります。
また追加した際は production build 時には無効化する必要があります。


感想

これまで、アプリで発生したエラーを React ではどのように処理すべきなのかがいまいちわかっていなかったんですが、Error Boundary の導入によって基本的な実装方法がわかりました。
エラー時に外部の error reporting サービスに送信したり、UI のパーツごとに境界を設けてあるパーツが壊れても他のパーツは機能するように、といった制御はかなりやりやすくなりましたね。

Uncaught Error 時の仕様変更については、予期せぬエラーで画面が真っ白になるという状況は避けたいので、アップデート前に最低限アプリのルートには Boundary を設けておいた方が良さそうです。


React 16 Beta を今すぐ試すには

React 16 beta · Issue #10294 · facebook/react

によると

# Yarn
yarn add react@next react-dom@next

# Npm
npm install --save react@next react-dom@next

でインストールできるそうです。未確認。

[Chrome拡張]chrome.storage.localやchrome.storage.syncでストレージに保存したデータを確認・削除する

メモ。
大昔に作った Chrome 拡張に手を加えようと思い立った。

今回は chrome.storage API を使って設定内容をローカルに保存できるようにしたかったんだけど
開発時に保存したデータを確認したり、一旦削除したりするにはどうすればいいのかわからず調べてみた。

GUI で確認する方法とコンソールで確認する方法を示す。

参考


方法1:Storage Area Explorer という Chrome 拡張を使う

ストレージを確認するための Chrome 拡張があるらしい。

使い方は、インストールした後ストレージの内容を確認したい Chrome 拡張のポップアップやバックグラウンド、オプションページを開く。

ポップアップはアイコン右クリック -> 「ポップアップを検証」から。

f:id:dackdive:20170726190108p:plain

バックグラウンドやオプションページは chrome://extensions から。

f:id:dackdive:20170726190348p:plain

その状態でデベロッパー・ツールを開くと、「Storage Explorer」というタブが追加されているはず。

f:id:dackdive:20170726190517p:plain

さらにその下の「chrome.storage.local」「chrome.storage.sync」というそれぞれのタブから、保存されているデータが確認できる。
「Clear」または行の右の削除ボタンを押すとデータを削除することができる。


方法2:デベロッパー・ツールでコマンドを実行する方法

方法1と同じようにデベロッパー・ツールを開いた後、コンソールで以下を実行する。

// sync または local
chrome.storage.sync.get(null, function (data) { console.info(data) });

こうするとコンソールに保存されているデータが表示される。

f:id:dackdive:20170726190920p:plain

削除したい場合はドキュメントに従い remove(key) または clear() を実行する。

chrome.storage.sync.remove('customFormat', function (data) { console.log('Successfully deleted', data); });

// または全削除の場合
chrome.storage.sync.clear()

IDDD本もくもく読書会メモ#2(第3章 コンテキストマップ)

第1回 に続いて第2回も無事に開催することができました。

※社外からの参加者もお待ちしています(Slack グループ

教材

書籍に加え、前回見つけた CodeZine の解説記事

今回は書籍の前に目を通したが、最初に概要を掴んでから書籍に入れるので効果的。今後もこの進め方でいきたい。

第2回で読んだ範囲

第3章を一通り。この章はボリュームが小さくて助かった。


学習メモ

第2章で「境界づけられたコンテキスト」を学んだが、「コンテキストマップ」は複数の「境界づけられたコンテキスト」の関係を俯瞰する図となる。
抽出したコンテキストを線で結び、互いの関係性を整理していくイメージ。

f:id:dackdive:20170717225955p:plain:w320

(図は書籍より引用)

2 つのコンテキスト間にはどちらかが上流(Upstream)でどちらかが下流(Downstream)という関係がある。図中には U または D で書く。

プロジェクトの現状を示す図。こうあって欲しいという将来の図ではない。

コンテキストマップを書く理由

http://codezine.jp/article/detail/9837 より引用。

コンテキストマップを描くことによって、システム間の関係を適切に把握できるメリットがあります。DDDチームは既存システムとの連携方法を把握でき、他チームとのコミュケーションの必要性を判断できるようになります。

 コンテキストマップはアーキテクチャ図というよりも、チーム間のコミュケーション関係を示す図の意味合いが強くなります。コンテキストマップは組織間の問題を見つけ出せる唯一のドキュメントとなるため、プロジェクトの成功に不可欠といわれています。

コンテキストマップの分類

境界づけられたコンテキスト間の関係には、いくつか名前の付いた種類がある。
これは CodeZine で解説されているように、まず大分類として

  1. チーム間の関係を示す「組織パターン」
  2. データとプログラムの連携方法を示す「統合パターン」

に分けて考えると理解しやすい。(書籍では全部いっしょくたにしているが)

  1. チーム間の関係を示す「組織パターン」
    • パートナーシップ
      • 2つのコンテキストを担当するチームが協力的な関係にある
    • 別々の道
      • コンテキスト間で統合を行わない
    • 順応者
      • 上流側が下流側の要求に応える必要がない
      • 例:TwitterGitHub などの API を使った場合
    • 顧客/供給者
      • 上流のチームが成功するかどうかが下流の結果に左右されうるという場合、上流(供給者)は下流(顧客)のニーズに対応する必要がある
      • 例:モバイルアプリ開発における、API 開発チーム(上流)とアプリ開発チーム(下流
  2. データとプログラムの連携方法を示す「統合パターン」
    • 共有カーネル
      • 複数ドメインにおいて共有が必要な部分に、共通で使用するドメインモデルを構築してソースコードレベルで共有する
      • 共有カーネルに変更が必要な場合は他のチームの承認が必要になるため、この部分は極力小さくする
    • 巨大な泥団子
      • (既存システムなど)大規模で複雑なものを、そのまま(適切なモデルに分割せず)大きな塊として捉えること
    • 公開ホストサービス(OHS:Open Host Service)
    • 公表された言語(PL:Published Language)
      • 2 つの境界づけられたコンテキスト内にあるモデル同士で変換するための、共通の言語
      • OHS と組み合わせて使うことが一般的
      • 例:JSON
    • 腐敗防止層(ACL:Anti Corruption Layer)
      • 下流側が上流側の機能を自コンテキストのドメインモデルに変換するレイヤ
      • 上流側と協力関係が築けなかった場合に、上流側に振り回されないように設ける変換層
コンテキストの分析と統合ポイント

境界づけられたコンテキストが適切に分割されているかを分析し、複数の概念が 1 つのコンテキストの中に混じっていた場合は Brandolini の記法では三角の警告アイコンを記載する。

f:id:dackdive:20170717235155p:plain:w320

(図は書籍より引用)


ディスカッションメモ

  • OHS と PL のどちらか一方だけしか使われないことってあるんだろうか

→ あんまりなさそう

  • 競合ポイントはどうやって見つけるのか

書籍に出てきた図が(ひょうたん型になっていて)恣意的な感じが...

  • リモートモデル、ローカルモデルとかのくだりがよくわからない

自立性を確保するには、依存するオブジェクトの状態をローカルシステム側に保持しておけばいい。依存するオブジェクト全体をキャッシュしておけばいいと考える人もいるかもしれない。しかし、DDDでは、この考え方は一般的ではない。その代わりに、ローカルのドメインオブジェクトを作って外部のモデルをそれに変換し、ローカルのモデルに必要な最小限の状態だけを保持する。

→ たとえば、GitHub API を使って Issue 管理アプリを作るとき、

  • API で Issue を GET した結果のプロパティをアプリ内で全部保持するわけはなく、実際には必要なものだけ保持するはず
  • API に仕様変更があったときにも影響を最小限にするため、レスポンスをそのまま使うのではなく何らかのオブジェクトに変換して使うはず(ACL にもなる)

というわけで、API のレスポンスを加工してアプリ内で使うためのモデルに変換する、というのはここに記載されたことそのものズバリな気がする。


次回

7/21(金)19:00 頃からやります。


資料

見つけたものをどんどん追加していきます。

"Redux Architecture Guidelines"を読んだ

こちらの記事を読んで。React&Redux でアプリを作る上で基本的だが重要なことが書かれていたので、後で見返すためにメモ。

なお本文は原文記事の完全な翻訳ではなくポイントだけかいつまんで自分の考えも交えたものです。
また、まとめさせていただく上で著者の許諾を得ています。
(拙い英語でおそるおそる訪ねたら流暢な日本語で快諾いただいてびっくり)


State

state の形を計画(設計)する(Plan your state shape

state オブジェクトの構造をきちんと設計することは非常に重要。
こういった構造にすればいい!という唯一解はないが、設計の手助けとなるチェックリスト(質問リスト)がこちら。

  • users, accounts, items など、API 経由で取得した複数のリソースか(配列かどうか、の意?)
  • ローディングアイコンを出す/出さないなど、loading state を扱うか
  • 成功またはエラー時の通知 UI を扱うか
  • 一貫性がありかつ予測しやすいか。別のチームメンバーがすぐに理解できるか
  • 必要なデータにアクセスしやすいか。不必要にネストしていないか
  • serializable か。ローカルストレージやデータベースに簡単に保存できるようになっているか
  • state のかわりに URL でアクセスできるプロパティはないか
    • URL でアクセスできる = state に保存する必要はない?
  • 重複したデータはないか


state の過剰なネストは避ける(Avoid nesting state objects

こういった state 構造になっていることがたまにある。

{
  foo: {
    bar: {
      baz: {
        qux: ...
      }
    }
  }
}

リレーショナルデータをそのまま state に突っ込むとこういった深いネスト構造になりがちだが、深いネストはそれだけ複雑さを上げる要因となる。

  • コンポーネントレベルでは、欲しい情報を得るためにネストをたどる必要がある
    • これは適宜 container component にすることでいくらか解決しそうだけど
  • reducer レベルでは、state 更新の際のマージ作業が複雑になる
  • 加えてパフォーマンスにも影響する

Normalizing State Shape · Redux
記事中でもリンク貼られてた Redux の公式ドキュメント。知らなかった。

これは今読んでる The Complete Redux Book にも書いてあった。


raw data だけ state に保存する(Storing only raw data in the state

記事中では Redux アプリにおける data には2種類あるとしていて、

  1. raw data:アプリが必要とするデータ。API 叩いて fetch したデータなど
  2. derived data:raw data から計算することで得られるデータ
    • firstNamelastName からユーザーの Name を得る、のような

後者の derived data は raw data からいつでも導出できるので、わざわざ state に保存する必要はない。
state に何か新しい情報を追加するときは、「これって現在の state の情報から作れない?」というのを自問してみるといいとのこと。


React の state よりも Redux の state を使う(Prefer Redux state over React state

React にも state 管理のしくみはあるが、 state の大部分は Redux の state として持たせた方が一貫性があって良い。特にチーム作業においては。

とはいえ例外として React の state で管理した方がいいケースもある。たとえば、複雑な UI の状態など、アプリ全体には重要でないような state を管理するようなとき。


Actions

action の payload は標準化する(Standardize action payloads

特にチーム開発においては、action の型というものも統一しておいた方がいいよねという話。
私も使っているけど Flux Standard Action に従っておけば大体問題ないんじゃないかな。


Action は composable にする(Ensure action creators are composable

composable は「組み立て可能な」ぐらいの意味?
既存の Action を組み合わせたより複雑な Action を作るというケースが実際のプロダクトだとよくあり、そのために Action のインターフェースを統一しておくと良い。
著者は全部 Promise でラップするようにしていて、そうすると全ての Action は then でつなげられる。

redux-thunk 使ってるとそんなに意識することがない?


Component Architecture

Containers & presentational components

コンポーネントの見た目に関する責務を持つ presentational component(純粋な React コンポーネント)と、コンポーネントの振る舞い?に関する責務を持つ container component(connect() したコンポーネント)を明確に分ける。

container と presentational という用語、公式ドキュメントにちゃんと書いてあったの知らなかった。
Usage with React · Redux


コンポーネントツリーの中間でも適切に container を使う(Use intermediary containers

コンポーネントツリーのどこを container にするかはアプリ作ってると悩まされる問題だけど、container にしていいのはアプリの root コンポーネントだけという制約はなくて、むしろそうすると末端のコンポーネントに必要な props を中間のコンポーネントがひたすら "passing through" するだけの無駄が発生する。

こういった不吉な臭いを感じ取ったら適宜 container 化していくといいんじゃないか。

Container にすることによるメリット・デメリットは kuy さんのこの記事が大変わかりやすい。

簡単に言うと connect() した container コンポーネントは Redux に依存することになるので、純粋な UI パーツとして再利用可能なコンポーネントにはならなくなる。そのトレードオフで判断するといい。