FastAPI シンプル チャット - Websocket

以下の過去記事でも述べていますが、FastAPIは**Starlette**を基盤として構築されており、WebSocketのサポートも継承しています。
[Starletteで作る Simple Web Server - Qiita](https://qiita.com/sand/items/dd622ce4f9249399974c)
[WebSocket - FastAPI公式サイト](https://fastapi.tiangolo.com/advanced/websockets/)

今回は公式サイトにあるチャットプログラムを少し修正し、特にクライアントは簡単なVueプログラムに置き替えています。


# 1. クライアント - Vue.js

過去記事の[FastAPI OAuth2 クライアント - Qiita](https://qiita.com/sand/items/990afc5d49a37b026acc)と同じディレクトリ構成で、FastAPIのディレクトリのサブディレクトリstaticにindex.htmlとindex.jsを置きます。

index.htmlは2つのフィールドから構成されています。ログイン画面とメッセージ送信画面です。メッセージ送信ボタンはログインするまでは無効化されています。

```html:static/index.html
<html>
    <head>
        <link rel="stylesheet" href="index.css">
        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    </head>
    <body>
        
    <div id="app">
        <div id="login">
            <h1>Login</h1>
            <p><label for="username">ユーザ名</label>
               <input id="username" v-model="username"></p>
            <p><label for="password">パスワード</label>
               <input id="password" v-model="password"></p>
            <button v-on:click="doLogin">ログイン</button>
            <hr>
            <h1>Chat message</h1>
            <p><input v-model="message"></p>
            <button v-bind:disabled="isButtonDisabled" v-on:click="sendMessage">メッセージ送信</button>
        </div>
        <ul id='messages'>
            <li v-for="(message,index) in logs">
                {{index}} {{message}}
            </li>
        </ul>
    </div>        
    <script src="index.js"></script>
    </body>
</html>
```

Vueのメッソドは2つあります。ログインを行う**doLogin**と、メッセージを送信する**sendMessage**です。ログイン時の処理として、WebSocketのエンドポイントにはクエリパラメータでユーザ名とパスワードを渡します。これはWebSocketのインスタンスを作成するときに行われます。

```javascript:index.js
var app = new Vue({
    el: '#app',
    data: {
      ws: null,
      username: 'yamada',
      password: 'pass123',
      message: '',
      logs: [],
      isButtonDisabled: true
    },
    methods: {
      doLogin: function(event) {
          this.ws = new WebSocket("ws://localhost:8000/ws?username=" + this.username + "&password=" + this.password);
          this.isButtonDisabled = false
          that = this
          this.ws.onmessage = function(event) {
            that.logs.push(event.data)
          };
          event.preventDefault()
        },
      sendMessage: function(event) {
        this.ws.send(this.message)
        this.message = ''
        event.preventDefault()
      }
    }
  })
```



# 2. サーバプログラム - FastAPI


サーバ側で注意すべき点は、**path operation function** ( websocket_endpoint ) の第一引数にWebSocketインスタンスが渡されることです。その後にクエリパラメータのusernameとpasswordが続きます。ここではナンチャッテ認証としてusernameとpasswordを検証していますが、もう少しそれらしくしたいのなら「[FastAPI OAuth2 クライアント](https://qiita.com/sand/items/990afc5d49a37b026acc)」でのコードをそのまま適用できるでしょう。

またブロードキャストを実現したい目的で、クラスConnectionManagerを定義し、接続クライアント( connection )を管理するようにしています。


```python:main.py
from typing import List

from fastapi import FastAPI, Query, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")

class ConnectionManager:
    def __init__(self):
        self.active_connections: List[WebSocket] = []

    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active_connections.append(websocket)

    def disconnect(self, websocket: WebSocket):
        self.active_connections.remove(websocket)

    async def send_personal_message(self, message: str, websocket: WebSocket):
        await websocket.send_text(message)

    async def broadcast(self, message: str):
        for connection in self.active_connections:
            await connection.send_text(message)

manager = ConnectionManager()


@app.websocket("/ws")
async def websocket_endpoint(
    websocket: WebSocket,
    username: str = Query(..., max_length=50),
    password: str = Query(..., max_length=50)
):
    print(f'username={username} password={password}')
    if username not in ['yamada','tanaka'] or password != 'pass123':
        return None
    await manager.connect(websocket)
    try:
        while True:
            data = await websocket.receive_text()
            await manager.send_personal_message(f"You wrote: {data}", websocket)
            await manager.broadcast(f"Client #{username} says: {data}")
    except WebSocketDisconnect:
        manager.disconnect(websocket)
        await manager.broadcast(f"Client #{username} left the chat")
```


**Starlette**の功績といえるのでしょうが、結構簡単にSocketプログラミングが可能です。まあ、ただし**Elixier/Phoenix**のフレームワークにはまだ届いていない気はしますが。


# 3. プログラムの動作

## 3-1.初期画面

以下のURLで初期画面にアクセスできます。メッセージ送信ボタンが無効化されています。
http://localhost:8000/static/index.html

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61915/51f4dcb6-847e-2335-c1b4-4f38cfc554fc.png)


## 3-2.ログイン

ユーザ名とパスワードの初期値そのままにログインします。ログインするとメッセージ送信ボタンが有効化されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61915/bd605629-732e-5a5f-7af1-3d2a226aa040.png)



## 3-3.メッセージ送信

まず「こんにちは、山田です」と入力します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61915/ad042d8f-6e41-7080-436b-aabca0d86606.png)

次に送信ボタンを押します。入力欄がクリアーされて、下部にメッセージログが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61915/743212a2-5fd9-5381-d846-d94136d3bb2d.png)

## 3-4.別ブラウザからのログイン

別ブラウザを立ち上げ、tanakaでログインします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61915/d4f0cc4f-2eb2-9fae-7d8b-16ebfa14defa.png)


## 3-5.別ブラウザからメッセージ送信

左が山田さんのブラウザ、右が田中さんのブラウザです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61915/360e026d-d61b-c2e0-063a-f24c9be63902.png)


今回は以上です。