Socket.IOとのリアルタイムコミュニケーションをVuexのmodulesを利用してハンドリングする
という趣旨の内容で、先週のVue.js Tokyo v-meetupでLTさせてもらった。
発表資料はこちら
背景
僕が今メインで担当しているプロダクトの性質上、ブラウザ上でのリアルタイムコミュニケーションが必要な機能を扱うことが多い。
そこでは、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はその中で、さらに利用者のグループを作ることができる。
利用イメージを雑に書くと以下のような感じ。
Roomはただ単にグループを定義するだけなので、必ずしも1つのクライアントが1つのルームにしか所属できない訳ではない。構造を整えれば、さらに小さい単位のグループを作ることもできる。
Namespaceは僕たちは機能単位で分けている。
これらを利用することで以下の利点を得られる。
- イベントを送出する対象を簡単にサーバ側で選択することができる
- 1つの機能(Namespace)に重大なエラーが発生したとしても、他の機能には影響がない
- Socket.IOサーバのコードは自然と構造化するのでメンテナンスがしやすい
VuexのNamespaced moduleをSocket.IOのNamespaceに割り当てる
Vuexにはmodulesという機能があり、SPAを構成するSingleStateTreeを小さい単位で分割することができる。
それぞれの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のプラグインを作った。
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を利用しようとしている方は、試してみていただけると嬉しいです。 今のところは自分のアプリケーションに必要な部分しか実装していないので、色々な意見をいただけると非常にありがたいです。
もちろん他にもやり方はたくさんあると思いますので、もしより良い方法や上手くいっている方法などをお知りの方はぜひ教えてください。色々語り合いたいです。
Vue + TypeScriptなプロジェクトにESLintを導入する
TypeScript + VueなプロジェクトでESLintを使ってみて、現状必要なモジュールが複数あって少し複雑だったのでまとめておきます。
サンプルは以下です。 github.com
内容はどうでも良いんですが、こんな感じのすごく簡単なTODO風のアプリケーションです。
なぜEslintを使うか
JavaScriptのためのLintingツールはたくさんありますが、Vueのroadmapにもある通り、Vueの公式スタイルガイドをサポートするESLintプラグインがESLintのメンテナによる公式プラグインとして作られています。
GitHub - vuejs/roadmap: Roadmap for the Vue.js project
これからもVueの公式としてサポートされていくと思うので、特にこだわりがなければESLintを使うのが良いかと思います。
ESLint for TypeScript
ESLintはJavaScript(ECMAScript)のためのLintingツールですが、TypeScriptをESTree互換の形に変換し、ESLintを適用できるようにするパーサがプラグインとして提供されています。TypeScriptのプロジェクトにESlintを使う場合にはこのプラグインが必要です。
以下のようにparserに指定すれば動きます。
.eslintrc.json
{ "parser": "typescript-eslint-parser", "extends": [ "eslint:recommended" ] }
ただし、TypeScriptはもともとESTree互換ではないので、現在(v11.0.0)いくつかのルールが思うように動きません。
eslint-plugin-typescriptというプラグインを使うと、TypeScriptの用のルールを導入することができます。
今回導入したプロジェクトも冒頭のサンプルもTypeScriptのみで構成されているので問題になりませんが、ピュアなJavaScriptと併用する場合にはルールがぶつかってしまうかもしれません。 ESLintは適用するルールをファイルタイプごとに設定ができるので、これを用いてJavaScriptとTypeScriptを分ければ回避できるはずです。
ESLint for Vue Single File Component
VueのSingleFileComponent(.vue)にESLintを適用するためのパーサを ESLintのメンテナのmysticateaさんが公開しています。
これを使うと、.vueファイルにESLintを適用することができます。 TypeScriptと併用する場合には、以下のようにparserOptionセクションにtypescript-eslint-parserを指定します。
.eslintrc.json
{ "parser": "vue-eslint-parser", "parserOptions": { "parser": "typescript-eslint-parser" }, "extends": [ "eslint:recommended", "typescript" ] }
サンプルを作った時点(2017/1/1)では、現状のstable(1.0.0)ではなくbetaをinstallする必要がありました。
mysticateaさんに教えていただきまして、現在はstableでokだそうです。
npm install vue-eslint-parser -D
Linting according to Vue StyleGuide
Vue.jsの公式として、ESLintプラグインが提供されています。
これは以下のStyle Guideを提供するための追加ルールと、ルールを組み合わせたテンプレートを提供しています。 vuejs.org
これにはテンプレート部分のシンタックスチェックも含まれるので、以下のようにテンプレート部分の違反を検出することができるようになります。
最終的に.eslintrc.jsonは以下のようになりました。
{ "parser": "vue-eslint-parser", "parserOptions": { "parser": "typescript-eslint-parser" }, "extends": [ "eslint:recommended", "plugin:vue/recommended", "typescript" ] }
追記
mysticateaさんから指摘をいただきまして、plugin:vue/recommendedはvue-eslint-parserのparserの指定を含むので、parserの行は削除可能だそうです。 ただし、今回の例だとeslint-plugin-typescriptもparserの設定を上書きするので、以下のようにextendsの順番でplugin:vue/recommendedが後に来るように設定する必要があります。
{ "parserOptions": { "parser": "typescript-eslint-parser" }, "extends": [ "eslint:recommended", "typescript", "plugin:vue/recommended" ] }
個人的にはextendsの順番で適用されるparserが変わるのは気持ち悪いので、明示的に指定しておいた方がいいかなーと思います。
参考
freee株式会社を退職しました
この記事は退職者アドベントカレンダー2017(その2)の15日目です。
今年の10月いっぱいでfreee株式会社を退職しました。 どこか時間がある時に退職エントリー書くかーと思ってたのですが、そうこうしているうちにアドベントカレンダーの時期になってしまったので、この機会に書くことにします。
freee株式会社でやっていたこと
Webアプリケーションエンジニアとして、サーバサイド、クライアントサイドを問わずアプリケーションの開発業務を中心に従事していました。ある機能の開発サイドのオーナーみたいなことをやっていたこともありました。これは自分の中でも特に良い経験でした。
言語的にはRubyとJavaScriptを書いている時間が長かったです。あと社内にボドゲが大量にあったので、暇な時にみんなで集まってやっていました。
振り返る
僕が入社したのは2015年の4月だったので、ちょうど2年半ほど在籍していたことになります。
入社当時の社員数は80名弱でしたが、退職時点ではその4倍を超える社員数になっていました。 すごい速度で会社として成長してきたんだなーと改めて思います。 2、3日おきに10人ぐらい入ってきていて、もう顔がわからん..みたいなことになる時期もありました。
この成長期を実際に中の人として経験できたのは、自分にとって本当に良かったと思います。会社が成長していく中でのなんとも言えない熱狂と、過程で生じる摩擦も含めて、なかなか得ようと思っても得られない経験だったと思います。
freeeは率直に言って働きやすい会社だったと思います。というと退職エントリーあるあるな前職を持ち上げるあれっぽいですが、まぁ率直な感想です。 出社退社時間や働く場所(リモート)の調整は自分の裁量で決めることができました。僕にはコードに熱中し始めるとある程度まで書き切らないと止まれなくなる悪癖があるので、すごくありがたかったですね。朝会あんまり出席してなくてすみませんでした。
なぜ退職したか
freeeの仕事に特に不満はありませんでしたが、どんどん拡大していく開発組織に戸惑いはありました。 ジェフ・ベゾフのTwo-Pizza Team Ruleの考え方が結構好きなのですが、開発組織の人数を増やす前に生産性をあげる手段は別にあるんじゃないかな、と思うことが増えて、わずかですが外を意識し始めました。
単純に2年半もいると中で使っている技術については熟知してしまって、刺激不足だったというのもあります。
とは言えfreeeのプロダクトの目指しているビジョンと、難しい領域へのチャレンジには共感していたので、それらがあってもまだやめる段階ではないと思っていました。むしろ自分でやっていきを発揮して何とかしてみようと思ってました。何とかできたかはわかりませんが。
自分の感覚的に、あと2年ぐらいでこの界隈の勢力争いに一定の区切りがつきそうだなと思っていたので、少なくともそれまでは何もなければ在籍しているつもりでした。
しかしそんな折に、知人から教育系のWebサービスのベンチャーの誘いを受けて、迷いましたが、彼の熱意とプロダクト・働き方の両面での面白さに惹かれて2人目の社員としてジョインすることにしました。
彼はプロダクトをすでに持っていましたが、プロダクト、今後の展開の両面でのフルリニューアルをしている真っ最中で、そのタイミングでのお誘いでした。 プロダクトについては、特にフロントについては全面書き換えということで、全てにおいて自分の裁量で決定できることに魅力を感じました。 Websocketを用いたリアルタイムなインタラクションやPWA対応が必要な要件で、特に興味がある領域だったことも大きいです。
展開については、海外に会社をもち、そこを拠点として展開させていきたいという話を受けました。現在事務処理を大急ぎで頑張っている(僕は何もやってないですが)ところなので詳細は避けておきますが、うまく行けば来年には海外に会社が建つことになります。僕も来年少なくとも半年以上はそこで過ごすことになると思います。以前より英語圏で仕事がしてみたいと思っていたので、これは非常に魅力でした。
あとは結構な額のストックオプションももらったので、当たればでかいというのも当然あります。一発あてたい。
僕も今年で30だしこの辺りで挑戦しておくか、という気持ちになったというのもあります。
実は10月に結婚もしているんですが、妻はあっさりokしてくれました。
現職については、また落ち着いたら色々書きたいです。技術的にも面白いことができているので、どこかの機会に発表もしたい。
というわけで
これからもよろしくお願いします!