CDIとMyBATIS3.1を組み合わせる

iBATIS2.xの後継ソフトウェアである、MyBatis3.xとJavaEE環境をどう組み合わせるか考える。

MyBatis3.xは、iBATISの開発グループがApache Software FoundationからGoogleCodeに場所を移して開発が続けられているSQLベースのO/Rマッパである。2010年5月にバージョン3.0がリリースされ、2013年1月現在のバージョンは3.1.1である。

JPA(主にHibernate)のようなSQLコーディングなしでDBアクセスというわけにはいかないが、機能がシンプルであり、マニュアルもポイントが絞られて書かれているので覚えやすいフレームワークだ。マニュアルが日本語に翻訳されているのも嬉しい。

1. iBATISとMyBATISの違い

実際に触ってみて、主に2つの点が便利になっている(と私は思う)。

複数データソースを1ファイルで定義

iBATIS2.xの場合、1つのファイルに定義できる接続先は1つだけであった。このため、複数の接続先DBがあると、接続先DBごとにファイルを作成する必要があった。

Oracle用(SqlMapConfig-oracle.xml)

<sqlMapConfig>
    <transactionManager type="EXTERNAL" >
        <dataSource type="JNDI">
            <property name="DataSource" value="jdbc/OracleDS"/>
        </dataSource>
    </transactionManager>
    <sqlMap resource="examples/sqlmap/maps/Book.xml" />
</sqlMapConfig>

PostgreSQL用(SqlMapConfig-postgres.xml)

<!-- PostgreSQL接続用SqlMapConfig.xml -->
<sqlMapConfig>
    <transactionManager type="EXTERNAL" >
        <dataSource type="JNDI">
            <property name="DataSource" value="jdbc/PostgresDS"/>
        </dataSource>
    </transactionManager>
    <sqlMap resource="examples/sqlmap/maps/Book.xml" />
</sqlMapConfig>

MyBATIS3.xの場合、environmentエレメントによって2つのデータソースを1つのファイルに定義できる。

mybatis-config.xml

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE configuration
  PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>

    <!-- default属性の設定は、接続先が1つだけの場合でも必須 -->
    <environments default="Oracle">
        <!-- Oracle用 -->
        <environment id="Oracle">
            <transactionManager type="MANAGED" />
            <dataSource type="JNDI">
                <property name="data_source" value="java:/OracleDS"/>
            </dataSource>
        </environment>
        <!-- PostgreSQL用 -->
        <environment id="PostgreSQL">
            <transactionManager type="MANAGED" />
            <dataSource type="JNDI">
                <property name="data_source" value="java:/PostgresDS"/>
            </dataSource>
        </environment>
    </environments>

    <mappers>
        <mapper resource="examples/sqlmap/maps/Book.xml" />
    </mappers>
</configuration>

設定ファイルを読み込む時のAPIも合わせて変更になっている。
SqlSessionFactoryBuilder#build()の引数にストリームだけ渡したときには、<environments default="Oracle">に設定されたデフォルト環境の設定がロードされる。デフォルト以外の設定をロードするためには、build()メソッドに引数に対象のIDを追加する。具体的には以下のようなコードでロードができる。

try (InputStream in = Resources.getResourceAsStream("mybatis-config.xml")) {
    SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
			
    // デフォルトのenvironmentを読み込む
    SqlSessionFactory sqlSessionFactory = builder.build(in);

    // デフォルト以外のenvironmentを読み込む
// SqlSessionFactory sqlSessionFactory = builder.build(in, "PostgreSQL");
    
    } catch (IOException e) {
        logger.error("mybatis config load failed.", e);
    }
}

注意したいところは、SqlSessionFactoryBuilder#builder()メソッドは内部で引数のストリームをclose()しているため、上記のコードのコメントを外してデフォルトとそれ以外を一度に読み込もうとすると例外が発生する。そのため、environmentのid属性ごとに同じ設定ファイルを対象にストリームを開く必要がある。

try (InputStream in = Resources.getResourceAsStream("mybatis-config.xml")) {
    SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
			
    // デフォルトのenvironmentを読み込む
    SqlSessionFactory sqlSessionFactory = builder.build(in);

} catch (IOException e) {
    logger.error("mybatis config load failed.", e);
}

// TODO 実際のコードではループとか、privateメソッド使って綺麗に繰り返す
// もう一回mybatis-config.xmlを読み込む
try (InputStream in = Resources.getResourceAsStream("mybatis-config.xml")) {
  
    // デフォルト以外のenvironmentを読み込む
    SqlSessionFactory sqlSessionFactory = builder.build(in, "PostgreSQL");
  
} catch (IOException e) {
    logger.error("mybatis config load failed.", e);
}

Mapperインタフェースの導入

Mapperインタフェースによって、タイプセープにSQLを実行できる。iBATIS2.xはそもそもJava1.4を対象に作られていたので、アノテーションジェネリクスに対応しておらず、コンパイルすると警告がたくさん出てきた。(2010/7にリリースされたiBATIS2.3.5ではJDK6の機能を一部のコードで使い始めた)

代表的な部分では、以下のiBATIS2.xのコードがあると思う。SqlMapClient#queryForList()の返り値はListの原型であるためコンパイラに警告され、さらに実行対象のクエリを文字リテラルで指定するので、タイプミスの可能性も多いだろう。

List<Book> books = sqlMapClient.queryForList("query-id");

MyBATISではMapperインタフェースの導入により、タイプセーフにクエリ実行が可能になった。
iBATIS2.xではオプションであったネームスペースの設定が、MyBATIS3.xから必須となり、ネームスペースに対応したインタフェースを作成すると、インタフェース経由でクエリ実行が可能な機能だ。

SQLマップファイル

<mapper namespace="mapper.BookMapper">
    <!-- 図書一覧の取得 -->
    <select id="referBook" resultType="Book">
        SELECT * FROM book;
    </select>
</mapper>

Mapperインタフェース

package mapper;

public interface BookMapper {
    public List<Book> referBook();
}

SQLマッピングファイルと、Mapperインタフェースを設定したら、Mapperインタフェース経由でクエリ実行する。SqlSession#getMapper(Class class)を実行すると、Mapperインタフェースを実装したプロキシオブジェクトがMyBATISより取得できる。

SqlSessionFactory sqlSessionFactory = builder.build(in);
SqlSession session = sqlSessionFactory.getSession();

// BookMapperインタフェースの実装はMyBATISによって自動的に生成される
BookMapper bookMapper = session.getMapper(BookMapper.class);

// クエリ実行
List<Book> bookMapper = bookMapper.referBook();

上記のコードでは、クエリ実行結果がList型で返ってくるので、コンパイラの警告も出力されない。またインタフェースのメソッドを指定して対象のクエリを選択しているので、SQL IDの文字リテラル入力と比べてタイプミスもないだろう。

MyBATISの詳細についてはマニュアルで詳しく解説されている。


2. Java EECDI(Producer)を使ってMyBATISと組み合わせる

さてここからが本題。
MyBATISはSpringと組み合わせる場合は『MyBatis-Spring』と呼ばれる拡張コンポーネントが提供されているが、JavaEEと組み合わせる場合は、自分でなんとかする必要がある。

ここではCDIの@Producesと@Disposeを組み合わせてEJBにSqlSessionをインジェクションしようと思う。
以下のように@InjectでSqlSessionを取得し、SqlSessionを取得する処理とcloseする処理をビジネスロジックから追い出したい。

@Stateless
public class BookManageService {
	
    @Inject
    SqlSession session;
	
    public List<Book> referBook() {
        return session.getMapper(BookMapper.class).referBook();
    }
}

また、複数のデータソースに対応させて、アノテーションによって接続先が異なるSqlSessionも取得したい。

@Inject
@PostgreSQL
SqlSession session;


結果的には以下のようなクラスを作ると、@InjectでSqlSessionが取得できると思う。WEB-INF直下に空のbeans.xmlを置き忘れると、CDI自体が動かないので注意。

import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;

import javax.annotation.PostConstruct;
import javax.ejb.Singleton;
import javax.ejb.Startup;
import javax.enterprise.context.RequestScoped;
import javax.enterprise.inject.Disposes;
import javax.enterprise.inject.Produces;

import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.log4j.Logger;

@Singleton
@Startup
public class SqlSessionProducer {
    private static final Logger logger
        = Logger.getLogger(SqlSessionProducer.class);

    private Map<String, SqlSessionFactory> sqlSessionFactoryCash
        = new HashMap<>();
    private String defaultId;
	
    @PostConstruct
    public void initialize() throws IOException {
        loadDefaultEnvironment();	
        loadEnvironment("PostgreSQL");
    }
	
    private void loadDefaultEnvironment() {
        try (InputStream in 
            = Resources.getResourceAsStream("mybatis-config.xml")) {
      
          SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
			
          // デフォルトのenvironmentを読み込む
          SqlSessionFactory defaultEnvironmentFactory = builder.build(in);
          Configuration con = defaultEnvironmentFactory.getConfiguration();
          defaultId = con.getEnvironment().getId();
          sqlSessionFactoryCash.put(defaultId, defaultEnvironmentFactory);
        } catch (IOException e) {
          logger
            .error("mybatis-config.xml default environment load failed.", e);
        }
    }
	
    private void loadEnvironment(String environment) {
        try (InputStream in
          = Resources.getResourceAsStream("mybatis-config.xml")) {
    
          SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();

          // default以外のenvironmentを読み込む
          SqlSessionFactory factory = builder.build(in, environment);
          sqlSessionFactoryCash.put(environment, factory);

        } catch (IOException e) {
          logger
            .error("mybatis-config.xml " + environment + "load failed.", e);
        }
    }
	
    @Produces
    @RequestScoped
    public SqlSession openSession() {
      return sqlSessionFactoryCash.get(defaultId).openSession();
    }
	
    @Produces
    @RequestScoped
    @PostgreSQL
    public SqlSession openPostgresSession() {
        return sqlSessionFactoryCash.get("PostgreSQL").openSession();
    }
	
    public void closeSession(@Disposes SqlSession sqlSession) {
        sqlSession.close();
    }
	
    public void closePostgreSQLSession(@Disposes @PostgreSQL SqlSession sqlSession) {
        sqlSession.close();
    }
}

ポイント1 @Disposesでクローズ処理

@DisposesはインジェクションしたSqlSessionが破棄されるタイミングで呼び出されるメソッドだ。@Disposeを生かすことで、CDIでインジェクションしたオブジェクトでは、ビジネスロジックからfinallyでクローズする処理を追い出すことができる。

ポイント2 @RequestScopedを使う

インジェクション対象としているステートレスセッションBeanのBookServiceクラスはコンテナによってプールにより再利用されている、APサーバをシャットダウンするまで@Disposesのメソッドが呼ばれない。リクエストスコープを明示的に指定すると、リクエスト処理(ボタンを押して、画面を返すまで)が終わるたびにインジェクションしたSqlSessionが破棄され、@Disposesが呼び出される。
@RequestScopeを付け忘れると、あっという間にDBのコネクションプールが尽きるので注意。

ポイント3 Qualifier(限定子) @PostgreSQLを作る

限定子については、iBATISのことを書いたときの記事を参照。


3. 作っていたときに思ったこと

3-1. InjectionPointを使おうと思ったが、使えなかった

Producerメソッドでは、引数としてインジェクション対象フィールドの型など、色々なメタ情報が入っているInjectionPointを引数として受け取ることができるのだが、今回のように@RequestScopeを明示的に指定する場合は例外が発生して使えなかった。JavaEE6のAPIにも明記されているので仕方ないが、アクセスできた方が便利だった。今回作成したクラスのopenSession()とopenPostgresSession()は、InjectionPointが取得できれば1つのメソッドに共通化できたと思う。

3-2. @Disposesメソッドが共通化できなかった

@Inject
@PostgreSQL
SqlSession session;

のように、インジェクション対象に限定子を指定した場合、Disposesメソッドはあくまで同じ限定子が付与されているメソッドしか呼ばれない為、closeメソッドを接続先ごとに作成した。限定子がなくても、型が合えば呼び出してくれる機能があると、クローズ処理ももう少し共通化できたと思う。


4. ユニットテスト

せっかくJUnit実践入門を読んだので、Mockitoを使ってテストも書いた。
when().thenReturn()の組み合わせは非常に便利で、SqlSessionおよびMapperインタフェースのモックが簡単に書けた。

ビジネスロジックのテストも、Mockitoがあればサクサク進むだろう。Mockitoの詳細な使い方については、JUnit実践入門に詳しく解説されていて、非常に役に立った。

package bookmanager.service;

import java.util.ArrayList;
import java.util.List;

import org.apache.ibatis.session.SqlSession;
import org.junit.Test;

import bookmanager.entity.Book;
import bookmanager.mapper.BookMapper;
import static org.mockito.Mockito.*;
import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;

public class BookManageServiceTest {
	
    @Test
    public void referBookのテスト() {
        BookManageService sut = new BookManageService();
		
        List<Book> stublist = new ArrayList<>();
        Book book = new Book();
        book.setIsbn("test isbn");
        book.setTitle("test title");
        book.setAuthor("test author");
        book.setPublisher("test publisher");
        stublist.add(book);

        // Mapperのモック実装は固定的なリストを返す
        BookMapper mockBookMapper = mock(BookMapper.class);
        when(mockBookMapper.referBook()).thenReturn(stublist);

        // SqlSessionのモック実装はMapperのモック実装を返す
        SqlSession mockSqlSession = mock(SqlSession.class);
        when(mockSqlSession.getMapper(BookMapper.class)).thenReturn(mockBookMapper);
		
        // テスト対象オブジェクトにSqlSessionのモック実装を設定
        sut.session = mockSqlSession;
		
        // テスト対象の実行を検証
        List<Book> actual = sut.referBook();
        assertThat(actual, is(stublist));
    }
}