WebアプリケーションのStruts1.x + aspectJ + iBatisでの実装例です。 SAStrutsの機能リファレンスを参考に書いています。
プロジェクト構成は、SAStrutsのように、下記構成でもよいですが、 (SAStruts構成でよければ、SAStrutsを採用すると高度なURLルーティング等が使用できたかと思います)
・ルートパッケージ.action ・ルートパッケージ.form ・ルートパッケージ.entity ・ルートパッケージ.service
ユースケース単位で作成してもよいです。
・ルートパッケージ.共通.entity(テーブルと1対1のentity) ・ルートパッケージ.共通.dbservice(マスタテーブルのトランザクションサービス) ・ルートパッケージ.共通.dao(マスタテーブルのdao) ・ルートパッケージ.ユースケース1.action ・ルートパッケージ.ユースケース1.dbservice ・ルートパッケージ.ユースケース1.dao ・ルートパッケージ.ユースケース2.action ・ルートパッケージ.ユースケース2.dbservice ・ルートパッケージ.ユースケース2.dao
この構成だと、「ルートパッケージ.共通.jar」と「ルートパッケージ.ユースケース1.jar]があればWebアプリは動作します。 機能変更の影響範囲と、ビルド単位をあわせることができます(共通.jarの影響範囲は全ユースケースですが・・・)
特別なフレームワークの使用は無いため、プロジェクト構成に制約はありません。
Strutsなので、MVC(Model View Controller)のアーキテクチャに基づいていて、 Modelはエンティティ、 ViewはJSP、Controllerはアクションになります。
トランザクションは、xxxxxDbServiceクラスのpublicメソッドの開始と終了時に開始、コミット、ロールバックされます。 マニュアルトランザクションをAspectJで差込ます。
/** トランザクション開始ポイントカット */
pointcut atUpdateable() :
execution(public * *..*DbService.*(..) throws Exception)
&& !within(DaoAspect);
/** トランザクションを開始するアドバイス */
before() throws Exception : atUpdateable() {
// サービスのdaoゲッターを呼び、SqlMapClientを取得する
SqlMapClientWrapper sqlMap = getTargetSqlMapClient(joinPoint);
// トランザクションを開始する
SqlMapClientManager.start(sqlMap, joinPoint);
}
/** 正常系でトランザクションを終了するアドバイス */
after() returning throws Exception : atUpdateable() {
// サービスのdaoゲッターを呼び、SqlMapClientを取得する
SqlMapClientWrapper sqlMap = getTargetSqlMapClient(joinPoint);
// コミット
SqlMapClientManager.commit(sqlMap, joinPoint);
// トランザクションを終了する
SqlMapClientManager.end(sqlMap, joinPoint);
}
/** 読み書き可能且つ異常系でトランザクションを終了するアドバイス */
after() throwing(Exception e) throws Exception : atUpdateable() {
// サービスのdaoゲッターを呼び、SqlMapClientを取得する
SqlMapClientWrapper sqlMap = getTargetSqlMapClient(joinPoint);
// ロールバックログを出力するためにtrueを設定
sqlMap.setRollback(true);
// トランザクションを終了する
SqlMapClientManager.end(sqlMap, joinPoint);
throw e;
}
xxxxxDbServiceクラスのpublicメソッド呼び出しの際、すでにトランザクションが開始されている場合、 そのトランザクションは引き継がれます(同じトランザクション内でSQLが発行されます。) SAStrutsのデフォルト設定と同様に、トランザクション属性はRequiredとなります。 ただし、このAOPでは、トランザクション属性はRequires_newは設定できません。
Requires_newが必要な場合は、別トランザクションで伝票番号採番等を処を行い、その結果を引数に、トランザクションを開始します。 ログ出力等も同じく、別トランザクションで出力します。
public ActionForward execute() throws Exception {
// 開始ログ出力(ログは別トランザクション)
logDbService.writeLog();
// 伝票番号採番(採番後そのトランザクションはコミットする。)
long denpyoNo = denpyoDbService.createDenpyoNo();
// 新規伝票作成(伝票作成、在庫引き当て、出荷手配を行う。)
denpyoDbService.regist(denpyoNo, date);
// 終了ログ出力(ログは別トランザクション)
logDbService.writeLog();
return mapping.findForward("success");
}
同じRDBMSを共有するので、ロストアップデートが発生します。ロストアップデートは楽観ロックで行います。 各テーブルは更新日時(TimeStamp)を持ち、その値が更新されていないことを確認し、ロストアップデートを防ぎます。 すでに更新日時が更新されている場合、ロストアップデートとしてエラーとします。 以下の楽観ロックをサポートするAspectJを提供します。
/** Dao楽観ロック更新、削除実行時のポイントカット */
pointcut atDaoOptimisticLockExecute() :
execution(public * *..*DaoImpl.*WithOptimisticLock(..))
&& !within(DaoAspect);
after() returning(Object o) throws Exception : atDaoOptimisticLockExecute() {
int updRecode = ((Integer)o).intValue();
if (updRecode == 0) { // 更新件数が0件の場合
throw new AplBusinessException("申し訳ありません。ご指定のデータは他の利用者がすでに更新しています。
データ整合性を保つために、検索からやり直してください。");
}
}
サイクルデットロックを防ぐには、ロックを取得するテーブルとレコードの順序をすべての機能で同じにすることです。 更新するbean(関連するbeanを含む)に対し、クラス名の昇順、主キーの昇順でSELECT~WITH UPDLOCKすればよいでしょうか。 このロジックは、IBatorでentityを生成した場合は可能かなと思っています(現在未実装)
サービスとは、トランザクションの制御クラスとしましす。 xxxxxDbServiceクラスは、DBトランザクションを制御します。 xxxxxServiceクラスは、その他BMPを制御します。
xxxxxDbServiceクラスは、コンストラクタ呼び出し時に、使用するDaoをインジェクションします。 フィールドインジェクションではないですが(サクセサは必要)、以下のメンバ定義でインジェクションされます。
/** 特定健康診断データDAOインスタンス */ @DaoInjection private HealthDiagnosDao healthDiagnosDataDao = null;
または、別途ファイルに定義することも可能です。 ファイル定義と@DaoInjectionでは、ファイル定義が優先されます。 単体テスト時などは、ファイル定義でスタブDaoをインジェクションさせることができます。
※ ビジネスロジックはどこに実装する。
ビジネスロジックの実装はPofEAAではentityとなっています。のでentityに実装します。 ところでビジネスロジックとはなんでしょうか?SAStrutsにまとめられていますので、それを踏まえ下記2つと定義します。
導出プロパティは単価計算等。判定プロパティは、区分値を参照した判定ロジック等。 この2つをentityに実装します。この実装方針により、ビジネスロジックは完全に共通化されます(entityは1つなので) また、entityは関連するリレーショナルデータをすべてもっているので、ビジネスロジックメソッドの引数は簡単なものになります。
さて、上記ビジネスロジックだけではイベント処理は成立しません。これらを呼び出しならが、データの操作が必要です。 このデータ操作処理はxxxxxDbServiceクラスに実装します。xxxxxDbServiceクラスはイベントに依存する可能性があるので、 処理の共通化が難しいかもしれません。共通化が難しいところは、実装が簡単な方法としましょう。
上記のような実装をすれば、次のテスト方法でC2に近い網羅率となり、品質が確保されると思います。
xxxxxDbServiceクラスがC1で良い実装であれば、多少共通化されていなくても、テスト工数が爆発することはありません。
例外はStrutsの機能でキャッチします。
<global-exceptions>
<!-- 回復可能例外 -->
<exception key=""
path=""
handler="exp.common.web.exception.AplExceptionHandler"
type="exp.common.exception.AbsRecoveryException">
</exception>
<!-- システム例外 -->
<exception key=""
path="/jsp/common/error.jsp"
handler="exp.common.web.exception.AplExceptionHandler"
type="java.lang.Exception">
</exception>
</global-exceptions>
回復可能例外は、エラーを設定し遷移元画面に遷移します(戻ります)
// 例外ハンドリングを行う
if (ex instanceof AbsRecoveryException) {
errors.add(ActionMessages.GLOBAL_MESSAGE, new ActionMessage(ex.getMessage(), false));
forward = mapping.getInputForward();// 戻り先はinput属性の指定先
}
※複数行エラーに対応すると、便利かと思います。
システムエラーは、ログ出力を行いシステムエラー画面に遷移します。 その際のログ内容は自由に設定できます。エラー発生時のForm、Session、パラメータ等。
通常のStruts1.xのアクションクラスです。 ただ、やはりアクションの開始、終了には共通的な処理を差し込みたいものです。
アクションの開始、終了は、AspectJで処理を差込ます。
/** ececuteポイントカット */
pointcut atExecute() :
execution(public ActionForward *..*Action.execute(..) throws Exception)
&& !within(*..test.action.*) // テスト用Actionクラス
&& !within(ActionAspect);
/** ececuteを実行するbeforeアドバイス */
before() throws Exception : atExecute() {
HttpServletRequest request = (HttpServletRequest)((thisJoinPoint.getArgs())[2]);
HttpSession session = request.getSession();
String userId = AssertionUtil.getUserId(session);
// アサーションが妥当かチェックする。
CheackAssertion.check(session);
// プロセス毎のコンテキストを設定する。
ContextUtil.setUserId(userId); // ユーザーID
// log4j診断コンテキストの設定
NDC.push(userId);
// 操作情報ログを出力する。
ActionMapping mapping = (ActionMapping)((thisJoinPoint.getArgs())[0]);
ActionForm actionform = (ActionForm)((thisJoinPoint.getArgs())[1]);
ActionLogger.write(userId, mapping, actionform);
}
/** ececuteを実行するafterアドバイス */
after() throws Exception : atExecute() {
// log4j診断コンテキストの削除
NDC.remove();
}
Struts2.xのだと簡単に実装できるかと思いますが、 Struts1.x + AspectJでも十分対応できるのではと思います。
entity(Model)とform(View)のデータは異なります。 formのデータはすべてString型で、entityはLongだったり、Dateだったり。 formの日付は、西暦だったり、和暦だったり。数値の表示は3桁,区切りだったり。 entityはIDを管理しますが、formは名称のケースがほとんどです。 entityとformのデータ変換を行わないといけません。 このデータ変換は、各form単位に作成しなくてはなりません。
ただし、このデータ変換の際にDBアクセスが発生してはいけません。すでにトランザクションは終了しています。 (トランザクション範囲を広くすればよいという問題ではないと思います。トランザクションは設計されたものです。)
iBatisを使用すれば、entityは関連するデータをすべて取得する(O/Rマッピング)ので、 formへ設定する情報はすべてそろっていると思います。IDに関連する名称等。 後は、Stringへの変換を1とつ1とつ実装するとよいと思います(変換ロジックは、簡単に変換Util化できると思います) 各formには、toFrom(entity), toEntiry()メソッドがあれば、ソースの可読性は確保されるかと思います。
※リストボックスの値は? entityは関連があるのではれば、リレーショナルをたどって取得可能かと思います。 関連性がないなら別entityで取得し、toFrom(entity, entity,・・・)のような実装が可能かと思います。 entityは一つでなくてもよいですよね。
相変わらずHTMLとロジックの分離はできませんがJSPを使用します。HTMLモック作成はデザイナーが、JSPへの変換はプログラマが行います。 JSPへの変換後、複雑なjavascriptを記載します。javascriptをどれだけ書けるかが、高機能なWebページ作成の肝だと思います。 高機能なWebページ作成するには、プログラマーがページ全体を設計しなければいけません・・
taglibは「JSTL」を使います。JSTLであればJSPへの変換は簡単です。 あと、テーブル表示にはstruts-layoutを使用します。 layout:row でページング、ソートが簡単に実装できます。ページングするデータはセッションに保存されます。 layout:rowはJSTLと相性がよく下記のような実装が可能です。
例)実装例 1ページ25行、ドキュメントID列はソート可能、hrefを含む、 name="document[<c:out value="${index}" />]のhidden項目については、Struts側でBeanのListとして復元される(インラインエディットが可能)
<layout:row styleClass="downloadListTable">
<layout:pager maxPageItems="25">
<layout:collection name="documentList" id="document" indexId="index" styleClass="dataGrid" styleClass2="dataGrid2">
<layout:collectionItem title="ドキュメントID" style="width:110px;text-align:center;" sortable="true">
<c:if test="${document.deleteFlg}" >
<c:out value="${document.did}"/>
</c:if>
<c:if test="${!document.deleteFlg}" >
<a href="javascript:doDownload(<c:out value="${invalue="${index}" />;" class="link"><c:out value="${document.did}"/></a>
</c:if>
<input type="hidden" name="document[<c:out value="${index}" />].docuocument.documentId}" />" />
<input type="hidden" name="document[<c:out value="${index}" />].updTment.updTime}" />" />
<input type="hidden" name="document[<c:out value="${index}" />].datet.date}" />" />
<input type="hidden" name="document[<c:out value="${index}" />].hosp{document.hospitalName}" />" />
<input type="hidden" name="document[<c:out value="${index}" />].did".did}" />" />
<input type="hidden" name="document[<c:out value="${index}" />].down{document.downloadDate}" />" />
<input type="hidden" name="document[<c:out value="${index}" />].delecument.deleteFlg}" />" />
<input type="hidden" name="document[<c:out value="${index}" />].expiocument.expiryDate}" />" />
<input type="hidden" name="document[<c:out value="${index}" />].etc".etc}" />" />
</layout:collectionItem>
<layout:collectionItem style="width:110px;text-align:center;" title="検診日" property="date"/>
<layout:collectionItem style="width:300px;text-align:center;" title="病院" property="hospitalName"/>
<layout:collectionItem style="width:110px;text-align:center;" title="ダウンロード期限" property="expiryDate"/>
<layout:collectionItem style="width:210px;text-align:center;" title="" property="etc"/>
</layout:collection>
</layout:pager>
</layout:row>
ページング、ソートを独自実装するには、下記SortActionを拡張すればよいかと思います。
<action path="/sort"
type="fr.improve.struts.taglib.layout.sort.SortAction"
scope="request"
validate="false">
</action>
パーシステンス層にはiBatisを使用します。 lazy loadを使用したORマッピングも可能なようですが、select joinでも実装可能です。 lazy loadはトランザクション外でselectが発生する可能性があるのでselect joinで実装します。Lazy Loading vs. Joinsも参考ください。 下記はユーザーIDに関連するグループ情報を取得する定義です。
<!-- ユーザマスタ -->
<resultMap id="userResult" class="User" groupBy = "userId"> // ① userIdでDISTINCT
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="user_kind" jdbcType="CHAR" property="userKind" />
<result column="user_login_id" jdbcType="VARCHAR" property="userLoginId" />
<result column="user_sex_code" jdbcType="CHAR" property="userSexCode" />
<result column="user_birthday" jdbcType="DATE" property="userBirthday" />
<result column="user_delete_flg" jdbcType="BIT" property="userDeleteFlg" />
<result property="userGroupList" resultMap="user.userGroupResult"/> // ② 1対nマッピング
<result property="openRuleList" resultMap="user.openRuleResult"/> // ③ 1対nマッピング
</resultMap>
<!-- ユーザグループ -->
<resultMap id="userGroupResult" class="UserGroup" groupBy = "userGroupId"> // ④ userGroupIdでDISTINCT
<result column="user_group_id" property="userGroupId" jdbcType="BIGINT" />
<result column="user_id" property="userId" jdbcType="BIGINT" />
<result column="group_id" property="groupId" jdbcType="BIGINT" />
<result property="userGroupAttributeList" resultMap="user.userGroupAttributeResult"/> // ⑤ 1対nマッピング
<result property="group" resultMap="user.groupResult"/> // ⑥ n対1マッピング
</resultMap>
<!-- ユーザグループ属性 -->
<resultMap id="userGroupAttributeResult" class="UserGroupAttribute" >
<result column="user_group_attribute_id" property="userGroupAttributeId" jdbcType="BIGINT" />
<result column="user_group_id" property="userGroupId" jdbcType="BIGINT" />
<result column="user_group_attribute_attribute" property="userGroupAttributeAttribute" jdbcType="VARCHAR" />
</resultMap>
<!-- グループマスタ -->
<resultMap id="groupResult" class="Group">
<result column="group_id" property="groupId" jdbcType="BIGINT" />
<result column="group_name" property="groupName" jdbcType="VARCHAR" />
<result column="group_nickname" property="groupNickname" jdbcType="VARCHAR" />
</resultMap>
<!-- ユーザデータを取得するSQL -->
<select id="select_by_condition" parameterClass="UserCondition" resultMap="userResult">
SELECT
*
FROM
m_user usr // ⑦ 取得するテーブルをすべてjoinする
LEFT JOIN
m_user_group userGroup on usr.user_id = userGroup.user_id
LEFT JOIN
m_user_group_attribute attribute on userGroup.user_group_id = attribute.user_group_id
LEFT JOIN
m_group grp on userGroup.group_id = grp.group_id
WHERE
usr.delete_flg = false
<isNotEmpty property="userId" prepend="AND"> // ⑧ 可変条件
usr.user_id = #userId#
</isNotEmpty>
<isNotEmpty property="groupId" prepend="AND"> // ⑨ 可変条件
grp.group_id = #groupId#
</isNotEmpty>
ORDER BY
$oderBy$ // ⑩ ソート条件
</select>
userResultを記載するのは大変です。ので、IBatorでテーブルより自動生成します。 ②③⑤⑥の定義は自動生成してくれません(できるかもしれません?)ので、手動で追加します。 自動生成の際にentityも生成してくれます。②③⑤⑥に対応するアクセサは自動生成してくれませんので、手動で追加します。 自動生成クラスを継承する形でentityクラスを作成し、その中で②③⑤⑥を実装すればよいかと思います。 自動生成クラスを継承したentityクラスには、ビジネスロジックも実装します。
iBatisの定義を作成するためIBatorを使用します。 次のファイルが自動生成されます。
・SqlMap →"resultMap","insert文","update文"が利用可能です。 ・entity →entityとして利用可能です。 ・dao →利用しません。
Strutsのvalidation.xmlを利用するのは生産性が悪いと思います。validation.xmlの一番のうりであるjavascript検証コードの自動生成も すべてのバリデータに対して行われるわけではありません(一番使用したいvalidwhenが自動生成されないのが残念です) javascript検証コードは別途作成すると割り切って、ValidatorForm#validateをオーバーライドするほうがよいと思います。
Strutsはマルチパートに対応しているので、特別な実装はありません。 formにbyte[]のアクセサを用意すれば、アップロードされたファイルが取得できます。
Ajaxも通常のhttp通信のため、特別な実装はありません。 画面遷移はしませんので、ActionForward=nullを返します。 値を返す場合は、PrintWriterで値を返します。 (PrintWriterはちょっと特別な実装なので、ライブラリ化したほうがいいでしょう。)
ログの出力は、AspectJを利用すれば、actionの開始、終了時、Daoの開始、終了等に設定できます。また、それらの入力情報も出力できるかと思います。 しかし、入力情報も出力はセキュリティ上問題です。ログファイルに機微なデータが記録されてしまいます。「ログファイルは参照禁止」となると本末転倒ですね。 例えば、「いつ、だれが、何データ」を参照したかをロギングするこは、ログの監査として有効かもしれません。
Daoの親クラスを作成し、selectを発行する(queryForList)ラッパーメソッドを作成し、 「ユーザ識別子」、「発行するselect文のID」(SQLをログに出力することもNGだと思います)、「検索条件」をログ出力すればよいと思います。 「ユーザ識別子」はlog4jのNDCを使用します。 「検索条件」に機微なデータが指定されたら? ラッパーメソッドの機能に「検索条件」を出力制御フラグを用意すればよいでしょう。
もう面倒なので、ログ出力を暗号化しては? ログのリアルタイム監視ができなくなります・・・
ログの設計は重要ですね。