Deno で WebSocketサーバー 【2022時点】

#### 前提知識 (読み飛ばしてください)

- 通常のhttp(s)通信では、サーバーはクライアントからの呼びかけに対してただ応答を投げ返すだけですが、WebSocket通信を使うことでサーバーからクライアント方向へも積極的にデータを送信することができます。
- これによって、リアルタイムのゲーム対戦などでクライアント同士が サーバーを通じて データを送信しあうことができます。
- ちなみにクライアント端末同士で直接通信する P2P, broadcast もありますがこの記事では触れません。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982650/d5913ed4-3843-9d51-641d-bbeff4e386ef.png" width="100%" />

<br />
<br />

# 本題

さて、**Node.js** よりも手軽に **TypeScript** をサクッと動かすことができる **Deno** というランタイムがありますが,
現在は進化中なので、ちょっと前の参考サイトのコードが動かないことがよくあります.

ですので現時点で動作を確認できたコードを共有しておくことにしました。

:::note info
Denoのインストールはこちらから
https://deno.land/
```
# Mac ターミナル
$ curl -fsSL https://deno.land/install.sh | sh

# Windows パワーシェル
$ iwr https://deno.land/install.ps1 -useb | iex
```
:::


# WebSocketサーバー

- 以下の `main.ts` をコピペしてから `deno run --allow-net main.ts` コマンドでサーバーが起動します
- クライアントからはURL `ws://0.0.0.0:8080` に接続します

```main.ts

// importなし 標準だけで実行できます

const clients = new Map<number, WebSocket>();

let clientId = 0;
function newId(): number {
  clientId += 1;
  return clientId;
}

function sendToAll(msg: string) {
  for (const client of clients.values()) {
    client.send(msg);
  }
}

function wsHandler(ws: WebSocket) {
  const id = newId();
  clients.set(id, ws);
  ws.onopen = () => {
    sendToAll(`ID${id} さんが接続しました`);
  };
  ws.onmessage = (e) => {
    const text = `ID:${id} さん: ${e.data}`;
    sendToAll(text);
    console.log(text);
  };
  ws.onclose = () => {
    clients.delete(id);
    sendToAll(`ID${id} さんが切断しました`);
  };
}

function requestHandler(req: Deno.RequestEvent) {
  const pathname = new URL(req.request.url).pathname;
  if (req.request.method === "GET" && pathname === "/") {
    const { socket, response } = Deno.upgradeWebSocket(req.request);
    wsHandler(socket);
    req.respondWith(response);
  }
}

async function connHandler(conn: Deno.Conn) {
  const httpConn = Deno.serveHttp(conn);
  for await (const requestEvent of httpConn) {
    requestHandler(requestEvent);
  }
}

console.log("server starting...");
const PORT = 8080;
const server = Deno.listen({ port: PORT });
for await (const conn of server) {
  connHandler(conn);
}
```

次項で1つずつ解説していきます

<br />
<br />


# 解説

まずは接続中の全クライアントを状態として持ちました

```main.ts
const clients = new Map<number, WebSocket>();
```

次に新しい クライアントにつける ID です。 今回は単純に数字を1ずつ増やしたものを ID として与えていきます

```main.ts
let clientId = 0;
function newId(): number {
  clientId += 1;
  return clientId;
}
```

クライアント全員にメッセージを送るメソッドを用意します

```main.ts
function sendToAll(msg: string) {
  for (const client of clients.values()) {
    client.send(msg);
  }
}
```

クライアント1人ずつに対する処理を定義しておきます

```main.ts
function wsHandler(ws: WebSocket) {
  // 新しい ID をつけて保存しておく
  const id = newId();
  clients.set(id, ws);
  // 接続がオープンしたとき
  ws.onopen = () => {
    sendToAll(`ID${id} さんが接続しました`);
  };
  // クライアントからメッセージを受け取ったとき
  ws.onmessage = (e) => {
    const text = `ID:${id} さん: ${e.data}`;
    sendToAll(text);
    console.log(text);
  };
  // 接続がクローズしたとき
  ws.onclose = () => {
    clients.delete(id);
    sendToAll(`ID${id} さんが切断しました`);
  };
}
```

個別の Httpリクエスト 1つずつに対する処理を定義しておきます。もしもクライアントが接続してきたときは上で定義しておいたメソッドを呼び出します。

```main.ts
function requestHandler(req: Deno.RequestEvent) {
  const pathname = new URL(req.request.url).pathname;
  if (req.request.method === "GET" && pathname === "/") {
    // WebSocket 専用のURL にアクセスされたとき
    const { socket, response } = Deno.upgradeWebSocket(req.request);
    wsHandler(socket);
    req.respondWith(response);
  }
}
```

全ての Http 接続に対する処理を定義しておきます。個別のリクエストに対しては上で定義しておいたメソッドを呼び出します。

```main.ts
async function connHandler(conn: Deno.Conn) {
  const httpConn = Deno.serveHttp(conn);
  for await (const requestEvent of httpConn) {
    requestHandler(requestEvent);
  }
}
```

最後にサーバーを起動します

```main.ts
console.log("server starting...");
const PORT = 8080;
const server = Deno.listen({ port: PORT });
for await (const conn of server) {
  connHandler(conn);
}
```

解説ここまで

<br />
<br />

# おまけ

ついでにクライアント側のコードも載せておきます。今回は **Flutter** で作成してみました。

- Flutter SDK `2.17` 以上が必要です

- パッケージに`web_socket_channel` を追加します

```pubspec.yaml
dependencies:
  web_socket_channel: # これを追加
```

- 画面のコードを編集 `main.dart`

```lib/main.dart
import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    const title = 'WebSocket Demo';
    return const MaterialApp(
      title: title,
      home: MyHomePage(
        title: title,
      ),
    );
  }
}

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

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final TextEditingController _controller = TextEditingController();
  final _channel = WebSocketChannel.connect(
    Uri.parse('ws://0.0.0.0:8080/'),
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Form(
              child: TextFormField(
                controller: _controller,
                decoration: const InputDecoration(labelText: 'Send a message'),
              ),
            ),
            const SizedBox(height: 24),
            StreamBuilder(
              stream: _channel.stream,
              builder: (context, snapshot) {
                return Text(snapshot.hasData ? '${snapshot.data}' : '');
              },
            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _sendMessage,
        tooltip: 'Send message',
        child: const Icon(Icons.send),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }

  void _sendMessage() {
    if (_controller.text.isNotEmpty) {
      _channel.sink.add(_controller.text);
    }
  }

  @override
  void dispose() {
    _channel.sink.close();
    _controller.dispose();
    super.dispose();
  }
}
```

Flutter で2つの画面を起動すると、相互通信を確かめることができます。

以上です。ありがとうございました。