GuiceとCubbyとトランザクション管理

前から気になっていたWebアプリケーションフレームワークCubbyが2.0からGuiceやSpringに対応するという話なので、ちょうど最近Guice 2.0が出たことだしちょっと試してみる。

初期セットアップ

Maven 2.0.9 と Eclipse 3.4 (Pleiades) で、公式ページのGuice統合の通りに行う。groupId は example.cubby2 、artifactId は cubby2-guice とした。ただ、Guice 2.0 と JDK6 を使うために、pom.xml に以下の設定変更を行った。

まず、Guice 2.0のjarファイルはMaven2のセントラルリポジトリに無いので、<repositories> 配下に、以下のようにGoogleリポジトリを追加する(コメント参照)。

    <repository>
      <id>guice-maven</id>
      <name>guice maven</name>
      <url>http://guice-maven.googlecode.com/svn/trunk</url>
    </repository>

で、<dependencies> 配下の guice, guice-servlet の version を 2.0 に変更し、aopalliance の依存関係を追加する。

    <dependency>
      <groupId>com.google.code.guice</groupId>
      <artifactId>guice</artifactId>
      <version>2.0</version>
    </dependency>
    <dependency>
      <groupId>com.google.code.guice</groupId>
      <artifactId>guice-servlet</artifactId>
      <version>2.0</version>
    </dependency>
    <dependency>
      <groupId>aopalliance</groupId>
      <artifactId>aopalliance</artifactId>
      <version>1.0</version>
    </dependency>

あと、maven-compiler-plugin のところで以下のように1.6を指定するように変更した。

      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <configuration>
          <source>1.6</source>
          <target>1.6</target>
          <encoding>UTF-8</encoding>
        </configuration>
      </plugin>

これでTomcatの起動にある通りにTomcatで起動して http://localhost:8080/cubby2-guice/ にアクセスすると、雛形が動いていることが確認できた。

DB環境のセットアップ

わたしの環境では、最近Oracle XEで遊んでいるので、Oracle上に以下のような USERS テーブルを作ってみた。

SQL> desc users
 Name                                      Null?    Type
 ----------------------------------------- -------- ----------------------------
 ID                                        NOT NULL NUMBER(18)
 USERNAME                                  NOT NULL VARCHAR2(16)
 PASSWORD                                  NOT NULL VARCHAR2(128)
 DESCRIPTION                                        VARCHAR2(1024)

OracleではIDの自動採番をしてくれないので、それ用のシーケンスを追加しておく。

SQL> create sequence SEQ_USER_ID;

データベースの設定は必ずアプリケーションサーバ側で行うことにしているので、データベースの設定はEclipseのサーバプロジェクトにある server.xml に追加した。*1

server.xml の <GlobalNamingResources> の中に

<Resource auth="Container" driverClassName="oracle.jdbc.OracleDriver" name="jdbc/oracleDataSource" password="test" type="javax.sql.DataSource" url="jdbc:oracle:thin:@localhost:1521:XE" username="test"/>

のような設定を追加して、Webアプリが登録されている <Context> 要素を編集して以下のように ResourceLink を追加する。

<Context docBase="cubby2-guice" path="/cubby2-guice" reloadable="true" source="org.eclipse.jst.jee.server:cubby2-guice">
  <ResourceLink global="jdbc/oracleDataSource" name="jdbc/dataSource" type="javax.sql.DataSource" />
</Context>

また、Oracleのサイトからダウンロードした ojdbc6.jar と orai18n.jar をTomcatインストールフォルダの lib に配置する。

最後に、(Tomcatではなく)アプリケーション側の web.xml に以下を追記して、Tomcatに定義したリソースを参照できるようにする。

  <resource-ref>
    <res-ref-name>jdbc/dataSource</res-ref-name>
    <res-type>javax.sql.DataSource</res-type>
    <res-auth>Container</res-auth>
  </resource-ref>

この辺は環境依存だしCubbyにもGuiceにも全く関係ないのでどうでもいい。とりあえず USERS テーブルを作ったのと、データソースが jdbc/dataSource という名前でアプリケーションから取れるようになったということだけ。

依存ライブラリのセットアップ

データベースアクセスには最近よく使っている iBATIS を使用することにした(JPAとかのほうがGuiceには合ってそうなので、敢えて茨の道を行ってみる)。トランザクション管理については、Guiceでは特に提供されないので、使い慣れたSpringのトランザクション管理の仕組みを流用することにする。

というわけで、pom.xml の <dependencies> から spring-mock と spring-core を消して、以下の依存関係を追加する。

    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>jcl-over-slf4j</artifactId>
      <version>1.5.6</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-tx</artifactId>
      <version>2.5.6.SEC01</version>
      <exclusions>
        <exclusion>
          <groupId>commons-logging</groupId>
          <artifactId>commons-logging</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-mock</artifactId>
      <version>2.0.8</version>
      <scope>test</scope>
      <exclusions>
        <exclusion>
          <groupId>commons-logging</groupId>
          <artifactId>commons-logging</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
    	<groupId>org.springframework</groupId>
    	<artifactId>spring-ibatis</artifactId>
    	<version>2.0.8</version>
      <exclusions>
        <exclusion>
          <groupId>commons-logging</groupId>
          <artifactId>commons-logging</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
    	<groupId>org.apache.ibatis</groupId>
    	<artifactId>ibatis-sqlmap</artifactId>
    	<version>2.3.4.726</version>
    </dependency>

ついでに、slf4j大好きなのでcommons-loggingにはご退場願うことにした。

この辺もいつも通りで、特にCubbyGuice特有の設定ということはないのでどうでもいい。

iBATISの設定

以下の2つのファイルを src/main/resources 以下に置く。

SqlMapConfig.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE sqlMapConfig PUBLIC "-//ibatis.apache.org//DTD SQL Map Config 2.0//EN" "http://ibatis.apache.org/dtd/sql-map-config-2.dtd" >
<sqlMapConfig >
  <settings useStatementNamespaces="true" />
  <sqlMap resource="TEST_USERS_SqlMap.xml" />
</sqlMapConfig>

TEST_USERS_SqlMap.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//ibatis.apache.org//DTD SQL Map 2.0//EN" "http://ibatis.apache.org/dtd/sql-map-2.dtd">
<sqlMap namespace="TEST_USERS">
  <resultMap class="example.cubby2.entity.User" id="ibatorgenerated_BaseResultMap">
    <result column="ID" jdbcType="DECIMAL" property="id" />
    <result column="USERNAME" jdbcType="VARCHAR" property="username" />
    <result column="PASSWORD" jdbcType="VARCHAR" property="password" />
    <result column="DESCRIPTION" jdbcType="VARCHAR" property="description" />
  </resultMap>
  <select id="list" resultMap="ibatorgenerated_BaseResultMap">
    select ID, USERNAME, PASSWORD, DESCRIPTION
    from TEST.USERS
  </select>
  <insert id="insert" parameterClass="example.cubby2.entity.User">
    <selectKey keyProperty="id" resultClass="java.lang.Long" type="pre">
      select SEQ_USER_ID.NEXTVAL from DUAL
    </selectKey>
    insert into TEST.USERS (ID, USERNAME, PASSWORD, DESCRIPTION)
    values (#id:DECIMAL#, #username:VARCHAR#, #password:VARCHAR#, #description:VARCHAR#)
  </insert>
</sqlMap>

また、これに対応するDAOクラスを作成する。

package example.cubby2.dao;

import java.util.List;
import example.cubby2.entity.User;
import example.cubby2.entity.UserExample;

public interface UserDAO {

    /**
     * {@link User} を新規作成します。
     */
    void insert(User record);

    /**
     * 全ての {@link User} のリストを取得します。
     */
    List<User> findAll();
}
package example.cubby2.dao.impl;

import java.util.List;

import org.springframework.orm.ibatis.support.SqlMapClientDaoSupport;

import example.cubby2.dao.UserDAO;
import example.cubby2.entity.User;

public class UserDAOImpl extends SqlMapClientDaoSupport implements UserDAO {
    /**
     * {@inheritDoc}
     */
    public void insert(User record) {
        getSqlMapClientTemplate().insert("TEST_USERS.insert", record);
    }

    /**
     * {@inheritDoc}
     */
    @SuppressWarnings("unchecked")
    public List<User> list() {
        List<User> list = getSqlMapClientTemplate().queryForList("TEST_USERS.list");
        return list;
    }
}

Guice用の設定

ようやく、Guiceに特有の設定に移れる(長かった)。

とりあえず、以下のようなModuleを作成する。

package example.cubby2;

import javax.sql.DataSource;

import example.cubby2.dao.UserDAO;
import example.cubby2.dao.impl.UserDAOImpl;
import example.cubby2.service.Services;

import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.lookup.JndiDataSourceLookup;
import org.springframework.orm.ibatis.SqlMapClientFactoryBean;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionInterceptor;

import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import com.google.inject.matcher.Matchers;

public class SqlMapDaoModule extends AbstractModule {

    protected TransactionInterceptor createTransactionInterceptor() {
        // データソースをJDNIから取得。
        // Springの設定では以下に該当。
        // <jee:jndi-lookup id="dataSource" jndi-name="java:comp/env/jdbc/dataSource"/>
        DataSource dataSource =
            new JndiDataSourceLookup().getDataSource("java:comp/env/jdbc/dataSource");

        // 下の provideSqlMapClientFactoryBean で使用するためにバインド
        bind(DataSource.class).toInstance(dataSource);

        // トランザクションマネージャの作成。
        // Springの設定ファイルでは以下に該当。
        // <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        //   <property name="dataSource" ref="dataSource" />
        // </bean>

        DataSourceTransactionManager txManager =
            new DataSourceTransactionManager(dataSource);

        // @Transactional アノテーションがついているものを対象にする設定
        AnnotationTransactionAttributeSource txAttr =
            new AnnotationTransactionAttributeSource();

        // トランザクションの管理を行うインターセプターを作成。
        // 上の行とあわせて、Springの設定では以下に近い。
        // <tx:annotation-driven transaction-manager="transactionManager" />
        TransactionInterceptor interceptor = new TransactionInterceptor(txManager, txAttr);

        return interceptor;
    }

    // 簡易Provider。
    // bind(SqlMapClientFactoryBean.class).toProvider(このメソッドと同じ値を get() で返すProvider).in(Singleton.class)
    // のような感じ
    @Provides @Singleton
    SqlMapClientFactoryBean provideSqlMapClientFactoryBean(DataSource dataSource)
    throws Exception {
        // SqlMapClientのファクトリーを作成。
        // Springの設定では以下に該当。
        // <bean id="sqlMapClient" class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
        //   <property name="dataSource" ref="dataSource" />
        //   <property name="configLocation" value="classpath:SqlMapConfig.xml" />
        // </bean>
        SqlMapClientFactoryBean bean = new SqlMapClientFactoryBean();
        bean.setConfigLocation(new ClassPathResource("SqlMapConfig.xml"));
        bean.setDataSource(dataSource);
        bean.afterPropertiesSet();
        return bean;
    }

    @Override
    protected void configure() {
        // サービスクラスを配置したパッケージ以下のクラスの、
        // @Transactional アノテーションのついたメソッドを対象にして、
        // TransactionInterceptor を噛ませるようにする。
        bindInterceptor(Matchers.inSubpackage(Services.getServicePackageName()),
                Matchers.annotatedWith(Transactional.class),
                createTransactionInterceptor());

        // UserDAO が要求されると UserDAOImpl をインジェクションするように設定する。
        // DB接続エラー等がすぐにわかるように eager にする。
        bind(UserDAO.class).to(UserDAOImpl.class).asEagerSingleton();
    }
}

上の Matchers.inSubpackage でパッケージを指定するところは文字列で書きたくないので、以下のようなクラスをCubbyのセットアップ時に自動生成された service パッケージの下に置くようにした。

package example.cubby2.service;

public final class Services {
    private static final Package PACKAGE = Services.class.getPackage();

    public static Package getServicePackage() {
        return PACKAGE;
    }

    public static String getServicePackageName() {
        return PACKAGE.getName();
    }
}

また、DAOには自動でSqlMapClientをインジェクションするようにしてほしいので、以下のようなクラスを追加して、今まで SqlMapClientDaoSupport を継承していた UserDAOImpl などのDAO実装クラスは、これを継承するように修正する。

package example.cubby2.dao.impl;

import org.springframework.orm.ibatis.SqlMapClientFactoryBean;
import org.springframework.orm.ibatis.support.SqlMapClientDaoSupport;

import com.google.inject.Inject;
import com.ibatis.sqlmap.client.SqlMapClient;

public class SqlMapClientFactoryDaoSupport extends SqlMapClientDaoSupport {

    @Inject
    public void setSqlMapClientFactoryBean(SqlMapClientFactoryBean bean) {
        setSqlMapClient((SqlMapClient)bean.getObject());
    }
}

最後に、作成した Module を元からある ApplicationModule の configure() に追加する。

public class ApplicationModule extends AbstractModule {

	@Override
	protected void configure() {
            // 追加
	    install(new SqlMapDaoModule());

            install(new AbstractCubbyModule() {
                // 以下省略

サービスクラスの追加

さっき作成した Services クラスと同じ場所に、トランザクションスクリプト的な UserService を作成する。

package example.cubby2.service;

import java.util.List;

import example.cubby2.dao.UserDAO;
import example.cubby2.entity.User;
import example.cubby2.entity.UserExample;

import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import com.google.inject.Inject;

public class UserService {

    private final UserDAO userDAO;

    @Inject
    public UserService(UserDAO userDAO) {
        this.userDAO = userDAO;
    }

    protected UserDAO getUserDAO() {
        return userDAO;
    }

    // Propagationのデフォルトは REQUIRED
    @Transactional
    public void createUser(User user) {
        getUserDAO().insert(user);
    }

    @Transactional(propagation=Propagation.SUPPORTS, readOnly=true)
    public List<User> listUsers() {
        return getUserDAO().list();
    }
}

前節で行った bindInterceptor() により、

  • Services クラスと同じパッケージかそれ以下のパッケージの中のクラスで、
  • @Transactional アノテーションのついたメソッド

トランザクション管理されるので、このクラスの createUser と listUsers がトランザクション管理されることになる。

propagation などの設定は各メソッドのロジックに依存するものと思われるので、ここに書くのはまあ正当と思って良い気がする。

あと、ここで UserService と UserServiceImpl を分けるパターンもあるけど、リモート呼び出しもしないし、テストもサブクラスさえ作れれば特に問題なさそうなのでインタフェースは作らなかった。具象クラスだと Guice に bind() とか書かなくても済むので楽になるというのもある。

個人的に嫌なのが @Inject なんだけど、Guiceの仕組みの中でMethodInterceptorを噛ませようと思うとインスタンス生成をGuiceに任せることが必須になるらしいので、Provider戦法は使えずこうするしか無かった。一応 UserService と UserServiceImpl を分けて以下のように書けばできなくもないけど、記述が面倒。

import com.google.inject.Inject;
import com.google.inject.Injector;
import com.google.inject.Provider;

public abstract class PrepareProvider<T> implements Provider<T> {

    protected Injector injector;

    @Inject
    public void setInjector(Injector injector) {
        this.injector = injector;
    }

    @Override
    public abstract T get();
}
public class SqlMapDaoModule extends AbstractModule {
    // ...中略

    @Override
    protected void configure() {
        // ...中略

        bind(UserService.class).toProvider(new PrepareProvider<UserService>() {
            @Override
            public UserService get() {
                UserServiceImpl us = injector.getInstance(UserServiceImpl.class);
                us.setUserDAO(injector.getInstance(UserDAO.class));
                return us;
            }
        }).asEagerSingleton();
    }

ただ、このProviderとインターセプターの組み合わせについては、開発者の一人が "We want to support it". と言っているのでそのうちサポートされる可能性はあるかも。

アクションの追加

Cubbyのセットアップ時に自動生成された action パッケージの下にテスト用の UsersAction を追加する。

package example.cubby2.action;

import java.util.List;

import example.cubby2.entity.User;
import example.cubby2.service.UserService;

import org.seasar.cubby.action.Action;
import org.seasar.cubby.action.ActionResult;
import org.seasar.cubby.action.Forward;
import org.seasar.cubby.action.Redirect;
import org.seasar.cubby.action.RequestParameter;
import org.seasar.cubby.action.Validation;
import org.seasar.cubby.util.Messages;
import org.seasar.cubby.validator.DefaultValidationRules;
import org.seasar.cubby.validator.ValidationRules;
import org.seasar.cubby.validator.validators.RequiredValidator;

import com.google.inject.Inject;
import com.google.inject.servlet.RequestScoped;

@RequestScoped
public class UsersAction extends Action {

    private ValidationRules validation = new DefaultValidationRules() {
        @Override
        public void initialize() {
            add("username", new RequiredValidator());
            add("password", new RequiredValidator());
        }
    };

    @Inject
    private UserService userService;

    private List<User> users;

    private String username;

    private String password;

    public ValidationRules getValidation() {
        return validation;
    }

    public String getUsername() {
        return username;
    }

    public String getPassword() {
        return password;
    }

    @RequestParameter
    public void setUsername(String username) {
        this.username = username;
    }

    @RequestParameter
    public void setPassword(String password) {
        this.password = password;
    }

    public List<User> getUsers() {
        return users;
    }

    public ActionResult index() {
        users = userService.listUsers();
        return new Forward("index.jsp");
    }

    @Validation(rules="validation", errorPage="index.jsp")
    public ActionResult create() {
        User user = new User();
        user.setPassword(password);
        user.setUsername(username);
        userService.createUser(user);
        flash.put("notice", Messages.getText("user.created", username));
        return new Redirect("/users/");
    }
}

@RequestParameter を見落としていて、Struts2感覚で「なんでパラメータが取れてないんだ?」と悩んだりはしたけど、後は自動生成されたサンプルを参考にすれば全く問題なかった。

上でユーザの作成後に出す flash メッセージを追加しているので、src/main/resources/messages_ja.properties と messages_en.properties にメッセージを追加しておく。

user.created=ユーザー [{0}] を作成しました。
user.created=User [{0}] created.

ApplicationModule の前節で「以下省略」とした部分にアクションが列挙されているので、 作成した UsersAction のクラスを追加する。

install(new AbstractCubbyModule() {

    @Override
    protected PathResolver getPathResolver() {
        final PathTemplateParser pathTemplateParser = new PathTemplateParserImpl();
        final PathResolver pathResolver = new PathResolverImpl(pathTemplateParser);
        pathResolver.add(IndexAction.class);
        pathResolver.add(HelloAction.class);
        pathResolver.add(UsersAction.class); // 追加
        return pathResolver;
    }

ここで追加したクラスは Guice によってインスタンス化されるので、@Inject 指定したサービスクラスも Guice によってインスタンス化され勝手にインジェクションされる。

JSPの作成

Cubbyでは "/アクション名小文字" というパスと自動的に関連づけが行われるようなので、src/main/webapp/users/ というフォルダを作成して、その中に index.jsp を以下のように作成する。

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="ja" xml:lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Style-Type" content="text/css" />
  <meta http-equiv="Content-Script-Type" content="text/JavaScript" />
  <link href="css/default.css" rel="stylesheet" type="text/css" media="screen,projection" charset="utf-8" />
  <title>users</title>
</head>
<body>
<h1>Users</h1>
<c:import url="/common/errors.jsp"/>
<c:import url="/common/notice.jsp"/>

<t:form action="create" value="${action}" method="post">
  username: <t:input type="text" name="username" /><br/>
  password: <t:input type="password" name="password" /><br/>
  <input type="submit" value="create user" />
</t:form>

<table>
<thead>
  <tr>
    <td>id</td>
    <td>name</td>
  </tr>
</thead>
<tbody>
<!-- アクションクラスのプロパティが ${プロパティ名} で参照できる -->
<c:forEach items="${users}" var="user">
  <tr>
    <td><c:out value="${user.id}" /></td>
    <td><c:out value="${user.username}" /></td>
  </tr>
</c:forEach>
</tbody>
</table>
</body>
</html>

これも、自動生成されたサンプルを参考にすれぱすぐにできる。Cubby固有のタグライブラリ(<t:)も、途中まで打つと説明が出るのでWebサイトを参照する必要も無かった。

完成

これで /user/ にアクセスするとユーザの一覧が出てきて、ユーザ名とパスワードを入力するとユーザが作成できたりして、フラッシュメッセージも出たりする。org.springframework.transaction の debug ログを出すとトランザクション管理も行われている様子が見られる。

それにしてもCubbyはすばらしい。XMLファイル不要だし、雛形が親切だし、URLはきれいだし、バリデータもわかりやすいし*2、タグライブラリもシンプルにまとめられているし…。Webサイトや本やソースコードを全く見ずに作れるフレームワークなんて初めてですよ。

Guiceは、1.5以降のJavaのパワーを活用してる実感がして楽しいのはいいんだけど、Springと比べるとDI以外の部分が足りないので結局他のライブラリを使ったり、対象クラスに@Injectと書くのが前提になっていて本来不要なところまでGoogle依存コードになったり*3といろいろ微妙なところもある感じ。

後はテストのやりかたと認証の管理を試してみたい。Spring Securityも使えるんだろうか。

*1:Tomcat 6.0の設定はこの辺とか

*2:Struts2のバリデータは自分でやったほうが楽なほど複雑

*3:まるでGoogleWebサービスのようだ。企業文化なんだろうか