Kotlin,SpringBootでWebSocket通信を用いた簡易チャットアプリを作る

## はじめに
仕事でWebSocket通信を用いたアプリケーションの要件定義をする機会があり、実際に開発した経験がなかったため理解を深めるために簡易アプリを作ってみました。
やっていることは[こちらの記事](https://qiita.com/omix222/items/a6bd8b896ec48a727cb4)と同じですが、言語がKotlinだったり、他にいくつか変更点や解説を加えているので記事を書いてみることにしました。

## 実装

### ディレクトリ構成
![スクリーンショット 2024-06-23 22.33.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1994221/94287bd3-29fb-2551-4b13-df0bc961d136.png)

:::note warn
SpringBootはデフォルトで`src/main/resources/static`ディレクトリが静的なリソースの格納場所として設定されているため、`index.html`は`resources/static/`直下に置いてください。
:::

### プロジェクトの作成
![スクリーンショット 2024-06-23 21.45.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1994221/77a81b4a-21f1-75ed-48da-f278f341a462.png)
[SpringInitializr](https://start.spring.io/)でプロジェクトを作成。
私はいつもGradleで開発を行っているのでこれにしましたが、お好みで大丈夫です。
また、DependenciesにWebSocketを追加しています。

### ライブラリの依存関係を追加
`build.gradle.kts`にライブラリの依存関係を追加します。
```build.gradle.kts
dependencies {
	implementation("org.springframework.boot:spring-boot-starter-websocket")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
	testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
	testRuntimeOnly("org.junit.platform:junit-platform-launcher")
	implementation("org.webjars:webjars-locator-core") //追加
	implementation("org.webjars:sockjs-client:1.0.2") //追加
	implementation("org.webjars:stomp-websocket:2.3.3") //追加
	implementation("org.webjars:bootstrap:3.3.7") //追加
	implementation("org.webjars:jquery:3.1.1-1") //追加
}
```
[WebJars](https://www.webjars.org/)はBootstrapやjQueryなどのライブラリを、Java(Kotlin)の依存関係として管理することができ、静的リソースとしてサーバーから提供することも可能になるというものです。
(WebSocket通信とは関係ないので、これ以上の説明は割愛。)

### Modelを定義

下記は**クライアントからのリクエスト**のモデルになります。
```HelloMessage.kt
package testwebsocket.Model

data class HelloMessage(
        var name: String,
        var message: String
)
```

下記は**サーバーからのレスポンス**のモデルになります。
```Greeting.kt
package testwebsocket.Model

data class Greeting(
        var content: String
)
```

### Controllerを定義
WebSocket通信を用いたメッセージの送受信を行うクラスになります。
```GreetingController.kt
package testwebsocket.Controller

import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.SendTo
import org.springframework.stereotype.Controller
import org.springframework.web.util.HtmlUtils
import testwebsocket.Model.Greeting
import testwebsocket.Model.HelloMessage

@Controller
class GreetingController {

    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    fun greeting(message: HelloMessage): Greeting {
        Thread.sleep(1000) //処理を1秒間待機
        return Greeting(
                HtmlUtils.htmlEscape(message.name)
                        + " : "
                        + HtmlUtils.htmlEscape(message.message)
        )
    }
}
```
WebSocket通信のアノテーションである[MessageMapping](https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/messaging/handler/annotation/MessageMapping.html)と[SendTo](https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/messaging/handler/annotation/SendTo.html)について簡単に解説します。

* `@MessageMapping("/hello")`というアノテーションは、クライアントから`/hello`というエンドポイントにメッセージが送られてきたときに、`greeting`メソッドを実行します

* `@SendTo("/topic/greetings")`というアノテーションは、`greeting`メソッドの処理が終わった後、その結果を`/topic/greetings`というエンドポイントに送信します


`greeting`メソッドの処理内容についても簡単に解説します。
* 引数として`HelloMessage`型の`message`を受け取り、その中の`name`と`message`を取り出しています
* `HtmlUtils.htmlEscape`を用いてエスケープ処理を行った後にそれらを結合して、新たな`Greeting`オブジェクトを作成して返却しています

### Configクラスを定義
WebSocket通信の設定クラスです。
Springフレームワークで使用されるWebSocketとSTOMPプロトコル[^1]を設定しています。

```WebSocketConfig.kt
package testwebsocket.Config

import org.springframework.context.annotation.Configuration
import org.springframework.messaging.simp.config.MessageBrokerRegistry
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker
import org.springframework.web.socket.config.annotation.StompEndpointRegistry
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer

@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {

    override fun configureMessageBroker(config: MessageBrokerRegistry) {
        config.enableSimpleBroker("/topic")
        config.setApplicationDestinationPrefixes("/app")
    }

    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/gs-guide-websocket").withSockJS()
    }
}
```

* [@EnableWebSocketMessageBroker](https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/web/socket/config/annotation/EnableWebSocketMessageBroker.html)はSpringフレームワークにおいてWebSocketメッセージブローカーを有効にするアノテーションであり、WebSocketを用いたメッセージングの設定や制御が可能になります

* `configureMessageBroker`メソッドはメッセージブローカー[^2]の設定を行っています
    * `config.enableSimpleBroker("/topic")`は、`/topic`というエンドポイントを持つシンプルなメッセージブローカーを有効にします。これにより、クライアントは`/topic`を購読[^3]してメッセージを受信することができます
    * `config.setApplicationDestinationPrefixes("/app")`は、サーバーへのメッセージの送信先のプレフィックスを"/app"に設定します

* `registerStompEndpoints`メソッドは、STOMPプロトコル[^1]のエンドポイントを登録します
    * `registry.addEndpoint("/gs-guide-websocket").withSockJS()`は、`/gs-guide-websocket`というエンドポイントを追加し、SockJSを使用することを示しています。SockJSは、WebSocketが利用できない場合でも、可能な限りWebSocketと同等の機能を提供するためのJavaScriptライブラリです


[^1]:STOMPプロトコルとはシンプルなテキストベースのメッセージングプロトコルのことで、主にクライアントとサーバー間のメッセージ交換を行うために使用される。WebSocketと組み合わせて使用されることが多い。

[^2]: メッセージブローカーとは、異なるプロセスやアプリケーション間でメッセージをやり取りする役割を担うソフトウェアのこと。メッセージの送信者からメッセージを受け取り、それを受信者に配信する。
これにより、大量のメッセージ通信を効率的に処理したり、システム間の通信をスムーズにしたりすることが可能になる。

[^3]:サーバーから送られてくるメッセージを受け取るための待機状態に入るという意味。

### フロントエンド(HTML,JS)の実装

```index.html
<!DOCTYPE html>
<html>
<head>
    <title>WebSocket Chat</title>
    <link href="/webjars/bootstrap/css/bootstrap.min.css" rel="stylesheet">
    <link href="/main.css" rel="stylesheet">
    <script src="/webjars/jquery/jquery.min.js"></script>
    <script src="/webjars/sockjs-client/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/stomp.min.js"></script>
    <script src="/app.js"></script>
</head>
<body>
<noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being
    enabled. Please enable
    Javascript and reload this page!</h2></noscript>
<div id="main-content" class="container">
    <div class="row">
        <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <label for="connect">WebSocket connection:</label>
                    <button id="connect" class="btn btn-default" type="submit">Connect</button>
                    <button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect
                    </button>
                </div>
            </form>
        </div>
        <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <label for="name">Name</label>
                    <input type="text" id="name" class="form-control" placeholder="Your name here...">
                </div>
                <div class="form-group">
                    <label for="name">Message</label>
                    <input type="text" id="message" class="form-control" placeholder="Hello">
                </div>
                <button id="send" class="btn btn-default" type="submit">Send</button>
            </form>
        </div>
    </div>
    <div class="row">
        <div class="col-md-12">
            <table id="conversation" class="table table-striped">
                <thead>
                <tr>
                    <th>Messages</th>
                </tr>
                </thead>
                <tbody id="greetings">
                </tbody>
            </table>
        </div>
    </div>
</div>
</body>
</html>

```


```app.js
let stompClient = null;

const setConnected = (connected) => {
    $("#connect").prop("disabled", connected);
    $("#disconnect").prop("disabled", !connected);
    if (connected) {
        $("#conversation").show();
    } else {
        $("#conversation").hide();
    }
    $("#greetings").html("");
};

const connect = () => {
    const socket = new SockJS('/gs-guide-websocket');
    stompClient = Stomp.over(socket);
    stompClient.connect({}, (frame) => {
        setConnected(true);
        console.log('Connected: ' + frame);
        stompClient.subscribe('/topic/greetings', (greeting) => {
            showGreeting(JSON.parse(greeting.body).content);
        });
    });
};

const disconnect = () => {
    if (stompClient !== null) {
        stompClient.disconnect();
    }
    setConnected(false);
    console.log("Disconnected");
};

const sendMessage = () => {
    stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val(),'message': $("#message").val()}));
    $("#message").val('');
};

const showGreeting = (message) => {
    $("#greetings").append("<tr><td>" + message + "</td></tr>");
};

$(function () {
    $("form").on('submit', function (e) {
        e.preventDefault();
    });
    $("#connect").click(() => connect());
    $("#disconnect").click(() => disconnect());
    $("#send").click(() => sendMessage());
});

setTimeout(() => connect(), 3000);
```

* `stompClient`という変数は、WebSocketの接続を管理するためのものです
* `connect`メソッドでは、サーバーとのWebSocket接続を確立し、サーバーからのメッセージを購読[^3]します
* `disconnect`メソッドでは、WebSocketの接続を切断します
* `sendMessage`メソッドでは、サーバーにメッセージを送信します。送信内容は、`name`と`message`の入力フィールドから取得します
* ページが読み込まれた3秒後に`connect`メソッドを呼び出して、WebSocketの接続を確立します

### 見た目を整える

```main.css
body {
    background-color: #f5f5f5;
}

#main-content {
    max-width: 940px;
    padding: 2em 3em;
    margin: 0 auto 20px;
    background-color: #ffffff;
    border: 1px solid #e5e5e5;
    -webkit-border-radius: 5px;
    -moz-border-radius: 5px;
    border-radius: 5px;
}

```

## アプリを立ち上げる

プロジェクト直下でbootRunを実行して、[localhost:8080](http://localhost:8080/)にアクセスするとページを閲覧できます。

```
$ ./gradlew bootRun
```

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1994221/99a615da-86be-df6a-c380-e03c723231ca.gif)

### Tips

久々にプライベートPCでIntelliJを使用したら、JAVAのバージョン不一致のエラーが出ました。
プロジェクト作成時に選んだバージョンがJAVA17でしたが、IntelliJのJVMがJAVA11を使用していたようです。
```
Caused by: org.gradle.internal.component.resolution.failure.exception.VariantSelectionException: Dependency requires at least JVM runtime version 17. This build uses a Java 11 JVM.
```

なので、IntelliJの設定を開いて、JAVA17のGradleJVMをインストール。
私はM1Macを使用しているのですが、AmazonCorretto17を選択しました。
![スクリーンショット 2024-06-23 23.48.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1994221/ddda4d76-3918-7eae-ea8f-e0d2af6117ef.png)

## 参考文献
- [公式ドキュメント](https://spring.io/guides/gs/messaging-stomp-websocket)
- [記事を書く上で参考にさせていただいたブログ記事](https://qiita.com/omix222/items/a6bd8b896ec48a727cb4)