Live By The Code

元業務系プログラマの呟き

JUnitを用いたジョブ単位のテスト

jbatchで書いたバッチについて、ジョブ単位でJUnitを使ってテストする手法について述べる。

ジョブの起動手段

jbatchジョブの起動手段(JobOperator#start()を呼び出す)としては、Webへのリクエストを起点にする他に、APサーバへのリモートEJB呼び出しを起点にする方法がある。処理の簡潔さから、このエントリでは後者を用いる。

起点となるリモートEJB呼び出しを受け取るためのコード

まずインタフェースを作る。

public interface BatchExecutionBean {

    JobExecution getJobExecutionDetails(long executionId);
    void stopJob(long executionId);
    long restartJob(long executionId);
    long submitJob(String batchXMLName, Properties jobProperties);
    BatchStatus waitForFinish(long executionId);
    @javax.ejb.Remote
    public interface Remote extends BatchExecutionBean {}
    @javax.ejb.Local
    public interface Local extends BatchExecutionBean {}
}

その実装クラス。

@Stateless
public class BatchExecutionBeanImpl implements BatchExecutionBean.Local, BatchExecutionBean.Remote {

   @Override
   public long submitJob(String batchXMLName, Properties props) {
        JobOperator jobOperator = getJobOperator();
        long executionId = jobOperator.start(batchXMLName, props);
        return executionId;
    }

    @Override
    public JobExecution getJobExecutionDetails(long executionId) {
        JobOperator jobOperator = getJobOperator();
        JobExecution jobExecution = jobOperator.getJobExecution(executionId);
        return jobExecution;
    }

    @Override
    public long restartJob(long executionId) {
        Properties jobProperties = new Properties();
        long newExecutionId = getJobOperator().restart(executionId, jobProperties);
        return newExecutionId;
    }

    @Override
    @TransactionAttribute(NOT_SUPPORTED)
    public BatchStatus waitForFinish(long executionId) {
        Date endTime = null;
        try {
            while (endTime == null) {
                JobExecution je = getJobExecutionDetails(executionId);
                endTime = je.getEndTime();
                Thread.sleep(1000l);
            }
        } catch (InterruptedException ex) {
            throw new RuntimeException(ex);
        }
        return getJobExecutionDetails(executionId).getBatchStatus();
    }

    protected JobOperator getJobOperator() {
        return BatchRuntime.getJobOperator();
    }

    @Override
    public void stopJob(long executionId) {
        getJobOperator().stop(executionId);
    }
}
テストクラスの処理の流れ

概ね以下のような流れを想定している。

  1. テストデータの準備
  2. BatchExecutionBeanのリモートインタフェース取得
  3. リモートインタフェースを介してバッチを起動しexecutionIdを取得
  4. executionIdを引数に、バッチが終了するまで返らないwaitForFinish()を呼び出す
  5. 終了コード(BatchStatus)を検証
  6. 実行結果を検証(DB等)
テストクラスの例
  • ジョブ定義XMLのファイル名が「test.xml
  • アプリケーション名が「Test」
  • パッケージが「com.example」

の場合、テストクラスは以下のようになる。

public class HogeTest {

    @Test
    public void normal() throws Exception {
        // TODO: このへんでテストデータを準備        

        Properties props = new Properties();
        BatchExecutionBean be = InitialContext.doLookup("java:global/Test/BatchExecutionBeanImpl!com.example.BatchExecutionBean$Remote");
        Assert.assertEquals(BatchStatus.COMPLETED, b.waitForFinish(be.submitJob("test", props)));

        // TODO: このへんで出力データを検証
    }
}

doLookup()に渡す文字列は、GlassFishの場合、アプリケーションのデプロイ中にログに出力される以下のようなメッセージの内容から文字列を抜き出してコピペすると良い。

EJB5181:Portable JNDI names for EJB BatchExecutionBeanImpl: [java:global/Test/BatchExecutionBeanImpl!com.example.BatchExecutionBean$Local, java:global/Test/BatchExecutionBeanImpl!com.example.BatchExecutionBean$Remote]

テストクラスを実行する際は、GlassFishをインストールしたディレクトリ内のglassfish/lib/gf-client.jarをクラスパスに追加する。

テストクラスとAPサーバを実行するマシンが同一で、APサーバがGlassFishかつポート番号を変更していない場合は、このままテストクラスを実行すればリモートEJB呼び出しを経由してジョブが実行され、終了コードのアサーションが出来る。

DBへのテストデータ投入・検証にはDBUnitが非常に便利である。

テストをし易くするために

ファイルやネットワーク上のリソース(Web・FTP等)を入出力するジョブの場合は、Properties属性と@BatchPropertyアノテーションを使って、ファイルパスやURLを外部から受け取るようにしておくとテストがし易くなる。

上記テストクラスの場合、ジョブ実行前にテストデータが含まれたファイルを置いたり*1、テスト用のWebサーバ*2FTPサーバを起動して、そのパスやURLをsubmitJobの第2引数で渡すPropertiesに入れて渡してやれば良い。

JPAのキャッシュに注意

バッチでJPAを使っている場合、共有キャッシュが有効になっていると、DBUnitを使ってDBのデータ投入・検証を行う際に実際のテーブル上のデータとバッチから見えるデータに乖離が生まれてしまう。特にEclipseLinkの場合はデフォルトで共有キャッシュが有効になっているため、本エントリで述べた手法でテストをする際は必ず無効にしておく。

具体的には、persistence.xml内persistence-unit要素内に以下を記載する。

<shared-cache-mode>NONE</shared-cache-mode>

*1:JUnitのRuleを使うと便利

*2:com.sun.net.httpserver.HttpServerを使うと便利

Step間のインスタンス持ち回り

JavaEE7から新しく入ったバッチフレームワークの規格JSR352(だいたいjbatchと表記される)を最近よく弄っているので、気付いた点を若干纏めてみようと思う。

JSR352とは

概要は、既にいい感じにスライドとして纏められているこれを見ると非常にわかりやすい。要はSpringBatchをベースに、最低限の範囲で機能を抜き出して作られたイメージの規格になっている。JavaEEコンテナ上で動作するので、トランザクション管理やDIの仕組みはJavaEEのものを使うことができる。

Step間のインスタンス持ち回りがやり辛い

jbatchでバッチを作り始めて、先ず思ったのがStep(jbatchにおける処理の単位)間で何らかのインスタンスを持ちまわるのがすごくやり辛いという事(設計思想的に、そのような実装は推奨しないという事かもしれないが)。

例えばジョブの先頭で何らかのマスタデータをDBから拾ってきて持っておき、後続のStepで参照しながら処理をするといったような実装が難しい。

標準で用意されている持ち回りの手段の問題点

一応標準で用意されている手段としては、アプリ実装者が書くクラスにDI出来るJobContextまたはStepContextインスタンスに存在するgetTransientUserData()/setTransientUserData()メソッドを使う方法がある。

これを使えばジョブ単位あるいはステップ単位でのインスタンス持ち回りが可能となるが、単位がObject型のインスタンス1つだけというのは非常に使い辛い。せめてMapなら良かったのだけど。

またtransientUserDataに入っているインスタンスの型を決め打ちする実装になってしまうので、実装したクラスを他のジョブ等へ流用するのが難しくなるし、単体テストも若干面倒になる(JobContext/StepContextのモックを用意してやる必要がある)。

CDIのスコープを自作する

jbatchではJavaEE、即ちCDIに用意されている各種機能を使うことが出来るので、JSFでCDIのスコープを自作する例を参考に、JobContextScoped/StepContextScopedというアノテーションを定義して使えるようにしてみた。

まずアノテーションを定義する。

@Target(value = {ElementType.METHOD, ElementType.TYPE, ElementType.FIELD})
@Retention(value = RetentionPolicy.RUNTIME)
@NormalScope
@Inherited
public @interface JobContextScoped {
}

次にContextインタフェースの実装クラスを作る。CDIのBeanManagerからJobContextを取ってきて、transientUserDataにHashMapを突っ込み、そこにCDI管理Beanのインスタンスを格納する。

public class JobContextContext implements Context {

    @Override
    public Class<? extends Annotation> getScope() {
        return JobContextScoped.class;
    }

    @Override
    public <T> T get(Contextual<T> contextual, CreationalContext<T> creationalContext) {
        Bean bean = (Bean) contextual;
        Map<String, Object> utdMap = getUserTransientDataMap();
        if (utdMap.containsKey(bean.getName())) {
            return (T) utdMap.get(bean.getName());
        } else {
            T t = (T) bean.create(creationalContext);
            utdMap.put(bean.getName(), t);
            return t;
        }
    }

    @Override
    public <T> T get(Contextual<T> contextual) {
        Bean bean = (Bean) contextual;
        Map<String, Object> utdMap = getUserTransientDataMap();
        if (utdMap.containsKey(bean.getName())) {
            return (T) utdMap.get(bean.getName());
        } else {
            return null;
        }
    }

    private Map<String, Object> getUserTransientDataMap() {
        JobContext jc = getJobContext();
        Map<String, Object> map = (Map<String, Object>) jc.getTransientUserData();
        if (map == null) {
            map = new HashMap<>();
            jc.setTransientUserData(map);
        }
        return map;
    }

    private JobContext getJobContext() {
        try {
            BeanManager bm = InitialContext.doLookup("java:comp/BeanManager");
            Context context = bm.getContext(Dependent.class);
            Set<Bean<?>> beans = bm.getBeans(JobContext.class);
            if (beans.isEmpty()) {
                return null;
            }
            Bean<JobContext> bean = (Bean<JobContext>) beans.iterator().next();
            CreationalContext<JobContext> createCreationalContext = bm.createCreationalContext(bean);
            return context.get(bean, createCreationalContext);
        } catch (NamingException ex) {
            return null;
        }
    }

    @Override
    public boolean isActive() {
        return getJobContext() != null;
    }
}

さらにExtensionインタフェースの実装クラスを作る。

public class JobContextContextExtension implements Extension{

    public void afterBeanDiscovery(@Observes AfterBeanDiscovery event, BeanManager manager) {
        event.addContext(new JobContextContext());
    }
}

最後にクラスパス上のMETA-INF/servicesに、javax.enterprise.inject.spi.Extensionという名前のファイルを作り、前述のExtensionを実装したクラスのFQCNを書いてやる(複数ある場合は改行して記入)。パッケージがcom.exampleなら以下になる。

com.example.JobContextContextExtension
使い方

例えば、以下のようなCDI管理Beanを定義し、

@Named
@JobContextScoped
public class HogeBean {

    private String hoge;

    public String getHoge() {
        return hoge;
    }

    public void setHoge(String hoge) {
        this.hoge = hoge;
    }
}

あるStepで以下のようなBatchletを使い値を書き込むと、

@Named
public class NewBatchlet extends AbstractBatchlet {

    @Inject
    HogeBean h;

    @Override
    public String process() throws Exception {
        h.setHoge("hoge");
        return "SUCCESS";
    }
}

後続のStepで以下のように値を取り出すことが出来るようになる。

@Named
public class NewBatchlet2 extends AbstractBatchlet {

    @Inject
    HogeBean h;

    @Override
    public String process() throws Exception {
        System.out.println(h.getHoge());
        return "SUCCESS";
    }
}
メリット

これでStep間の結合度が弱くなり、付随して以下のようなメリットが得られる。

  • 単体テストがし易くなる
  • 実装したクラスを他のジョブ等へ流用することが若干楽になる
  • キャストが減る
備考、その他

StepContextScopedも殆ど上記と同じコードで可能。

あとはジョブを定義するXML上で、JSFのようにel式を使ってDIするインスタンスを指定できればより良いのだが、仕様にはそのような使い方は書かれていないし、GlassFish4付属の実装でも動かないようだ。

「JobScoped」ではなく「JobContextScoped」という名称にしたのは意図があり、厳密にはJobに対して一意のインスタンスではないのでこのような名称とした(JobContextに対して一意になっている)。

Job定義XML内でsplit要素等を使って並列処理をさせると、1つのジョブであっても、JobContextインスタンスがそれぞれ別インスタンスになる*1。これは厳密にはジョブに対して一意のインスタンスではない。

JSR352仕様はこちら

*1:JSR352仕様「9.4.1.1 Batch Context Lifecycle and Scope」による

脱Windows?

ノートPCを購入予定だが、現在作業しているWindows環境に不満があるためにLinuxあるいはMacへの移行を検討中である。

現在の環境の不満

  • コマンドラインが貧弱
  • Emacsの設定が大変
コマンドラインが貧弱

Cygwinを入れているがLinuxに慣れた身には使い辛い。最低限のコマンドは揃っているものの、ファイルシステムやカーネルはWindowsのそれなわけで。Cygwinより良い何かがあるのかもしれないけど見つけられなかった。

Emacsの設定が大変

Windows版のEmacsを、Linux上でのそれと同等に使えるレベルまで設定する事が出来ていない。ちゃんと時間かけて調べれば、まともに使えるように出来るのだろうけど、そこまで到達できていない。

良い機会かもと思い別のエディタに移ろうかと思ったけど無理過ぎた。

その他の不満

NetBeans+GlassFishで作業しているときに出るこの現象とか地味にイライラする事が…。

どうするか

やっぱり慣れたUNIXライクな環境に移行するのが楽である。候補としてはMacかLinuxになるがどちらが良いか今後検討していく。

とりあえず使っていない古いMacBookが有るので、これを最新のOSに更新して、ソコソコ使える環境が構築出来るか試してみることにする。満足出来ればそのまま、あるいは新しいMacBook購入、駄目ならLinuxが使えそうなノートPC購入。

GlassFish4のJSR352(jbatch)のジョブ定義ファイルで日本語を使うと文字化け

ジョブ定義ファイルはこんな感じ

<?xml version="1.0" encoding="UTF-8"?>
<job id="mock" version="1.0" xmlns="http://xmlns.jcp.org/xml/ns/javaee">
    <step id="step1">
        <batchlet ref="mockBatchlet">
            <properties>
                <property name="text" value="にほんご"/>
            </properties>
        </batchlet>
    </step>
</job>

Batchletはこんな感じ

package finance.mock;

import javax.batch.api.AbstractBatchlet;
import javax.batch.api.BatchProperty;
import javax.inject.Inject;
import javax.inject.Named;

@Named
public class MockBatchlet extends AbstractBatchlet {

    @Inject
    @BatchProperty
    String text;

    @Override
    public String process() throws Exception {
        System.out.println(text);
        return "SUCCESS";
    }
}

で、走らせると
f:id:lbtc_xxx:20130629215718j:plain

うーん。なんだこりゃ。冒頭のencodingとファイルの文字コードが違ってるとか、そういう事はないんだけども。

こういう時は数値文字参照を使うと化けずに文字列を渡せる。こんな感じ

<?xml version="1.0" encoding="UTF-8"?>
<job id="mock" version="1.0" xmlns="http://xmlns.jcp.org/xml/ns/javaee">
    <step id="step1">
        <batchlet ref="mockBatchlet">
            <properties>
                <property name="text" value="&#12395;&#12411;&#12435;&#12372;"/>
            </properties>
        </batchlet>
    </step>
</job>

日本語文字列を数値文字参照に変換する際は以下サイトが便利

http://ledyba.org/utl/NumericCharacterReference/