今日開催されたLT会、NSEEMに参加してきました。
こちら のイベントです。
発表内容
Kotlinを使ってMinecraftサーバーをWebから管理するアプリケーションを作ろうとしたけど、失敗した話です。
本当は完成させたかったですが、時間があまりにも足りませんでした。
内容補足
今回発表したプロジェクトのソースコードなどを補足説明します。
作成中に得た知見や技術などを書き込んでおきます。かなり躓いたので忘れないためでもあります。
リポジトリの一覧です。
バックエンド
依存関係
バックエンドには以下のパッケージを使用しています。
パッケージ名
説明
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だったのでとても緊張しましたが、とても楽しかったです。
また機会があれば登壇したいですね。
今度はしっかりといい感じのやつを発表できるように頑張ります。