Action の Transaction

デフォルトTransaction

まず、大前提

Action の Execute メソッドは、デフォルトで Transaction が発行されています。

正常終了 (例外throwなし)
自動的にコミット
例外発生 (Executeメソッドが中断)
自動的にロールバック

Actionクラスのディベロッパーは、基本的に自分でTransaction制御をする必要はありません。 正常終了すればコミットされますし、例外が発生してExecuteメソッドが中断すればロールバックされます。

アーキテクチャコンセプト

このようなコンセプトを元にしています。

  • ActionのほとんどのTransactionは、リクエスト単位で制御したい
  • LastaFlute の Action はフローコントロールも兼ねる (無駄なレイヤの排除) ※A
  • 検索だけのリクエストでも、一貫性を保ったアクセスを推奨
  • 検索だけのリクエストのとき、SQLごとの内部トランザクションコストを回避

※A: Actionの役割として、バウンダリ、かつ、ファサード を想定しています。

物理的な配慮

これらは、ActionのTransactionに限らず、LastaFlute自体のTransactionの特徴となります。

実際のTransaction開始
実際のTransaction開始は最初のDBアクセスからです。なので、DBアクセスのないリクエストは実質的にTransactionコストはありません。
Transaction時間の提供
Transaction内では、TimeManager が提供する現在日付 (currentDateTime()など) は固定でTransaction開始時間を戻します。 Transactionの中では業務的な時間経過を意識しないというコンセプトで、同じTransactionの中でのDBに保存される更新日時などを同一にします。
更新時はTransaction必須
Transaction内でのみ、AccessContext が利用可能です。実質的に更新処理は必ずTransactionが必要ということになります。 (もちろん、共通カラムが存在しないテーブルだと更新はできてしまいますが)

コミット後に何か処理したい

もし、コミット後に何か処理をしたいというときは、afterTxCommit()を使います。

例えば、Signupが終わった後に自動的にログイン処理を走らせたい時、コミットしてからじゃないとログイン処理の中のログイン履歴登録の非同期処理 (別トランザクションになる) が動作しないので、そういうときに使うと良いでしょう。

e.g. afterTxCommit() for signup'S login @Java
@Execute
public HtmlResponse signup(SignupForm form) { // already in transaction
    validate(form, messages -> moreValidate(form, messages), () -> {
        return asHtml(path_Signup_SignupHtml);
    });
    Member member = insertProvisionalMember(form);
    String token = signupTokenAssist.saveSignupToken(member);
    sendSignupMail(form, token);
    return redirect(MypageAction.class).afterTxCommit(() -> {
        loginAssist.identityLogin(...); // after transaction committed
    });
}

正常/異常に関わらず何か処理したい

Transactionの中でもいいのであれば

あまり特別なことはありません。Java の try-finally を使えば実現できます。

e.g. afterTxCommit() for signup's login @Java
@Execute
public HtmlResponse signup(SignupForm form) { // already in transaction
    validate(form, messages -> moreValidate(form, messages), () -> {
        return asHtml(path_Signup_SignupHtml);
    });
    try {
        return doSignup(form);
    } finally {
        // always executed in spite of transaction success/failure
        ...
    }
}

private HtmlResponse doSignup(SignupForm form) {
    ...
}

大抵のケースでは、これで特に問題ないと想定しています。

Transactionの外じゃないとダメであれば

どうしてもトランザクションの外じゃないといけないというのであれば、そもそも独立性が高い処理ではないでしょうか? であれば、ActionHook の hookFinally() を使うこともできます。

e.g. hookFinally() for business process outside action transaction @Java
@Override
public void hookFinally(ActionRuntime runtime) { // outside transaction
    super.hookFinally(runtime); // don't forget super
    // always executed in spite of transaction success/failure
    ...
}

@Execute
public HtmlResponse signup(SignupForm form) { // already in transaction
    return ...;
}

そのActionクラス内の別のExecuteメソッドでも実行されてしまいますが、発生した例外とかで判断できるのであれば、引数の ActionRuntime から例外を取得して分岐させることができます。

Transactionの外だけど、つながりも深い

Transactionの外じゃないとダメで、かつ、つながりも深いので、そのExecuteメソッド内じゃないと実装がしづらいというのであれば、 デフォルトのTransactionを抑制して、自前でTransaction制御をすると良いでしょう。

デフォルトTransactionの抑制
@Execute(suppressTransaction=true)
自前のTransaction制御
TransactionStage: requred(), requiresNew()

ロールバックしつつ画面遷移したい

共通的に画面を表示するのであれば

すでに、LastaFluteの中で業務例外の仕組みが用意されています。MessageApplicationException などを throw することで、HtmlResponseであれば show_errors.html に遷移し、JsonResponseであれば ApiFailureHook が呼ばれます。

固有の画面に遷移するのであれば

こちらも同じく、業務例外の仕組みで実現すると良いでしょう。

そもそもロールバックするということは業務的な例外と言えますし、フレームワークを経由することで通知ログに記録されたりなどのメリットもあるので、単純な分岐で実現するのではなく仕組みの上でやりましょう。

業務例外を作成して throw し、handleApplicationException() をオーバーライドして処理します。 個別のActionだけの話であれば、その個別のActionでオーバーライドして、どのActionでも共通する処理であれば、Actionのスーパークラスでオーバーライドしましょう。

e.g. handleApplicationException() for specific exception @Java
@Override
public void handleApplicationException(ActionRuntime runtime, ApplicationExceptionHandler handler) {
    super.handleApplicationException(runtime, handler); // don't forget super
    handler.handle(SeaLandBothLimittedException.class, createMessages().addErrors...(GLOBAL), cause -> {
         return asHtml(path_...);
    });
}

private static class SeaLandBothLimittedException extends LaApplicationException {

    ...
}

@Execute
public HtmlResponse signup(SignupForm form) { // already in transaction
    if (...) {
        throw new SeaLandBothLimittedException(...);
    }
    return ...;
}

もし、メッセージが不要なのであれば、UserMessages は UserMessages.empty() を指定します。

業務例外は、LaApplicationException を継承して作成します。そのActionだけで閉じる局所的な例外であれば、インナークラスでも構わないでしょう。

はちゃめちゃTransaction制御したい

TransactionStageを連れてきて

こうなったら、suppressTransaction=true しつつ、TransactionStage を DI して、好きなようにやりましょう。

デフォルトTransactionの抑制
@Execute(suppressTransaction=true)
自前のTransaction制御
TransactionStage: requred(), requiresNew()
e.g. suppressTransaction and TransactionStage @Java
@Resource
private TransactionStage transactionStage;

@Execute(suppressTransaction=true)
public HtmlResponse signup(SignupForm form) { // already in transaction
    transactionStage.required(tx -> {
        // as you like it
        ...
    });
    String result = (String)transactionStage.required(tx -> {
        // as you like it
        ...
        tx.returns("sea"); // you can return value for caller
    }).get();
    logger.debug("result: {}", result); // result: sea
    return ...;
}

TransactionStageのメソッド

TransactionStageのメソッドには、幾つかの種類があります。

required()
外側に Transaction があれば継承
requiresNew()
関係なく Transaction を必ず発行
selectable()
そのどっちかを引数で指定

どのメソッドでも、TransactionStage のコールバック処理の中で例外が発生すれば自動的にロールバックされます。 (正常に終了すれば自動的にコミットされます)

suppressTransactionしているのであれば、どちらでも挙動は変わらないので、UnitTestのときのことを考えて required() の方が良いでしょう。UnitTestのときだけはUnitTestのTransactionを継承して全体ロールバックができるようになります。

ネストしたTransactionのときは、requiresNew() を使います。UnitTestのときは、UTFlute の changeRequiresNewToRequired() を呼び出して、一時的に効果を調整すると良いでしょう。

TransactionStage の JavaDoc コメントをしっかり読んで使いましょう。

アノテーションじゃないのは?

アノテーションを付けて AOP で実現しようとすると、クラスをエンハンスするコストがかかるため、アプリ起動時間などに影響が出ます。 また、本来privateでも良いメソッドをpublicにする必要もあるなど、何かとややこしいので、普通のプログラムで表現した方がわかりやすいだろうと判断しました。

ちなみに、アノテーションによるトランザクション制御も実はできます。

更新処理はTransaction必須!?

LastaFluteでは、insert,update,deleteなどの更新処理は Transaction で実行すること を強く推奨しています。

(update()だけの処理...と思ってたら後でdelete()/insert()に変わったり、業務的に別の処理が追加されたりなどなど)

NonTransactionalUpdateException (Translated)

ActionのデフォルトTransactionがあるので、自然とTransactionの中で更新することになるのであまり気にすることはないですが、明示的にデフォルトTransactionを抑制したり、Jobクラスからの実行の場合、そのまま更新処理をすると 更新にはTransactionが必要だよ例外 が発生する可能性があります。

e.g. NonTransactionalUpdateException by AccessContextNotFoundException @Log
2019-07-14 01:28:16,176 [qtp815320891-15] ERROR (RequestLoggingFilter@logError():1050) - *ServletException occurred.
/= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =: /member/edit/
...
  ; url=http://localhost:8090/harbor/member/edit/
...
  [param] memberAccount=Genius
  [param] memberId=2
  [param] memberName=Savicevic
...
  [header] Date=Sat, 13 Jul 2019 16:28:15 GMT
  [header] Last-Modified=Thu, 04 Jul 2019 07:46:50 GMT
= = = = = = = = = =/ [00m00s285ms] #2efafc2e
org.lastaflute.db.dbflute.exception.NonTransactionalUpdateException: Look! Read the message below.
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
The update process without transaction was found.

[Advice]
Update (contains insert, delete) should be executed in transaction.
Check your settings and implementations of the process.
* * * * * * * * * */
	at org.lastaflute.core.exception.ExceptionTranslator.throwNonTransactionalUpdateException(ExceptionTranslator.java:132)
	at org.lastaflute.core.exception.ExceptionTranslator.translateException(ExceptionTranslator.java:112)
	...
	...
	...
Caused by: org.dbflute.exception.AccessContextNotFoundException: Look! Read the message below.
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
The access context was not found on thread.

[Advice]
Set up the value before DB access (using common column auto set-up)
You should set it up at your application's interceptor or filter.
For example:
  try {
      AccessContext context = new AccessContext();
      context.setAccessLocalDateTime(accessLocalDateTime);
      context.setAccessUser(accessUser);
      context.setAccessProcess(accessProcess);
      AccessContext.setAccessContextOnThread(context);
      return invocation.proceed();
  } finally {
      AccessContext.clearAccessContextOnThread();
  }
* * * * * * * * * */
	at org.dbflute.hook.AccessContext.throwAccessContextNotFoundException(AccessContext.java:448)
	at org.dbflute.hook.AccessContext.getAccessUserOnThread(AccessContext.java:332)
	...
	...
	...

AccessContextNotFoundException (Caused by)

AccessContextNotFoundExceptionは、NonTransactionalUpdateExceptionの原因例外となっています。 (スタックトレースにて Caused by で表示される)

DBFluteの共通カラムの自動設定のための AccessContext は、Transactionが開始されたら初期化され、Transactionが終了したら破棄されます。

つまり、共通カラムの自動設定は、Transaction内でのみ有効となります。 ゆえに、Transactionが実行されていない状態で更新処理をした場合、AccessContextNotFoundException が発生し、NonTransactionalUpdateException に翻訳されます。

厳密には共通カラムが設定されていないテーブルの更新だと例外にはなりませんが、共通カラムが定義されているであろうメジャーなテーブルでチェックされれば十分と捉えてこのようにしています。 (将来のバージョンで厳密なチェックをするオプションなどを実装するかもしれませんが、実装コストも掛かるので費用対効果次第)

この例外を見かけたら...

この例外を見かけたら、以下のことを確認してみましょう。

  • ActionのデフォルトTransactionを外していないか?
  • JobクラスでTransactionStageを使わずに更新処理をしていないか?
  • TransactionStageの使い方が間違っていないか?

そもそも Transaction が掛かっているかどうかは、デバッグログのBeginログの有無で確認できます。

e.g. transaction begin logging @Log
...
(LaTransaction@begin():69) - Begin transaction: [FormatId=..., GlobalId=..., BranchId=]
...
...
...
(LaTransaction@commit():181) - Commit transaction: [FormatId=..., GlobalId=..., BranchId=]
...