Socket.IOとのリアルタイムコミュニケーションをVuexのmodulesを利用してハンドリングする

という趣旨の内容で、先週のVue.js Tokyo v-meetupでLTさせてもらった。

vuejs-meetup.connpass.com

発表資料はこちら

speakerdeck.com

背景

僕が今メインで担当しているプロダクトの性質上、ブラウザ上でのリアルタイムコミュニケーションが必要な機能を扱うことが多い。

そこでは、Socket.IOと一部でGraphQLのsubscriptionを主に使っている。

GraphQLのクライアントにはapolloを使っているので、何も考えずともある程度体制の整った(まぁこうなるよねという感じの)コードになるけど、Socket.IOとのコミュニケーションについては、タイムライン上に発生したイベントをいかにして可読性も担保しつつ、Viewで購読可能な状態のStateに落とすか、という所に課題があった。

ContainerComponentでObservableを利用するか、もしくはreduxで言う所のmiddlewareみたいな層を設けてそこに変換を任せるか、というような選択肢も検討したけど、最終的にはVuexのmutation、actionにそのままSocket.IOのイベントをマッピングして、イベントの発生をトリガーにしてVuexの持つStateを構築することにした。

今の所は可読性を保ったまま、スケールしやすい構成にできている実感がある。なのでより詳細な内容をブログにも書いておこうと思った。

利用しているSocket.IOの機能について

Socket.IOは(利用可能な環境において)WebSocketプロトコルを使って双方向通信を実現する。

提供されている機能にNamespacesとRoomsというものがある。

Socket.IO — Rooms and Namespaces

Namespacesを利用することで、利用するストリーム毎に異なるエンドポイントを得ることができる。 Roomsはその中で、さらに利用者のグループを作ることができる。

利用イメージを雑に書くと以下のような感じ。

f:id:joe-re:20180530075415p:plain

Roomはただ単にグループを定義するだけなので、必ずしも1つのクライアントが1つのルームにしか所属できない訳ではない。構造を整えれば、さらに小さい単位のグループを作ることもできる。

Namespaceは僕たちは機能単位で分けている。

これらを利用することで以下の利点を得られる。

  • イベントを送出する対象を簡単にサーバ側で選択することができる
  • 1つの機能(Namespace)に重大なエラーが発生したとしても、他の機能には影響がない
  • Socket.IOサーバのコードは自然と構造化するのでメンテナンスがしやすい

VuexのNamespaced moduleをSocket.IOのNamespaceに割り当てる

Vuexにはmodulesという機能があり、SPAを構成するSingleStateTreeを小さい単位で分割することができる。

Vuex | Modules

それぞれのmoduleの中にそれぞれのstateを構成するためのaction、mutation、getterを持つ。

const moduleA = {
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const moduleB = {
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const store = new Vuex.Store({
  modules: {
    a: moduleA,
    b: moduleB
  }
})

store.state.a // -> `moduleA`'s state
store.state.b // -> `moduleB`'s state

ここにもNamespacingという機能があり、これを有効にしている場合にはmoduleのaction、mutation、getterへのアクセスする際のパスにmodule名が付与される。

const moduleA = {
  namespace: true,
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const moduleB = {
  namespace: true,
  state: { ... },
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const store = new Vuex.Store({
  modules: {
    a: moduleA,
    b: moduleB
  }
})

// --- View --- //

Vue.extend({
  computed: {
    getterFromModuleA() {
      return this.$store.getters['moduleA/fooGetter']
    },
    getterFromModuleB() {
      return this.$store.getters['moduleB/fooGetter']
    },
  },
  methods: {
    dispatchToModuleA() {
      this.$store.dispatch('moduleA/fooAction')
    },
    dispatchToModuleB() {
      this.$store.dispatch('moduleB/fooAction')
    }
  }
})

Namespacingを利用しない場合には分割されるのはStateだけなのでactionとgetterにおいて名前の衝突を気にしないといけないが、これを有効にすることでその心配はなくなる。

今回はVuexのNamespaced moduleにSocket.IOのNamespaceを割り当てることにした。 そのためにvuex-socketio-pluginという、Vuexのプラグインを作った。

github.com

Vue-Socket.ioというプラグインがあり、もともとはこれを利用しようと思っていたんだけど、マルチチャンネルをサポートしていなかったのとSocket.IOのNamespaceをVuexのNamespaced moduleに割り当てる着想があったので自作することにした。このプラグインのVuex integrationの部分にIFはかなり影響を受けている。

vuex-socketio-pluginの提供するcreateSocketioPluginという関数を利用し、Vuexのpluginを作成できる。

const plugin = createSocketioPlugin([
  'http://localhost:3000/moduleA',
  'http://localhost:3000/moduleB'
])
const store = new Vuex.Store({
  plugins: [plugin],
  modules: { moduleA, moduleB }
})

このプラグインを使うと、Socket.IOの/moduleAというNamespaceはmoduleAに、/moduleBというNamespaceはmoduleBに割り当てられ、そのモジュールの中でイベントを通じたコミュニケーションが利用できるようになる。

Action、MutationをSocket.IOのイベントにマッピングする

vuex-socketio-pluginで作成したpluginを通じてSocket.IOサーバからイベントを受信すると、Namespaceがあれば同名のVuexのNamespaced moduleのActionとMutationが発火される。 Action名にはsocket_、Mutation名にはSOCKET_のプレフィックスがそれぞれデフォルト付与され、その後ろはイベント名となる。(プレフィックスはオプションで変更可能)

const moduleA = {
  namespace: true,
  state: { ... },
  mutations: {
    SOCKET_CONNECT (state, payload) {
      //...
    },
    SOCKET_EVENT1 (state, payload) {
      //...
    }
  },
  actions: {
    socket_connect (context, payload) {
      //...
    },
    socket_EVENT1 (context, payload) {
      //...
    }
  }
}

mutationのCONNECTは大文字で、actionのconnectは小文字なのはこれがSocket.IOが用意しているイベントだからで、ユーザ定義ではないイベントであればmutationは大文字にactionは小文字にすることにしている。(設定で変更できるか、どちらであっても発火できるようにする方が親切なのかもしれない。)

利用できる標準のイベントはSocket.IOのdocsを参照。 Socket.IO — Client API

VuexのNamespaced moduleを利用していない場合のため、globalで以下のような命名規則をしてもイベントを受け取れるようにしている。

  • Mutation: SOCKET_moduleA_EVENT1
  • Action: socket_moduleA_EVENT1

これによって/moduleAというnamespaceのストリームはmoduleA内でハンドリングを完結させることができる。

僕たちはSocket.IOのNamespaceは機能単位になるように設計しているので、この仕組みによってリアルタイムコミュニケーションによるStateの変更について非常に見通しの良いコードにすることができた。

さらにSocket.IOのイベントハンドリングを子のmoduleに分割する

しばらくこの構成で運用していたのだけど、機能を拡張していくにつれて可読性の部分に問題が生じた。

Stateが変更される要因はSocket.IOのイベントのみではないし、ユーザの操作をトリガーにしてSocket.IOサーバにイベントを送ることももちろんある。

そうするとNamespaced moduleにSocket.IOのイベントのハンドリングとそれ以外が混在することになる。

const moduleA = {
  namespace: true,
  state: { ... },
  mutations: {
    SOCKET_CONNECT (state, payload) {
      //...
    },
    SOCKET_EVENT1 (state, payload) {
      //...
    },
    FOO_MUTATION (state, payload) {
       //...
    },
    BAR_MUTATION (state, payload) {
       //...
    },
    FOO_BAR_MUTATION (state, payload) {
       //...
    }
  },
  actions: {
    socket_connect (context, payload) {
      //...
    },
    socket_EVENT1 (context, payload) {
      //...
    },
    fooAction (context, payload) {
      //...
    },
    barAction (context, payload) {
      //...
    },
    fooBarAction (context, payload) {
      //...
    },

  }
}

機能がシンプルなうちはあまり問題にならなかったが、これが増えると非常に煩雑で可読性が失われていった。

Vuexのmoduleは多重にネストすることができるので、Socket.IOのイベントハンドリングは更に子のモジュールに落とすことでこれを解決した。

Socket.IOのイベントに付与されるプレフィックスは変更することができるようにしているので、これを子のモジュール名とすることでこれを実現できる。

const plugin = createSocketioPlugin([
  'http://localhost:3000/moduleA',
  'http://localhost:3000/moduleB'
], {
  actionPrefix: 'socket/',
  mutationPrefix: 'socket/'
})

const store = new Vuex.Store({
  plugins: [plugin],
  modules: { moduleA, moduleB }
})

この場合にはmoduleAは以下のようになる。

const socket = {
  namespace: true,
  state: { ... },
  mutations: {
    CONNECT (state, payload) {
      //...
    },
    EVENT1 (state, payload) {
      //...
    }
  },
  actions: {
    connect (context, payload) {
      //...
    },
    EVENT1 (context, payload) {
      //...
    }
  }
}

const moduleA = {
  namespace: true,
  modules: { socket },
  state: { ... },
  mutations: {
    FOO_MUTATION (state, payload) {
       //...
    },
    BAR_MUTATION (state, payload) {
       //...
    },
    FOO_BAR_MUTATION (state, payload) {
       //...
    }
  },
  actions: {
    fooAction (context, payload) {
      //...
    },
    barAction (context, payload) {
      //...
    },
    fooBarAction (context, payload) {
      //...
    },

  }
}

これによって一目でSocket.IOのイベントとそれ以外とを区別できるようになり、可読性を取り戻すことができた。

この場合には親(moduleA)にViewに通知するStateを持ち、子(moduleA/socket)はほぼState-lessとなる。(Socket.IOとのコネクションの有無などは子に持つけど)

そうすると子のmoduleから親のmoduleのstateを更新する必要があり、アンチパターンっぽくて少し気持ち悪い。 mutationを発行する際にも{ root: true } のオプションが必要になり少し面倒になる。

  actions: {
    EVENT1 (context, payload) {
      context.commit('moduleA/FOO_MUTATION', { root: true })
    }
  }

これは仕方ないトレードオフだし、局所的にここでしか発生しないので目を瞑ることにしている。(良い解決法があるなら知りたい)

おわり

もしこれからVueアプリケーションでSocket.IOのNamespaceを利用しようとしている方は、試してみていただけると嬉しいです。 今のところは自分のアプリケーションに必要な部分しか実装していないので、色々な意見をいただけると非常にありがたいです。

もちろん他にもやり方はたくさんあると思いますので、もしより良い方法や上手くいっている方法などをお知りの方はぜひ教えてください。色々語り合いたいです。