FastAPIとWebSocketで位置情報共有アプリを作ってみた

# リアルタイム位置情報共有アプリWhere am I in Sapporo

複数のユーザーがブラウザから自分の位置を入力し、他のユーザーとの間にリアルタイムで共有することができるアプリを作ってみました。選択できる位置は札幌市内の主要観光スポットを事例としています。
ユーザーごとにIDが付与され、ネコの写真もアイコンとしてランダムで付与されます。

![スクリーンショット 2023-07-19 15.52.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2365400/dfd865bf-ba4b-c43f-0e3d-bb8165b428ea.png)

ソースコードは以下リポジトリで公開しています。
https://github.com/xinmiao1995/share_now_location


# 利用するツール
- WebAPI配信
    - FastAPI
    - WebSocket
    - ngrok
- マップビュワー
    - MapLibre GL JS
        - OpenStreetMapベースマップの表示
        - マーカーの表示
        - 地図ズーム
    - Turf.js
        - 地図ズーム領域の取得
- アイコン画像フリー素材
    - [placekitten](https://placekitten.com/)



# リアルタイム通信を実現するために
## FastAPIについて
FastAPIはPython（バージョン3.6以降対応）製の軽量Webフレームワークです。非同期処理が対応可能との特徴もありますので、今回は主にこちらを利用しています。また、PythonのパッケージではWebSocketも内容されているので、fastapiをインストールするだけで、WebSocketの機能も使えます。

- インストール
```shell
pip install fastapi
```
- Pythonコードの書き方
```python
from fastapi import FastAPI, WebSocket
```


## WebSocketについて
WebSocketについては常時接続が可能なプロトコルです。接続トラフィックの低減と非同期処理によって、安定した双方向通信が実現できます。それによって、今回の目標である複数クライアント間のリアルタイム通信が可能になリます。
![スクリーンショット 2023-07-19 16.18.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2365400/d4ddd1ae-f83a-5741-08ca-b85a1e5e8793.png)
出典：https://atmarkit.itmedia.co.jp/ait/articles/1111/11/news135.html

## システムフロー
今回のアプリケーションでは、このように複数のクライエントとサーバー間の通信ができることを目指しています。
![スクリーンショット 2023-07-19 16.03.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2365400/a7f05ec3-1024-4576-6f30-f68856bdfbb4.png)

## ソースコード
- サーバーサイド
サーバーサイドではクライエントの情報を保存したり、加工したり、クライエントに情報を送付したりしています。本来はデータベースを利用するべきですが、今回は簡易的にPythonのリストでユーザー情報を保管します。サーバーを再起動すればリセットされますので要注意です。

- WebSocket接続の成立と切断
    ```python
    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active_connections.append(websocket)
    
    def disconnect(self, websocket: WebSocket):
        self.active_connections.remove(websocket)
    ```
- クライエントにメッセージの送付
    ```python
    async def broadcast(self, client_id: int, coordinate: str):
        self.client_ids.append(client_id)
        self.markers[client_id] = {
            "coordinate": coordinate,
            "marker_size": self.client_ids.index(client_id) + 40,
        }
    
        for connection in self.active_connections:
            await connection.send_json({
                "message": f"user {client_id}'s location: {coordinate}",
                "markers": self.markers
            })
    ```

    ```python
    @app.websocket("/ws/{client_id}")
    async def websocket_endpoint(websocket: WebSocket, client_id: int):
        await manager.connect(websocket)
        try:
            while True:
                text = await websocket.receive_text()
                await manager.broadcast(client_id, text)
    
        except WebSocketDisconnect:
            manager.disconnect(websocket)
            del manager.markers[client_id]
    
    ```
こちらではjsonを送っていますが、WebSocketではsend,receiveできる型はそれぞれtext,bytes,jsonがあります。async、awaitは非同期処理を意味しています。


- フロントエンド
クライエントでは、ユーザーIDの生成や、サーバーから受け取った情報をマップ上の常時をしています。

    - 定義したURLにアクセスしたらWebSocketインスタンスが宣言される

    ```javascript
    let ws = new WebSocket(`wss://${hostName}/ws/${client_id}`);
    ```

    - ユーザーが選択した位置をサーバーに送る
  
    ```javascript
    function sendMessage(event) {
        ws.send(loc_coordinat[document.getElementById("myDropdown").value])
        event.preventDefault()
    }
    ```

    - サーバーからメッセージが届いたらマップ上にマーカーを表示させる
    
    ```javascript
    ws.onmessage = function (event) {
        Object.keys(JSON.parse(event.data).markers).forEach((key, index) => {
            let coordinate = JSON.parse(event.data).markers[key].coordinate;
            let marker_size = JSON.parse(event.data).markers[key].marker_size;
            ...
            // add marker to map
            new maplibregl.Marker(el)
                .setLngLat(coordinate.split(','))
                .addTo(map);
            points.push(coordinate.split(','));

            // Zoom to points
            const pointsCollection = turf.featureCollection(points.map(point => turf.point(point)));
            const bbox = turf.bbox(pointsCollection);
            map.fitBounds([
                [bbox[0], bbox[1]],
                [bbox[2], bbox[3]]
            ]);
        });
    };

    ```


- 起動するには
```shell
# ローカルホストを起動する（ローカルマシンから接続可能）
uvicorn main:app --reload
# ngrokで公開（任意のIPから接続可能）
ngrok http 8000
```


# 参考資料
[動かして学ぶ！Python FastAPI開発入門](https://www.shoeisha.co.jp/book/detail/9784798177229)
[FastAPI公式ドキュメント](https://fastapi.tiangolo.com/ja/)
[双方向通信を実現！ WebSocketを使いこなそう](https://atmarkit.itmedia.co.jp/ait/articles/1111/11/news135.html)
[MapLibre公式ドキュメント](https://maplibre.org/)