NSEEMの登壇報告と技術補足

今日開催されたLT会、NSEEMに参加してきました。

こちらのイベントです。

発表内容

Kotlinを使ってMinecraftサーバーをWebから管理するアプリケーションを作ろうとしたけど、失敗した話です。

本当は完成させたかったですが、時間があまりにも足りませんでした。

内容補足

今回発表したプロジェクトのソースコードなどを補足説明します。

作成中に得た知見や技術などを書き込んでおきます。かなり躓いたので忘れないためでもあります。

リポジトリの一覧です。

名前 リポジトリ
バックエンド https://github.com/rokuosan/mcc-backend
フロントエンド https://github.com/rokuosan/mcc-frontend
プラグイン https://github.com/rokuosan/mcc-plugin

バックエンド

依存関係

バックエンドには以下のパッケージを使用しています。

パッケージ名 説明
Spring boot JavaのWebフレームワーク
Kotlin Serialization Kotlin のシリアライザー

Spring bootにはそれ以外のモジュールがいろいろ入ってます。

エンティティ

他プロジェクトとのコピペ互換性のためにKotlin Serialization を利用してシリアライズしています。

サンプルとしてPlayer.ktを紹介します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import kotlinx.serialization.SerialName
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import org.jetbrains.annotations.NotNull
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.Table

@kotlinx.serialization.Serializable
@Entity
@Table(name="PLAYERS")
data class Player(
    // UUID
    @Id
    @NotNull
    @Column(name="uuid")
    @SerialName("uuid")
    var uuid: String = "",

    // 名前
    @NotNull
    @SerialName("name")
    @Column(name="name")
    var name: String = "",

    // 表示名
    @SerialName("display_name")
    @Column(name="display_name")
    var displayName: String? = null,

    // 管理者フラグ
    @SerialName("admin_flag")
    @Column(name="admin_flag")
    var isAdmin: Boolean? = null,

    // 接続状況
    @SerialName("status")
    @Column(name="status")
    var status: String? = null
){
    fun parse(body: String): Player? {
        return Json.decodeFromString(body)
    }
}

@Repository
interface PlayerRepository: JpaRepository<Player, String>

@Service
class PlayerService(private val playerRepository: PlayerRepository){
    fun findAll(): List<Player> = playerRepository.findAll()
    fun getByUUID(uuid: String): Player = playerRepository.getById(uuid)
    fun save(player: Player) = playerRepository.save(player)
    fun delete(player: Player) = playerRepository.delete(player)
    fun deleteByUUId(uuid: String) = playerRepository.deleteById(uuid)
}

enum class PlayerConnection(val status: String) {
    ONLINE("ONLINE"),
    OFFLINE("OFFLINE"),
    AFK("AFK");
}

@kotlinx.serialization.Serializable アノテーション(@Serializable)をつけたクラスはシリアライズ可能クラスとして認識されます。

また、JSONでの名前とKotlinで使用する名前が異なる場合は@SerialName() をつけることでマッピングすることができます。

そのほかのアノテーションはすべてSpring Bootのものです。

コントローラ

LTではめちゃくちゃ省略されたコントローラです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.RequestParam
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@RestController
class ServerController{

    // Server Orders
    val orderList = mutableListOf<Order>()

    // Order List
    @GetMapping("/api/server/order")
    @ResponseBody
    fun getOrder(
        @RequestParam(required = false) done: Boolean=false,
        @RequestParam(required = false) cancel: Boolean=false,
        @RequestParam(required = false) all: Boolean=true
    ): String{

        // 判定してからreturn してる
        return if(done){
            Json.encodeToString(orderList.filter { it.isDone==true })
        }else if(cancel){
            Json.encodeToString(orderList.filter { it.isCanceled==true })
        }else{
            Json.encodeToString(orderList)
        }
    }
}

Kotlinではifは文としても使えますが、式としても使用可能なため、演算ののちに代入することができます。

また、if以外にもwhen(Javaにおけるswitch)、tryも式として扱うことができます。

フロントエンド

パッケージ

パッケージ名 バージョン 説明
Kotlinx Coroutine core js 1.6.0 Kotlin/JSでCoroutineを使用するためのパッケージ
Ktor client core 1.6.7 Kotlinで非同期通信するパッケージ(諸悪の根源)
Ktor client Serialization 1.6.7 KtorでKotlin Serializationを使うモジュール
Ktor client js 1.6.7 KtorをKotlin/JSで動かすためのモジュール
Kotlin React 17.0.2-pre.312 Kotlin/JS のReactラッパー
Kotlin React 17.0.2-pre.312 Kotlin/JS のReactDOMラッパー
Kotlin React Router DOM 6.2.1-pre.312 Kotlin/JS のReactRouterラッパー
Kotlin React CSS 17.0.2-pre.312 Kotlin/JS のReact CSSラッパー
Kotlin React 17.0.2-pre.312 Kotlin/JS のReactラッパー
Kotlin MUI 5.5.0-pre.314 Kotlin/JS のMUIラッパー
Kotlin MUI Icons 5.5.0-pre.314 Kotlin/JS のMUI Iconsラッパー
Kotlin Emotion 11.7.1-pre.314 Kotlin/JS のEmotionラッパー
Kotlin Serialization 1.3.2 Kotlin Serialization

Ktorが時間のほとんどを持って行ったと言っても過言ではない。絶許。

エンティティ

エンティティのほとんどはコピペで終わります。

同じパッケージ入れてると便利です。

こんな感じでいらないアノテーションはキレイさっぱり無くなってます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import kotlinx.serialization.SerialName

@kotlinx.serialization.Serializable
data class Player(
    // UUID
    @SerialName("uuid")
    val uuid: String = "",

    // プレイヤー名
    @SerialName("name")
    var name: String = "",

    // 表示名
    @SerialName("display_name")
    var displayName: String? = null,

    // 管理者フラグ
    @SerialName("admin_flag")
    var isAdmin: Boolean? = null,

    // 接続状況
    @SerialName("status")
    var status: String? = null
)

MUI

MUIを使用したUI作成をしています。

使ってみるとこんな感じになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import com.deviseworks.mcc.frontend.common.Area
import csstype.FontWeight
import csstype.integer
import csstype.number
import kotlinx.browser.window
import mui.icons.material.GitHub
import mui.material.*
import mui.system.sx
import react.FC
import react.Props
import react.ReactNode
import react.dom.aria.ariaLabel
import react.dom.html.ReactHTML

val Appbar = FC<Props>{
    AppBar{
        position = AppBarPosition.fixed

        sx{
            gridArea=Area.Appbar
            zIndex=integer(1500)
        }

        Toolbar{
            Typography{
                variant = "h6"
                component = ReactHTML.div

                sx{
                    flexGrow=number(1.0)
                    fontWeight = FontWeight.bold
                }

                onClick = {
                    window.location.href=window.location.origin
                }

                +"Minecraft Console Controller"
            }

            Tooltip{
                title=ReactNode("View on GitHub")

                IconButton{
                    ariaLabel = "source"
                    size = Size.large

                    onClick = {
                        window.location.href = "https://github.com/rokuosan/mcc-frontend/"
                    }

                    GitHub()
                }
            }
        }
    }
}

閉じタグとかが綺麗にブラケットになっていることがわかるかと思います。

非同期通信

ここが問題の箇所です。

Kotlin/JSで非同期通信するためにfetchAPIを使用しました。

これがそのソースコードです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import com.deviseworks.mcc.frontend.common.API
import com.deviseworks.mcc.frontend.entity.Player
import kotlinx.browser.window
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.await
import kotlinx.coroutines.launch
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import react.FC
import react.Props
import react.useEffectOnce
import react.useState

val PlayerListTab = FC<Props> {
    // stateを用いた管理(重要)
    var playerList: List<Player> by useState(emptyList())

    // 一度のみ実行される(重要)
    useEffectOnce {
        mainScope.launch {
            playerList = fetchPlayerList()
        }
    }

    // 回して表示
    playerList.forEachIndexed{ _, p ->
        PlayerListItem{
            data = p
        }
    }
}

// スコープ初期化(重要)
private val mainScope = MainScope()

// 非同期実行される関数(重要)
suspend fun fetchPlayerList(): List<Player>{
    val response = window
        .fetch(
            API.PLAYER_LIST+"?offline=true"
        )
        .await()
        .text()
        .await()

    return Json.decodeFromString(ListSerializer(Player.serializer()), response)
}

もう全部重要です。

これ以外の方法も知りたいので、知っている方はぜひコメントしてください(懇願)

プラグイン

パッケージ

パッケージ名 説明
Kotlin Serialization おなじみ
OkHttp3 HTTP通信するパッケージ
Json in Java JSONをJavaで使うためのパッケージ

OkHttp3もJson in JavaもそれぞれKtorとSerializationで出来るんですが、これを作った時はSerializationでリストをデシリアライズする方法がわからなかったため採用しました。

OkHttp3も同様で、Ktorはクライアントも対応していますが作成時はサーバーのみのパッケージだと思い込んでしました。

エンティティ

さっきみた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Serializable
data class Player(
    @SerialName("uuid")
    var uuid: String,

    @SerialName("name")
    var name: String,

    @SerialName("display_name")
    var displayName: String? = "",

    @SerialName("admin_flag")
    var isAdmin: Boolean = false,

    @SerialName("status")
    var status: String = PlayerConnection.OFFLINE.status
)

エントリーポイント

JavaPluginを継承したクラスを勝手にエントリーポイントとして読み込んでくれます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Main: JavaPlugin() {
    override fun onEnable() {
        // EventListenerを登録
        Bukkit.getServer().pluginManager.registerEvents(PlayerJoin(), this)
        Bukkit.getServer().pluginManager.registerEvents(PlayerQuit(), this)

        // スケジューラを登録
        MemorySchedule().runTaskTimerAsynchronously(this, 0L, 40L) // 2秒に一回実行
        OrderSchedule().runTaskTimer(this, 0L, 20L) // 1秒に一回実行
    }
}

イベントリスナ

Listenerインターフェースを実装することでリスナクラスを作成できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class PlayerJoin: Listener {

    @EventHandler(priority = EventPriority.HIGHEST)
    fun postJoinedPlayer(event: PlayerJoinEvent){
        // プレイヤー取得
        val player = event.player

        // プレイヤーをシリアライズ
        val body = Json.encodeToString(
            Player(
                uuid = player.uniqueId.toString(),
                name = player.name,
                displayName = player.displayName,
                isAdmin = player.hasPermission("admin.staff"),
                status = PlayerConnection.ONLINE.status
            )
        )

        // POST
        Request().postJSON("${API_URL}/api/player/join", body)
    }
}

その後、作成したクラスをさっき作成したエントリーポイントで登録するだけで使える。

スケジューラ

スケジューラはBukkitRunnableを継承することで作成できる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class MemorySchedule: BukkitRunnable() {
    // Server VM
    private val runtime: Runtime = Runtime.getRuntime()

    override fun run() {
        // Get memory size
        val totalKB = runtime.totalMemory() / 1024
        val maxKB = runtime.maxMemory() / 1024
        val freeKB = runtime.freeMemory() / 1024
        val usedKB = totalKB - freeKB
        val ratio: Double = (usedKB / totalKB.toDouble()) * 100

        // Serialize
        val memory = Memory(totalKB, maxKB, freeKB, usedKB, ratio)

        // POST
        val json = Json.encodeToString(Memory.serializer(), memory)

        Request().postJSON("$API_URL/api/server/memory", json)
    }
}

あとは、好きなイベントでこれを呼び出す。今回はエントリーポイントにある起動時に呼び出すようになっている。

plugin.yml

スペルミスして一時間奪いやがったこいつ。

1
2
3
4
5
6
7
# Information
name: MinecraftConsoleController
main: com.deviseworks.mcc.Main
version: 1.0.0
api-version: 1.18
prefix: MCC
author: rokuosan

適当に書いてください

おわり

初めてのLTだったのでとても緊張しましたが、とても楽しかったです。

また機会があれば登壇したいですね。

今度はしっかりといい感じのやつを発表できるように頑張ります。