Spring Boot+Thymeleaf+DockerでMiRmの基幹システムを開発した

普通科高校の2年生をやっている@itsu_devです。
今回は[MiRm](https://mirm.jp)（Minecraft 無料マルチプレイサーバーホスティングサービス）の基幹システムの入れ替えに伴い、@haniokasai氏と新しくシステムを開発しなおしたのでそれを紹介します。

# はじめに
## そもそもMiRmとは？
Minecraftを友達としようにも、

- サーバーが必要 
- ソフトの導入・設定が面倒 
- 環境構築が難しい 
- 維持費がかかる 

といった欠点があり、なかなか敷居の高いものでした。
そこでこれらすべてを解決すべく始まったのがMiRm Projectです。

## MiRmにはどんな機能があるの？
- サーバーソフトの標準出力のリアルタイム表示
- サーバーソフトへの標準入力
- FTP経由でのファイル操作
- ワンタッチでのサーバー起動・停止
- Markdown記法によるサーバーリストへの紹介文掲載

こういった機能をメインに、ほかにも様々な機能をユーザーに提供しています。

# 技術的な内容
## 使用した技術
タイトルにもあるように、Spring BootベースのWebアプリケーションとしてすべてを完結させています。「スマホだけでできる」がウリなので、もちろんPWAにも対応しています。
各ユーザーが所有するサーバーは一つのDockerコンテナで完結しており、ファイル操作やサーバーソフトの動作もすべてこの中で行っています。

他の主なものとしては

- Thymeleaf（htmlテンプレートエンジン）
- MySQL
- [Material Design Bootstrap](https://mdbootstrap.com)（Bootstrapのマテリアルデザインライブラリ）
- jQuery
- [SimpleMDE](https://github.com/sparksuite/simplemde-markdown-editor)（埋め込みMarkdownエディタ）
- [darkmode.js](https://github.com/sparksuite/simplemde-markdown-editor)（ダークモードの実装）
- kotlin coroutine

といったところです。
全体を管理する基幹システムはkotlinで書かれています。

## 全体構成
![FlowChart_Qiita.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237903/f9ffa25e-6c06-2d90-dcc6-e5420917f651.png)

## Spring Boot
文献が豊富にあり、kotlinで扱うにはちょうどいいと思ったので採用しました。

### csrfを有効にした時の403エラー
これはかなりハマった点なのですが、Spring security標準のcsrfを有効化した状態でREST Controllerで組んだAPIにpostすると403 Forbiddenで弾かれる問題が発生しました。(getだと問題はありませんでした。)

これに対する解決策としては

1. csrf認証自体を無効化する
2. postを受けるページに対するcsrf認証を無効化する

の二つが挙げられます。
(参考: https://qiita.com/xx2xyyy/items/385492431a95caa60d7a)

## Thymeleaf
Spring Bootとよく使われるhtmlテンプレートエンジンです。一度別件で使ったことがある程度でしたが、改めて使ってみることにしました。

```html
<span th:text=“${text}”></span>
```

```Java
@GetMapping(value = “example”)
public ModelAndView display(ModelAndView mv) {
    // 中略
    mv.addObject(“text”, “Hello, World!”);
    return mv;
}
```

上のようにhtmlにJava(kotlin)から変数を埋め込めるのですが、これがWebアプリを作る時に案外便利です。

## Docker
### 基幹システムとDockerコンテナとの接続
基本的には[Docker-Java](https://github.com/docker-java/docker-java)とJava標準のProcess経由でのコマンド実行の両方を使っています。

Minecraftのコマンドを標準入力に書き込むのに、普通に`docker attach CONTAINER`をProcess経由で実行して得たそれに対して行ってもよいのですが、それだとある問題が発生します。
それは、コンテナ内部のサーバーソフトがPID 1で動作していないことです。
この問題によってProcessで得られる標準入力に書き込んでもサーバーソフトに書きこまれない現象が発生しました。それを解決するために、コマンドを`/proc/PID/fd/0`に書き込むことにしました。

以下のコードでは、上記のPIDを得るためにdocker topコマンドの内容をパースしています。
※MiRmDockerClient#getCommandExceptList　サーバーソフトのプロセスを取得するために除外する文字列のリスト
※TopElementクラス　出力内容のオブジェクトクラス

```java
public static List<TopElement> parse(String serverId) {
        ArrayList<TopElement> elements = new ArrayList<>();
        LinkedList<String> outputs = new LinkedList<>();

        try {
            StringBuffer buf = new StringBuffer();
            buf.append("docker -H "+ DOCKERHOST + " top " + serverId + MiRmDockerClient.getPrefix());
            MiRmDockerClient.getCommandExceptList().forEach(str -> buf.append(" | grep -v \"" + str + "\""));

            ProcessBuilder pb = new ProcessBuilder("/bin/bash", "-c", buf.toString());
            Process process = pb.start();
            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
            String temp;
            while ((temp = reader.readLine()) != null) {
                outputs.add(temp);
            }
            reader.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        outputs.forEach(str -> {
            str = str.replaceAll("\\s+", " ");
            if (!str.startsWith("UID")) {
                String[] data = str.split(" ");

                StringBuffer buf = new StringBuffer();
                for (int i = 7; i < data.length; i++) {
                    buf.append(" " + data[i]);
                }

                elements.add(new TopElement(
                        data[0],
                        Long.parseLong(data[1]),
                        Long.parseLong(data[2]),
                        Integer.parseInt(data[3]),
                        data[4],
                        data[5],
                        data[6],
                        buf.toString().substring(1)
                ));
            }
        });

        return elements;
    }
```

## PWA (Progressive Web Application)
最近過熱してきている技術で、Webアプリをスマートフォンのネイティブアプリかのように動作させられる技術です。OS(Android/iOS)によって使える機能はまちまち(Androidの方が発展している)ですが、実用には十分耐えられるレベルです。

iOSでは以下のように動作します。
スプラッシュ
<img width="375" alt="905F8EF9-C33E-4B90-9192-E102AFA7B96A.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237903/d1ee3de2-7294-b672-413c-4a5312b89aa3.jpeg">

動作
<img width="375" alt="1EC78B27-3186-4482-9503-7B45EACAB3B1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237903/70aca04f-7eed-5c17-b0bc-2784dea556f4.png">

タスクビュー
<img width="375" alt="BB46FFEA-D5BF-4E6C-AAB7-1FC0F2B58750.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237903/b9be40ec-a570-b282-99cd-ff7b2388d80e.png">


## SimpleMDE
[超簡単にMarkdownエディタをWebページに実装可能な"SimpleMDE"](https://qiita.com/dojyorin/items/ba8847362a44bbdd3a7c)を参考にさせていただきました。ありがとうございます。

MiRmで[サーバーリスト](https://x.mirm.jp/list)を表示するのに、各サーバーに紹介文を書いてもらいたかったのですが、普通の文章だとみんな同じで目立たなくなってしまう。とはいえhtmlを敷居が高い...
と思った矢先、SimpleMDEというjsライブラリを見つけたのでこれを使用して簡単なMarkdownに対応させることにしました。

右下のLobiのアイコンはフォントを作成して表示しています。（参考：https://nelog.jp/feedly-web-iconic-font）

編集画面
![a.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237903/2c185df5-1e6a-1760-ed7a-a1dfa1f510f6.png)

実際の表示
![b.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237903/5226ba7e-bb4f-b78c-aa68-c3fd9450d686.png)

編集はMarkdownですが、表示はhtmlで行っています。この変換もSimpleMDEに標準でついており、簡単に使用することができます。

以下のコードでtextareaをエディタ化しています。
toolbarの部分でエディタ上部のツールバーをカスタマイズできます。

```html
<html>
<head>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
</head>

<body>
<label for="description">マークダウン記法が使えます。（htmlタグは使用不可・ボタンとして載っている記法のみ使用可能）</label>
<textarea class="form-control z-depth-1" id="description" name="description" rows="8" cols="40"
                          placeholder="xxサーバーへようこそ！" th:text="${description}">
</textarea>

<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
<script>
var simplemde = new SimpleMDE({
            element: document.getElementById("description"),
            forceSync: true,
            spellChecker: false,
            toolbar: ["bold", "italic", "strikethrough", "heading", "|", "quote", "unordered-list", "ordered-list", "link", "|", "preview", "side-by-side", "guide"]
        });

 marked.setOptions({
            sanitize: true,
            sanitizer: escape,
            breaks: true
        });
</script>
</body>
</html>
```

# 今後の展望
コントロールパネルや設定画面等、コンテンツの中身が多いがゆえに表示速度が若干遅いので、その辺をもっと改善していきたいと思っています。
また将来的にはより簡単に操作できるバージョンのリリースも考えています。