美しき青きDoma!~SQLとIDEが奏でる美しきORマッピング~

このブログについて

これはJava Advent Calendar 2014 - Qiitaの10日目の記事です。
昨日(2014/12/09)は@irofさんの「Javaであまりしないコーディング - 日々常々」でした。
明日(2014/12/11)は@dk_masuさんです。


今回、僕は「Doma」というORマッパーについて調べました。
Domaには「バージョン 1」と「バージョン 2」がありますが、今回はJava SE 8に対応している「バージョン 2」を取り上げます。

Domaは日本語のチュートリアルが充実しているため、今回のブログでは、Domaを使うための最初の環境作成や、チュートリアルを読んで理解できるようになるための下地となる、基本的な部分について解説したいと思います。
Domaを使って、簡単な検索Webアプリを作ります。
サーブレットJSPで作っていますので、適宜お使いのフレームワークに置き換えながらお読みください。

公式チュートリアルは下記です。
Welcome to Doma — Doma 2.0 ドキュメント
Javadocはこちら。
org.seasar.doma:doma:2.0.1 API Doc :: Javadoc.IO
このブログを読み終わったら、このチュートリアルを読んで、更に学習を進めてください。

僕はDomaを使うのは今回が初めてなので、勘違いしている部分などあるかもしれません。
何か気になる点などございましたら、コメントなどでご指摘ください。

今までのORマッパー遍歴

僕が今まで使ったことがある主なORマッパーは、S2JDBCJPA(EclipseLink・Hibernate)なのですが、どちらも一長一短があると感じていました。

S2JDBC

タイプセーフ記述が出来たり、SQLを外部ファイルに外だし出来たりと、非常に便利なのですが、
利用する全てのフレームワークSeasarプロダクトにしないと、開発が難しいです。
例えば、MVCフレームワークJSFDIコンテナCDI、ORマッパーだけS2JDBCを使いたい、という事は難しいです。

JPA

Java EE標準のORマッパーで、使いこなせれば非常に強力なのですが、学習コストが高い(要は難しい)です。
例えば、1対nのリレーションシップ、カスケード、エンティティの状態管理、キャッシュなど。
JPQL(SQLに似た問い合わせ言語)をプログラム内に文字列(String)で書かなければならないのも不満でした。
また、実装が複数あるので、それによって同じコードでも挙動が違ったり・・・。

Domaに注目した理由

大規模システムでの事例あり!

最初のきっかけは、JJUG CCC 2014 Fallでの、@matsumanaさんのセッションでした。
テーブル数は約750、画面数は約1000という、かなり大規模なERPだそうです。
僕はちょうど同じ時間帯でラムダ式のセッションをしていたので聞けなかったのですが、実は一番聞きたかったセッションでした。
スライドは後ほど拝見しました↓
Spring Boot + Doma + AngularJSで作るERP #jjug_ccc #ccc_r12

ラムダ式java.time、Optionalなど、Java SE 8の新機能に対応!

これはTwitterで@backpaper0さんから教えていただいて知りました。
JPAJava EE 8で対応するという噂)

依存ライブラリ、一切ナシ!

何かライブラリを使う際は、Mavenなどで依存関係を指定すると、芋ヅル式に大量の依存ライブラリがダウンロードされることが多いです。
そんなものは一切ナシで、DomaのJARファイルが1つあればOKです。
ですから、MVCやDIは他のフレームワーク使って、ORマッパーだけDoma、といったことも容易にできます。
例えば、上記の事例ではSpring Boot+Domaという組み合わせですね。

SQLは外部ファイルに書ける!

自分的には、これが一番デカいです。これは、S2DaoS2JDBCなどのSeasar系ORマッパーの特徴を引き継いでいます。

select
  /*%expand*/*
from
  emp
where
  empno = /* empno */1

上記のようなSQLを、「1SQL 1ファイル」で書くことができます。
また、パラメータ変数は上記のようにコメントで指定し、なおかつダミー値を
「/* empno */1
のようにコメント直後に書くことで、SQLファイル単体で実行することが可能になります。
プログラム内で文字列としてベタ書きすると、そんなことは出来ませんね(下記の様では・・・)。

String sql = new StringBuilder()
    .append("select ")
    .append("* ")
    .append("from ")
    .append("emp ")
    .append("where ")
    .append("empno = ? ")
    .toString();

で、連結する各文字列の最後に半角スペース入れ忘れて「select*fromemp・・・」とかなって実行時に例外(SQLシンタックスエラー)が発生するとか、ありがちだと思います。
そういうことが無いのが非常にいいなと。

DomaでWebアプリを開発するための準備

長くなるので別記事にしました。
今回は、EclipseとGradleを使います。APサーバーはGlassFish 4.1(Tomcatでも大丈夫です)、DBはPostgreSQLです。
Doma 2をEclipse 4.4.1+Gradle 2.2で使うための環境設定・WTPプロジェクト作成・Doma利用設定まで - Java EE 事始め!

AppConfigクラスの作成

まず、JDBC設定やトランザクション管理の中心となる、AppConfigクラスを作成します。

package com.tada.doma2;

import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.sql.DataSource;

import org.seasar.doma.SingletonConfig;
import org.seasar.doma.jdbc.Config;
import org.seasar.doma.jdbc.dialect.Dialect;
import org.seasar.doma.jdbc.dialect.PostgresDialect;
import org.seasar.doma.jdbc.tx.LocalTransactionDataSource;
import org.seasar.doma.jdbc.tx.LocalTransactionManager;
import org.seasar.doma.jdbc.tx.TransactionManager;

@SingletonConfig
public class AppConfig implements Config {
    // シングルトンインスタンス
    private static final AppConfig CONFIG = new AppConfig();
    // SQL方言
    private final Dialect dialect;
    // データソース
    private final LocalTransactionDataSource localTransactionDataSource;
    // トランザクション管理を行う
    private final TransactionManager transactionManager;

    private AppConfig() {
        try {
            dialect = new PostgresDialect();
            DataSource dataSource = (DataSource) InitialContext.doLookup("jdbc/postgres");
            localTransactionDataSource = new LocalTransactionDataSource(dataSource);
            transactionManager = new LocalTransactionManager(
                    localTransactionDataSource.getLocalTransaction(getJdbcLogger()));
        } catch (NamingException e) {
            throw new RuntimeException(e);
        }
    }
    @Override
    public Dialect getDialect() {
        return dialect;
    }
    @Override
    public DataSource getDataSource() {
        return localTransactionDataSource;
    }
    @Override
    public TransactionManager getTransactionManager() {
        return transactionManager;
    }
    public static AppConfig singleton() {
        return CONFIG;
    }
}

注意点としては、@SingletonConfigを付けた上で、シングルトンクラスとして作成することです。
また、シングルトンを返すメソッド名は「singleton()」と決められています。

Gradleタスクでエンティティ等を自動生成

genタスクの実行

build.gradleには、下記のようなタスクを記述します。

task gen << {
    ant.taskdef(resource: 'domagentask.properties',
        classpath: configurations.domaGenRuntime.asPath)
    ant.gen(url: 'jdbc:postgresql://localhost/postgres', user: 'postgres', password: 'P@ssw0rd') {
        entityConfig(packageName: 'com.tada.doma2.entity')
        daoConfig(packageName: 'com.tada.doma2.dao', configClassName: 'com.tada.doma2.AppConfig')
        sqlConfig()
    }
}

このgenタスクを実行するには、プロジェクトを右クリック→[Gradle]-[タスク・クイック・ランチャー]です。
で、表示されたウィンドウに「gen」と記述し、Enterキーで実行です。
すると、エンティティ・リスナー・DAO・SQLファイルが自動生成されます。
f:id:MasatoshiTada:20141204145556p:plain

DAO

package com.tada.doma2.dao;
import com.tada.doma2.AppConfig;
import com.tada.doma2.entity.Emp;
import org.seasar.doma.Dao;
import org.seasar.doma.Delete;
import org.seasar.doma.Insert;
import org.seasar.doma.Select;
import org.seasar.doma.Update;
@Dao(config = AppConfig.class)
public interface EmpDao {
    @Select
    Emp selectById(Integer empno);
    @Insert
    int insert(Emp entity);
    @Update
    int update(Emp entity);
    @Delete
    int delete(Emp entity);
}

インターフェイスであること、@Daoアノテーションが付加されconfig属性に先ほど作成したAppConfigが指定されていること、各メソッドには@Selectなどのアノテーションが付いていること、などが特徴です。
この○○Daoインターフェイスの実装クラス(○○DaoImpl)も自動生成されるのですが、ナビゲータービューからじゃないと見えません。
f:id:MasatoshiTada:20141204151047p:plain
この○○DaoImplは、Java SE 6から導入された「Pluggable Annotation Processing API」で生成されています。
恥ずかしながら初耳でした・・・(^^;

エンティティ

package com.tada.doma2.entity;
import java.time.LocalDate;
import org.seasar.doma.Column;
import org.seasar.doma.Entity;
import org.seasar.doma.Id;
import org.seasar.doma.Table;
@Entity(listener = EmpListener.class)
@Table(name = "emp")
public class Emp {
    @Id
    @Column(name = "empno")
    Integer empno;
    @Column(name = "ename")
    String ename;
    @Column(name = "job")
    String job;
    @Column(name = "mgr")
    Integer mgr;
    // Date and Time API!!
    @Column(name = "hiredate")
    LocalDate hiredate;
    @Column(name = "sal")
    Integer sal;
    @Column(name = "comm")
    Integer comm;
    @Column(name = "deptno")
    Integer deptno;
    // 以下、setter/getter

日付がJava SE 8のLocalDateになっていますね!

SQLファイル

src/main/resources/META-INFフォルダ内には、SQLファイルが生成されています。

select
  /*%expand*/*
from
  emp
where
  empno = /* empno */1

Optionalを使う

EmpDaoクラスを少し書き換えます。

@Dao(config = AppConfig.class)
public interface EmpDao {
    @Select
    Optional<Emp> selectById(Integer empno);
    ・・・

Java SE 8なので、Optionalを返すようにしてみました。
これで、nullチェックすることなく、検索結果の有無を判定できます。

主キー検索Webアプリを作る

サーブレット

package com.tada.doma2.servlet;
import java.io.IOException;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.seasar.doma.jdbc.tx.TransactionManager;
import com.tada.doma2.AppConfig;
import com.tada.doma2.dao.EmpDao;
import com.tada.doma2.dao.EmpDaoImpl;
import com.tada.doma2.entity.Emp;
@WebServlet("/EmpnoSelectServlet")
public class EmpnoSelectServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;
    protected void doGet(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        int empno = Integer.parseInt(request.getParameter("empno"));
        // 本来はDIで取得
        EmpDao empDao = new EmpDaoImpl();
        TransactionManager tm = AppConfig.singleton().getTransactionManager();
        Emp emp = tm.required(() -> {
            return empDao.selectById(empno);
        }).orElseGet(() -> {
            Emp other = new Emp();
            other.setEmpno(empno);
            other.setEname("存在しません");
            return other;
        });
        request.setAttribute("emp", emp);
        RequestDispatcher rd = request.getRequestDispatcher("/empnoSelectResult.jsp");
        rd.forward(request, response);
    }
}

検索の際には、必ずトランザクションが開始されていなければなりません。
それが

tm.required(() -> {
    ・・・
})

の部分です。
このラムダ式内がトランザクション内となります。
処理が正常に完了した際はコミットされ、例外が発生した場合はロールバックされます。
このラムダ式はSupplierで、ラムダ式内でreturnされた値が、そのままrequired()メソッドの戻り値となります。

社員番号入力画面(empnoSelectIndex.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
    <head>
        <title>社員番号入力画面</title>
    </head>
    <body>
       <form action="./EmpnoSelectServlet">
           <input type="text" name="empno">
           <input type="submit" value="送信">
       </form>
    </body>
</html>

結果表示画面(empnoSelectResult.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
    <head>
        <title>結果表示画面</title>
    </head>
    <body>
       ${emp.empno}の社員は${emp.ename}です。<br>
       <a href="./empnoSelectIndex.jsp">社員番号入力画面へ</a>
    </body>
</html>

実行結果

f:id:MasatoshiTada:20141204160852p:plain
f:id:MasatoshiTada:20141204160927p:plain

表の結合

Domaでは、JPAのように1対nのリレーションシップはありません。
例えば、EmpとDeptを結合したい場合は、新たにエンティティを作ることになります。
FAQ — Doma 2.0 ドキュメント

エンティティ

package com.tada.doma2.entity;
import org.seasar.doma.Entity;
import org.seasar.doma.Id;
@Entity
public class EmpJoinDept {
    @Id
    Integer empno;
    String ename;
    Integer deptno;
    String dname;
    // 以下、setter/getter
}

DAOとSQLファイル

EmpDaoにメソッドを追加します。

public interface EmpDao {
    ・・・
    // メソッド追加
    @Select
    Optional<EmpJoinDept> selectByIdJoinDept(Integer empno);
    ・・・
}

作ったメソッドには、コンパイルエラーが出ていると思います。
これは、このメソッドに対応するSQLファイルが、まだ無いからです。
では、このメソッドを1回左クリックしてカーソルを当ててから、右クリック→[Doma]-[Jump to Sql File]とします。
そうすると、「(メソッド名).sql」というファイルが作成されます。
このファイルに、SQLを記述します。
f:id:MasatoshiTada:20141204164909p:plain
ちなみに、SQLはまずpgAdminなどのDB管理ツール上で書いてから、SQLファイルにコピペするという方法が推奨されます。
SQLファイルが作成されたら、DAOのコンパイルエラーは消えます。

サーブレット

package com.tada.doma2.servlet;
import java.io.IOException;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.seasar.doma.jdbc.tx.TransactionManager;
import com.tada.doma2.AppConfig;
import com.tada.doma2.dao.EmpDao;
import com.tada.doma2.dao.EmpDaoImpl;
import com.tada.doma2.entity.EmpJoinDept;
@WebServlet("/EmpnoSelectJoinDeptServlet")
public class EmpnoSelectJoinDeptServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        int empno = Integer.parseInt(request.getParameter("empno"));
        EmpDao empDao = new EmpDaoImpl();
        TransactionManager tm = AppConfig.singleton().getTransactionManager();
        EmpJoinDept emp = tm.required(() -> {
            return empDao.selectByIdJoinDept(empno);
        }).orElseGet(() -> {
            EmpJoinDept other = new EmpJoinDept();
            other.setEname("存在しません");
            return other;
        });
        request.setAttribute("emp", emp);
        RequestDispatcher rd = request.getRequestDispatcher("/empnoSelectJoinDeptResult.jsp");
        rd.forward(request, response);
    }
}

社員番号入力画面(empnoSelectJoinDeptIndex.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
    <head>
        <title>社員番号入力画面(結合版)</title>
    </head>
    <body>
       <form action="./EmpnoSelectJoinDeptServlet">
           <input type="text" name="empno">
           <input type="submit" value="送信">
       </form>
    </body>
</html>

結果表示画面(empnoSelectJoinDeptResult.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
    <head>
        <title>結果表示画面</title>
    </head>
    <body>
        社員番号:${emp.empno}<br>
        社員名:${emp.ename}<br>
        部署番号:${emp.deptno}<br>
        部署番号:${emp.dname}<br>
        <a href="./empnoSelectJoinDeptIndex.jsp">社員番号入力画面へ</a>
    </body>
</html>

実行結果

f:id:MasatoshiTada:20141204165417p:plain
f:id:MasatoshiTada:20141204165422p:plain

今回調べきれなかったこと

長くなってきたので、今回のブログはここまでにしたいと思います。
今回は書けなかった・調べきれなかった主な事項を簡単に列挙します。

ドメイン

Domaのもう1つの特徴として、ドメインクラスがあります。
これは、テーブルの列の型(=エンティティのフィールドの型)として定義するものです。
ドメインを使わない場合、

@Select
public List<Emp> selectByEnameAndJob(String ename, String job);

// ①
List<Emp> list = empDao.selectByEnameAndJob("Ni", "Director");
// ②
List<Emp> list = empDao.selectByEnameAndJob("Director", "Ni");

①と②は、どちらもコンパイルが通ってしまいます。
そこで下記のようにドメインを使えば、それを防ぐことができます。

@Select
public List<Emp> selectByEnameAndJob(Ename ename, Job job);

これは重要な機能なので、今後さらに調査したいと思います。

JTAでのトランザクション管理

今回はDomaのLocalTransactionを利用しましたが、グローバルトランザクションを利用したい場合は、JTAを使わなければなりません。
JTAを使う場合にどう書けばいいのか、今回は分かりませんでした。
おそらくAppConfigクラスの書き方をどうにか変えた上で、こんな感じで書けると思うのですが・・・。

@Transactional(TxType.REQUIRED)
@RequestScoped
public class EmpService {
    @Inject
    EmpDao empDao;

    public Optional<Emp> findById(Integer empno) {
        return empDao.selectById(empno);
    }
}

DAOをDIする方法

上記のコードのように、EmpDaoをDIする場合、CDIだと「bean-discovery-mode="all"」かなあ・・・。
でも何でもかんでもDIできるようになっちゃうので、危険な香りがするなあ・・・。
あとSpringにも、これに相当する設定があるんでしょうか?

2015-10-18追記

DAOをCDIでインジェクションして、JTAトランザクション管理する方法が分かったので、別記事に記載しました!

masatoshitada.hatenadiary.jp

まとめ

Doma 2はシンプル・イズ・ベストだと感じました。
複雑なものはいっさい省き、SQLをエンティティにマッピングするというところに集中しています。
今回は簡単なサンプルしか作っていませんが、変な「ハマりどころ」が無いと思います。
ただ、エンティティを自動生成する際に、対象スキーマ内の全テーブルのエンティティを、1つのパッケージに生成してしまうので、テーブル数が多いと管理が大変そうです。
genタスク実行の際に、対象テーブルを絞ったり、パッケージを分割したりすることは出来るのでしょうか。
それも要研究ですね。

雑感

まだまだ自分の非力さを感じました。技術的にも、文章的にも。
「美しき」をうまく表現できなかったなあ、というのが実感です。反省。
Advent CalendarもDomaも初めてだったので、どうかご勘弁ください。
ここまでお読みくださって、ありがとうございました!

ちなみに

「青き」はSeasarのイメージカラーから来ています。
The Seasar Project