Actionの実装デザイン

LastaFluteからの提案です。

いきなりまとめ

実装デザインの提案

本気でDDDをやるフェーズまで達していない段階、かつ、プロジェクトで独自にアーキテクチャを定義していない場合の、LastaFlute からのデフォルト実装デザインの提案です。

Action (バウンダリ、かつ、ファサード: Transactionあり)
フローコントロール、そして、再利用しない検索や更新など(ちょこっとロジック)を書く
WEB依存 "する" 処理の再利用や、ちょこっと役割分担は、(Action)Assist
WEB依存 "しない" 処理の再利用は、(Business)Logic
Logic (ビジネスロジック)
WEB依存しない再利用するものを書く (二箇所以上で呼ばれて初めて Logic に)
ただし、検索の再利用はできるだけ ArrangeQuery にて (Logicでのまるごと再利用は限定的に)
Logicの名前は、"モノ+Logic" はダメ、できるだけ "モノ+業務+Logic"

LastaFlute ActionDesign

Logic についてはこちら

Logicは、また別途まとめています。

Bean についてはこちら

入れ物クラスの Bean (Form や JsonResult など) についてもまとめています。

Actionの役割

バウンダリ、かつ、ファサード

Action には、すでにTransactionがかかっています。 単なるバウンダリ(境界)としての役割だけでなく、ビジネスロジックのファサード(玄関) としての役割を持つことができます。

厳密なアーキテクチャではその役割を分離することが多いですが、LastaFlute ではシンプルさを追求するために、一つにしています。 スタートアップの開発では、その二つを分離することのメリットを享受する前に変更がたくさん入り、逆に分離によるデメリットの方が足かせになりやすいと考えます。

もし、ActionでTransactionがなければ

例えば、とある Action では、A という処理と B という処理と C という処理を行う必要があるとします。 もし、ActionにTransactionがかかっていなければ、通常は A, B, C を呼び出す流れのロジックは Action には書けません。 A と B で insert や update をしたとき、C で例外が発生しても A と B をロールバックできないからです。

ゆえに、Transactionを発行させるビジネスロジックのファサードのようなレイヤ(様々な呼ばれ方をします)を用意して、Actionはそこに委譲することが多いです。 ですが、そこで迷うのが、じゃあそのレイヤはどこまで依存していいのか?Formを引数で渡してもいいのか?何かしらのDTOに詰め替えをして渡す? しっかり決め事をしないとどんどん実装はバラバラになります。

LastaFluteでは、スタートアップではそのデザインの必要性があまりないだろうと考え、Action でそれをまかなえるようにしています。 少なくとも、わざわざ Form の値を DTO に詰め替えて、すぐ隣のクラスに渡すというようなことはスタートアップではあまり意味がないと考えています。

規約ベースなので Action は自然分離

LastaFlute の Action は規約ベースであり、ある意味不自由です。少なくとも、ネストしたURLの Execute メソッドを定義することはできません。SeaAction に、/sea/land/piari/ を受け付けるメソッドは作れません。 ゆえに、Action クラスに定義できるメソッドは限界があり、自然と分離されていきます。規約ベースでないフレームワークの Action で、/sea/ 配下の URL のメソッドが大量に一つの SeaAction に実装されて見通しが非常に悪いというようなアンチパターンを見かけますが、 LastaFluteでは比較的それは発生しづらいことです。

なので、Action クラスで "ある程度" ベタっと書くことを許容 して、逆にそのメリットを享受する方がよいだろうと考えています。 インクリメンタル開発では、既存のソースコードを読む時間が非常に長いです。あちこち飛ぶのはできるだけ少なくしたいものです。

Actionに何を実装するか?

フローコントロール (ファサード)

先ほど出てきた "A やって B やって C やって" という、そのリクエストにおけるTransactionに含む必要のある流れの呼び出しを司ります。

仮に、C で例外が発生しても、A と B がしっかりとロールバックされるように。

再利用しない検索や更新など (ちょこっとロジック)

ActionでDBアクセスしてはいけないというポリシーは LastaFlute には存在しません。その画面でしか使わない ConditionBean による検索や更新など、Action で入れてもOKです。

つまりこんな感じです

シンプルな処理ですが...

e.g. SeaLandAction in harbor @Java
/**
 * @author yourname
 */
public class SeaLandAction extends HarborBaseAction {

    // ===================================================================================
    //                                                                           Attribute
    //                                                                           =========
    @Resource
    private PurchaseBhv purchaseBhv;

    // ===================================================================================
    //                                                                             Execute
    //                                                                             =======
    @Execute
    public HtmlResponse index(int productId, SeaLandForm form) {
        validate(form, messages -> {}, () -> {
            return asHtml(path_Sea_SeaLandHtml);
        });
        ListResultBean<Purchase> purchaseList = selectPurchaseList(form);
        List<SeaLandRowBean> beans = mappingToBeans(purchaseList);
        return asHtml(path_Sea_SeaLandHtml).renderWith(data -> {
            data.register("beans", beans);
        });
    }

    // ===================================================================================
    //                                                                              Select
    //                                                                              ======
    private ListResultBean<Purchase> selectPurchaseList(SeaLandForm form) {
        return purchaseBhv.selectList(cb -> {
            cb.setupSelect_Member();
            cb.setupSelect_Product();
            cb.query().setProductId_Equal(productId);
            cb.orScopeQuery(orCB -> {
                orCB.query().setMemberId_Equal(userId);
                orCB.query().queryMember().existsMemberFollowingByYourMemberId(followingCB -> {
                    followingCB.query().setMyMemberId_Equal(userId);
                });
            });
            cb.query().existsPurchasePayment(paymentCB -> {
                paymentCB.query().setPaymentMethodCode_Equal_AsPaymentMethod(form.pay);
            });
            cb.query().addOrderBy_PurchaseDatetime_Desc();
        });
    }

    // ===================================================================================
    //                                                                             Mapping
    //                                                                             =======
    private List<SeaLandRowBean> mappingToBeans(ListResultBean<Purchase> purchaseList) {
        return purchaseList.mappingList(purchase -> {
            SeaLandRowBean bean = new SeaLandRowBean();
            bean.purchaseId = purchase.getPurchaseId();
            purchase.getMember().alwaysPresent(member -> {
                bean.memberName = member.getMemberName();
            });
            purchase.getProduct().alwaysPresent(product -> {
                bean.productName = product.getProductName();
                bean.productHandleCode = product.getProductHandleCode();
            });
            bean.purchaseDate = toStringDate(purchase.getPurchaseDatetime()).get();
            bean.purchasePrice = purchase.getPurchasePrice();
            return bean;
        });
    }
}

検索の再利用は部品単位で

もし、検索の中で再利用したいものがあれば、ArrangeQuery を使いましょう。これは、DBFluteのポリシーでもあります。検索の丸ごと再利用は多くの場合失敗に終わります。

e.g. 特別過ぎる優待会員を検索 @Java
MemberCB cb = new MemberCB();
cb.query().arrangeTooPreferentialMember(); 
cb.query().addOrderBy_...();
... = memberBhv.selectList(cb);
e.g. ある特別な商品を購入したことのある会員名称が "S" で始まる正式会員という条件を設定するメソッド @Java
public class MemberCQ extends BsMemberCQ {

    ...

    /**
     * 特別過ぎる優待会員
     */
    public void arrangeTooPreferentialMember() {
        setMemberName_PrefixSearch("S");
        setMemberStatusCode_Equal_Formalized();
        existsPurchaseList(purCB -> {
            purCB.query().setProductId_Equal(SPECIAL_PRODUCT_ID);
        });
    }
}

ActionをアシストするAssist

WEB依存でちょこっとまとめたい

Assistクラスは、単なるActionのアシストです。

e.g. Assist class @Java
public class SeaAssist {

    ...
}

WEBや画面に依存してるので、(Logicではなく) Action の中で記述するべきものだけど、ちょこっとまとまったクラスにしたい というときに使います。

  • Actionの中のprivateメソッドがでかくなってきたので、ちょっと外に出したい
  • WEB依存した処理を、A画面とB画面でちょっと再利用したい

そういうときに作ります。

Assistクラスのパッケージ

再利用しないなら、その Assist を使う Action クラスの隣でいいかと思います。

e.g. package of no-recycled assit class @Directory
app
 |-web
 |  |-sea
 |  |  |-SeaAction.java // uses SeaAssist
 |  |  |-SeaAssist.java
 |  |  |-SeaForm.java

再利用するのであれば web.base パッケージに、任意のパッケージを作って再利用すると良いでしょう。

e.g. package of recycled assit class @Directory
app
 |-web
 |  |-base
 |  |  |-maihama
 |  |  |  |-ResortAssist.java
 |  |-sea
 |  |  |-SeaAction.java // uses ResortAssist
 |  |  |-SeaForm.java
 |  |-land
 |  |  |-LandAction.java // uses ResortAssist
 |  |  |-LandForm.java

AssistクラスはWEBに依存してOK

RequestManager や SessionManager や CookieManager などガッツリ使ってOKです。 どちらかというと、そういったWEBに絡んだ処理を再利用するためのものと言えるでしょう。

ただ、あまり ActionResponse の処理を Assist に入れると、Action で何をやっているのかわかりづらくなるので、そこはあくまで Action の役割と捉えて、そのための手続き的な処理を Assist でやるのが良いでしょう。

Assistクラスは画面に依存してOK!?

画面に依存していいいかどうかは、再利用するのかどうか次第です。

再利用しない Assist であれば、べったり呼び出し元の Action に依存させてOKです。 つまり、FormクラスやBodyクラスも参照しちゃってOKです。パッケージ的にも不自然ではありません。 この場合の Assist の役割は、単に Action で書くのがちょっと量が多いので外に出すという感じですね。

再利用する Assist であれば、もちろんとある固定の画面に依存することはできませんが、 例えば Form や Body 自体を再利用しているとかであれば(その場合、Form や Body も base パッケージに)、 その Form や Body を Assist から参照しても良いでしょう。

Assistクラスも普通にDIできる

Actionクラスの中で、普通に DI できます。

また Assistクラスの中で、様々なコンポーネントをDIすることもできます。

WEBに依存したロジックを作らないためにも

どうしても、WEBに依存したまとまった処理を再利用したいと考えることがあります。

一方で、Action はバウンダリ、かつ、ファサード、Logicには再利用するものだけを記述する、という方式を追求すると、 それなりに Action には処理が入り込む可能性があります。

そうしたとき、ちょっと気軽にWEB依存したままのクラスを作ってしまえばいいなと、Assistがないアーキテクチャで開発していたときに思っていました。 でも、アーキテクチャにそういう概念がないと、なかなか誰も新しいクラスを作ろうとはしません。 そのしわ寄せが Logic に行くのです。

チグハグなスーパークラスを作らないためにも

というか、これはチグハグなスーパークラスにさせないためのものでもあります。 LogicをWEB依存させないようにすると、しわ寄せがスーパークラスに来ます。

先の通り、スーパークラスは概念を形づけるためのものです。 ある程度の便利にするためのメソッドが入り込むのは仕方ないとしても、特定の業務的なものは入れるものところではありません。

それなら、ActionAssist を使って、再利用しましょう。

Jobには、JobAssistが

Actionではなく、LastaJob の Job クラスでも、同じように Assist クラスが利用できます。考え方としては基本的に同じです。

No more, ごちゃごちゃスーパークラス

BaseAction にどんどん業務メソッドが追加されていくという現象があります。

e.g. No way, many business methods in super class @Java
public abstract class MaihamaBaseAction extends ... {

    // ★どかどか業務メソッドが追加されていく。。。
}

スーパークラスの役割は、その概念を形づけること

ある程度、便利メソッドの入り口になる役割として使ってしまうこともありますが、それは特定の業務に(できるだけ)依存せず、Actionとしての役割を支援するもの に限ります。あくまで、フレームワークとしての役割を担う場所がスーパークラスです。

ですが、スーパークラスというもの、特に Action のスーパークラスは目の前にあり、手軽に全体に影響させることのできるものです。 ついついサクッとメソッドを追加してしまいがちです。

WEB依存のLogicを作らないとなると、WEB依存の処理を再利用したいとなると、確かにどこに?という感じになるかもしれません。 そこで、ActionAssist です。

それでも、"スーパークラスにあってもいいかなぁ" と思うような処理があったとします。 メソッド追加する前にひとつ質問 そのメソッドを利用する Action って特定できますか? できるのであれば、やっぱりAssistクラスでしょう。

No more, あっちらこっちら参照

インクリメンタルでよく発生する問題は、"A画面を直したら気付いたらB画面が動かなくなっている" というような間接デグレです。スタートアップではある程度そのリスクは覚悟する必要はありますが、安易に起きるのもつらいものです。

そのよくある二つの典型的なパターン、まず一つ目...

再利用できるレイヤに(本来)再利用できないメソッドが定義されている というパターン。 例えば、Logic に実質的潜在的にA画面に依存したメソッドがあると、Logicは再利用メソッドを置く領域なので、B画面がついつい使ってしまうかもしれません。 でも、A画面のディベロッパーは知らずにA画面の都合で修正してしまうでしょう。A画面のActionで書くべき、もしくは、もっと細かい部品単位で再利用するようにすべきでしょう。 これは Logic に何を書くべきか?をさんざんこのページで議論してきましたのでここでは話はおしまい。

ということで、二つ目がポイントです。

A画面専用のクラスっぽい風貌なのに、実はB画面からも使われている というパターン。

例えば、SeaAction に対する SeaForm クラスがあったとして、パッケージも sea の下、なのに、land パッケージの LandAction で SeaForm が使われているようなケース。

e.g. stileto reference @Directory
app
 |-web
 |  |-sea
 |  |  |-SeaAction.java
 |  |  |-SeaForm.java
 |  |-land
 |  |  |-LandAction.java // uses SeaForm

ディベロッパーとしては、直したらIDEの機能を使うなりしてクラスの参照を辿って影響範囲を特定すべきところですが、スタートアップの現場ではなかなかそれを徹底させることは現実的には難しいもので、 いかにも共通っぽいクラスであればまだディベロッパーは気をつける意識が強くなりますが、A画面専用っぽい見た目をしていたらなかなか見逃してしまいます。

もうその場合は、そういう 不意打ちのような再利用 をした方が良くないと言えるでしょう。

ひとつ提案です。以下のようなポリシーでクラス作りをしてみてはいかがでしょう?

  • app.web.[業務固有のパッケージ] から、他の業務固有のパッケージのクラスは参照しない
  • WEB依存のものを再利用するなら app.web.base.[再利用業務パッケージ] に置く
  • app.web.[業務固有のパッケージ] の同じパッケージ(サブ含む)なら参照してもOK
  • もちろん、ビジネスロジックとして再利用できるなら Logic にする
e.g. use base for recycle @Directory
app
 |-web
 |  |-base
 |  |  |-sea // rename if better name exists
 |  |  |  |-SeaForm
 |  |  |-MaihamaBaseAction
 |  |  |-...
 |  |-sea
 |  |  |-SeaAction // uses base.sea.SeaForm
 |  |-land
 |  |  |-LandAction // uses base.sea.SeaForm
 |  |-piari
 |  |  |-dstore
 |  |  |  |-PiariDstoreAction // uses PiariForm
 |  |  |-PiariDetailAction // uses PiariForm
 |  |  |-PiariEditAction // uses PiariForm
 |  |  |-PiariForm // cane be recycled in same package

なかなか厳密にやるのは難しいのですが、できるだけ...

再利用されないクラスと再利用されるクラスを明確にわけることが大切だと考えます。

No more, しっちゃかめっちゃかクラス名

LastaFluteは、わざとクラス名が不自由になっています。 単純に自由のメリットと不自由のメリットとのトレードオフで、後者を最大限享受するという思想を持っています。

例えば、ある画面ではパラメーターの受け取りが Model になっているのに、ある画面では Dto になっていたり。 JSONを戻すクラスが Bean だと思いきや、あっちでは Model になっていたり、同じLogicでもDIするものとnewするものがあったり... (バラけているだけならまだしも、入れ替わったりすることがあると可読性を著しく低下させます)

スタートアップでは、命名のポリシーを決める時間も無ければ浸透させることもなかなか困難であることが多いです。 "フレームワークとしてこうである、こうじゃないと動かない" というのを提供することで、何もポリシー決めのない開発でも "ある程度" 統一性のあるコードになるようにしています。

クラス名の規約は?

こうじゃないともう動かないという強いものです。

...Action
リクエストを受け付けるクラス
...Form
GETやPOSTのパラメーターを受け取るクラス (ActionForm)
...Body
リクエストされるJSONを受け取るクラス (JsonBody)
...Assist
web依存ありでActionの実装を助けるクラス (ActionAssist)
...Logic
web依存せず業務単位で切り出されるクラス (BusinessLogic)
...Bhv
DBFluteのBehaviorクラス (自動生成なので規約というか決まり)

クラス名の慣習は?

規約ではないですが、特にポリシーがなければ LastaFlute のデフォルト慣習としてお奨めしているものです。 スタートアップでは慣習を決めて浸透させること自体が大変なので、"フレームワークの思想の通りでお願いします" と一言言って終わりにできるようにと。LastaFlute の Example もこのようになっています。

...Bean
Thymeleafに渡すデータのクラス、その他の用途でも使うかも (HtmlBean)
...Result
レスポンスで戻すJSONを表現するクラス (JsonResult)
...Part
BodyやResultなどの入れ物クラスの一部データをまとめたインナークラス
...Param
Logicなどで項目が多い場合にまとめる引数クラス (RemoteApiの送信データでも利用)
...Return
Logicなどで項目が多い場合にまとめる戻り値クラス (RemoteApiの受信データでも利用)

これでもまだ網羅できてなくて迷う場面もあるかと思いますが、これらデフォルトの慣習をベースに名前を付けていけば、そこまで大外れしないでしょう。 そういう意味でも、ある程度のベースをフレームワークが提供することに意義があると考えています。

(Json)Result, とてもよかった

(Json)Resultは、実際に実践してみてとても良かったです。"受け取るもの" と "戻すもの" がすぐに判別できるというのは、想像以上にコードを読み解く上でのストレスを無くすものだなと感じました。 ちょっとライブラリのクラスなどとかぶりそうな名前ですが、基本 Action のそばにしかいないので、紛れることもほとんどありません。 (別に 100% ユニークな名前じゃなくてもいいのです)

Part も意外によかった

また、Partも、実際に実践してみて想像以上に良かったです。JSONでのやり取りが多くなると、再利用もしない小さな入れ物クラスが増えます。 それらをつどつど独立した .java ファイルにするのはわりと手間だし、バラバラになるとJSONの全体像もわかりづらくなるので、インナークラスで表現するのがこれまたLastaFluteの慣習です。 そのとき、インナークラスの名前もどうする?という風になりますが、一部のデータということで Part にすることで、クラス検索のときのノイズ対策にもなりますし、一番外側の重要なクラスなのかそうではないのかの判断に付き可読性も上がりました。 ということで、FormでもBodyでもBeanでもResultでもParamでも、一部のデータをまとめるインナークラスは Part にしています。