ネストしたプロパティで入力チェック
アノテーションによるネストした子Dtoのバリデーションは、複雑なことは対応しないという方針に基づいて、やってくれないのが仕様です。
2008-04-17 - 出羽ブログ
自分でカスタマイズするなら ActionCustomizer を継承し、setupValidator メソッドに少しコードを追加するすることで対応可能です。プロパティを getClass() して、その定義クラスを getFileds() し、getAnnotations() した後、プロパティ名 + "." + ネストプロパティ名をバリデーション対象として登録みたいな感じだったと思います。
追記:コードイメージ
SQL 発行
次のようなインターフェースを持つ Sql クラスを作成し、複雑な SQL に対応しました。このクラスは SQL 文字列とパラメータのリストを持つ単純なクラスです。メソッドは append しかありません。
- public class Sql
上記のような StringBuilder ライクな単純なインターフェースで下記を実現できました。コンストラクタで条件を AND でつなげるか、OR でつなげるか設定し、AND と OR 複合の場合は Sql オブジェクト自体をネストして append します。第 2 引数以降が指定されている場合のみ AND や OR で連結されます。
- S2JDBC の流れるインターフェースに近い少ないコード量
- like エスケープなども勝手に対応 (エスケープ文字は決めうち)
- 値が null の場合は条件無視
- in の場合は自動で (?,?,,) のように展開
- SQL 文の簡易構文チェック機能つき
- 副問い合わせや UNION など、どんな SQL でも対応可能
こんな感じ。Dao は JdbcManagerImple を継承したクラス。
Sql sql = new Sql("1".equals(fooDto.andOr)) .append("select * from TABLE1 where") .append("col1 = ?", "a") .append("col2 in ?", "a", "b") .append("col3 like ?||'%'", "a"); if (dao.getCountBySql(sql) == 0) { return; } List<BeanMap> list = dao.selectBySql(BeanMap.class, sql) .offset(20) .limmit(10) .getResultList();
今回のプロジェクトでは検索画面の検索項目が多く、かつ AND、OR が切り替え可能なため、このような措置を取りました。検索画面以外では S2JDBC デフォルトの SQL 自動生成を使用します。selectBySql の場合、結果を格納するクラスは最初は型安全にするために、エンティティを継承したクラスを、SQL ごとに作成することを考えていましたが、結局、ほとんどがフォームにコピーするだけなので、S2JDBC の BeanMap を使う方針にしました。
アクションの粒度
アクションは機能単位で作成し、フォーム (Dto) も必ず対で作成。のためか思ったより肥大化せず。フォームはセッションスコープ固定で、メニューなどでセッションから削除。各業務画面では完了画面への遷移時にエンティティなどの大きなオブジェクトのみ削除。index メソッドではフィールドをすべて null 化するユーティリティメソッドで対応。あと、ロジックもアクション単位で空でも 1 つ必ず作成します。
アクションからロジックに切り出す指標は何かというと、メソッド分割したいならロジックに書きます。つまりアクションには @Execute が付くメソッドのみとし、private メソッドは書かないようします。
エンティティの自動生成
今回は Object Browser ER の備考欄だったかにマッピングアノテーションを書いておき、定義書を生成して、そこから Excel マクロでエンティティを自動生成しました。DB への CREATE も Object Browser ER でフォワードエンジニアリングで楽々でした。自動生成マクロを書いたメンバは OneToMany 時の List フィールド自動生成に結構苦労したみたいですが、逆に自動生成が無いと、きついと思います。あと、S2JDBC に都合の良いように DB を自由に定義できない立場の場合も多少大変かもしれません。今回、DB 定義は S2JDBC 用に完全に最適化しました。ちなみに Oracle ですが、DATE や TIMESTAMP 型は使用禁止にし、VARCHAR2 と NUMBER のみにしました :)
JSP2.0 ファンクション
今回のプロジェクトでは 2 種類程度のフォーマットしか使用しない (ほとんどのプロジェクトでそうだと思いますが) ため、フォーマット決めうちの x:date や x:datetime を用意しました。
拡張性
Struts もそうですが、SAStruts と S2JDBC もかなり高い拡張性を持っています。汎用的に出来ている分、シンプルとはいえ、プロジェクトや開発メンバスキルにより切り捨てたほうが良い機能もあります。必要な機能のみを周知し、足りない機能は追加する。というのは当たり前ですが、これが、やりやすいです。このあたりは脱 CoC のおかげですね。DI 自体が脱 CoC と矛盾しますが、SAStruts と S2JDBC は各業務開発メンバが DI を設定する必要はありません。DI を開発メンバに意識させるのは無駄だ、裏方の仕組みを支えるだけでいい、というのは DI が出た当初から言ってましたが、良い感じです。
あと、やはり開発ルールは単純かつ明快であるべき気がします。開発メンバからは、簡単で楽でいい! 1 機能が 1 時間で出来ました!(たぶん出来てはない) ってみんな喜んでくれてました。良かったです。でも、問題が出るのはたぶんこれからです。
フォームとアクションの標準構造
上の「アクションの粒度」で書きましたが、アクションと同じ粒度、つまり 1 機能に対し 1 フォームを作成します。一覧の明細はインナークラスにします。こんな感じ。
追記:下記から transient 指定は削除しました。コメント参照。
@Component(instance = InstanceType.SESSION) public class HogeDto implements Serializable { // 検索項目系 public String searchHogeCd; public String searchHogeNm; // 登録更新項目系 public String id; @Required(target = ...) public String hogeCd; @MaxBytesLength(target = ..., length = ...) public String hogeNm; // DB 更新用に SELECT 時に退避したエンティティのインスタンス public THoge tHoge; // 検索結果一覧明細表示用の行リスト public List<DetailDto> detailDtoList; // 検索結果一覧明細表示用の行クラス public static class DetailDto implements Serializable { public String id; public String hogeCd; public String hogeNm; } }
上で「ネストしたプロパティの入力チェック」を有効にする方法を書きましたが、結局それは使いません。JSP の name 属性に . がたくさんあるのは開発メンバに受けがよくないようです:)。tHoge は一覧から詳細画面に遷移したときに、SELECT したエンティティを退避しておき、更新時や論理削除時に入力値を上書きコピーします。こんな感じ。論理削除には S2JDBC のカラム指定更新が使えますが、今回はそのあたりは開発基準としては使用禁止にしました。
public class HogeAction { @ActionForm public HogeDto hogeDto; public HogeLogic hogeLogic; public Dao dao; // 初期表示 @Execute(validate = false) public void index() { // フォームのプロパティをすべて null に。 Objects.clear(hogeDto); return "list.jsp"; } // 検索一覧表示 @Execute(input = "list.jsp") public void search() { Sql sql = new Sql(hogeDto.isAndOr) .append("select * from T_HOGE where") .append("HOGE_CD = ?", hogeDto.searchHogeCd) .append("HOGE_NM like ?||'%'", hogeDto.searchHogeNm); long count = dao.getCountBySql(sql); if (count > 0) { hogeDto.detailDtoList = new LinkedList<DetailDto>(); List<BeanMap> list = dao.selectBySql(BeanMap.class, sql); for (BeanMap map : list) { DetailDto detailDto = Beans.createAndCopy(DetailDto.class, map).execute(); // 必要に応じて画面表示用に編集 ... hogeDto.detailDtoList.add(detailDto); } } return "list.jsp"; } // 詳細画面表示 @Execute(input = "list.jsp") public void detail() { // セッション節約のためクリア hogeDto.detailDtoList = null; // DB から 1 件取得 THoge tHoge = dao.from(THoge.class).id(hogeDto.id).getSingleResult(); // 更新用に退避 hogeDto.tHoge = tHoge; // 画面表示用にコピー Beans.copy(tHoge, hogeDto).execute(); // 必要に応じて画面表示用に編集 ... return "input.jsp"; } // 登録 @Execute(input = "input.jsp") public void regist() { Beans.copy(hogeDto, hogeDto.tHoge).execute(); // 必要に応じて登録用に編集 ... dao.insertExecute(hogeDto.tHoge); // セッション節約のためクリア hogeDto.tHoge = null; return "successRegist.jsp"; } // 更新 @Execute(input = "input.jsp") public void update() { Beans.copy(hogeDto, hogeDto.tHoge).execute(); // 必要に応じて更新用に編集 ... dao.updateExecute(hogeDto.tHoge); // セッション節約のためクリア hogeDto.tHoge = null; return "successUpdate.jsp"; } // 削除 @Execute(input = "input.jsp") public void delete() { hogeDto.tHoge.delFlg = Flg.ON; dao.updateExecute(hogeDto.tHoge); // セッション節約のためクリア hogeDto.tHoge = null; return "successDelete.jsp"; } }
ついでに Dao はこんな。システム共通。AbstractEntity はエンティティの共通項目を定義した共通親クラス。Dao で ActionMessagesException なんかスローすると、エラい人に指摘されそうですけどね。
public class Dao extends JdbcManagerImpl { // SELECT 作成 public <T> SqlSelect<T> selectBySql(Class<T> baseClass, Sql sql) { return super.selectBySql(baseClass, sql.getString(), sql.getParams()); } // SELECT で件数取得 public long getCountBySql(Sql sql) { return super.getCountBySql(sql.getString(), sql.getParams()); } // INSERT public int insertExecute(AbstractEntity entity) { // 共通項目をセット entity.insTime = HogeThread.transactionStartTime; entity.insUserId = HogeThread.userId; ... try { return super.insert(entity).execute(); } catch (EntityExistsException e) { // ユニーク制約は必要に応じて事前に呼び出し側で // チェックするのでシステムエラーに throw new ActionMessagesException(Message.ERRORS_SYSTEM); } } // UPDATE public int updateExecute(AbstractEntity entity) { // 共通項目をセット entity.updTime = HogeThread.transactionStartTime; entity.updUserId = HogeThread.userId; ... try { return super.update(entity).execute(); } catch (OptimisticLockException e) { // 楽観的ロックされているか、存在しない場合 throw new ActionMessagesException(Message.ERRORS_MODIFIED); } } }
JavaBeans プロパティ命名ルール
JavaBeans 仕様では先頭の連続する 2 文字が大文字だと先頭が小文字になりません。Struts でハマった方も多いのではないでしょうか? S2JDBC でも厳密にこれに沿っているため、DB のテーブル名が T_HOGE とかだと、S2JDBC デフォルトだとテーブルの外部キーが テーブル名 + "id" となり、プロパティ名が THogeId となります。tHogeId になって欲しいので、PersistenceConventionImpl を継承し、fromColumnNameToPropertyName メソッドを下記ような感じに書き換えました。使っているのは Seasar の StringUtil ではなく Commons StringUtils です。なんか他のメソッドも書き換えないとだめな気もしますが、とりあえず動いてる気がします。
//return StringUtil.decapitalize(StringUtil.camelize(columnName)); return StringUtils.uncapitalize(StringUtil.decapitalize(StringUtil.camelize(columnName)));
セッション管理
上でも書いてますが、フォームは InstanceType.SESSION 固定です。画面入出力情報だけでなくセッションに持ちたい情報はすべてフォームに持ち、ユーザに関する情報はすべて User オブジェクトにもち、アクションやロジックにインジェクションされます。HttpSession は一切使用しません。session.removeAttribute とかしたくないですからね。
つまり、ルール的にはフォームは全部セッションにあるから、いつでも使えるよ、でも設計時も実装時もでかいプロパティはちゃんと null にするか、1 画面だけで使うなら transient にすることを意識してね。のような感じです。
追記:transient はだめでした。コメント参照。
SAStruts を選んだ 5 つの理由 - アーキテクチャ
これは私がその都度、俺々フレームワークでやってきた理想形でした。もちろん、すべての問題領域でカバーできるとは思いませんが、私が今まで携わったシステムでは適用しても問題にならないと思います。その昔、エラい人たちが語ってきたものとすべて真逆ですが、そういう方はやっぱり SAStruts は向かないと思います。
トランザクション境界が広い
その昔 Session-in-view パターンというのがありました。でも、トランザクション境界は狭ければ狭いほど良い、とかビュー層でトランザクション開始とかバカじゃね? サービス層だろ? というのもよく見かけました。いや狭かったらめんどくさいでしょ。というか広いと問題になることを実際に検証せずに言っている人も多かったと思いますし、そういうとこだけ何とかすればいいと思います。各開発者がトランザクション境界や属性を決めるって無駄すぎです。
責務に応じたレイヤー分けがない
何か詰めなおして、次のレイヤー呼び出すだけっていうのを良くみかけました。本当は疎結合で再利用可能になるのが理想的ですが、これはレイヤー分けを主張する人の夢物語でしかありませんでした。現実はほとんどコピペして名前変えるだけのクラスが大量生産され、設定ファイル地獄に陥っているところがほとんどではないでしょうか?
public フィールド
これは、ちょっと私がいやらしいかもしれないのですが、今まで開発者に対して質問してきました。なぜ setter/getter を作るの?って。回答はカプセル化しないとだめだから、とか。突っ込んでいくと最終的に、そう習ったとか、みんなそう言ってるもん、とか。いえ、利点はありますが、少なくとも DTO に関しては public フィールドの長所がカプセル化の長所をはるかに凌駕します。実際、自分でフレームワークを作るとライブラリやツールのサポートの問題にぶつかるのですが、今回は SAStruts と Beans がそれをやってくれています。
ビュー
誤解をおそれずに言えば、コンポーネントツリーとかいう .net のコントロールツリーの劣化版や Swing ライクなものもありますが、これは開発メンバ全員がこういうの大好きっていう場合は良いと思います。SAStruts は JSTL や Struts タグを除くと s:form とファンクションちょこっとっていう潔さが良いです。HTML ベースのものもいいですが、それはそれで結構コード量が増えますしね。
Struts 1.2.9 ベース
これは 1.3 や 2.0 ではないところです。1.2.9 は完全に枯れていて、1.3 や 2.0 は中身は別もので、枯れていません。同じ名前を名乗っていいの?というぐらいです。
SAStruts 採用説得パターン
今回のシステムは色々な会社が開発に参画しているのですが、END のお客様に説明した時にちょっと変わった反応が。あとで分かったのですが、他の人が Struts なので開発者はすぐ集まりますとか周知していたらしいです。当事者の方も見ているかもしれませんが、下記の流れでした。私の対応もかなりおかしいですが、これはおそらく、Seasar のひがさんの狙いどおりだと思います。
- お客様:え? Struts じゃないんですか? Seasar って書いてますけど。。。
- 私:え? (なんか雰囲気が変だ) えーと、Struts ですよ。
- お客様:私は Seasar か Struts かという話を聞いていて、開発者が集めやすい Struts に決定したと聞きました。
- 私:あ、そういうことですね。使うのは Struts です。で、Seasar ですが、Seasar と言っても非常にたくさんのフレームワークがあります。ここでは一旦 Seasar は忘れてください。
- お客様:はい?
- 私:Seasar に SAStruts という Struts の非常に薄いラッパーがあります。おっしゃるとおり、Struts は枯れているので開発者も集めやすいです。かつ SAStruts には生産性が高くなるように便利な機能が搭載されています。なので今回のフレームワークは Struts であり SAStruts でもあります。
- お客様:あ、そういうことですか。良かったです。安心しました。
- 私:はい。大丈夫ですよ。何も心配いりません。
・・・そうです。これは Struts です。名前に含まれてますから。SAStruts。でも最後の私の返答は、ハードルを上げてしまったことを多少後悔。