エンティティと DAO の作成
intra-mart Accel Platform が JavaEE 開発モデル向けに提供するDBアクセス基盤が im_mirage です。
実体は 2WaySQL のオブジェクト関係マッピング(ORM)フレームワーク Mirage-SQL を intra-mart が自社の名前空間(jp.co.intra_mart.mirage.*)に取り込んだもので、モジュール名が im_mirage にあたります。
「IM-Mirage」は製品名ではないため、本ドキュメントではモジュール名に合わせて im_mirage と表記します。
テーブルを一つ扱うために書くのは、テーブルの形を写したエンティティクラスと、そのエンティティを操作する DAO クラスの二つです。 複雑な検索が要るなら、これに2WaySQLのSQLファイルが加わります。
クラスの階層
DAO はゼロから書くのではなく、プラットフォームが用意した階層の末端に置きます。
DAO<T> … 基底インタフェース
↑
BaseDAO<T> … protected IntramartSqlManager sqlManager を保持
↑
AbstractDAO<T> … insert / update / delete / find の共通実装
↑
OrderDAO extends AbstractDAO<OrderEntity> … 独自クエリを追加する具象DAO
sqlManager は BaseDAO が持っているため、具象DAOで宣言し直す必要はありません。
具象DAOは AbstractDAO<エンティティ型> を直接継承します。
共通処理をまとめた中間クラスを挟む場合は、CommonDAO<T> extends AbstractDAO<T> のように型変数をそのまま渡す形にします。
AbstractDAO#find は、DAO クラスが直接継承しているクラスの型引数からエンティティ型を解決するためです。
型引数を固定した中間クラスを挟んだり、具象DAOをさらに継承したりすると解決できず、find を呼んだ時点で NullPointerException になります。
insert と update と delete、SQLファイルを使う独自クエリはこの解決を使わないため、find を呼ぶまで問題が表に出ません。
エンティティクラス
エンティティはテーブルの1行をそのまま写したクラスです。
package jp.co.example.foo.infrastructure.entity;
import java.math.BigDecimal;
import java.sql.Timestamp;
import jp.co.intra_mart.mirage.annotation.Column;
import jp.co.intra_mart.mirage.annotation.PrimaryKey;
import jp.co.intra_mart.mirage.annotation.PrimaryKey.GenerationType;
import jp.co.intra_mart.mirage.annotation.Table;
/**
* 発注情報エンティティ。
*/
@Table(name = "foo_order")
public class OrderEntity {
@PrimaryKey(generationType = GenerationType.APPLICATION)
@Column(name = "order_id")
public String orderId;
@Column(name = "customer_name")
public String customerName;
@Column(name = "amount")
public BigDecimal amount;
@Column(name = "status")
public String status;
@Column(name = "create_user_cd")
public String createUserCd;
@Column(name = "create_date")
public Timestamp createDate;
@Column(name = "record_user_cd")
public String recordUserCd;
@Column(name = "record_date")
public Timestamp recordDate;
}
満たすべき規約は四つあります。
- public フィールドを使う:im_mirage は public フィールドへ直接値をマッピングします。private フィールドと getter の組にすると、値がマッピングされません。
- 引数なしコンストラクタを用意する:ORMがリフレクション経由でインスタンスを生成するためです。フィールドだけを宣言していれば暗黙のコンストラクタで足りますが、他のコンストラクタを定義した場合は明示的に追加します。
@Tableと@Columnと@PrimaryKeyを付与する:テーブル名とカラム名は明示指定します。未指定でもクラス名から自動変換されますが、この規約では明示を必須としています。- 主キーの生成方式は
GenerationType.APPLICATIONに限る:DB側のIDENTITYやSEQUENCEに採番を委ねると、DB製品への依存が生まれ、採番済みのIDを登録前に扱えなくなります。
@PrimaryKey は IDENTITY と SEQUENCE も定義していますが、この規約のもとでは使いません。
複合主キーの場合は、主キーとなるフィールドそれぞれに @PrimaryKey を付けます。
クラス名とテーブル名の対応
クラス名は、テーブル名(スネークケース)をパスカルケースに変換した形が基本です。
order_status_history なら OrderStatusHistoryEntity になります。
ただしこれは強制ルールではなく、実装時に意識しておく努力目標です。
対応を機械的に守ると、モジュール接頭辞や略号がそのままクラス名に入り込み、かえって読みにくくなることがあります。 その場合は、可読性を優先して意訳してかまいません。
foo_order→OrderEntity:foo_はサンプル共通のプレースホルダとしての企業名接頭辞で、ドメインを表す語ではないため省きます。実プロジェクトのb_m_やb_t_といったモジュール接頭辞も同じように扱えます。b_m_account_b→AccountBasicInfoEntity:素直に変換しても意味が読み取れないため、意味の通る英語名に置き換えます。
接頭辞を落としたり略号を意訳したりした場合でも、@Table(name = "...") に実テーブル名を書き、クラスの JavaDoc にもテーブル名を記載します。
名前が離れても、テーブルからクラスへの対応をたどれるようにしておくためです。
対応しているカラム型
Javaの型は、DDLで定義した型に合わせて選びます。
| DB型 | Java型 | 備考 |
|---|---|---|
| VARCHAR / NVARCHAR | String | |
| TIMESTAMP / DATETIME | java.sql.Timestamp | |
| INTEGER / INT | int または long | 値域に応じて選ぶ。ID系や大きな値は long |
| DECIMAL / NUMERIC | java.math.BigDecimal | double と float は使わない |
| CHAR(1) のフラグ | String | "0" や "1" などDBの値をそのまま保持する |
型の選択で迷いやすいのは次の四つです。
- 日付と時刻は
java.sql.Timestampだけを使います。java.util.Dateやjava.time.LocalDateTimeは im_mirage がマッピングできません。 - 金額と小数は
java.math.BigDecimalを使います。doubleとfloatは丸め誤差が出るため、金額や精密な計算には使えません。 - フラグを
booleanにマッピングしません。DBカラムの値に合わせてStringで保持し、真偽への解釈はドメインモデル側で行います。 - 列挙値も
Stringで受けます。Javaの enum への変換はドメインモデル層の仕事です。
NOT NULL 制約のあるカラムでも、エンティティの public フィールドはnull許容とし、値の妥当性はDAO層とDB制約で担保します。
自動で設定される監査項目
作成者と更新者、およびそれぞれの日時を記録する四つのカラムは、すべてのエンティティに必ず含めます。
@Column(name = "create_user_cd")
public String createUserCd;
@Column(name = "create_date")
public Timestamp createDate;
@Column(name = "record_user_cd")
public String recordUserCd;
@Column(name = "record_date")
public Timestamp recordDate;
AbstractDAO#insert では EntityHelper.setCreateFields を、AbstractDAO#update では EntityHelper.setRecordFields を内部で呼び出し、監査項目に自動で値を設定します。
二つの挙動には違いがあります。
setCreateFieldsは、監査項目の四つすべてを対象にし、値がnullの項目にだけ値を設定します。すでに値が入っていれば上書きしません。setRecordFieldsは、recordUserCdとrecordDateの二つだけを対象にし、常に値を設定します。createUserCdとcreateDateは補完しません。
監査項目は規約上の要求であるだけでなく、実装上も欠かせません。
四つのうち一つでも宣言していないエンティティを渡すと、insert と update は NullPointerException になります。
insert には四つすべてが、update には record 系の二つが必要です。
呼び出し側でこれらを手で設定すると、意図しない値のまま登録されたり、AbstractDAO による設定と二重になったりします。
// 避けること
order.createUserCd = "system";
order.createDate = new Timestamp(System.currentTimeMillis());
dao.insert(order);
エンティティ側は宣言だけを持ち、値の設定はDAOに任せます。
DAO クラス
独自クエリが不要なら、AbstractDAO を継承するだけで済みます。
package jp.co.example.foo.infrastructure.dao;
import jp.co.intra_mart.mirage.ext.dao.AbstractDAO;
import jp.co.example.foo.infrastructure.entity.OrderEntity;
/**
* foo_order を操作する DAO です。
*/
public class OrderDAO extends AbstractDAO<OrderEntity> {
}
これだけで次のメソッドが使えます。
| メソッド | 動作 |
|---|---|
insert(T entity) | 1件登録する。監査項目のうち値が null のものを自動設定する |
insertBatch(T... entities) | 複数件を登録する |
update(T entity) | 1件更新する。主キー以外の全カラムを更新し、監査項目のrecord系を自動設定する |
updateBatch(T... entities) | 複数件を更新する |
delete(T entity) | 1件削除する |
deleteBatch(T... entities) | 複数件を削除する |
find(Object... ids) | 主キーで1件取得する。該当がなければ null を返す |
find の引数には主キーの値を渡します。
複合主キーの場合は、エンティティでの宣言順に複数の値を並べます。
更新は既存のエンティティをもとに行う
update は部分更新ではありません。
主キー以外の全カラムを SET 句に並べるため、値を設定していないフィールドは null で上書きされます。
また、update が補完するのは record 系の二項目だけで、create 系の二項目は補完しません。
ドメインモデルから新しく組み立てたエンティティを渡すと、登録時の作成者と作成日時が消えます。
final OrderEntity existing = dao.find(orderId);
if (existing != null) {
existing.status = newStatus;
dao.update(existing);
}
find で取得した既存のエンティティに変更点だけを反映してから、update に渡します。
一部のカラムだけを更新したい場合は、UPDATE 文の SQLファイルを用意して sqlManager.executeUpdate で実行します。
executeUpdate は監査項目を自動では設定しないため、更新者と更新日時も SET 句に含めます。
DAO インスタンスの取得
DAO は new せず、DAOFactory から取得します。
final OrderDAO dao = DAOFactory.getTenantDatabaseDAO(OrderDAO.class);
dao.insert(order);
シェアードDBを対象にする場合は、接続IDを添えて取得します。
final OrderDAO dao = DAOFactory.getSharedDatabaseDAO(OrderDAO.class, connectId);
new OrderDAO() で直接生成すると、BaseDAO の sqlManager フィールドが設定されないままになります。
このフィールドは DAOFactory がリフレクション経由で注入するもので、注入されていなければ最初の呼び出しで NullPointerException になります。
取得したインスタンスはスレッドローカルにキャッシュされ、セッションの解放時に自動的にリリースされます。 呼び出し側でキャッシュや解放を管理する必要はありません。
独自クエリと検索条件
基本CRUDで表現できない検索は、SQLファイルと sqlManager の呼び出しで実装します。
package jp.co.example.foo.infrastructure.dao;
import java.util.List;
import jp.co.intra_mart.mirage.ext.dao.AbstractDAO;
import jp.co.example.foo.infrastructure.entity.OrderEntity;
/**
* foo_order を操作する DAO です。
*/
public class OrderDAO extends AbstractDAO<OrderEntity> {
/** SQLファイルパス(クラスパス起点。先頭のスラッシュは付けない) */
private static final String SQL_PATH = "META-INF/sql/jp/co/example/foo/infrastructure/dao/OrderDAO/";
/**
* ステータスを指定して発注一覧を取得します。
* @param status 検索対象のステータス(null の場合は全件)
* @return 発注一覧
*/
public List<OrderEntity> findByStatus(final String status) {
final OrderEntity criteria = new OrderEntity();
criteria.status = status;
return super.sqlManager.getResultList(OrderEntity.class, SQL_PATH.concat("find-by-status.sql"), criteria);
}
}
ここでエンティティは二つの役割を持ちます。
getResultList の第1引数に渡した OrderEntity.class は結果を受け取る型であり、第3引数に渡した criteria は検索条件を運ぶ入れ物です。
SQLファイル側のプレースホルダ名(/*status*/)と、渡したオブジェクトのプロパティ名が一致していれば値がバインドされます。
検索条件に使えるのはエンティティだけではありません。
- エンティティ:条件がテーブルのカラムと対応しているとき。上の例がこれにあたります。
- 任意の JavaBean:期間の開始と終了、ページングの開始位置のように、テーブルにないプロパティを条件にしたいとき。
Map<String, Object>:条件が動的で、専用クラスを作るまでもないとき。キー名がそのままプレースホルダ名になります。
エンティティを条件に流用すると、監査項目まで条件オブジェクトに含まれます。 条件の数が増えたり、カラムに対応しない条件が混ざったりする場合は、専用の JavaBean を用意する選択肢もあります。
SqlManager のメソッドの使い分け
SqlManager には、名前がよく似た二系統のメソッドがあります。
SQLを指定する引数の意味が違うため、取り違えると実行時まで気づきにくくなります。
| 系統 | メソッド | SQLを指定する引数 | パラメータの渡し方 |
|---|---|---|---|
| SQLファイル系 | getResultList / getSingleResult / getCount / executeUpdate / iterate | 2WaySQLファイルのパス | エンティティ、JavaBean、Map |
| SQL文字列系 | getResultListBySql / getSingleResultBySql / executeUpdateBySql / iterateBySql | SQL文そのもの | 可変長引数(? プレースホルダ) |
// SQLファイル系では、SQLの指定はファイルのパスとして解釈される
sqlManager.getResultList(OrderEntity.class, "META-INF/sql/jp/co/example/foo/infrastructure/dao/OrderDAO/find-by-status.sql", criteria);
// SQL文を直接渡すなら BySql 系を使う
sqlManager.getResultListBySql(OrderEntity.class, "SELECT * FROM foo_order WHERE status = ?", status);
xxxBySql 系では2WaySQLのコメント構文が使えません。
条件によってSQLを組み替えたいなら、SQLファイル系を選びます。
このほか、エンティティ単位のCRUD(insertEntity や updateEntity など)とストアドプロシージャ呼び出し(call と callForList)も SqlManager が提供します。
エンティティCRUDは AbstractDAO の各メソッドが内部で呼び出しているため、通常は AbstractDAO 側を使えば済みます。
1件を返すメソッドの戻り値
getSingleResult と find は、件数が一意であることを保証しません。
| 状況 | 挙動 |
|---|---|
| 0件 | 例外にならず null を返す |
| 2件以上 | 例外にならず先頭の1行を返す(残りは捨てられる) |
戻り値は必ず null を確認します。
一意であることが必要な検索では、主キーや一意制約で担保するか、getResultList で取得して件数を確かめます。
ORDER BY のない検索が複数件に一致した場合、どの1件が返るかは DB の返却順で決まります。
件数の取得
getCount は、渡された SQL を SELECT COUNT(*) FROM (...) のサブクエリに丸ごと包んで実行します。
そのため、渡す SQLファイルには一覧を取得するときと同じ形の SELECT を書きます。
SELECT COUNT(*)を書かない:件数を数えた結果の1行をさらに数えることになり、例外を出さずに常に1を返します。ORDER BYを書かない:サブクエリの内側に入るため、SQLServer では構文エラーになります。
SQLファイルの置き場所
SQLファイルは src/main/resources/META-INF/sql の下に、DAOクラスと同じパッケージパスを作り、その下の DAO のクラス名のディレクトリに配置します。
src/main/java/jp/co/example/foo/infrastructure/dao/OrderDAO.java
src/main/resources/META-INF/sql/jp/co/example/foo/infrastructure/dao/OrderDAO/find-by-status.sql
ファイル名はケバブケースで付け、そのファイルを使う DAO のメソッドに対応させます(findByStatus から使うなら find-by-status.sql)。
DB製品ごとに構文が違う場合は、ファイル名の末尾に方言のサフィックスを付けたファイルを追加します(2WaySQL)。
DAO の SQL_PATH はクラスパス起点で、先頭にスラッシュを付けずに META-INF/sql/ から書き始め、末尾を DAO のクラス名のディレクトリにします。
パスがファイルの配置と一致しないと、resource: xxx.sql is not found. で失敗します。
src/main/java に置くとビルド後の実行時クラスパスに含まれず、resource: xxx.sql is not found. で失敗します。
プラットフォーム標準機能のソースツリーでは .java と .sql が同じディレクトリに並んで見えますが、これはビルド前のリポジトリ構成であり、Maven標準レイアウトでの配置先とは別です。
関連ドキュメント
- 2WaySQL:SQLファイルの構文とDB方言別ファイル
- トランザクション制御:
SessionTemplateによる更新・参照処理の実行 - インフラストラクチャ層の実装ルール:DAO を呼び出すリポジトリの実装
- 全体アーキテクチャ:DAOを呼び出すリポジトリ層の位置づけ