Sails.js(Node.js)でSessionを無駄にしないように使い方


## Sails.jsとは

最近筆者はNode.jsの「Sails.js」というフレームワークを利用して新しいプロジェクトを構築しています。Sails.jsはExpress.jsを基いて、更にWebSocket・RESTFul API・MVC・ORMなど様々な技術を含め、非常に便利なので、さすが「現代的なフレームワーク」と言えます。

今回はSessionを無駄遣いしてしまう話をしましょう。

## Sessionの無駄に使う問題

普通のWeb Siteで、下記のようなURL Pathがあります。

| Path                  | Method  | Memo               
|:----------------------|:--------|:------------------|
| /、/login、/my/book    | GET     |  Webページ<br/>機能によってSessionを使う可能性があります。
| /api/item/ranking     | GET     |  公開情報<br/>Webとアプリ向けAPI<br/>Sessionと関係ありません。
| /api/user/profile/@me | GET/PUT |  個人情報<br/>Webとアプリ向けAPI<br/>認証に関するSession／OAuthと関係あります。

### Webページとブラウザーの場合

上記内容によると、Sessionを最も利用すべき所はWebページです。ユーザーが最初にWebページにアクセスすると、新しいSessionを作成して、Sessionの内容はSession Storeに保存する(Memcache・Redis等)。新しいSession IDはCookieの形でUser Agentに発行して、保存されます。

例えば、

```bash
curl -v http://127.0.0.1:1337/login > /dev/null
> GET /login HTTP/1.1
...
< HTTP/1.1 200 OK
< X-Powered-By: Sails <sailsjs.org>
...
< Set-Cookie: sails.sid=s%3AvX4jELukbYhzct43etS21uRU.apwQfFIzp1bpAgvTYaIfx%2FaheTw%2B0DoLKQV52a98uEg; Path=/; HttpOnly
...
```
User Agentが常にそのCookieを保有していますから、サーバー側との通信状態をうまく保有します。
そして、通信期間内に一つCookieがSession IDを保有しま。つまり、サーバー側Session Storeも一つしかないから、Sessionを無駄に使うことではありません。

### クライアント側がブラウザーではない場合

但し、アプリ向けAPIや、外部パートナー向け公開情報APIの場合、大きく違いがあります。

例えば、認証の必要がある一部のAPIがWebの場合でSessionを読み込まれる可能性がありますが、アプリに呼び出さる場合はSessionと関係ありません。代わりにOAuthのAccess Tokenでの認証が必要です。その場合、クライアント側が発行されたSession IDを常に保存する義務がないから、毎回繰り返してアクセスすると、毎回新しいSessionを作成ことになってしまいました。そうすると、Session Storeにコストが増えるかもしれません。

下記の例は、クライアント側のHTTP会話の例の一つです。

```bash
> GET /api/item/ranking HTTP/1.1
...
< HTTP/1.1 200 OK
...
< Set-Cookie: sails.sid=s%3ADw8BML5vRNwDSN8L_t2Lw40V.DOhHMvk6Z5FqkbJPjzgZesI9rtBKPBaimP0EVjB3lWU; Path=/; HttpOnly
...

> GET /api/user/profile/@me?access_token=myaccesstokenkeyxxxxxx HTTP/1.1
...
< HTTP/1.1 200 OK
...
< Set-Cookie: sails.sid=s%3ALOS8QewE8uX0tx6loLOp-vkn.zp9wsMOEBVicrenyVwnd2%2BkMCQ8c1b%2Fze%2BeVPISoNzM; Path=/; HttpOnly
...


> GET /api/item/ranking HTTP/1.1
...
< HTTP/1.1 200 OK
...
< Set-Cookie: sails.sid=s%3ANcNHD5g6Mig7s5VGU_wMHQzO.WJEoCBLE0BcYQaVRTH%2B3UDp6Zhz5vnK9%2BtRbH7xcOMA; Path=/; HttpOnly
...

> GET /api/user/profile/@me?access_token=myaccesstokenkeyxxxxxx HTTP/1.1
...
< HTTP/1.1 200 OK
...
< Set-Cookie: sails.sid=s%3AMroUSUAJSSuDWkpdHBNPcael.jCTQcdVjCZ%2Ff60qJat4tOTJFpNPuhcsp3TcK256XXUw; Path=/; HttpOnly
...

```

そこで、 _クライアント側がブラウザーではない場合_ に、毎回新しいSessionの作成するのが無駄だと言う問題を気付きました。

## 問題の解決方法

### Node.jsのConnectモジュールのSession middleware

調べて見ると、Sessionを作成するロジックはここです：

    node_modules/sails/node_modules/express/node_modules/connect/lib/middleware/session.js:246

つまり、Node.jsのConnectモジュールのSession middlewareであります。

```javascript
    // set-cookie
    res.on('header', function(){
      if (!req.session) return;
      var cookie = req.session.cookie
        , proto = (req.headers['x-forwarded-proto'] || '').split(',')[0].toLowerCase().trim()
        , tls = req.connection.encrypted || (trustProxy && 'https' == proto)
        , isNew = unsignedCookie != req.sessionID;

      // only send secure cookies via https
      if (cookie.secure && !tls) return debug('not secured');

      // long expires, handle expiry server-side
      if (!isNew && cookie.hasLongExpires) return debug('already set cookie');

      // browser-session length cookie
      if (null == cookie.expires) {
        if (!isNew) return debug('already set browser-session cookie');
      // compare hashes and ids
      } else if (originalHash == hash(req.session) && originalId == req.session.id) {
        return debug('unmodified session');
      }

      var val = 's:' + signature.sign(req.sessionID, secret);
      val = cookie.serialize(key, val);
      debug('set-cookie %s', val);
      res.setHeader('Set-Cookie', val);
    });

    // proxy end() to commit the session
    var end = res.end;
    res.end = function(data, encoding){
      res.end = end;
      if (!req.session) return res.end(data, encoding);
      debug('saving');
      req.session.resetMaxAge();
      req.session.save(function(err){
        if (err) console.error(err.stack);
        debug('saved');
        res.end(data, encoding);
      });
    };

```

注目すべきところは、7行目 *「isNew」* の判断です。そして、37行目の *「if (!req.session)」* も重要だと思います。

また、私のSails.jsのプロジェクトのPackageはリリースする時npm installされるものだから、直接にConnectモジュールのSession middlewareのソースコードを修正するのは良くないと思います。

そして、下記のmiddlewareを書きました：

### 問題を解決する middleware

```javascript
function doNotCreateNewSession(pathPrefixArr) {
  return function(req, res, next) {
    if (isNewSession(req) && matchPathPrefixArr(req, pathPrefixArr)) {
      // proxy end() to commit the session
      var end = res.end;
      res.end = function(data, encoding) {
        res.end = end;
        if (!req.session) {
          return res.end(data, encoding);
        }
        sails.log.debug("res.end proxy: destory new session: " +
          req.sessionID);
        req.session.destroy();
        req.session = null;
        res.end(data, encoding);
      };
    }
    next();
  }

  function matchPathPrefixArr(req, pathPrefixArr) {
    return pathPrefixArr.some(function(pathPrefix) {
      return (0 == req.originalUrl.indexOf(pathPrefix))
    })
  }

  function isNewSession(req) {
    return req.sessionID != getSessionIdByCookie(req)
  }

  function getSessionIdByCookie(req) {
    var secret = sails.config.session.secret,
      key = 'sails.sid';

    // grab the session cookie value and check the signature
    var rawCookie = req.cookies[key];

    // get signedCookies for backwards compat with signed cookies
    var unsignedCookie = req.signedCookies[key];

    if (!unsignedCookie && rawCookie) {
      unsignedCookie = utils.parseSignedCookie(rawCookie, secret);
    }

    return unsignedCookie;
  }

}

```

Node.js/Express.jsの使い方：

```javascript
      app.use(doNotCreateNewSession([
        '/api',
      ]));
```

Sails.jsの使い方はちょっと違います、customMiddlewareにします：

```javascript

// config/foo.jsで
/*
 * express http customMiddleware
 */
module.exports = {
  http: {
    customMiddleware: function(app) {
//...
      app.use(doNotCreateNewSession([
        '/api',
      ]));
//....
```

今のロジックは、最初Session middlewareを実行して、Sessionを作成したけど、そのdoNotCreateNewSession middlewareを実行する時、このタイミングでsessionをdestroyするのです。（Session Storeのコストをかからないようにします）

そうすると、下記の目標を達成しました：

| Path                  | 種類  | 目標               
|:----------------------|:--------|:------------------|
| /、/login、/my/book    | Webページ     |  Sessionがアクセス出来ます。<br/>新しいSessionも作成出来ます。
| /api/item/ranking<br/>/api/user/profile/@me     | API     | Sessionが<b>アクセス出来ます</b>。<br/>但し、新しいSessionが<b>作成出来ません</b>。


#### テスト
```bash
# 新しいSessionを作成する
➜   curl -v http://127.0.0.1:1337/ 2>&1 |grep Set-Cookie
< Set-Cookie: sails.sid=s%3AQIUUfdKRxecI7Hz8ejamoQLW.9SxuJuuiltb%2BG4hz8Ks%2FNk6%2FiQ%2BubA5n0zydjTW54P8; Path=/; HttpOnly

# 新しいSessionを作成すべきではない
➜   curl -v http://127.0.0.1:1337/api/ 2>&1 |grep Set-Cookie

# req.sessionを利用する
➜   curl -v http://127.0.0.1:1337/api/ -H "Cookie: sails.sid=s%3AQIUUfdKRxecI7Hz8ejamoQLW.9SxuJuuiltb%2BG4hz8Ks%2FNk6%2FiQ%2BubA5n0zydjTW54P8" 2>&1 |grep Set-Cookie

```

**これで問題を解決済みました。**

### 続いて

もっと良い方法は、Node.jsをConnectモジュールをForkして、Session middlewareのインタフェースをこんなように修正します：

```javascript
  session({doNotCreateIn:["/api", "/restful"]})
```

まあ、暇なら続いて頑張りたいと思います。


PS: 同僚たちの鈴木さん、山本さん、 [日本語を丁寧に修正してくれて](http://qiita.com/dulao5/items/c0b6e2a7ea16e1d9116c/revisions#revision-7) 、ありがとうございました。