API Gateway(WebSocket API)のバックエンドをRailsで実装する で作成したAPI GatewayにWebSocketで接続してメッセージのやり取りが出来るようにします。
仕様は、Action CableでWebSocketを試す と同じ状態を目指します。
先にまとめ
・WebSocketClientは使えなかったので、W3CWebSocketを使用した。
・再接続は自前でする必要がある。
・ActionCableではされていたヘルスチェックが、W3CWebSocketではされない。
ゆえに、一時的な通信断では切断されない。自前で表示を変えないと繋がってそうに見える。
・10分(デフォルト)を超えるか、サーバー側で切断した場合は切断される。
・PCがスリープすると切断される(Chromeの挙動かな?)
パッケージ追加
今回は、NuxtでAction Cableに接続したソースを使います。
https://dev.azure.com/nightonly/nuxt-app-origin/_git/nuxt-app-origin/commit/acd46282fc63edfe7fa4ecbd96810bec667927ec
% yarn add websocket
フロント実装
ダサいけど、component名はMessages2にしました。
(接続先やライブラリ名ではなく、機能名にしたいので)
pages/index.vue
<template> <div> <v-row> <v-col v-if="!$auth.loggedIn" cols="12" md="6"> <SignUp /> </v-col> <v-col cols="12" md="6"> <Infomations /> </v-col> </v-row> <v-row> <v-col> <Messages /> </v-col> </v-row> + <v-row> + <v-col> + <Messages2 /> + </v-col> + </v-row> </div> </template> <script> import SignUp from '~/components/index/SignUp.vue' import Infomations from '~/components/index/Infomations.vue' import Messages from '~/components/index/Messages.vue' + import Messages2 from '~/components/index/Messages2.vue' export default { name: 'Index', components: { SignUp, Infomations, Messages, + Messages2 } } </script>
components/index/Messages2.vue を作成
<template> <div> <v-card> <Processing v-if="loading || connecting || processing || $nuxt.isOffline" />
Action Cableと異なり、ヘルスチェックされない。
通信断でも表示は変わらないので、「$nuxt.isOffline」でネットワークが切れたら、くるくるを表示するようにしました。
メッセージは接続後に受信されるし、送信も繋がるまで送信中にできるので実害はないですが、受信されない状態である事を明示する為。
<v-card-title>新着メッセージ (WebSocket/API Gateway)</v-card-title> <v-card-text class="pb-0"> <article v-if="loading"> 取得中... </article> <article v-else-if="lists.length === 0"> メッセージはありません。 </article> <template v-else> <article v-for="list in lists" :key="list.id"> <v-divider class="mb-2" /> <div v-if="list.id == null" class="mb-2"> 〜〜 {{ list.unsent_count.toLocaleString() }}件のメッセージが取得できませんでした 〜〜 </div> <div v-else class="mb-2"> <v-chip small :color="(list.channel === 'all_channel') ? 'green' : ''">{{ list.channel_i18n }}</v-chip> {{ (readMore[list.id]) ? list.body : list.body.substr(0, 255) + ((list.body.length > 255) ? '...' : '') }} <span class="text-nowrap"> <a v-if="list.body.length > 255 && !readMore[list.id]" @click="$set(readMore, list.id, true)">▼続きを読む</a> <a v-if="list.body.length > 255 && readMore[list.id]" @click="$set(readMore, list.id, false)">▲少なくする</a> ({{ list.created_at_i18n }}) </span> <span v-if="list.user != null" class="text-nowrap"> <v-avatar size="24px"> <v-img :src="list.user.image_url.mini" /> </v-avatar> {{ list.user.name }} </span> </div> </article> </template> </v-card-text> <v-divider /> <v-card-actions> <div v-if="authError" class="my-2"> 認証に失敗しました。ページを更新してください。 </div> <div v-else-if="systemError" class="my-2"> 処理に失敗しました。しばらく時間をあけてから、やり直してください。 </div> <div v-else-if="!$auth.loggedIn" class="my-2"> ※ログインするとメッセージを送信できるようになります。 </div> <div v-else class="d-flex my-2"> <v-select id="message_channel" v-model="channel" :items="channels" return-object label="送信先" dense outlined filled :loading="processing" hide-details="false" class="v-select-size" /> <v-text-field id="message_body" v-model="body" label="メッセージ" autocomplete="off" dense outlined filled :loading="processing" class="mx-1" hide-details="false" style="width: 38vw" /> <v-btn class="mt-1" color="primary" :disabled="connecting || processing || !channel || !body" @click="onSendMessage()">送信</v-btn> </div> </v-card-actions> </v-card> </div> </template> <script> import Application from '~/plugins/application.js' export default { name: 'IndexMessages2', mixins: [Application], data () { return { connecting: true, processing: false, socket: null, authIntervalID: null, authError: false, systemError: false, pageClose: false, token: null, lists: [], readMore: {}, channel: null, channels: [], body: '' } }, async created () { await this.$axios.post(this.$config.apiBaseURL + this.$config.wsTokenNewUrl) .then((response) => { if (response.data == null) { return this.$toasted.error(this.$t('system.error')) } else { this.token = response.data.token this.channels = response.data.channels if (response.data.channels.length > 0) { this.channel = response.data.channels[0] } } }, (error) => { if (error.response == null) { return this.$toasted.error(this.$t('network.failure')) } else if (error.response.data == null) { return this.$toasted.error(this.$t('network.error')) } else { return this.$toasted.error(this.$t('system.error')) } }) this.connectWebSocket() }, // ページ遷移 or 閉じる -> 切断 // Tips: NuxtLinkだと切断されない為、明示的する必要がある destroyed () { // eslint-disable-next-line no-console if (this.$config.debug) { console.log('== [API Gateway]destroyed') } this.pageClose = true if (!this.authError) { this.socket.close() } }, methods: { // WebSocket接続 connectWebSocket () { // eslint-disable-next-line no-console if (this.$config.debug) { console.log('== [API Gateway]connectWebSocket') } const self = this const W3CWebSocket = require('websocket').w3cwebsocket this.socket = new W3CWebSocket(this.$config.wsApiGatewayURL)
WebSocketClientも試しましたが、エラーになる為、深掘りせずに断念。
var WebSocketClient = require('websocket').client; var client = new WebSocketClient(); > TypeError: WebSocketClient is not a constructor
// 接続成功 or 再接続 -> メッセージ取得(認証含む) this.socket.onopen = function () { // eslint-disable-next-line no-console if (self.$config.debug) { console.log('== [API Gateway]onopen') } if (self.lists.length === 0) { self.socket.send(JSON.stringify({ action: 'default', method: 'get_messages', token: self.token, limit: 5 })) } else { self.socket.send(JSON.stringify({ action: 'default', method: 'get_messages', token: self.token, last_id: self.lists[self.lists.length - 1].id })) } } // 接続失敗 or 通信断 or 離脱 or 認証失敗 -> 表示切り替え # Tips: 一時的な通信断ではcloseされない(デフォルトのクォータ: 10分) this.socket.onclose = function () { // eslint-disable-next-line no-console if (self.$config.debug) { console.log('== [API Gateway]onclose') } if (self.authIntervalID != null) { clearInterval(self.authIntervalID) self.authIntervalID = null } if (self.pageClose) { return } // Tips: 離脱時は再接続しないようにする if (!self.authError) { self.connecting = true setTimeout(self.connectWebSocket(), 1000 * 5) // Tips: 5秒後に再接続。PCスリープでも切断される
再接続は自前でする必要がある。
一時的な通信断では切断されない。
10分(デフォルト)を超えるか、サーバー側で切断した場合は切断される。
意外だったのは、PCがスリープすると切断される(Chromeの挙動かな?)
} } // 認証・送信結果受信 or メッセージ受信 -> 表示切り替え this.socket.onmessage = function (e) { // eslint-disable-next-line no-console if (self.$config.debug) { console.log('== [API Gateway]onmessage', e) } let data = [] if (e.data !== '') { data = JSON.parse(e.data) } // eslint-disable-next-line no-console if (data.alert != null) { console.log(data.alert) } if (data.action === 'auth_result') { if (self.authIntervalID != null) { clearInterval(self.authIntervalID) self.authIntervalID = null } if (data.success !== true) { self.authError = true self.socket.close() } else { self.authIntervalID = setInterval(self.authRequest, 1000 * 60 * 15) // Tips: 15分毎 } } else if (data.action === 'send_result') { if (data.success !== true) { alert(data.alert) } else { self.body = '' } } else if (!['get_messages', 'send_message'].includes(data.action) || data.success !== true) { // eslint-disable-next-line no-console console.log('undefined action[' + data.action + '] or success[' + data.success + ']') self.systemError = true } else { if (data.unsent_count > 0) { self.lists.push({ id: null, unsent_count: data.unsent_count }) } self.lists = self.lists.concat(data.messages) } self.processing = false self.connecting = false self.loading = false } }, // 認証チェック authRequest () { // eslint-disable-next-line no-console if (this.$config.debug) { console.log('== [API Gateway]authRequest') } this.socket.send(JSON.stringify({ action: 'default', method: 'auth_request', token: this.token })) }, // メッセージ送信 onSendMessage () { // eslint-disable-next-line no-console if (this.$config.debug) { console.log('== [API Gateway]onSendMessage: ' + this.channel.value + ', ' + this.body) } if (!this.channels.some(channel => channel.value === this.channel.value) || this.body === '') { // eslint-disable-next-line no-console console.log('undefined channel[' + channel + '] or empty body[' + body + ']') return } this.processing = true this.socket.send(JSON.stringify({ action: 'default', method: 'send_message', channel: this.channel.value, body: this.body, token: this.token })) } } } </script> <style scoped> .v-select-size >>> .v-select__selections input { width: 4px; } .text-nowrap { white-space: nowrap!important; } </style>
config/development.js
module.exports = { debug: true, envName: '【開発環境】', apiBaseURL: 'http://localhost:3000', frontBaseURL: 'http://localhost:5000', wsBaseURL: 'ws://localhost:3000', + wsApiGatewayURL: 'wss://test-ws.example.com/development' }
コミットしたURLはサンプルなので、環境に合わせて変更してください。
その他:Jestが落ちてた
これはactioncableのパッケージですが。
% yarn test FAIL test/page/index.spec.js ● Test suite failed to run TypeError: Cannot set property 'ActionCable' of undefined 81 | 82 | <script> > 83 | import ActionCable from 'actioncable' | ^ 84 | import Application from '~/plugins/application.js' 85 | 86 | export default {
importをrequireに変更して解消。
components/index/Messages.vue
- import ActionCable from 'actioncable'
+ const ActionCable = require('actioncable') this.consumer = ActionCable.createConsumer(this.$config.wsBaseURL + this.$config.wsCableUrl)