狛ログ

2022年8月4日木曜日

【Angular】エラーメッセージの管理について考える。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

以前「FormのValidationの書き方を簡略化させる」と言う記事を書きましたが、その時、特にValidationエラーのメッセージについては触れませんでした。
今回、Validationエラーも含めた「メッセージ管理」について考えて行こうと思います。
以前の記事をまだ見ていない方は、是非、目を通して頂けると幸いです。

ちなみに、今回の考え方と実装は、Angularに限らず(TypeScriptやJavaScriptを使っているフレームワークであれば)、同じように使えるかな、と思います。

(1)そもそも、何が問題なのか

前回、最終的にテンプレート(View)側は下記のようになりました。
[register.component.html]
<form [formGroup]="formRegister" (ngSubmit)="onSubmit()" (keydown.enter)="$event.preventDefault()">
      <div>
        <label>お名前</label>
        <input formControlName="name" type="text" class="form-control" placeholder="お名前を入力(必須)"
          [ngClass]="{'alert-danger' : v.nameInvalid}">
        <ng-container *ngIf="v.nameInvalid">
          <ng-container *ngIf="v.nameHasErrorRequired">
            <p class="error-message">お名前は必ず入力してください。</p>
          </ng-container>
          <ng-container *ngIf="v.nameHasErrorMaxLength">
            <p class="error-message">お名前は10文字以内で入力してください。</p>
          </ng-container>
        </ng-container>
      </div>
      <button class="btn btn1 ml-auto" [disabled]="formRegister.invalid" type="submit">登録</button>
</form>

これの何が問題になるのでしょうか・・・・?

例えば、必須入力のエラーである「xxxは必ず入力してください。」ですが、内容を「xxxの入力は必須です。」に変えたい、となった場合を考えます。
テンプレート(View)側を変えれば良いのでしょうが、例えば、必須項目が数十個あった場合はどうでしょうか?
そして、この画面だけでなく、他の画面でも必須項目があった場合はどうでしょうか?
なるべく修正箇所は少なく済ませたいですが、どうしても対応する量が多くなってしまいます。

(2)メッセージを1箇所で管理する

まあ、1箇所って言ってしまうと極端ですが、なるべくまとめて管理する、が良いと思います。

※例えば、メッセージにも「正常」「エラー」「ワーニング」など種別があると思いますので、それを全て1箇所で管理すると、今度は管理するファイルが肥大化することになるので、種別毎に管理した方が良い・・・とか、ですね。

早速、まとめて管理するファイルを作ろうと思います。
弊社のプロジェクトでは、「app」配下に「shared」と言うディレクトリを作り、その中にプロジェクトで共通的に使うものを配置しています。
今回は、以下のように作成します。
src/
└ app/
    └ shared/
        └ message/
            └ error-messages.ts

見ての通り、今回は、あくまで「エラー用メッセージ」として管理します。

(3)メッセージ管理処理の仕様について

さて、管理用のファイルを作ったところで、実装はどのようにしましょう。
パッと思い付く要求仕様としては、

  • 複数のメッセージを定義可能。
  • メッセージは、差し込みが可能。
  • メッセージ毎にユニークなKeyを持ち、Keyからメッセージを取得可能。

と言うところですかね。

特に今回は「メッセージは、差し込みが可能。」が重要です。
必須項目であれば、「{0}は必ず入力してください。」と定義して、「{0}」の部分を名前だったり、電話番号だったり、郵便番号だったり・・・・と動的に差し込み出来れば、メッセージの定義は1つだけで済みます。

では、要求仕様を満たす実装をします。
[error-messages.ts]
export const errorMessages: { [key: string]: string } = {
  msg_error_field_required: '{0}は必ず入力してください。',
  msg_error_field_max: '{0}は{1}文字以内で入力してください。',
};

function formatMessage(msg: string, ...args: any[]): string {
  return msg.replace(/\{(\d+)\}/g, (m, k) => {
    return args[k];
  });
}

export function getMessage(messageId: string, ...args: any[]): string {
  return formatMessage(errorMessages[messageId], ...args);
}

ざっくりと説明します。
まず、他の処理からこの「メッセージ管理処理」を使いたい時は、メッセージの Key と、差し込みたい文字列を引数に getMessage を呼ぶことになります。
export function getMessage(messageId: string, ...args: any[]): string {
  return formatMessage(errorMessages[messageId], ...args);
}

getMessage の中で、formatMessage が呼ばれ、ここで、Keyに該当するメッセージを取得しつつ、差し込み文字列を置換している、と言う感じです。
function formatMessage(msg: string, ...args: any[]): string {
  return msg.replace(/\{(\d+)\}/g, (m, k) => {
    return args[k];
  });
}

メッセージの定義自体は、連想配列として、Key・Valueで定義しています。
export const errorMessages: { [key: string]: string } = {
  msg_error_field_required: '{0}は必ず入力してください。',
  msg_error_field_max: '{0}は{1}文字以内で入力してください。',
};

(4)メッセージ管理処理を使ってみる

では、実際にメッセージ管理処理を使ってみます。
まずは、コンポーネント側の実装です。
[register.component.ts]
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { RegisterValidator } from './register.validator';
import { getMessage } from '@app/shared/message/error-messages';
  // (中略)
export class RegisterComponent implements OnInit {
  // (中略)
  formRegister: FormGroup = this.formBuilder.group({
    name: ['', [Validators.required, Validators.maxLength(10)]],
  });
 
  // (中略)
  
  message(messageId: string, ...args: any[]): string {
    return getMessage(messageId, ...args);
  }

コンポーネント側で「error-messages.ts」のimportを行い、
メッセージ管理処理の「getMessage」を呼び出す「message()」を追加しました。

この「message()」を呼び出すのは、テンプレート側です。
[register.component.html]
<form [formGroup]="formRegister" (ngSubmit)="onSubmit()" (keydown.enter)="$event.preventDefault()">
      <div>
        <label>お名前</label>
        <input formControlName="name" type="text" class="form-control" placeholder="お名前を入力(必須)"
          [ngClass]="{'alert-danger' : v.nameInvalid}">
        <ng-container *ngIf="v.nameInvalid">
          <ng-container *ngIf="v.nameHasErrorRequired">
            <p class="error-message">{{ message('msg_error_field_required', 'お名前') }}</p>
          </ng-container>
          <ng-container *ngIf="v.nameHasErrorMaxLength">
            <p class="error-message">{{ message('msg_error_field_max', 'お名前', 10)}}</p>
          </ng-container>
        </ng-container>
      </div>
      <button class="btn btn1 ml-auto" [disabled]="formRegister.invalid" type="submit">登録</button>
</form>

元々、メッセージを固定で記載していた部分を以下のように変更しています。
{{ message('msg_error_field_required', 'お名前') }}
{{ message('msg_error_field_max', 'お名前', 10)}}

テンプレート側とコンポーネント側で「10」を2回定義しているのが気になりますね。ここは定数宣言して1回の定義で済むようにしましょう。
[register.component.ts]
  nameMaxLength = 10;

  formRegister: FormGroup = this.formBuilder.group({
    name: ['', [Validators.required, Validators.maxLength(this.nameMaxLength)]],
  });

テンプレート側も変えておきます。 [register.component.html]
{{ message('msg_error_field_max', 'お名前', nameMaxLength)}}

これで全ての対応が完了しました。

今後、仮に「xxxは必ず入力してください。」を「xxxの入力は必須です。」に変えたいとなっても、
全項目・全画面確認する必要はなく、「error-messages.ts」を変更すれば、一律変更出来ることになりました。

(5)デメリットも把握しておく

当然ながら、汎用的に使われているメッセージについて、「特定の1箇所だけメッセージだけ変えたい」と言う要望に対しては、対応が難しくなります。
まあ、そんな事はあまり無い思いますが、それよりも、実際に弊社が抱えている悩みとしては・・・・・
デザイナーが作ったhtmlをAngular側に反映する時に苦労する。
があります。

この方式は結局のところ、テンプレート側にロジックを記載している事になるのですが、デザイナーとしては、そんなこと知ったこっちゃないので、元のままメッセージ直書きしたhtmlを渡してきます。
画面のレイアウトに変更があった場合など、プログラマーが気を付ける必要がある、と言う事が、デメリットと言えばデメリットなのかもしれません。


0 件のコメント:

コメントを投稿