FastAPIでWebSockets

## TL;DR
- FastAPIでAPI作成は簡単だけど、WebSocketsも簡単に実装できちゃう
- FastAPI+WebSocketsで簡易チャットアプリの作成
- チャットアプリではブロードキャストを実装

## デモ
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130670/f5f5d9b3-4603-8be4-1cbe-e079687a9851.gif)

入力したメッセージは送信ボタンを押すと、送信元のIDと入力メッセージを全ブラウザにブロードキャストする

## 環境構築
### 必要な知識
- dockerとdocker-composeがインストールされていること
- Pythonのちょっとした知識
- WebSocketsのちょっとした知識

### ファイル構成
```bash
$ tree
.
├── api
│   └── main.py
├── app
│   ├── app.js
│   └── index.html
├── docker
│   ├── node
│   │   └── Dockerfile
│   └── uvicorn
│       ├── Dockerfile
│       └── requirements.txt
└── docker-compose.yml

```

### インストール

```bash
# コードをクローンする
$ git clone https://github.com/sattosan/sample-fastapi-websockets.git

# クローンしたディレクトリへ移動
$ cd ./sample-fastapi-websockets

# コンテナの起動
$ docker-compose up -d --build
```

### Webサーバにアクセス
http://localhost:8080

## サーバ側の実装 : FastAPI

```python:./api/main.py
from fastapi import FastAPI
from starlette.websockets import WebSocket

app = FastAPI()

# 接続中のクライアントを識別するためのIDを格納
clients = {}

# WebSockets用のエンドポイント
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
    await ws.accept()
    # クライアントを識別するためのIDを取得
    key = ws.headers.get('sec-websocket-key')
    clients[key] = ws
    try:
        while True:
            # クライアントからメッセージを受信
            data = await ws.receive_text()
            # 接続中のクライアントそれぞれにメッセージを送信（ブロードキャスト）
            for client in clients.values():
                await client.send_text(f"ID: {key} | Message: {data}")
    except:
        await ws.close()
        # 接続が切れた場合、当該クライアントを削除する
        del clients[key]
```

[FastAPI公式ドキュメント](https://fastapi.tiangolo.com/advanced/websockets/#create-a-websocket)では、クライアントとサーバの双方向通信のサンプルが紹介されているが、今回は、サーバと接続が確立している全クライアントに対して**ブロードキャスト**を行いたかったため、**clients**という辞書の中に、接続されたクライアントを識別する**一意のkey**を格納している

メッセージがサーバ側に送られて来たとき、
keyを利用して接続しているクライアントそれぞれに受け取ったメッセージを送っている

```python
# 接続中のクライアントそれぞれにメッセージを送信（ブロードキャスト）
for client in clients.values():
    await client.send_text(f"ID: {key} | Message: {data}")
```

## フロント側の実装 : Node.js

```js:./app/app.js
// モジュールのロード
const http = require('http');
const fs = require('fs');

// サーバーオブジェクトの作成
const server = http.createServer((req,res)=>{
  // ファイル読み込み
  fs.readFile('index.html','UTF-8',
    (error, data) => {
        // エラー処理
        if (error) {
            response.writeHead(500, {"Content-Type": "text/plain"});
            response.write("500 Internal Server Error\n");
            res.end();
        }
        // index.htmlを表示
        res.writeHead(200,{'Content-Type':'text/html'});
        res.write(data);
        res.end();
    });
});

// 待ち受け開始
server.listen(8080);
console.log('Server running');
```

Node.jsを使って簡易的なWebサーバを立てている
ここでは便宜上Node.jsを使っているが、もちろん他のWebサーバに代用可能

```html:./app/index.html
<!DOCTYPE html>
<html>
    <head>
        <title>Chat</title>
    </head>
    <body>
        <h1>WebSocket Chat</h1>
        <form action="" onsubmit="sendMessage(event)">
            <input type="text" id="messageText" autocomplete="off"/>
            <button>Send</button>
        </form>
        <ul id='messages'>
        </ul>
        <script>
            var ws = new WebSocket("ws://localhost:8000/ws");
            ws.onmessage = function(event) {
                var messages = document.getElementById('messages')
                var message = document.createElement('li')
                var content = document.createTextNode(event.data)
                message.appendChild(content)
                messages.appendChild(message)
            };
            function sendMessage(event) {
                var input = document.getElementById("messageText")
                ws.send(input.value)
                input.value = ''
                event.preventDefault()
            }
        </script>
    </body>
</html>
```
FastAPIで作成したWebSocketsのエンドポイント（ws://localhost:8000/ws）を指定している
Submit!ボタンが押されたとき、FastAPIに入力したテキストメッセージを送信

逆に、ソケットからメッセージを受け取った場合、ulタグに受け取ったメッセージをliタグごと挿入する

## おわりに
FastAPIでWebSocketsを使って簡単なチャットアプリ実装できた
今後は、namespaceを利用してルームごとにメッセージの送受信ができるように拡張したい