Jakarta Persistence(旧 JPA:便宜的に以下「JPA」と記載しています)による、@Entityを使わないクエリ―の実装サンプルを考えてみました。
例えば、ReadOnlyなDTOを任意に作りたい時などに使えます。
はじめに
少し背景や動機的なところを。
JPAはクエリ―の戻り型とし@Entityを付与したクラスを指定しないといけないため、@Idを付与したプロパティが必須です。
それはそれでつけてしまえば良いのですが、参照系クエリ―は更新系と異なりDBから取得した状態を そのままクライアントへ返却しても十分なケースが多々あります。
最終的に JSONで返却しますが、特に必要でない場合でも「ID(もしくはそれに相当するカラム)」が必要となります。
私には どうもそれが煩わしいと思いました。
参照系クエリ―はクライアントの都合に合わせて自由な型で返したい、と。そのためには可能な限り制約は減らしておきたい、と。
また、JPQLは悪くはないと思うのですが 個人的には標準仕様のSQLの範囲であれば、各種DBにおいても概ね動くのではないか? また、標準仕様に加えて さらに抽象化しても、結局のところ 実行されたSQLを確認することは避けられないと思っています。
ということで、NativeQueryを使うやり方を きちんと整理しておこうと思い立ち色々と久しぶりにJPAと戯れた足跡を未来の自分のために残しておくことにしました(ついでにSQLは外部ファイル化しておきたい*1)。
なお、やってみて思ったことなのですが
「確かにDTOを好きに定義できるのは便利だと思う。ただ当初のクライアントの都合に合わせて自由な型で返したいというのはアクセスする仕様に合わせて変えれば良いので、データの取得時点で あれこれ気にしすぎず、用途に合わせて詰め替えれば良いんじゃないのかな」という、結局のところ原点回帰みたいな感想を持ったわけですが、それは別の話ということで。
part-0:プロジェクトの基本構成 ですが、パッケージやクラスの役割の説明です。
本題とは無関係です。
自分が何故こういう構成にしたのか?というメモなので やり方だけを見たい方は飛ばしていただければと思います。
ルートプロジェクトはこちらです。
https://github.com/vermeer-1977-blog/jakarta-ee9-sample
サブプロジェクト毎に やったことを分けています。
全てのプロジェクトは単独で起動できるように Cargo*2を使っています。
ルートプロジェクトを git cloneした後で、各プロジェクトフォルダにて
./mvnw package
でパッケージを作成して
./mvnw cargo:run
で起動します。
各プロジェクトのアクセスポイントはREADMEに記載しています。
Cargo自体は各EEサーバーで動かす薄いラッパーなのですが、私の手元ではPayara Serverでしか動かせませんでした。その点はご了承ください。
.mvn/wrapper フォルダですが 本来はparentに1つだけ作っておき、サブプロジェクトから相対位置指定でコマンド実行をすれば より無駄は無いと思いますが、今回はブログ用サンプルと言う特性も鑑みて 各プロジェクトに配置しています。
実行環境
part-0:プロジェクトの基本構成
パッケージ構成(レイヤー構成)は「三層+ドメイン」が好きなので、それに則っています。
vermeer.hatenablog.jp
今回は単純な参照アプリということで、Service(UseCase)は不要と考え、Application層を そもそも中継していません。
Presentation層から、Domain層のQueryインターフェース(Repository)を経由してDBから情報を取得しています。なので正しくは「2層+ドメイン」です。
もし参照する際に 事前条件や事後条件の検証をしたい場合は、Application層での操作を挟んでください。
Domain層はEEも含めて依存の無いPOJOです。
Domain層は業務ロジックの中枢みたいな扱いをすべきかもしれませんが、私は「依存の無いPOJOを、適宜 分類して格納する層」くらいに雑な整理をしています。なので、例えば Infrastructure層で何かしらロジカルなことをしたい場合は、関数として抜き出してパッケージで分類をしてDomain層へ配置するという感じです。全部が全部をDomain層へ移動すべきだ!とは思っていないですし 過度にパッケージ移動させるのも違うかもしれないのですが、例えば Domain層に「domain.presentation.converter」というパッケージが例えあったとしても良いんじゃないかなぁと。POJOはユニットテストがしやすいので、その辺りも含めて「Domain層のクラスは依存を持たずにテストはしやすく」であり「依存を持たないテスト対象のクラスはDomain層へ集約しておく」というのが 現時点でのパッケージ整理における方向性です。
この辺りの「Domain Objectは どの層からでも利用する」というところが「三層+ドメイン」を好きな理由でもあります。
余談ではあるのですが 私はTDDが苦手です。もちろんユニットテストは作ると後々で楽になることが多いので作ります。VOやEntityに業務の関心事を集約すべし*3という延長でTDDもあると思っているのですが、「頭が業務フロー的思考」な私には部品は主たる振舞いの流れの中の部品でしかなく、それの先出しを強制されると全体俯瞰への思考から離れるため苦手です。金額計算ロジックを集約するような部品としての業務知識も大事ですが、そもそもこのシステムは何するものぞというフローやユースケースを考える方が 私の思考には親和性があります。私にとって「設計」とは幹となる骨格を扱うことで、枝葉は「設定」と捉えているのかもしれません。
part-0 ですが、データアクセスをJPAの@Entityを使っています。
@Entity
@NamedQueries({
@NamedQuery(name = "UserData.findAll", query = "SELECT u FROM UserData u"),})
public class UserData implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue
private Long id;
private String userName;
private String nickName;
(...)
@RequestScoped
public class UserDataQueryImpl implements UserDataQuery {
@PersistenceContext
EntityManager em;
@Override
public List<User> getUserData() {
var results = em.createNamedQuery("UserData.findAll", UserData.class).getResultList();
var users = results.stream().map(userData -> {
var _names = userData.getUserName().split(" ");
var _userName = new UserName(_names[0], 2 <= _names.length ? _names[1] : "");
return new User(_userName, userData.getNickName());
}).collect(Collectors.toList());
return users;
}
}
NamedQueryでデータを取得して、DomainObject(User.class)へデータを詰め替えて呼び出し元へ返却しています。
Domain層に jakarta.persistence.* パッケージが流入しないように、ここで詰め替えをします。
以下では、この NamedQueryを NativeQueryに変えるところを中心に説明をします
part-1:クラスで実装
NativeQueryによるデータ取得についてクラスを使った実装例です。
受け取るクラスを@Entityではなく、POJOであるDTOにします。このDTOは使用しない「Id」をプロパティから除いています。
このように自分のSQLの結果と同じレイアウトのDTOを準備しておけば良いので、DBスキーマの構造は考えなくてよくなります。
難点は、@Entityで使用できた @OneToMany などが使えないので 階層的な構造を持たせたい場合は 詰め替えが必要です。
@RequestScoped
public class UserDataQueryImpl implements UserDataQuery {
@PersistenceContext
EntityManager em;
@Override
public List<User> getUserData() {
List<UserDataQueryDto> results = em.createNativeQuery("select userName, nickName from UserData", "UserDataQuery").getResultList();
var users = results.stream().map((result) -> {
var user = result.toUser();
return user;
}).collect(Collectors.toList());
return users;
}
}
public class UserDataQueryDto {
private final String userName;
private final String nickName;
public UserDataQueryDto(String userName, String nickName) {
this.userName = userName;
this.nickName = nickName;
}
public User toUser() {
var names = this.userName.split(" ");
var _userName = new UserName(names[0], 2 <= names.length ? names[1] : "");
var user = new User(_userName, this.nickName);
return user;
}
Dummy Class for ResultSet Mapping
<cod></code><code></code><br>
@SqlResultSetMapping(name = "UserDataQuery",
classes = {
@ConstructorResult(
targetClass = UserDataQueryDto.class,
columns = {
@ColumnResult(name = "userName", type = String.class),
@ColumnResult(name = "nickName", type = String.class),}
)
})
@Entity
static class MappingConfig implements Serializable {
@Id
String id;
}
}
em.createNativeQuery の第二引数 "UserDataQuery" と UserDataQueryDto の @SqlResultSetMapping(name = "UserDataQuery", と関連付けされて クエリ―の取得結果がマッピングされます。
項目マッピングは UserDataQueryDto の targetClass = UserDataQueryDto.class で関連付けされます。
List<UserDataQueryDto> results = em.createNativeQuery
のように戻り型を明示的に指定している(varを使っていない)のは、コンパイル時点では型が確定していないためです(実行時に解釈される)*4。
toUser()では、レスポンスJSONの型(User.class)にあわせて DTO(UserDataQueryDto)から詰め替えをしています。
こうすることで Queryのメソッドに変換する処理が集約できます。(なお part-0 でも UserData.class内で 同等のメソッドを準備しておけば同じことが実現できます)
MappingConfig は DTOとNativeQueryの結果をマッピングするためのクラスです。
@SqlResultSetMapping アノテーションは@Entityクラスに付与することが必須となっています。
実際に該当するテーブルが存在する必要はありません(@Tableは必須ではない)。
ただし @Idは必須です。
そういったJPAの都合を踏まえて作成したクラスです。
アクセス修飾子をdefault(無印)にして、他パッケージからのアクセスを抑制しています。privateにしても動きますが、Lintによって警告メッセージ(未使用のクラス)が出たりするので、defaultにしています。
MappingConfigクラスですが、UserDataQueryImpl に記述しても良いですし 独立したクラスで実装しても問題ありません。私はDTOのプロパティと近い場所にあった方が確認しやすいということで上述の箇所に記述することにしました。
概ね これで問題は無いのですが、SQL記述部分は 外部ファイルにしたいです。
複数行にまたがった場合、記述したSQLをコピーして実行検証がやりにくいためです。
EEサーバーで対応するJDKのバージョンが上がり、Text Blocks(JEP 378)が使えるようになったら この辺りの作法も変わっていくかもしれません。
part-2:orm.xmlで実装
NativeQueryを外部ファイルで扱う実装例です。
動機は前述の通り「複数行にまたがった場合、記述したSQLをコピーして実行検証がやりにくいため」です。
orm.xml を使って実現します。
@RequestScoped
public class UserDataQueryImpl implements UserDataQuery {
@PersistenceContext
EntityManager em;
@Override
public List<User> getUserData() {
var results = em.createNamedQuery("UserDataQuery", UserDataQueryDto.class).getResultList();
var users = results.stream().map((result) -> {
var user = result.toUser();
return user;
}).collect(Collectors.toList());
return users;
}
}
public class UserDataQueryDto {
private final String userName;
private final String nickName;
public UserDataQueryDto(String userName, String nickName) {
this.userName = userName;
this.nickName = nickName;
}
public User toUser() {
var names = this.userName.split(" ");
var _userName = new UserName(names[0], 2 <= names.length ? names[1] : "");
var user = new User(_userName, this.nickName);
return user;
}
}
xml version="1.0" encoding="UTF-8"
<entity-mappings
xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
xmlnsxsi="http://www.w3.org/2001/XMLSchema-instance"
xsischemaLocation="http://java.sun.com/xml/ns/persistence/orm_2_2.xsd"
version="2.2">
<named-native-query name="UserDataQuery" result-set-mapping="UserDataQueryDto">
<query>
<![CDATA[
/* UserDataQuery */
select userName, nickName from UserData;
]]>
</query>
</named-native-query>
<sql-result-set-mapping name="UserDataQueryDto">
<constructor-result target-class="vermeer.sample.ee9.infra.queries.userdata.UserDataQueryDto">
<column name="userName" class="java.lang.String"/>
<column name="nickName" class="java.lang.String"/>
</constructor-result>
</sql-result-set-mapping>
</entity-mappings>
UserDataQueryDto のマッピングに関する内容は orm.xmlで記述するので除外です。
UserDataQueryImpl は
var results = em.createNamedQuery("UserDataQuery", UserDataQueryDto.class).getResultList();
のように varが使えるようになっています。Queryの第二引数で型(UserDataQueryDto.class)を渡していることで解釈可能になっているためです。
注意してほしいところは「createNamedQuery」となっているところです。先の「createNativeQuery」ではありません。
orm.xml で定義した名前と一致する情報を使用する(Namedなクエリ―を使用する)という違いがあります。
createNamedQuery の第一引数 "UserDataQuery" は以下の orm.xml の named-native-query の name属性 と関連付いています。
orm.xml の
named-native-query の result-set-mapping="UserDataQueryDto" は sql-result-set-mapping name="UserDataQueryDto" で SQLと項目マッピング定義を関連付けしています。そして、
constructor-result target-class でクラスの完全修飾を記述してDTOクラスとの関連付けをします。
SQL文にコメントで実行クエリー名をメモとして記載しておくことで、ログにもクエリ―名が出力され part-0,part-1と比べて 保守や障害時の調査にも役立ちそうです。
SQLファイルを外部ファイルに できたのですが、orm.xml の sql-result-set-mapping が せっかく Javaを使っているのに型の恩恵を受けられません。ちょっとしたtypoで動かなくなりますし、DTOクラスの場所を移動させても動かなくなります。非常にリファクタリングに弱い仕組みだと思います。
part-1(クラス)、part-2(XML) それぞれ単独では 帯に短し襷に長しです。
ということで 次の part-3 で両方を組み合わせていきます。
part-3:クラスとorm.xmlを組み合わせ
前述の通り、クラスとxmlの良いとこ取りをした実装例です。
私調べの範囲では ミックスした例はあまり紹介はされていませんでしたが個人的には有効な実装だと思っています。
@RequestScoped
public class UserDataQueryImpl implements UserDataQuery {
@PersistenceContext
EntityManager em;
@Override
public List<User> getUserData() {
var results = em.createNamedQuery("UserDataQuery", UserDataQueryDto.class).getResultList();
var users = results.stream().map((result) -> {
var user = result.toUser();
return user;
}).collect(Collectors.toList());
return users;
}
}
public class UserDataQueryDto {
private final String userName;
private final String nickName;
public UserDataQueryDto(String userName, String nickName) {
this.userName = userName;
this.nickName = nickName;
}
public User toUser() {
var names = this.userName.split(" ");
var _userName = new UserName(names[0], 2 <= names.length ? names[1] : "");
var user = new User(_userName, this.nickName);
return user;
}
Dummy Class for ResultSet Mapping.
<code></code><code></code><br>
@SqlResultSetMapping(name = "UserDataQueryDto",
classes = {
@ConstructorResult(
targetClass = UserDataQueryDto.class,
columns = {
@ColumnResult(name = "userName", type = String.class),
@ColumnResult(name = "nickName", type = String.class),}
)
})
@Entity
static class MappingConfig implements Serializable {
@Id
String id;
}
}
xml version="1.0" encoding="UTF-8"
<entity-mappings
xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
xmlnsxsi="http://www.w3.org/2001/XMLSchema-instance"
xsischemaLocation="http://java.sun.com/xml/ns/persistence/orm_2_2.xsd"
version="2.2">
<named-native-query name="UserDataQuery" result-set-mapping="UserDataQueryDto">
<query>
<![CDATA[
/* UserDataQuery */
select userName, nickName from UserData;
]]>
</query>
</named-native-query>
</entity-mappings>
各資産との関連付けは
- createNamedQuery の第一引数 "UserDataQuery" は orm.xml の named-native-query のname属性
- @SqlResultSetMapping(name = "UserDataQueryDto" ... は orm.xmll named-native-query の result-set-mapping属性
となります。
em.createNamedQueryの第二引数のクラス指定はメソッドの戻り値の型、@ConstructorResultの targetClass はDTOのマッピング対象のクラス指定なので、同じクラス(UserDataQueryDto.class)を指定していますが 別の意図による指定です*5。
型マッピングはクラスで実装しているので入力補完も出来ており、リファクタリングにも強い実装になっています。
外部SQLファイル(orm.xml)も、SQL文とマッピングキーだけというシンプルな構成です。
ここまでは orm.xmlを1ファイルだけで管理していました。クエリ―を追加するときには <named-native-query>を追加していくことになります。ただ、それだとチーム開発をする際に1つのファイルを奪い合うため競合が発生してしまいます。
ということで、次は外部SQLファイル(orm.xml)を複数に分けたいと思います。
part-4:外部SQLを複数orm.xmlで管理
クエリ―毎に orm.xml を作成する(複数orm.xmlで管理する)実装例です。
実際の開発では条件を固定してクエリ―を分けるという事はしませんが、ここでは例示を簡素にしたかったので 元のクエリーに絞り込み条件を追加しただけのものを準備しました。
@RequestScoped
public class UserDataQueryImpl implements UserDataQuery {
@PersistenceContext
EntityManager em;
@Override
public List<User> getUserData(String condition) {
var results = condition.equals("data1")
? em.createNamedQuery("UserDataQuery.data1", UserDataQueryDto.class).getResultList()
: em.createNamedQuery("UserDataQuery.data2", UserDataQueryDto.class).getResultList();
var users = results.stream().map((result) -> {
var user = result.toUser();
return user;
}).collect(Collectors.toList());
return users;
}
}
xml version="1.0" encoding="UTF-8"
<persistence version="3.0" xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlnsxsi="http://www.w3.org/2001/XMLSchema-instance"
xsischemaLocation="https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd">
<persistence-unit name="PU" transaction-type="JTA" >
<jta-data-source>jdbc/DemoDS</jta-data-source>
<properties />
<mapping-file>META-INF/orm-data1.xml</mapping-file>
<mapping-file>META-INF/orm-data2.xml</mapping-file>
</persistence-unit>
</persistence>
xml version="1.0" encoding="UTF-8"
<entity-mappings
xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
xmlnsxsi="http://www.w3.org/2001/XMLSchema-instance"
xsischemaLocation="http://java.sun.com/xml/ns/persistence/orm_2_2.xsd"
version="2.2">
<named-native-query name="UserDataQuery.data1" result-set-mapping="UserDataQueryDto">
<query>
<![CDATA[
/* UserDataQuery.data1 */
select userName, nickName from UserData where id = 1;
]]>
</query>
</named-native-query>
</entity-mappings>
xml version="1.0" encoding="UTF-8"
<entity-mappings
xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
xmlnsxsi="http://www.w3.org/2001/XMLSchema-instance"
xsischemaLocation="http://java.sun.com/xml/ns/persistence/orm_2_2.xsd"
version="2.2">
<named-native-query name="UserDataQuery.data2" result-set-mapping="UserDataQueryDto">
<query>
<![CDATA[
/* UserDataQuery.data2 */
select userName, nickName from UserData where id = 2;
]]>
</query>
</named-native-query>
</entity-mappings>
resources
└ META-INF
├─ orm-data1.xml
├─ orm-data2.xml
└─ persistence.xml
UserDataQueryImpl を見ていただければ part-3以前のものより、DTOのマッピングのイメージが付きやすいかもしれません。
UserDataQueryDtoは受け取る型なので、使い回しが出来ます。
- createNamedQuery の第一引数 "UserDataQuery" は orm.xml の named-native-query のname属性
ということで、クエリ―に追加する属性情報を付与したマッピング名にしてマッピングキーとしてユニークにしています。
orm-data1.xml、orm-data2.xml の格納場所は persistence.xml の mapping-file属性として クラスパスを列挙します。
ということで、このパターンが最終稿になります。
なお、これまでクエリ―名と同じ文字列をマッピングキーにしていましたが、システム全体で重複しないようにしなければいけません。その意味ではクラスのFQDNにしておけば確実なのですが、先述の通り やりすぎるとリファクタリングのコストにもなります。
orm.xmlは クラスパス配下であればサブフォルダを作っても良いので、コンテキスト毎にサブフォルダを設けて衝突を避けるというやり方は可能です。
例えば、こんな感じです。
persistence.xml
└── META-INF
├── orm
│ ├── context1
│ │ └── orm-UserDataQuery.xml
│ └── context2
│ └── orm-UserDataQuery.xml
├── persistence.xml
<named-native-query name="context1.UserDataQuery" result-set-mapping="UserDataQueryDto">
<query>
<![CDATA[
(...)
]]>
</query>
</named-native-query>
外部ファイルとマッピングキーの一意性については、色々と考え方はあると思いますので、よしなに決めていただければと思います。
part-5:DTOを中継しない
基本的には part-4で完結なのですが、当初構想であった クエリ―の結果を そのままJSON形式でクライアントに返却する実装例を最後におまけで追記しておきます。
フラットな配列構造を返却するのであれば、この方法で必要十分なこともあると思います。
あくまで私のレイヤー構成を前提としていますので、これでなければいけないということはありません。
package vermeer.sample.ee9.domain;
public class UserDataQueryDto {
private final String userName;
private final String nickName;
public UserDataQueryDto(String userName, String nickName) {
this.userName = userName;
this.nickName = nickName;
}
public String getFirstName() {
return this.userName.split(" ")[0];
}
public String getLastName() {
var _names = this.userName.split(" ");
return 2 <= _names.length ? _names[1] : "";
}
public String getNickName() {
return nickName;
}
}
@RequestScoped
public class UserDataQueryImpl implements UserDataQuery {
@PersistenceContext
EntityManager em;
@Override
public List<UserDataQueryDto> getUserData() {
var results = em.createNamedQuery("UserDataQuery", UserDataQueryDto.class).getResultList();
return results;
}
}
Dummy Class for ResultSet Mapping.
<code></code><code></code><br>
@SqlResultSetMapping(name = "UserDataQueryDto",
classes = {
@ConstructorResult(
targetClass = UserDataQueryDto.class,
columns = {
@ColumnResult(name = "userName", type = String.class),
@ColumnResult(name = "nickName", type = String.class),}
)
})
@Entity
public class UserDataQueryMappingConfig implements Serializable {
@Id
String id;
}
part-0 で説明した通り、Domain層にはEEライブラリを含めて依存の無い状態にしたいため、UserDataQueryDto には @SqlResultSetMapping を記述しないようにしています。
代わりに UserDataQueryMappingConfigとして独立したクラスに変更しました。
せっかく(?)DTOを直接使用するということで、DTOのgetterに UserNameを加工するロジックを追加して Domain Objectととしてロジックを持たせた実装例にしています。
Jakarta RESTful Web Services(旧 JAX-RS)は JavaBeansの仕様に従った形式でJSON形式に編集をしてくれます。
Domain ObjectのパターンとQueryDTOのパターンで分けるのが煩わしいければ*6、本例のようにマッピングクラスを作成するというルールにしても良いかもしれません。
個人的には、フラットなDTOをそのまま返却するよりも、ある程度の階層構造にしてクライアントへ返却するケースが多いと思うので、どちらかといえば本例のパターンがマイナーになるのではないか?と、今回いろいろと試す中で当初構想の逆の思いに至りました。
参考リンク
Cargoの使い方
JPA周りをあれこれやっているときに、ちょうど @backpaper0さんが Cargoを使った EEのリポジトリをTweetされて そこから色々と参考にさせていただきました。
https://github.com/backpaper0/java-you
JPA では Read-Only の Entity は定義できない - A Memorandum
(spring-data-jpa)JPA2.1を利用してPOJO(エンティティクラス以外)を取得する方法 - Qiita
SQL文を外部ファイルに | 老いぼれSEの艱難辛苦
さいごに
5年位前にJPAを使ってあれこれとしたときには、EEサーバーを使わず*7無駄努力をしていました。自分が思いつく方法を手探りでやったので、orm.xmlのやり方など調べた記憶は多少ありますが、自前でsqlファイルを読み込むUtilを作ったりと、とにかく独自路線を突き進んでいました*8。
今は 可能な限り独自実装は避け、EEを上手に使ったり、割り切る前提で考えるように変わったことで 色々と見える世界が変わったように思います。
月日の流れにより情報が増えたという事もありますが(例えば、参考としてSpringの実装例も役に立ちました)、多少は調べて試せるくらいにはなったのかもしれません。
cargoというものを初めて知りました。
ブログなどでコードを公開する際には、動く環境というのは凄くありがたいです。
mvnwを組み合わせることで、手元で環境構築することなく UberJarのように試せるのがすごく良いです。最低限の使い方であれば @backpaper0さんのものや 今回の私のプロジェクトを流用すれば簡単に始められると思います。