同じ機能なのに動作が違う?2つのプロジェクト間の実装差異を解消した全記録
案件の内容
既存の類似プロジェクトからのコピーで開発していたプロジェクトに対し、ある日こんな指示が来ました:
「元プロジェクトのコードに合わせて実装を統一してください」
コードを比較してみると…ValidationHelper、ValidationUtil、Controller、そしてバリデーションルールの実装に大きな差異が存在していました。さらに調査を進めると、「適格事業者登録番号の重複チェックが動作しない」という致命的な問題も発覚。
発覚した問題
-
適格事業者登録番号の重複チェックが動作しない
- 新規登録時に既存データとの重複をチェックするはずが、何もチェックされていない
- エラーも出ない、保存は通る
-
ValidationHelperのメソッド実装が異なる
- 存在チェックメソッドが3つ不足
- 重複チェックのロジックが微妙に違う(key0 vs supCd)
-
ValidationUtilの正規表現パターンが異なる
- 全角チェックの基準が緩い
- 口座名義チェックの制約が不十分
-
権限別バリデーションルールが未実装
- 単一ルールのみで権限による制御がない
- フロントエンドからmodeパラメータを送信していない
真相:なぜバリデーションが動かなかったのか?
// フロントエンド(Vue.js)
// 問題点:modeパラメータを送信していない
const params = this.cloneData(this.page);
// params.mode = ??? (未設定)
// バックエンド(Controller)
this.dataValidator.validate(data,
new String[] {"supplier." + data.getMode(), "supplier.max"});
// → data.getMode() が null または undefined
// → "supplier.undefined" を探す
// → データベースに存在しない!
// → バリデーター「ルールがないのでスキップします」
結果: バリデーションメソッドは呼ばれているが、存在しないルール名のため何もチェックされずにスキップされていた。
まず理解しよう:各ファイルの役割
差異を理解する前に、バリデーション機能を構成する各ファイルの役割を理解しましょう。レストランで例えると分かりやすいです。
Controller(コントローラー)
役割: お客さんからの注文を受け付ける「接客係」
// 例:保存処理のController
@RequestMapping(path = "", method = RequestMethod.POST)
public Result<SupplierModel> save(@RequestBody SupplierModel data) {
// ① お客さん(フロントエンド)から注文(データ)を受け取る
// ② チェック担当(Validator)に「これ確認して!」と依頼
this.dataValidator.validate(data,
new String[] {"supplier." + data.getMode(), "supplier.max"});
// ③ 問題なければ、キッチン(Service)に注文を渡す
return service.save(data);
}
何をするファイル?
- HTTPリクエストを受け付ける
- バリデーターに検証を依頼する
- 結果をフロントエンドに返す
何を入れる?
-
@RequestMappingでパスを定義 - データの受け渡し処理
-
validate()の呼び出し
何を入れない?
- 複雑なビジネスロジック(Serviceに任せる)
- 細かい検証ロジック(Helper/Utilに任せる)
ValidationHelper(バリデーションヘルパー)
役割: お店専用の特別なルールをチェックする「このお店専用のチェック係」
// 例:コードの重複チェック
public String checkSupplierCdExistence() throws Exception {
// このお店(プロジェクト)専用のルール:
// 「同じコードの商品を2つ登録してはいけない」
// データベースで既存のコードを検索
CoMSupplierEntity entity = new CoMSupplierEntity();
entity.setSupCd(CommonUtil.toLong(this.value));
if (CommonUtil.isNotEmptyList(dao.select(entity))) {
return "重複しています。"; // エラー
}
return null; // OK
}
何をするファイル?
- プロジェクト固有の検証ルール
- データベースアクセスあり(重複チェックなど)
- このプロジェクト専用のビジネスルール
何を入れる?
- 重複チェック(DBに問い合わせが必要)
- プロジェクト特有のルール
- 他のテーブルとの整合性チェック
何を入れない?
- どこでも使える汎用的なチェック(Utilに任せる)
レストラン例:
- 「このお店では、○○を注文したら△△も必須」
- 「このメニューは既に売り切れかどうかチェック(在庫確認)」
ValidationUtil(バリデーションユーティル)
役割: どこでも使える一般的なルールをチェックする「全国共通のチェック係」
// 例:全角文字のみかチェック
public static boolean isZenkaku(String str) {
// これは「このお店」だけじゃなく、
// どんなプロジェクトでも使える一般的なチェック
String regex = "^[^ -~。-゚]*$";
return str != null && str.matches(regex);
// スペース、ASCII、半角カタカナを除外
}
// 例:メールアドレスの形式チェック
public static boolean isEmail(String str) {
// これもどこでも使える一般的なルール
String regex = EMAIL_PATTERN;
return str != null && str.matches(regex);
}
何をするファイル?
- どこでも使える汎用的な検証ルール
- データベースアクセスなし(文字列や数値の処理のみ)
- 他のプロジェクトでもそのまま使える
何を入れる?
- 文字種チェック(全角、半角カタカナなど)
- 形式チェック(メール、電話番号など)
- 数値の範囲チェック
- 正規表現を使った汎用的なパターンマッチ
何を入れない?
- データベースアクセス(Helperに任せる)
- プロジェクト固有のルール(Helperに任せる)
レストラン例:
- 「電話番号は数字とハイフンだけ」(どのレストランでも同じ)
- 「メールアドレスには@が必要」(どのレストランでも同じ)
比較表:Helper vs Util
| 項目 | ValidationHelper | ValidationUtil |
|---|---|---|
| 対象 | このプロジェクト専用 | どこでも使える汎用性 |
| DBアクセス | あり | なし |
| 例 | 「コード123は既に登録済み?」 | 「この文字列は全角のみ?」 |
| 再利用性 | プロジェクト内のみ | 他プロジェクトでも使える |
| メソッド形式 | public String checkXxx() |
public static boolean isXxx() |
| 返り値 | エラーメッセージ or null | true or false |
重要な違い:なぜ分ける必要があるの?
理由1:再利用性
- Utilは「どこでも使える便利な道具」として他のプロジェクトにコピーできる
- Helperは「このプロジェクト専用の道具」なので他では使えない
理由2:テストのしやすさ
- Utilはデータベース不要でテストできる(速い)
- Helperはデータベースが必要(遅いけど実際のデータで確認)
理由3:責任の明確化
// これはNG:Utilでデータベースアクセス
public static boolean checkExistence(...) {
dao.select(...); // Utilではデータベース禁止
}
// これが正しい:Helperでデータベースアクセス
public String checkSupplierCdExistence() throws Exception {
dao.select(...); // Helperなのでデータベース使えます
return list.isEmpty() ? null : "重複しています。";
}
プロジェクト間の実装差異とは?
同じフレームワーク、同じ機能を持つ2つのプロジェクトでも、実装方法が異なることがあります。
なぜ差異が生まれるのか?
原因1:コピー後の独自改修
- プロジェクトAからコピーしてプロジェクトBを作成
- その後、プロジェクトAは改善され続ける
- プロジェクトBには反映されない
- 時間が経つほど差異が拡大
原因2:異なる要件への対応
- プロジェクトBで独自の要件が発生
- 独自の実装方法で対応
- プロジェクトAとは異なるパターンになる
原因3:開発者の違い
- 異なる開発者が異なるアプローチで実装
- コードレビューでも気づかれない微妙な差異
今回発見された主な差異
| 項目 | プロジェクトA(元) | プロジェクトB(対象) | 影響 |
|---|---|---|---|
| ValidationHelper | 6つのメソッド | 3つのメソッド(不足) | 一部の重複チェックが動かない |
| checkTaxPayerNo実装 | supCd使用、ゼロパディングあり | key0使用、ゼロパディングなし | 重複チェックが正しく動かない |
| checkSupplierCd実装 | シンプルな存在チェック | key0除外処理あり(二重制御) | 新規登録で重複チェック失敗 |
| ValidationUtil | 厳密な正規表現 | 緩い正規表現 | 不正な文字が通る |
| バリデーションルール | 権限別(editor/manager) | 単一ルール(validation) | 権限による制御ができない |
| フロントエンドmode | this.viewer送信 | mode未送信 | ルール名が解決できない |
| エラーメッセージ | 「重複しています。」 | 「登録済みの○○です。」 | 表現の統一性がない |
差異の詳細:何が違っていたのか?
1. checkSupplierCdExistence(支払先コード存在チェック)の差異
プロジェクトA(元・正しい実装)
public String checkSupCdExistence() throws Exception {
if (StringUtils.isBlank(CommonUtil.toString(this.value))) {
return null;
}
CoMSupplierEntity supEntity = new CoMSupplierEntity();
supEntity.setSupCd(CommonUtil.toLong(this.value));
if (CommonUtil.isNotEmptyList(dao.select(supEntity))) {
return "重複しています。";
}
return null;
}
特徴:
- シンプルな存在チェックのみ
- SQLバリデーションルールで
{% if key0 is empty %}により新規時のみ実行 - メソッド内での除外処理は不要
プロジェクトB(対象・問題あり)
public String checkSupplierCdExistence() throws Exception {
if (StringUtils.isBlank(CommonUtil.toString(this.value))) {
return null;
}
String key0 = CommonUtil.toString(CommonUtil.getProperty(this.curData, "key0"));
if (StringUtils.isBlank(key0)) {
key0 = "-1";
}
jp.co.access_point.common.entity.CoMSupplierEntity entity =
new jp.co.access_point.common.entity.CoMSupplierEntity();
entity.setSupCd(CommonUtil.toLong(this.value));
entity.set_condition(jp.co.access_point.common.entity.CoMSupplierEntity.Columns.key0,
SqlOperator.NotEqual, key0);
if (CommonUtil.isNotEmptyList(dao.select(entity))) {
return "登録済みの支払先コードです。";
}
return null;
}
問題点:
- メソッド内でkey0による除外処理を実装
- SQLルールでも
{% if key0 is empty %}で制御 - 二重制御になり、逆に動作しなくなった
2. checkTaxPayerNoExistence(適格事業者登録番号チェック)の差異
プロジェクトA(元・正しい実装)
public String checkTaxPayerNoExistence() throws Exception {
String taxPayerNo = CommonUtil.toString(this.value);
String supCd = CommonUtil.toString(CommonUtil.getProperty(this.curData, "supCd"));
String id = StringUtils.isBlank(supCd) ? "-1" : CommonUtil.leftPadZero(supCd, 8);
if (StringUtils.isBlank(taxPayerNo)) {
return null;
}
jp.co.access_point.common.entity.CoMSupplierSubEntity supSubEntity =
new jp.co.access_point.common.entity.CoMSupplierSubEntity();
supSubEntity.setTaxPayerNo(taxPayerNo);
supSubEntity.set_condition(
jp.co.access_point.common.entity.CoMSupplierSubEntity.Columns.id,
SqlOperator.NotEqual, id);
if (CommonUtil.isNotEmptyList(dao.select(supSubEntity))) {
return "重複しています。";
}
return null;
}
ポイント:
-
supCdを使用(支払先コード) -
CommonUtil.leftPadZero(supCd, 8)でゼロパディング - データベースのID形式に合わせた比較
プロジェクトB(対象・問題あり)
public String checkTaxPayerNoExistence() throws Exception {
String taxPayerNo = CommonUtil.toString(this.value);
String key0 = CommonUtil.toString(CommonUtil.getProperty(this.curData, "key0"));
if (StringUtils.isBlank(taxPayerNo)) {
return null;
}
jp.co.access_point.common.entity.CoMSupplierSubEntity supSubEntity =
new jp.co.access_point.common.entity.CoMSupplierSubEntity();
supSubEntity.setTaxPayerNo(taxPayerNo);
supSubEntity.set_condition(
jp.co.access_point.common.entity.CoMSupplierSubEntity.Columns.id,
SqlOperator.NotEqual, key0);
if (CommonUtil.isNotEmptyList(dao.select(supSubEntity))) {
return "重複しています。";
}
return null;
}
問題点:
-
supCdではなくkey0を使用 - ゼロパディングがない
- データベースのID形式と一致せず、重複チェックが失敗
3. ValidationUtilの正規表現パターンの差異
isZenkaku(全角チェック)
プロジェクトA(元・厳密):
public static boolean isZenkaku(String str) {
String regex = "^[^ -~。-゚]*$"; // スペース、ASCII、半角カタカナを除外
return str != null && str.matches(regex);
}
プロジェクトB(対象・緩い):
public static boolean isZenkaku(String str) {
String regex = "^[^\\x01-\\x7E]*$"; // ASCII制御文字のみ除外
return str != null && str.matches(regex);
}
問題: プロジェクトBでは半角カタカナが通ってしまう
isHalfKatakanaSpaceAlphabetNumberMark(口座名義チェック)
プロジェクトA(元・銀行要件準拠):
public static boolean isHalfKatakanaSpaceAlphabetNumberMark(String str) {
// 半角カタカナ、長音、アルファベット大文字、数字、().-スペース
String regex = "^[ヲ\\uFF70-\\uFF9FA-Z0-9(). -]+$";
return str != null && str.matches(regex);
}
プロジェクトB(対象・緩すぎ):
public static boolean isHalfKatakanaSpaceAlphabetNumberMark(String str) {
String regex = "^[\\x20-\\x7E\\uFF65-\\uFF9F]*$"; // 緩い制約
return str != null && str.matches(regex);
}
問題: 銀行で受け付けられない文字(小文字アルファベットなど)が通ってしまう
4. 権限別バリデーションルールの有無
プロジェクトA(元・権限別実装)
Controller:
this.dataValidator.validate(data,
new String[] {"supplier." + data.getMode(), "supplier.max"});
データベース:
-
supplier.editor- 編集部用(基本情報のみ) -
supplier.manager- 経理部用(全項目、口座情報含む)
フロントエンド:
params.mode = this.viewer; // "manager" または "editor"
プロジェクトB(対象・単一ルール)
Controller:
this.dataValidator.validate(data,
new String[] {"supplier.validation", "supplier.max"});
データベース:
-
supplier.validation- 単一ルールのみ
フロントエンド:
// modeパラメータなし
問題:
- 権限による入力制御ができない
- 編集部が口座情報を変更できてしまう(セキュリティリスク)
解決方法:実装を統一する
ステップ1: ValidationHelper.javaの修正
3つの不足メソッドを追加し、2つのメソッドを修正しました。
追加メソッド1: checkSupMailExistence(メール存在チェック)
/**
* 支払先メールアドレス存在チェック
*/
public String checkSupMailExistence() throws Exception {
String mail = CommonUtil.toString(this.value);
if (StringUtils.isBlank(mail)) {
return null;
}
String key0 = CommonUtil.toString(CommonUtil.getProperty(this.curData, "key0"));
if (StringUtils.isBlank(key0)) {
key0 = "-1";
}
CoMSupplierEntity entity = new CoMSupplierEntity();
entity.setMail(mail);
entity.set_condition(CoMSupplierEntity.Columns.key0, SqlOperator.NotEqual, key0);
if (CommonUtil.isNotEmptyList(dao.select(entity))) {
return "重複しています。";
}
return null;
}
追加メソッド2: checkSupExistence(支払先存在チェック)
/**
* 支払先存在チェック
*/
public String checkSupExistence() throws Exception {
String supCd = CommonUtil.toString(this.value);
if (StringUtils.isBlank(supCd)) {
return null;
}
CoMSupplierEntity entity = new CoMSupplierEntity();
entity.setSupCd(CommonUtil.toLong(supCd));
if (CommonUtil.isEmptyList(dao.select(entity))) {
return "存在しません。";
}
return null;
}
追加メソッド3: checkSupManagerData(経理データ登録チェック)
/**
* 経理データ登録チェック
*/
public String checkSupManagerData() throws Exception {
String supCd = CommonUtil.toString(this.value);
if (StringUtils.isBlank(supCd)) {
return null;
}
CoMSupplierSubEntity entity = new CoMSupplierSubEntity();
entity.setSupCd(CommonUtil.toLong(supCd));
if (CommonUtil.isEmptyList(dao.select(entity))) {
return "経理データが登録されていません。";
}
return null;
}
修正1: checkSupplierCdExistence
修正前(二重制御で動作不良):
public String checkSupplierCdExistence() throws Exception {
// key0による除外処理あり(不要)
String key0 = CommonUtil.toString(CommonUtil.getProperty(this.curData, "key0"));
entity.set_condition(..., SqlOperator.NotEqual, key0);
// ...
}
修正後(シンプルな存在チェック):
public String checkSupplierCdExistence() throws Exception {
if (StringUtils.isBlank(CommonUtil.toString(this.value))) {
return null;
}
jp.co.access_point.common.entity.CoMSupplierEntity entity =
new jp.co.access_point.common.entity.CoMSupplierEntity();
entity.setSupCd(CommonUtil.toLong(this.value));
if (CommonUtil.isNotEmptyList(dao.select(entity))) {
return "重複しています。";
}
return null;
}
変更点:
- key0による除外処理を削除(Pebbleテンプレートで制御)
- シンプルな存在チェックのみ
- エラーメッセージを統一
修正2: checkTaxPayerNoExistence
修正前(key0使用、ゼロパディングなし):
String key0 = CommonUtil.toString(CommonUtil.getProperty(this.curData, "key0"));
supSubEntity.set_condition(..., SqlOperator.NotEqual, key0);
修正後(supCd使用、ゼロパディングあり):
String supCd = CommonUtil.toString(CommonUtil.getProperty(this.curData, "supCd"));
String id = StringUtils.isBlank(supCd) ? "-1" : CommonUtil.leftPadZero(supCd, 8);
supSubEntity.set_condition(..., SqlOperator.NotEqual, id);
変更点:
-
key0→supCdに変更 - ゼロパディング処理追加
- データベースのID形式に合わせた比較
ステップ2: ValidationUtil.javaの修正
修正1: isZenkaku
修正前:
String regex = "^[^\\x01-\\x7E]*$"; // ASCII制御文字のみ除外
修正後:
String regex = "^[^ -~。-゚]*$"; // スペース、ASCII、半角カタカナを除外
効果: 半角カタカナも除外され、厳密な全角チェックが可能に
修正2: isHalfKatakanaSpaceAlphabetNumberMark
修正前:
String regex = "^[\\x20-\\x7E\\uFF65-\\uFF9F]*$"; // 緩い制約
修正後:
String regex = "^[ヲ\\uFF70-\\uFF9FA-Z0-9(). -]+$"; // 銀行要件準拠
効果: 銀行で受け付けられる文字のみに制限
ステップ3: Controller.javaの修正
修正前(固定ルール名):
this.dataValidator.validate(data,
new String[] {"supplier.validation", "supplier.max"});
修正後(権限別ルール名):
this.dataValidator.validate(data,
new String[] {"supplier." + data.getMode(), "supplier.max"});
変更点: data.getMode()で権限別ルール名を解決
ステップ4: フロントエンド(Vue.js)の修正
修正前(modeパラメータなし):
saveData() {
const params = this.cloneData(this.page);
// modeパラメータなし
this.post('supplier', params);
}
修正後(権限を送信):
saveData() {
const params = this.cloneData(this.page);
params.mode = this.viewer; // "manager" または "editor"
this.post('supplier', params);
}
変更点: this.viewer(ユーザー権限)をmodeパラメータとして送信
ステップ5: SQLバリデーションルールの作成
権限別に2つのルールを作成しました。
ルール1: supplier.editor(編集部用)
INSERT INTO CM_M_VALIDATION_RULE
(SUB_SYSTEM_CD, VALIDATION_NAME, VALIDATION_URL, VALIDATION_URL_METHOD, VALIDATION_RULE)
VALUES('A', '支払先マスター 入力チェック(編集部)', 'supplier.editor', 'POST', '{
"supCd":["isRequired;",
"isInteger;",
{% if key0 is empty %}
"checkSupplierCdExistence;",
{% endif %}
""
],
"taxPayerNo":"isAlphanumeric;checkTaxPayerNoExistence;",
"supNm": "isRequired;",
"mail":"isEmail;",
"tel":"isTel;",
"fax":"isTel;",
"tel2":"isTel;",
"fax2":"isTel;"
}', NULL, 9999, SYSDATE);
特徴:
- 基本情報のみ(名称、連絡先等)
- 口座情報は編集不可
- 編集部の権限範囲内
ルール2: supplier.manager(経理部用)
INSERT INTO CM_M_VALIDATION_RULE
(SUB_SYSTEM_CD, VALIDATION_NAME, VALIDATION_URL, VALIDATION_URL_METHOD, VALIDATION_RULE)
VALUES('A', '支払先マスター 入力チェック(経理部)', 'supplier.manager', 'POST', '{
"supCd":["isRequired;",
"isInteger;",
{% if key0 is empty %}
"checkSupplierCdExistence;",
{% endif %}
""
],
"taxPayerNo":"isAlphanumeric;checkTaxPayerNoExistence;",
"supNm": "isRequired;",
"mail":"isEmail;",
"tel":"isTel;",
"fax":"isTel;",
"moneytransferType":"isRequired;",
"kouzNm":"isHalfKatakanaSpaceAlphabetNumberMark;",
"kouzNumber":"isInteger;",
"aflg":"isRequired;",
"cooperationDivision":"isRequired;",
"taxCd":"isRequired;",
"withtaxCd":"isRequired;",
"roundCd1":"isRequired;",
"rflg1":"isRequired;",
"divCd":"isRequired;",
"simebi":[
{% if divCd == 0 %}
"isRequired;",
{% endif %}
""
],
"pmonth":[
{% if divCd == 0 %}
"isRequired;",
{% endif %}
"isInteger;",
"between:0,99;"
],
"pday":[
{% if divCd == 0 %}
"isRequired;",
{% endif %}
"isInteger;",
"between:0,99;"
],
"occCd":"isRequired;",
"gtaxCd":"isRequired;",
"payunitCd":"isRequired;",
"roundCd4":"isRequired;",
"tel2":"isTel;",
"fax2":"isTel;",
"treatyTaxId":[
{% if gtaxCd == 91 %}
"isRequired;",
{% endif %}
"isInteger;"
],
"currencyCd":[
{% if moneytransferType == 1 %}
"isRequired;",
{% endif %}
""
],
"moneytransferName":"isSingleByte;",
"moneytransferAdr":"isSingleByte;",
"moneytransferAccount": "isSingleByte;",
"moneytransferBank":"isSingleByte;",
"moneytransferBranch":"isSingleByte;",
"moneytransferBranchAdr":"isSingleByte;",
"moneytransferPurpose1Label":"isInteger;",
"moneytransferPurpose2":"isSingleByte;",
"message":"isSingleByte;",
"instructionMessage1": "isSingleByte;",
"instructionMessage2": "isSingleByte;",
"instructionMessage3": "isSingleByte;",
"throughBank":"isSingleByte;",
"throughBranch":"isSingleByte;",
"throughBranchAdr":"isSingleByte;",
"invoiceFlg":"isRequired;"
}', NULL, 9999, SYSDATE);
特徴:
- 全項目(口座情報、送金情報含む)
- 条件付き必須チェック多数(Pebbleテンプレート活用)
- 経理部の全権限
Pebbleテンプレートの活用
SQLバリデーションルールでは、Pebbleテンプレートエンジンを使って動的なルールを実現しています。
パターン1: 新規登録時のみ重複チェック
"supCd": [
"isRequired;",
"isInteger;",
{% if key0 is empty %}
"checkSupplierCdExistence;",
{% endif %}
""
]
動作:
-
key0が空(新規登録)→ 重複チェック実行 -
key0がある(編集)→ 重複チェックスキップ
パターン2: 条件付き必須チェック
"simebi": [
{% if divCd == 0 %}
"isRequired;",
{% endif %}
""
]
動作:
-
divCdが0 →simebiが必須 -
divCdが0以外 →simebiは任意
パターン3: 特定の値の場合のみ必須
"currencyCd": [
{% if moneytransferType == 1 %}
"isRequired;",
{% endif %}
""
]
動作:
-
moneytransferTypeが1(国際送金)→currencyCdが必須 - それ以外 →
currencyCdは任意
バリデーション実行フロー(修正後)
【フロントエンド: 保存ボタンクリック】
↓
this.viewer = "manager" (または "editor")
↓
params.mode = this.viewer
↓
【POST /api/supplier】
{ mode: "manager", supCd: 12345, taxPayerNo: "T1234567890123", ... }
↓
【Controller.save()】
validate(data, ["supplier.manager", "supplier.max"])
↓
【DataValidator】
① DB から "supplier.manager" を取得 → 見つかった!
② JSON をパース
③ Pebbleテンプレート評価(key0の有無、divCdの値など)
④ フィールドごとにチェック実行
↓
【supCd フィールド】
→ isRequired → OK
→ isInteger → OK
→ key0が空なので checkSupplierCdExistence 実行
↓
【ValidationHelper.checkSupplierCdExistence()】
↓
【DB で重複チェック(シンプルな存在確認)】
↓
→ 重複あり → エラー「重複しています。」
→ 重複なし → OK
↓
【taxPayerNo フィールド】
→ isAlphanumeric → OK
→ checkTaxPayerNoExistence 実行
↓
【ValidationHelper.checkTaxPayerNoExistence()】
↓
【supCdからゼロパディングしたIDで重複チェック】
↓
→ 重複あり → エラー「重複しています。」
→ 重複なし → OK
↓
【kouzNm フィールド(口座名義)】
→ isHalfKatakanaSpaceAlphabetNumberMark 実行
↓
【ValidationUtil.isHalfKatakanaSpaceAlphabetNumberMark()】
↓
【正規表現チェック: ^[ヲ\\uFF70-\\uFF9FA-Z0-9(). -]+$】
↓
→ 小文字含む → エラー
→ 規定文字のみ → OK
↓
【エラーがあれば】
{
"status": "Error",
"message": "入力エラー",
"errors": [
{ "field": "supCd", "message": "重複しています。" },
{ "field": "kouzNm", "message": "半角カタカナ・大文字英数のみ入力してください。" }
]
}
↓
【フロントエンド: エラー表示】
実装のポイント
1. 責任の明確な分離
各層が明確な責任を持つことで、保守性とテスト性が向上します。
| 層 | やること | やらないこと |
|---|---|---|
| Controller | リクエスト受付、バリデーション呼び出し | ビジネスロジック、細かい検証 |
| Service | ビジネスロジック、DB保存 | バリデーション、HTTP処理 |
| ValidationHelper | プロジェクト固有検証、DB利用 | 汎用的な処理 |
| ValidationUtil | 汎用的な文字列・数値検証 | DBアクセス、プロジェクト固有 |
悪い例(責任が混在):
// Controller内でビジネスロジックを記述
@RequestMapping(...)
public Result save(@RequestBody Data data) {
// NG: Controller内で複雑な計算
if (data.getAmount() > 10000) {
data.setDiscount(data.getAmount() * 0.1);
}
// NG: Controller内でDB操作
Entity entity = dao.select(...);
return service.save(data);
}
良い例(責任が分離):
// Controller: リクエスト受付のみ
@RequestMapping(...)
public Result save(@RequestBody Data data) {
this.dataValidator.validate(data, ...);
return service.save(data);
}
// Service: ビジネスロジック
public Result save(Data data) {
// 複雑な計算はServiceで
if (data.getAmount() > 10000) {
data.setDiscount(calculateDiscount(data.getAmount()));
}
// DB操作もServiceで
return dao.insert(data);
}
// ValidationHelper: プロジェクト固有の検証
public String checkSupplierCdExistence() {
// DB利用の検証
return dao.select(...).isEmpty() ? null : "重複しています。";
}
// ValidationUtil: 汎用的な検証
public static boolean isZenkaku(String str) {
// 文字列処理のみ
return str.matches("^[^ -~。-゚]*$");
}
2. プロジェクト間の実装統一の重要性
統一すべき理由:
- バグの混入を防ぐ
- メンテナンス性の向上
- ノウハウの共有
- コードレビューの効率化
統一の方法:
- 定期的なコード比較
- 共通ライブラリの活用
- 実装ガイドラインの整備
- 元プロジェクトの改善を他プロジェクトにも反映
3. 二重制御の危険性
今回のcheckSupplierCdExistenceのように、SQLテンプレートとメソッド内の両方で同じ制御をすると、予期しない動作になることがあります。
悪い例:
// メソッド内でkey0除外
public String checkSupplierCdExistence() {
entity.set_condition(..., SqlOperator.NotEqual, key0);
// ...
}
// SQLテンプレートでもkey0チェック
{% if key0 is empty %}
"checkSupplierCdExistence;",
{% endif %}
良い例:
// メソッドはシンプルに存在チェックのみ
public String checkSupplierCdExistence() {
if (dao.select(entity).isNotEmpty()) {
return "重複しています。";
}
return null;
}
// SQLテンプレートで条件制御
{% if key0 is empty %}
"checkSupplierCdExistence;",
{% endif %}
原則: 制御は1箇所で(SQLテンプレートで制御する場合、メソッドはシンプルに)
4. 権限別バリデーションの利点
セキュリティ向上:
- 編集部は口座情報を編集できない
- 権限に応じた入力制限
画面のシンプル化:
- 権限に応じて必須項目が変わる
- フロントエンドで複雑な制御不要
柔軟性:
- SQLで権限ルールを追加可能
- コード変更不要
5. 正規表現の厳密性
緩い正規表現の危険性:
"^[\\x20-\\x7E\\uFF65-\\uFF9F]*$" // 何でも通る
- 銀行で受け付けられない文字が通る
- 後で修正が大変
厳密な正規表現の安全性:
"^[ヲ\\uFF70-\\uFF9FA-Z0-9(). -]+$" // 許可する文字のみ
- 必要な文字だけを許可
- データ品質の向上
トラブルシューティング
問題1: バリデーションが実行されない
確認ポイント:
-
ルール名が正しいか
SELECT * FROM CM_M_VALIDATION_RULE WHERE VALIDATION_URL LIKE 'supplier%'; -
Controllerで正しいルール名を指定しているか
this.dataValidator.validate(data, new String[] {"supplier." + data.getMode(), "supplier.max"}); -
フロントエンドからmodeパラメータが送信されているか
console.log('mode:', params.mode); // "manager" または "editor"
問題2: 重複チェックが動作しない
確認ポイント:
-
Pebbleテンプレートの条件が正しいか
{% if key0 is empty %} "checkSupplierCdExistence;", {% endif %} -
メソッド内で二重制御していないか
// NG: メソッド内でもkey0除外 entity.set_condition(..., SqlOperator.NotEqual, key0); // OK: シンプルな存在チェック entity.setSupCd(...); -
IDのフォーマットが一致しているか
// ゼロパディング必要? String id = CommonUtil.leftPadZero(supCd, 8);
問題3: 正規表現が期待通りに動作しない
デバッグ方法:
String regex = "^[ヲ\\uFF70-\\uFF9FA-Z0-9(). -]+$";
String testValue = "ジョン スミス";
System.out.println(testValue.matches(regex)); // true or false
// マッチしない文字を探す
for (char c : testValue.toCharArray()) {
String s = String.valueOf(c);
if (!s.matches("[ヲ\\uFF70-\\uFF9FA-Z0-9(). -]")) {
System.out.println("NG: " + c + " (U+" + Integer.toHexString(c) + ")");
}
}
まとめ
実装範囲
| ファイル | 変更内容 | 行数 |
|---|---|---|
| ValidationHelper.java | 3メソッド追加、2メソッド修正 | 約150行 |
| ValidationUtil.java | 2メソッド修正 | 約20行 |
| Controller.java | 1行修正 | 1行 |
| Vue.js | 1行追加 | 1行 |
| SQL | 2つのバリデーションルール作成 | 約110行 |
| 合計 | 5ファイル、約282行 |
解決した問題
- 適格事業者登録番号の重複チェックが動作するようになった
- 権限別バリデーションが実装された(editor/manager)
- ValidationHelperとValidationUtilがプロジェクトAと統一された
- 正規表現が厳密になり、不正な文字を防げるようになった
- エラーメッセージが統一された
学んだこと
-
各ファイルの役割を明確に
- Controller: リクエスト受付
- ValidationHelper: プロジェクト固有の検証(DB利用)
- ValidationUtil: 汎用的な検証(DB不使用)
- 責任を分離することで保守性とテスト性が向上
-
プロジェクト間の実装差異は定期的に確認する
- 元プロジェクトの改善を他プロジェクトにも反映
- コード比較ツールの活用
-
二重制御は避ける
- SQLテンプレートで制御する場合、メソッドはシンプルに
- 制御ロジックは1箇所に集約
-
権限別バリデーションは有効
- セキュリティ向上
- 画面のシンプル化
- 柔軟な権限管理
-
正規表現は厳密に
- 緩い正規表現は後で問題になる
- 必要な文字だけを許可する方針で
-
デバッグの重要性
- 「動いている」≠「正しく動いている」
- ログを活用して実際の動作を確認
この記事が参考になれば幸いです。