Serverpodでwebsocketを使ってみた

# 成果物

[Serverpod](https://serverpod.dev)でチャットアプリを作り、ブラウザとPCアプリでチャットすることができました。
![chat_app_by_flutter_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287677/f1223416-67f8-257d-7cfb-b05d3146f0a0.gif)

# はじめに
 [Serverpod](https://serverpod.dev)という開発フレームワークをご存知でしょうか？
Dartという共通言語でサーバからフロントまで構築でき、かつ、マルチプラットフォームに開発できるものです。
意外と流行っていない印象がありますが、面白そうなので使ってみました。
今回はマルチプラットフォームな簡易チャットアプリを作りました。

# 開発環境
OS: Ubuntu 22.04 LTS
Flutter: 3.10.4
Dart: 3.0.3

# 構築
## serverpodのインストール
まだの方は、serverpodをインストールしてください。

https://docs.serverpod.dev/

## exampleアプリの構築
まずは、serverpod createコマンドでアプリを作ります。
```bash
$ serverpod create chatapp
$ cd chatapp/chatapp_server
$ docker-compose up --build --detach
```
これで、バックエンドも含めて、exampleのアプリが立ち上がるのがびっくりです。
今回は分かりやすいように、このexampleのアプリを改修していきます。

## websocket用の型の定義
chatapp/chatapp_server/lib/src/protocol内に、以下のchat.yamlを生成します。
```yaml: chat.yaml
class: Chat
table: chat
fields:
  message: String
  user_id: int
```
上記ファイル生成後、serverpod generateして、classファイルを生成します。

```bash
$ serverpod generate
```
chatapp/chatapp_server/generated/tables.pgsqlにデータベースのテーブル作成用のSQL文が自動生成されています。[serverpodのチュートリアル動画](https://youtu.be/3Q2vKGacfh0)では、ここでデータベースにログインして生成していますが、今回は省略します。
```sql:tables.pgsql
--
-- Class Chat as table chat
--

CREATE TABLE "chat" (
  "id" serial,
  "message" text NOT NULL,
  "user_id" integer NOT NULL
);

ALTER TABLE ONLY "chat"
  ADD CONSTRAINT chat_pkey PRIMARY KEY (id);
```


## エンドポイント処理の定義
chatapp/chatapp_server/lib/src/endpoint内に、エンドポイント処理定義用のコードファイル,chat_endpoint.dartを生成します。
```dart: chat_endpoint.dart
import 'dart:async';
import 'package:serverpod/serverpod.dart';
import '../generated/protocol.dart'; //NOTE Chat型を使用するために追加

class ChatEndpoint extends Endpoint {
  final String _hello = "hello";
  @override
  Future<void> streamOpened(StreamingSession session) async {
    //NOTE clientからのwebsocket接続要求時の処理
    print("chat websocket started");
    session.messages.addListener(_hello, (message) {
      //リスナーの追加
      print("send to client");
      sendStreamMessage(session, message);
    });
  }

  @override
  Future<void> handleStreamMessage(//NOTE クライアントからwebsocketを受信したときの処理。
    StreamingSession session,
    SerializableEntity message,
  ) async {
    print("websocket handle");
    if (message is Chat) {//NOTE Chatメッセージが来た時の処理
      print("recieve Chat");
      session.messages.postMessage(
          _hello, Chat(message: message.message, user_id: message.user_id));
    }
  }

  @override
  Future<void> streamClosed(StreamingSession session) async {
    print("stream close");
  }
}

```
上記ファイル生成後、もう一度serverpod generateして、client用のファイルを生成します。

```bash
$ serverpod generate
```
すると、chatapp/chatapp_clientの中にclient用のコードが自動生成されます。
このコードを、flutterから使用します。

## フロントエンド
最後にflutterのコードを修正します。
chatapp/chatapp_flutter/lib/main.dartを以下のように編集します。
```dart: main.dart
import 'package:marubatsugame_client/marubatsugame_client.dart';
import 'package:flutter/material.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'dart:math' as math;

var client = Client('http://localhost:8080/')
  ..connectivityMonitor = FlutterConnectivityMonitor();

final int _userId = math.Random().nextInt(1000); //ランダムなユーザIDを生成
Future<void> main() async {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Serverpod Chat Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Serverpod Chat Example'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  MyHomePageState createState() => MyHomePageState();
}

class MyHomePageState extends State<MyHomePage> {
  final List<Chat> _messages = [];

  @override
  void initState() {
    Future(() async {
      await client.openStreamingConnection();
      await for (var message in client.chat.stream) {
        if (message is Chat) {
          _messages.add(message); //NOTE メッセージを追加
        }
        setState(() {});
      }
    });
    super.initState();
  }

  @override
  void dispose() {
    client.closeStreamingConnection(); //NOTE websocketコネクションの開放
    super.dispose();
  }

  final _textEditingController = TextEditingController();

  void _sendMessage() async {
    try {
      //NOTE APIではなく、websocketに変更
      Chat message =
          Chat(message: _textEditingController.text, user_id: _userId);
      await client.chat
          .sendStreamMessage(message);
    } catch (e) {
      print(e);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.only(bottom: 16.0),
              child: TextField(
                controller: _textEditingController,
                decoration: const InputDecoration(
                  hintText: 'Enter chat Message', //NOTE ヒントテキストを変更
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.only(bottom: 16.0),
              child: ElevatedButton(
                onPressed: _sendMessage,
                child: const Text('Send to Server'),
              ),
            ),
            for (final message in _messages)//NOTE メッセージの表示
              _ChatDisplay(message: message.message, userId: message.user_id)
          ],
        ),
      ),
    );
  }
}

class _ChatDisplay extends StatelessWidget {
  //NOTE chat用のwidgetを生成
  final String? message;
  final int? userId;

  const _ChatDisplay({
    required this.message,
    required this.userId,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 50,
      color: userId == _userId
          ? Colors.grey[300]
          : Colors.green[300], //NOTE 自分と自分以外で、色分け
      child: Center(
        child: Text(message!),
      ),
    );
  }
}

```

以上で、コードの編集は終わりです。
## 起　動

バックエンドを起動します。
```bash
$ cd chatapp_server
$ dart bin/main.dart
```
フロントエンド（Flutter）を起動します。
マルチプラットフォームをアピールするため、web版（chrome）とPCアプリ（linux）の２つ、起動します。
```bash
$ flutter run -d chrome
```
別ターミナルで、
```bash
$ flutter run -d linux
```
実際の様子（再掲）
![chat_app_by_flutter_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287677/f1223416-67f8-257d-7cfb-b05d3146f0a0.gif)
web版とPCアプリ版で、websocketを用いて、相互通信することができました。

処理の流れ
1. ブラウザ（chrome）からサーバにwebsocketでメッセージ送信
1. サーバから各websocket client(chrome, linux)にメッセージをブロードキャスト
1. 各websocket clientは受け取ったメッセージを描画

# おわりに
serverpodを使用し、マルチプラットフォームな簡易チャットアプリを作成しました。
バックエンドからフロントエンドまでのシームレスな開発は癖になります。
今後はデータベースも絡めた簡単な対戦ゲームを作っていこうと思います。

# 参考資料
Tutorials & Examples
https://docs.serverpod.dev/tutorials
このチュートリアルを見れば体系的に学べます。
特にserverpod generateコマンドで、何をやっているかがだいたい分かります。

Streams and messaging
https://docs.serverpod.dev/concepts/streams
今回の肝である、websocketの公式ガイドです。