dackdive's blog

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

ExpressとPassport.jsでOAuth2 (4)有効期限切れのアクセストークンをリフレッシュする

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


前回時点での課題

前回までで、認証したユーザー情報を MongoDB に保存することができました。
これで一件落着かと思われたのですが、よくよく考えるとこの後認証先のサービス(ここでは Google)の API を利用するとなると
アクセストークンが必要になります。これも保存しておかないといけませんね。

また、アクセストークンには有効期限が定められているものもあります。
その場合は「アクセストークンが期限切れになったら、リフレッシュトークンを使って新しいアクセストークンを取得し、再度 DB に保存する」という処理も入れなければいけません。

調べてみたところ Passport.js ではそのためのライブラリとして passport-oauth2-refresh というものがあるようです。

(あんまスターついてなくて心配。。。)

というわけで今回はまず、アクセストークンとリフレッシュトークンも DB に保存するようにした後、
passport-oauth2-refresh を使ったアクセストークンの更新処理も実装してみます。


ソースコード

PR は https://github.com/zaki-yama/passport-express-oauth2/pull/2


API の実装

本旨とは関係ありませんが、先に今回用意した API のコードを載せておきます。
内部的に Google Tasks API を叩いて Tasklist を取得します。

// routes/api.js
import express from 'express';
import fetch from 'node-fetch';

const router = express.Router();

router.get('/tasklists', (req, res) => {
  console.log('/tasklists', req.user);
  fetch(`https://www.googleapis.com/tasks/v1/users/@me/lists?access_token=${req.user.accessToken}`)
    .then((response) => {
      return response.json();
    })
    .then((data) => {
      console.log(data);
      return res.json(data.items);
    });
});

export default router;

アクセストークンが切れなければこのままでも問題ありませんが、時間が経つと Google Tasks API のレスポンスが 401 になってしまいます。


アクセストークン、リフレッシュトークンを DB に保存する

これは簡単です。
認証時に accessToken, refreshToken は取得できていたので、ユーザーモデルにこれらを追加して一緒に保存してしまいます。

 // models/user.js
 const userSchema = mongoose.Schema({
   _id: String,
   displayName: String,
+  accessToken: String,
+  refreshToken: String,
   image: String,
 });
 // routes/auth.js
-  User.findByIdAndUpdate(profile.id, extractProfile(profile), {
+  const user = {
+    accessToken,
+    refreshToken,
+    ...extractProfile(profile),
+  };
+
+  User.findByIdAndUpdate(profile.id, user, {


passport-oauth2-refresh のインストールと設定

$ yarn add passport-oauth2-refresh

でインストールします。

 // routes/auth.js

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

 import User from '../models/user';

 ...
 // 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({
+const strategy = new GoogleStrategy({
   clientID: process.env.CLIENT_ID,
   clientSecret: process.env.CLIENT_SECRET,
   // FIXME: Enable to switch local & production environment.
   ...
     console.log(err, user);
     return done(err, user);
   });
-}));
+});
+passport.use(strategy);
+refresh.use(strategy);

ここもそんなにやることはないですね。元々 passport.use() にストラテジーを渡していたのと同じことを refresh.use() に対してもやります。


アクセストークンが有効期限切れだったら更新する、という処理を実装

最初に紹介した /routes/api.js にアクセストークンが有効期限切れだったときの処理を入れると、以下のようになります。
これは、 passport-oauth2-refresh の Issue に書かれていたコードを参考にしたものです。
Mongoose でのモデルの save 方法などは↑のコードとは違うようなので、そこは直してます。

 import express from 'express';
 import fetch from 'node-fetch';
+import refresh from 'passport-oauth2-refresh';
 
 const router = express.Router();
 
 router.get('/tasklists', (req, res) => {
-  console.log('/tasklists', req.user);
-  fetch(`https://www.googleapis.com/tasks/v1/users/@me/lists?access_token=${req.user.accessToken}`)
-    .then((response) => {
-      return response.json();
-    })
-    .then((data) => {
-      console.log(data);
-      return res.json(data.items);
-    });
+  const user = req.user;
+  const makeRequest = () => {
+    console.log('/tasklists', user.accessToken);
+    fetch(`https://www.googleapis.com/tasks/v1/users/@me/lists?access_token=${user.accessToken}`)
+      .then((response) => {
+        if (response.status === 401) {
+          console.log('response status 401. Retry');
+          // Access token expired (or is invalid).
+          // Try to fetch a new one.
+          refresh.requestNewAccessToken('google', user.refreshToken, (err, accessToken) => {
+            // TODO: Error handling
+            console.log('new access_token', accessToken);
+
+            // Save the new accessToken
+            user.accessToken = accessToken;
+            user.save().then(() => {
+              makeRequest();
+            });
+          });
+        }
+
+        console.log(response.status);
+        return response.json();
+      })
+      .then((data) => {
+        console.log(data);
+        return res.json(data.items);
+      });
+  };
+
+  return makeRequest();
 });
 
 export default router;

fetch を使った場合、ネットワークエラーでもない限りレスポンスは then() に渡されるので、 response.status の値で 401 が返ってきていないかチェックします。
401 で返ってきた場合はアクセストークン有効期限切れの可能性があるので、

refresh.requestNewAccessToken()

で新しいアクセストークンを取得します。
レスポンスの第二引数には新しいアクセストークンが渡されてくるので、あとはこのアクセストークンでユーザーモデルの内容を上書きして保存( user.save() => { ...)すればいいですね。

ただしリトライ数には上限がないので、Issue のサンプルコードと同じように

  • const retry = 2 などを用意する
  • makeRequest() 時にデクリメントする
  • retry <= 0 の場合は 401 画面を出す

とした方がいいと思います。

また、今回は簡単なサンプルコードのため API が一つしかなく、その中にアクセストークン更新処理&リトライを書いてますが、
これはおそらくすべての API で共通な処理となると思うので、独立した関数にする?など工夫が必要そうです。


TODO

エラーハンドリングを諸々省略してるので、もうちょっと安全にするためにはそういった処理も必要そうです。
なんか動作検証してたら UnhandledPromiseException がちょいちょい出てた気がする(けどリロードしたら出なくなる)んですが、 user.save() に失敗しているとかかな...

[Salesforce]承認プロセスの「割り当て先として使用するユーザ項目」について

メモです。
承認プロセスの設定で、「割り当て先として使用するユーザ項目」というのが出てくるけどどういう挙動になるのかわからず。
合わせて、その下にある「XXX 所有者の承認者項目を使用」というチェックボックスについても。

f:id:dackdive:20170926191854p:plain

公式ドキュメントはこちら。
承認プロセスで自動化された承認者の選択


割り当て先として使用するユーザ項目

ざっくりとした説明

ユーザオブジェクトの任意の項目(※)を設定しておくと、続く承認ステップの設定で「ユーザのこの項目に設定した人を承認者にする」ということができるようになる。
※ただし階層関係の項目のみ。カスタム項目を作らない限り該当するのは「マネージャ」項目だけ


もうちょっと具体的に

この項目を設定せずに承認ステップの割り当て先(承認者)の選択画面を開くと、選択肢として選べるのは以下のいずれか。

f:id:dackdive:20170926192536p:plain

  • 申請者が承認者を手動で選択する
    • 文字通り、申請する側が申請時に明示的に承認者を選ぶ
  • 自動的にキューに割り当てる
    • 別途作成しておいたキューを割り当てる
  • 自動的に承認者に割り当てる
    • 特定のユーザを明示的に指定したり、申請するオブジェクトに作成されている参照項目(ユーザ)を使う

ここで、ユースケースとして「申請者のマネージャを承認者に設定したい」というのは割とありそうだが、上記の選択肢では実現できない。
このときに先ほどの項目を使う。

f:id:dackdive:20170926193953p:plain

設定すると上のキャプチャのように、「選択したユーザ項目を使用して自動的に承認者を割り当てる。」という選択肢が1個増える。
このような設定にしておくと、申請時に申請者のマネージャ(人によって異なる)が自動的に承認者に選ばれるようになる。

デフォルトでは「マネージャ」しか選べないが、ユーザのカスタム項目で階層関係型の項目を追加してあげればそちらを利用することも可能。


XXX 所有者の承認者項目を使用

こちらは、申請するレコードの所有者と申請者が異なる場合に承認者の選ばれ方が変わってくる。
ユーザの階層関係項目を使って承認者を選ぶところは同じだが、ON にすると文字通り、承認者はレコードの「申請者」ではなく「所有者」に設定されている内容を見に行く。

なのでたとえば、部長 > 上司 > 部下 という 3 人のユーザがいて、

  • 「部下」のマネージャ項目は「上司」
  • 「上司」のマネージャ項目は「部長」

という設定をしているとする。

この状態で「部下」が所有者になっているレコードを申請するとき、

  • 「XXX 所有者の承認者項目を使用」が OFF の場合
    • 「部下」が申請した場合:承認者は「上司」
    • 「上司」が申請した場合:承認者は「部長」
  • ON の場合
    • 「部下」が申請しても「上司」が申請しても、承認者は「上司」(所有者である「部下」のマネージャ項目を使うため)

という挙動になる。

IDDD本もくもく読書会メモ#4(第6章 値オブジェクト)

第5章エンティティのメモが書けないまま第6章を終えてしまった。
ので、記憶の新しいうちに値オブジェクトの方のメモを書く。

過去メモ
教材

学習メモ

値オブジェクトとは?
  • ドメイン内の何かを計測したり定量化したり、あるいは説明したりするためのオブジェクト
  • 例:姓・名や電話番号、金額や数量など
  • なぜ値オブジェクトを使うのか
    • int 型のようなプリミティブな型ではなく専用の値オブジェクトにすることで、ドメインの業務をプログラムでわかりやすく示すことができる
    • 現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法 にはこれに加え、プリミティブな型よりも制約を設けることでその値が備えるべき要件を明確にする、といった記述があった気がする
      • 例:数量に int 型を使うと Java の場合 -2^31 ~ 2^31 - 1 までの整数を許容してしまうが、数量にマイナスはない。また要件によっては最大値もあるかもしれないし、その場合バリデーションロジックを記述することで要件が明確になる
エンティティと値オブジェクトの違い、使い分け
  • 違いは 一意な識別子を必要とするかどうか
    • 値オブジェクトは「一意に識別して変更を管理する必要がないモノ」(CodeZine より)
  • 値オブジェクトにすべきところをエンティティにしてしまいがちだが、値オブジェクトにすることで使いやすくテストしやすくなるので、積極的に利用すべき

可能な限り、エンティティよりは値オブジェクトを使ってモデリングすべきだと聞いたら、驚くかもしれない。ドメインの概念をエンティティとしてモデリングしなければいけないとしても、そのエンティティの設計は、子エンティティのコンテナではなく値のコンテナとして組み立てるよう心がけるべきだ。このアドバイスは、単なる気まぐれによるものではない。値型は何かを計測したり定量化したり説明したりするときに使うもので、作成やテストがしやすいし、使うのも最適化するのも保守するのも楽だ。

  • 値オブジェクトを使用するかどうかの判断基準は以下

あるモデル要素について、その属性しか関心の対象とならないのであれば、その要素を値オブジェクトとして分類すること。値オブジェクトに、自分が伝える属性の意味を表現させ、関係した機能を与えること。値オブジェクトを不変なものとして扱うこと。同一性を与えず、エンティティを維持するために必要となる複雑な設計を避けること。[Evans](97ページ)


値オブジェクトの特徴
  1. 計測/定量化/説明
    • 値オブジェクトは、ドメインの何かを計測・定量化・説明した結果である
  2. 不変性
  3. 概念的な統一体
    • 一つの属性値だけでは意味を持たず、それぞれが組み合わさることで適切な説明をできることを「概念的な統一体」と呼ぶ
    • 例:「50,000,000ドル」は「50,000,000」と「ドル」という2つの属性があるが、これらを切り離すと別の意味になる(か、意味をもたなくなる)
  4. 交換可能性
  5. 等価性(値が等しいかどうかを、他と比較できる)
  6. 副作用のない振る舞い


標準型(タイプコード)
  • 区分や種類を示す
    • 例:「電話番号」というユビキタス言語に対して、それが自宅の番号なのか職場の番号なのかそれ以外の番号なのか、といった種類についての説明
  • 現場で役立つシステム設計の原則 に書いてあった気がする
  • Enum を使うことを推奨してる
  • データベースのレコードを値オブジェクトに変換し、それをタイプコードとする手法もある


6.4 値オブジェクトのテスト

https://github.com/VaughnVernon/IDDD_Samples/blob/master/iddd_agilepm/src/test/java/com/saasovation/agilepm/domain/model/product/backlogitem/BusinessPriorityTest.java#L27-L45

public void testCostPercentageCalculation() throws Exception {
                                                                       
    BusinessPriority businessPriority =
        new BusinessPriority(new BusinessPriorityRatings(2, 4, 1, 1));
                                                                       
    BusinessPriority businessPriorityCopy =
        new BusinessPriority(businessPriority);
                                                                       
    assertEquals(businessPriority, businessPriorityCopy);
                                                                       
    BusinessPriorityTotals totals =
        new BusinessPriorityTotals(53, 49, 53 + 49, 37, 33);
                                                                       
    float cost = businessPriority.costPercentage(totals);
                                                                       
    assertEquals("2.7", this.oneDecimal().format(cost));
                                                                       
    assertEquals(businessPriorityCopy, businessPriority);
}
6.5 実装
  • (著者は通常)値オブジェクトの初期化は2つのコンストラクタを用意する:
    1. 状態の属性を設定するために必要なすべてのパラメータを受け取る、「プライマリ」コンストラクタ。属性の初期化にはprivateなセッターを用いる(自己委譲)
    2. 既存の値をコピーして新しいコンストラクタを作るコピーコンストラクタ。シャローコピーを行う
      • ディープコピーもできなくはないが処理が複雑になる&不変な値を扱うときにはインスタンス間で属性/プロパティを共有したところで問題ないはず


疑問とか

「属性」と「プロパティ」は区別して使ってる?(6.1 値の特徴 > 概念的な構造体)
  • CodeZine だと属性(プロパティ)と表記しているが、本書では区別しているっぽい
  • → 属性は値オブジェクトではない標準的な型による値、値オブジェクトになったものはプロパティと呼んでいるんだと理解した
「自己委譲」は一般的な用語?(6.5 実装)

ググっても出てこなかった。文字とおり自己+委譲なので処理を自分自身のクラスの別のメソッドに任せているから?

値オブジェクトのストラテジ/ポリシーにあたる部分?(6.5 実装)

以下がストラテジパターンにあたるというのがよくわからなかった。

public float costPercentage(BusinessPriorityTotals aTotals) {
    return (float) 100 * this.ratings().cost() / aTotals.totalCost();
}
                                                                                     
public float priority(BusinessPriorityTotals aTotals) {
    float costAndRisk = this.costPercentage(aTotals) + this.riskPercentage(aTotals);
                                                                                     
    return this.valuePercentage(aTotals) / costAndRisk;
}
                                                                                     
public float riskPercentage(BusinessPriorityTotals aTotals) {
    return (float) 100 * this.ratings().risk() / aTotals.totalRisk();
}
                                                                                     
public float totalValue() {
    return this.ratings().benefit() + this.ratings().penalty();
}
                                                                                     
public float valuePercentage(BusinessPriorityTotals aTotals) {
    return (float) 100 * this.totalValue() / aTotals.totalValue();
}
                                                                                     
public BusinessPriorityRatings ratings() {
    return this.ratings;
}

ストラテジパターンの説明 や同じ箇所で言及されてる PofEAA のセパレートインターフェース なんかを見る限り、共通のインターフェースを用意してストラテジごとの実装は各クラスに分けて切替可能にする、とかのようだ。

↑の例だと、今は costPercentage() の算出ロジックが1種類しかないから実装をべたっと書いてしまっているけど、別の算出方法が登場した際にはどっかにインターフェースだけ定義して実装とは分離することになるんだろうか。


おわりに

第6章も骨太で 2 回に分けてなんとか読み終えた。
6.2 節の、コンテキストをまたいだ場合にエンティティではなく値オブジェクトとして定義することで責務を少なめに抑える、というのがまだぴんと来ていない。

また 6.6 節については Hibernate というツール(ORM)の具体的な使い方が中心で最後まで読みきらなかったけど、コードが多かったせいか Hibernate がどういうものであるかは感じ取ることができてよかった。

詳しく知りたくなったらこのあたり読めばいいんだろうか。
Hibernateで理解するO/Rマッピング(1):O/Rマッピングの役割とメリット - @IT


次回

10/4(水) 19:00 予定です。connpass ページ作ったら貼る。


資料

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 に保存する方法を調べます。

(追記)

書きました。

リファレンス