ガイド: WebSocketでチャットを実装する方法

Estimated reading time: 2 minutes

このチュートリアルでは、 Ktor を用いたチャットアプリケーションの作り方を学びます。 WebSocket を用いてリアルタイム双方向通信を行います。

これを達成するために、 RoutingWebSocketsSessions の3つの Feature を用います。

本チュートリアルは高度な内容になっています。 Ktor に関する基本的な知識があることを前提としているため、先に ウェブサイトの作り方 を 学んでください。

目次:

プロジェクトのセットアップ

まずはじめに、プロジェクトのセットアップを行います。 Quick Start を参考にするか、下記の設定を用いてプロジェクトを作成してください。

the pre-configured generator form

WebSocket とは

WebSocket は HTTP のサブプロトコルです。 WebSocket 通信は Upgrade リクエストヘッダを付与した通常の HTTP リクエストから始まり、なにか1つレスポンスを返すのではなく、双方向通信に接続が切り替わります。

WebSocket プロトコルにて送信可能な情報の最小単位は Frame と呼ばれます。 WebSocket Frame は型、長さ、そしてバイナリかテキストのペイロードを定義します。 内部的には、これらのフレームは複数の TCP パケットに分割されて透過的に送信される場合があります。

Frame は WebSocket メッセージとして扱えます。 Frame の型は、 TextBinaryClosePingPong があります。

基本的には、 TextBinary のフレームだけを考えればよく、それ以外の種類は多くの場合は Ktor に任せることができます。 (Raw mode を使うことで、独自の Frame 型を扱うこともできます。)

詳細は WebSockets feature に記載してあります。

WebSocket ルーティング

まずはじめに、 WebSocket 用のルーティングを作りましょう。 今回は /chat という名前にします。 /chat という名前にしましたが、最初のうちは受信したメッセージをオウム返しするだけの「エコー」 WebSocket ルーティングとして機能させます。

webSocket ルーティングは長命なオブジェクトです。 Suspend block (CoroutineScope) で Kotlin の軽量な coroutine を用いているので、コードの可読性を保ちつつも、 数十万のコネクションを一度に処理できます。 (マシンのスペックに依存します。)

routing {
    webSocket("/chat") { // this: DefaultWebSocketSession
        while (true) {
            val frame = incoming.receive() // suspend
            when (frame) {
                is Frame.Text -> {
                    val text = frame.readText()
                    outgoing.send(Frame.Text(text)) // suspend
                }
            }
        }
    }
}

コネクションの保持

確立済みコネクション群を Set に保持できます。 言語標準の try...finally を用いてコネクションの追跡ができます。 Ktor はデフォルトでマルチスレッドで動作するため、スレッドセーフなコレクションを利用するか、 newSingleThreadContext を用いて実体をシングルスレッドに制限 しなければなりません。

routing {
    val wsConnections = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
    
    webSocket("/chat") { // this: DefaultWebSocketSession
        wsConnections += this
        try {
            while (true) {
                val frame = incoming.receive()
                // ...
            }
        } finally {
            wsConnections -= this
        }
    }
}

すべてのコネクションにメッセージを伝播させる

コネクションの集合(Set)を保持できたので、全コネクションに対して送信したい frame を送信することができます。 ユーザがメッセージを送信するたびに、接続済のすべてのクライアントに対しそのメッセージを伝播させてみましょう。

routing {
    val wsConnections = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
    
    webSocket("/chat") { // this: DefaultWebSocketSession
        wsConnections += this
        try {
            while (true) {
                val frame = incoming.receive()
                when (frame) {
                    is Frame.Text -> {
                        val text = frame.readText()
                        // 全コネクションに対する繰り返し処理
                        for (conn in wsConnections) {
                            conn.outgoing.send(Frame.Text(text))
                        }
                    }
                }
            }
        } finally {
            wsConnections -= this
        }
    }
}

users や connections に名前をつける

接続済みのコネクションに名前をつけるなど、何らかの情報を付与したくなることがあります。 下記のように、 WebSocketSession となにかを一緒に情報を保持することができます。

class ChatClient(val session: DefaultWebSocketSession) {
    companion object { var lastId = AtomicInteger(0) }
    val id = lastId.getAndIncrement()
    val name = "user$id"
}

routing {
    val clients = Collections.synchronizedSet(LinkedHashSet<ChatClient>())
    
    webSocket("/chat") { // this: DefaultWebSocketSession
        val client = ChatClient(this)
        clients += client
        try {
            while (true) {
                val frame = incoming.receive()
                when (frame) {
                    is Frame.Text -> {
                        val text = frame.readText()
                        // Iterate over all the connections
                        val textToSend = "${client.name} said: $text"
                        for (other in clients.toList()) {
                            other.session.outgoing.send(Frame.Text(textToSend))
                        }
                    }
                }
            }
        } finally {
            clients -= client
        }
    }
}

発展課題

クライアントの作成

作成したエンドポイントに接続する JavaScript クライアントを作成し、Ktor を用いて提供しましょう。

JSON

kotlinx.serialization を用いて Value Object の送受信をしましょう。