2012年4月28日土曜日

例外処理

データベースアクセスの際に使われる決まり文句にはトランザクション境界を明示する begin, commit/rollback の他、例外を捕まえるための tyr {..} catch {..} [final {..}] があります。前者については @Transactional アノテーションや『トランザクションを“Declarative”に管理する』で紹介した“Declarative transaction management(宣言的トランザクション管理)”が使えます。一方、例外処理においても Spring Framework には便利な機能が用意されています。

HandlerExceptionResolver
その一つが HandlerExceptionResolver です。リファレンスの“16.11 Handling exceptions”には「Spring の HandlerExceptionResolver 実装は、コントローラー実行中に発生した予測不能の例外を処理する」とあります。

このインターフェースを使えば、Spring に DAO レイヤーから上げられた例外を特定のビューに解決させることができます。例えば以下のような感じです。

HibernateExceptionResolver.java
HandlerExceptionResolver を実装したカスタム例外リゾルバーです。同インターフェースに定義された resolveException() メソッドを実装しています。このメソッド内では、受け取った例外を instanceof で判別し、戻り値として ModelAndView に特定のビューをセットしています。必要とあらば例外情報をログに記録するなどの処理を挟むこともできます。このクラスでタッチしない例外の場合は null を返します。

尚、このクラス(HibernateExceptionResolver)では、抽象クラス AbstractHandlerExceptionResolver の作法を参考に HandlerExceptionResolver の他、Ordered を実装しています。order の値が大きいほど優先順位が低くなります。デフォルトとして Integer.MAX_VALUE を設定しています。
package wrider.controller;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.hibernate.HibernateException;
import org.springframework.core.Ordered;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

public class HibernateExceptionResolver 
    implements HandlerExceptionResolver, Ordered {
  
  private int order = Integer.MAX_VALUE;
  
  public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    ModelAndView mav = new ModelAndView();
    
    if (ex instanceof HibernateException) {
      mav.addObject("pageTitle", "Sorry");
      mav.setViewName("ex/dae");
      return mav;
    }
    else {
      return null;
    }
  }
  
  public int getOrder() {
    return this.order;
  }

}

上記リゾルバーを applicationContext.xml に登録します。
<!-- Exception Resolver -->
  <bean class="wrider.controller.HibernateExceptionResolver"/>

SimpleMappingExceptionResolver
もしも、これといった例外処理は必要なく、単純に特定のビューに解決するだけでいいのであれば SimpleMappingExceptionResolver が簡単です。以下のような感じで applicationContext.xml に SimpleMappingExceptionResolver を登録し、例外クラスとビュー名を設定するだけです。
<!-- Exception Resolve & Translate -->
  <bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">
    <property name="exceptionMappings">
      <map>
        <entry key="org.hibernate.HibernateException" value="ex/dae"></entry>
      </map>
    </property>
  </bean>

@ExceptionHandler
また、コントローラークラス/メソッドに @ExceptionHandler アノテーションを付ける方法もあります。以下のような感じです。

AccountController.java(抜粋)
HibernateException が上がってきたら HibernateExceptionHandler()メソッドで処理するように定義しています。
@ExceptionHandler(org.hibernate.HibernateException.class)
  public ModelAndView HibernateExceptionHandler(org.hibernate.HibernateException ex, HttpServletRequest request) {
    ModelAndView mav = new ModelAndView();
    mav.setViewName("ex/dae");
    return mav;
  }

DAO メソッドのスリム化
トランザクションや例外に関する設定、処理を本体コードの外側に定義することで、本体コードを大幅にスリム化できます。例えば、以前作った ProfileManagement.javagetUserProfile()メソッドは以下のようになります。

使用前
public UserProfile getUserProfile(IdCard idCard) {
    
    final String HQL = 
      "from UserProfile as up " + 
      "where up.email = :email and up.password = :password";
    
    /*
     * Boilerplate pattern
     */
    
    UserProfile result = null;
    Session session = txManager.getSessionFactory().getCurrentSession();
    Transaction tx = session.getTransaction();
    
    try {
      tx.begin();
      
      result = (UserProfile)session.createQuery(HQL)
            .setParameter("email", idCard.getEmail())
            .setParameter("password", idCard.getPassword())
            .uniqueResult();
      
      tx.commit();
      
    } catch (Exception e) {
      tx.rollback();
      System.out.println("error occured: " + e.toString());
    }
    
    return result;
    
  }

使用後
決まり文句の排除に加え、setParameter()メソッドで個々にパラメータをセットする代わりに、setProperties()メソッドを使い HQL のプレースホルダーに、IdCard オブジェクトのフィールド値を割り当てています。
public UserProfile getUserProfile(IdCard idCard) {
    
    final String HQL = 
      "from UserProfile as up " + 
      "where up.email = :email and up.password = :password";
    
    return (UserProfile)txManager.getSessionFactory().getCurrentSession()
            .createQuery(HQL)
            .setProperties(idCard)
            .uniqueResult();
  }

実験
試しに上記コードの where up.email = :email .. の部分を up.mail に変えて例外を発生させてみました。すると上のほうで紹介したどの方法でも、図のような「ごめんなさい画面」が表示されました。

ある処理に関連する処理を一つのコードにまとめて書くより、特定の処理を専門的に扱うコードの連携として設計した方が、一つ一つのコードの目的が明確になりますし、重複やミス、見落としの確率も下がることを実感しました。何より、作りたい本来の機能に集中できます。Spring のようなフレームワークを使うことの本質を垣間見たような気がします。

2012年4月27日金曜日

トランザクションを“Declarative”に管理する

データベースにアクセスする際の“boilerplate(決まり文句)”にはうんざりさせられます。しかし、begin, commit/rollback といった決まり文句は、トランザクションの境界線をはっきりさせるために必要です。また、例外を捕まえて rollback するために try {..} catch {..}を使います。これもまた面倒です。

Declarative Transaction Management
Spring Framework はそうした面倒を緩和してくれる仕掛けを持っています。“Declarative Transaction Management(宣言的トランザクション管理)”もその一つです。これを使うと、「どの例外が発生したら rollback するか(declarative rollback rules)」とか「ダーティリードやファントムリードを許すか(isolation level)」とか「現在のトランザクションをサスペンドして、新しいトランザクションを生成するか(transaction propagation)」といったことを“宣言”できます。

宣言は XML で記述する方法と、@Transactional アノテーションの属性として記述する方法があります。今回、もちろん @Transactional は使いますが、種々の設定は XML で行うことにします。

その前に『Hibernate でデータアクセス(3)』で作成した hibernate.cfg.xml から、以下の行を削除しておきます。

<property name="hibernate.current_session_context_class">thread</property>

というか、JTA を使わないのであればこの項目は厳禁みたいです。以下のような書き込みが初歩的な相談事として巷に溢れていました。

宣言
リファレンス“11.5.1 Understanding the Spring Framework's declarative transaction implementation”に、Spring の宣言的トランザクションは AOP Proxy を介して実現されているとあります。実際、AOP と同様に AdvicePointcut を定義して、それらを Advisor で結び付けるというのが基本になります。

では、11.5.2 Example of declarative transaction implementation 以降の例と説明に従いながらトランザクションを宣言していきます。

名前空間
まずは、<tx:advice/> などの要素を利用するための名前空間を追加します。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    :
  xmlns:tx="http://www.springframework.org/schema/tx"
  xmlns:aop="http://www.springframework.org/schema/aop"
    :
  xsi:schemaLocation="
    :
  http://www.springframework.org/schema/tx 
  http://www.springframework.org/schema/tx/spring-tx-3.0.xsd
  http://www.springframework.org/schema/aop 
  http://www.springframework.org/schema/aop/spring-aop-3.0.xsd">
    :

<tx:advice/>
この advice にトランザクションに対する各種属性を記述していきます。トランザクション属性を適用したいメソッド名を <tx:method/> タグの name で指定します。デフォルト設定のままでいい場合は、その他の記述は必要ありません。例えば <tx:method name="*"/> みたいな感じです。
read-only
トランザクションがリードオンリーの場合は true にします。
timeout
トランザクションがタイムアウトするまでの時間を秒数で指定します。
rollback-for
ロールバックの切欠となる例外クラスを指定します。複数の場合はカンマ区切り。@Transactional アノテーションの説明には、Throwable のサブクラスでなければならないと記述されています。FQCN(完全修飾クラス名)で書いた方が無難かも。
no-rollback-for
rollback-for の逆の意味です。
propagation
トランザクションの伝播(propagation)に関する設定です。11.5.7 Transaction propagationREQUIRED, REQUIRES_NEW, NESTED の動きが解説されています。

また、IBM developerWorks の記事“Transaction strategies: Understanding transaction pitfalls”は、propagation と他の設定を組み合わせた際の動作を知る参考になると思います。同じ read-only + propagation.REQUIRED でも JDBC と JPA では内部の動きが違うんですね。

因みにこの記事にもある Unit of Work の考え方については Hibernate Core リファレンス“13.1.1. Unit of work”が参考になります。
isolation:
トランザクションの分離(isolation)レベルに関する設定です。コミット前のデータでもかまわない場合は READ_UNCOMMITTED, 最低限コミットされていなければならない場合は READ_COMMITTED, トランザクション内での繰り返しリードでデータが変わると困る場合は REPEATABLE_READ, 他のトランザクションの影響は絶対に許さない場合は SERIALIZABLE, その辺のことはデータソースに丸投げで構わない場合は DEFAULT という感じになると思います。ロックやパフォーマンスにも関わる部分なので慎重に考えたい項目です。

以上を勘案して次のように設定してみました。「change」で始まるメソッドでは、全ての例外が rollback のトリガーとなるよう設定していますが、もっと条件を細かくしてもいいかもしれません。
:
  <bean id="txManager"
    class="org.springframework.orm.hibernate4.HibernateTransactionManager"
    p:sessionFactory-ref="sessionFactory">
  </bean>
  
  <tx:advice id="noRollBackTxAdvice" transaction-manager="txManager">
    <tx:attributes>
      <tx:method name="get*" read-only="true" isolation="READ_UNCOMMITTED" propagation="REQUIRED"/>
    </tx:attributes>
  </tx:advice>
  <tx:advice id="rollBackTxAdvice" transaction-manager="txManager">
    <tx:attributes>
      <tx:method name="change*" read-only="false" isolation="READ_COMMITTED" propagation="REQUIRES_NEW"
        rollback-for="java.lang.Throwable"/> 
      <tx:method name="*"/>
    </tx:attributes>
  </tx:advice>
    :

<aop:pointcut/>
pointcut と次の advisor は <aop:config/> 内に定義します。記述方法は AOP の規則に則ります。

以下のコードでは更新系トランザクションメソッドの pointcut に「wriderChangeOperation」、参照系に「wriderGetOperation」、ストアドプロシージャを呼び出すメソッドの pointcut に「wriderMakeOperation」という ID を割り当てています。

<aop:config>
    <aop:pointcut 
      expression="execution(* wrider.service.*.get*(..))" 
      id="wriderGetOperation"/>
    <aop:pointcut 
      expression="execution(* wrider.service.*.make*(..))" 
      id="wriderMakeOperation"/>
    <aop:pointcut 
      expression="execution(* wrider.service.*.change*(..))" 
      id="wriderChangeOperation"/>

<aop:advisor/>
advisor で上記 advice と pointcut を紐付けます。

<aop:advisor advice-ref="noRollBackTxAdvice" pointcut-ref="wriderGetOperation"/>
    <aop:advisor advice-ref="rollBackTxAdvice" pointcut-ref="wriderMakeOperation"/>
    <aop:advisor advice-ref="rollBackTxAdvice" pointcut-ref="wriderChangeOperation"/>
  </aop:config>

@Transactional アノテーションの有効化
リファレンス“11.5.6 Using @Transactional”に従って @Transactional を利用できるようにします。

<tx:annotation-driven transaction-manager="txManager"/>

次回は DAO レイヤーの boilerplate な部分を排除します。

2012年4月23日月曜日

Hibernate でデータアクセス(3)

前回、前々回に続き Hibernate と c3p0 を使うための設定を行います。

リファレンス“11.3 Understanding the Spring Framework transaction abstraction”の説明と例に倣いながら Data Source, Session Factory, Transaction Manager を applicationContext.xml に定義します。まずは Data Source から。

Data Source
Data Source には c3p0 コネクションプールを使用します。

pooledDataSource ビーン
applicationContext.xml 内に“pooledDetaSource”というビーンを定義し、class 属性で ComboPooledDataSource を指定しています。

このビーン定義内では基本的なプロパティ driverClass, jdbcUrl そしてdescription を設定しているだけです。詳細な設定はクラスパスに配置した c3p0-config.xml に記述しています(もちろん上記ビーン定義内に記述することも可能です)。
<!-- 'Pooled' DataSource Configuration -->
  <bean id="pooledDataSource" 
  class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close"
    p:driverClass="com.mysql.jdbc.Driver"
    p:jdbcUrl="jdbc:mysql://[host]/[db]?useUnicode=true&characterEncoding=utf-8"
    p:description="C3P0 Pooled Data Source">
  </bean>

c3p0-config.xml
データベース接続に関するプロパティ(user, password)とコネクションプールの上限、下限に関するプロパティ(maxPoolSize, minPoolSize)を設定しています。maxStatements は、グローバル PreparedStatement キャッシュの上限です。
<?xml version='1.0' encoding='utf-8'?>
<c3p0-config>
  <default-config>
    <property name="user">demo</property>
    <property name="password">demo</property>
    <property name="initialPoolSize">2</property>
    <property name="minPoolSize">2</property>
    <property name="maxPoolSize">5</property>
    <property name="acquireIncrement">1</property>
    <!-- Global PreparedStatement Cache -->
    <property name="maxStatements">200</property>
  </default-config>
</c3p0-config>

設定できるプロパティの詳細は c3p0 サイトの“Appendix A: Configuration Properties”に記載されています。

Session Factory
Hibernate セッションを取得するための Session Factory を構成します。class 属性にLocalSessionFactoryBean を指定しています。

sessionFactory ビーン
DataSource に前述の pooledDataSource を指定しています。Hibernate 関連の詳しいプロパティを <property name="hibernateProperties"> で設定することもできますが、今回は configLocation で示したファイル(/WEB-INF/conf/hibernate.cfg.xml)に記述します。
<bean id="sessionFactory" scope="singleton"
    class="org.springframework.orm.hibernate4.LocalSessionFactoryBean"
    p:dataSource-ref="pooledDataSource"
    p:configLocation="/WEB-INF/conf/hibernate.cfg.xml">
  </bean>

hibernate.cfg.xml
SQL の方言(dialect)を吸収する hibernate.dialect に MySQLDialect を指定しています。また hibernate.show_sql を true にすることで Hibernate が発行した SQL 文をコンソールに出力させます。今回はアノテーションで OR マッピングを定義しているので <mapping/>要素には、対象クラス(UserProfile と IdCard)を登録しているだけです。
<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE hibernate-configuration PUBLIC
    "-//Hibernate/Hibernate Configuration DTD 3.0//EN"
    "http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
  <session-factory>
    <!-- Hibernate properties -->
    <property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property>
    <property name="hibernate.show_sql">true</property>
    <property name="hibernate.current_session_context_class">thread</property>
    <!-- Mapping -->
    <mapping class="wrider.model.UserProfile"/>
    <mapping class="wrider.model.IdCard"/>
  </session-factory>
</hibernate-configuration>

Transaction Manager
Spring トランザクションの基点となる Transaction Manager の定義です。

txManager ビーン
HibernateTransactionManager を使います。sessionFactory プロパティは前述の sessionFactory ビーンを参照しています。
<bean id="txManager"
    class="org.springframework.orm.hibernate4.HibernateTransactionManager"
    p:sessionFactory-ref="sessionFactory">
  </bean>

ビュー
最後にビューです。ログインフォーム(login.jsp)では <form:form/> 要素の modelAttribute 属性で“idCard”、アカウント作成フォーム(registrationForm.jsp)では“userProfile”を指定しています。

login.jsp(抜粋)
:
  <form:form action="login.html" method="POST" modelAttribute="idCard">
    <fieldset>
    <legend>ログイン</legend>
    <div class="items">
      <label class="item" for="email">メールアドレス</label>
      <div class="item_body">
        <input type="text" id="email" name="email" value="${idCard.email}"/>
        <form:errors path="email" cssClass="red"/>
      </div>
    </div>
    :

registrationForm.jsp(抜粋)
:
  <form:form action="register.html" method="POST" modelAttribute="userProfile">
    :

図のログイン画面から認証情報を送信すると コンソールに Hibernate セッションで発行された SQL 文が Hibernate: select userprofil0_.pid as pid0_, ... from UserProfile userprofil0_ where userprofil0_.email=? and userprofil0_.password=? のような感じで表示されます。認証が成功すると welcome/home.html に遷移します。アカウント作成が成功した場合も同様です。

尚、MySQL との接続の様子はコマンドラインで
mysqladmin -u [user] -p extended-status | egrep "connect"
あるいはMySQL クライアントから
show status like '%connect%';

のように打ち込めば確かめられます。

以上でミッション終了です。一旦データベースへの接続環境が整えば、Spring (や Hibernate)が持つ色々な機能が利用できるようになります。個人的には AOP の仕掛けを利用したトランザクション管理に興味が沸きました。そうしたものを含めていつか試したいと思います。

2012年4月21日土曜日

Hibernate でデータアクセス(2)

前回の続きです。

サービスレイヤー
DAO レイヤーと連携しながら、コントローラーの要求に応じたサービスを提供するレイヤーです。DAO レイヤーと同様(というか Spring Framework における開発の基本?)、インターフェースを定義し、その実装クラスを作ります。

AccountService.java(インターフェース)
新規アカウントを作成する makeAccount() と UserProfile を取得する getProfile() を定義してます。

package wrider.service;

import java.util.Map;

import wrider.model.IdCard;
import wrider.model.UserProfile;

public interface AccountService {
  
  public Map makeAccount(UserProfile userProfile);
  
  public UserProfile getProfile(IdCard idCard);

}

AccountServiceImpl.java(実装クラス)
@Service アノテーションでこのクラスがサービスコンポーネントであることを示しています。尚、ProfileManager インターフェースの実装クラス ProfileManagerImple には、@Repository アノテーションを付けてリポジトリーコンポーネントであることを示しています。

[servlet-name]-servlet.xml で <context:component-scan/> が有効になっている場合、Spring Framework は、@Autowired されたフィールドの型に該当するインターフェースの実装クラスを探して DI します。実装クラスに付けた @Service や @Repository は、この時の目印になります。

ProfileManager フィールドに @Autowired を付けて、 ProfileManagerImpl を DI しています。

makeAccount()メソッドは、コントローラーから受け取った UserProfile オブジェクトを profileManager の callAddUserProfile()メソッドに渡し、処理結果を Map で受け取ります。getProfile()メソッドは、コントローラーから IdCard オブジェクトを受け取り、profileManager の getProfile()を呼び出す際、パラメーターとして渡します。

package wrider.service;

import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import wrider.dao.ProfileManager;
import wrider.model.IdCard;
import wrider.model.UserProfile;

@Service
public class AccountServiceImpl implements AccountService {
  
  @Autowired
  private ProfileManager profileManager;
  
  public Map<String, Object> makeAccount(UserProfile userProfile) {
    return this.profileManager.callAddUserProfile(userProfile);
  }
  
  public UserProfile getProfile(IdCard idCard) {
    return this.profileManager.getUserProfile(idCard);
  }
  
}

コントローラー
テーブルスキーマの定義から DAO レイヤー、サービスレイヤーと遡って、ようやくコントローラーに辿りつきました。『CGLIB Proxy で捕捉エリアを拡大』までに散々手を加えた AccountController クラスに、先述した AccountService を利用するメソッドを追加します。

AccountController.java(抜粋)
まず、@Autowired で AccountService を DI しています。/account/login.html および /account/register.html に対する GET リクエストを受けた場合は、単純に各 URL に対応するビュー名を返すだけです。
package wrider.controller;
    :
@Controller
@SessionAttributes({"idCard", "userProfile"})
public class AccountController {
  
  @Autowired
  private AccountService accountService;
  
  @ModelAttribute("idCard")
  public IdCard makeIdCard() {
    return new IdCard();
  }
  
  @ModelAttribute("userProfile")
  public UserProfile makeUserProfile() {
    return new UserProfile();
  }
    :
  @RequestMapping(value="/account/login.html", method=RequestMethod.GET)
  public String login() {
    return "account/login";
  }
  
    :
  @RequestMapping(value="/account/register.html", method=RequestMethod.GET)
  public String register() {
    return "account/registrationForm";
  }
    :
}

login()メソッド:
ログインフォームからの POST リクエストで実行される login()メソッドは、引数として フォームへの入力データを含む IdCard とバリデーション結果を格納する BindingResult のほか、@ModelAttribute でアノテートされた UserProfile オブジェクトを受け取ります。これに accountService.getProfile(idCard) の実行結果がバインドされます。UserProfile オブジェクトは、@SessionAttributes に登録されているため、セッションが有効である間は維持されます。認証成功後、取得した UserProfile を ModelAndView オブジェクトに登録して /welcome/home.html にリダイレクトします。

@RequestMapping(value="/account/login.html", method=RequestMethod.POST)
  public ModelAndView login(
      @ModelAttribute("userProfile") UserProfile userProfile,
      @Valid IdCard idCard,
      BindingResult br) {
    
    ModelAndView mav = new ModelAndView();
    
    if (br.hasErrors()) {
      mav.getModel().putAll(br.getModel());
      mav.setViewName("account/login");
    }
    else {
      userProfile = accountService.getProfile(idCard);
      if (userProfile == null) {
        mav.setViewName("account/login");
      }
      else {
        mav.addObject("userProfile", userProfile);
        mav.setViewName("redirect:../welcome/home.html");
      }
    }
    return mav;
  }

尚、引数の順序について若干の注意が必要です。リファレンス“16.3.3.1 Supported method argument types”の最後に『BindingResult(またはError)引数は、モデルオブジェクト(引数)の直後に続かなければならない。Spring は、先行する各モデルオブジェクトのために BindingResult インスタンスを生成する』という旨が書いてあります。確かに、BindingResult が @Valid でアノテートした引数の後に記述されていないと「バリデーション結果を入れる場所が無い」と怒られます。

register()メソッド:
accountService.makeAccount(userProfile) の実行結果は、HashMap型で返されます。キー「result」には成功(true)/失敗(false)、「newpid」には割り当てられた pid(profileId)が入ります。アカウント作成が成功した場合は /welcome/home.html にリダイレクトし、失敗の場合は再度、アカウント作成フォームを表示します。

@RequestMapping(value="/account/register.html", method=RequestMethod.POST)
  public ModelAndView register(
      @Valid UserProfile userProfile, 
      BindingResult br) {
    
    ModelAndView mav = new ModelAndView();
    
    if (br.hasErrors()) {
      mav.getModel().putAll(br.getModel());
      mav.setViewName("account/registrationForm");
    }
    else {
      
      HashMap<String, Object>notice = (HashMap<String, Object>)accountService.makeAccount(userProfile);
      
      if (Boolean.parseBoolean(notice.get(Items.RESULT).toString())) {
        mav.setViewName("redirect:/welcome/home.html");
      }
      else {
        mav.setViewName("account/registrationForm");
      }
      
    }
    
    return mav;
  }

次回は、Hibernate, c3p0 関連の設定を予定しています。

Hibernate でデータアクセス(1)

Spring のデータアクセスについてリファレンスを調べると“11.3 Understanding the Spring Framework transaction abstraction”に...
トランザクション抽象化のキーは、トランザクション戦略の概念である。トランザクション戦略は PlatformTransactionManager インターフェースにより定義される...

という記述があります。

HibernateTransactionManager
この PlatformTransactionManager の実装クラスを物色してみると HibernateTransactionManager というクラスがありました。説明を読んで特に惹きつけられたのが『このトランザクションマネージャは、transactional なデータアクセスのために単一の Hibernate SessionFactory を使用するアプリケーションに適しているが、トランザクション中の直接的な DataSource アクセスもサポートする。』の部分です。つまり、生 JDBC と Hibernate に一つの TransactionManager で対応できるということです。何だか面白そうです。

そこで今回はデータベースに MySQL 5.5 を使い、JDBC による Stored Procedure コールと、HQL(Hibernate Query Language)によるテーブルの参照を行います。

準備 - Hibernate ORM
Hibernate サイトから Hibernate ORM のリリースバンドルをダウンロードします。現時点の最新は hibernate-release-4.1.2.Final です。解凍すると hibernate-release-[version].Final/lib フォルダー下に 4 つのフォルダー(envers, jpa, optional, required)が現れます。今回は required 内の全てと、Connection pooling のために option/c3p0 内の全てを /WEB-INF/lib にコピーし、ビルドパスに登録します。

準備 - MySQL Connector/J
MySQL の Download サイトからのリンクを辿り mysql-connector-java-[version].zip をダウンロードし、解凍先フォルダーにある mysql-connector-java-[version]-bin.jar を上記と同様 /WEB-INF/lib にコピーし、ビルドパスに登録します。

準備 - Spring Framework(必要に応じて)
ところで Spring Framework 3.1.0 上で Hibernate と c3p0 を使ったときの不具合として #SPR-8924 が報告されています。同 3.1.1 で fix されているそうなので、必要ならば公式サイトから最新版を持ってきます。


テーブル
まずはテーブルの設計です。『DI による Validator の再利用』で作ったモデルクラスとコントローラーを雛形に、以下の CREATE TABLE 文で示すような単純なアカウント管理テーブルを考えてみました。

UserProfile.sql
create table UserProfile(
  pid mediumint not null autoutincrement unique,
  email          varchar(255) not null unique,
  password       varchar(255) not null,
  lastName       varchar(10) not null comment '名字',
  firstName      varchar(30) not null comment '名前',
  penName        varchar(255) comment '愛称',
  registeredDate date not null comment '登録日',
  lastLogin      timestamp not null comment '最終ログイン',
  constraint UserProfile_pkc primary key(pid),
  index UserProfile_idx1 using hash(email)
  );

主キーは pid で、mail にインデックスを指定しています。

ストアドプロシージャ
上記テーブルに対するアカウントの新規登録は以下のようなストアドプロシージャで行うことにします。

addUserProfile.sql
delimiter //
create procedure addUserProfile(
  IN inEmail varchar(255),
  IN inPassword varchar(255),
  IN inLastName varchar(10),
  IN inFirstName varchar(30),
  IN inPenName varchar(255),
  OUT result boolean,
  OUT newpid mediumint)
begin
  -- local valiable
  declare cnt int;
  
  -- cursor for checking prior to inserting received profile
  declare dispatch_cur cursor for
    select count(*) from UserProfile where email = inEmail;
  
  -- exit handler for ERROR: 1062 (ER_DUP_ENTRY)
  declare EXIT handler for SQLSTATE '23000'
    begin
      rollback;
      set result = false;
      set newpid = -1;
    end;
    
  start transaction;
  
  open dispatch_cur;
  fetch dispatch_cur into cnt;
  close dispatch_cur;
  
  if cnt = 0 then
    set result = true;
    insert into UserProfile set
      email = inEmail,
      password = inPassword,
      lastName = inLastName,
      firstName = inFirstName,
      penName = inPenName,
      registeredDate = now();
      
    select LAST_INSERT_ID() into newpid;
  else
    set result = false;
    set newpid = -1;
  end if;
  
  commit;
  
end//
delimiter ;

insert 前に email の重複チェックを行い、重複があれば OUT パラメータ result に false を、newpid には -1 をセットします。それでも ERROR: 1062 のエラーが発生したら EXIT HANDLER で捕まえてロールバックと上記と同様の OUT パラメーターの設定を行います。

モデルクラス
Hibernate ORM は、その名の通り ORM(Object-Relational Mapping)のフレームワークです。これからモデルクラス(IdCard, UserProfile)と上記テーブルのマッピングに取り掛かります。

IdCard.java
永続化のため Serializable インターフェースを implements しています。2 つのプロパティ(email, password)には、@Column アノテーションを付けて、テーブル側の unique, not null と合わせています。

また、クラス定義の先頭に @MappedSuperclass を付けています。Hibernate のリファレンス 5.1.6. Inheritance strategy の“5.1.6.4. Inherit properties from superclasses”にある注意書きよると、@MappedSuperclass としてマッピングされていないスーパークラスから継承したプロパティは無視される、とあります。

後でお見せしますがもう一つのモデルクラス UserProfile は、IdCard のサブクラスです。OR マッピングにおいてこの継承関係を有効にするため IdCard には @MappedSuperclass を付けたというわけです。

package wrider.model;

import java.io.Serializable;

import javax.persistence.Column;
import javax.persistence.MappedSuperclass;

import org.hibernate.validator.constraints.NotEmpty;

import wrider.annotation.CheckPassword;
import wrider.annotation.CheckEmail;

@MappedSuperclass
public class IdCard implements Serializable {
  
  private static final long serialVersionUID = 1L;
  
  @NotEmpty
  @CheckEmail
  @Column(unique = true, nullable = false)
  private String email;
  
  public String getEmail() {
    return this.email;
  }
  
  public void setEmail(String email) {
    this.email = email;
  }
  
  @NotEmpty
  @CheckPassword(min = 4, max = 32)
  @Column(unique = false, nullable = false)
  private String password;
  
  public String getPassword() {
    return this.password;
  }
  
  public void setPassword(String password) {
    this.password = password;
  }
  
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || !(o instanceof IdCard)) {
      return false;
    }
    
    IdCard other = (IdCard)o;
    if (email == null || password == null) {
      return false;
    }
    return email.equals(other.getEmail()) && password.equals(other.getPassword());
  }
}

UserProfile.java
前述の通りこのモデルクラスは IdCard を extends したものです。そのため UserProfile 自体に定義された 6 つのプロパティ(profileId, lastName, firstName, penName, registeredDate, lastLogin)に加え、継承した 2 つのプロパティ(email, password)も持つことになります。

後、profileId はテーブルの pid カラムに対応し、@Id @GeneratedValue(strategy = IDENTITY) アノテーションを付けることにより同カラムの auto_increment に対応しています。

package wrider.model;

import static javax.persistence.GenerationType.IDENTITY;
import static javax.persistence.TemporalType.DATE;

import java.io.Serializable;
import java.sql.Timestamp;
import java.util.Date;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Temporal;

import org.hibernate.validator.constraints.NotEmpty;

import wrider.annotation.CheckPersonName;

@Entity
public class UserProfile extends IdCard implements Serializable {
  
  private static final long serialVersionUID = 1L;
  
  @Id
  @GeneratedValue(strategy = IDENTITY)
  @Column(name = "pid", unique = true, nullable = false)
  private long profileId;
  public long getProfileId() {
    return this.profileId;
  }
  public void setProfileId(long profileId) {
    this.profileId = profileId;
  }
  
  @NotEmpty
  @CheckPersonName(max = 10)
  @Column(unique = false, nullable = false)
  private String lastName;
  
  public String getLastName() {
    return this.lastName;
  }
  
  public void setLastName(String lastName) {
    this.lastName = lastName;
  }
  
  @NotEmpty
  @CheckPersonName(max = 30)
  @Column(unique = false, nullable = false)
  private String firstName;
  
  public String getFirstName() {
    return this.firstName;
  }
  
  public void setFirstName(String firstName) {
    this.firstName = firstName;
  }
  
  @CheckPersonName(max = 30)
  @Column(unique = false, nullable = true)
  private String penName;
  
  public String getPenName() {
    return this.penName;
  }
  
  public void setPenName(String penName) {
    this.penName = penName;
  }
  
  @Column(unique = false, nullable = false)
  @Temporal(DATE)
  private Date registeredDate;
  
  public Date getRegisteredDate() {
    return this.registeredDate;
  }
  
  public void setRegisteredDate(Date registeredDate) {
    this.registeredDate = registeredDate;
  }
  
  @Column(unique = false, nullable = false)
  private Timestamp lastLogin;
  
  public Timestamp getLastLogin() {
    return this.lastLogin;
  }
  
  public void setLastLogin(Timestamp lastLogin) {
    this.lastLogin = lastLogin;
  }
}


DAO レイヤー
具体的なデータアクセスを行うレイヤーです。Spring IoC の仕掛けを利用して、後述するサービスレイヤーと連携させるために、まずインターフェースを定義し、それに則って実装クラスを作るという手順と採ります。

ProfileManager.java(インターフェース)
2 つのメソッドを定義しています。ストアドプロシージャを呼び出して新しいアカウントを作成する callAddUserProfile()と UserProfile を参照する getUserProfile() です。

package wrider.dao;

import java.util.Map;

import wrider.model.IdCard;
import wrider.model.UserProfile;

public interface ProfileManager {
  
  public Map callAddUserProfile(UserProfile userProfile);
  
  public UserProfile getUserProfile(IdCard idCard);

}

ProfileManagerImpl.java(実装クラス)
ProfileManager インターフェースの実装クラスです。callAddUserProfile() は、DataSourceUtils を使って DataSource から Connection を取得し、CallableStatement でストアドプロシージャに対する IN/OUT パラメーターを設定し、実行しています。DataSource はトランザクションマネージャ(txManager) から取得したものです。尚、例外処理はごく簡易にしています。

一方 getUserProfile() は、トランザクションマネージャ(txManager)から取得した SessionFactory を介して Hibernate Session を取得後、createQuery()で HQL のプレースホルダーにパラメーターをセットし、クエリーを発行しています。

両メソッドとも @Autowired で txManager に注入した HibernateTransactionManager を使用して JDBC コネクションや Hibernate セッションを取得しています。

package wrider.dao;

import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.Types;
import java.util.HashMap;
import java.util.Map;

import org.hibernate.Session;
import org.hibernate.Transaction;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.orm.hibernate4.HibernateTransactionManager;
import org.springframework.stereotype.Repository;

import wrider.model.IdCard;
import wrider.model.UserProfile;

@Repository
public class ProfileManagerImpl implements ProfileManager {

  @Autowired
  private HibernateTransactionManager txManager;
  
  public UserProfile getUserProfile(IdCard idCard) {
    /*
     * Boilerplate pattern
     */
    final String HQL = 
      "from UserProfile as up " + 
      "where up.email = :email and up.password = :password";
    
    UserProfile result = null;
    Session session = txManager.getSessionFactory().getCurrentSession();
    Transaction tx = session.getTransaction();
    
    try {
      tx.begin();
      
      result = (UserProfile)session.createQuery(HQL)
            .setParameter("email", idCard.getEmail())
            .setParameter("password", idCard.getPassword())
            .uniqueResult();
      
      tx.commit();
      
    } catch (Exception e) {
      tx.rollback();
      System.out.println("error occured: " + e.toString());
    }
    
    return result;
  }
  
  public Map<String, Object> callAddUserProfile(UserProfile userProfile) {
    /*
     * Boilerplate pattern
     */
    final String STMT = "{call addUserProfile(?, ?, ?, ?, ?, ?, ?)}";
    
    HashMap<String, Object> result = new HashMap<String, Object>();
    
    try {
      Connection conn = DataSourceUtils.getConnection(txManager.getDataSource());
      
      CallableStatement cstmt = conn.prepareCall(STMT);
      
      cstmt.setString(Items.IN_EMAIL, userProfile.getEmail());
      cstmt.setString(Items.IN_PASSWORD, userProfile.getPassword());
      cstmt.setString(Items.IN_LASTNAME, userProfile.getLastName());
      cstmt.setString(Items.IN_FIRSTNAME, userProfile.getFirstName());
      cstmt.setString(Items.IN_PENNAME, userProfile.getPenName());
      
      cstmt.registerOutParameter(Items.RESULT, Types.BOOLEAN);
      cstmt.registerOutParameter(Items.NEWPID, Types.BIGINT);
      
      cstmt.execute();
      
      result.put(Items.RESULT, cstmt.getBoolean(Items.RESULT));
      result.put(Items.NEWPID, cstmt.getLong(Items.NEWPID));
      
      cstmt.close();
      conn.close();
      
    } catch (Exception e) {
      System.out.println("Error occured: " + e.toString());
    }
    
    return result;
  }

}


次回はサービスレイヤーから書く予定です。

2012年4月13日金曜日

CGLIB Proxy で捕捉エリアを拡大


Spring Framework リファレンスの“8.6 Proxying mechanisms”に、Spring AOP はターゲットオブジェクトがインターフェースを実装している時は JDK の Dynamic Proxy、インターフェースを実装していない時は CGLIB Proxy を使うと記されています。

今回は、CGLIB Proxy を有効にして Pointcut の対象を広げます。

準備 - cglib
Spring AOP で流れを追う!』で触れたとおり、cglib(Code Generation Library) サイトから cglib-2.2.2.jar をダウンロードし、/WEB-INF/lib にコピーします。

asm
CGLIB Proxy を利用するためにはこれも必要です。cglib サイトのトップページ、あるいはOW2 Consortium サイトの ASM ページに行き、リンクを辿って asm-3.3.1-bin.zip を持ってきたら、解凍先の lib フォルダーに入っている asm-3.3.1.jar を /WEB-INF/lib にコピーします。

最新の asm-4.0 で試したところ cglib-2.2.2 がうまく動きませんでした。


CGLIB Proxy の有効化
リファレンス“8.6 Proxying mechanisms”に従って[servlet-name]-servlet.xml で以下の設定を行います。

<aop:aspectj-autoproxy proxy-target-class="true"/>


Pointcut の追加
まずは Join Point にしたいメソッド周りの特徴を見極めます。以下は『Bean Validation』で手を加えた AccountController クラスの一部です。

AccountController.java(抜粋)
package wrider.controller;
  :

@Controller
@SessionAttributes({"idCard", "userProfile"})
public class AccountController {
  :
  @RequestMapping(value="/account/login.html", method=RequestMethod.POST)
  public ModelAndView login(@Valid IdCard idCard, BindingResult br) {
  :
  }
  :
  @RequestMapping(value="/account/register.html", method=RequestMethod.POST)
  public ModelAndView register(@Valid UserProfile userProfile, BindingResult br) {
  :
  }
  :
}

クラス定義を見ると AccountController は wrider パッケージにあり、インターフェースを implements していないことがわかります。POST リクエストで呼び出される login()メソッドと register()メソッドは public で、どちらも2つ目の引数として BindingResult 型を受け取っています。この辺の特徴を Pointcut に定義したのが以下のコードです。

WebPointcuts.java(抜粋)
@Aspect
@Component
public class WebPointcuts {
  
  @Pointcut("within(com.scopeandtarget.wrider..*)")
  public void inWriderPackage() {}
  :
  @Pointcut("inWriderPackage() && execution(public * *(*,org.springframework..BindingResult))")
  public void postAction() {}
  
}

これは『Spring AOP で流れを追う!』で作った WebPointcuts クラスに手を加え、新たに postAction というメソッドを追加しています。@Pointcut アノテーションの中身を見ると、「wrider パッケージにある全ての型に定義されたメソッド」を表す inWriderPackage と、「public で2つ目の引数に BindingResult 型を持つメソッド」という条件を "&&" でつないでいます。

Advice の追加
続いて、Pointcut に新しく追加した postAction で呼び出される Advice を追加します。

WebAdvices.java(抜粋)
@Before("com.scopeandtarget.wrider.aop.pointcut.WebPointcuts.postAction() && args(*,param)")
  public void logPrePostRequest(final JoinPoint jp, final Object param) {
    
    BindingResult bindingResult = (BindingResult)param;
    
    String format = "{}.{} will be invoked!";
    outputLogMessage(
        format, 
        jp.getTarget().getClass().getSimpleName(), 
        jp.getSignature().getName());
    
    StringBuffer sb = new StringBuffer();
    
    sb.append("BindingResult has ..");
    sb.append(CoreConstants.LINE_SEPARATOR);
    for (Map.Entry entry : bindingResult.getModel().entrySet()) {
      sb.append(" key: " + entry.getKey());
      sb.append(CoreConstants.LINE_SEPARATOR);
      sb.append(" value: " + entry.getValue().toString());
      sb.append(CoreConstants.LINE_SEPARATOR);
      sb.append(" -----------------------------------");
      sb.append(CoreConstants.LINE_SEPARATOR);
    }
    
    logger.info(sb.toString());
  }

新たに logPrePostRequest メソッドが加わりました。@Before アノテーションを付けているので Before Advice です。インターセプトしたメソッドの 2 番目の引数を final Object 型で受け取り、メソッドの中で BindingResult 型にキャストしています。クラス名とメソッド名は、他の Advice と同じように、引数として受け取った JoinPoint を介して取得後、logger.info() でメッセージ内のプレースホルダーに埋め込んで出力しています。

一方 BindingResult は、取り出した key と value を StringBuffer に append しながらループし、最後に logger.info() で出力するようにしています。尚、改行に ch.qos.logback.core.CoreConstants を使用しているため、/WEB-INF/lib の logback-core-[version].jar をビルドパスに登録しています。


実行!
以上で作業は完了です。実際に動かし、吐き出されたログの一部が以下です。まず、CheckPasswordValidator、続いて CheckEmailValidator の isValid で入力値が検証され、AccountController の login メソッドが呼び出されている様子がわかります。検証結果は全て正常なので、最後にある BindingResult の内容は“0 errors”のみです。

尚、検証エラーがあった際の BindingResult の内容は『Validator と MessageSource』に一例を示しています。

ishtar.log
21:47:04.593 [ishtar] INFO  [http-bio-8080-exec-3] wrider.aop.advice.WebAdvices [WebAdvices.java:93] CheckPasswordValidator.isValid will be invoked! [q1w2e3r4]
21:47:04.593 [ishtar] INFO  [http-bio-8080-exec-3] wrider.aop.advice.WebAdvices [WebAdvices.java:93] CheckPasswordValidator.isValid is on going!
21:47:04.593 [ishtar] INFO  [http-bio-8080-exec-3] wrider.aop.advice.WebAdvices [WebAdvices.java:93] CheckPasswordValidator.isValid completed! result is true
21:47:04.640 [ishtar] INFO  [http-bio-8080-exec-3] wrider.aop.advice.WebAdvices [WebAdvices.java:93] CheckEmailValidator.isValid will be invoked! [wrider@abc.com]
21:47:04.640 [ishtar] INFO  [http-bio-8080-exec-3] wrider.aop.advice.WebAdvices [WebAdvices.java:93] CheckEmailValidator.isValid is on going!
21:47:04.640 [ishtar] INFO  [http-bio-8080-exec-3] wrider.aop.advice.WebAdvices [WebAdvices.java:93] CheckEmailValidator.isValid completed! result is true
21:47:04.640 [ishtar] INFO  [http-bio-8080-exec-3] wrider.aop.advice.WebAdvices [WebAdvices.java:93] AccountController.login will be invoked!
21:47:04.640 [ishtar] INFO  [http-bio-8080-exec-3] wrider.aop.advice.WebAdvices [WebAdvices.java:89] BindingResult has ..
 key: idCard
 value: wrider.model.IdCard@1875a82
 -----------------------------------
 key: org.springframework.validation.BindingResult.idCard
 value: org.springframework.validation.BeanPropertyBindingResult: 0 errors
 -----------------------------------


ログと聞くと一見地味な印象ですが、特にセキュリティやマーケティングでは必要不可欠な要素です。CGLIB Proxy を使うことで Pointcut に設定できる Join Point ―― Spring AOP の場合はメソッド実行のタイミングということですが――の領域を広げることができ、それに伴いログデータを採取できる機会も増えます。BigData の重要性が増しているといわれていますが、例えばシステム上でのユーザーの行動についての、より詳細で豊富なデータを集めたいというようなとき、Spring AOP を試してみるのも悪くないと思いました。

2012年4月11日水曜日

SLF4J と Logback でロギング!


前回の『Spring AOP で流れを追う!』で、特定の Aspect(相、特徴) を持つメソッド周りの動きを捕捉したので、今回は取得した情報を基にメッセージを整形し、ログに残します。

SLF4J
SLF4J(Simple Logging Facade for Java) とは、公式サイトにある SLF4J user manualイラストが表しているように、log4j や Logback といった様々な Logging Framework にシンプルな“Facade(ファサード)”、「外観」を提供するツールです。

因みに、この SLF4J だけでも前回の最後で見せたようなログメッセージをコンソールに出力することができます。

準備 - SLF4J
前回書いたとおり SLF4J サイトから slf4j-[version] の圧縮ファイルを持ってきて解凍後、slf4j-api-[version].jarslf4j-simple-[version].jar を /WEB-INF/lib にコピーし、ビルドパスにも登録します。

準備 - Logback
SLF4J user manual に『Logback の Logger クラスは SLF4J の Logger インターフェースの直接的な実装クラスなので、両者の接続においてリソースのオーバヘッドが無い』という旨が記されています。確かにイラストにも log4j や Apache Commons Logging(JCL の実装)に見られるようなアダプターが必要ない様子が示されています。

(これを信じて)Logback Project サイトから logback-[version] の圧縮ファイルを持ってきます。解凍先に出てくる 3 つの jar の内、次の 2 を /WEB-INF/ lib にコピーします。

logback-classic-[version].jar
logback-core-[version].jar

今回 Servlet のアクセスログは取らないので logback-access-[version].jar は使いません。


Logback の構成 - logback.xml
The logback manual の“Chapter 3: Logback configuration”によると、Logback は初期化時にまず、以下の順番でクラスパス上の構成ファイルを探します。

1. logback.groovy
2. logback-test.xml
3. logback.xml

クラスパスに以上のファイルが見つからなかった場合は自動的に ch.qos.logback.classic パッケージの BasicConfigurator を呼び出して基本構成を行います。

今回は、以下の“logback.xml”を作成しました。コンソール出力用(STDOUT)とファイル出力用(FILE)の 2 つの appender を定義しています。後者は前者に対して、より詳細な内容となるように定義しています。

logback.xml
<?xml version="1.0" encoding="utf-8"?>
<configuration debug="false">
  <contextName>ishtar</contextName>
  
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{HH:mm:ss.SSS} [%contextName] %-5level %logger{18} - %msg%n</pattern>
    </encoder>
  </appender>
  
  <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>d:/usr/var/logs/logback/ishtar.log</file>
    <encoder>
      <pattern>%d{HH:mm:ss.SSS} [%contextName] %-5level [%thread] %logger{36} [%file:%line] %msg%n</pattern>
    </encoder>
  </appender>
  
  <root level="info">
    <appender-ref ref="STDOUT"/>
    <appender-ref ref="FILE"/>
  </root>
</configuration>

<appender>要素
2 つの appender にname 属性で「STDOUT」、「FILE」という名前を定義しています。STDOUT では ConsoleAppender クラス、FILE では FileAppender クラスを使用します。両クラスとも OutputStreamAppender クラスを extends したもので、子要素の <encoder> で OutputSteam に乗せるバイトアレイへの変換方法などを Encoder に知らせます。

<pattern>要素
<encoder> の子要素である <pattern> で、具体的なレイアウトを指定します。今回は次の項目を適当に組み合わせて各 appender に設定しています。

時刻:%d{HH:mm:ss.SSS}
コンテキスト名:%contextName
ログレベル:level
スレッド名:%thread
ロガー名:%logger{文字数}
ロガーが出したメッセージ:%msg

指定可能な項目についての詳細はマニュアルの“Chapter 6: Layouts”にあります。

<timestamp>要素
尚、<timestamp>要素を使うと、例えば日付ごとに新しいログファイルを生成させることができます。
<contextName>ishtar</contextName>
  <timestamp key="byDate" datePattern="yyyyMMdd"/>
    :
  <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>d:/usr/var/logs/logback/ishtar-${byDate}.log</file>
    :
  </appender>

マニュアルの“Chapter 4: Appenders”に Appender についての詳細が記されています。ロールオーバーやリモートホスト/SMTPによる転送、データベースへの記録など、試してみたい機能が色々とあります。


メッセージの成型
Logback の導入に伴い前回作成した WebAdvices クラスの各 Advice メソッドに手を加えました。

ポイントは、SLF4J がサポートする“parameterized logging”と呼ばれる機能を利用していることです。直訳すると「パラメーター化されたロギング」となりますが、要は、雛形に含まれるプレースホルダーと文字列を対応付けてメッセージを成型する仕掛けということです。

logger.info()メソッドは、雛形となる String 型の引数と、そこに含まれる各プレースホルダー‘{}’に当てはめる文字列を Object[] 型の引数で受け取ることができます。WebAdvice クラスでは、これを利用して outputLogMessage() という private メソッドへの可変長引数という形でプレースホルダーに適用する値を渡しています。

WebAdvices.java(抜粋)
package com.scopeandtarget.wrider.aop.advice;
  :
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Aspect
@Component
public class WebAdvices {
  
  private Logger logger = LoggerFactory.getLogger(WebAdvices.class);

  @Before(..)
  public void logFieldValidationOccured(..) {
    
    String format = "{}.{} will be invoked! [{}]";
    outputLogMessage(
        format, 
        jp.getTarget().getClass().getSimpleName(), 
        jp.getSignature().getName(), 
        param.toString());
    
  }
  
  @AfterReturning(..)
  public Object logFieldValidationFinished(..) {
    
    String format = "{}.{} completed! result is {}";
    outputLogMessage(
        format, 
        jp.getTarget().getClass().getSimpleName(), 
        jp.getSignature().getName(), 
        retVal.toString());
    
    return retVal;
  }
  
  @Around(..)
  public Object logFieldValidationOnGoing(..)  throws Throwable {
    
    String format = "{}.{} is on going!";
    outputLogMessage(
        format, 
        pjp.getTarget().getClass().getSimpleName(), 
        pjp.getSignature().getName());
    
    return pjp.proceed();
  }
  
  private void outputLogMessage(String format, String... args) {
    logger.info(format, args);
  }
}

前回は、地道に文字列を連結して System.out.println() していましたが、今回はプレースホルダーを使ったことでコードが見易くなったと思います。


実行!
以上で一通りの作業は完了です。アプリケーションを起動しブラウザーで /account/login.html にアクセスすると、logback.xml で設定したログファイル([contextName].log)が作成され、呼び出されたバリデーターやフォームに入力されたデータ、検証結果、スレッド名、各 Advice メソッドで成型されたメッセージなどが克明に記録されています。

ishtar.log(抜粋)
:
14:42:10.156 [ishtar] INFO  [http-bio-8080-exec-7] wrider.aop.advice.WebAdvices [WebAdvices.java:66] CheckEmailValidator.isValid will be invoked! [wrider@abc.com]
14:42:10.156 [ishtar] INFO  [http-bio-8080-exec-7] wrider.aop.advice.WebAdvices [WebAdvices.java:66] CheckEmailValidator.isValid is on going!
14:42:10.156 [ishtar] INFO  [http-bio-8080-exec-7] wrider.aop.advice.WebAdvices [WebAdvices.java:66] CheckEmailValidator.isValid completed! result is true
14:42:10.156 [ishtar] INFO  [http-bio-8080-exec-7] wrider.aop.advice.WebAdvices [WebAdvices.java:66] CheckPasswordValidator.isValid will be invoked! [123456]
14:42:10.156 [ishtar] INFO  [http-bio-8080-exec-7] wrider.aop.advice.WebAdvices [WebAdvices.java:66] CheckPasswordValidator.isValid is on going!
14:42:10.156 [ishtar] INFO  [http-bio-8080-exec-7] wrider.aop.advice.WebAdvices [WebAdvices.java:66] CheckPasswordValidator.isValid completed! result is true
  :

当然の話ですが、アプリケーションは見た目上動いているだけでは駄目で、その内部動作を詳しく把握しておくことが理想です。Spring AOP と Logback のようなロギング・フレームワークを活用すれば、処理フローの捕捉と記録が割りと容易にできます。

ロギング・フレームワークの「機能面」に絞って言えば、開発や評価だけでなく、例えば「ログローテーションをきちんと計画するようなシステム」とか「アクセスログを DB に蓄積して、解析を行うようなシステム」とか、色々な用途に対応できると思います。

2012年4月10日火曜日

Spring AOP で流れを追う!


アプリケーションを開発していると作りこんだクラスやメソッドに関して「入出力はどうなっているか」とか、「そもそも呼ばれているのか」といったことが気になることがあります。そんなとき Spring Framework の Spring AOP(Aspect Oriented Programming)が重宝します。今回は、Spring AOP を使い、前回の『Bean Validation』で作ったバリデーターの動きを追跡してみたいと思います。

Pointcut, Join point, Advice
Spring AOP の目玉は、Pointcut(ポイントカット) の記述言語として AspectJ を採用していることです。Spring AOP の詳しい用語解説は、リファレンスの“8.1.1 AOP concepts”に書かれていますが、Pointcutとは要するに、プログラムの中で共通の特徴を持つ(いくつかの)場所(Join Point)に、何らかの処理(Advice)を差し込むための条件です。Spring AOP ではそうした条件の記述に AspectJ が利用できるということです。

準備 - AspectJ
というわけで AspectJ を準備します。The AspectJ Project サイトから aspectj-[version].jar をダウンロードし、同サイトの FAQ ページにある“2 Quick Start”に従って、適当な場所にインストールします。因みに次のように打ち込めばインストーラーが起動します。

java -jar aspectj-[version].jar

すると [インストール先フォルダー]/lib に必要な jar が入っているので、これらを /WEB-INF/lib にコピーし、ビルドパスに追加します。

準備 - SLF4J
これは次回のための準備としていれておきます。SLF4J(Simple Logging Facade for Java) サイトから slf4j-[version] の圧縮ファイルを持ってきて解凍後、slf4j-api-[version].jar と slf4j-simple-[version].jar を上記と同様、ビルドパスに登録します。


その他
aopalliance.jar
古いファイルですが、これが無いと AOP を有効にして立ち上げようとした時に怒られます。AOP Alliance サイトからリンクを辿って AOP Alliance フォルダーに行き、aopalliance.zip をダウンロードします。解凍後 /WEB-INF/lib にコピーします。

cglib-2.2.2.jar
8.1.3 AOP Proxies に書いてありますが、Spring AOP はインターフェースを実装していないクラスに対しては CGLIB(Code Generation Library) Proxy を使うそうです。将来的に必要となるかもしれないので、これも CGLIB サイトから持ってきて /WEB-INF/lib にコピーしておきます。


AOP Proxy の有効化
applicationContext.xml [Servlet-name]-servlet.xml に以下の一行を追加して AOP Proxy を有効化します。

<aop:aspectj-autoproxy/>


Pointcut の定義
一通りの準備が整ったところで、Pointcut の定義に取り掛かります。まずは Join Point にしたいメソッドの特徴の見極めです。以下のコードは、パスワードのバリデーションを行う CheckPasswordValidator クラスです。

CheckPasswordValidator
前回のコードに少し手を加えています。正規表現でチェックする部分を wrider.utils.AbstractRegexpUtils という抽象クラスに持たせ、CheckPasswordValidator はこれを extends しています。また、有効文字数をアノテーションの引数 min と max で指定できるようにしています。後者に伴い CheckPassword.java も少し変わりましたが、コードは AbstractRegexpUtils.java と共に割愛させていただきます。
package wrider.validator;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

import org.springframework.util.StringUtils;

import wrider.annotation.CheckPassword;
import wrider.utils.AbstractRegexpUtils;

public class CheckPasswordValidator extends AbstractRegexpUtils
  implements ConstraintValidator {

  private static final String BASE_PATTERN = "^[a-zA-Z0-9]";
  private int max;
  private int min;
  
  public void initialize(CheckPassword constraintAnnotation) {
    max = constraintAnnotation.max();
    min = constraintAnnotation.min();
  }
  
  public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
    if (!StringUtils.hasLength(object)) {
      return true;
    }
    else {
      final String PASSWORD_PATTERN = BASE_PATTERN + "{" + min + "," + max + "}$";
      return super.patternMatching(PASSWORD_PATTERN, object);
    }
  }
  
}

上記クラスは javax.validation.ConstraintValidator インターフェースの実装クラスで、wrider.validator パッケージにあり、boolean 型の値を返す isValid メソッドを持っています。同パッケージには、メールアドレスのバリデーションを行う CheckEmail クラスもあり、同様の特徴を持っています。

そこで、これらのクラスの isValid メソッドを Join point とするよう Pointcut を定義したのが次のコードです。

WebPointcuts.java
package wrider.aop.pointcut;

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class WebPointcuts {
  
  @Pointcut("within(wrider..*)")
  public void inWriderPackage() {}
  
  @Pointcut("execution(public boolean isValid(..))")
  public void doValidate() {}
  
  @Pointcut("inWriderPackage() && doValidate()")
  public void fieldValidation() {}
  
}

クラス定義を @Aspect と @Component でアノテートしています。これにより「component-scan と Stereotypeアノテーション」で書いたように[servlet-name]-servlet.xml で <context:component-scan /> が有効になっていれば、Spring Framework が自動検出してくれます。

クラス定義の中にはいくつかの空のメソッドがあり、それぞれに @Pointcut アノテーションが付いています。最初のメソッドは「wrider パッケージ内のすべての型におけるメソッドの実行」と定義した inWriderPackage、次が「public で、boolean 型の返り値と任意の引数を持つ isValid メソッドの実行」を対象とした doValidate です。そして最後の fieldValidation は、上記二つを同時に満たす Pointcut の定義です。

このようにプログラムの「aspect(相、特徴)」に着目した記述ができるのが AspectJ です。

Advice の定義
Advice には大別して Join Point の直前で実行する Before Advice、Join Point 終了後に実行する After Advice、Join Point が呼び出された辺りで実行する Around Advice があります。

WebAdvices.java
以下のコードでは 3 つの Advice が定義しています。いずれも Pointcut に“fieldValidation”を指定し、文字列を連結して作ったメッセージを System.out.println() でコンソールに出力している点は共通していますが、@Before, @AfterReturning, @Around の違いに応じて、返り値や引数の扱いを変えています。

@Before - Before Advice
実行直前にインターセプトしたメソッドの最初の引数を、final Object 型の引数(param)として受け取るよう指定しています。jp.getTarget().getClass().getSimpleName() でクラス名、jp.getSignature().getName() でそのクラスのメソッド名を取得しています。そしてメッセージには「実行前」ということで“will be invoked!”の文字列を含めています。
@Before("wrider.aop.pointcut.WebPointcuts.fieldValidation() && args(param,*)")
  public void logFieldValidationOccured(final JoinPoint jp, final Object param) {
    
    Signature sig = jp.getSignature();
    String cn = jp.getTarget().getClass().getSimpleName();
    String buf = cn + "." + sig.getName() + " will be invoked! [" + param.toString() + "] ";
    
    System.out.println(buf);
    
  }

@AfterReturning - AfterReturning Advice
メソッド実行後の返り値を final Object 型の引数(retVal)で受け取り、それをそのまま return しています。インターセプトしたクラス名、メソッド名の取得は上記と同じです。
@AfterReturning(
      pointcut="wrider.aop.pointcut.WebPointcuts.fieldValidation()", 
      returning="retVal")
  public Object logFieldValidationFinished(final JoinPoint jp, final Object retVal) {
  :
    return retVal;
  }

@Around - Around Advice
ProceedingJoinPoint インターフェースの proceed() メソッドを使って、進行中の状態を return しています。クラス名、メソッド名の取得は上の 2 つと異なり、Around Advice で使用できる ProceedingJoinPoint から取得しています。
@Around("wrider.aop.pointcut.WebPointcuts.fieldValidation()")
  public Object logFieldValidationOnGoing(final ProceedingJoinPoint pjp)  throws Throwable {
  :
    return pjp.proceed();
  }

全体のコードです。
package wrider.aop.advice;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.AfterReturning;

import org.springframework.stereotype.Component;

@Aspect
@Component
public class WebAdvices {
  
  @Before("wrider.aop.pointcut.WebPointcuts.fieldValidation() && args(param,*)")
  public void logFieldValidationOccured(final JoinPoint jp, final Object param) {
    
    Signature sig = jp.getSignature();
    String cn = jp.getTarget().getClass().getSimpleName();
    String buf = cn + "." + sig.getName() + " will be invoked! [" + param.toString() + "] ";
    
    System.out.println(buf);
    
  }
  
  @AfterReturning(
      pointcut="wrider.aop.pointcut.WebPointcuts.fieldValidation()", 
      returning="retVal")
  public Object logFieldValidationFinished(final JoinPoint jp, final Object retVal) {
    
    Signature sig = jp.getSignature();
    String cn = jp.getTarget().getClass().getSimpleName();
    String buf = cn + "." + sig.getName() + " completed! result is " + retVal.toString();
    
    System.out.println(buf);
    
    return retVal;
  }
  
  @Around("wrider.aop.pointcut.WebPointcuts.fieldValidation()")
  public Object logFieldValidationOnGoing(final ProceedingJoinPoint pjp)  throws Throwable {
    
    Signature sig = pjp.getSignature();
    String cn = pjp.getTarget().getClass().getSimpleName();
    String buf = cn + "." + sig.getName() + " is on going!";
    
    System.out.println(buf);
    
    return pjp.proceed();
  }
}

実行!
では試して見ましょう。今まで再三使ってきた login.html にアクセスし、エラーとなる文字列を入力した結果が以下のコンソール画面です。青字が各 Advice の出力です。
1: makeIdCard has been invoked!
2: makeUserProfile has been invoked!
3: login[GET] has been invoked!
CheckEmailValidator.isValid will be invoked! [wrider] 
CheckEmailValidator.isValid is on going!
CheckEmailValidator.isValid completed! result is false
CheckPasswordValidator.isValid will be invoked! [123] 
CheckPasswordValidator.isValid is on going!
CheckPasswordValidator.isValid completed! result is false
4: login[POST] has been invoked!
got email: wrider

CheckEmailValidator に着目すると

 ~ will be invoked [wrider]
  ↓
 ~ is on going!
  ↓
 ~ completed! result is false

というメッセージの流れから“Before”→“Around”→“AfterReturning”という順番で Advice が呼び出されていることがわかります。また、CheckEmailValidator が「wrider」という文字列の検証で「false」を返している様子もわかります。

次回の予告(かも?)
最後に、SLF4J を使って各 Advice を書き換えた場合のコンソール出力を載せておきます。

SLF4Jによるコンソール出力
10969 [http-bio-8080-exec-3] INFO wrider.aop.advice.WebAdvices - CheckEmailValidator.isValid will be invoked! [wrider] 
10969 [http-bio-8080-exec-3] INFO wrider.aop.advice.WebAdvices - CheckEmailValidator.isValid is on going!
10969 [http-bio-8080-exec-3] INFO wrider.aop.advice.WebAdvices - CheckEmailValidator.isValid completed! result is false

2012年4月5日木曜日

Bean Validation


Spring Frameworkリファレンスの“6.7 Spring 3 Validation”に、Spring 3 は Bean Validation API(JSR-303)を完全サポートし、デフォルトのリファレンス実装として Hibernate Validator を採用している と記されています。

今回は、この JSR-303 Validator を使って「Validator と MessageSource」で作成した login フォームのバリデーションを作り変えます。また、簡単な機能ですが、独自の制約条件(custom constraint)を定義した、カスタムメイドのアノテーションも作ってみます。

JSR と Bean Validation
JSR(Java Specification Requests) とは、Java 標準として JCP(Java Community Process)にポストされたリクエストで、JCP メンバーのレビューを経て認可されたリクエストは仕様化ステージに入ります。JSR 303: Bean Validation もそうしたリクエストの一つで、JCPサイトから仕様の最終リリースをダウンロードできます。

事前準備
Hibernate Validator サイトからリンクを辿ると SourceForge.net の /hibernate-validator フォルダーに飛べるので、そこから必要な Distribution bundle をダウンロードします。私は以下のバージョンをダウンロードしました。

hibernate-validator-4.2.0.Final-dist.zip

JAR の登録
今回の作業で必要なのは次の 4 つです。

解凍先フォルダー
hibernate-validator-4.2.0.Final.jar
hibernate-validator-annotation-processor-4.2.0.Final.jar

解凍先フォルダー/lib/required
slf4j-api-1.6.1.jar
validation-api-1.0.0.GA.jar

これらを図のように /WEB-INF/lib にコピーし、それをビルドパスに追加します。因みに、私は Eclipse(Helios SR2) + JDK6 + Java EE6 SDK + Tomcat 7.0.25 という環境です。


<mvc:annotation-driven/>
リファレンスの 6.7.4.3 Configuring a JSR-303 Validator for use by Spring MVC に従って [servlet-name]-servlet.xml に <mvc:annotation-driven/>を追加します。

[servlet-name]-servlet.xml(抜粋)
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xmlns:p="http://www.springframework.org/schema/p"
     xmlns:context="http://www.springframework.org/schema/context"
     xmlns:aop="http://www.springframework.org/schema/aop"
     xmlns:tx="http://www.springframework.org/schema/tx"
     xmlns:mvc="http://www.springframework.org/schema/mvc"
     xsi:schemaLocation="
       http://www.springframework.org/schema/beans 
       http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
       http://www.springframework.org/schema/context 
       http://www.springframework.org/schema/context/spring-context-3.0.xsd
       http://www.springframework.org/schema/aop 
       http://www.springframework.org/schema/aop/spring-aop-3.0.xsd
       http://www.springframework.org/schema/tx 
       http://www.springframework.org/schema/tx/spring-tx-3.0.xsd
       http://www.springframework.org/schema/mvc 
       http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd">
  
  <mvc:annotation-driven/>
  
  <context:component-scan base-package="wrider"/>
    :
    
</beans>

制約条件をアノテート
以上で準備は完了です。では早速、login フォームのデータを格納する IdCard クラスの各プロパティにビルトインのアノテーションで制約条件を定義してみます。

IdCard.java
まず「メールアドレス(email)」には@NotEmpty と @Email アノテーションで“必須”、“E-Mailのフォーマット”という制約条件を付けます。一方「パスワード(password)」については、@NotEmpty と @Pattern を使って“必須”、“8 文字以上 32 文字以下の半角英数”という制約条件を付加します。
package wrider.model;

import javax.validation.constraints.Pattern;

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.NotEmpty;

public class IdCard {
  
  @NotEmpty
  @Email
  private String email;
  
  public String getEmail() {
    return this.email;
  }
  
  public void setEmail(String email) {
    this.email = email;
  }
  
  @NotEmpty
  @Pattern(regexp = "^[a-zA-Z0-9]{8,32}$")
  private String password;
  
  public String getPassword() {
    return this.password;
  }
  
  public void setPassword(String password) {
    this.password = password;
  }
}

コントローラークラスの修正
独自に作成した IdCard 用のバリデーター(IdCardValidator)の代わりにアノテーションを使用することに伴う変更を施します。

AccountController.java(抜粋)
@RequestMapping で POST リクエストに紐付けられた login メソッドに渡す IdCard 型引数に @Valid アノテーションを付け、idCardValidator を呼び出す部分をコメントアウトしています。
package wrider.controller;

import java.util.Map;

import javax.validation.Valid;

import org.springframework.beans.factory.annotation.Autowired;
  :
import wrider.model.IdCard;
import wrider.model.UserProfile;

@Controller
@SessionAttributes({"idCard", "userProfile"})
public class AccountController {
  /*
  @Autowired
  private Validator idCardValidator;
  */
  @Autowired
  private Validator userProfileValidator;
  
  @ModelAttribute("idCard")
  public IdCard makeIdCard() {
    return new IdCard();
  }
  
  @ModelAttribute("userProfile")
  public UserProfile makeUserProfile() {
    return new UserProfile();
  }
  
  @RequestMapping(value="/account/login.html", method=RequestMethod.GET)
  public String login() {
    return "account/login";
  }
  
  @RequestMapping(value="/account/login.html", method=RequestMethod.POST)
  public ModelAndView login(@Valid IdCard idCard, BindingResult br) {
    
//    this.idCardValidator.validate(idCard, br);
    
    ModelAndView mav = new ModelAndView();
    mav.getModel().putAll(br.getModel());
    mav.setViewName("account/login");
    return mav;
  }
  :
}

エラーメッセージの登録
メッセージソースは「Validator と MessageSource」で行った ReloadableResourceBundleMessageSource の設定をそのまま使います。なので、Bean Validator が吐き出したエラーコードと、それらに対応するメッセージを errors.xml 追加するだけです。

バリデーションの結果、検出されたエラー情報は次のような感じで BindingResult に格納されます。
:
codes [Pattern.idCard.password,Pattern.password,Pattern.java.lang.String,Pattern];
    :
codes [NotEmpty.idCard.password,NotEmpty.password,NotEmpty.java.lang.String,NotEmpty];
    :
要は、各エラーにおいて上記コードのどれかに対応したメッセージが定義されていればいいわけです。従って、今回は次のように定義しました。

errors.xml(抜粋)
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
  <comment>field and form level error messages</comment>
    :
  <entry key="Email.email">指定外の文字列</entry>
  <entry key="NotEmpty.email">必須です</entry>
  <entry key="Pattern.password">8-32文字の半角英数</entry>
  <entry key="NotEmpty.password">必須です</entry>
</properties>

実行!
図は、以上の作業を経てできた login フォームに、メールアドレス「wrider」、パスワード「未入力」として submit した後の画面です。

メールアドレス欄には“指定外の文字列”と表示されています。ここを「未入力」にすると“必須です”と表示されます。

一方、パスワード欄は @NotEmpty で「未入力」が検出されたことを示す“必須です”と、@Patter の引数で指示した正規表現にマッチしていないことを示す「8-32文字の半角英数」の両方が表示されています。これではちょっとかっこ悪いです。

GrepCode を使って、@Email アノテーションで呼び出される EmailValidator のソースを調べて見ると、未入力(null または length() == 0)の場合は true を返すようになっています。つまり、「未入力チェックは @Email の仕事ではありません」ということなのでしょう。

カスタムアノテーションの実装
そこでパスワード(password)について、JSR-303: Bean Validator と Hibernate Validator リファレンスの「Chapter 3. Creating custom constraints」を参考に、@CheckPassword という独自の制約条件(Custom Constraint)を実装してみます。

CheckPassword.java
まず、アノテーション CheckPassword を定義します。@Target でアノテーションの付与対象を指定し、@Constraint の引数 validatedBy で、このアノテーションが呼び出すバリデーターを指示します。
package wrider.annotation;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.*;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

import wrider.validator.CheckPasswordValidator;

@Target({FIELD, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = CheckPasswordValidator.class)
@Documented
public @interface CheckPassword {
  
  String message() default "{wrider.annotation.CheckPassword.message}";
  
  Class<?>[] groups() default {};
  
  Class<? extends Payload>[] payload() default {};
  
}

CheckPaswordValidator.java
実際のバリデーション処理を行うクラスです。未入力チェックは他のアノテーションに譲り、正規表現によるフォーマットチェックの結果を返すようにしています。
package wrider.validator;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

import org.springframework.util.StringUtils;

import wrider.annotation.CheckPassword;

public class CheckPasswordValidator implements ConstraintValidator<CheckPassword, String> {

  private static final String PASSWORD_PATTERN = "^[a-zA-Z0-9]{8,32}$";
  
  public void initialize(CheckPassword constraintAnnotation) {
    
  }
  
  public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
    if (!StringUtils.hasLength(object)) {
      return true;
    }
    else {
      return this.patternMatching(PASSWORD_PATTERN, object);
    }
  }

  private boolean patternMatching(String patternStr, String targetStr) {
    Pattern pattern = Pattern.compile(patternStr);
    Matcher matcher = pattern.matcher(targetStr);
    return matcher.matches();
  }
  
}

IdCard.java(抜粋)
これで @CheckPassword の作成は完了です。これを password に対して @Pattern の変わりに付与します。
:
  @NotEmpty
  @CheckPassword
  private String password;
    :

errors.xml(抜粋)
後は、@CheckPassword に対応したエラーメッセージを errors.xml に追加します。
:
  <entry key="CheckPassword.password">8-32文字の半角英数</entry>
    :

再び実行!
今度は、未入力時には“必須です”、入力ルールに従っていない文字列の時には“8-32文字の半角英数”のメッセージだけがパスワード欄に表示されるようになりました。

様々な制約条件に柔軟に対応できる Bean Validation は、慣れてしまえば重宝する仕掛けだと感じました。今回は取り上げませんでしたがバリデーションの順序を指定できる @GroupSequence など、他にも色々と面白そうな機能があり、これからやみつきになりそうです。

2012年4月2日月曜日

DI による Validator の再利用


今回の課題は「Validator と MessageSource」で作成したパーツを再利用した登録フォームにします。

データオブジェクトの再利用
とりあえず登録フォームの入力項目は以下のように考えました。
  • メールアドレス: email
  • パスワード: password
  • 名字: firstName
  • 名前: lastName
この中で「メールアドレス」と「パスワード」は前に作った IdCard と重複しているので、登録フォーム用のデータオブジェクト(UserProfile)には、これを再利用することにします。

UserProfile.java
このクラスは IdCard クラスを extends しています。名字(firstName)と名前(lastName)は、プロパティとそれらに対応する setter/getter を定義していますが、メールアドレス(email)とパスワード(password)については、スーパークラスの setter/getter を呼び出しています。
package wrider.model;

public class UserProfile extends IdCard {
  
  private String lastName;
  
  public String getLastName() {
    return this.lastName;
  }
  
  public void setLastName(String lastName) {
    this.lastName = lastName;
  }
  
  private String firstName;
  
  public String getFirstName() {
    return this.firstName;
  }
  
  public void setFirstName(String firstName) {
    this.firstName = firstName;
  }
  
  public String getEmail() {
    return super.getEmail();
  }
  
  public void setEmail(String email) {
    super.setEmail(email);
  }
  
  public String getPassword() {
    return super.getPassword();
  }
  
  public void setPassword(String password) {
    super.setPassword(password);
  }

}

Validator の再利用
Validator は、IdCard 用の IdCardValidator を DI(Dependency injection)します。

UserProfileValidator.java
リファレンスの“6.2 Validation using Spring's Validator interface”に習い、コンストラクター引数として IdCardValidator を DI しています。この方法を Constructor-based DI というらしいです。

上記 UserProfile クラスと同じように登録フォーム独自の項目である「名字」と「名前」の未入力チャックとエラー情報の reject を ValidationUtilsrejectIfEmptyOrWhitespace(..) メソッドで行い、「メールアドレス」と「パスワード」に関しては、DI した IdCardValidator をinvokeValidator(..) メソッドで呼び出して検証させます。
package wrider.validator;

import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
import org.springframework.validation.Errors;

import wrider.model.UserProfile;

public class UserProfileValidator implements Validator {
  
  private final Validator idCardValidator;
  
  private static final String ERROR_FIELD_EMPTY = "error.field.empty";
  
  public UserProfileValidator(Validator idCardValidator) {
    this.idCardValidator = idCardValidator;
  }
  
  public boolean supports(Class<?> clazz) {
    return UserProfile.class.isAssignableFrom(clazz);
  }
  
  public void validate(Object target, Errors errors) {
    
    ValidationUtils.rejectIfEmptyOrWhitespace(errors, "firstName", ERROR_FIELD_EMPTY);
    ValidationUtils.rejectIfEmptyOrWhitespace(errors, "lastName", ERROR_FIELD_EMPTY);
    
    UserProfile userProfile = (UserProfile)target;
    
    ValidationUtils.invokeValidator(idCardValidator, userProfile, errors);
    
  }
}

Validator の登録
新しく作成した UserProfileValidator を applicationContext.xml に登録します。

applicationContext.xml(抜粋)
リファレンスの“4.4.1.1 Constructor-based dependency injection”に習い <constructor-arg> 要素で userProfileValidator ビーンのコンストラクター引数として idCardValidator を DI するよう指示しています。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    :
  http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-2.5.xsd">
  
  <!-- Validator -->
  <bean id="idCardValidator"
    class="wrider.validator.IdCardValidator"/>
  
  <bean id="userProfileValidator"
    class="wrider.validator.UserProfileValidator">
    <constructor-arg ref="idCardValidator"/>
  </bean>
    :
</beans>

エラーコードの修正
今回は IdCard と UserProfile のバリデーションエラーに共通のエラーコードで対応したいので、以下のように errors.xml を修正しました。

修正前 [エラーコード].[オブジェクト名].[フィールド名]
 ↓
修正後 [エラーコード].[フィールド名]

errors.xml
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
  <comment>field and form level error messages</comment>
  <entry key="error.field.empty.email">必須です</entry>
  <entry key="error.field.illigal.email">あなたのメルアドです</entry>
  <entry key="error.field.empty.password">必須です</entry>
  <entry key="error.field.illigal.password">内容をお確かめ下さい</entry>
  <entry key="error.field.empty.firstName">必須です</entry>
  <entry key="error.field.empty.lastName">必須です</entry>
  <entry key="error.form.invalid.idCard">入力ミスがあります</entry>
  <entry key="error.form.invalid.userProfile">入力ミスがあります</entry>
</properties>

registrationForm.jsp(抜粋)
登録フォームの jsp です。とりあえず先の login.jsp に名字と名前を追加して、<form:form> タグの modelAttribute と、POST リクエストを投げる先を変えただけです。
<body>
  <h1>WriDer's Demo Site</h1>
  <form:form action="register.html" method="POST" modelAttribute="userProfile">
    <label for="email">メールアドレス</label>
    <input type="text" id="email" name="email" value="${userProfile.email}"/>
    <span class="red"><form:errors path="email"/></span>
    <br />
    <label for="password">パスワード</label>
    <input type="password" id="password" name="password" value="${userProfile.password}"/>
    <span class="red"><form:errors path="password"/></span>
    <br />
    <label for="firstName">名字</label>
    <input type="text" id="firstName" name="firstName" value="${userProfile.firstName}"/>
    <span class="red"><form:errors path="firstName"/></span>
    <br />
    <label for="lastName">名前</label>
    <input type="text" id="lastName" name="lastName" value="${userProfile.lastName}"/>
    <span class="red"><form:errors path="lastName"/></span>
    <br />
    <input type="submit" value="決定"/>
  </form:form>
</body>

コントローラーの変更
今回の登録フォームも AccountController クラスで対応します。@RequestMapping アノテーションで /account/register.html に対する GET/POST リクエストを振り分けています。POST で実行される register メソッドで userProfileValidator を呼び出し、バリデーションを行っています。

また、@SessionAttributes, @Autowired, @ModelAttribute アノテーション、および import に UserProfile に関する記述を追加しています。

AccountController.java(抜粋)
package wrider.controller;
    :
import wrider.model.UserProfile;

@Controller
@SessionAttributes({"idCard", "userProfile"})
public class AccountController {
  
  @Autowired
  private Validator idCardValidator;
  
  @Autowired
  private Validator userProfileValidator;

    :
  @ModelAttribute("userProfile")
  public UserProfile makeUserProfile() {
    return new UserProfile();
  }
    :

  @RequestMapping(value="/account/register.html", method=RequestMethod.GET)
  public String register() {
    return "account/registrationForm";
  }
  
  @RequestMapping(value="/account/register.html", method=RequestMethod.POST)
  public ModelAndView register(UserProfile userProfile, BindingResult br) {
    
    this.userProfileValidator.validate(userProfile, br);
    
    ModelAndView mav = new ModelAndView();
    mav.getModel().putAll(br.getModel());
    mav.setViewName("account/registrationForm");
    return mav;
  }
    :
}

実行
図はブラウザーで /account/register.html にアクセスし、「名前」を未入力にして submit した後の画面です。

POST リクエストを受けた Spring Framework は IdCard インスタンスに「メールアドレス」と「パスワード」、UserProfile インスタンスに「名字」「名前」をセットし、AccountController の register(..)メソッドを呼び出します。同メソッドは UserProfileValidator のバリデーション結果を BindingResult から取り出して ModelAndView にセットします。

Java は元々、コンポーネントを再利用する仕掛けを持っていますが、Spring Framework が提供する DI(または IoC)の仕掛けを使えば、依存関係を記した XML(やアノテーション)に従って、インスタンス化や注入を行ってくれます。使いたいコンポーネントを直に import して new する頻度を減らせるので、うまく使いこなせば大規模アプリケーションの改修なども楽になるかも、と感じました。