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)
