AWSとGoogle Cloudのコスト最適化の道 〜データドリブンな取り組みの紹介〜

CTO の小笠原(@yamitzky)です。今日は、CTO として推進している「サーバー費削減プロジェクト」の取り組みについてご紹介します。

本稿では「リザーブドインスタンスを購入する」や「入札型のインスタンスに移行する」といった一般的な削減テクニックについては扱いません。プロジェクトとしてどう分析、進行し、成果を出しているか、という話を中心に、取り組みをまとめています。

背景

JX通信社では、Amazon Web Services(以下、AWS) や Google Cloud などのクラウドサービスを活用しています。これらのクラウドサービスは通常、ドルで費用が決まっており、日本円で支払います。そのため、為替の影響を受けてしまいます。

ちょうど最近は円高の恩恵を受けていますが、つい3年前の2021年は1ドル103円だったところ、2024年のピーク時には160円まで進行しています。つまり原価が1.5倍近く上がってしまっていることになります。

Google Finance のドル円チャート

サーバーコスト削減のための開発は、直接的な売上増や、顧客へ提供する価値の向上には繋がらないものです。ついつい後回しになりがちではありましたが、為替などの背景もあり、2022年ごろから大規模なサーバーコスト削減に断続的に取り組む形になりました。

サーバーコスト削減施策に取り組むための、3つの基本

サーバーコスト削減を成功に導くコツとしては、3つあると考えています。これらを順を追ってご紹介します。

  1. プロジェクトとして立ち上げる
  2. 「行動につなげやすいコミュニケーション」を意識する
  3. データドリブンなアプローチを取る

1. プロジェクトとして立ち上げる

企業においてなんらかの取り組みを成功させるには「プロジェクト」を立ち上げるのが良いと思います。プロジェクトの要素として、次の点を抑えると良いです。

  • 「プロジェクト名」を決める
  • 「時期」「ゴール」「リソース」を決める
  • どう実現するか、施策の優先順位の方針を決める
  • モニタリングと振り返りを行う

2022年に取り組んだ最初の「サーバー費削減プロジェクト」は 「2023年3月までに費用を30%削る」というゴールを設定 し、と銘打ったりしました。

プロジェクト用のNotionページ

そして 「月に10万円以上の削減効果があるものを優先する」「削除するだけで終わるものを優先する」 といった方針を設けたり、「一ヶ月以上工数がかかるものはやらない」「放っておけば減りそうなものはやらない」といった優先度付けをしたり、「機能削減だけでできるものを優先し、施策の責任者と調整する」「数日でできるものはプロダクトバックログに入れてもらう」といった交渉などを行いました。

2. 「行動につなげやすいコミュニケーション」を意識する

例えば 「Amazon S3 のデータが高いのでなんとかしてください!」 と伝えても、「どれくらいの重要度なのか」「どれくらいの大変さなのか」「なぜそれをやらないといけないのか」などはわからず、納得感のあるコミュニケーションにならないですし、行動につなげることもできません。

そこで、次のようなコミュニケーションを意識・徹底しています。

  • コストや削減幅を伝えるときは、単位を「一ヶ月あたり◯万円」に揃える *1
  • 「何にかかっているコストなのか」「どんな施策や売上に紐づいたコストなのか」などを調べ、伝える
  • 削減の難易度についての考えを述べる

例えば、冒頭の例を言い換えると、 「開発版のS3バケットに、月20万円もかかっています。開発版なので、3ヶ月以上古いデータを自動削除する設定をするだけで、月2万円程度までコストが下げられるはずです」 などという具合に伝えます。そうすると「開発版だから確かにもったいないな」「開発版だから古いの消すのは合理的だな」「消すだけなら簡単だな」と、関係者が納得感を持って理解し、行動しやすくなります

詳細はほぼお見せできないのですが、施策や削減手段ごとにかかっているコストなどをまとめて管理しています

3. データドリブンなアプローチ

サーバーコスト削減の成果を出すために、 定量的に分析してなるべく効果の高いものを見つけ、その結果を日次でモニタリング するようにしています。分析とモニタリングにわけてご説明します。

分析フェーズ

まず、AWS や Google Cloud のすべてのコストを、BigQuery に転送しています。そのデータを、Connected Sheets を使って Google Sheets に連携しています。 さらに、一個一個の細目に対して、「何の機能にかかっているコストなのか」を目視でアノテーションしています *2

Connected Sheet の例。一個一個の細目に対して、プロダクトの機能や、コストの目的をアノテーションしています。

そして Google Sheets 上に集約したものを、以下のような分析軸でピボットテーブルにかけます。

  • クラウドのアカウント・プロジェクトIDごと
  • クラウドの製品ごと (Lambda, DynamoDB, Cloud Run, etc...)
  • 利用タイプ・SKUごと (Lambda の「GB-Second-ARM」、Cloud Run の「CPU Allocation Time」といった単位。このとき、リージョンは分かれないようにまとめます)
  • 機能・施策ごと (自社プロダクトにおける「◯◯機能」や、「セキュリティ監査のため」などの用途)
  • 事業ごと

このように分析を進めると、 削減幅の大きい対象や、ムダに感じられる費用、費用対効果の見合わないプロダクトの機能、社内システムetc...などが浮かび上がってきます。「ムダな費用かもしれなくて確認が必要だが、削減幅の大きくないもの」の優先度を落とすこともできます。

また、AWS の Cost Explorer を使った分析をされている方も多いと思います。私も、簡易的な用途としてはよく利用しますが、クラウド横断での分析ができないこと、分析の集計軸(ピボットテーブルできる区分)が限定的で意味のある集計になりづらいこと、定期的なモニタリングがしづらいことなどから、BigQuery や Google Sheets をベースにした分析をおすすめしたいです。

モニタリングフェーズ

BigQuery に集約したクラウドのコストを、Redash で定時集計し、毎朝 Slack に投稿するようにしています。Redash への投稿は主に私の作った bot を使っています*3

毎日だと変動が大きく削減できたかわかりづらいこともあるので、週次集計や、月次予測での過去の◯月比、といった比較も定期的に行っています。また、AWS、Google Cloud 以外については、稟議申請のタイミングでの費用チェック等も地道にやっています。

全体像。構築時期がかなり古いため冗長ですが、S3→GCS→BigQueryの転送などはもっとシンプルにできます。AWS のコストデータはクラスメソッドの仕組みで保存されています。

まとめ

今回は「サーバー費のコスト削減」というテーマについて、具体的なテクニックではなく、データドリブンな取り組みやプロジェクト管理にフォーカスを当ててご紹介しました。削減テクニックとしては、AWSの公式ブログ やその他の技術ブログも参考にしましたが、削減幅が大きくないためにJX通信社ではやっていない施策も多々あります。定量的に分析をしてから取り組む、というのが大事ではないでしょうか。

また、サーバー費削減が進んでいるのは、ひとえに社内関係者のご協力があってのことです。この場を借りて、御礼を申し上げます。

*1:「月◯万円」という単位で目標や売上、あるいは自分の給料を見ることが多いので、このような単位にしています

*2:アノテーションしていない費用は、全体の1%程度です。金額が大きいものは厳密に確認しつつ、ある程度ルールベースでのアノテーションもして、えいやで付与しています

*3:hakobera さんの素晴らしいアイデアをフォークしていますが、コードはほぼ書き換わっています

気象庁XMLを正しく扱いたい!

テーマの紹介

JX通信社エンジニアのr_uematsuです。
弊社は、日本テレビ放送網株式会社と共同で「日テレ気象・防災サイト」を開発しています。気象警報、地震・津波情報、噴火情報など、防災に関わる情報をまとめて閲覧できるサイトです。 bosai.news.ntv.co.jp

情報源には気象庁から配信されるXML(電文)を使用しています。
気象庁XMLは気象情報や地震情報など様々な情報を配信しており、日テレ防災サイト以外にも社内プロジェクトでも広く利用されています。
今回は気象庁XMLの紹介と正しく扱うためには、どんなことに気を付けるべきかを地震津波関連のXMLを例に掘り下げてみたいと思います。

これから気象庁XMLを使ってみたい方に雰囲気が伝わると幸いです! また掘り下げる内容は、自分自身が気象庁の地震津波関連のXMLに初めて触れた時に、把握が難しかった仕様や重要なポイントなど取り上げてみました。地震津波関連のXMLを既に使ってる方の助けになればと思います。

気象庁防災情報XMLについて

気象庁防災情報XMLとは、気象庁が発表する気象警報や地震津波情報、火山情報などをITサービスに取り入れたい時に便利なデータです。公式情報がXML形式で配信されていてPULL型で取得することができます。
例えば気象警報について以下のようなXMLが配信されます。

<Report xmlns="http://xml.kishou.go.jp/jmaxml1/" xmlns:jmx="http://xml.kishou.go.jp/jmaxml1/" xmlns:jmx_add="http://xml.kishou.go.jp/jmaxml1/addition1/">
<Control>
<Title>気象特別警報・警報・注意報</Title>
<DateTime>2024-07-22T16:11:07Z</DateTime>
<Status>通常</Status>
<EditorialOffice>気象庁本庁</EditorialOffice>
<PublishingOffice>気象庁</PublishingOffice>
</Control>
<Head xmlns="http://xml.kishou.go.jp/jmaxml1/informationBasis1/">
<Title>東京都気象警報・注意報</Title>
<ReportDateTime>2024-07-23T01:11:00+09:00</ReportDateTime>
<TargetDateTime>2024-07-23T01:11:00+09:00</TargetDateTime>
<EventID/>
<InfoType>発表</InfoType>
<Serial/>
<InfoKind>気象警報・注意報</InfoKind>
<InfoKindVersion>1.1_2</InfoKindVersion>
<Headline>
<Text>小笠原諸島では、23日夕方まで急な強い雨や落雷に注意してください。</Text>
<Information type="気象警報・注意報(府県予報区等)">
<Item>
<Kind>
<Name>雷注意報</Name>
<Code>14</Code>
</Kind>
<Areas codeType="気象情報/府県予報区・細分区域等">
<Area>
<Name>東京都</Name>
<Code>130000</Code>
</Area>
</Areas>
</Item>
</Information>
~~~~省略~~~~

<Item>
<Kind>
<Name>雷注意報</Name>
<Code>14</Code>
</Kind>
<Areas codeType="気象・地震・火山情報/市町村等">
<Area>
<Name>千代田区</Name>
<Code>1310100</Code>
</Area>
</Areas>
</Item>
<Item>

~~~~省略~~~~
</Body>
</Report>

配信される情報は数十種類にも及び、それぞれにXMLフォーマットと仕様が存在します。

いざ気象庁XMLを導入しよう!と開発を進めると、このフォーマットと仕様の把握がとても大変でした。。。

地震津波関連を例にXMLの仕様を覗いてみる

XMLの仕様は例えばどんなものかというのを弊社でよく扱う地震津波関連のXMLを例に覗いてみたいと思います。

気象庁が配信する地震津波関連のXMLだけでも種類はこんなにあります。

  • 津波警報・注意報・予報
  • 津波情報
  • 沖合の津波観測に関する情報
  • 緊急地震速報
  • 震度速報
  • 地震情報(震源に関する情報)
  • 地震情報(震源・震度に関する情報)
  • 地震情報(地震の活動状況等に関する情報)
  • 地震情報(地震回数に関する情報)
  • 地震情報(顕著な地震の震源要素更新のお知らせ)
  • 長周期地震動に関する観測情報
  • 南海トラフ地震に関連する情報
  • 地震・津波に関するお知らせ

それぞれに個別の仕様とXMLのフォーマットが存在します。さらに発表条件と順番があります。
参考:地震情報について
参考:津波警報・注意報、津波情報、津波予報について

発令とEventIDについて

気象庁のWebページ地震情報についてによると1回の地震が発生した場合に複数のXMLが配信される可能性があることがわかります。 その地震が震度3以上なのか、津波に関する情報はあるのかなどの条件によりそれぞれのXMLの配信の有無が決まります。

よく使用する種別を簡単に紹介します。

  • 津波警報・注意報・予報
    津波に関する警報の発令の有無に関する情報が載ってます。

  • 津波情報
    津波の到達予想時刻や波の高さなどの情報が載ってます。

  • 震度速報
    震度3以上の揺れを観測した場合に全国各地の地震の揺れを速報として配信されます。速報のため震度観測区域は「東京都23区」のように荒めになります。

  • 震源に関する情報
    津波警報または注意報が出ていない場合に配信されます。地震の発生場所(震源)やその規模(マグニチュード)の情報が載ってます。

  • 震源・震度に関する情報
    震源に関する情報の内容に加えて、震度速報に比べてさらに細かい区域の「東京千代田区」のような単位での観測震度の情報が載ってます。
    参考:緊急地震速報や震度情報で用いる区域の名称

EventIDに関して

地震津波関連XMLでは、ある特定の地震を識別するために地震識別番号(14 桁の数字例:20240101210208)がXMLの<EventID>で与えられます。

<Report xmlns="http://xml.kishou.go.jp/jmaxml1/" xmlns:jmx="http://xml.kishou.go.jp/jmaxml1/">
<Control>
<Title>震度速報</Title>
<DateTime>2024-01-01T07:07:40Z</DateTime>
<Status>通常</Status>
<EditorialOffice>気象庁本庁</EditorialOffice>
<PublishingOffice>気象庁</PublishingOffice>
</Control>
<Head xmlns="http://xml.kishou.go.jp/jmaxml1/informationBasis1/">
<Title>震度速報</Title>
<ReportDateTime>2024-01-01T16:07:00+09:00</ReportDateTime>
<TargetDateTime>2024-01-01T16:06:00+09:00</TargetDateTime>
<EventID>20240101160608</EventID>  <---こちら
<InfoType>発表</InfoType>
<Serial/>
<InfoKind>震度速報</InfoKind>
<InfoKindVersion>1.0_1</InfoKindVersion>
<Headline>
<Text> 1日16時06分ころ、地震による強い揺れを感じました。震度3以上が観測された地域をお知らせします。</Text>
<Information type="震度速報">
~~~~省略~~~~
</Report>

地震には前震、本震、余震とありますが、一般的に震源地や発生時刻が異なるため別々の識別番号(EventID)が与えられます。異なる種別のXMLでEventIDが同じ場合は同一の地震に関するXMLと解読することができます。

XML種別 EventID 説明
震源・震度に関する情報 20240101xxxxx1 前震
震度速報 20240101xxxxx2 本震
震源・震度に関する情報 20240101xxxxx2 本震
震源・震度に関する情報 20240101xxxxx3 余震
津波警報・注意報・予報 20240101xxxxx2 本震によって発令
津波情報 20240101xxxxx2,
20240101xxxxx3
本震,余震によって起きた津波の情報

具体的に以上のようにXMLが配信された場合、以下のように解読できます。

  • 前震、本震、余震があった。
  • 本震では震度速報が配信され震度3以上である。
  • 本震の揺れにより津波警報・注意報・予報が発令された。
  • 本震、余震によって引き起こされた津波がありそう。

取消報について

地震が発生すると気象庁からの公式情報が次々と流れてきますが、ごく稀に誤った情報が配信される場合があります。そのような場合、取消電文というものが配信されます。実際に、2024/01/01に石川県能登で震度7を観測した内容のXMLが誤って配信されました。TVニュースなどでもそのまま発表されて後に訂正されていた記憶があります。まさにあの時、取消報が配信されていました。

実際に配信された取消電文

<Report xmlns="http://xml.kishou.go.jp/jmaxml1/" xmlns:jmx="http://xml.kishou.go.jp/jmaxml1/">
<Control>
<Title>震度速報</Title>
<DateTime>2024-01-01T14:13:46Z</DateTime>
<Status>通常</Status>
<EditorialOffice>気象庁本庁</EditorialOffice>
<PublishingOffice>気象庁</PublishingOffice>
</Control>
<Head xmlns="http://xml.kishou.go.jp/jmaxml1/informationBasis1/">
<Title>震度速報</Title>
<ReportDateTime>2024-01-01T23:13:00+09:00</ReportDateTime>
<TargetDateTime>2024-01-01T23:03:00+09:00</TargetDateTime>
<EventID>20240101230402</EventID>
<InfoType>取消</InfoType>
<Serial/>
<InfoKind>震度速報</InfoKind>
<InfoKindVersion>1.0_1</InfoKindVersion>
<Headline>
<Text>震度速報を取り消します。</Text>
</Headline>
</Head>
<Body xmlns="http://xml.kishou.go.jp/jmaxml1/body/seismology1/" xmlns:jmx_eb="http://xml.kishou.go.jp/jmaxml1/elementBasis1/">
<Text>先ほどの、震度速報を取り消します。</Text>
</Body>
</Report>

この電文は種別「震度速報」のEventIDが「20240101230402」の電文を撤回することを意味します。 DBなどに地震情報を保存していたりする場合何かしらのロールバック処理が必要になると思います。(場合によっては結構厄介ですね。。。)
地震津波関連のXMLを扱うシステムは取消電文を受け取る可能性があることも考慮しておきたいですね。

終わりに

最後までお読みいただき、ありがとうございます。気象庁XMLにはどんな仕様があるかを地震津波関連のXMLを掘り下げてみました。また今回は紹介していない気象、火山、台風などでも地震津波のように固有事情、仕様が存在します。
気象庁から公式情報が配信されてますが、正しく扱うには仕様の深い理解が必要です。防災関連のシステムで利用した場合、重要な場面で想定外の挙動を起こさないよう安定に動作するように心掛けたいですね。 
今回掘り下げた地震津波関連では発令順やEventID、取消報以外にも気を付けるべき点がいくつかあり、さらに気象や火山のXMLを扱う場合はそれぞれの仕様の把握が必要です。弊社は、気象庁XMLをより扱いやすいフォーマットに加工、整理して返却するAPIを開発と提供をしています。災害情報を活用する機会がありましたらぜひお問い合わせください! jxpress.net

Playwrightでメール配信のテスト自動化にチャレンジ!

こんにちは、JX通信社でシニアエンジニアをしているSirosuzumeです。

JX通信社の「FASTALERT」には、ユーザーが事前に設定した地域で発生した災害情報を、メールで受信する機能があります。

しかしテストする手順も複雑で、配信条件も多様化していったこともあって、手動でのテストを行うことに限界を感じていました。

設定画面の挙動確認など、ブラウザ上で完結するテストであればPlaywrightを使って自動化することもできていたのですが、実際にメールを受信するところのテストを自動化する方法についてのノウハウ不足が課題でした。

そこで、Amazon SESの機能を改めて確認していたところ、特定のメールアドレスで受信したメールをS3に保存する機能があることを知り、E2Eテスト内からS3にアクセスすることでメールの受信テストまで自動化でカバーできるのではないか、と考えたことが、今回のチャレンジのきっかけです。

この記事ではAmazonSES + S3を経由して、Playwright上からメール受信テストを行なう方法を解説します。

概要

今回作成したPlaywrightのテストは、以下のシーケンス図のような流れで動作させました。

E2Eテストシーケンス図

Amazon SESでを使用して、メールを受信するための設定方法は公式ドキュメントを参照してください。

以降のドキュメントはルール設定時にアクションの追加で、Amazon S3バケットにメールを保存するようにしていることが前提になります。

S3経由でメールの受信を検知する方法(Node環境の場合)

S3にアクセスする必要があるため、AWS SDKを使用します。

S3に保存されたメールを取得するだけならば、ListObjectsV2Commandを使ってファイル一覧を取得し、GetObjectCommandを使ってファイルの内容を取得し、解析することで可能です。

特定のアクションをトリガーとして新しいメールが来るのを検知したい場合は、ポーリングを行って新しいファイルが追加されるのを検知する必要があります。

また、メール配信のトリガーとなるアクション自体も時間がかかる場合があるため、以下のような順番で新しいメールが来るのを待つ処理を実装してみました。

  1. 受信前のリストを取得する
  2. メール配信のトリガーとなるアクションを実行する
  3. ポーリングを一定時間行ない、受信前のリストと比較して、新しいファイルが追加されたらそのファイルのオブジェクトキーを返却する
  4. オブジェクトキーを使ってファイル(メール)をダウンロードする

以下はポーリングを行なう関数、S3からメールを取得する関数の実装例です。

import { setInterval } from "node:timers/promises";
import {
  DeleteObjectsCommand,
  GetObjectCommand,
  ListObjectsV2Command,
  S3Client,
} from "@aws-sdk/client-s3";

const client = new S3Client({
  region: process.env.E2E_MAIL_S3_BUCKET_REGION,
});

export async function readNewEmailFromS3(): Promise<string[]> {
  // WARN: S3に1000件以上のメールが溜まっている場合、1000件を超えるメールが来た場合に対応できない
  const command = new ListObjectsV2Command({
    Bucket: process.env.E2E_MAIL_S3_BUCKET_NAME,
  });
  const response = await client.send(command);
  const fileNames =
    response.Contents?.map((content) => content.Key ?? "") ?? [];
  return fileNames;
}

/**
 * 一定時間メールが来なかった場合に発生するエラー。逆にメールが来ないことを検知する際は、このエラーが発生することを期待する
 */
export class WatchTimeoutError extends Error {
  constructor() {
    super("Timeout");
  }
}

export async function watchNewEmailsUntilTimeout(
  actionPromise: Promise<void>,
  timeout: number,
  interval = 500,
): Promise<string[]> {
  const lastEmails = await readNewEmailFromS3();
  await actionPromise;
  // Promise完了からtimeoutまでの間、新しいメールが来るのを待つ
  for await (const startTime of setInterval(interval, Date.now())) {
    if (Date.now() - startTime > timeout) {
      break;
    }
    const emails = await readNewEmailFromS3();
    if (emails.length > lastEmails.length) {
      return emails.filter((email) => !lastEmails?.includes(email));
    }
  }
  throw new WatchTimeoutError();
}
/**
 * オブジェクトキーを指定してメールを文字列の形式で取得する
 */
export async function fetchEmailByKey(key: string): Promise<string> {
  const command = new GetObjectCommand({
    Bucket: process.env.E2E_MAIL_S3_BUCKET_NAME ?? "",
    Key: key,
  });
  const response = await client.send(command);
  if (!response.Body) {
    throw new Error("No body");
  }
  return response.Body.transformToString();
}

メールの内容を取得し、解析できる形式に変換する

S3に保存されたEmailはBase64でエンコードされたRawデータが保存されているため、一度デコードしてメールの内容を取得する必要がありました。

Node環境でメールをデコードする場合、mailparserを使うと、メールの内容を解析してオブジェクトとして取得できます。

以下はmailparserを使って、1件のメール受信を待つ関数の実装例です。

import { ParsedMail, simpleParser } from "mailparser";

export async function waitNewMail(
  actionPromise: Promise<any>,
  timeout: number,
): Promise<ParsedMail> {
  const newEmailKeys = await watchNewEmailsUntilTimeout(actionPromise, timeout);
  if (!newEmailKeys.length) {
    throw new Error("No new email");
  }
  if (newEmailKeys.length > 1) {
    throw new Error("Too many new emails");
  }
  const newEmail = await fetchEmailByKey(newEmailKeys[0] as string);
  const parsedEmail = await simpleParser(newEmail);
  return parsedEmail;
}

テスト例

上記の関数を使って、メールが来ることを検知するテストと、メールが来ないことを検知するテストを実装してみました。

メールが来ることをテストする場合は、一定時間内にメールが来ることを検知する関数を使って、メールの内容を検証します。 メールが来ないことをテストする場合は、逆に一定時間内にメールが送信されず、WatchTimeoutErrorが発生することを期待します。

import { expect, test } from "@playwright/test";

test.beforeEach(async ({ page }) => {
  // メールの受信設定を行なう
  await page.goto("https://example.com/mail-setting");
  // SESで設定したメールアドレスを入力する
  await page.getByRole("textbox", { name: "メールアドレス" }).type(process.env.E2E_MAIL_ADDRESS);
  await page.click("button", { text: "設定" });
});
test("メールが10秒以内に来ることを検知する", async ({ page }) => {
  // メールが来ることを検知する
  const mail = await waitNewMail(actionToTriggerMail(), 10000);
  await expect(mail.subject).toBe("メールの件名");
  // page.setContentを使うと、HTMLメールをブラウザ上で表示しているかのようにテストできる。ただしメーラーによる差分までは検知できない
  await page.setContent(mail.html);
  await expect(page.getByText("メールの内容")).toBeTruthy();
});
test("メールが来ないことを検知する", async () => {
  // メールが来ないことを検知する
  await expect(
    waitNewMail(actionToTriggerMailButNoMail(), 10000),
  ).rejects.toThrowError(WatchTimeoutError);
});

HTMLメールのメーラーごとの差分を検知するのはPlaywrightでは困難ですが、コーディング時にReact Emailを使用するとHTMLメールの開発を楽にすることができます。

詳しくは、実践 React Emailを使ったHTMLメールの開発・運用という記事を書いたので、もしよろしければご参考にしてください。

まとめ

メールの受信テストを自動化したことで、新機能開発、リファクタリング時のリグレッションテストが大幅に効率化することができました。 手動では1時間以上かかってしまうテストが数分で完了し、リグレッションテストも毎回網羅的に行えるようになりました。

テスト用のコードそのものが複雑になりがちではありますが、それを補ってあまりある効果があったと感じています。

今後、FASTALERTではメールによる各種情報の配信を増やしていく予定のため、より多くのテストを自動化し、品質を担保していきたいと考えています。

実践 React Emailを使ったHTMLメールの開発・運用

こんにちは、JX通信社でシニアエンジニアをしているSirosuzumeです。

JX通信社の「FASTALERT」には、ユーザーが事前に設定した地域で発生した災害情報を、HTMLメールで受信する機能があります。

HTMLメールには以下のような特徴があり、普段のフロントエンド開発の知識が役に立たないことが多いです。

  • Gmail、Outlook、その他のメーラーでは、使用できるタグやCSSに違いがある
  • CSS 自体が使えないクライアントも存在する

ベストプラクティス的なものを探しても、「tableタグを使ってレイアウトを頑張って調整しよう!」といった辛い現実が待ち受けています。

今回、HTMLメールのコーディングを担当することになり、もっと簡単な方法はないかと探していたところReact Emailというライブラリと巡り合いました。

React Emailは、名前の通りReactを使ってHTMLメールを作成するためのライブラリです。 用意されたコンポーネントを使うことで、Gmail、Apple Mail、Outlookといった主要なメールソフトに対応したHTMLメールを作成できます。

この記事ではReact Emailがどのようなものであるのか、どのようにプロダクトで運用したかを紹介します。

React Emailの機能

簡単にいうと、HTMLメール向けのReactコンポーネントと、そのNext.js製の開発サーバーを提供するライブラリです。

Getting Startedに従ってセットアップを行うと、以下の主要3パッケージを含む開発環境が作成されます。

  • @react-email/components
    • HTMLメール専用に作られたコンポーネント。例えばSection、Rowといったコンポーネントはレンダリングするとtableタグに変換される
  • @react-email/tailwind
    • Tailwindで指定されたクラスをHTMLメール用にインラインスタイルに変換するライブラリ
      • 例としてtext-basestyle=”font-size:1rem;line-height:1.5rem;”に変換される
      • remをpxに自動変換するといった機能はサポート対象外のため注意
        • remを使う場合は、pxに変換してから指定する必要がある
  • react-email
    • Next.jsで作られた開発サーバー
    • いくつかの企業製HTMLメールのexample等が最初から格納されている
    • resendを使用して、作成したメールを任意のアドレスに送る機能等がついている

Example

import { Body, Column, Container, Row, Text } from "@react-email/components";
import { MyTailwind } from "../tailwind";

export type ExampleForBlogEmailProps = {
  id: string;
  title: string;
  description: string;
};

export const ExampleForBlogEmail = ({
  title,
  description,
}: ExampleForBlogEmailProps) => {
  return (
    {/* MyTailwindはremをpxに上書きしたreact-email/componentのTailwindのコンポーネント */}
    <MyTailwind>
      <Body className="bg-slate-200 my-auto mx-auto font-sans px-2 pt-4 pb-16 text-black">
        <Container className="mx-auto py-8 px-4 max-w-[592px] bg-white rounded-lg">
          <Row>
            <Column>
              <Text className="text-base font-bold mb-0">{title}</Text>
              <Text className="text-sm mt-1">{description}</Text>
            </Column>
          </Row>
        </Container>
      </Body>
    </MyTailwind>
  );
};

これをHTMLで出力したものを見ると、tableタグとstyle属性変換されている様子を見ることができます。

<body style="background-color:rgb(226,232,240);margin-top:auto;margin-bottom:auto;margin-left:auto;margin-right:auto;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto, &quot;Helvetica Neue&quot;, Arial, &quot;Noto Sans&quot;, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;padding-left:8px;padding-right:8px;padding-top:16px;padding-bottom:64px;color:rgb(0,0,0)">
  <table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:592px;margin-left:auto;margin-right:auto;padding-top:32px;padding-bottom:32px;padding-left:16px;padding-right:16px;background-color:rgb(255,255,255);border-radius:8px">
    <tbody>
      <tr style="width:100%">
        <td>
          <table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation">
            <tbody style="width:100%">
              <tr style="width:100%">
                <td data-id="__react-email-column">
                  <p style="font-size:16px;line-height:24px;margin:16px 0;font-weight:700;margin-bottom:0px">FASTALERT</p>
                  <p style="font-size:14px;line-height:20px;margin:16px 0;margin-top:4px">FASTALERTはSNS等のビッグデータをAIで即時解析し、防災DXや施設管理に活用することができます。リアルタイムな速報や、過去の災害のデータセットをAPIやウィジェットで提供しています。</p>
                </td>
              </tr>
            </tbody>
          </table>
        </td>
      </tr>
    </tbody>
  </table>
</body>

Node環境以外での運用を考える (Go html/templateの例)

FASTALERT開発チームでは、Go言語でHTMLメールのサーバーを開発し、html/templateのライブラリを使用してHTMLメールを生成しています。

そのため、React Emailで作成したコンポーネントのコードをそのまま使用することはできません。 作成したコンポーネントをhtml/templateに対応した形で出力する必要がありました。

またHTMLメールのデザインが複雑化すると、より細かい粒度のコンポーネントに分割して管理する必要があります。

その場合react-emailの提供する開発サーバーを用いるよりも、Storybookを使用してコンポーネントを管理する方が開発体験が良いのではないかと考えました。

運用の基本方針

html/templateでは埋め込みたい値を{{ value }} のように表現します。

まずは、React Emailで作成したコンポーネントをhtml/templateに変換する方法を考えます。

  • メールのデザイン時はpropsの値をそのまま出力する
  • コンポーネントはContainer/PresenterパターンのPresenterに相当するものを作成し、渡されたPropsをそのまま出力する形で設計する
    • 値の表示に計算が必要名箇所は、極力Go側で計算し、結果を文字列で渡す
  • export時はhtml/templateに対応する形に変換し出力する
  • html/templateのrangeやifに対応する

export時の出し分け方法の検討

html/templateに対応する形に変換するため、propsの値によって出し分けを行います。以下は、propsの値によって出し分けを行う関数の例です。

export function renderValue(
  /** デザイン時に出力する値 */
  value: string,
  /** export時に出力するPropsの名前 */
  valueName: string,
  /** exportかどうか */
  isExported: boolean,
): string {
  return isExported ? `{{ ${valueName} }}` : value;
}

// 使用例 isExportedをバケツリレーする
const html = render(<FastalertEmail isExported />)

ifや繰り返しへの対応

繰り返しやifの場合も、同様にpropsの値によって出し分けを行います。以下は繰り返しの場合の例です。 childrenには、繰り返しの中で出力するコンポーネントを渡します。

import React from "react";

export type ChildTemplateProps<T> = {
  value: T;
  arrayName: string;
  itemName: string;
  index: number;
  isExported: boolean;
};

export type GoRangeBlockTemplateProps<T> = {
  arrayName: string;
  itemName: string;
  array: T[];
  children: (props: ChildTemplateProps<T>) => React.ReactNode;
  isExported: boolean;
};

export const GoRangeBlockTemplate = <T,>({
  array,
  arrayName,
  itemName,
  children,
  isExported,
}: GoRangeBlockTemplateProps<T>) => {
  // export時は1つだけ出力する
  const arrayForRendering = isExported ? [array[0]] : array;
  return (
    <>
      {isExported && `{{ range $index, ${itemName} := .${arrayName} }}`}
      {arrayForRendering.map((value, index) => (
        <React.Fragment key={JSON.stringify(value)}>
          {/* childrenに空の配列などが入ることがあるため、typeofでfunctionの判定をいれる */}
          {typeof children === "function" &&
            children({ value, itemName, index, isExported, arrayName })}
        </React.Fragment>
      ))}
      {isExported && "{{ end }}"}
    </>
  );
};
// 使い方
<Section>
  <GoRangeBlockTemplate
    array={productions}
    arrayName="Productions"
    itemName="Production"
    isExported={isExported}
  >
    {({ itemName, value: { title, description } }) => (
      <Row>
        <Column>
          <Text className="text-md font-bold mb-0">
            {renderValue(title, `${itemName}.Title`, isExported)}
          </Text>
          <Text className="text-sm mt-1">
            {renderValue(
              description,
              `${itemName}.Description`,
              isExported,
            )}
          </Text>
        </Column>
      </Row>
    )}
  </GoRangeBlockTemplate>
</Section>

上記のようなコンポーネントを作成することで、html/templateに対応する形に変換することができます。

Storybookでの確認

Storybookを使用すれば、通常のフロントエンド開発と同様にコンポーネントのデザインを確認することができます。また、@react-email/componentsが提供するrender関数を使用することで、HTMLメールの出力結果を確認することもできます。以下はHTMLメールの出力結果を確認するためのStorybookの設定例です。

import type { Meta, StoryObj } from "@storybook/react";

import { render } from "@react-email/components";
import { ExampleForBlogEmail } from ".";
import { generateDefaultExampleForBlogProps } from "./index.mock";

export default {
  component: ExampleForBlogEmail,
} as Meta<typeof ExampleForBlogEmail>;

export const Primary: StoryObj<typeof ExampleForBlogEmail> = {
  render: (props) => <ExampleForBlogEmail {...props} />,
  args: {
    ...generateDefaultExampleForBlogProps(),
  },
};

export const Exported: StoryObj<typeof ExampleForBlogEmail> = {
  // pretty: trueを指定することで、インデントを整えて出力できる
  render: (props) => (
    <pre>
      {render(<ExampleForBlogEmail {...props} isExported />, {
        pretty: true,
      })}
    </pre>
  ),
  args: {
    ...generateDefaultExampleForBlogProps(),
  },
};

render関数はstringを返すため、jestやvitestと言ったテストフレームワークを使用して、出力結果をテストすることもできます。

exportの方法

前項に出てきたrender関数を使用してstringに変換したものを、ファイルに出力するスクリプトを組んで実行します。 以下は./dist/example.gohtmlに出力するスクリプトの例です。

import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { render } from "@react-email/components";
import { ExampleForBlogEmail } from "./_components/example-for-blog";
import { generateDefaultExampleForBlogProps } from "./_components/example-for-blog/index.mock";

if (!fs.existsSync("dist")) {
  fs.mkdirSync("dist");
}

const gohtml = render(
  <ExampleForBlogEmail {...generateDefaultExampleForBlogProps()} isExported />,
  {},
);

fs.writeFile(path.join("dist", "example.gohtml"), gohtml, (err) => {
  if (err) {
    console.error(err);
    process.exit(1);
  }
  console.log("File has been created");
});

実行時はtsxライブラリを使うと、簡単に実行できて便利でした。

npx tsx ./export.ts
# またはpackage.jsonにスクリプトを追加して実行する
pnpm run export

まとめ

React Emailを使用することで、HTMLメールのコーディングや管理が簡単になりました。Storybookの導入により、より小さな単位でコンポーネントを管理することができ、開発効率が向上しました。 今後、FASTALERTではメールによる各種情報の配信を増やしていく予定のため、React Emailの活用を進めていきます。

アプリケーションの動作を担保するテストをどう書くか

こんにちは。kimihiro_nです。
今回はアプリケーションの動作を保証するために不可欠なテストコードの書き方についてです。 特に外部依存要素のテストに焦点を当ててみていきたいと思います。

外部に依存するテストコード

皆さんはアプリケーションのテストコードを書いていますか? 内部的な状態を持たず、入力と出力が常に変化しない関数であれば、テストコードを書くのは比較的容易です。実際に関数を呼び出ししてその出力と期待値が一致しているかをみればテストすることができます。

しかし実際にアプリケーションを開発する場合、データベースへの接続だったり外部へのAPI呼び出しだったりといった外部の状態に依存した処理が含まれることが多いです。このような場合、素直にテストを書くのが難しいです。

多くの場合モックを利用して実際のデータベース呼び出しを置き換えたり、テスト用のリソースをdockerなどで構築してダミーデータを使って動作を確認する事になるでしょう。

これらの方法にはそれぞれ一長一短あります。

モックを利用する場合

モックを利用する利点としては、外部のリソースに頼らず純粋な単体テストとして実行出来ることです。しかし、実際のリソースに接続していないため、いざ繋いで動かしてみたら全く期待通りでなかったということも少なくありません。

テスト用リソースを利用する場合

テスト用のリソースを用意する場合、テストの段階でアプリケーションの挙動をある程度保証することができます。ただテスト用に動かす環境を作るのはコストが高く、リソースによってはローカルで再現できないといったこともよくあります。またローカルで動かせてもCI上で動かすのが大変だったり、テストの実行が遅くなってしまったりと万能ではないです。

アプリケーションの動作を担保するための方針

ではどのようにテストを書けば、アプリケーションの動作を担保することが出来るでしょうか。テストを書くための時間や動作環境によってベストな方法は異なりますが、最近私が採用している方針を共有したいと思います。

レポジトリの作成

まずはアプリケーションをテストしやすくするため、クリーンアーキテクチャでいうところのレポジトリを作成しています。データベースや外部APIなどのアクセスを抽象化するためのレイヤーです。

// UserRepository インターフェース
type UserRepository interface {
    // ユーザーを保存する
    Save(ctx context.Context, user *User) error
}

// EmailRepository インターフェース
type EmailRepository interface {
    // Eメールを送信する
    Send(ctx context.Context, email string, message string) error
}

Go言語での例になりますが、このような形で裏側のリソースに依存しないインターフェースをレポジトリとして定義します。ユーザーの保存先はMySQLかもしれませんし、NoSQLのDynamoDBになるかもしれませんが、インターフェースとしては受け取ったユーザーを保存するだけとなっています。メールについても同様でどんなAPI、SDKを利用するかはこの層では触れません。

レポジトリを使ったコードの実装

// UserService はユーザー管理に関わるサービスです
type UserService struct {
    userRepo   UserRepository  // ユーザーデータを操作するためのリポジトリ
    emailRepo  EmailRepository  // Eメール送信を扱うリポジトリ
}

func (s *UserService) RegisterUser(ctx context.Context, user *User, email string) error {
    // 入力のバリデーション
    if user.Name == "" || email == "" {
        return errors.New("invalid input")
    }

    // ユーザー情報の保存
    err := s.userRepo.Save(ctx, user)
    if err != nil {
        return err
    }

    // メール送信
    message := fmt.Sprintf("Hello %s, welcome!", user.Name)
    err = s.emailRepo.Send(ctx, email, message)
    if err != nil {
        return err
    }

    return nil
}

アプリケーションのロジックを組み立てるときは、このレポジトリを利用してコードを書いていきます。いわゆるユースケース層です。今回はユーザー登録の流れを例としてます。レポジトリで定義されたインターフェースを呼び出しているだけなので、この部分でも裏側のリソースが何なのかを気にすることなく実装が出来ます。

ユースケースの流れ

このコードを動かす時は、UserRepo、EmailRepoを実際の外部リソースを使って実装することになります。 図の例としてはUserRepoをMySQLで、EmailRepoをAmazon SESとしてみました。

この状態でどうテストを書くのがいいのかを考えてみます。

ユースケース層のテストコードを書く

レポジトリをモックしてユースケースをテスト
先ほども書いたとおり、ユースケース層ではレポジトリを経由して呼び出しているため、バックエンドがなにかを気にせずロジックを組み立てることが出来ます。バックエンドが何でもいいので当然モックでも問題ありません。

なので gomock などのモックライブラリを活用し、レポジトリをモックした上でユースケースのテストを書きます。

以下は、gomock を使用して UserRepository と EmailRepository をモックし、UserService の RegisterUser 関数をテストする例です。

func TestRegisterUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockUserRepo := NewMockUserRepository(ctrl)
    mockEmailRepo := NewMockEmailRepository(ctrl)

    ctx := context.Background()
    user := &User{Name: "Test User"}
    email := "test@example.com"
    message := fmt.Sprintf("Hello %s, welcome!", user.Name)

    // UserRepoのSaveが1度呼ばれることを確認
    mockUserRepo.EXPECT().Save(ctx, user).Return(nil).Times(1)
    // EmailRepoのSendが1度呼ばれることを確認
    mockEmailRepo.EXPECT().Send(ctx, email, message).Return(nil)Times(1)

    service := &UserService{
        userRepo:  mockUserRepo,
        emailRepo: mockEmailRepo,
    }
    // mockを利用してRegisterUserを呼び出し
    err := service.RegisterUser(ctx, user, email)
    assert.NoError(err)
}

モックライブラリにはメソッドがどんな引数で、何度呼ばれたかを確かめる機能があります。これを活用し、ユースケースがどのような動きをして欲しいのかをテストすることが可能です。 今回はシンプルな例なのでありがたみが少ないですが、例えば「バリデーションでエラーが出たらユーザーのSaveやメールのSendが呼ばれないこと」「Save時にエラーが出たらメールのSendが呼ばれないこと」は同様にモックを使ってテストすることが出来ます。実際のアプリケーションではもっと処理が複雑になると思いますが、そういった場合でもしっかりテスト出来ることが強みです。

一方でモックのセットアップでコード量が膨れやすいという欠点もあるので網羅的なテストにはあまり向いていないです。ユースケースの処理を変更する度それに応じてテストもまとめて変更する必要があるので、代表的なシナリオをピックアップしてテストを書くことが多いです。細かいロジックに関しては切り出してユニットテストにするのが確実です。

レポジトリ実装のテストをする

残りのレポジトリ実装の部分

ユースケースの部分はテストをすることが出来ました。 残るレポジトリ実装の部分についてもテストが出来れば、アプリケーション全体からみて一連の動作確認が行えたと言えそうです。 しかし、こちらはMySQLだったりAmazon SESだったりと背後のリソースに依存する部分が大きく、実際に動かしてみないと確認が難しそうです。

考えられる方針としては

  • ローカルで実際のリソースを動かす
  • ローカルでエミュレーターを動かす
  • 実際のリソースに接続してしまう
  • モックを使って入出力だけを確認する

あたりがありますが、どれを利用するかはリソースによっても異なってきます。

ローカルで実際のリソースを動かす

MySQLの場合、Dockerなどで簡単に動かすことが出来るので、docker composeなどで開発環境と一緒に立ち上げてしまうのがお手軽です。

# compose.yaml 

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD=yes
      MYSQL_USER: testuser
    volumes:
      - db-data:/var/lib/mysql

volumes:
  db-data:

バージョンを揃えてあげることで本番環境との挙動のずれも最小限に抑えて動作確認ができます。 欠点としては動作を確認するためにデータベースを立ち上げたりデータをセットアップしたりと準備が多いことです。 また、CI上で動かすことを考えると、実行時間やCIサーバーのメモリの問題も出てきます。

ローカルでエミュレーターを動かす

DynamoDBのようにマネージドなサービスの場合、そのままローカルで動かすことは出来ません。 しかしDynamoDB localやLocalStackといった開発環境で代替する仕組みが公式・非公式で用意されていることも多く、そちらを活用することができます。 SESのようなサービスもLocalStackで模擬的に動かすことが可能です。(Community版はモックのみ)
Simple Email Service (SES) | Docs

AWSのSDKの場合バックエンドの Endpoint を差しかえられるようになっているため、これを docker compose で立ち上げたエミュレーターに向ければローカルで動作確認が可能です。

   var sesEndpoint *string
    // SES_ENDPOINT が定義されていたら上書きする
    if os.Getenv("SES_ENDPOINT") != "" {
        sesEndpoint = aws.String(os.Getenv("SES_ENDPOINT"))
    }
    sesClient := sesv2.NewFromConfig(cfg, func(o *sesv2.Options) {
        o.BaseEndpoint = sesEndpoint
    })

注意すべきところはやはりエミュレートしたものであることです。実環境とは異なる上限値が用いられていたりと完全な動作確認をすることは難しいです。

実際のリソースに接続してしまう

ローカルから直接実際のリソースに接続して確認するのも時には有効です。 動作を確かめる意味では確実ですし、流れているデータをそのまま利用できるなどの利点もあります。 反面、開発・テスト用のインフラを構築するコストや、リソースへセキュアに接続する方法をどうするかといった課題が出てきます。 テストコードから実際のリソースに接続するのも弊害が出やすいため、動作確認での利用に留めつつ、テストとしては別の手段を用意するのが賢明かもしれません。

モックを使って入出力だけを確認する

MySQLなどのデータベース接続ではsqlmock等を利用して、SQLの組み立てが期待通りかを確認する方法もあります。 クエリビルダーを活用しているときは有用な手段となります。

import (
    "context"
    "database/sql"
    "github.com/DATA-DOG/go-sqlmock"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestRegisterUser(t *testing.T) {
    db, mock, err := sqlmock.New()
    assert.NoErr(err)
    defer db.Close()

    ctx := context.Background()
    user := &User{Name: "Test User", Email: "test@example.com"}

    mock.ExpectExec("^INSERT INTO users \\(name, email\\) VALUES \\(\\?, \\?\\)$").
        WithArgs(user.Name, user.Email).
        WillReturnResult(sqlmock.NewResult(1, 1))

    repo := NewUserRepo(db)
    err = repo.Save(ctx, user)
    assert.NoErr(err)

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Errorf("there were unfulfilled expectations: %s", err)
    }
}

クエリビルダーを利用せず生のSQLを使って接続する場合などは、sqlmockを使用しても実際のSQLとテストのSQLが一致するかを確認するだけとなってしまい、意義のあるテストが行いづらいです。 モックを利用する場合なにをテストしたいのかが難しいところです。

まとめ

アプリケーション全体のテストを書くには、外部リソースに依存する部分をレポジトリとして抽象化することが大切です。 アプリケーションから外部依存をレポジトリとして分離することでロジック自体のテストが書きやすくなります。 完全なクリーンアーキテクチャを採用していなくても、外部依存要素を抽象化し、テストを簡素化するという考え方は有効です。 レポジトリの実装のテストは、実際のリソースを用いるかエミュレーターを利用するかなど、状況に応じて適切な方法を選択していきましょう。