spring
Doma
TERASOLUNA5

Spring MVCのプロジェクトにDoma2を適用する

More than 1 year has passed since last update.

今回は、Spring MVCプロジェクトにO/R MapperのDoma2を適用してみた話をします。
(実際には、お仕事で使ってるTERASOLUNAに適用したんですが、できるだけTERASOLUNAに限定した話にせずに汎用的なSpring MVC+Doma2の話として紹介したいと思います。)

Doma2はSeaserのS2Daoの後継として開発され、DAOインターフェイスの自動生成により半自動的にSQLを作成することができます。イメージ的にはMyBatis3とJPAの中間?ですね。

以降は単純にDomaと呼んでいきます。

Domaを利用するために作成するもの

item description
Configクラス Domaのセッティングを行うConfigインターフェイス実装クラスです。
Entityクラス DBのテーブルに対応するエンティティクラスです。
Daoインターフェイス DBにアクセスしO/Rマッピングを行うためのインターフェイスです。実装クラスはDomaにより自動生成されます。
SQLファイル Daoインターフェイス実装クラスが使用するSQLを記述するファイルです。

他にも、データカラムを独自の型にマッピングするためのDomainクラス、複数のデータカラムをまとめるためのEmbeddableクラスがありますが、今回は扱いません。

Domaの基本的なアーキテクチャ

Domaでは、Daoインターフェイスを利用してDBアクセスとO/Rマッピングを行います。

実際にはコンパイル時にDaoインターフェイスから実装クラスが自動生成されます。
自動生成された実装クラスにはConfigクラスをセットし、Configクラスを通じてアクセスするデータソースや、SQLファイルの取得方法などが決定します。

DIコンテナを利用する場合はConfigクラスをDIにより実装クラスに注入することができ、その準備も非常にシンプルです。

自動生成をサポートするため、Eclipseで円滑に開発するためにはDoma Toolsプラグインがほぼ必須となります。

また、Daoインターフェイスで実行される更新系のSQLは、使用するEntityクラスに基づいて自動生成されます。
SELECT文や複雑な更新系SQLを使用する場合は、自分で記述する必要があります。

大体の場合、SELECT文はカスタマイズすることになると思うので、それなりに合理的ですね。

Domaを実装してみる

今回は、TERASOLUNAのチュートリアルアプリケーション(TODOアプリ)のMyBatis3版をDomaに換装してみました。

POMファイル(Maven)

何はともあれ、まずは依存関係を追加します。

<!-- dependencies -->
<dependency>
    <groupId>org.seasar.doma</groupId>
    <artifactId>doma</artifactId>
    <version>${doma.version}</version>
</dependency>
<!-- properties -->
<doma.version>2.18.0</doma.version>

あ、MyBatis3の依存関係は要らないので、適宜消してくださいね。

Entityクラス

ここからDomaの実装に入ります。
Entityクラスには、アノテーションベースでDBテーブルとのマッピング情報をセットします。

  • 変更前のクラス
public class Todo implements Serializable {
    private static final long serialVersionUID = 1L;
    private String todoId;
    private String todoTitle;
    private boolean finished;
    private Date createdAt;

    // omitted getter and setter
}
  • Domaに対応したEntityクラス
@Entity(naming = NamingType.SNAKE_LOWER_CASE) // (1)
public class Todo implements Serializable {
    private static final long serialVersionUID = 1L;
    @Id // (2)
    private String todoId;
    private String todoTitle;
    private boolean finished;
    private Date createdAt;

    // omitted getter and setter
}
no. description
(1) Entityクラスには、@Entityを付与します。naming属性は、Entityクラスのフィールド名とテーブルのカラム名を自動マッピングするためのネーミングルールです。例では、カラム名はフィールド名をスネークケース(単語をアンダーバーで結んだ名前)にしたものとなります。
(2) テーブルの主キーに対応するフィールドには、@Idを付与します。複合主キーの場合は@Idを複数のフィールドに付与するだけです。簡単ですね。

フィールド名がネーミングルールに沿わない場合は、@Columnを付与することで任意の名前を付けることができますが、基本的にはネーミングルールに沿うような設計をさせるべきだと思います。

DBのIDENTITYを利用した自動採番などにも対応していますが、今回は使わないので紹介しません。

Daoインターフェイス

次に、MyBatisのMapperインターフェイスからDomaのDaoインターフェイスに換装します。
Daoインターフェイスでは、アノテーションベースで実装クラスを自動生成するための情報をセットします。

前準備として、Daoインターフェイスに共通する設定をまとめるアノテーションインターフェイスを作成します。

// (1)
@AnnotateWith(annotations = {
        @Annotation(target = AnnotationTarget.CLASS, type = Repository.class),
        @Annotation(target = AnnotationTarget.CONSTRUCTOR, type = Autowired.class) })
public @interface DomaRepository {
}
no. description
(1) @AnnotaitonWithを利用して、自動生成される実装クラスに付与するアノテーションを指定します。例では、Springのコンポーネントスキャンの対象とするためクラスレベルで@Repositoryを付与し、DomaのConfigクラスを注入するためコンストラクタに@Autowiredを付与しています。

ここでは@Repository@Autowiredを付与していますが、それぞれ@Component@InjectでもOKです。

次に、Daoインターフェイスを実装していきます。

  • 変更前のクラス
public interface TodoRepository {
    Todo findOne(String todoId);
    Collection<Todo> findAll();
    void create(Todo todo);
    boolean update(Todo todo);
    void delete(Todo todo);
    long countByFinished(boolean finished);
}
  • Domaに対応したDaoインターフェイス
@Dao // (1)
@DomaRepository // (2)
public interface TodoRepository {
    @Select // (3)
    Todo findOne(String todoId);
    @Select
    List<Todo> findAll(); // (4)
    @Insert // (5)
    int create(Todo todo); // (6)
    @Update
    int update(Todo todo);
    @Delete
    int delete(Todo todo);
    @Select
    long countByFinished(boolean finished);
}
no. description
(1) Daoインターフェイスには、@Daoを付与します。
(2) 先ほど作成した@DomaRepositoryを付与します。これにより、Daoインターフェイス実装クラスに@Repository@Autowiredが付与されます。
(3) SELECT文を発行するメソッドには、@Selectを付与します。@Selectでは実行するSQLを記述したSQLファイルを作成する必要があります。SQLファイルについては後で解説します。
(4) 検索結果として複数行を取得するメソッドの戻り値は、java.util.List型である必要があります。変更前はjava.util.Collectionを使っていたので、シグネチャを変更する必要がありました…
(5) 更新系のSQLを発行するメソッドには、@Insert@UpdateDeleteのいずれかを付与します。これらのSQLはメソッド引数に指定したEntityクラスをベースに自動生成されるためラクチンです!
(6) ここでもメソッドシグネチャを変更する必要がありました。更新系のSQLを発行するメソッドは更新された行数を返却するため、戻り値の型はint型である必要があります。

更新系のSQLでも、自動生成ではなく複雑なSQLを自分で記述したい場合もあると思いますが、@Selectと同様にSQLファイルを作成すれば、そちらを使用することができます。簡単ですね。

Daoインターフェイス実装クラスをコンポーネントスキャンで解決できるようにしたので、Serviceクラスを変更する必要はありません。(今回はシグネチャの変更も影響ありませんでした。)

SQLファイル

次に、SQLファイルを作成します。

SQLファイルはMETA-INF配下に[パッケージ名]/[Daoインターフェイス名]のフォルダを作成し、[メソッド名].sqlのファイル名で格納します。今回のTodoRepositoryなら以下のようになります。

  • todo.domain.repository.todo.TodoRepository#findOne のSQLファイル
src/main/resources
└ META-INF
  └ todo
    └ domain
      └ repository
        └ todo
          └ TodoRepository
            └ findeOne.sql

では次に、SQLファイルの内容を見てみましょう。

SELECT
    todo_id,
    todo_title,
    finished,
    created_at
FROM
    todo
WHERE
    todo_id = /* todoId */'todo-001' // (1)

DomaでSQL(ステートメント)にパラメータを使用する場合は、SQLコメントにパラメータ名を記述します。また、SQLファイルのパースのため正しい文法でSQLを書く必要があり、SQLコメントの後にダミーのパラメータ値を併せて記述する必要があります。

ここではSQLファイルについての細かい解説はしないので、公式リファレンスを確認してみてくださいね。

Configクラス

最後に、Daoインターフェイス実装クラスに注入するConfigクラスを作成します。
ここでは以下のセットアップを行うConfigクラスを作成しています。

  • DBに接続するDataSource
  • DBごとの方言を解決するDialect
  • SQLファイルの取得・キャッシュを行うSqlFileRepository
public class DefaultDomaConfig implements Config {

    private DataSource dataSource;
    private Dialect dialect;
    private SqlFileRepository sqlFileRepository;

    @Override
    public DataSource getDataSource() {
        return new TransactionAwareDataSourceProxy(dataSource); // (1)
    }

    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Dialect getDialect() {
        return dialect;
    }

    public void setDialect(Dialect dialect) {
        this.dialect = dialect;
    }

    @Override
    public SqlFileRepository getSqlFileRepository() {
        return sqlFileRepository;
    }

    public void setSqlFileRepository(SqlFileRepository sqlFileRepository) {
        this.sqlFileRepository = sqlFileRepository;
    }

}
no. description
(1) ここでのポイントは、DataSourceをTransactionAwareDataSourceProxyをラップしている点です。TransactionAwareDataSourceProxyを使用することで、DaoインターフェイスがDataSourceを使用するとき、Springのトランザクション管理を適用することができます。(つまり、Serviceクラスに付与した@Transactionalが有効に働きます。)

作成したConfigクラスはSpringのBean定義に追加しましょう。
(TERASOLUNAだと[プロジェクト名]-infra.xmlになります。)

<bean id="domaConfig" class="jp.yoshikawaa.gfw.doma.jdbc.DefaultDomaConfig">
    <property name="dataSource" ref="dataSource" />
    <property name="dialect" ref="dialect" />
    <property name="sqlFileRepository" ref="sqlFileRepository" />
</bean>

<bean id="dialect" class="org.seasar.doma.jdbc.dialect.H2Dialect" />
<bean id="sqlFileRepository" class="org.seasar.doma.jdbc.GreedyCacheSqlFileRepository" />
no. description
(1) DataSourceとして、別途Bean定義してあるdataSourceを指定しています。
(2) Dialectとして、H2DBのDialectを指定しています。他にもたくさんのDBがサポートされているので、気になる方は公式リファレンスを見てください。
(3) SqlFileRepositoryとして、GreedyCacheSqlFileRepositoryを指定しています。GreedyCacheSqlFileRepositoryは読み込んだSQLファイルと、記述されたSQLをパースした結果をキャッシュしてくれます。キャッシュが必要ない場合はNoCacheSqlFileRepositoryを使用すると良いでしょう。

DataSourceを複数使い分ける場合は、Configクラスも複数定義、Configクラスが複数DataSourceを扱えるように実装する、RoutingDataSourceを利用するなどいくつか手段がありますね。ちなみに、Configクラスを複数定義する場合はDaoインターフェイスに付与した@Daoで注入するクラスを指定することができます。

ここで気づいた方もいるかと思いますが、通常Configクラスは最初に作るものです。今回は理解しやすいように最後に作成するフローで説明しているだけなので、お間違いないよう。

動作確認してみる

Domaに対応したTodoアプリが完成したので、EclipseのWTP機能で動かしてみると、、、
NoSuchBeanDefinitionExceptionが発生するかもしれません!

こんなときは、以下の2つを確認してみると良いです。

  • EclipseにDoma Toolsをインストールしてみる
    EclipseではDaoインターフェイス実装クラスを自動生成してくれないので、非常に開発しにくいです。Doma Toolsをインストールすることで、SQLファイルの変更を契機に自動生成してくれるようになるので、Doma使うならぜひインストールを!

  • プロジェクトをコンパイルしてみる
    DaoインターフェイスがDomaのコーディング規則に沿っていないと、コンパイルエラーになるはずです。起動時にNoSuchBeanDefinitionExceptionだと、ちょっと想像しにくいですね…

SQLログを出力してみる

TERASOLUNAではSQLログの出力にlog4jdbc-remixを採用していますが、
Bean定義で実際のDataSourceをラップしなければならないので、開発時と本番でBean定義を変更すべきというデメリットがあります。
また、log4jdbc-remix自体もう更新されていないので、古いものはちょっと…

幸いにもDomaはSQLログの出力もサポートしているので、これを使用して実装してみましょう。
ポイントは以下の3点です。

  • SQLログを出力するロガーの選定は、Configクラスで行う
  • SQLログを出力するロガーは、デフォルトでjava.util.logging.Loggerが採用されている
  • SQLログを出力するロガーを拡張する場合は、AbstractJdbcLogger抽象クラスを実装する

JdbcLoggerクラス

TERASOLUNAではSLF4Jでログ出力を行っているため、まずはAbstractJdbcLogger抽象クラスを実装してSLF4Jでログ出力してみましょう。

public class Slf4JJdbcLogger extends AbstractJdbcLogger<Level> { // (1)

    private static final Logger logger = LoggerFactory.getLogger(Slf4JJdbcLogger.class);

    public Slf4JJdbcLogger() {
        this(Level.DEBUG);
    }

    protected Slf4JJdbcLogger(Level level) {
        super(level); // (2)
    }
    // (3)
    @Override
    protected void log(Level level, String callerClassName, String callerMethodName, Throwable throwable,
            Supplier<String> messageSupplier) {

        switch (level) {
        case ERROR:
            if (logger.isErrorEnabled())
                logger.error(buildLogMessage(callerClassName, callerMethodName, messageSupplier), throwable);
            break;
        case WARN:
            if (logger.isWarnEnabled())
                logger.warn(buildLogMessage(callerClassName, callerMethodName, messageSupplier), throwable);
            break;
        case INFO:
            if (logger.isInfoEnabled())
                logger.info(buildLogMessage(callerClassName, callerMethodName, messageSupplier));
            break;
        case DEBUG:
            if (logger.isDebugEnabled())
                logger.debug(buildLogMessage(callerClassName, callerMethodName, messageSupplier));
            break;
        default:
            if (logger.isTraceEnabled())
                logger.trace(buildLogMessage(callerClassName, callerMethodName, messageSupplier));
            break;
        }
    }
    // (4)
    protected String buildLogMessage(String callerClassName, String callerMethodName,
            Supplier<String> messageSupplier) {
        return messageSupplier.get();
    }

}
no. description
(1) AbstractJdbcLoggerの型パラメータはログレベルになります。SLF4JではログレベルがEnumで提供されているので、これを指定しています。
(2) AbstractJdbcLoggerのコンストラクタにセットしたログレベルで、すべてのログが出力されます。AbstractJdbcLoggerではSQLログ以外にも様々なタイミングでログ出力することができ、個別にログレベルを変更することもできます。今回は、デフォルトDEBUGレベルでログ出力することにしました。
(3) AbstractJdbcLogger#logメソッドは、すべてのログ出力で使用されています。なので、最終的にログ出力を行うロガーを変更したいだけであれば、ここを拡張すればOKです。
(4) ログメッセージとして、各ログ出力メソッドから渡されるmessageSupplier引数を使用しています。デフォルト実装では、これで指定されたSQLファイル名や実行するSQLがログ出力できます。

今回はロガーだけ変更していますが、ログメッセージのフォーマットやログの種類ごとのログレベルを変更することも可能です。例えば、AbstractJdbcLogger#logSqlメソッドを拡張することで、実行するSQLのログ出力を変更することができます。

Configクラス

続いて、作成したJdbcLoggerを使用するよう、Configクラスを変更してみましょう。

public class DefaultDomaConfig implements Config {

    // omitted other properties

    // (1)
    private Level jdbcLogLevel;

    public void setJdbcLogLevel(Level jdbcLogLevel) {
        this.jdbcLogLevel = jdbcLogLevel;
    }

     // (2)
   @Override
    public JdbcLogger getJdbcLogger() {
        return (jdbcLogLevel == null) ? new Slf4JJdbcLogger() : new Slf4JJdbcLogger(jdbcLogLevel);
    }

}
no. description
(1) SQLログを出力するログレベルを変更できるよう、jdbcLogLevelプロパティを用意します。
(2) AbstractJdbcLogger#getJdbcLoggerを拡張することで、任意のJdbcLoggerクラスを使用することができます。ここでは、先ほど作成したSlf4JJdbcLoggerを使用します。

動作確認してみる

動作確認してみると、以下のようにSQLがログ出力されることが確認できました。
(出力される日時などは省略しています)

level:DEBUG logger:todo.doma.jdbc.Slf4JJdbcLogger       message:[DOMA2220] ENTER  : クラス=[todo.domain.repository.todo.ITodoRepositoryImpl], メソッド=[findOne]
level:DEBUG logger:todo.doma.jdbc.Slf4JJdbcLogger       message:[DOMA2076] SQLログ : SQLファイル=[META-INF/todo/domain/repository/todo/ITodoRepository/findOne.sql],
SELECT
    todo_id,
    todo_title,
    finished,
    created_at
FROM
    todo
WHERE
    todo_id = 'todo-002'
level:DEBUG logger:todo.doma.jdbc.Slf4JJdbcLogger       message:[DOMA2221] EXIT   : クラス=[todo.domain.repository.todo.ITodoRepositoryImpl], メソッド=[findOne]

本番向けに出力しないようにするには、AppenderのログレベルをINFO以上に変更するだけなので、シンプルですね。

まとめ

Doma2は公式リファレンスが充実しているので、比較的簡単に適用することができました。

個人的には、MyBatis3に比べて記述するSQL量が減る&JPAのように独自のクエリ言語じゃないので良いと思いましたが、有効活用するにはテーブル(Entity)の単位がDomaの設計思想に合致するか(つまり、複雑なSQLを書くことが多くならないか)を検討する必要がありそうですね。

また、自動生成によりDaoインターフェイス作成のお作法があるのと、開発ツール上の制約もあるため、実際の開発で採用するなら開発初期にしっかりと教育することも重要ですね。(程度の差はあれ、どのライブラリを使っても同じですが)

参考