0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Spring Boot】他プロジェクトとのコード差異が原因でバリデーションが動かない→実装を統一して解決した話

0
Last updated at Posted at 2026-03-07

同じ機能なのに動作が違う?2つのプロジェクト間の実装差異を解消した全記録

案件の内容

既存の類似プロジェクトからのコピーで開発していたプロジェクトに対し、ある日こんな指示が来ました:

「元プロジェクトのコードに合わせて実装を統一してください」

コードを比較してみると…ValidationHelper、ValidationUtil、Controller、そしてバリデーションルールの実装に大きな差異が存在していました。さらに調査を進めると、「適格事業者登録番号の重複チェックが動作しない」という致命的な問題も発覚。

発覚した問題

  1. 適格事業者登録番号の重複チェックが動作しない

    • 新規登録時に既存データとの重複をチェックするはずが、何もチェックされていない
    • エラーも出ない、保存は通る
  2. ValidationHelperのメソッド実装が異なる

    • 存在チェックメソッドが3つ不足
    • 重複チェックのロジックが微妙に違う(key0 vs supCd)
  3. ValidationUtilの正規表現パターンが異なる

    • 全角チェックの基準が緩い
    • 口座名義チェックの制約が不十分
  4. 権限別バリデーションルールが未実装

    • 単一ルールのみで権限による制御がない
    • フロントエンドから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);

変更点:

  • key0supCdに変更
  • ゼロパディング処理追加
  • データベースの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: バリデーションが実行されない

確認ポイント:

  1. ルール名が正しいか

    SELECT * FROM CM_M_VALIDATION_RULE WHERE VALIDATION_URL LIKE 'supplier%';
    
  2. Controllerで正しいルール名を指定しているか

    this.dataValidator.validate(data, 
        new String[] {"supplier." + data.getMode(), "supplier.max"});
    
  3. フロントエンドからmodeパラメータが送信されているか

    console.log('mode:', params.mode);  // "manager" または "editor"
    

問題2: 重複チェックが動作しない

確認ポイント:

  1. Pebbleテンプレートの条件が正しいか

    {% if key0 is empty %}
        "checkSupplierCdExistence;",
    {% endif %}
    
  2. メソッド内で二重制御していないか

    // NG: メソッド内でもkey0除外
    entity.set_condition(..., SqlOperator.NotEqual, key0);
    
    // OK: シンプルな存在チェック
    entity.setSupCd(...);
    
  3. 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行

解決した問題

  1. 適格事業者登録番号の重複チェックが動作するようになった
  2. 権限別バリデーションが実装された(editor/manager)
  3. ValidationHelperとValidationUtilがプロジェクトAと統一された
  4. 正規表現が厳密になり、不正な文字を防げるようになった
  5. エラーメッセージが統一された

学んだこと

  1. 各ファイルの役割を明確に

    • Controller: リクエスト受付
    • ValidationHelper: プロジェクト固有の検証(DB利用)
    • ValidationUtil: 汎用的な検証(DB不使用)
    • 責任を分離することで保守性とテスト性が向上
  2. プロジェクト間の実装差異は定期的に確認する

    • 元プロジェクトの改善を他プロジェクトにも反映
    • コード比較ツールの活用
  3. 二重制御は避ける

    • SQLテンプレートで制御する場合、メソッドはシンプルに
    • 制御ロジックは1箇所に集約
  4. 権限別バリデーションは有効

    • セキュリティ向上
    • 画面のシンプル化
    • 柔軟な権限管理
  5. 正規表現は厳密に

    • 緩い正規表現は後で問題になる
    • 必要な文字だけを許可する方針で
  6. デバッグの重要性

    • 「動いている」≠「正しく動いている」
    • ログを活用して実際の動作を確認

この記事が参考になれば幸いです。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?