狛ログ

2022年9月30日金曜日

Photoshopの「書き出し形式」で数倍に書き出した時に画質が劣化してしまうオブジェクト・劣化しないオブジェクトの違い。

9月 30, 2022


こんにちは、オフィス狛 デザイン部のSatoです。


初歩的な話なのですが、Photoshopの書き出し形式機能で小さい画像を2倍など大きいサイズを指定して書き出すと一部のオブジェクトの画質が劣化し、ぼやけてしまうことがあります。

過去に無知故に2倍(2x)で書き出せるなら…と作成したデータを2xで書き出したところ、画像の一部だけが少し劣化してぼやけてしまったことがありました。

この時、画像全体の画質が劣化する訳では無く画像の一部だけ劣化するのが不思議で、画質が落ちる箇所・画質が落ちない箇所の差が何なのかが気になりました🤔

今回は、Photoshopの「書き出し形式」で、元サイズより大きい書き出した時に画質が落ちてしまうオブジェクト・綺麗なオブジェクトの違いについて解説しようと思います。

Photoshopの書き出し形式機能自体は、私が書いた上記の記事でも解説しています。
合わせて読んでいただけると、書き出し形式機能についての基礎もわかりますよ。




書き出し形式機能を利用して、拡大して書き出した場合に画質が落ちぼやけるオブジェクト・画質が落ちずぼやけないオブジェクトの一覧表を作ってみました。

この一覧表自体、Photoshopの書き出し機能を利用しキャンバスサイズの3倍(3x)のサイズで書き出してみたので、クリックして拡大表示してみてくだい👇

同じ画像内でも、こんなに画質が変わってきます。

ぼやけないカテゴリの画像は画質が落ちていませんが、ぼやけるカテゴリに入れている画像はかなり画質が劣化してしまいにじんでしまっています😞


さて、この画質が劣化する・しないの違いは何かというと……。
スマートオブジェクト以外は、「ラスタ形式のオブジェクト」か「ベクタ形式のオブジェクト」かです。


ラスタ形式のオブジェクトは、色のついたピクセルで作られたデータで、拡大するとぼやけてしまいます。

ベクタ形式のオブジェクトは、点と曲線の数値で作られたデータで、拡大してもぼやけません。

ラスタ形式とベクタ形式についての解説は、アドビのラスター&ベクトルが分かりやすいと思います。ぜひ読んでみてください。



しかし、上の説明で
「Photoshopのようなグラフィックソフトで作成するオブジェクトは全部ラスタ形式のはずでは?」
と疑問に思われる方もいらっしゃるかもしれません。

実はグラフィックソフトのPhotoshopでも、ベクタ形式のオブジェクトを扱っています!


Photoshopのベクタ形式のオブジェクトをご紹介します👇

ペンツールやシェイプツール(図形ツール・カスタムシェイプ)などで製図した図形「シェイプ
シェイプで製図した際に作られるレイヤー「シェイプレイヤー」は下記のような見た目になります。



字ツールで文字を打ち込んだ際に作成される「テキスト
テキストを入力した際に作られるレイヤー「テキストレイヤー」は下記のような見た目になります。



Illustratorで作成したオブジェクトをPhotoshopのレイヤーにコピー&ペーストした際に出るペースト形式選択で、スマートオブジェクトを選択した際に作成される「ベクトルスマートオブジェクト




ベクトルスマートオブジェクトをペーストした際に作られるレイヤー「ベクトルスマートオブジェクトレイヤー」は下記のような見た目になります。



以上、3つのオブジェクトは全てベクタ形式のオブジェクトです。


また、ラスタ形式ではありませんが、「元の画像のサイズが書き出したサイズより大きいスマートオブジェクト」は縮小しても元のサイズのデータを保持している為、劣化しません。

しかし、スマートオブジェクトはあくまで元のサイズのデータを保持しているだけですので、「元の画像のサイズが書き出したサイズより小さいスマートオブジェクト」は劣化してしまいます。
スマートオブジェクトも完璧という訳ではないのです。



まとめると、
シェイプレイヤー」「テキストレイヤー」「ベクトルスマートオブジェクトレイヤー」「元の画像のサイズが書き出したサイズより大きいスマートオブジェクト
以上の4つのオブジェクトであれば、ラスタライズしない限りは画質が落ちる事なく等倍で書き出すことが可能です。

逆に「通常レイヤーのオブジェクト」や「元の画像のサイズが書き出したサイズよりも小さいスマートオブジェクト」などのオブジェクトが含まれるキャンバスを等倍で書き出すのは、画質が落ちてしまうので、やめた方が良いでしょう。

Photoshopを使われる方は、覚えておいて損はないと思います🙌

【Sourcetree】カスタムアクションで特定コミット間の差分ファイルをサクッと抽出する。(Mac版)

9月 30, 2022

技術部のyuckieee(ゆっきー)です。
色々なプロジェクトで開発を行っていて、ちょいちょい発生する作業で面倒だなって思っていることがありました。それは納品物やリリース対象物の準備です。

何かというと、運用保守中に追加開発などが発生した場合に、プログラムの差分ファイルのみを納品物やリリース対象として準備する必要があります。
これ、中々に面倒くさいんですよね。間違えたら大変だし、毎回ドキドキしちゃいます(笑)

そこで、出来るだけ間違いが起こらないように自動化出来ないか探してみた結果、私がいつも使っているGit管理ツール「Sourcetree」のカスタムアクションを試してみたら良さげだったので、ご紹介しようと思います。

■作成したカスタムアクションの概要

まずはイメージ共有のため、今回作成したカスタムアクションの概要を説明します。
ざっくりした動作仕様としては「Sourcetreeで現在選択されているリポジトリ-ブランチに存在するコミット間の差分ファイルを取得する。」です。 利用イメージ(方法)は以下のとおり。

[利用イメージ(方法)]
Sourcetreeの該当ブランチの履歴から差分ファイルをとりたいコミットを選択のうえ、カスタムアクションを実行します。 差分を取るためにコミットは2つ選択。ただし、1つだけ選択した場合でも、選択したコミットから最新コミットまでを対象と実行します。
実行時は、経過が分かるようにダイアログにログが表示されます。
実行後、ログ記載の出力先に差分ファイル(zip)、差分ファイル一覧、実行結果ログが格納されます。
以上です!

■カスタムアクションの実装方法

実装完了までの大まかな流れは以下のとおりです。

[実装の流れ]
① 呼び出しスクリプト作成
② カスタムアクション登録
③ 動作確認

それではサクッと詳細の説明に入ります。

① 呼び出しスクリプト作成

最初にカスタムアクションで呼び出されるスクリプトを作成します。
今回はシェルスクリプト(sh)で作成し、実際に作成したコードは以下のとおりです。(スクリプトの引数は②で説明しますが、$1にリポジトリ名、$2$3にコミットIDが指定されています)
#!/bin/sh
#########################################
# ファイル名: export_diff.sh
# 処理内容 : 該当コミット間の差分ファイル出力
#########################################

#########################################
# 変数定義
#########################################
repo_name=$(basename "$(pwd)")
branch_name=$(git rev-parse --abbrev-ref @)
file_name="$(basename "$(pwd)")_diff.zip"
list_name="$(basename "$(pwd)")_diff_list.log"
today="$(date +"%Y%m%d")"
dir_name="export_diff/$repo_name/$branch_name/$today"
log_file=export_diff_result.log

#########################################
# 出力処理 
#########################################
export_diff() {
    # リモートブランチと同期を実行した上で、出力対象をダイアログ表示
    echo ""
    echo "出力を開始します($(date))"
    echo "-------------------"
    echo "■リモートブランチと同期"
    echo "-------------------"
    git pull
    echo ""
    echo "-------------------"
    echo "■出力対象"
    echo "-------------------"
    echo "レポジトリ:${1}"
    echo "ブランチ:${branch_name}"
    echo ""
    echo "-------------------"

    # 差分ファイル出力処理
    if  [ "$3" != "" ]; then
        # 2コミット間の差分ファイルを出力
        git archive --worktree-attributes -o "$file_name" "$2" $(git diff --name-only --diff-filter=du "$3" "$2")
        echo "■差分対象に含まれるコミット"
        echo "-------------------"
        git log "$3"..."$2" --pretty=format:"%h : %s"
        echo ""
    elif  [ "$2" != "" ]; then
        # 選択されたコミットから最新コミットまでを出力
        git archive --worktree-attributes -o "$file_name" HEAD $(git diff --name-only --diff-filter=d "$2" HEAD)
        echo "■差分対象に含まれるコミット"
        echo "-------------------"
        git log "$2"...HEAD --pretty=format:"%h : %s"
        echo ""
    else
        # 上記以外の場合は正しく出力できないためエラーとする
        echo "【ERROR】指定できるコミット数は1又は2です。"
        echo "-------------------"
        exit
    fi

    # 出力された差分ファイルの一覧をリスト出力(ディレクトリのみの表示は除く)
    zipinfo -1 "$file_name" | grep -v /$ > "$list_name"
    echo ""
    echo "-------------------"
    echo "■出力ファイル一覧"
    echo "-------------------"
    cat "$list_name"
    echo ""

    # 出力されたファイル移動(混乱しないように)
    mv "$file_name" ~/"$dir_name"
    mv "$list_name" ~/"$dir_name"
    echo "-------------------"
    echo "■ファイル出力先"
    echo "-------------------"
    echo "${HOME}/$dir_name"
    echo "-------------------"
    echo ""
    echo "出力が完了しました($(date))"
}

#########################################
# メイン処理
#########################################
if [ $# -gt 3 ]; then
    # 選択したコミットが4つ以上の場合は正しく出力できないためエラーとする
    echo "【ERROR】指定できるコミット数は1又は2です。"
    echo "-------------------"
    exit
fi

# 出力ディレクトリを作成
mkdir -p ~/"$dir_name"

# 出力処理を呼び出し
export_diff "$1" "$2" "$3" 2>&1 | tee "${HOME}/$dir_name/$log_file"

...長い!!!(笑)
ダイアログ表示やファイル移動等の余分なコードが含まれていますが、重要なのは以下に抜粋したコマンドです。
git archive --format=zip -o release.zip  --worktree-attributes "$2" $(git diff --name-only --diff-filter=d "$2" "$3")

上記は、Gitが提供している「Git管理対象のみを圧縮形式で出力してくれる」コマンドです。
出力対象の指定がない場合は、指定したブランチ又は、コミット時点のGit管理対象の全ファイルが出力されますが、ファイル名及び、パスを指定することで出力対象ファイルを絞り込むことが出来ます。
今回は、このファイル名・パス指定にgit diffコマンドを使用することで差分ファイルの抽出を実現しました。
以降、git diffgit archive、それぞれのコマンドについて紹介します。

git diffコマンド
差分ファイルを抽出するために使用したコマンドです。
オプションを省いた場合のシンプルな構成は以下のとおりです。
git diff <変更前のコミットID> <変更後のコミットID>
例) git diff 1668413042b61f16f0f353fd66b53cd4c0dbc5d5 2d13b702a783bb2c8252ba07f5410619ee3a0186 
なお、コミットIDの指定順序が逆転してしまうと「削除したファイル」と「新規作成されたファイル」の識別が逆転してしまう等、その後の出力に影響があるので注意してください。

以降に私が指定が必要そうだと考えたオプションについて補足します。

[コマンドオプション]
--name-only  (必須)
差分比較結果としてファイル名(パス含む)のみを返す指定です。
本オプションを指定しない場合、ズラズラとコミット間の比較内容が表示されるのですが、それがgit archiveに引数として渡ってしまうとエラーになりますので指定必須です。

--diff-filter=<出力対象の指定>  (必須)
例)--diff-filter=ACMR
指定することで、差分取得対象を絞り込むことが出来ます。
このオプションは、大文字で記述すると「出力対象」を、小文字で記述すると「出力除外対象」を指定することが出来ます。それぞれのオプション文字列の説明を下表にまとめました。

指定可能なオプション文字列    
オプション
文字列
説明
A/a 追加されたファイル
C/c コピーされたファイル
D/d 削除されたファイル
M/m ファイル内容又は、ファイル属性が変更されたファイル
R/r ファイル名が変更されたファイル
T/t ファイルタイプ(通常ファイル、シンボリックファイル、サブモジュール等)が変更されたファイル
U/u マージされていないファイル(コンフリクトファイル確認時に使用されるようです)
B/b ペアリングが壊れているファイル
X/x 不明な変更タイプ(通常は有り得ないようです)

今回は差分に「削除されたファイルのパス」が返ってしまうとgit archive時に対象無しのエラーになってしまうので「明確に出力対象としたいファイルのみ」を指定しました。
この指定方法以外に--diff-filter=dとすることで、削除されたファイルを「出力対象外」にすることも可能です。

git archiveコマンド
指定したコミット時点のGit管理対象ファイルを圧縮出力してくれるコマンドです。(ブランチ指定も可能ですが、Sourcetreeから引数として受け取れないので説明割愛します)
指定したコミットID時点のファイルが出力対象となりますので、当たり前ではありますが、必ず変更後のコミットIDを指定するようにしてください。
git archive <変更後コミットID>
例) git archive 1668413042b61f16f0f353fd66b53cd4c0dbc5d5

[コマンドオプション]
--format=<圧縮形式>   (任意)
例) --format=zip
出力される差分ファイルの圧縮形式として、zip形式又はtar形式が指定可能です。
このオプションが指定されていない場合は、ファイル名に付与された拡張子から推測され、推測ができない場合はtar形式となるようです。
今回はzip形式で出力したいため、念のため、フォーマットも指定しておきました。

-o <ファイル名> (必須)
※--output=<ファイル名>としても同じ
例) -o export_diff.zip
本オプションを指定しない場合は、標準出力(コンソール)に結果が出力されます。
結果をファイルに出力したい場合は、本オプションを付与した上で、 出力ファイル名称を指定する必要があります。

--worktree-attributes(任意)
Git管理対象となっているファイルでも、今回の出力対象として含めたくない!というファイルがある場合は、本オプションを使用します。 例えば、本番環境へのリリース対象としてReadMeや環境変数のサンプルファイルなどは不要で、これらを除外したい、と言う時に使います。

なお、除外対象は、リポジトリ直下にある.gitattributesファイルに記述することで認識されます。
以下のように対象ファイル export-ignoreと書き込めばOKです。
README.md export-ignore
*.example export-ignore
試してみた感じでは、今回の実装内容であれば、本ファイルをリモートリポジトリにマージしていなくても適用されるようでした。

--prefix=<プレフィックス文字列/> (任意)
例)--prefix=prod/
今回は使用しませんでしたが、出力されるファイルを特定のディレクトリでラップしたい場合に指定します。 例えば--prefix=prod/と入力しておくと、出力されたzipを解凍した際にprodディレクトリの配下に差分ファイルが格納されることになります。
試してみたところ--prefix=prod/app1/など、ディレクトリを多階層で指定することもできるようでした。

ザックリ説明はここまでとなりますが、各コマンドには他にもオプションがありますので、公式ドキュメントを参考にしながら試してみるのも面白いと思います。
git-diffコマンド (Documentation - Reference)
git-archiveコマンド (Documentation - Reference)

なお、スクリプトについては、私のやりたように書いていますが、自分仕様にカスタマイズいただければと思います♪(見やすさ重視でechoの乱れ咲きさせてたり...笑)

② カスタムアクションの登録

①で作ったスクリプトを実際にSourceTreeに登録します。
Sourcetreeを開き、左上の[Sourcetree]>[環境設定...]を押下し、設定画面を開きます。そのうえで、右上にある[>>]>[カスタムアクション]を選択してカスタムアクションの定義画面を開き、左下の[追加]ボタンを押下してください。

[注意事項]
Sourcetree日本語版では、不具合(確認したところ、4.1及び、4.2.0は駄目でした)により上記の流れで画面を開いた場合、カスタムアクションが非活性となっており選択できないようです!(笑)
選択できない場合は、対処法として2パターン有り、1つ目が言語を英語に切り替えてSourcetreeの再立ち上げを行う方法、2つ目がトップ画面で任意のリポジトリ右クリックメニューから[カスタムアクション]>[編集...]を選択する方法です。どちらかお好きな方をお試しください。

登録画面が開きますので、必要事項を入力し[OK]を押下して、登録を完了させます。

【登録内容】
 メニューキャプション:差分出力(zip) ※1
 別のウィンドウで開く:チェックオフorオン どちらでも可 ※2
 フル出力を表示:チェックオン ※3
 実行するスクリプト:①で作成したスクリプトを選択
 パラメータ:$REPO $SHA ※4
※1:メニューに表示される名称なので、お好きなものを指定してください。
※2:選択したファイルをVSCodeなどの別ウィンドウで表示したい場合は、このチェックをオンにします。なお、このチェックをオンにすることでダイアログも別ウィンドウとなるのでダイアログの拡大縮小が可能になります。
※3:チェックがオフの場合は、エラーのみがダイアログ表示されます。今回は全て表示したいのでチェックをオンにします。
※4:$SHAについては、複数コミットを選択した場合、コミットタイミングが新しいコミットIDから順に、選択した全てのコミットIDがスクリプトの引数として渡されます。(順番の選択順には依らないようです)

③ 動作確認

最後に動作確認を行います。
今回登録したカスタムアクションは、できるだけ汎用的に作っているので、どのリポジトリ、ブランチでも対応可能(な、はず)ですので、皆さんも動作確認してみてください。
実行方法は、「■作成したカスタムアクションの概要」と同様の流れになるため、説明は割愛しますが、コミットの選択方法について、少しだけ補足します。

[実行時の補足事項]
Sourcetreeのデフォルトでは、履歴に「すべてのブランチ」のコミットが表示されています。
この場合、親ブランチの差分を取得しようとした際に、間違って未マージの子ブランチのコミットを選択してしまっても、それらが差分出力対象となっているようでした。(これは--diff-filter=uとしても結果は変わりませんでした)

そのため、履歴の表示フィルターを使用して「現在のブランチ」のみの表示にしておくと、ミスがなくて良いのかなと思います。

ここまでで、カスタムアクションの実装は完了となります!
これでサクッと差分ファイルの抽出ができるようになったので、これからのリリース準備作業も捗りそうです♪
ただし、自動化しても、間違いがないかのダブルチェックは怠らず!!

■まとめ

Sourcetreeやgitには、沢山の便利機能やコマンドがあるのに活用できていないなぁ、と、改めて感じました。
特に今回使ったSourcetreeのカスタムアクションは、利用の幅が広く、もっと色々な応用がききそうだなと感じたため、継続して色々と試してみたいと思います。

laravel-analyticsを使用して、Universal Analyticsの値を取得する方法。

9月 30, 2022

オフィス狛 技術部のmmm(むー)です。

今回、LaravelからUniversal Analyticsの値を取得する必要があり、調査したため記事に残します。

※ちなみに、Universal Analyticsは2023年7月1日にサービスが終了されるので、時間に余裕があり移行できる場合、Google Analytics 4を使用しましょう。

前提条件

Universal Analyticsの設定が完了していること

■Laravel バージョン
$ php artisan -V
Laravel Framework 8.27.0

■Comporser バージョン
$ composer --version 
Composer version 2.4.1 2022-08-20 11:44:50


1. laravel-analyticsをインストールする

laravel-analyticsをインストールします。
# laravel-analytics(バージョン 4.0.1)をインストールする
$ composer require spatie/laravel-analytics 4.0.1

■補足
・ 2022/09/07現在、laravel-analyticsの最新バージョンは 4.1.0ですが、今回のプロジェクトのLaravelのバージョンが8で対応していなかったので、1つ下のバージョンの 4.0.1をインストールしています。
・Laravel 9を使用している場合、(試していませんが)最新バージョンを使用できるので、バージョン指定なしで、composer require spatie/laravel-analyticsを代わりに実行してください。

■エラーが起きた場合
・composerのバージョンが1でメモリエラーになる場合、composerを2に更新すると解決することがあるようです。
# comporserのバージョンを「1」から「2」に更新する
$ docker exec -it yanase-ybs_app_1 composer self-update --2

・また関連してインストールされるパッケージが多く、私の環境の場合、google/apiclient-servicesをインストールしている最中にタイムアウトしました。その場合、laravel-analyticsをインストールするコマンドをもう一度実行すると、google/apiclient-servicesだけ差分でインストールできます。
# laravel-analyticsをインストールした際のエラーログ
$ composer require spatie/laravel-analytics 4.0.1

〜省略〜

  - Installing symfony/cache (v5.4.11): Extracting archive
  - Installing spatie/laravel-package-tools (1.12.1): Extracting archive
  - Installing google/apiclient (v2.12.1): Extracting archive
  - Installing spatie/laravel-analytics (4.0.1): Extracting archive
 103/104 [===========================>]  99%    Install of google/apiclient-services failed

In Filesystem.php line 314:
                                                                                                                                    
  Could not delete /work/vendor/composer/e8a27dbc/googleapis-google-api-php-client-services-28c4208/src/SASPortalTesting/Resource:


2. laravel-analyticsを使用するための設定を行う

下記コマンドを実行すると、設定ファイル(config/analytics.php)が作成されます。
# 設定ファイル(config/analytics.php)を作成する
$ php artisan vendor:publish --provider="Spatie\Analytics\AnalyticsServiceProvider"

config/analytics.phpの中身を見ると下記の様になっています。
// config/analytics.php
return [

    /*
     * The view id of which you want to display data.
     */
    'view_id' => env('ANALYTICS_VIEW_ID'),

    /*
     * Path to the client secret json file. Take a look at the README of this package
     * to learn how to get this file. You can also pass the credentials as an array
     * instead of a file path.
     */
    'service_account_credentials_json' => storage_path('app/analytics/service-account-credentials.json'),

    /*
     * The amount of minutes the Google API responses will be cached.
     * If you set this to zero, the responses won't be cached at all.
     */
    'cache_lifetime_in_minutes' => 60 * 24,

    /*
     * Here you may configure the "store" that the underlying Google_Client will
     * use to store it's data.  You may also add extra parameters that will
     * be passed on setCacheConfig (see docs for google-api-php-client).
     *
     * Optional parameters: "lifetime", "prefix"
     */
    'cache' => [
        'store' => 'file',
    ],
];

まず、view_idを設定する必要があるので、.env に値をUniversal Analyticsの画面で取得した値を記載してください。 (下記は例となりますので、ご自身の環境に合わせて値を修正してください。)
// .env
ANALYTICS_VIEW_ID=1234567

次に、service_account_credentials_jsonを設定する必要があるので、app/analytics/フォルダの配下にservice-account-credentials.jsonファイルを作成して、Universal Analyticsの画面で取得した値を記載してください。 (下記は例となりますので、ご自身の環境に合わせて値を修正してください。)
// app/analytics/service-account-credentials.json
{
  "type": "service_account",
  "project_id": "testapi",
  "private_key_id": "...",
  "private_key": "...",
  "client_email": "...",
  "client_id": "1234",
  "auth_uri": "...",
  "token_uri": "...",
  "auth_provider_x509_cert_url": "...",
  "client_x509_cert_url": "..."
}


3. Universal Analyticsの値を取得する

下記に、一例ですが値を取得する方法を記載します。
namespace App\Console\Commands;

use Illuminate\Console\Command;
use Analytics;
use Spatie\Analytics\Period;

class TestCommand extends Command
{

	// 例1:
	// 1日分の訪問者数とページビューを取得します。
	// こちらはlaravel-analyticsが用意しているメソッドとなります。
	$data1 = Analytics::fetchVisitorsAndPageViews(Period::days(1));

	// 例2:
	// 期間を指定して、7日分の訪問者数とページビューを取得します。
	// ちなみにライブラリ内で日付に変換されるので、時間は設定できません。
	$periodFrom = Carbon::now()->subDay(7);
	$periodTo = Carbon::now();
	$period = Period::create($periodFrom, $periodTo);
	$data2 = Analytics::fetchVisitorsAndPageViews($period);

	// 例3:
	// 取得する指標、取得項目、ソート順をカスタムしたい場合は、performQuery()を使用してください。
	// 指標(合計イベント数)
	$metrics = 'ga:totalEvents';
	// 取得項目(イベントラベル)、ソート(合計イベント数の降順)
	$item = [
            'dimensions' => 'ga:eventLabel',
            'sort' => '-ga:totalEvents',
	];
	$data3 = Analytics::performQuery(Period::days(3), $metrics, $item);

}

fetchVisitorsAndPageViews()以外のメソッドについては、公式ページを確認してみてください。
公式が用意しているメソッドで、項目が取得できない場合はperformQuery()メソッドを使用してください。
指定する項目は、下記から必要なものを指定してください。
■参考サイト:https://ga-dev-tools.web.app/dimensions-metrics-explorer/

以上となります。
参考にして頂ければ幸いです。

2022年8月8日月曜日

【Angular】独自エラーチェック(カスタムバリデーション)を作成する。

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

今回は、独自エラーチェック(カスタムバリデーション)を作成します。
プログラムは、以前の記事で使用した「アカウント登録機能」を使用したいと思いますので、下記の記事も参照ください。

参照1:【Angular】コンポーネントの設計(画面ごとの設計)について。
参照2:【Angular】エラーメッセージの管理について考える。

(1)登録画面の実装を確認

まずは、現在の登録画面のコンポーネントを見てみます。
コード量が多いので、全体はこちら(register.component.ts)で確認ください
今回カスタムバリデーションを追加するのは、こちらの携帯番号(mobilePhoneNumber)部分です。
  mobilePhoneNumberMaxLength = 11;
  nameMaxLength = 20;
  // (中略)
  formRegister: FormGroup = this.formBuilder.group({
    mobilePhoneNumber: ['',[Validators.required,Validators.maxLength(this.mobilePhoneNumberMaxLength)]],
    name: ['', [Validators.maxLength(this.nameMaxLength)]],
  });

しかし、これだけだと、「18011112222」のような「0」始まりではない番号はエラーになりません。
このチェックを独自で作成しようと思いますが、
その前に、一応テンプレート側の記載も見ておこうと思います。

こちらもコード量が多いので、全体はこちら(register.component.html)で確認ください
下記に、今回関係する部分だけ記載しておきます。
    <div>
      <label>携帯電話番号<span>必須</span></label>
      <input formControlName="mobilePhoneNumber" type="tel" inputmode="numeric" class="form-control" placeholder="携帯電話番号を入力"
        [ngClass]="{'alert-danger' : v.mobilePhoneNumberInvalid}" autofocus>
      <ng-container *ngIf="v.mobilePhoneNumberInvalid">
        <p *ngIf="v.mobilePhoneNumberHasErrorRequired" class="error-message">{{ message('msg_error_field_required', '携帯電話番号') }}</p>
        <p *ngIf="v.mobilePhoneNumberHasErrorMaxLength" class="error-message">{{ message('msg_error_field_max', '携帯電話番号', mobilePhoneNumberMaxLength) }}</p>
      </ng-container>
    </div>

(2)カスタムバリデーションを作成する

弊社のプロジェクトでは、「app」配下に「shared」と言うディレクトリを作り、その中にプロジェクトで共通的に使うものを配置しています。
今回のカスタムバリデーションも、共通的に使われるものなので、、以下のように作成します。
src/
└ app/
    └ shared/
        └ validator/
            └ custom-validators.ts

では、携帯電話番号のチェックを実装しようと思います。
[custom-validators.ts]
import { FormControl } from '@angular/forms';

export class CustomValidators {
  /**
   * 携帯電話番号かどうか判定
   * @param control Formのコントロール
   */
  static mobilePhoneNumberValidator(control: FormControl) {
    const dateObj = control.value;

    // 当メソッドでは、電話番号は空文字で登録することも許可する
    if (dateObj === '') {
      return null;
    }

    const regexp = new RegExp('^(0{1}\\d{10})');
    if (
      typeof dateObj === 'undefined' ||
      dateObj === null ||
      !regexp.test(dateObj)
    ) {
      return { mobilePhoneNumberFormat: true };
    }

    return null;
  }
}

当メソッドでは、電話番号は空文字で登録することも許可する」と言うコメントの部分が特殊なのですが、例えば、「任意の項目」でこのバリデーションを使いたくなった場合、そのまま使うと「未入力」でもエラーになってしまうので、未入力はエラーとしないようにしています。
どっちにしても、必須かどうかは、通常のバリデーションで行なっているので、そちらに任せる、と言う感じです。
それと、「携帯電話番号かどうか」はかなり適当に記載していますので、ご了承ください(今回は、その説明が本質では無いので)

ちょっと分かり難い(勘違いしやすい)のですが、「エラーになるパターンは『true』を返却し、エラーとしない場合『null』を返却しています」

(3)作成したカスタムバリデーションを使う

では、作成したカスタムバリデーションを実際に使ってみます。
「register.component.ts」に実装していきますが、まずは先ほどのカスタムバリデーションをimportします。
import { CustomValidators } from '@app/shared/validator/custom-validators';

importしたカスタムバリデーションは、通常のバリデーションと同じ流れで定義します。
  formRegister: FormGroup = this.formBuilder.group({
    mobilePhoneNumber: [
      '',
      [
        Validators.required, // ←通常のバリデーション
        Validators.maxLength(this.mobilePhoneNumberMaxLength), // ←通常のバリデーション
        CustomValidators.mobilePhoneNumberValidator, // ←カスタムバリデーション
      ],
    ],
    name: ['', [Validators.maxLength(this.nameMaxLength)]],
  });

とても簡単ですね、次は、別ファイルにしている「register.validator.ts」にも追記します。
こちらは、全文そのまま記載しようと思います。
[register.component.html]
import { Injectable } from '@angular/core';
import { FormGroup } from '@angular/forms';

@Injectable()
export class RegisterValidator {
  private form: FormGroup;

  constructor() {}

  set formGroup(form: FormGroup) {
    this.form = form;
  }

  get mobilePhoneNumberInvalid() {
    return (
      this.form.controls['mobilePhoneNumber'].invalid &&
      (this.form.controls['mobilePhoneNumber'].dirty ||
        this.form.controls['mobilePhoneNumber'].touched)
    );
  }
  get mobilePhoneNumberHasErrorRequired() {
    return this.form.controls['mobilePhoneNumber'].hasError('required');
  }
  get mobilePhoneNumberHasErrorMaxLength() {
    return (
      !this.form.controls['mobilePhoneNumber'].hasError('required') &&
      this.form.controls['mobilePhoneNumber'].hasError('maxlength')
    );
  }
  get mobilePhoneNumberHasErrorFormat() {
    return (
      !this.form.controls['mobilePhoneNumber'].hasError('required') &&
      this.form.controls['mobilePhoneNumber'].hasError(
        'mobilePhoneNumberFormat',
      )
    );
  }

  get nameInvalid() {
    return (
      this.form.controls['name'].invalid &&
      (this.form.controls['name'].dirty || this.form.controls['name'].touched)
    );
  }
  get nameHasErrorMaxLength() {
    return this.form.controls['name'].hasError('maxlength');
  }
}

追記した部分は以下の通りです。
  get mobilePhoneNumberHasErrorFormat() {
    return (
      !this.form.controls['mobilePhoneNumber'].hasError('required') &&
      this.form.controls['mobilePhoneNumber'].hasError(
        'mobilePhoneNumberFormat',
      )
    );
  }
この「mobilePhoneNumberHasErrorFormat」は、テンプレート側で使うことになります。
では、そのテンプレート側も変更しようと思います。

    <div>
      <label>携帯電話番号<span>必須</span></label>
      <input formControlName="mobilePhoneNumber" type="tel" inputmode="numeric" class="form-control" placeholder="携帯電話番号を入力"
        [ngClass]="{'alert-danger' : v.mobilePhoneNumberInvalid}" autofocus>
      <ng-container *ngIf="v.mobilePhoneNumberInvalid">
        <p *ngIf="v.mobilePhoneNumberHasErrorRequired" class="error-message">{{ message('msg_error_field_required', '携帯電話番号') }}</p>
        <p *ngIf="v.mobilePhoneNumberHasErrorMaxLength" class="error-message">{{ message('msg_error_field_max', '携帯電話番号', mobilePhoneNumberMaxLength) }}</p>
        <p *ngIf="v.mobilePhoneNumberHasErrorFormat" class="error-message">{{ message('msg_error_cellphone_number') }}</p>
      </ng-container>
    </div>

下記が追加した部分です。今回、新たにエラーメッセージも追加しています。
(エラーメッセージの管理については、「【Angular】エラーメッセージの管理について考える。」を参照ください)
<p *ngIf="v.mobilePhoneNumberHasErrorFormat" class="error-message">{{ message('msg_error_cellphone_number') }}</p>

これで、独自エラーチェック(カスタムバリデーション)を実装できました。
ある程度規模の大きいプロジェクトになると、結構な数のカスタムバリデーションを作る事になるかと思いますので、参考にして頂ければ幸いです。


2022年8月5日金曜日

【Angular】コンポーネントの設計(画面ごとの設計)について。

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

前回、「機能内の画面構成(コンポーネント構成)設計」について記載したので、今回は、「画面ごとのコンポーネント設計」を記載しようと思います。

それでは、前回の続きとして、『アカウント登録処理』から「登録情報入力画面」について、コンポーネントの設計を説明しようと思います。

(1)前回のおさらい

「アカウント登録処理」として、以下のような画面が必要と想定されます。

①登録情報入力画面
②入力情報確認画面
③登録完了画面

画面遷移としては以下になります。

そして、ディレクトリ構成は以下となります。(一部省略)
src/
└ app/
    └ account/
        └ containers/
            ├ account/
            ├ account-register
            │  └ account-register.component.html
            │  └ account-register.component.spec.ts
            │  └ account-register.component.ts
            ├ account-register-confirm
            │  └ ・・・
            └ account-register-complete
                 └ ・・・

図で表すと、以下のようになります。

(2)画面単位でのディレクトリ構成を考える

画面単位で考えた時、「登録情報入力画面」にはどんな機能が含まれるでしょうか?

  • 画面の表示
  • 入力項目のチェック(バリデーション、業務的な関連チェックなど)
  • 入力項目を次の画面へ持ち越す為の準備
  • 確認画面から戻って来た場合は、入力した値を再現

などなど、色々ありますね。
もっと複雑な機能であれば、「遷移先を入力値によって分岐させる」や、「APIを呼び出し、入力用の補足情報を取得する」・・・・なんて事もあるかもしれません。

このような多種多様な処理を以下の「containers」配下のコンポーネントだけでやるとしたら、コンポーネントが肥大化してしまうと思います。
src/
└ app/
    └ account/
        └ containers/
            └ account-register
                  └ account-register.component.ts ← 肥大化する

そこで、「画面の表示」「入力項目のチェック」など、View(ビュー)側に属する処理については、「presentations」ディレクトリに分けることにします。

と言うことで「presentations」ディレクトリとファイルを追加します。
src/
└ app/
    └ account/
        └ containers/
        └ presentations/
              └ register/
                   └ register.component.html
                   └ register.component.spec.ts
                   └ register.component.ts
                   └ register.validator.ts

※「 register.validator.ts」について
弊社のプロジェクトでは、Validationは出来る限りViewとコンポーネントからは切り離して別ファイルで管理するようにしています。(Validationについては、以前の記事「【Angular】FormのValidationの書き方を簡略化させる。」を参照ください。)

(3)「Container」と「Presentation」の切り分け方

「Container」と「Presentation」の切り分けですが、弊社では以下のように定義付けしています。

「Container」として分類するもの
  • 画画面の状態保持に関すること(NgRxのStore操作など)
  • 画面に入力した値の業務チェック(API呼び出しが必要なもの、など)
  • API(バックエンド処理)の呼び出し
  • 画面遷移

「Presentation」として分類するもの
  • 画面表示、及び表示内容の制御(エラー時など)
  • 画面に入力した値のバリデーションチェック
  • 画面に入力した値の関連チェック、及び業務チェック

「関連チェック」というのは、例えば「AとBが選択されているときは、Cは必須となる」とか、です。
両方に含まれている「業務チェック」が、判断難しいところかな、と思います。
クライアント内で完結出来るチェックであれば「Presentation」。完結出来ないものは「Container」、という考えで良いと思います。

アカウント登録で言うと、「入力されたメールアドレスが既に使用されているかどうか」のチェックは、クライアント内では完結出来ず、バックエンドの処理(API等)を呼び出す必要があると思いますので、「Container」側に記載する必要があります。

さて、今の状態を図で表すと、以下のようになります。(登録情報入力画面のみ)

(4)「Container」と「Presentation」間のデータ・処理受け渡し方法を考える

ここで、分かりやすいように、「Container」を、「Presentation」をとして、話を進めようと思います。
親と子の間では、データ・処理のやり取りが必要になります。

コンポネート間のデータ・処理のやり取りについては、いくつか方法がありますが、
今回は、「@Input()、@Output()」を使います。

参考サイト(本家):ディレクティブとコンポーネントの親子間でのデータ共有

(5)親子間でやり取りが必要なデータ・処理を考える

方式が決まったところで、親子間で、どんなデータ・処理のやり取りが必要か考えます。
登録情報入力画面の内部処理は、以下の流れになると思います。

1. 画面初期表示
 ※確認画面から戻ってきた場合は、画面の入力値を復元
  →親から子へ、復元用のデータを送る(@Input())
2. 画面の各フィールドへの値入力(バリデーション)
3. Submitによって、入力値を引き継ぎつつ、確認画面へ遷移
 →入力値を引数に、子から親へ処理を引き継ぐ(@Output())

上記を実現したいのですが、ここで、
  • 親子間でのデータ
  • 次画面(確認画面)に送るデータ
両方を兼ね備えたデータモデルを作成しようと思います。
src/
└ app/
    └ account/
        └ containers/
        └ presentations/
        └ models/
              └ account.ts

中身は下記のようにします。
// アカウント登録用画面間保持データモデル
export class AccountViewSaveModel {
  mobilePhoneNumber: string;
  name: string;
}
とりあえず、電話番号と名前を項目として用意しました。この辺はあくまで例なので適当です。

続いて、親と子のコンポーネントの中身を実装します。

(6)親子間のデータ・処理のやり取りを実装する

まずは親コンポーネントの実装です。
[account-register.component.ts]
import { Component, OnInit } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Router } from '@angular/router';
import { AccountViewSaveModel } from '../../models/account';
import * as fromAccount from '../../store/reducers';
import * as AccountActions from '../../store/actions/account.actions';

@Component({
  selector: 'koma-account-register',
  templateUrl: './account-register.component.html',
})
export class AccountRegisterComponent implements OnInit {

  // Storeから入力情報を取得する(確認画面から戻ってきた場合には値が入っている)
  // 取得値の型はObservableになるので、変数の末尾に「$」を付ける(お約束みたいなもの)
  accountViewSave$ = this.store.pipe(select(fromAccount.getDataRegisterPost));

  constructor(public store: Store<fromAccount.State>, private router: Router) {}

  ngOnInit(): void {}

  onSubmit(formModel: AccountViewSaveModel): void {
    // 入力情報をStoreに格納して(=引き継ぎ情報として)、遷移後の画面でも使えるようにする。
    this.store.dispatch(
      AccountActions.setRegisterPostData({ data: formModel }),
    );
    // 確認画面へ遷移する
    this.router.navigateByUrl('/account/register-confirm');
  }
}

唐突に「NgRxのStore」が出てきましたが、ここは一旦スルーしてください。(いずれ、NgRxの実装方法も記事にしようと思います。)
ここでは、画面で入力された値はStore(と言う場所)に格納されている、ぐらいの理解でOKです。
(今回重要なのは、「データ保存方式」ではなく、あくまで「データ受け渡し部分」なので)

細かい説明も実装内のコメントとして記載しましたので、参考にお願いします。

続いて、子コンポーネントの実装です。
[register.component.ts]
import { Component, OnInit, Input, Output, OnDestroy, EventEmitter } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Observable, Subscription } from 'rxjs';
import { RegisterValidator } from './register.validator';
import { AccountViewSaveModel } from '../../models/account';
import { getMessage } from '@app/shared/message/error-messages';

@Component({
  selector: 'koma-register',
  templateUrl: './register.component.html',
})
export class RegisterComponent implements OnInit, OnDestroy {
  // Input・Outputの定義。
  @Input() accountViewSave$: Observable<AccountViewSaveModel>;
  @Output() formSubmit = new EventEmitter<AccountViewSaveModel>();

  mobilePhoneNumberMaxLength = 11;
  nameMaxLength = 20;

  registerSubscription: Subscription = new Subscription();

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

  constructor(private formBuilder: FormBuilder, public v: RegisterValidator) {}

  ngOnInit(): void {
    // バリデーション設定
    this.v.formGroup = this.formRegister;

    this.registerSubscription.add(
      // 画面初期値設定
      this.accountViewSave$.subscribe(value => {
        if (value) {
          this.formRegister.controls.mobilePhoneNumber.setValue(
            value.mobilePhoneNumber,
          );
          this.formRegister.controls.name.setValue(value.name);
        }
      }),
    );
  }

  ngOnDestroy(): void {
    // サブスクリプション解除
    this.registerSubscription.unsubscribe();
  }

  // 画面でSubmitが発生した時の処理
  onSubmit(): void {
    if (this.formRegister.valid) {
      // バリデーションエラーが発生していない場合
      const formModel = {
        mobilePhoneNumber: this.formRegister.controls.mobilePhoneNumber.value,
        name: this.formRegister.controls.name.value,
      } as AccountViewSaveModel;

      this.formSubmit.emit(formModel);
    }
  }

  message(messageId: string, ...args: any[]): string {
    return getMessage(messageId, ...args);
  }
}
ちょっと記載量が多いのですが、あくまで「親子間のデータやり取り」部分についてのみ説明します。
その他、FormBuilder, FormGroup, Validatorsあたりは、以前の記事などを参照ください。

参照:FormのValidationの書き方を簡略化させる

まずはInput・Outputの定義です。
  @Input() accountViewSave$: Observable<AccountViewSaveModel>;
  @Output() formSubmit = new EventEmitter<AccountViewSaveModel>();
Inputは、親からデータが来る為の受け口になるので、分かりやすく同じ変数名にします。
また、型も同じ「Observable」にします。
Outputについては、「処理を引き継ぐ」為のお決まりの「EventEmitter」を使います。

続いて、ngOnInitで、親からのデータをFormに反映する処理をSubscribeします。
      // 画面初期値設定
      this.accountViewSave$.subscribe(value => {
        if (value) {
          this.formRegister.controls.mobilePhoneNumber.setValue(
            value.mobilePhoneNumber,
          );
          this.formRegister.controls.name.setValue(value.name);
        }
      }),

Subscribeする事で、親からのデータが来れば、常にアクティブに画面表示が更新されることになります。

最後が、画面でSubmitが発生した場合です。
  // 画面でSubmitが発生した時の処理
  onSubmit(): void {
    if (this.formRegister.valid) {
      // バリデーションエラーが発生していない場合
      const formModel = {
        mobilePhoneNumber: this.formRegister.controls.mobilePhoneNumber.value,
        name: this.formRegister.controls.name.value,
      } as AccountViewSaveModel;

      this.formSubmit.emit(formModel);
    }
  }

バリデーションエラーが発生していない場合、画面で入力された値を Model にセットし、その Model を引数に Emit します。 Emitする事で、親側に処理を引数ごと引き渡すことになります。

続いて、親のテンプレート(View)を実装します。
[account-register.component.html]
<koma-register
  [accountViewSave$]="accountViewSave$"
  (formSubmit)="onSubmit($event)"
></koma-register>
左辺が子の変数(処理)、右辺が親の変数(処理)となります。
ここで、親子を結び付けています。

最後に、子のテンプレート(View)を実装します。
[register.component.html](form部分のみ抜粋)
<form [formGroup]="formRegister" (ngSubmit)="onSubmit()" (keydown.enter)="$event.preventDefault()">
  <div class="form-input">
    <div>
      <label>携帯電話番号<span>必須</span></label>
      <input formControlName="mobilePhoneNumber" type="tel" inputmode="numeric" class="form-control" placeholder="携帯電話番号を入力"
        [ngClass]="{'alert-danger' : v.mobilePhoneNumberInvalid}" autofocus>
      <ng-container *ngIf="v.mobilePhoneNumberInvalid">
        <p *ngIf="v.mobilePhoneNumberHasErrorRequired" class="error-message">{{ message('msg_error_field_required', '携帯電話番号') }}</p>
        <p *ngIf="v.mobilePhoneNumberHasErrorMaxLength" class="error-message">{{ message('msg_error_field_max', '携帯電話番号', mobilePhoneNumberMaxLength) }}</p>
      </ng-container>
    </div>
    <div>
      <label>お名前</label>
      <input formControlName="name" type="text" class="form-control" placeholder="お名前を入力(任意)"
        [ngClass]="{'alert-danger' : v.nameInvalid}">
      <ng-container *ngIf="v.nameInvalid">
        <p class="error-message">{{ message('msg_error_field_max', 'お名前', nameMaxLength)}}</p>
      </ng-container>
    </div>
  </div>
  <footer>
    <button [disabled]="formRegister.invalid" type="submit">確認</button>
  </footer>
</form>
こちらに関しては、特に「親子間のデータやり取り」について意識している部分は無いですね。

これで、

1. 画面初期表示
 ※確認画面から戻ってきた場合は、画面の入力値を復元
  →親から子へ、復元用のデータを送る(@Input())
2. 画面の各フィールドへの値入力(バリデーション)
3. Submitによって、入力値を引き継ぎつつ、確認画面へ遷移
 →入力値を引数に、子から親へ処理を引き継ぐ(@Output())

が実現出来たことになります。(長かった・・・・・)
最終的に図で表すと下記のようになります。

今回は、コンポーネント設計(と実装)を紹介しました。
もっと複雑な画面になると、設計方法や実装も違ってきますので、それはまた別途記事にしようと思います。


2022年8月4日木曜日

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

8月 04, 2022
オフィス狛 技術部の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を渡してきます。
画面のレイアウトに変更があった場合など、プログラマーが気を付ける必要がある、と言う事が、デメリットと言えばデメリットなのかもしれません。


2022年8月3日水曜日

【Angular】コンポーネントの設計(機能内の画面構成設計)について。

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

今回は、弊社内のAngularプロジェクトでのコンポーネントの設計のルールを記載しようと思います。

2記事に分けて「コンポーネントの設計」についてお届けしますが、今回は「機能内の画面構成設計」となります。
(次回は「画面ごとの設計」になります)

それでは、今回は、『アカウント登録処理』を例に、コンポーネントの設計を説明しようと思います。

(1)まずは必要な画面と、画面遷移を考える

「アカウント登録処理」として、以下のような画面が必要と想定されます。

①登録情報入力画面
②入力情報確認画面
③登録完了画面

当然、画面遷移としては以下になりますね。

(2)機能としてのディレクトリ構成を考える

では、次は「アカウント登録処理」として、ディレクトリ構成を考えます。
各画面の設計の前に、「機能」としての設計をする、って事ですね。

まず、何も機能が無いAngularプロジェクトだと、
src/
├ app/
├ app-routing.module.ts
├ app.component.html
├ app.component.ts
└ app.module.ts
となっています。

ここに「アカウント登録処理」を追加するとしたら、以下のようになります。
src/
└ app/
    └ account/
        ├ containers/
        │  └ account/
        │      └ account.component.html
        │      └ account.component.spec.ts
        │      └ account.component.ts
        ├ account-routing.module.ts
        └ account.module.ts
いきなり「containers」ディレクトリが出てきましたが、後ほど説明しますので、一旦スルーしてください。
ここでは、「account」と言う「アカウント登録処理」を示すコンポーネントを作った、と言うことになります。
図で表すと、
となります。これだけだと、「??」って感じですが、とりあえず先へ進みます。

(3)各画面のディレクトリ構成を考える

続いて、各画面のディレクトリ構成を考えてみます。
画面が3つだから、ディレクトリも3つで、コンポーネントとしても3つ・・・はい、最初はその考えで大丈夫です。
Angularに限らず、コンポーネント設計で重要なのは、まずは、ざっと大きめな区分けで考えておいて、後から細分化していく、です。

と言うことで、以下のような構成になります。
src/
└ app/
    └ account/
        └ containers/
            ├ account/
            ├ account-register/
            │  └ account-register.component.html
            │  └ account-register.component.spec.ts
            │  └ account-register.component.ts
            ├ account-register-confirm/
            │  └ account-register-confirm.component.html
            │  └ account-register-confirm.component.spec.ts
            │  └ account-register-confirm.component.ts
            └ account-register-complete/
                 └ account-register-complete.component.html
                 └ account-register-complete.component.spec.ts
                 └ account-register-complete.component.ts

図で表すと、
こんな感じですね。
なんとなくイメージが出来るようになって来ました。

ついでなので、ルーティングの設定も記載しておきます。
[app-routing.module.ts]
  {
    path: 'account',
    loadChildren: () =>
      import('./account/account.module').then(m => m.AccountModule),
  },

[account-routing.module.ts]
const routes: Routes = [
  {
    path: '',
    component: AccountComponent,
    children: [
      { path: '', redirectTo: 'register' },
      {
        path: 'register',
        component: AccountRegisterComponent,
      },
      {
        path: 'register-confirm',
        component: AccountRegisterConfirmComponent,
      },
      {
        path: 'register-complete',
        component: AccountRegisterCompleteComponent,
      },
    ],
  },
];

上記を元に、先ほどの図にURLを記載すると、以下のようになります。

今回はここまで。

次回は、さらに細分化して「画面ごとのコンポーネント設計」を見て行こうと思います。