VaporでWebSocketチャットサーバを作る

SwiftのWebフレームワークである[Vapor](https://vapor.codes/)を使って，WebSocketチャットサーバを作ってみました．

## 環境

* Vapor 3.1
* swift 4.2.1
* Xcode 10.1
* macOS 10.14 (Mojave)

## WebSocketライブラリの追加

Vaporの新規プロジェクトを作成し，WebSocketライブラリを`Package.swift`に入れます．
（apiテンプレートを元にしていますが，テンプレートで提供しているAPIサーバ機能は今回使っていません）．

```shell:
$ vapor new --api vapor-websocket
```

```Pakage.swift
// swift-tools-version:4.0
import PackageDescription

let package = Package(
    name: "vapor-websocket",
    dependencies: [
        // 💧 A server-side Swift web framework.
        .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"),

        // 🔵 Swift ORM (queries, models, relations, etc) built on SQLite 3.
        .package(url: "https://github.com/vapor/fluent-sqlite.git", from: "3.0.0"),
        .package(url: "https://github.com/vapor/websocket.git", from: "1.0.0"),
    ],
    targets: [
        .target(name: "App", dependencies: ["FluentSQLite", "Vapor", "WebSocket"]),
        .target(name: "Run", dependencies: ["App"]),
        .testTarget(name: "AppTests", dependencies: ["App"])
    ]
)
```

## WebSocketチャットサーバ機能の追加

`Sources/App/chat.swift`というファイルを新規作成し，以下のようなチャットルームクラスを作りました．

```chat.swift
import Vapor

public class ChatRoom {
  // 接続中のWebSocketクライアント
  var clients = [WebSocket]()

  // WebSocketクライアントの接続ハンドラ
  func handler() -> ((WebSocket, Request) throws -> ()) {
    return { ws, req in
      // 接続中クライアントリストに追加
      self.clients.append(ws)
      // メッセージ受信時のハンドラを登録
      ws.onText(self.onText)
      // 切断時にクライアントリストから除去
      ws.onClose.always {
        self.clients = self.clients.filter { $0 === ws }
      }
    }
  }

  // WebSocketクライアントからのメッセージハンドラ
  private func onText(sender: WebSocket, text: String) -> () {
    // 送られたメッセージをそのまま全クライアントに送信する
    self.clients.forEach{ ws in
      if ws !== sender {
        ws.send("> \(text)")
      }
    }
  }
}
```

* 接続中のWebSocketクライアントのリストを`clients`で保持し，接続・切断で追加・除去しています
* メッセージの受信時に，送信元以外のクライアントにメッセージを転送しています

これを，`Sources/App/configure.swift`でサービスに追加します．

```configure.swift
    // WebSocketサーバの作成
    let wss = NIOWebSocketServer.default()

    // WebSocketアップグレードサポートを/echoに追加
    let chatRoom = ChatRoom()
    wss.get("echo", use: chatRoom.handler())

    // WebSocketサーバの登録
    services.register(wss, as: WebSocketServer.self)
```

## 実行

サーバを起動し，[wsta](https://github.com/esphen/wsta)で動作確認します．

```shell:
$ vapor build && vapor run
Running vapor-websocket ...
[ INFO ] Migrating 'sqlite' database (/Users/hoge/sandbox/vapor-websocket/.build/checkouts/fluent.git--929032494386059648/Sources/Fluent/Migration/MigrationConfig.swift:69)
[ INFO ] Preparing migration 'Todo' (/Users/hoge/sandbox/vapor-websocket/.build/checkouts/fluent.git--929032494386059648/Sources/Fluent/Migration/Migrations.swift:111)
[ INFO ] Migrations complete (/Users/hoge/sandbox/vapor-websocket/.build/checkouts/fluent.git--929032494386059648/Sources/Fluent/Migration/MigrationConfig.swift:73)
Running default command: .build/debug/Run serve
Server starting on http://localhost:8080
```

```shell:クライアント1
$ wsta ws://localhost:8080/echo
Connected to ws://localhost:8080/echo
Hi, my name is client1.
> Hi! I'm client2!
```

```shell:クライアント２
$ wsta ws://localhost:8080/echo
Connected to ws://localhost:8080/echo
> Hi, my name is client1.
Hi! I'm client2!
```

両方のクライアントで接続後，クライアント1から`Hi, my name is client1.`と送信し，その後クライアント2から`Hi! I'm client2!`と送信しました．

## 注意点

WebSocketクライアントからのメッセージ受信時は`onText`でハンドラを登録しますが，切断時は`onClose`Futureで非同期処理する必要があり，書き方が異なります．
エラー時は`onText`と同様に`onError`でハンドラを登録します．

## 参考

* [Vapor WebSocket](https://docs.vapor.codes/3.0/vapor/websocket/)
* [Vapor Async Overview - Always](https://docs.vapor.codes/3.0/async/overview/#always)
* [GitHub - esphen/wsta: A CLI development tool for WebSocket APIs](https://github.com/esphen/wsta)