LoginSignup
33
36

More than 5 years have passed since last update.

この記事は、 Java EE Advent Calendar 2015 - Qiita の 17 日目の記事です。
昨日は @glory_of さんの JAX-RSでJSONのやりとり - シュンツのつまづき日記 でした。
明日は @aforarsh さんです。

Java EE とは Java Platform, Enterprise Edition の略で、企業向けの業務システムなどを作るために必要となる仕様をまとめたエディションです。

とは言っても、別に企業向けのシステムしか作ってはいけないわけではなく、遊びで使っても全然問題ないわけです。
いや、むしろ遊びで使った方が、マスタ管理画面だらけの退屈な業務システムよりも効率的に Java EE を勉強できるかもしれません。

ということで、 Java EE でライフゲームを作ってみます。

※でき上がったコードは GitHub に上げています。
https://github.com/opengl-8080/lifegame-javaee

ライフゲームとは

説明は wikipedia に詳しく載っています。

ライフゲーム - Wikipedia

簡単に言うと、マス目をたくさん用意して、各マスを特定のルールに従って黒くしたり白くしたりしたら、なんか予想もしない動きが見れて楽しいよ! ってやつです。

特定のルールを上記 wikipedia のページから引用します。

誕生:死んでいるセルに隣接する生きたセルがちょうど3つあれば、次の世代が誕生する。
生存:生きているセルに隣接する生きたセルが2つか3つならば、次の世代でも生存する。
過疎:生きているセルに隣接する生きたセルが1つ以下ならば、過疎により死滅する。
過密:生きているセルに隣接する生きたセルが4つ以上ならば、過密により死滅する。

ライフゲームを知らない方、知ってるけど何が楽しいの? って方は、以下の動画を視聴することをおすすめします。
特に第8回は必見です。

ライフゲームの世界 ‐ ニコニコ動画

要件

まずは、どんなアプリを作るかを考えます。

アプリの操作イメージ

アプリを開きます。

新規登録.png

最初は、ライフゲームを新規に作成する画面です。

マス目の数を数値で入力します(縦横は同じ長さにする)。

大きさを確定させると、次の画面に進みます。

編集画面.png

画面には先ほど入力したサイズでマス目が表示されていて、クリックすることでマス目の白黒を切り替えることができます。

また、白黒の初期状態は保存することができます。
保存した初期状態は、エクスポートして他人と共有できるようにしたいですね(余裕があれば対応する)。

保存したマス目の状態は、後で開き直して編集することができます。

ゲーム画面.png

ゲームを開始すると、自動でセルの状態が更新されていきます。

更新は一時停止(ストップ)したり、再開(リスタート)することができます。

ゲームは、常に保存している初期状態からのスタートになります。
つまり、一度開始したゲームは元のゲームとは独立して動くことになります。

設計

画面遷移

画面遷移.jpg

とりあえず正常系だけに絞った画面遷移のイメージです。
要件で考えたものより、もうちょっと具体的な機能を考えながら描きました(削除とかが追加されてます)。

最初は、新規にゲームを登録する画面か、既存のゲームを編集する画面を開きます。

ゲーム編集画面で編集や保存が完了したら、ゲームを開始し、ゲーム実行画面に遷移します。

画面を閉じたら終了です。

画面イメージ

手書きの適当なやつより、もうちょっと綺麗な感じで画面イメージを描いてみます。

画面イメージ.jpg

手書きのやつとあんまり変わらないですね。
まぁ、でも各画面に必要な情報や機能は何となく分かりました。

モデルを考える

ライフゲームのモデルを考えてみます。

まずは、ゲーム登録画面を見ながら。。。

モデル_ゲーム登録画面.jpg

なんか、「ゲーム」というものがあるようですね。
また、「ゲーム」にはサイズを設定できるようなので、属性として持たせてます。

次は、ゲーム編集画面を見ながら。。。

モデル_ゲーム編集画面.jpg

「ゲーム」は「セル」をたくさん持つようです。ライフゲームなので当然ですね。

「セル」は二次元の座標、すなわち「位置」を持ち、「位置」によって「ゲーム」が持つ「セル」を一意に特定することができます。
その辺を限定子で表現しています。

また、「セル」はクリックすることで白・黒が切り替わるわけですが、ライフゲームでのセルの色はセルの生死を表しています。
つまり、セルは生死を属性として持つことになります。また、生死を切り替えられるように「生死を設定する()」メソッドを定義しています。

あと、セルを実際に描画するときに、そのセルの生死が分かる必要があるので、「生きてる()」メソッドで確認できるようにしました。

最後はゲーム実行画面。
おそらく、このシステムの肝ですね。

ゲーム実行画面では、定期的にゲームが進行していき表示が切り替わっていきます。
つまり、ゲームのステップを進行させるメソッドが必要そうです。

03_ゲーム実行画面_1.jpg

「セル」クラスに「次のステップ()」メソッドを追加してみました。
が、ちょっと待ってください。

この「セル」クラスの状態を、ゲームが始まったあとで変更しても良いのでしょうか?

要件のところで、以下のように書きました。

また、白黒の初期状態は保存することができます。
保存した初期状態は、エクスポートして他人と共有できるようにしたいですね(余裕があれば対応する)。

ゲームは、常に保存している初期状態からのスタートになります。
つまり、一度開始したゲームは元のゲームとは独立して動くことになります。

どうやらダメなようです。

ゲームが開始されると、「ゲーム」と「セル」が持つ状態をコピーした、別の何かが動き出すようにしないとダメそうです。
というか、そちらのほうを「ゲーム」と「セル」と呼んだほうがしっくり来そうです。逆に、今「ゲーム」「セル」と呼んでいるクラスは、初期状態の定義だけを持っているので、「ゲーム定義」とか「セル定義」と呼んだほうがしっくり来る気がします。

ということで、クラス図を修正してみます。

03_ゲーム実行画面_2.jpg

ライフゲームのルールから、おそらく必要になりそうなメソッドも追加しています。

これらのクラスは、以下のように連携するイメージです。

シーケンス_ゲーム実行画面.jpg

「ゲーム定義」の情報をもとに「ゲーム」を生成します。
シーケンス図では省略してますが、「ゲーム定義」が持つ「セル定義」の情報をもとに「セル」を作成して「ゲーム」を初期化します。

「ゲーム」に次のステップに遷移するよう指示すると、まず各「セル」に対して次の状態を予約するよう指示を出します。
各「セル」は、自分のまわりに存在する「セル」を知っており、それらが生きているか死んでいるかを数え、ルールに従って次の状態を決定します。

全ての「セル」の次の状態が決定したら、改めて全「セル」に対して次の状態に遷移するよう指示を出します。

なんだか実装できそうな気がしてきました。

システム構成

システム構成.jpg

システム構成のゆるーいイメージです。

アプリケーションサーバーには、 Payara Micro を利用します。データベースには Payara Micro が内部に組み込んでいる Derby を利用します。

AP サーバー上のアプリで使う Java EE 仕様としては、入り口に JAX-RS を使い、データベースとのやり取りには JPA を使います。
間に自作するビジネスロジックが挟まり、それらを繋ぐ裏方として EJB や CDI を利用します。
まぁ、よくある構成かと。

あと、データベースのマイグレーションには Flyway を使います。

処理の流れ

処理の流れ.jpg

大雑把な処理の流れです。

基本的に、ブラウザから非同期でサーバーに通信します。
そして、必要に応じてデータベースから値を取得したり保存したりします。

非同期通信が終わったら、画面の状態を更新します。

データベースを設計する

永続化が必要なデータは何でしょう?

ざっと見た感じ、「ゲーム定義」「セル定義」「ゲーム」「セル」は保存しなければならなさそうです。

テーブル定義.jpg

クラス図を利用してますが、よしなにデータベース定義と読み替えてください。。。
下線があるカラムは主キー項目で、シャープが付いている項目はユニークとなる組み合わせを表しています。

「セル」が持つ周囲の「セル」については、それぞれの「位置」が分かれば頑張って算出できそうです。
なので、データベースには保存せず、 Java 側で再構築したあとで設定することにしましょう。
(実際は、最初はデータベースに保存してやってみてたのですが、セル数が 30 × 30 を超えたあたりから JPA が StackOverflow が頻発させるようになったので止めました orz)

Web のエンドポイントを考える

フロントは JAX-RS で待ち構えるので、 REST っぽい Web のエンドポイントを考えます。

必要になるのは、おそらく以下のような処理でしょう。

  • 「ゲーム定義」の CRUD。
  • 「ゲーム」の CRUD。

コンテキストルートは /lifegame にするとして、 JAX-RS の @ApplicationPath に設定するルートとなるパスはとりあえず /api にしましょう。

あとは、「ゲーム定義」と「ゲーム」に対する処理になるので、以下のような感じでしょうか。

  • 「ゲーム定義」の CRUD。
    • POST : /game/definition?size=<サイズ>
      • 新しい「ゲーム定義」を作成する。
      • クエリパラメータで size=10 みたいな感じで、作成する「ゲーム定義」のサイズを渡す。
    • GET : /game/definition/{id}
      • {id} で指定した「ゲーム定義」とそれに紐づく「セル定義」の情報を取得する。
    • PUT : /game/definition/{id}
      • {id} で指定した「ゲーム定義」を更新する。
      • ボディには、「セル定義」ごとの生き死にの情報を JSON で持たせる。
    • DELETE : /game/definition/{id}
      • {id} で指定した「ゲーム定義」を削除する。
  • 「ゲーム」の CRUD。
    • POST : /game?game-definition-id=<ゲーム定義ID>
      • 新しい「ゲーム」を作成する。
      • クエリパラメータで game-definition-id=3 みたいな感じで、元となる「ゲーム定義」の ID を指定する。
    • POST : /game/{id}
      • {id} で指定した「ゲーム」の状態を1つ進める。
      • レスポンスには、状態が1つ進んだあとの「ゲーム」の情報が返る。
    • DELETE : /game/{id}
      • {id} で指定した「ゲーム」を削除する。

やり取りする JSON についてもうちょっと詳しく考える

GET /game/definition/{id}

ゲーム定義を取得したときのレスポンス
{
    "size": 3,
    "cells": [
        [true, false, true],
        [false, false, false],
        [true, true, true]
    ]
}

PUT /game/definition/{id}

ゲーム定義を更新するときのリクエストボディ
{
    "cells": [
        [true, false, true],
        [false, false, false],
        [true, true, true]
    ]
}

POST /game/{id}

ゲームを1ステップ進めたときレスポンス
{
    "size": 3,
    "cells": [
        [true, false, true],
        [false, false, false],
        [true, true, true]
    ]
}

なんかどれも同じような構造ですね。Java 側は1つの DTO クラスでやり取りすれば良さそうです。

size は「ゲーム」および「ゲーム定義」のサイズで、 cells は各セルの状態を boolean の2次元配列で持っています(true が生存で false が死滅)。

実装

では、実装していきましょう。
とりあえず、レイヤの下の方から作っていきます。

War プロジェクトを作って Payara Micro で起動できるようにする

まずは、システム構成 で挙げた形で動かせるプロジェクトを Gradle で作ります。

build.gradle
apply plugin: 'eclipse-wtp'
apply plugin: 'war'

sourceCompatibility = '1.8'
targetCompatibility = '1.8'
compileJava.options.encoding = 'UTF-8'

repositories {
    mavenCentral()
}

dependencies {
    compile 'org.flywaydb:flyway-core:3.2.1'
    providedCompile 'fish.payara.extras:payara-micro:4.1.1.154'
}

war {
    baseName = 'lifegame'
}

task lifegame(type: Exec, dependsOn: war) {
    def payaraJar = configurations
                        .providedCompile
                        .find {it.name =~ /payara-micro.*\.jar/}
                        .absolutePath
    def warFile = war.archivePath

    commandLine 'java', '-jar', payaraJar, '--deploy', warFile
}

eclipse {
    project.name = 'lifegame-javaee'
}

task wrapper(type: Wrapper) {
    gradleVersion = '2.9'
}

プロジェクト自体は war プラグインを読み込んで Web アプリ用のプロジェクトにします。

依存関係として Payara Micro を providedCompile で指定します。
ここでダウンロードした Payara Micro の jar は、 lifegame タスクで再利用します。

lifegame タスクでは、依存関係に指定してダウンロードした Payara Micro のローカルパスを取得し、 java コマンドを使って起動に利用しています。

データソースを定義する

アプリケーションサーバが起動できるようになったので、次はデータベースを用意します。

といっても、データベース自体は Payara Micro が組み込みで持っている Derby を利用するので、特にインストールは必要ありません。
必要なのは、データソースの登録以降の作業だけになります。

データソースを登録する方法はいくつかありますが、今回は web.xml を利用した方法を採ります。

web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee 
         http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">

  <data-source>
    <name>java:app/lifegameDS</name>
    <class-name>org.apache.derby.jdbc.EmbeddedDataSource</class-name>
    <database-name>lifegame_db</database-name>
    <property>
      <name>connectionAttributes</name>
      <value>;create=true</value>
    </property>
    <transactional>true</transactional>
  </data-source>
</web-app>

Flyway を使ってデータベースを作成する

データソースの準備はできたので、次はデータベースの構築を Flyway を使って実行します。
Flyway については こちら を参照ください。

今回は、アプリケーションがロードされたらデータベースのマイグレーションが実行されるようにします。

Servlet でアプリケーション起動時に処理を挟むといえば、リスナーのお仕事ですね。
ということで、リスナーを定義します。

DatabaseMigration.java
package gl8080.lifegame.persistence;

import javax.annotation.Resource;
import javax.inject.Inject;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import javax.servlet.annotation.WebListener;
import javax.sql.DataSource;

import org.flywaydb.core.Flyway;
import org.slf4j.Logger;

@WebListener
public class DatabaseMigration implements ServletContextListener {

    @Inject
    private Logger logger;

    @Resource(lookup="java:app/lifegameDS")
    private DataSource ds;

    @Override
    public void contextInitialized(ServletContextEvent sce) {
        logger.info("initialize database...");

        Flyway flyway = new Flyway();
        flyway.setDataSource(this.ds);
        flyway.migrate();

        logger.info("initialize database done.");
    }

    @Override public void contextDestroyed(ServletContextEvent sce) {}
}

ServletContextListener インターフェースを実装したクラスを @WebListener でアノテートすることでリスナーを定義できます。

アプリケーションが起動されると contextInitialized() メソッドがコールバックされるので、ここで Flyway を使ったデータベースの初期化を行っています。

ビジネスロジックを実装する

データベースができたので、次はビジネスロジックを実装していきます。

と言っても、ここはあんまり Java EE とは関係ないので、詳細な内容は省きます。
興味がある方は、一部のクラスを実装したときの手順を GitHub に上げておくので、そちらを参照してください。 > ビジネスロジックの実装手順的な.md

ビジネスロジックは、設計のところで描いたクラス図 をそのまま Java コードに落としこむ感じです。

できたクラスが、 こちらにあるクラス達 になります。

データベースとマッピングする

ビジネスロジックを実装したので、そのクラス達をデータベースに保存できるようにします。

しかし、改めて クラス図データベース定義 を見ると、その構造に乖離があります。
これがいわゆるインピーダンス・ミスマッチと呼ばれるやつですね。

Java EE では、このミスマッチを埋めるために JPA と呼ばれるフレームワークが用意されています。
JPA では、アノテーションを使ってこの差を埋めることができます。

実際にアノテーションでマッピングした結果が以下です(import は省略してますが、アノテーションは全て JPA で定義されているものです)。

AbstractEntity.java
...

@MappedSuperclass // ★ ID を持つエンティティの共通親クラス
public abstract class AbstractEntity implements Serializable {
    private static final long serialVersionUID = 1L;

    @Id @GeneratedValue(strategy=GenerationType.IDENTITY) // ★ ID の値はデータベースで自動採番させる
    private Long id;

    ...
GameDefinition.java
...

@Entity
@Table(name="GAME_DEFINITION") // ★テーブル名
public class GameDefinition extends AbstractEntity {
    // ★カラム名が DB と一致しているので、特に設定はしなくてもOK
    private int size;

    @OneToMany(cascade={PERSIST, MERGE, REMOVE}) // ★登録・更新・削除を連動して実行する
    @JoinColumn(name="GAME_DEFINITION_ID") // ★ CELL テーブルのどのカラムが GAME_DEFINITION の ID を参照しているかを指定する
    private Map<Position, CellDefinition> cells;
    // ★Position がどのカラムとマッピングされるかは Position クラスで定義しているので、ここで明示する必要はない(便利!)

    ...
CellDefinition.java
...

@Entity
@Table(name="CELL_DEFINITION")
public class CellDefinition extends AbstractEntity {
    // ★DBは INT 型だけど、良しなに変換してくれる模様
    private boolean alive;

    ...
}
Game.java
...

@Entity
public class Game extends AbstractEntity {

    private int size;

    // ★GameDefinition と同じ要領で設定
    @OneToMany(cascade={PERSIST, MERGE, REMOVE})
    @JoinColumn(name="GAME_ID")
    private Map<Position, Cell> cells;

    ...
Cell.java
...

@Entity
public class Cell extends AbstractEntity {

    private boolean alive;

    @Transient // ★永続化しないフィールドは @Transient でアノテート
    private Boolean nextStatus;
    @Transient
    private List<Cell> neighbors = Collections.emptyList();

    ...
Position.java
...

@Embeddable // ★組み込み可能オブジェクト
public class Position {

    @Column(name="VERTICAL_POSITION") // ★対応するカラムの名前を設定
    private int vertical;
    @Column(name="HORIZONTAL_POSITION")
    private int horizontal;

    ...

Map のマッピングがアノテーションだけでうまくいくか気になってましたが、いい感じにできました。
JPA のマッピング便利ですね!

ただ、慣れないと難しそうだなとも感じました。

マッピングの柔軟性はハンパないです。
しかし、いかんせんアノテーションの種類が多いのがつらいです。
どのアノテーションを使うべきかをすぐに判断できないと、マッピング定義だけでも結構時間を取られてしまいます。
この辺は慣れでしょうかね(JPA アノテーション職人とか現れそうです)。

しかし、使い方をマスターできれば後はマッピングを定義するだけなので、いちいちマッピング処理を実装する必要なくなる分、かなり時間を短縮できそうな気もします。

あと、エンティティの equals() メソッドと hashCode() メソッドをどうするかについても結構悩みました。
そのへんの話は AbstractEntity クラスの Javadoc に苦悩を記載しているので、興味があれば読んでみてください。

アノテーションを使わずにマッピングする

アノテーションを使ったマッピングは便利ではあるのですが、ビジネスロジックを担うクラスにデータベースの関心事(カラム名や JOIN 方法など)が紛れ込むのがつらいと感じる人もいると思います(僕も若干感じています)。

その場合は、アノテーションを使わず xml でマッピングを定義することも可能です。

persistence.xml
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.1"
             xmlns="http://xmlns.jcp.org/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
                                 http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd">

  <persistence-unit name="LifeGameUnit">
    <jta-data-source>java:app/lifegameDS</jta-data-source>
    <mapping-file>/META-INF/mapping.xml</mapping-file> <!-- ★マッピング定義ファイルを指定 -->
  </persistence-unit>

</persistence>

まずは、マッピングファイルの場所を persistence.xml<mapping-file> タグで指定します。

mapping.xml の中身は以下のような感じです。

mapping.xml
<?xml version="1.0" encoding="UTF-8" ?>
<entity-mappings
    xmlns="http://java.sun.com/xml/ns/persistence/orm"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/persistence/orm http://java.sun.com/xml/ns/persistence/orm_1_0.xsd"
    version="1.0">
    <description>LifeGame Entity Mapping</description>

    <mapped-superclass class="gl8080.lifegame.logic.AbstractEntity">
      <attributes>
        <id name="id">
          <generated-value strategy="IDENTITY" />
        </id>
      </attributes>
    </mapped-superclass>

    <entity class="gl8080.lifegame.logic.Game">
      <attributes>
        <one-to-many name="cells">
          <join-column name="GAME_ID" />
          <cascade>
            <cascade-persist />
            <cascade-merge />
            <cascade-remove />
          </cascade>
        </one-to-many>
      </attributes>
    </entity>

    <entity class="gl8080.lifegame.logic.Cell">
      <attributes>
        <transient name="nextStatus"/>
        <transient name="neighbors" />
      </attributes>
    </entity>

    <entity class="gl8080.lifegame.logic.definition.GameDefinition">
      <table name="GAME_DEFINITION" />

      <attributes>
        <version name="version" />

        <one-to-many name="cells">
          <join-column name="GAME_DEFINITION_ID" />
          <cascade>
            <cascade-persist />
            <cascade-merge />
            <cascade-remove />
          </cascade>
        </one-to-many>
      </attributes>
   </entity>

   <entity class="gl8080.lifegame.logic.definition.CellDefinition">
     <table name="CELL_DEFINITION" />
   </entity>

   <embeddable class="gl8080.lifegame.logic.Position">
     <attributes>
       <basic name="vertical">
         <column name="VERTICAL_POSITION" />
       </basic>
       <basic name="horizontal">
         <column name="HORIZONTAL_POSITION" />
       </basic>
     </attributes>
   </embeddable>
</entity-mappings>

アノテーションで定義した場合と見比べるとわかりますが、そのまま右から左に移植した感じで記述できます。

アノテーションの定義方法さえわかっていれば、 xml への置き換えは割りと簡単にできます。
(eclipse などの xml のスキーマ定義から入力補完をしてくれるエディタを使っていれば、アノテーションと同じ名前のタグを探せばいいので更に簡単です)

では、ビジネスロジックの方のクラスを見てみましょう。

Game.java
package gl8080.lifegame.logic;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import gl8080.lifegame.logic.definition.GameDefinition;

public class Game extends AbstractEntity implements LifeGame {
    private static final long serialVersionUID = 1L;

    private int size;

    private Map<Position, Cell> cells;

    ...

アノテーションが無くなってスッキリしてますね。
import 文からも分かるように、 JPA との関連は完全に断たれました。

ただ、当然ながらフィールド名やクラス名を変更したら mapping.xml の編集も忘れないように注意が必要です。

ビジネスロジックの純粋さとどちらを優先するかは、プロジェクトのメンバーと相談して決めればいいかと思います。

この xml を使ったバージョンについては、ブランチを別途切っているので、ソース全体を確認したい場合はそちらを参照ください。

Web エンドポイントを実装する

アプリケーションの裏側ができたので、次は表側の入り口である Web のエンドポイントを実装します。

ここは、 JAX-RS を使って実装します。
設計のところ で考えた URL の感じだと、リソースクラスは2つくらい必要そうです。

GameDefinitionResource.java
package gl8080.lifegame.web.resource;

import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

@Path("/game/definition") // ★パスとマッピング
public class GameDefinitionResource {

    @POST // ★HTTP メソッドとマッピング
    @Produces(MediaType.APPLICATION_JSON)
    public Response register(@QueryParam("size") int size) { // ★クエリパラメータを受け取る
        ...
    }

    @GET
    @Path("/{id}") // ★パスパラメータを定義
    @Produces(MediaType.APPLICATION_JSON) // ★レスポンスのコンテンツタイプ
    public LifeGameDto search(@PathParam("id") long id) { // ★パスパラメータを受け取る
        ...
    }

    @PUT
    @Path("/{id}")
    @Consumes(MediaType.APPLICATION_JSON) // ★リクエストのコンテンツタイプ
    public Response modify(LifeGameDto dto) {
        ...
    }

    @DELETE
    @Path("/{id}")
    public Response remove(@PathParam("id") long id) {
        ...
    }
}

まずは「ゲーム定義」のリソースクラスです。

設計で考えた HTTP の定義に合わせてアノテーションで定義しています。
JAX-RS の詳細な仕様を理解していなくても、どういう意味で定義されているのかは何となく分かるかと思います。

JAX-RS は、このように HTTP の入り口をアノテーションで宣言的にかつシンプルに定義することができるフレームワークです。
このシンプルさのためか、 Java EE のなかでは珍しく人気があります。

ちなみに、 LifeGameDto クラスは「ゲーム」や「ゲーム定義」の情報をやり取りするための入れ物となるクラスです。

LifeGameDto.java
package gl8080.lifegame.web.resource;

import java.util.List;

public class LifeGameDto {

    private int size;
    private List<List<Boolean>> cells;

    // getter, setter...
}

List を入れ子で持っています。
これを JSON に変換することで、 boolean の2次元配列になることを想定しています。

JSON のマッピング

試しに、 GameResource クラスの search() メソッドでダミーのデータを返すようにして、クライアントにどのようなデータが返されるか見てみましょう。

確認コマンド
> curl http://localhost:8080/lifegame/api/game/definition/2
帰ってきたJSON
{"cells":["true false false","false false false","false false false"],"size":3}

(;^ω^)・・・
 
 
 
 
見なかったことにしましょう。

JAX-RS にはリクエストやレスポンスを任意の形式に変換できるような仕組みが用意されています。
今回は、 Jackson と呼ばれる JSON のパーサーを使って JSON の変換を自作したいと思います。

自作と言っても処理の大部分は Jackson にお任せするので、実装する部分はほんの少しだけです。

まずは依存関係を追加して。。。

build.gradle
    compile 'com.fasterxml.jackson.core:jackson-databind:2.6.4'

MessageBodyWriter を実装したクラスを作成します。

JsonWriter.java
package gl8080.lifegame.web.json;

import java.io.IOException;
import java.io.OutputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;

import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.ext.MessageBodyWriter;
import javax.ws.rs.ext.Provider;

import com.fasterxml.jackson.databind.ObjectMapper;

import gl8080.lifegame.web.resource.LifeGameDto;

@Provider // ★これをつけると、 JAX-RS が勝手にクラスを見つけてくれる
@Produces(MediaType.APPLICATION_JSON) // ★このクラスが対応しているコンテンツタイプ
public class JsonWriter implements MessageBodyWriter<LifeGameDto> { // ★MessageBodyWriter インターフェースを実装する

    private ObjectMapper mapper = new ObjectMapper(); // ★Jackson のクラス

    @Override
    public boolean isWriteable(Class<?> clazz, Type type, Annotation[] annotations, MediaType mediaType) {
        return mediaType.isCompatible(MediaType.APPLICATION_JSON_TYPE); // ★サポート対象かどうかの判定
    }

    @Override
    public long getSize(LifeGameDto dto, Class<?> clazz, Type type, Annotation[] annotations, MediaType mediaType) {
        return -1; // ★レスポンスのサイズ(分からなければ -1 で)
    }

    @Override
    public void writeTo(LifeGameDto dto, Class<?> clazz, Type type, Annotation[] annotations, MediaType mediaType, MultivaluedMap<String, Object> headers, OutputStream out) throws IOException, WebApplicationException {
        this.mapper.writeValue(out, dto); // ★ここで変換
    }
}

writeTo() メソッドで変換を行っていますが、実体は Jackson に丸投げです。

これで再度動作を確認してみます。

確認コマンド
> curl http://localhost:8080/lifegame/api/game/definition/2
帰ってきたJSON
{"size":3,"cells":[[true,false,false],[false,false,false],[false,false,false]]}

いい感じで2次元配列の JSON が返されました!

トランザクション制御

今回のアプリでは、複雑なトランザクション制御は必要なさそうです。
単純に正常終了ならコミットで、エラーがあればロールバックで問題無いでしょう。

ということで、トランザクションの制御はコンテナに丸投げします。

Java EE では、 EJB コンテナがトランザクションの制御を担っています。

セッションビーンを作れば、そのクラスのメソッドが自動的にトランザクション境界となります。
つまり、メソッドが実行されるとコンテナが勝手にトランザクションを開始し、正常終了すれば勝手にコミット、非チェック例外がスローされたら勝手にロールバックしてくれます。

RegisterGameDefinitionService.java
package gl8080.lifegame.application.definition;

import javax.ejb.Stateless;
import javax.inject.Inject;

import gl8080.lifegame.logic.definition.GameDefinition;
import gl8080.lifegame.logic.definition.GameDefinitionRepository;

@Stateless // ★ @Stateless でアノテートすることでステートレスセッションビーンを定義できる
public class RegisterGameDefinitionService {

    @Inject
    private GameDefinitionRepository gameDefinitionRepository;

    public GameDefinition register(int size) {
        ... // ★ このメソッド内がトランザクション管理の対象になる
    }
}

データベースアクセスが発生する処理については、セッションビーンのメソッドを経由させるようにすれば、とりあえずトランザクション制御は OK でしょう。

同時更新チェック

今回のアプリは複数人で利用することを想定していません。なので、同時更新チェックとかは必要なさそうです。
しかし、業務システムなら同時更新チェックは必須の仕組みですし、せっかくなので実装してみましょう。

このアプリで更新系の処理は、ゲーム定義の編集とゲームのステップ進行があります。
後者は、アプリの仕組み上単一の画面からしか実行されないはずなので、同時更新チェックは不要でしょう(不可能ではないですが、今回は無いものとして扱います)。

前者は、画面を2つ開けば簡単に同時更新を実現できます。
一般的な業務アプリなら、ほかの人が編集した内容を誤って上書きしてしまう危険性があるので、同時更新チェックが必要になるかと思います。運用上一人しか触らないなら不要な可能性もありますが、そのへんは作ってるシステムごとにお客さんと要調整ですね

とりあえず、今回はこちらの機能に同時更新のチェックを入れることにしましょう。

同時更新のチェックについては、 JPA が楽観ロックという形で仕組みを提供してくれています。これを利用します。

楽観ロックを利用するには、データベースにバージョン番号を保存するカラムを用意し、エンティティにそれに対応するフィールドを追加します。

GameDefinition.java
...

import javax.persistence.Version;

...
public class GameDefinition extends AbstractEntity {
    ...

    @Version // ★このフィールドを追加
    private Long version;

    ...

この @Version というアノテーションで注釈されたフィールドが、バージョン番号を持つためのフィールドになります。
データベースの GAME_DEFINITION テーブルには VERSION というカラムを追加しました。

そして、データを更新するときに以下のように EntityManager からエンティティを取得するようにします。

JpaGameDefinitionRepository.java
package gl8080.lifegame.persistence;

import javax.enterprise.context.ApplicationScoped;
import javax.persistence.EntityManager;
import javax.persistence.LockModeType;
import javax.persistence.PersistenceContext;

import gl8080.lifegame.logic.definition.GameDefinition;
import gl8080.lifegame.logic.definition.GameDefinitionRepository;
import gl8080.lifegame.logic.exception.NotFoundEntityException;

@ApplicationScoped
public class JpaGameDefinitionRepository implements GameDefinitionRepository {

    @PersistenceContext(unitName="LifeGameUnit")
    private EntityManager em;

    ...

    @Override
    public GameDefinition search(long id) {
                                       // ★ロックが不要なら NONE
        return this.searchWithLock(id, LockModeType.NONE);
    }

    @Override
    public GameDefinition searchWithLock(long id) {
                                       // ★PESSIMISTIC_FORCE_INCREMENT を指定
        return this.searchWithLock(id, LockModeType.PESSIMISTIC_FORCE_INCREMENT);
    }

    private GameDefinition searchWithLock(long id, LockModeType lock) {
                                        // ★ LockModeType を指定
        GameDefinition gameDefinition = this.em.find(GameDefinition.class, id, lock);

        if (gameDefinition == null) {
            throw new NotFoundEntityException(id);
        }

        return gameDefinition;
    }
}

EntityManagerfind() メソッドで検索をするときに、引数でロックの方法を指定します。

PESSIMISTIC_FORCE_INCREMENT は対象行を排他ロックしたうえで、トランザクションのコミット前に @Version でアノテートされたカラムを自動でインクリメントします(排他ロックも同時にしておかないと、ほぼ同時に複数のリクエストが来たときに正しく同時更新のチェックができなくなります)。

さらに、このロック用の検索処理を利用して、以下のように更新処理を実装します。

UpdateGameDefinitionService.java
package gl8080.lifegame.application.definition;

import javax.ejb.Stateless;
import javax.inject.Inject;

import gl8080.lifegame.logic.definition.GameDefinition;
import gl8080.lifegame.logic.definition.GameDefinitionRepository;
import gl8080.lifegame.web.resource.LifeGameDto;

@Stateless
public class UpdateGameDefinitionService {

    @Inject
    private GameDefinitionRepository gameDefinitionRepository;

    public GameDefinition update(LifeGameDto dto) {
        GameDefinition gameDefinition = this.gameDefinitionRepository.searchWithLock(dto.getId());

        ...

        gameDefinition.setVersion(dto.getVersion()); // ★クライアントから渡ってきたバージョンを設定する

        return gameDefinition;
    }
}

GameDefinition をロックと共に取得し、 versiondto が持っていたバージョン番号を設定しています。

この dto が持っているバージョン番号は、編集画面を最初に表示したときにクライアントに返したバージョン番号です。

このクラスは @Stateless でアノテートしているので EJB になります。よって update() のメソッドが終了すると自動でトランザクションがコミットされます。
このとき、 JPA は gameDefinition にセットされたバージョン番号(クライアントが画面を表示したときのバージョン)とデータベースに保存されている対象レコードのバージョン番号を比較します。そして、バージョンの関係に齟齬がある場合(対象レコードが既に更新されている場合)は OptimisticLockException をスローします。

これで、同時更新チェックができるようになりました。

最終更新日時で同時更新チェックをする

今回は Long 型のフィールドを @Version でアノテートしましたが、 java.sql.Timestamp 型のフィールドも @Version でアノテートできます。
つまり、 Timestamp を使えば、最終更新日付を記録しつつ同時更新のチェックができるようになります。

しかし、エンティティクラスのフィールドに java.sql パッケージに含まれるクラスを使うというのはかなりキモイです。
それが嫌な場合は、 @Version でアノテートするのはあくまで Long 型のフィールドにして、最終更新日付は別途 java.util.Date 型で定義したフィールドを設けたほうが良いかもしれません。

エラーハンドリング

エラーが起こったときの処理を実装しましょう。

発生しうるエラーについてはいくつかのパターンが考えられますが、大別すると以下の4種類に分かれるかと思います。

  1. 存在しないリソースへのアクセス。
  2. 不正なパラメータの受信(ゲーム定義のサイズが許容する最大値より大きい、など)。
  3. 同時更新チェックエラー。
  4. その他の予期せぬサーバーエラー。

これらのエラーが起こったときにどうすべきかですが、入り口を Web API 風にしているので、適切なステータスコードを返すようにするのがよさそうです。

それぞれの対応は以下のような感じかと思います。

エラーの種類 ステータスコード
存在しないリソース 404 - Not Found
不正なパラメータ 400 - Bad Request
同時更新 409 - Conflict
その他のエラー 500 - Internal Server Error

その他エラー(NullPointerException とか)は、 JAX-RS が勝手に 500 を返してくれるので特別な実装は不要です(レスポンスを任意の形にしたい場合は必要ですが、今回はステータスコードだけの対応にします)。

リソースが存在しない場合や不正なパラメータについては、そのエラーであることが分かる例外クラスをスローするようにして、 ExceptionMapper でハンドリングさせるようにしましょう。

存在しないリソースを検索した場合のエラーハンドリング

NotFoundEntityException というランタイム例外を自作し、 EntityManager から検索した結果が null の場合はその例外をスローするようにします。

JpaGameDefinitionRepository.java
package gl8080.lifegame.persistence;

...
import javax.persistence.EntityManager;

import gl8080.lifegame.logic.exception.NotFoundEntityException;

@ApplicationScoped
public class JpaGameDefinitionRepository implements GameDefinitionRepository {

    @PersistenceContext(unitName="LifeGameUnit")
    private EntityManager em;

    ...

    private GameDefinition searchWithLock(long id, LockModeType lock) {
        GameDefinition gameDefinition = this.em.find(GameDefinition.class, id, lock);

        if (gameDefinition == null) {
            throw new NotFoundEntityException(id); // ★ここでスロー
        }

        return gameDefinition;
    }

}

この例外をキャッチする ExceptionMapper は以下のように実装します。

package gl8080.lifegame.web.exception;

import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;

import gl8080.lifegame.logic.exception.NotFoundEntityException;

@Provider
public class NotFoundEntityExceptionMapper implements ExceptionMapper<NotFoundEntityException> {

    @Override
    public Response toResponse(NotFoundEntityException exception) {
        return Response.status(Status.NOT_FOUND).build();
    }
}

ExceptionMapper インターフェースを実装して、型引数にハンドリングしたい例外クラスを指定します。
すると、該当する例外がスローされたときに toResponse() メソッドがコールバックされるので、適切な Response オブジェクトを作って返してあげるように実装します。

同じような要領で、不正なパラメータのパターンも実装できます。

同時更新については OptimisticLockException をハンドリングするように実装すれば良さそうですが、残念ながら現実は甘くありませんでした。

OptimisticLockException が EJB によって管理されているトランザクション内からスローされると、 EJBException > RollbackException > OptimisticLockException というふうに幾重にもラップされた例外がスローされてしまいます。
結果、単純に OptimisticLockException を型引数で指定した ExceptionMapper ではハンドリングできませんでした。

この問題については @backpaper0 さんに対処方法を教えていただきました。
こちら→JAX-RSとかの話 — 裏紙 に具体的な対処方法が書かれています。

兎にも角にも、これでエラーハンドリングもできたっぽいです。

動かす!

細かいことを言うと、他にもいろいろ実装しないといけない場所はありますが、そのへんは GitHub にコード上げているので興味のある方はそちらを読んでみてください。
ただ、重要な部分はだいたい説明できたかと思います。

ということで、その他の細かいところを実装してしまって、さっさとアプリを起動してみましょう。

Gradle で lifegame タスクを実行すれば、 Payara Micro が立ち上がってアプリが起動します。

アプリの起動
> gradlew lifegame

しばらくサーバー起動のログが出力されます。最後に Deployed 1 wars と表示されたら起動完了です。

Web ブラウザを開いて http://localhost:8080/lifegame にアクセスしてください。

lifegame.JPG

表示されました!

適当にサイズを入力して、「登録」ボタンをクリックしてください。

編集画面が開くので、適当にセルを塗ってください。
通常クリックで黒く塗れて、 Ctrl クリックで白く塗れます。
ドラッグでも塗れるので、好きな絵とか描くと楽しいかもしれません。

lifegame_duke.JPG

塗り絵が終わったら、「更新」ボタンをクリックして保存してください。

保存が終わったら「開始」ボタンでゲーム開始です!

lifegame.gif

(;゚д゚)デューーーーーーーーーーーーーーーーーーーーーーク!!!!

おわりに

感想

Java EE をつかって、ライフゲームを作ってみました。

途中何度も闇に触れてつらい時もありましたが、なんとかいい感じに動く物ができて良かったです。
まぁつらいと言っても、それはそのまま知見として自分のレベルアップに繋がっているはずなので、あながち悪いものでもないと思います(むしろ、この壁にぶつかる→ネットとかで調べる→解決する→知識として定着してレベルアップ、というプロセスもプログラミングの醍醐味のひとつかなと思います)。

自分は実践でのフル Java EE な開発経験がありません。
なので、 Java EE での開発ってこんな感じになるのかなぁ、という想像をしながら作ってみてました。だいたい自分が思っていたような感じで作ることはできたかなと思います(特にオブジェクトと DB のマッピングあたり)。

けどまぁ、現実はもっと泥臭くてしんどいことになりそうな気はしてます。

今度は JSF もガッツリ使ったやつを作ってみたいですね(JSF は勉強中...)。

サンプルの「ゲーム定義」

GitHub に置いてるアプリには、デフォルトでいくつかサンプルの「ゲーム定義」を登録しています。

以下に URL と登録している初期配置を載せますので、興味があれば見てみてください。

デューク

lifegame.JPG

言わずと知れた Java のマスコットですね。
ちなみに、オープンソースで BSD ライセンスだそうです(ニュース - SunがJavaのマスコットDukeも“オープンソース化”、こちらはBSDライセンス:ITpro)。

振動子

lifegame.JPG

一定の周期で同じ振動を繰り返すタイプのパターンです。

宇宙船

lifegame.JPG

移動するパターン(移動物体)のうち、大・中・小の宇宙船と呼ばれるパターンです。

グライダー銃

lifegame.JPG

グライダーと呼ばれる移動物体を無限に生成するパターンです。

以上です、これを通して、皆様が少しでもライフゲームと、あとついでに Java EE にも興味を抱いて頂ければ幸いです。

参考

参照したページの多さからも、どこが一番大変だったかが見えてきますね (・∀・;)

ライフゲーム

Derby

データソース, JPA

REST, JAX-RS

Servlet

JavaScript

結局使わなくなったけど残しておきたい参考リンク...

33
36
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
33
36