4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Day 5】View を抽出して MVC に近づける【じゃんけんアドカレ】

Last updated at Posted at 2020-12-04

じゃんけんアドベントカレンダー の 5 日目です。


前回 Player などのデータの入れ物クラスを作成したことで、MVC の Model の原型のようなものができました
今回は View の抽出により、MVC に近いかたちまで持っていこうと思います

View の抽出

Web アプリケーションの MVC フレームワークには、View としてテンプレートエンジンが用意されていることが多いです。1

Java であれば JSP や Thymeleaf、Ruby であれば ERB などが有名です。

テンプレートエンジン自体は HTML のテンプレート化のためだけに使われるものではないので、CLI アプリケーションの UI にも使用可能です。
こいうことで、じゃんけん CLI アプリケーションにテンプレートエンジンを組み込み、View を分離してみます。

使用するテンプレートエンジン

Java の非 HTML のテンプレートエンジンについて調べたところ、Velocity、FreeMaker あたりが定番のようでした。
たいした機能を使うわけでもないので何でも良いのですが、今回は Velocity を採用することにしました。

テンプレートエンジンを選ぶのに参考にした記事は以下です。

Velocity の基本的な使い方は、以下のドキュメントや記事が参考になります。

Velocity の導入

Velocity を使うため、build.gradle に依存関係を追加します。

build.gradle
dependencies {
    implementation 'org.apache.velocity:velocity:1.7'
    :
}

テンプレートファイルの作成

テンプレートエンジンを使うことになったので、今まで以下のように定数として定義していた表示用の文字列を、Velocity のテンプレートファイルに切り出しました。

    private static final String LINE_SEPARATOR = System.getProperty("line.separator");
    private static final String SCAN_PROMPT_MESSAGE_FORMAT = String.join(LINE_SEPARATOR,
            Hand.STONE.getName() + ": " + Hand.STONE.getValue(),
            Hand.PAPER.getName() + ": " + Hand.PAPER.getValue(),
            Hand.SCISSORS.getName() + ": " + Hand.SCISSORS.getValue(),
            "Please select {0} hand:");
    private static final String INVALID_INPUT_MESSAGE_FORMAT = "Invalid input: {0}" + LINE_SEPARATOR;
    private static final String SHOW_HAND_MESSAGE_FORMAT = "{0} selected {1}";
    private static final String WINNING_MESSAGE_FORMAT = "{0} win !!!";
    private static final String DRAW_MESSAGE = "DRAW !!!";

例えば、手の入力を促す表示は以下のテンプレートになりました。

scan-prompt.vm
#foreach($hand in $hands)
$hand.name: $hand.value
#end
Please select $player.name hand:

他のメッセージについてもテンプレートファイルに抽出し、合計 4 つのファイルを作成しました。

$ tree app/src/main/resources/
app/src/main/resources/
└── view
    ├── invalid-input.vm
    ├── result.vm
    ├── scan-prompt.vm
    └── show-hand.vm

1 directory, 4 files

テンプレートエンジンを実行する実装

さて、Velocity を使うコードを Java で書いていきます。
Velocity をそのまま使おうとすると、以下のようなコードになります。

        try (val sw = new StringWriter()) {

            val vc = new VelocityContext();
            vc.put("player", player);
            vc.put("hands", Hand.values());

            val template = Velocity.getTemplate("view/scan-prompt.vm");
            template.merge(vc, sw);

            System.out.print(sw.toString());

        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }

この処理を Velocity を使う箇所で毎回書いていたら大変です。

そこで関数などに抽出するわけですが、簡単に思いつくのは以下のようなメソッドを作る方法です。

    private static void show(Stirng templateName, Map<String, Object> params) {
        try (val sw = new StringWriter()) {

            val vc = new VelocityContext();
            params.forEach(vc::put);

            val template = Velocity.getTemplate(templateName);
            template.merge(vc, sw);

            System.out.print(sw.toString());

        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

このメソッドを以下のように呼び出せば、呼び出し箇所で毎回 VelocityContext などを操作する必要が無くなります。

        val params = Map.of("player", player, "hands", Hand.values());
        show("view/scan-prompt.vm", params);

View クラスの作成

上記のメソッドを用意する方針でも全然良いのですが、せっかくなのでもう少しオシャレに呼び出せるようにしてみます。
例えば以下のように呼び出せたらちょっとカッコイイのではないでしょうか。

        new View("view/scan-prompt.vm")
                .with("player", player)
                .with("hands", Hand.values())
                .show();

ビューにテンプレート名を渡して、パラメータを順に渡して、最後に show を呼び出せば表示されるという仕組みです。

このように使える View クラスを実際に実装してみました。
View クラスの全体像は以下の通りです。

public class View {

    static {
        Velocity.setProperty("resource.loader", "class");
        Velocity.setProperty("class.resource.loader.class", "org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader");
        Velocity.setProperty("input.encoding", "UTF-8");
        Velocity.setProperty("output.encoding", "UTF-8");
        Velocity.init();
    }

    private String templateName;
    private Map<String, Object> map;

    private View(String templateName, Map<String, Object> map) {
        this.templateName = templateName;
        this.map = map;
    }

    public View(String templateName) {
        this.templateName = templateName;
        this.map = new HashMap<>();
    }

    public View with(String key, Object value) {
        val newMap = new HashMap<String, Object>(map);
        newMap.put(key, value);
        return new View(templateName, newMap);
    }

    public void show() {
        try (val sw = new StringWriter()) {

            val vc = new VelocityContext();
            map.forEach(vc::put);

            val template = Velocity.getTemplate(templateName);
            template.merge(vc, sw);

            System.out.print(sw.toString());

        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

}

GoF のデザインパターンの 1 つである「ビルダーパターン」に近いですが、イミュータブルなクラスにするなどの工夫を入れています。

全体的にこの View クラスを使うように変更すれば、View の抽出は完了です。

JAR ファイル実行エラー

さて、ここまでできたので Git にコミットしようとしたところ、先日用意したビルドスクリプトがエラーになってしまいました。
./gradlew run./gradlew test ではエラーが発生しないのに、何が問題なのでしょうか ?

$ ./bin/build.sh
    :
    :
    :
BUILD SUCCESSFUL in 28s
10 actionable tasks: 10 executed
    :
    :
    :
+ java -jar ...
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/velocity/context/Context
        at com.example.janken.App.scanHand(App.java:182)
        at com.example.janken.App.main(App.java:47)
Caused by: java.lang.ClassNotFoundException: org.apache.velocity.context.Context
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:581)
        at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
        ... 2 more

メッセージを読むと、java -jar コマンドで JAR ファイルを実行しようとしたときに、Velocity 関係のクラスが見つからないと言われています。

これは JAR ファイルに依存関係を含めるようにすることで解決できます。
具体的には build.gralde に以下のような設定を書きます。

build.gradle
jar {
    from configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
    :
}

今回エラーを検知できたのは、JUnit の自動テストだけなく、JAR ファイルを実際に実行できるかテストするコードを書いておいたからこそでした。

Controller の作成

View の抽出が完了したので、MVC らしくするために Controller クラスも作ろうと思います。
といっても、main クラスの処理を JankenController クラスに全て移動してしまうだけです。

JankenController に処理を移動すると、main クラスはただ JankenController をインスタンス化してメソッドを呼び出すだけになります。
今後ここで別の役割を果たす予定もあるので、これで OK とします。

public class App {

    public static void main(String[] args) throws IOException {
        val controller = new JankenController();
        controller.play();
    }

}

現時点のコード

これで MVC に近いかたちになりました。
現時点のコードの構成を図示すると、以下のようになっています。

Day5_クラス図_viewとcontroller追加.png

コードは GitHub の この時点のコミット を参照ください。

次回のテーマ

今回でついに MVC に「近いかたち」になりました。
私が「近いかたち」という表現を使っているのは、本来の MVC のように Model がビジネスロジックを持っていないためです。
また、別の課題として、Controller にデータアクセスのコードが入り込んでいるというものもあります。

現状、このプログラムは処理がほぼ全て Controller に書かれており、いわゆるファットコントローラになっています。
これは Web アプリケーション開発でレイヤー化を前提としない Ruby on Rails や Django などの MVC フレームワークを使った際にも発生しやすい現象です。

ここからしばらくは、このファットコントローラの状態を解決するよう少しずつリファクタリングしていきます。

それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
じゃんけんアドベントカレンダー に興味を持ってくださった方は、是非購読お願いします。

次回の記事

【Day 6】サービスクラスの導入【じゃんけんアドカレ】

  1. 最近だとサーバサイドは API を提供するだけでテンプレートエンジンを使わないことも多いです

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?