ExpressのhttpセッションをWebSocketで共有する方法

先日、WEBサイト制作者向けのウェブサービスをリリースしたのですが、その制作過程で得た知見をシリーズで発信していく記事の第3弾になります。
個人開発でウェブサービスにトライしてみたいと考えている方の参考になりましたら嬉しいです。

Node.jsサーバでWebSocketを使用する際、httpのセッションデータをWebSocketでも読み込みたいという場面が出てくると思います。
今回の記事では、httpセッションをWebSocketで共有（正確に言うとWebSocketからはReadOnly）する方法について書いてみたいと思います。

## Redisをセッションストアとして使う
実はRedisを使わなくても実現できるのですが、以下の理由によりRedisを使うこととします。

- Nodoサーバで全部受け止めるのではなく負荷を分散させておきたい
- 開発時にNodeサーバを起動し直してもセッションを維持できて便利
- 複数のNodeサーバでセッションを共有できるので、機能によってサーバ（コンテナ）を分けられる

Redisをひとことで説明すると、ネットワーク接続されたインメモリデータストアで、主にキー・バリュー型のデータを扱うNoSQLデータベースのひとつというところでしょうか。

セッションデータをRedisに書き込むことにより、複数のプログラムから参照してセッションを共有しようという作戦です。

## httpセッションをWebSocketで呼び出す

ちょっと長いですが、コードを提示します。

```javascript
const express = require('express')
const WebSocket = require('ws')

const Redis = require('ioredis')
const Session = require('express-session')
const RedisStore = require('connect-redis')(Session)

// ---------------------------------------------------
// sessionデータを保管するstoreを用意する
// ---------------------------------------------------

// Redisのhostとportは環境変数から読み込む
const { REDIS_HOST, REDIS_PORT } = process.env

// Redis用インスタンスを生成する
// セッション＆通知バッファリング用
const redis = new Redis({ 
  host: REDIS_HOST || 'redis',   // ※Dockerで使用する前提でこんな風に設定してます
  port: REDIS_PORT || 6379
})

// セッションストアを生成する
const store = new RedisStore({ client: redis })

// ---------------------------------------------------
// ここからexpressの設定（httpセッション）
// ---------------------------------------------------

// expressを生成する
const app = express()
app.use(express.json())

// Redisに紐づいたセッションプロバイダーを作る
const session = Session({
  store,
  secret: 'hogehoge',  // お好きにsecretを指定してください
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: 'auto'
  }
})

// expressにセッションプロバイダーを紐づける
app.use(session)

// port 3000 でlinten開始
const httpServer = app.listen(3000)


// expressを初期化する段階で、Redisに紐づいたセッションプロバイダーと差し替えるだけなので、
// すでに稼働しているプログラムの改変も最小限の作業で済む（かも）

// あとは普通にexpressの処理を記述すればOK!
...
...
...

// ログイン処理
app.post('login', (req, res, next) => {
  // ログインチェック
  ...
  ...
  // ログイン完了時の処理
  // DBなどから取得したユーザ情報をセッションに書き込むイメージ user = ユーザ情報
  req.session.user = user  
  // ログイン成功時にセッションID（req.sessionID）をクライアントに返す！  
  // これを後でWebSocketを通して返却してもらい紐づけに使用するべし
  res.json({ login: true, sessId: req.sessionID })
})

// ---------------------------------------------------
// ここからWebSocketの設定
//

// WebSocketサーバを生成する
const wsServer = new WebSocket.Server({ server: httpServer.server })

wsServer.on('connection', (ws) => {
  ws.on('open', () => {
    // ここでは、redisSessionId というパラメータにセッションIdを保管することにします
    ws.redisSessionId = ''
  })

  ws.on('message', (message) => {
    // データを復号する
    const msg = JSON.parse(message)
    // セッションidをwsに紐づける処理
    // この例では、クライアントから次のようなデータを受け取る想定
    // { cmd: 'connect_session', sessionID: 'xxxxxxxxxxxxx' }
    if (msg.cmd === 'connect_session') {
      ws.redisSessionId = msg.sessionID
      // クライアントにセッションidを受け取ったことを報告しておく
      return ws.send(JSON.stringify({
        result: 'received_session_id'
      }))
    }
    ...
    ...   
  })

  ...
  ...
})


// ---------------------------------------------------
// 何らかのイベント処理（WebHookなど）
//

const hogeEvent = (params) => {
  // wsServerには複数のsocketが接続されている
  // wsServer.clientsをループで処理して個々のWebSocketに対して処理を行う
  wsServer.clients.forEach(async (ws) => {
    // Sessionを取得する
    const session = !ws.redisSessionId
      ? await getSession(ws.redisSessionId)  // セッションデータ読込関数を呼ぶ
      : {}
    console.log(session.user)
    ...
    ...
  })
}


// ---------------------------------------------------
// セッションデータの読み込み関数
//
const getSession = (sid) => {
  return new Promise((resolve, reject) => {
    // Redisに紐づいたセッションストアからセッションデータを取得する
    store.get(sid, (err, session) => {
      if (err) {
        return reject(err)
      }
      resolve(session ? session : {})
    })
  })
}
```

言葉で説明するとこんな感じです

1. httpサーバ：セッションをRedisで管理するよう設定する
1. httpサーバ：ログインが完了したらセッションIDをクライアントに返す
1. クライアント：WebSocketでセッションIDを送信する
1. WebSocketサーバ：受け取ったセッションIDをソケットに紐づけて記録しておく
1. WebSocketサーバ：セッションIDを使ってRedisストアからセッションデータを読み出す

クライアント側のコーディングに関して注意が必要なのは、WebSocketのコネクションの状況（未接続・接続・切断）に応じて適切に処理を切り分けてやる点です。

このあたりは、要件に応じて正解が異なって来ますので、悩みどころではないかと思います。

## 宣伝です。。。
この記事の冒頭で触れたウェブサービスにも、この手法（異なる部分も多いですが）を利用しています。

WEBサイト制作者さん向けのサービスですので、お気軽にお試しいただけると幸いです。（無料で使えます）

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ウェブサイト制作に携わる人達に使っていただきたいウェブサービスをリリースしました。<br><br>ウェブサイトを作る際、大量の情報やデータを管理する必要がありますが、それをスッキリ整理整頓できて、簡単にチームで共有できます。<br><br>昨年の夏から作り始めて、やっと公開です。<a href="https://t.co/kFOCip3eUD">https://t.co/kFOCip3eUD</a></p>&mdash; 橋本技研@売れるネットショップ研究所 WEB技術×販促 (@hashimotosubaru) <a href="https://twitter.com/hashimotosubaru/status/1246611675379191808?ref_src=twsrc%5Etfw">April 5, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>