Nuxt.jsで作るラズパイアプリのWebSocket導入

この記事はNuxt/UnJS Advent Calendar 2024 23日目の記事です。

https://qiita.com/advent-calendar/2024/nuxt-and-unjs

# はじめに
はじめまして！添田です！
簡単な自己紹介をさせていただきます。
私はTypeScriptが一番好きなフロントエンドエンジニアで、TSKaigi2024から運営スタッフメンバーとして活動しています！業務でもVue.jsを使用していますが、今回は趣味で作成したラズパイアプリをNuxt.jsで構築した際の知見を共有したいと思います。

## ざっくり概要
ラズパイを使用したIoTデバイスとWebアプリケーションを連携させ、リアルタイムな状態監視と制御を実現しています。
使用している技術スタックは以下の通りです。

フロントエンド：Nuxt.js 3.x
バックエンド：Node.js on Raspberry Pi
通信プロトコル：WebSocket (Socket.IO)

## 設計
### シーケンス図
https://www.websequencediagrams.com/

Webアプリケーションの設計を進める際、WebSequenceDiagrams を使用して作りたい機能ごとにシーケンス図を作成しました。
以下はサンプルのシーケンス図です（本記事のテキストはダミーです）。
```
sequenceDiagram
    participant User
    participant Frontend
    participant Raspi(webAPI)
    participant Raspi(WebSocket)
    participant Locker

    Frontend ->> User: テキスト
    User ->> Locker: イベント

    alt 扉を１秒以上開けた場合
        Raspi(webAPI) ->> Locker: テキスト
        Raspi(WebSocket) ->>+ Frontend:イベントを知らせる
        Raspi(WebSocket) ->> Frontend:イベントを知らせる

        alt 特定のイベントをした場合
            Frontend ->>- User:XXの画面
        else 特定のイベントをした場合
            Frontend ->> User:30秒後にXXの画面（タイムアウト）
        end
    else 特定のイベントをした場合
        Raspi(webAPI) ->> Raspi(webAPI):何もしない
    end
```

![IMG_6347.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2708799/88eadb4b-b909-db8f-b21c-b705ce82d48d.png)

このように簡単なテキストを書くことで、シーケンス図を素早く作成できるのでとても便利です。

### アーキテクチャ説明
このアプリケーションでは、以下の理由からWebSocket（Socket.IO）を採用しています。
1. リアルタイム性の要件：デバイスの状態変化を即座にUI上に反映する必要がある
2. 双方向通信：サーバーからクライアントへのプッシュ通知が必要
3. 効率的なリソース利用：継続的なポーリングを避けたい

## WebSocket概要
### WebSocketとSocket.IOの違い
WebSocketは、リアルタイム性が求められるイベントを即座に処理できる技術です。
HTTPリクエストとは異なり、クライアントとサーバー間で継続的な双方向通信が可能です。
これにより、リアルタイムでの通知や更新が求められるアプリケーションに適しています。

WebSocketは低レベルのプロトコルで、Socket.IOはその上に構築された高レベルのライブラリです。
Socket.IOの主な特徴は、
1. 自動再接続機能
2. フォールバック機能（WebSocket未対応の場合）
3. イベントベースの通信
4. 部屋（Room）という概念によるブロードキャスト機能

## Nuxt.jsでのWebSocket導入
### ライブラリのインストール
WebSocketをNuxt.jsで簡単に利用するために、nuxt-socket-ioをインストールします。
https://classic.yarnpkg.com/en/package/nuxt-socket-io

```
yarn add nuxt-socket-io
```

### .envファイル作成
以下のように作成しました！
```
API_BASE_URL=http://XX.XX.XX.XXX/v1
WS_BASE_URL=ws://XX.XX.XX.XXX
WS_PATH=/socket.io
```

### Nuxt.jsの設定
`nuxt.config.ts`に以下の設定を追加します。

```nuxt.config.ts
export default defineNuxtConfig({
  ~~~他の記述
  modules: ['nuxt-socket-io'],
  io: {
    sockets: [{
      name: 'main',
      url: process.env.WS_BASE_URL,
      default: true
    }]
  },
  nitro: {
    output: {
      publicDir: path.join(__dirname, "output", "prod"),
    },
    experimental: {
      websocket: true,
    },
  },
  ~~~他の記述
})
```
公式ドキュメントも参考にしています：https://socket.io/how-to/use-with-nuxt

### WebSocket用のComposable作成
`/composables/useWebSockets.ts`に以下のコードを実装しました。

```useWebSockets.ts
import { ref, onUnmounted } from 'vue'
import type { Socket } from 'socket.io-client'
import io from 'socket.io-client'

interface OrderItem {
  item_id: number
  box_no?: number
}

interface WebSocketEvents {
  onMessage?: (data: any) => void
  onError?: (error: Error) => void
  onReconnect?: () => void
}

export default function useWebSocket() {
  const socket = ref<Socket | null>(null)
  const isConnected = ref(false)
  const lastError = ref<Error | null>(null)

  const config = useRuntimeConfig()
  const wsBaseUrl = config.public.WS_BASE_URL
  const wsPath = config.public.WS_PATH

  const setupConnection = ({
    orderItems,
    events = {},
  }: {
    orderItems: OrderItem[]
    events?: WebSocketEvents
  }) => {
    if (socket.value) {
      closeConnection()
    }

    try {
      const _socket = io(wsBaseUrl, {
        path: wsPath,
        timeout: 1000,
        transports: ['websocket'],
        reconnectionAttempts: 5,
        reconnectionDelay: 1000,
      })

      _socket.on('connect', () => {
        console.log('WebSocket connected!')
        isConnected.value = true
      })

      _socket.on('disconnect', () => {
        console.log('WebSocket disconnected')
        isConnected.value = false
      })

      _socket.on('error', (error: Error) => {
        console.error('WebSocket error:', error)
        lastError.value = error
        events.onError?.(error)
      })

      _socket.on('reconnect', () => {
        console.log('WebSocket reconnected')
        events.onReconnect?.()
      })

      _socket.on('message', (data: any) => {
        events.onMessage?.(data)
      })

      socket.value = _socket
    } catch (error) {
      console.error('Failed to setup WebSocket connection:', error)
      lastError.value = error as Error
      events.onError?.(error as Error)
    }
  }

  const closeConnection = () => {
    if (socket.value) {
      socket.value.disconnect()
      socket.value = null
      isConnected.value = false
    }
  }

  // 自動クリーンアップ
  onUnmounted(() => {
    closeConnection()
  })

  return {
    setupConnection,
    closeConnection,
    socket,
    isConnected,
    lastError,
  }
}
```

### WebSocketの使用例
上記のComposableを以下のように利用します。
```hoge.vue
<script setup lang="ts">
import useWebSocket from '~/composables/useWebSocket';

const { setupConnection, closeConnection, isConnected } = useWebSocket();

onMounted(async () => {
  setupConnection({
    orderItems: orderItems,
    events: {
      onMessage: (data) => {
        // メッセージ処理
      },
      onError: (error) => {
        // エラー処理
      },
      onReconnect: () => {
        // 再接続時の処理
      }
    }
  });

  // タイムアウト処理
  const timeout = setTimeout(() => {
    router.push("/");
  }, 65 * 1000);
});

onBeforeUnmount(() => {
  closeConnection();
});
</script>

<template>
  <div>
    <div v-if="!isConnected" class="error-message">
      接続が切断されました。再接続を試みています...
    </div>
  </div>
</template>
```

### セキュリティ考慮事項
WebSocket実装時は以下のセキュリティポイントに注意が必要です

1. 認証・認可
    - WebSocket接続時の認証処理
    - 適切なトークン管理
2. データバリデーション
    - 受信データの検証
    - 不正なデータの除外
3. レート制限
    - メッセージ送信の制限
    - 接続数の制限

### パフォーマンス最適化
1. 接続管理
    - 不要な接続はすぐに切断
    - 再接続の適切な間隔設定
2. メッセージ設計
    - データ構造の最適化
    - 必要最小限のデータ送信
3. エラーハンドリング
    - タイムアウトの適切な設定
    - 再接続ロジックの実装

### まとめ
Nuxt.jsとWebSocketを組み合わせることで、リアルタイム性の高いアプリケーションを効率的に構築できます。
今回の記事が皆さんのプロジェクトの参考になれば幸いです。