狛ログ

2023年7月25日火曜日

iPhone(iOS16以降の端末)のブラウザでのinput[type="time"]のレイアウトをCSSで修正する。

7月 25, 2023

 


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



先日、モバイルで利用するWebシステムのHTMLを作成した際に、時間を入力する必要があるフォームが必要になりました。
そこで、input[type="time"]を使いました。

HTMLの作成後に、「iPhone端末で表示すると、時間入力欄(input[type="time"])の入力後の文字が上揃えになっているので、他のに入力欄と同じ縦中央揃えに修正してほしい」という報告をいただきました。
指摘いただいた箇所を私の方でもiPhone端末で確認すると、時間入力欄(input[type="time"])だけで入力後の文字の行揃えも中央揃えになっていて、他の入力欄に合わせる為に文字揃えも修正が必要になってきました。


そこで時間を入力するinputにclassをつけ、そのclassで余白をつけ行揃えを左に修正しました。
実機で修正ができていることを確認!これで解決!のはずが、
「iOS16以降の端末のブラウザでは不具合が修正されていません!」という報告をいただきとても困りました😢

私が修正確認に利用した端末のOSバージョンはiOS15でしたので、どうやらiOS16以降iPhone端末のブラウザではinputにclassをつけるだけでは修正が良い感じに反映されていないようです😢💦
非常にジミ〜な不具合ですが、解決に時間が掛かった為解決法を共有いたします。



修正には、「::-webkit-date-and-time-value」という疑似要素を使います。
これをinput[type="time"]につけてあげると、iOSで修正適用されなかった修正がうまく適用されます。
今回の場合は、下記のような記載になります👇

input[type="time"]::-webkit-date-and-time-value {
    padding-top: 10px;
    text-align: left !important;
}

「::-webkit-date-and-time-value」の擬似要素を利用する方法は、input[type="date"]にCSSが上手く反映されない場合でも使えるようです。
参考:https://developer.apple.com/forums/thread/691305
iOSのSafariだけ日付の入力欄のデザインが他のinputや他ブラウザと違って困った際は、是非お試しください!



デモを用意しましたので、良ければこちらもiOS16以降のiPhone端末で表示して確認してみてください👇

See the Pen iOS端末でのtype="time"の文字のレイアウトを他のinputと同じにする by sato (@officekoma_sato) on CodePen.

※端末固有の不具合のため、デベロッパーツールなどの開発ツールのモバイルモードで表示した場合は差異がわからないかと思います。
iOS16以降のiPhone端末のブラウザでお試しください!

【Laravel】GA4を使用して、Google Analyticsの値を取得する方法。

7月 25, 2023

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

突然ですが、Google Analytics を使用していますか?
弊社では、Laravel のプロジェクトにて、Google Analytics で取得した値を集計する処理があります。先日、Google Analytics の古いバージョンであるUA(Universal Analytics)から、GA4(Google Analytics 4)へ移行しましたので、その時の備忘録を残しておこうと思います。

※UA(Universal Analytics)は2023年7月1日にサービスが終了しました。
(参考:ユニバーサル アナリティクスについて

前提条件

Google Analytics 4 の設定が完了していること。

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

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

google/analytics-data のインストール

google/analytics-data をインストールします。
composer require google/analytics-data

Google Analytics API を実行するためのコードを記載します。
use Google\Analytics\Data\V1beta\BetaAnalyticsDataClient;
use Google\Analytics\Data\V1beta\DateRange;
use Google\Analytics\Data\V1beta\Dimension;
use Google\Analytics\Data\V1beta\Metric;
use Google\Analytics\Data\V1beta\OrderBy;
use Google\Analytics\Data\V1beta\OrderBy\MetricOrderBy;


// GAコンソール画面にてサービスアカウントの認証情報を確認し、取得してください。
// ファイル名と格納場所は一例となりますので、ご自身の環境に合わせて値を修正してください。
$jsonPath = storage_path('app/analytics/service-account-credentials.json');

// 認証用のライブラリで getenv が使用されているため、putenv の使用が必須のようです。
putenv("GOOGLE_APPLICATION_CREDENTIALS=".$jsonPath);

// GAのコンソール画面にてプロパティIDの値を確認し、ご自身の環境に合わせて値を修正してください。
$propertyId = 12345;

$client = new BetaAnalyticsDataClient();

// Google Analytics API を実行します。(GA4)
$response = $client->runReport([
    'property' => "properties/{$propertyId}",
    // 取得期間
    'dateRanges' => [
            new DateRange([
                'start_date' => '2023-07-01',
                'end_date' => '2023-07-02',
            ])
    ],
    // ディメンション
    'dimensions' => [new Dimension(['name' => 'pagePath'])],
    // 指標
    'metrics' => [new Metric(['name' => 'screenPageViews'])],
    // ソート
    'orderBys' => [
        new OrderBy([
            'metric' => new MetricOrderBy(
                [
                    'metric_name' => 'screenPageViews'
                ]
            ),
            'desc' => true
        ])
    ],
]);

// 取得した値を処理します。例となりますので、具体的な処理については省略します。
foreach ($response->getRows() as $row) {
    // ディメンションの値
    foreach ($row->getDimensionValues() as $v) {
        $pagePath = $v->getValue();
    }

    // 指標の値
    foreach ($row->getMetricValues() as $v) {
        $screenPageViews = $v->getValue();
    }
}

■補足(コードについて)
service-account-credentials.json には以下のような値の設定が必要となります。ご自身の環境に合わせて値を設定してください。
// 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": "..."
}

ディメンションと指標は、公式ページを参考にご自身が必要なものを設定してください。上記の例では、各ページごとのビューを取得しています。
(参考:API のディメンションと指標

■補足(エラーが発生した場合)
この修正をおこなっている時に遭遇したエラーについて、記載します。

1. Google Analytics Data API が無効になっているエラー
下記エラーが表示されている場合、Google Analytics Data API を使用したことがなく、無効になっていることが原因ですので、メッセージに記載されている URL にアクセスして API を有効にしてください。 (「..12345..」の部分は本来プロジェクトのIDとなります)
Google Analytics Data API has not been used in project ..12345.. before or it is disabled.
Enable it by visiting https://console.developers.google.com/apis/api/analyticsdata.googleapis.com/overview?project=..12345.. then retry.
If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.

2. bcmath がインストールされていないエラー
下記エラーが表示されている場合、bcmath が入っていないことが原因ですので、サーバーにてインストールを行なってください。(Apache を使用している場合、リロードも行なってください。)
Call to undefined function Google\Protobuf\Internal\bccomp()

■参考サイト
Google Analytics Data for PHP
google-cloud-php
Analytics Data API の概要

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

2023年2月27日月曜日

2023年オフィス狛の年賀状の制作秘話。

2月 27, 2023


出遅れたオフィス狛 デザイン部のSatoです💦
毎年恒例(?)の年賀状の記事を出さないまま2月になってしまいました!
言い訳になってしまいますが、オフィス狛社員全員私含め2023年1月から2月にかけ色々とてんてこまいでした😭💦
遅れてしまいましたが、2023年もどうぞよろしくお願いいたします!

2月も終わりそうですが、デザイン部の年明け初めての記事ですので去年同様、今年の年賀状のおはなしをしようと思います。
↓去年の年賀状の記事はこちら


2023年の年賀状はこちら!

例年通り弊社サイトやSNSなどのアイコンなどで登場しているこまちゃんとここまちゃんのイラストです。
2023年の干支は卯(うさぎ)!ウサギといえば月でお餅つきをしているウサギ!
……ということでこまちゃんにはウサギさんとお餅つきにチャレンジしてもらいました!
小柄なここまちゃんに餅つきはちょっと危険かな?杵で潰れちゃいそう?と思ったので、添えるだけになりました。



そして、今年の年賀状のラフです。

個人的には②の鏡餅とウサギ耳こまちゃん&ここまちゃんが結構気に入っています。
ここまちゃんの( ´ ω ` )←顔が良いと思ったのですが、ウサギ分がたりなかったのかもですね。
③は宇宙猫のパロディです。背景はフリー素材の宇宙の写真です


そして、①を清書したものが今年の年賀状はになります

ラフの時点で丸い窓状にイラストを切り抜きすることは決めていたのですが、実際に切り抜きしてみると寂しく感じたので、どうにか装飾がつけられないかな?と考えてみて、お正月飾りとしてポピュラーな「しめ飾り」を入れてみました。
実は私、このお正月飾りが「しめ飾り」という名前だということを、今回年賀状イラストを描く際に初めて知りました!


もっと「少しでも重心がずれたらこまちゃんが転びそう!」と見る人もハラハラするような躍動感のあるイラストにしたかったのですが、イメージよりも50%躍動感オフされたようなイラストになってしまいました💦
今後は動きのある絵を練習せねばと思いました!

そのためには遠近含めたパース、構図練り、デッサン…etc色々練習が必要そうです!

2022年11月1日火曜日

「iOSのSafariでheight:100vhが上手くいかない!」の問題をCSSで解決する。

11月 01, 2022

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


私がモバイル用サイトをコーディングする際、諸事情によりbodyタグにclassをつけられないことが多いです。

ですので、divタグで全体的な内容を囲んで背景色を設定していたりします。


そうなると、背景色をページごとに設定が必要サイトでSafariなどのiOSのブラウザで要素が少ないページを表示した場合に、下に余白ができたり余白をなくすためにheight:100vhを設定すると不要なスクロールができてしまうことが多々ありました。

不要なスクロールをなくすにはJSを書く必要がありましたが、CSSの新要素で解決できるようになったので、ご紹介します。




CSSの新要素、それは新しく追加された単位であるsvh・lvh・dvhです。

これら3つとも、ビューポートを基準にした単位ですがモバイルサイトをコーディングする際に困っていた部分を解決できる仕様になっています。


★svhはスモールビューポートです。

表示領域の高さが最小の際のサイズが基準になります。

height:100svhを設定すると、表示領域の高さが最小の時(アドレスバーが表示されている時)の高さになります。


★lvhはラージビューポートです。

表示領域の高さが最大の際のサイズが基準になります。

height:100lvhを設定すると、表示領域の高さが最大の時(アドレスバーの表示が小さい時)の高さになります。


★dvhはダイレクトビューポートです。

ブラウザの表示領域の動きに対し、動的に対応してくれます。この単位は今までの悩みを解決してくれるすごい単位です!

height:100dvhを設定すると、ブラウザの表示項目の表示・非表示に合わせサイズをフレキシブルに変更してくれます。



そして、min-height:100dvh;を設定することで、ブラウザの表示項目の表示・非表示に合わせサイズをフレキシブルに変更してくれるので画面の要素が少ない際は余分なスクロールが発生しない、かつ内容が画面に収まらない場合は下まで背景色がつくようになります!


どの単位も、今後スマートフォンサイトをコーディングする際に絶対に役に経ちますので、覚えておきたいですね。

2022年10月29日土曜日

Mavenビルドの「Error code 501, HTTPS Required」エラーに対応する。

10月 29, 2022

オフィス狛 技術部のJoeです。
先日、担当しているJavaのプロジェクトでMavenのビルドを行ったところ、下記のエラーが発生しました。

[ERROR] Failed to execute goal on project web: Could not resolve dependencies for project [プロジェクト]: Failed to collect dependencies at [アーティファクトID]: Failed to read artifact descriptor for [アーティファクトID]: Could not transfer artifact org.springframework.boot:spring-boot-dependencies:pom:1.5.4.RELEASE from/to central (http://repo1.maven.org/maven2/): Failed to transfer http://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/1.5.4.RELEASE/spring-boot-dependencies-1.5.4.RELEASE.pom. Error code 501, HTTPS Required

Maven Central リポジトリからアーティファクトをダウンロードできずに、「501」でエラーとなっています。

原因は、2020年1月15日から、Maven の Central リポジトリ へのHTTP経由による接続のサポートが終了し、HTTPS経由での接続が必須となりました。(ちょっと古い情報ですが)
そのため、このままですと、Central リポジトリ への接続が拒否され、ビルドが失敗します。

解決方法を調査してみると、いくつか対応策が出てきますが、私が解決できた方法をご紹介します。

【環境】
・Spring Tool Suite(Windows):4.15.3
・Maven:3.8.4

※環境の状態によっては、下記のエラーが発生する場合もありますが、同じ対応で解決できました
[ERROR] Failed to execute goal on project web: Could not resolve dependencies for project [プロジェクト]: Failed to collect dependencies at [アーティファクトID]: Failed to read artifact descriptor for [アーティファクトID]: org.springframework.boot:spring-boot-dependencies:pom:1.5.4.RELEASE was not found in https://oss.sonatype.org/content/repositories/snapshots during a previous attempt. This failure was cached in the local repository and resolution is not reattempted until the update interval of sonatype-snapshots has elapsed or updates are forced

① エラーとなったアーティファクトのバージョンを最新版に上げる

私の利用していたアーティファクト「doma-spring-boot-starter」は、バージョンを最新版(1.1.1 → 1.6.0)にすることでエラーが解消し、アーティファクトをダウンロードできました。
※アーティファクトによっては、最新バージョンとしても今回の事象が解消されていない可能性もあるかと思います

バージョンの上げ方は、Central リポジトリで、ご利用のアーティファクトを検索、最新バージョンを確認いただき、pom.xmlファイルで、対象のアーティファクトの「<version>タグ」で指定下さい。

② Maven Central リポジトリのURLを指定する

①でエラーが解消されない場合や、事情によりバージョンを変更できない場合は、pom.xmlファイルに、下記のとおり、Maven Central リポジトリのURLを明示的に指定することで、エラーを解消することが出来ました。
    <repositories>
        <repository>
            <id>maven</id>
            <name>Maven Central</name>
            <url>https://repo.maven.apache.org/maven2/</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>

※調査してみると、「<id>タグ」と「<name>タグ」には、下記を指定することで解消できるとの情報もありましたが、私の環境ではエラーを解消できませんでした。
            <id>central</id>
            <name>Central Repository</name>


①、②でpom.xmlファイルを編集し、ビルドしてもエラーが解消されない場合は、プロジェクトの更新(プロジェクトを右クリック > Maven > プロジェクトの更新)し、再度ビルドすることもお試しください。

以上です。
同じエラーが発生した場合に、お役に立てば幸いです。

2022年10月27日木曜日

【Angular】画面遷移前に確認ダイアログ風のポップアップを表示する。

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

今回は画面遷移前に表示する下図のような確認ダイアログ風のポップアップ(以降、確認ポップアップ)を作成していこうと思います。

1からこのような仕組みを作るのは大変なので、「Simple Modal Module」(ngx-simple-modal)というプラグインを使いたいと思います。

※「Simple Modal Module」のインストールや使い方の細かい説明は、弊社メンバーの記事(【Angular・【Simple Modal Module】モーダルを手軽に実装する。)をご覧ください。

当記事では、以前の記事で使用した「アカウント登録機能」に実装する形で、実践的な説明をしようと思います。
と言うことで、是非、以前の記事も参照ください

参照1:【Angular】独自エラーチェック(カスタムバリデーション)を作成する。
参照2:【Angular】コンポーネントの設計(画面ごとの設計)について。
参照3:【Angular】エラーメッセージの管理について考える。

(1)どのコンポーネントでポップアップを呼び出すべきか

まず、どのコンポーネントでポップアップを呼び出すべきか考えてみます。
親(Container)なのか、子(Presentation)なのか・・・・
以前の記事で、こんな定義をしたかと思います。

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

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


という事で、ちょっと悩みどころではあるのですが、呼び出しは「子(Presentation)」の方が良いと思います。
確認ポップアップの「OKボタン=画面遷移する」、「キャンセルボタン=画面遷移しない」と考えると、確認ポップアップとしての動きは、あくまで「画面遷移するかどうかの関連チェック」に過ぎない、というのが理由です。
他にも、「キャンセルを押された時に画面の表示を変えたい」という要件があった場合、確認ポップアップを親(Container)で呼び出すと、かなり複雑になってしまうから、というのも理由の1つです。(今回はそのような要件はないですが)

(2)確認ポップアップの作成

呼び出す場所は決まりましたので、呼び出す「確認ポップアップ」用のコンポーネントを作成しようと思います。
弊社のプロジェクトでは、「app」配下に「shared」と言うディレクトリを作り、その中にプロジェクトで共通的に使うものを配置しています。

今回は、以下のように作成します。
src/
└ app/
    └ shared/
        └ modal/
            └ containers/
                └ confirmation-dialog/
                    └ confirmation-dialog.component.html
                    └ confirmation-dialog.component.ts

では、それぞれ実装を見ていきましょう。まずは、テンプレート(View)から。
[confirmation-dialog.component.html]
<div class="message">
  <div class="modal-dialog modal-content">
    <div class="modal-header">
      <div class="modal-title"></div>
      <button type="button" class="close" aria-label="閉じる" (click)="onClickCancel()">
        <span aria-hidden="true">×</span></button>
    </div>
    <div class="modal-body">
      <p class="text-center">{{ message }}</p>
    </div>
    <div class="modal-footer p-0">
      <button class="btn btn2" (click)="onClickCancel()">キャンセル</button>
      <button class="btn btn1 btn-block" type="submit" (click)="onClickOk()">OK</button>
    </div>
  </div>
</div>
特筆すべきところは特に無いですが、このコンポーネントを汎用的に利用する為に、表示するメッセージは「{{ message }}」で変数を展開する形にしています。

続いて、コンポーネントの実装です。
[rconfirmation-dialog.component.ts]
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { SimpleModalComponent } from 'ngx-simple-modal';

export interface ConfirmModel {
  message: string;
}

@Component({
  selector: 'koma-confirmation-dialog',
  templateUrl: './confirmation-dialog.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConfirmationDialogComponent
  extends SimpleModalComponent<ConfirmModel, boolean>
  implements ConfirmModel, OnInit {
 
  message: string;

  constructor() {
    super();
  }

  ngOnInit() {}

  onClickCancel() {
    this.result = false;
    this.close();
  }

  onClickOk() {
    this.result = true;
    this.close();
  }
}

少し詳細を説明しています。
まず、下記の記載でポップアップに表示するメッセージを呼び出し元で設定出来るようにしています。
export interface ConfirmModel {
  message: string;
}

あと重要なのは、「ngx-simple-modal」のクラス(SimpleModalComponent)を継承し、先程作った「ConfirmModel」を実装する、と言う事です。
export class ConfirmationDialogComponent
  extends SimpleModalComponent<ConfirmModel, boolean>
  implements ConfirmModel, OnInit, OnDestroy {

「OKが押されたか、キャンセルが押されたか」は、「result」にTrue、Falseを設定することで、呼び出し側へ知らせます。
  onClickCancel() {
    this.result = false;
    this.close();
  }

  onClickOk() {
    this.result = true;
    this.close();
  }

これで確認ポップアップが作成できました。次はこの確認ポップアップを呼び出してみましょう。

※モジュール(NgModule)ファイルの記載は省略していますので、適宜、追加したコンポーネントをモジュールでdeclarationsしておいてください。
※モジュール(NgModule)ファイルでの「SimpleModalModule(ngx-simple-modal)」のインポートについてですが、共通的に使用されることを考えると、「app.module.ts」などでインポートした方が良いと思います。

(3)確認ポップアップの呼び出し

では早速、先程作成した確認ポップアップの呼び出しを実装して行きます。
まずは、呼び出し側の子のテンプレート(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>
            <p *ngIf="v.mobilePhoneNumberHasErrorFormat" class="error-message">{{ message('msg_error_cellphone_number') }}</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 class="footer fixed-bottom d-flex">
        <button type="button" (click)="onClickPrev()">戻る</button>
        <button [disabled]="formRegister.invalid" type="submit">確認</button>
      </footer>
    </form>

特筆すべき部分は特になく、単純に下記の部分を追加しているだけです。
<button type="button" (click)="onClickPrev()">戻る</button>
これは単純ですね、「戻る」ボタンを追加しただけ、になります。

では、次は子コンポーネントに戻るボタンを押した時のアクションを実装して行きます。
[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 { CustomValidators } from '@app/shared/validator/custom-validators';
import { RegisterValidator } from './register.validator';
import { AccountViewSaveModel } from '../../models/account';
import { getMessage } from '@app/shared/message/error-messages';
import { SimpleModalService } from 'ngx-simple-modal';
import { ConfirmationDialogComponent } from '@app/shared/modal/containers/confirmation-dialog/confirmation-dialog.component';

@Component({
  selector: 'koma-register',
  templateUrl: './register.component.html',
})
export class RegisterComponent implements OnInit, OnDestroy {
  // Input・Outputの定義。
  @Input() accountViewSave$: Observable<AccountViewSaveModel>;
  @Output() formBack = new EventEmitter();
  @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),
        CustomValidators.mobilePhoneNumberValidator,
      ],
    ],
    name: ['', [Validators.maxLength(this.nameMaxLength)]],
  });

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

  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.simpleModalService.removeAll();
    // サブスクリプション解除
    this.registerSubscription.unsubscribe();
  }

  onClickPrev(): void {
    // 確認ダイアログの表示
    this.simpleModalService
      .addModal(ConfirmationDialogComponent, {
        message: getMessage('msg_confirm_screen_transition'),
      })
      .subscribe(result => {
        if (result) {
          this.formBack.emit();
        }
      });
  }

  // 画面で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);
  }
}

中身を細かく見ていきましょう。
まずは、「ngx-simple-modalのSimpleModalService」と、先程作成した確認ポップアップをインポートしています。
import { SimpleModalService } from 'ngx-simple-modal';
import { ConfirmationDialogComponent } from '@app/shared/modal/containers/confirmation-dialog/confirmation-dialog.component';

戻るボタンを押された場合の画面遷移の制御は、親コンポーネント側で行うので、親コンポーネントへのイベント登録(定義)も行います。
@Output() formBack = new EventEmitter();

そして、インポートした「SimpleModalService」はDIする必要があります。
  constructor(
    private formBuilder: FormBuilder,
    public v: RegisterValidator,
    private simpleModalService: SimpleModalService,
  ) {}

次に「戻るボタン」を押した時の処理です。ここで確認ポップアップを表示します。
  onClickPrev(): void {
    // 確認ダイアログの表示
    this.simpleModalService
      .addModal(ConfirmationDialogComponent, {
        message: getMessage('msg_confirm_screen_transition'),
      })
      .subscribe(result => {
        if (result) {
          this.formBack.emit();
        }
      });
  }

ざっくり説明すると、
  • SimpleModalServiceのaddModalに自作コンポーネントを指定して、さらに自作コンポーネントのmessageに、表示用の文言を設定している
  • ポップアップの戻りは、subscribeで受け取り設定する(resultにTrue、Falseが設定されて戻ってくる)
と言う感じですね。
Trueの場合(確認ポップアップで「OK」が押された場合)、親画面へイベントを委譲しています。

(4)親コンポーネントの実装

確認ポップアップの表示とは直接関係ないですが、親コンポーネントも実装しておきましょう。
まずはテンプレート(View)に、子のOutput(formBack)と親のイベント(onClickBack)の紐付けを追加します。

[account-register.component.html]
<koma-register
  (formBack)="onClickBack()"
  (formSubmit)="onSubmit($event)"
  [accountViewSave$]="accountViewSave$"
></koma-register>

続いて、コンポーネント側の実装です。
[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 {}

  onClickBack(): void {
    // ホーム画面に遷移する(戻る)
    this.router.navigateByUrl('/home/top');
  }

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

「NgRxのStore」などが使われていますが、ここは一旦スルーしてください。(いずれ、NgRxの実装方法も記事にしようと思います。)
ここでは、画面で入力された値はStore(と言う場所)に格納されている、ぐらいの理解でOKです。
(重要なのは、「データ保存方式」ではなく、あくまで「戻るボタンの制御」なので)

と言う事で、中身を見ていくと・・・・先程、子側と紐付けたイベント(onClickBack)が実装されていますね。
画面遷移するだけの処理ですが、URLがそのまま固定文字列で記載されているのが気になります。
こちらも今回の記事の本質では無いですが、URLの固定文字列も定数化しちゃいましょう。

(2)でも説明したように、「app」配下に「shared」と言うディレクトリを作り、その中にプロジェクトで共通的に使うものを配置します。
今回は、以下のように作成します。
src/
└ app/
    └ shared/
        └ constant/
            └ page-constants.ts

実装は下記のようになります。
[page-constants.ts]
export namespace url {
  export const HOME_TOP = '/home/top';
  export const ACCOUNT_REGISTER_CONFIRM = '/account/register-confirm';
}

では、先程の固定文字列を定数に変更してみます。
下記が、最終的な親のコンポーネントとなります。
[account-register.component.ts]
import { Component, OnInit } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Router } from '@angular/router';
import * as PageConstants from '@app/shared/constant/page-constants';
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 {}

  onClickBack(): void {
    // ホーム画面に遷移する(戻る)
    this.router.navigateByUrl(PageConstants.url.HOME_TOP);
  }

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

(5)メッセージを追加する

最後に確認ポップアップに送る為のメッセージ定義もしておきましょう。

[error-messages.ts]
export const errorMessages: { [key: string]: string } = {
  msg_error_field_required: '{0}は必ず入力してください。',
  msg_error_field_max: '{0}は{1}文字以内で入力してください。',
  msg_confirm_screen_transition:
    '現在の入力中の情報は破棄されます。前の画面に戻りますか?',
};

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);
}

これで、全ての実装が完了しました。
この応用(むしろ、今回の方が応用ですが)で、「アラート表示ポップアップ」や、「完了メッセージ表示ポップアップ」など、色々作れると思います。

ぜひ、色々カスタマイズしてみてください!


【AWS】プライベートサブネット内のLambda関数でKMSを利用する(NAT Gatewayとエンドポイントについて)

10月 27, 2022

オフィス狛 技術部のJoeです。
担当のプロジェクトで、AWSのプライベートサブネット内のLambda関数で、環境変数の複合化を行うため、AWS KMSを利用する必要がありました。
インターネットゲートウェイとルーティングできないプライベートサブネット内のLambda関数が、VPC外のサービスであるAWS KMSを利用するためには、2つの方法があります。

方法と設定

1.NAT Gatewayを作成する


パブリックサブネットにNAT Gatewayを作成し、インターネット経由でKMSに接続します。

【NAT Gatewayの設定手順】
 ※NAT Gateway 以外は、既に構築済みの前提として、手順は省略させていただきます

① VPCのサービスから、NAT Gateway を作成します。
・名前:任意の名前を指定します。
・サブネット:NAT Gatewayを作成するパブリックサブネット を選択します。
・接続タイプ:「パブリック」を選択します。
・Elastic IP 割り当て ID:任意のElastic IP アドレスを選択します。
 ※「Elastic IP を割り当て」ボタンで、新たなElastic IP を割り当てることも可能です

② プライベートサブネットのルートテーブルのルートに、①で作成したNAT Gateway を指定します。
・送信先:「0.0.0.0/0」を指定します。
・ターゲット:①で作成したNAT GatewayのID(「nat-…」)を指定します。

■注意点
・Elastic IP アドレスは、AWS アカウントごとに各リージョンで 5 つのデフォルト制限がありますので、既に5つ割り当てている場合、制限の増加をリクエストする必要があります。


2.エンドポイントを作成する(PrivateLink)

KMS用のエンドポイント(インターフェイスエンドポイント)を作成し、プライベートリンクでKMSに接続します。

【エンドポイントの設定手順】
 ※エンドポイント以外は、既に構築済みの前提として、手順は省略させていただきます

① VPCのサービスからエンドポイントを作成します。
・名前タグ : 任意の名前を指定します。
・サービスカテゴリ : 「AWS のサービス」を選択します。
・サービス : 「com.amazonaws.ap-northeast-1.kms」を選択します。
・VPC : ご自身の環境の、VPC を選択します。
・DNS 名を有効化 : プライベート DNS 名を有効にする場合、チェックしてください。
・DNS レコードの IP タイプ : DNS 名を有効にした場合、IPv4 or IPv6 を選択ください。
・サブネット : ご自身の環境の、プライベートサブネット を選択します。
・IP アドレスタイプ : 選択したサブネットのIP アドレスタイプから、IPv4 or IPv6 を選択ください。
・セキュリティグループ : 任意のセキュリティグループを指定します。
・ポリシー : 今回は「フルアクセス」としますが、制限したい場合「カスタム」を選択し、ポリシーを設定します。

■注意点
・エンドポイントに設定するセキュリティグループは、インバウンドルールに「HTTPS」を許可する必要があります。


NAT Gatewayとエンドポイントのどちらを利用したほうが良いのか


ご利用に環境による場合もあるかもしれませんが、今回のように、新たにプライベートサブネット内のLambdaやEC2から、AWSのサービス(KMS、S3など)を利用する場合は、 下記の理由などから、エンドポイントを作成し、プライベートリンクで接続が良さそうです。

ただし、対象の AWS サービスで VPC エンドポイントが利用できない場合もありますので、こちらで事前にご確認ください。
AWS PrivateLink と統合できる AWS のサービス

1.セキュリティ

NAT Gatewayは、インターネット経由で接続しますが、プライベートリンクを利用すると、トラフィックをインターネットに公開することなく、AWS のサービスに接続できます。
そのため、ブルートフォース攻撃やDDos攻撃、その他の脅威に晒される危険が軽減されます。
また、セキュリティグループを関連付けることで、アクセス制御も可能です。

2.料金

料金については、AWS アーキテクチャ ブログを見ますと、エンドポイント(インターフェイスエンドポイント)は、「インターネット ゲートウェイへのトラフィックを回避し、NAT ゲートウェイ、NAT インスタンス、またはファイアウォールの維持に関連するコストが発生するのを回避することで、ネットワーク パスを最適化できます。」との記載があります。
また、NAT Gatewayと「1つ当たりの料金」と「処理データ 1 GB あたりの料金」を比較すると、料金は約6分の1で、AWSのコストを削減できる可能性があります。
NAT Gatewayの料金
エンドポイントの料金

3.管理

管理については、エンドポイントは設定手順が少なく、構成も分かり易いため、個人的には管理し易いと思いました。


以上です。
今回は、インターフェイスエンドポイント(プライベートリンク)についてご紹介しましたが、エンドポイントは他にも「ゲートウェイロードバランサーのエンドポイント」、「ゲートウェイエンドポイント」がありますので、機会があればご紹介したいと思います。

2022年10月26日水曜日

【Laravel】created_atの日付フォーマットを変更する方法。

10月 26, 2022
こんにちは、オフィス狛 技術部のmmm(むー)です。

以前、Laravelのプロジェクトで、日付のフォーマットを編集したかったのですが、レスポンスの項目名がcreated_at の場合、うまく変換できず少しはまりました。

その時、調べたことについて記事に残します。

前提条件

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


■SQL Server バージョン
SELECT @@VERSION;
Microsoft SQL Server 2017 (RTM-CU30) (KB5013756) - 14.0.3451.2 (X64)  	Jun 22 2022 18:20:15  	Copyright (C) 2017 Microsoft Corporation 	Developer Edition (64-bit) on Linux (Ubuntu 18.04.6 LTS)


1. したかったこと

当初、SQLでフォーマットを指定して、created_at カラムの値をレスポンスしたかったのですが、期待した形の値が取得できませんでした。
※コードは必要な箇所以外、簡易化して記載します。
127は「ISO 8601 (タイム ゾーン Z)」となります。
class ConditionsController extends Controller
{
    public function index(Request $request)
    {

			$conditions = Condition::select(
			            DB::raw("CONVERT(VARCHAR, created_at, 127) as created_at")
			        )->get()->first();

			return response()->json($conditions);
    }
}

/*
■結果
{
    "created_at": "2022-09-07T02:48:46.947000Z"
}
*/

本来、「2022-09-07T11:48:46.947」のようなフォーマットになる想定でしたが、
created_atの値は「2022-09-07T02:48:46.947000Z」でした。

一方、下記は全く同じ書き方ですが、取得するカラム名が created_at ではない場合、期待通りにフォーマットを変換することができました。
class ConditionsController extends Controller
{
    public function index(Request $request)
    {

			$conditions = Condition::select(
			            DB::raw("CONVERT(VARCHAR, created_at, 127) as test") // 別名でtestを設定
			        )->get()->first();

			return response()->json($conditions);
    }
}

/*
■結果
{
    "test": "2022-09-07T11:48:46.947"
}
*/

created_atカラムに、testという別名を付けると、値は「2022-09-07T11:48:46.947」でした。

2. 原因と解決策

調査した結果、created_atupdated_atはデフォルトの場合、EloquentがCarbonインスタンスへ変換していることがわかりました。
Modelから返ってきた値をCarbonのフォーマットを使用して編集するように修正しました。
class ConditionsController extends Controller
{
    public function index(Request $request)
    {

			$conditions = Condition::select(
			            DB::raw("CONVERT(VARCHAR, created_at, 127) as created_at")
			        )->get()->first();
			$response['created_at'] = $conditions['created_at']->toIso8601String(); // 変換処理を追記

			return response()->json($response);
    }
}

/*
■結果
{
    "created_at": "2022-09-07T11:48:46+09:00"
}
*/

今回は toIso8601String() を使用しましたが、基本的なフォーマットは下記ページに用意されています。
参考:https://carbon.nesbot.com/docs/#api-conversion

3. 補足

今回はControllerでフォーマットの修正を行いましたが、Modelで下記のように、属性を指定してフォーマットを指定することもできます。
protected $casts = [
    'created_at' => 'datetime:Y/m/d',
];

さらに、Laravelで get...Attribute (...は加工したい項目名)と記載して、フォーマットを指定することも可能です。
public function getCreatedAtAttribute($value)
{
    return Carbon::parse($value)->format('Y/m/d');
}

上記は一例となります。Laravelでは様々な機能が用意されているので、もっといろんな方法があると思います。

参考になりましたら、幸いです。

【Laravel】プログレスバーが表示されるコマンド実行の処理を作成する方法。

10月 26, 2022
こんにちは。オフィス狛 技術部のmmm(むー)です。

担当しているプロジェクトで、Laravelのコマンドラインで実行するバッチ処理がいくつかあるのですが、ユーザー数が日に日に増えていき実行時間が長くなっていきました。

普段は自動で実行しているので特に問題ないのですが、先日テストのため手動でバッチを実行しないといけないことがあり、

(この処理いつ終わるんだろう・・・・)

となってしまいました😖

処理を実行して放置すれば良いとしても、目安もわからず、都度確認するのは大変だと思いますので、今回はコマンド実行の処理とプログレスバーの作成方法をご紹介いたします。

前提条件

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


1. コマンドを作成する

コマンドを作成します。今回は例として、ランキング作成の処理としました。
# php artisan make:command [コマンド名]

$ php artisan make:command CreateRankingCommand

自動で作成されたファイルを確認すると下記のようになっています。
// app/Console/Commands/CreateRankingCommand.php

<?php
namespace App\Console\Commands;

use Illuminate\Console\Command;

class CreateRankingCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'command:name';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        return 0;
    }
}

まず、2つの項目を設定します。
1. $signatureに、コマンド名を設定します。ここに設定した名前を使用して、コマンドラインで処理を実行することになります。
2. $description に、このコマンドの説明を記載します。

修正すると、以下のようになりました。
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'command:create_ranking'; // 修正しました

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'ランキングを作成します。'; // 修正しました
    

次に、コマンドが実行できるか確認します。

処理内容はhandle()内に本来記載しますが、初期状態から何も修正していないので、特にエラー等が表示されなければ問題ありません。
# php artisan command:[コマンド名]

$ php artisan command:create_ranking

また、以下のようにコマンドが作成できているか確認することもできます。
$ php artisan list | grep command:
  command:create_ranking                  ランキングを作成します。


2. プログレスバーを作成する

コマンドが実行できることは確認できたので、処理とプログレスバーを作成します。

処理内容は、handle()メソッドの中に記載します。
大変簡素ですが、今回はただループするだけの処理を作成します。
public function handle()
    {
        $count = 10;

        for ($i = 1; $i <= $count; $i++) {
            // 本来、ここで何か処理を行います
        }

        return 0;
    }

ここにプログレスバーを表示する処理を追記していきます。
コードの説明としては、下記①②③のコメントに記載した通りです。
public function handle()
    {
        $count = 10;

        // ①処理の総ステップ数を指定します
        $progressBar = $this->output->createProgressBar($count);

        for ($i = 1; $i <= $count; $i++) {
            // 本来、ここで何か処理を行います

            // ②プログレスバーの表示を1つ分進めます
            $progressBar->advance();
        }

        // ③プログレスバーの表示を完了状態にします
        $progressBar->finish();

        return 0;
    }

処理を実行すると下記のようになります。
※この処理は中身がないため、すぐ終わります。
$ php artisan command:create_ranking
10/10 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%


とても簡単に処理の進捗度を表示できるようになりました!

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

2022年10月3日月曜日

【Python/Flask】AWSのALB(プロキシ)経由でWebシステムにアクセスしたらHTTPSがHTTPに書き換わって盛大にハマった話。

10月 03, 2022
技術部のyuckieee(ゆっきー)です。
今回は、PythonのFlaskフレームワークを使用し、Webシステム構築をした際にハマった事象について、解決策と合わせて、ご紹介しようと思います。

■Webシステム概要

発生事象の説明をする前に、まずは今回開発を行ったWebシステムの概要について共有しておきます。超ザックリとした概要ではありますが、以下のような構成・仕様となっていました。

【システム構成】
・ALB(ロードバランサー)
・EC2(Web/APサーバ)
 - OS:RedHatLinux8.x(EC2)
 - Web/AP:Apache 2.4.xx(mod_wsgiでFlaskと連携)
 - 言語:Python 3.8
 - フレームワーク:Flask
・通信プロトコル
 - クライアント⇔ALB:HTTPS(TCP/443※)
  ※HTTPで接続された場合でもALB側でHTTPSに変換して、リクエスト自体は受け付ける
 - ALB⇔EC2:HTTP(TCP/80)

(構成イメージ)

【Webシステムの仕様】
 ・BASIC認証を使用して、ログイン認証を行う
 ・ログイン認証後、トップページでユーザ情報をセッションに格納して他ページで使用する
 ・他ページはユーザ情報必須のため、トップページ以外へのダイレクトアクセス※は非許可
  ※ブラウザにURLを直接入力したり、お気に入りからアクセスした場合など
 ・ダイレクトアクセス検知時は、トップページに強制遷移させ、必ずトップページ経由とさせる

(画面遷移イメージ)

■発生した事象

Webシステム開発が完了し、システム構成で説明した通りの通信経路となるよう、Webシステムにアクセスする際のURLを以下のように変更しました。(URLはイメージです)

【開発時】http://websystem.com/
※Webシステムのあるサーバに直接アクセスするために設定されたWebシステムのURL
  ↓
【開発完了後】https://alb.websystem.com/
※AWSのALBを介してアクセスするために設定されたWebシステムのURL

そして、動作確認をしようと【開発完了後】のURLにアクセスしたのですが、通常アクセス時のとおりトップページから他ページに遷移しようとしても、トップページへの強制遷移が発生し、他ページに遷移ができない状態に陥りました。
明日からユーザ側で試験利用と言っているのに、軽くパニックです(笑)

(画面遷移イメージ)

■直接原因

Webシステムの仕様から考えると、ダイレクトアクセス検知によってトップページに強制転送されているのだろうと感じていました。 そして、その直感は当たっており、この仕様に絡んで以下2つの仕組みにより起こった問題であることが分かりました。

① 相対URLでの画面遷移

Webシステムのページ遷移に使用するURLは、Flaskのurl_forというメソッドを使用しており、このメソッドで生成されるURLは相対URLがデフォルトとなっており、このWebシステムでもデフォルト指定にて使用していました。
今回の場合、直前のアクセス元であるALBがhttps → httpに書き換えてリクエストを投げてきているため、受け取ったhttp://~から始まる絶対パスを元に、相対指定でURLが作成されて画面遷移されることになりました。

② HTTPSサイトからHTTPサイト遷移によるリファラ削除

突然何だ?!と思うかもしれませんが、このWebシステムではダイレクトアクセスの検知を、リクエストヘッダ内にあるリファラ(遷移元URL情報)の存在チェックで行っていました。
想定では、ダイレクトアクセスの場合、リファラには遷移元URLが入っていないため、ここをチェックすることでダイレクトアクセスの判定が可能と考えていたためです。
ですが、①の画面遷移を受け付けたブラウザは「HTTPSサイト(安全)」から「HTTPサイト(非安全)」への遷移が発生したと検知し、セキュリティリスク回避のためリファラの内容を削除してリクエストしていました。

その結果、リクエストを受け取ったWebシステムは、リファラなし(=ダイレクトアクセス)と判断し、トップページに強制転送していた訳ですね。

リファラという用語がピンとこない方は、公式ページを参照してみてください。
Referer - HTTP - MDN Web Docs

■根本原因

ここまで、実際に事象を引き起こした仕組みについて説明してきました。
ですが、そもそもAWSのALBを経由しなければ、正常に動作していたわけです。なのに、なぜこの事象が発生したのでしょうか?

それは、AWSのALBを経由したことでリクエスト情報が書き換わったためです。

今回でいうと、ALBからEC2(Web/APサーバ)への転送時に通信プロトコルをhttps → httpに書き換えており、転送リクエストを受け取ったEC2(Web/APサーバ)から見るとリクエスト元はALBなので何も間違ってはいませんし、そういう仕様なのです。
そのため、開発者は、プロキシ経由が発生すると分かった時点で、クライアントのリクエスト情報を正確に取得できるよう設計考慮が必要でした。

■解決方法

それでは、実際に根本原因に対処していきましょう。
今回は、クライアントの情報をちゃんと取得できれば解決するわけです。
でも、リクエスト情報が書き換えられてるのにどうすれば?

ご安心ください!

今回使用したALBの場合、経由前のリクエスト情報が別のヘッダーに退避されています。
それが「X-Forwarded-xxx」ヘッダーです。
「X-Forwarded-xxx」ヘッダーには、いくつか種類があり、通信プロトコルが格納されているヘッダーは「X-Fowarded-Proto」となります。

ヘッダー名 説明
X-Forwarded-For プロキシ経由時、クライアントのIPアドレスが格納されます。複数プロキシ経由時はカンマ区切りで表記され、プロキシ経由ごとに右端にIPアドレスが追加されていきます。
X-Forwarded-Host リクエストヘッダー内でクライアントから要求された元のホストを特定するための事実上の標準となっているヘッダーです。
X-Forwarded-Proto プロキシまたはロードバランサーへ接続するのに使っていたクライアントの通信プロトコル (HTTP または HTTPS) を特定するために事実上の標準となっているヘッダーです。

ただし、これらのヘッダーは標準化されたものでなく、書き換え容易且つ、経由するプロキシ(今回はALB)によって挙動が異なる可能性もあるので、ご注意ください。ALBの仕様については以下に記載がありました。
HTTP ヘッダーと Application Load Balancer

なお、「X-Forwarded-xxx」ヘッダーに関する詳細は以下を参照して確認してみてください。
※「X-Fowarded-xxx」は、現状「X-Forwarded」ヘッダに移行されるようなので「X-Fowarded」ヘッダーに関するリンクも張っておきます。
X-Forwarded-Proto - HTTP - MDN Web Docs
Forwarded - HTTP - MDN Web Docs - Mozilla
RFC 7239 Forwarded HTTP Extension

ここまでの説明から、この事象を解決するには「X-Fowarded-xxx」ヘッダーにあるプロトコル情報を使えば良い。というのが、ふんわり頭に浮かんだのではないかと思います。
それでは、このWebシステムでは、どのように「X-Forwarded-Proto」ヘッダーの値で書き換えればよいのでしょうか?
自分でゴリゴリ実装することも可能ですが、今回使用したPythonのフレームワークであるFlaskでは、既に対応するミドルウェアが提供されていました。
それが「X-Forwarded-For Proxy Fix」というミドルウェアです。

X-Forwarded-For Proxy Fix - Werkzeug

このミドルウェアを使用することで、経由(信頼)するプロキシ数に応じてリクエスト元情報の補正を行うことが可能です。 使用方法などの詳細は、上記公式ページを参照して、確認してみてください。
具体的な実装例は以下となります。

from werkzeug.middleware.proxy_fix import ProxyFix

def app():
~略~
    app = Flask(__name__)
    app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1)
~略~

インスタンス作成時に今回問題となった「X-Forwareded-Proto」ヘッダの値を使用して、通信プロトコル情報が補正されるように設定します。 公式を参照すると、「X-Fowareded-Proto」ヘッダーを補正したい場合は、引数にx_proto=(信頼する)プロキシ経由数の形式で指定すれば良いようです。
ヘッダーごとに引数が異なるようなので、詳細は公式参照ください。

上記を設定することで、無事問題は解決することが出来ました。

<おまけ>
対処方法を色々と探していたのですが、他の方法もあるようでした。
プロキシによっては「X-Forwarded-xxx」ヘッダーが設定されていないなどもあると思うので、参考までに紹介しておきます。 ただし、ヘッダーが取得可能な時は、わざわざ手間を増やしたり、セキュリティリスク高める必要はないと思うので、推奨は致しません。

・直接原因① Flaskが認識するリクエスト元プロトコルを"HTTPS"固定に変更する
Flaskのインスタンス生成時、リクエスト元のプロトコル情報を直接"HTTPS"(固定)に書き換えることで対処する方法です。以下、実装例です。
def create_app():
...省略...
    class SchemeFix:
        def __init__(self, app):
            self.app = app

        def __call__(self, environ, start_response):
            environ['wsgi.url_scheme'] = 'https'
            return self.app(environ, start_response)

    app = Flask(__name__)
    app.wsgi_app = SchemeFix(app.wsgi_app)
    
    return app

・直接原因② リファラが削除されないように設定を変更する
直接原因②はリファラが削除されるのが問題なので、削除されないようにすればいいじゃん!という対策です。対象のWebページ(HTML)に対して、以下のようなメタ情報を定義してリファラの送信制限を緩和させます。
<meta name="referrer" content="origin-when-crossorigin">

上記content="origin-when-crossorigin"の記載が該当箇所になります。
この設定の場合、同一のプロトコル水準 (HTTP→HTTP, HTTPS→HTTPS) で同一オリジンのリクエストを行う場合はオリジン、パス、クエリー文字列が送信され、オリジン間リクエストや安全性の低下する移動先 (HTTPS→HTTP) ではオリジンのみを送信します。
今回のダイレクトアクセスチェックでは、リファラの値有無を確認しているため、オリジンのみでも値があれば事象は解消可能となります。
全ての情報が必要な場合はcontent="unsafe-url"を設定することでリファラを取得可能ですが、安全性の面から非推奨となっているため、使用は回避するべきだと思います。

Referrer-Policy - HTTP - MDN Web Docs

■まとめ

Webシステムだけでなく、インフラも含めた全体的な視点でシステム構築をすることの難しさと面白さを知りました。
徐々に仕組みを理解し、謎が解けた時は本当に楽しいですね。
今後も幅広い領域の経験を積みながら、レベルアップしていければと思います。

Visual Studioでデバッグ時に「ID XXXXXのプロセスは実行されていません」のエラーからの復帰方法。

10月 03, 2022
オフィス狛 技術部のHammarです。

先日Visual StudioでIIS Expressからデバッグ時に、急に「ID XXXXXのプロセスは実行されていません」と表示されるようになり、全くデバッグが出来なくなりました。
いろいろ先人の復帰方法を見ながら試してみても、どうもうまくいかず軽くハマっていたんですが、意外と簡単な方法で復帰したので、お知らせしたいと思います。

実行環境

  • Windows10
  • Visual Studio 2017
  •       - IIS Express(Google Chrome)よりデバッグ

事象

先日までは普通にデバッグ開始ボタンよりデバッグできていて、ある日同じようにデバッグ開始したところ、起動してブラウザは開くんですが、「このサイトにアクセスできません」のエラーが発生して、上手く接続できていない状態になっていました。
その時、Visual Studioもデバッグモードになっているものの、設定したデバッグポイントが、「ブレークポイントは、現在の設定ではヒットしません。このドキュメントのシンボルが読み込まれていません。」と表示されてしまっていました。
なので、一旦デバッグを停止し、もう一度デバッグを開始したところ、下記のように「ID XXXXXのプロセスは実行されていません」が表示され、以降はデバッグをやり直しても、Visual Studioを起動しなおしても同エラーが出続けてしまう状態に陥ってしまいました。

解消方法

調べていくと、似たような状態になっている記事をいくつか見つけたので、手っ取り早くできる方法を2つ3つやってみたのですが、どうも解決しませんでした。
(「IIS Expressサーバーを起動できません。」というエラーと一緒に発生している記事の内容を主に試してみたので、ちょっと今回の事象とは微妙に違ったのかもしれません)
で、調べをすすめていくと、簡単に解消できた方法が以下の内容です。

■csprojファイルの下記3行を削除する

<DevelopmentServerPort>xxxxx</DevelopmentServerPort> 
<DevelopmentServerVPath></DevelopmentServerVPath>
<IISUrl>http://localhost:xxxxx/</IISUrl>

上記はデバッグ時に起動するIIS ExpressのURLや、仮想開発サーバーのポート、パスを設定している部分なのですが、こちら3行を削除して、プロジェクトをリロードすれば、なんとあっさりデバッグが正常にできるようになりました。
ちなみに、上記を削除しても、再度デバッグ実行したタイミングで、記述が自動的に再作成されるので、特に問題は無いようです。

また本エラーの解消方法調べていくと、結構対応方法が多様でバラバラなので、もしかしたら一概に、この方法で解決します!とはならないかもしれないのですが、とりあえず簡単に試せる方法ではあるので、もし同様のエラーに陥った方は、まずは一旦試していただければと思います。

node.jsでnockライブラリを使って外部API呼び出しをモック化させる。

10月 03, 2022
オフィス狛 技術部のHammarです。

node.jsでAPIを実装しているプロジェクトにユニットテストを実施することになり、いろいろと自動化させています。
このユニットテストにおいて、外部のAPIを呼び出す処理は、実際に外部APIを呼ばずに、APIモックを作成してテストするということになり、今回nockというライブラリを使って簡単にモックを作成ができたので、ご紹介したいと思います。

実行環境

※ユニットテストではjestを使います
  • Node.js  v12.14.0
  • jest           v27.5.1
  • nock        v13.2.4


以下代表的なGETとPOSTのテストについてサンプルを書いてみます。

GETの場合

テスト対象となるプログラムの例として、以下GETリクエストのプログラムを作成します。
■testGetRequest.js
const request = require('request');

exports.getUserInfo = async () => {
  // リクエスト作成
  const requests = {
    url: 'https://test.com/users',
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
    },
    json: true,
  };

  // リクエスト送信
  return new Promise((resolve, reject) => {
    request(requests, async (error, response, body) => {
      if (error) {
        reject(error);
      } else if (response.statusCode !== 200) {
        reject(response);
      } else {
        resolve(body);
      }
    });
  });
};
上記をnockを使ったテストコードを書くと以下のようになります。
■testGetRequest.test.js
const nock = require('nock');

describe('ユーザー情報取得のTest', () => {
  let resData;

  beforeEach(() => {
    // レスポンス
    resData = {
      user_id: '1',
      user_name: 'テスト太郎',
    };
  });

  afterEach(() => {
    nock.cleanAll();
    jest.clearAllMocks();
  });

  it('ユーザー情報取得リクエスト', async () => {
    // APIリクエストのモック
    nock('https://test.com')
      .get('/users')
      .reply(200, resData);

    const repos = await testGetRequest.getUserInfo();

    expect(repos.user_id).toEqual('1');
    expect(repos.user_name).toEqual('テスト太郎');
    expect(nock.isDone()).toBe(true);
  });
上記のnock('https://test.com').get('/users')が、テスト対象プログラム(testGetRequest.js)のgetUserInfo()で呼んでいるAPIリクエストのパスと一致、かつHTTPメソッドも同じにすることによって、resDataに記述したモックするレスポンス内容を返すことができるようになります。
ちなみにnock.isDone()で、リクエストをしたかどうかを判定できます。

続いて、POSTリクエストの例が以下になります。

POSTの場合

■testPostRequest.js
const request = require('request');

exports.entryUserInfo = async (userId, userName) => {
  // リクエストBODY
  const bodyData = {
    user_id: userId,
    user_name: userName,
  };

  // リクエスト作成
  const requests = {
    url: 'https://test.com/users',
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: bodyData,
    json: true,
  };

  // リクエスト送信
  return new Promise((resolve, reject) => {
    request(requests, async (error, response, body) => {
      if (error) {
        reject(error);
      } else if (response.statusCode !== 200) {
        reject(response);
      } else {
        resolve(body);
      }
    });
  });
};
上記のテストコードを書くと以下のようになります。
■testPostRequest.test.js
const nock = require('nock');

describe('ユーザー情報登録のTest', () => {
  let userId;
  let userName;
  let reqBody;
  let resData;
  
  beforeEach(() => {
    userId = '1';
    userName = 'テスト太郎'

    reqBody = {
      user_id: userId,
      user_name: userName,
    };
    // レスポンス
    resData = {
      message: '登録完了しました',
    };
  });

  afterEach(() => {
    nock.cleanAll();
    jest.clearAllMocks();
  });

  it('ユーザー情報登録リクエスト', async () => {
    // APIリクエストのモック
    nock('https://test.com')
      .post('/users', reqBody)
      .reply(200, resData);

    const repos = await testPostRequest.entryUserInfo(userId, userName);

    expect(repos.message).toEqual('登録完了しました');
    expect(nock.isDone()).toBe(true);
  });
});
GETの時と同様に、リクエストのパスとHTTPメソッドを一致させることでモック作成が可能となります。
POSTの場合は、上記.post('/users', reqBody)でリクエストボディの内容を設定できます。
ちなみに、テストを実行した後は、nock.cleanAll()でモックをクリアする必要がありますので、忘れずに。

上記のように、思っていた以上に楽にAPIリクエストのモックを作成することが出来るようになりました。
公式の情報を確認すると、さらに細かくモック設定できそうなので、細かなテストにも対応できそうです!

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のカスタムアクションは、利用の幅が広く、もっと色々な応用がききそうだなと感じたため、継続して色々と試してみたいと思います。