LunaChat の後継 "LunaticChat"
2013年 ucchyocean 氏が公開した Spigot 向けチャットプラグインである LunaChat の後継 そして最新版で動くように一から書き直したプラグインとして "LunaticChat" を開発した話
LunaChat の問題点
LunaChat には以下のような問題点が存在している.
- 最新版の Spigot に非対応
- Fork すれば動くし, GitHub 上には各マルチサーバの管理者が Fork しているビルドが転がっているが...
- Multiverse-Core / MCMMO など他プラグインの API への依存が激しい
- かな変換の結果をキャッシュしていないためにチャットイベントが発火する度にかな変換のために Google IME API へリクエストを送信している
これらのメンテが終了しているプラグインを長く使い続けるのはあまり喜ばれたものではないので,今回最新版で動き,長い間メンテし続けられて,上記の問題点が解決するために LunaticChat の開発を始めた.
言語を Kotlin にした理由
LunaChat が採用している言語は Java だが,まず私はここで Java を切り捨てて Kotlin で書くことにした.
理由として
- Null 安全の存在
- data class, プロパティ構文,拡張関数などの簡潔な記法の存在
- Coroutines による非同期処理
がある.
Null 安全の存在
Minecraft におけるプラグイン開発では,null になり得る値を頻繁に扱うことが多い.
これは私の思想でもあるが,基本的に Java の NullPointerException が嫌いで,実行するまでコンパイラが null であるかを保証してくれないのがストレスで仕方がない.[1]
val player: Player? = server.getPlayer(uuid)
player?.sendMessage("Hello")
例えば LunaticChat でのチャンネルチャット実装例で見ると
// チャンネル取得時の安全な処理
fun getPlayerChannel(uuid: UUID): Channel? {
return channelCache[uuid]
}
// 使用側
val channel = getPlayerChannel(player.uniqueId)
channel?.broadcast(message) // nullなら何もしない
// エルビス演算子でデフォルト値
val channelName = channel?.name ?: "GLOBAL"
// let でスコープを限定
channel?.let {
it.addMember(newPlayer)
it.broadcast("${newPlayer.name} joined!")
}
Kotlin ならこれで済むが, Java だと channel に対していちいち if 文を使って null の所在チェックをする必要がある.ただただ無駄である.[2]
data class, プロパティ構文,拡張関数などの簡潔な記法の存在
Kotlin には data class や拡張関数という便利な言語APIが多数存在する.
例えば拡張関数.これは既存のクラスに対してメソッドを新たに増やすことができるという機能なのだが,それが Minecraft のプラグイン開発においてはかなり優秀.
LunaticChat では /tell や /reply, チャンネルチャットの受信時に通知音を再生する機能がある.
ここで拡張関数を使い,Paper API の Player に対して playDirectMessageNotification() というメソッドを生やす.
すると Paper API にある Player クラスで該当のメソッドが扱えるようになる.メソッドを呼び出すだけで本来複数行にまたがってしまうような再生処理がこれだけで完結してしまう.
また,Kotlin の data class を使えば,Java で数十行必要だったコードが1行で済んでしまう.
data class ChannelData(
val name: String,
val members: Set<UUID>,
val description: String?
)
これだけで equals,hashCode,toString,copy メソッドが自動生成される.
Coroutines による非同期処理
Paper では非同期処理が頻繁に発生する.LunaticChat では,Google IME API の呼び出しなど.Java のコールバックや CompletableFuture は,ネストが深くなるがゆえに,可読性が著しく低下してしまうが,Kotlin Coroutines の存在でこれらも簡潔に書けている.
問題点にどう対応したか
Paper / Velocity のみのサポート
LunaChat は一応最終バージョンでは 1.16.5 までの Spigot で動作することが保証されている.チャットに関する仕様についてはそこまで破壊的変更がないからか[3],一応 1.18.2 までの動作はこの目で確認できている.ただ, 後述する依存プラグインの存在などで現最新版の Spigot では動作しないようだ.
また,先述した非同期処理についても Spigot には非同期イベント処理のサポートがない. LunaticChat や LunaChat のようなチャットプラグインとは相性が最悪だと考えている.
そのため, LunaticChat では Spigot / Bungeecord のサポートを完全に打ち切った[4].
Paper はサーバーパフォーマンスに多くの改善を加えていることや,チャットプラグインは頻繁にイベントを受け取り,メッセージを送信するため,Paper の最適化の恩恵を大きく受けるからというのも採用の理由である.
- 非同期イベント処理のサポート
- より効率的なプレイヤー検索 API
- 改善されたスケジューラ
「過去との互換性」よりも「未来への投資」を選択した.
依存プラグインの徹底排除
LunaChat は以下のプラグインに API レベルで依存していた.
- Multiverse-Core (ワールド管理)
- MCMMO (スキルレベル表示)
- LuckPerms / PermissionsEx (権限管理)
- Vault (経済・権限の抽象化層)
- Dynmap (マップ連携)
これらの依存プラグインの利点としては 外部プラグインの機能をフル活用 できることだが,その代償に依存プラグインのバグや更新遅延が動作に影響してしまう.
実際 LunaChat では Multiverse-Core v5 の依存関係で壊れてしまっている.
LunaticChat ではこれらの依存プラグインを徹底排除することにした.
もちろん権限管理では, LuckPerms などの別プラグインに頼らないといけないが, LunaticChat 自身は完全単体で動作するようになってる.
Unix哲学 「Do One Thing Well」(一つのことをうまくやる)[5] ではないが,競合リスクを徹底排除して,サーバ管理者の負担を減らす動きを取ることにしている.
キャッシュシステムの導入
LunaticChat では LunaChat と同様 日本語のテキストをローマ字に変換する機能を提供している.
LunaticChat はプレイヤーからのテキストを以下の手順で変換を行っている.
- プレイヤーからのテキストがローマ字で構成されているかを検証する
- メモリキャッシュから該当のフレーズがあるかどうかを調べる
2-a. ここで該当フレーズがヒットした場合はそれをサーバーに返す
2-b. メモリキャッシュに存在していない場合は,ローマ字からひらがなに変換する - 変換した文字列を Google IME API へ送り,人が読める形に変換する
- メモリキャッシュに保存し,それをサーバーに返す
┌───────────────────────────────────────────────────┐
│ User Input │
│ (Romanji Text) │
└─────────────────────┬─────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────┐
│ RomanjiConverter │
│ ┌───────────────────────────────────────────┐ │
│ │ 1. Check Memory Cache │ │
│ │ └─→ Hit: Return immediately │ │
│ │ │ │
│ │ 2. Call Google IME API │ │
│ │ │ │
│ │ 3. Store in Memory Cache │ │
│ │ │ │
│ │ 4. Queue for Disk Save (async) │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────┬─────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────┐
│ Converted Text │
│ (Japanese Text) │
└───────────────────────────────────────────────────┘
LunaChat はチャットイベントが発火する度に Google IME API に対するリクエストを投げていた.
もちろん効率も良くないが,行儀も良くない.そこで LunaticChat ではプレイヤーのチャットを単語ごとにキャッシュするようになり,毎回 API を呼び出す必要なく,より効率的に変換を行えるようにしている.[6]
例えば,以下のような長文のチャットがあるとする.
konnichiwa minna ohayou gozaimasu kyou wa totemo ii tenki desu ne bokutachi wa issho ni asobi ni ikimashou kono atarashii game wo tameshite mitai to omoimasu sore wa totemo omoshiroi to kiite imasu arigatou gozaimasu mata ne
LunaticChat はこの文章を一度に変換するのではなく,単語ごとに分割してキャッシュを行うようにしている.
二層キャッシュシステム
LunaticChat では二層のキャッシュシステムを採用している.
第1層:メモリキャッシュ
private val conversionCache = ConcurrentHashMap<String, CachedConversion>()
data class CachedConversion(
val result: String,
val timestamp: Long = System.currentTimeMillis()
)
suspend fun convert(romaji: String): String {
// まずメモリキャッシュを確認
conversionCache[romaji]?.let { cached ->
if (!cached.isExpired()) {
return cached.result // 即座に返す (< 1ms)
}
}
// キャッシュミスの場合のみ第2層へ
return fetchAndCache(romaji)
}
このメリットとして,
- ハッシュマップの検索のみなので超高速である
-
ConcurrentHashMapの採用によりスレッドセーフ - サーバーの再起動で自動的にクリアされる
第2層:永続キャッシュ
private val persistentCachePath = dataFolder.resolve("romaji_cache.json")
data class PersistentCache(
val entries: MutableMap<String, String> = mutableMapOf(),
val lastUpdated: Long = System.currentTimeMillis()
)
suspend fun fetchAndCache(romaji: String): String {
// 永続キャッシュを確認
val persistent = loadPersistentCache()
persistent.entries[romaji]?.let {
// メモリキャッシュにも追加
conversionCache[romaji] = CachedConversion(it)
return it
}
// 両方のキャッシュにミス → API 呼び出し
val result = callGoogleIME(romaji)
// 両方のキャッシュに保存
conversionCache[romaji] = CachedConversion(result)
persistent.entries[romaji] = result
savePersistentCache(persistent)
return result
}
これにより,よく使われる変換は永続的に高速化が施されるようになっている.
ローマ字から かな への変換
Google IME API への呼び出し前に行われるローマ字から かな への変換についても話そうと思う.
LunaticChat では基本の五十音などを一対一で変換するルールを決めている.
一般的な HashMap ベースの実装ではなく, Trie を使用している.
sealed class TrieNode {
data class Leaf(val value: String) : TrieNode()
data class Branch(
val children: Map<Char, TrieNode>,
val value: String? = null
) : TrieNode()
}
これにより,
- 最長一致検索が高速:
O(m)の時間計算量 (m= 検索文字列の長さ) - メモリ効率:共通プレフィックスを共有
- 曖昧性の解決:長いパターンを優先的にマッチできる
を実現している.
また,Kotlin の sealed class を使うことで,コンパイル時に全ケースを網羅することを保証している.
when (node) {
is TrieNode.Branch -> { /* 分岐ノード処理 */ }
is TrieNode.Leaf -> { /* 葉ノード処理 */ }
// 他のケースはコンパイルエラー
}
これにより
- 基本五十音
- 濁音・半濁音
- 拗音
- 小書き文字
-
tsu →つ,chi→ち,shi→し` のような特殊パターン - 促音の自動処理
- 撥音
に対応できている.
最後に
LunaticChat は前述した Spigot のサポート切り捨てなどもある関係上,決して LunaChat の完全代用になるとは考えていない.
ただ,もう親がいなくなってしまって放置されている LunaChat のメンテが負担に感じているサーバ管理者・運営のもう1つの選択肢になれば幸いに感じている.
-
まあ昔の言語に何を求めるんだって話ではある ↩︎
-
最新の Java には Optional とかあるので,これほど無駄とは言えないかもしれない ↩︎
-
1.19 でチャット署名が必須になったので,これで LunaChat が壊れると思ったが,そうでもなかった ↩︎
-
OSS なので Spigot でほしかったら 「Fork してください」 が答えです ↩︎
-
https://en.wikipedia.org/wiki/Unix_philosophy#Do_One_Thing_and_Do_It_Well ↩︎
-
ちなみに LunaChat にもキャッシュはあるにはある. HashMap なので結局アレなんだけど... ↩︎
Discussion