booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

JavaのユニットテストにおけるDBテスト環境の改善

アイキャッチ

はじめまして(前回のブログを読んでくださった方は改めまして)。プロダクト開発部アプリケーションエンジニアの中村と申します。
現在、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のシステム開発を主な業務として日々取り組んでいます。
今回はReader Storeの開発中に行ったDB関連のユニットテストの改善の話をしていきます。

現状のテスト環境と問題点

Reader Storeではこれまでユニットテストで使用するDBを以下のように構築していました。

  1. MySQLのコンテナを起動
  2. DDLおよびテストデータのDMLが定義されたSQLファイルを読み込む

上記の処理をテスト起動時に行い、各テストでDMLに定義されたデータを使用してテストが行われておりました。
この場合、いずれかのテストでデータの追加・更新・削除を行なった場合に他のテストに影響を及ぼす可能性があるため、テスタビリティがよくないという問題がありました。

また、INSERTやUPDATEのテストを行う場合、結果の確認方法としてテストコード内でSELECT文を実行して取得した結果をAssertionしておりました。
それにより、テストコード内でテスト対象外の処理を実行しているという問題や単純にテストコードが肥大化してしまうという問題がありました。

上記の問題を解決するために、今回はDatabase Riderというライブラリを導入することにしました。

Database Rider

Database Riderとは

Database Riderは、DBUnitをよりJUnitのように記述できるようにしてデータベースのテストを簡単に書けるようにすることを目的としたライブラリになります。

公式リポジトリ:https://github.com/database-rider/database-rider

Javaでは元々DB Unitというデータベースのテストを行うためのフレームワークがあります。
データベースで使用するデータの定義をcsv, xls, xmlのいずれかで定義し、テストコード内でそのファイルを指定することでデータベースにテスト用のデータをロードできます。 また、DML実行後の結果となるデータをファイルで定義して、それを実際のデータベースのデータとAssertionするといったこともできます。
しかし、それをするためにはコードを複数行書く必要がありました。
Database Riderでは、データのロードや結果のAssertionをアノテーションを使用して1行で済ませることができ、より使いやすいものになりました。
また、ファイルのフォーマットについて、上記に加えてjsonやyamlなどモダンなフォーマットもサポートしています。

Database Riderの使い方

Database Riderの使い方について簡単に説明させていただきます。

※今回はSpringBoot + JUnit5を使用している前提となります。

依存関係の追加

使用しているMavenもしくはGradleの依存関係に以下を追加します。

testCompile "com.github.database-rider:rider-junit5:{バージョン}"

テストクラスの定義

Database Riderを使用してテストしたいクラスには@DBRider および @DBUnit を付与します。

@DBRider
@DBUnit
@SpringBootTest
public class SampleRepositoryTest {
...
}

テストメソッドの定義

Database Riderを使用してテストしたいメソッドには、テストの目的に応じてアノテーションを付与していきます。

SELECT系のテスト

SELECT系のクエリのテストでは、まずは検索したいデータをファイルで定義します。
例えばyaml形式で定義する場合には、以下のように定義します。

sample_table:
  - column_a: "aaa" ← 1レコード目
    column_b: "bbb"
  - column_a: "ccc" ← 2レコード目
    column_b: "ddd"

※他のテーブルも使いたい場合は下に同様に定義する

なお、データを定義したファイルは、xxx/test/resources/配下に配置する必要があります。(左記ディレクトリ配下にディレクトリを追加することは可)
例えば、以下のようなディレクトリ構成でテストごとのデータファイルを管理するなどです。
xxx/test/resources/datasets/{クラス名}/{メソッド名}/{テストケース}/init.yml

データ定義ファイルの用意が完了したら、テストメソッドでそのファイルをロードするための定義をします。
データをロードするためには@DataSetをメソッドに付与します。
@DataSetの引数には、データ定義ファイルのパスを指定します。(xxx/test/resources/までのパスは省略してそれより配下のパスを記述)

@Test
@DisplayName("データ検索テスト")
@DataSet({"/datasets/SampleRepository/getData/normal/init.yml"})
public void getData_normal() throws Exception {
  List<Sample> expected = List.of(new Sample("aaa", "bbb"), new Sample("ccc", "ddd"));
  List<Sample> actual = sampleRepository.findAll();
  assertEquals(expected, actual);
}

これにより、データベースに必要なデータをロードできます。
また、ロード時には対象のテーブルに元々入っていたデータはクリアされるため、別のテストのデータと混ざることはありません。

なお、@DataSetの引数で指定するファイルパスは配列になっています。
例えばテストで複数のテーブルを使用する際に、各テストにおいて同じデータで良いテーブルとテストごとにデータを変えたいテーブルがあったとします。
そのような場合は、共通のテーブルデータを定義したファイルとテストごとのテーブルデータを定義したファイルを分けて複数のファイルをロードするということもできます。

@Test
@DisplayName("データ検索テスト")
@DataSet({"/datasets/SampleRepository/getData/common_init.yml", "/datasets/SampleRepository/getData/normal/init.yml"})
public void getData_normal() throws Exception {
  List<Sample> expected = List.of(new Sample("aaa", "bbb"), new Sample("ccc", "ddd"));
  List<Sample> actual = sampleRepository.findAll();
  assertEquals(expected, actual);
}

INSERT系のテスト

INSERT系のクエリのテストでは、実行結果と一致するデータをファイルで定義します。
ファイルはSELECTと同じように用意します。

データ定義ファイルの用意が完了したら、テストメソッドの定義をします。
INSERTでは実行結果のAssertionをするために@ExpectedDataSetを使用します。
@ExpectedDataSetの引数には、データ定義ファイルのパスを指定します。

テーブルにはよくレコードの追加日時や更新日時をカラムに持たせることがあります。
そのようなカラムはテスト実行ごとに値が変わるため、正しくAssertionできません。
そのような場合は、@ExpectedDataSetの引数にignoreColsでAssertionの対象外にしたいカラム名を指定します。

なお、別のテストのデータが残っていると正しくテスト結果の確認ができないため、@DataSetの引数にcleanBefore = trueを指定してテーブルのデータをクリアできます。
ただし、cleanBefore = trueでは全てのテーブルのデータがクリアされてしまうので、クリアしたくないテーブルがある場合はskipCleaningForでテーブル名を指定してください。

@Test
@DisplayName("データ登録テスト")
@DataSet(cleanBefore = true, skipCleaningFor = {"other_table"})
@ExpectedDataSet(value = "/datasets/SampleService/registerData/normal/expected.yml", ignoreCols = {"insert_at", "update_at"})
public void registerData_normal() throws Exception {
  sampleRepository.insert(new Sample("aaa", "bbb"));
}

@ExpectedDataSetはデフォルトでは全レコードを完全一致でチェックするため、@DataSet(cleanBefore = true)で事前にデータをクリアするやり方を説明しました。
それとは別に、@ExpectedDataSetの引数にcompareOperation = CompareOperation.CONTAINSを指定する方法もあります。
この引数を指定することで、データをクリアせずとも必要なデータが存在することの確認だけテストできます。
このやり方であれば、他のテストでテーブルのデータがどうなっているのかを気にする必要はありません。(ただし、INSERTするデータの主キーが既存データと被らないように気をつけてください)

@Test
@DisplayName("データ登録テスト")
@ExpectedDataSet(value = "/datasets/SampleService/registerData/normal/expected.yml", compareOperation = CompareOperation.CONTAINS)
public void registerData_normal() throws Exception {
  sampleRepository.insert(new Sample("aaa", "bbb"));
}

UPDATE系のテスト

UPDATE系のクエリのテストでは、更新対象のデータと実行結果のデータをそれぞれファイルで定義します。
ファイルはこれまでと同じように用意します。

データ定義ファイルの用意が完了したら、テストメソッドの定義をします。
UPDATEではデータをロードするために@DataSetを使用し、実行結果のAssertionをするために@ExpectedDataSetを使用します。

@Test
@DisplayName("データ更新テスト")
@DataSet("/datasets/SampleService/updateData/normal/init.yml")
@ExpectedDataSet("/datasets/SampleService/updateData/normal/expected.yml")
public void updateData_normal() throws Exception {
  sampleRepository.update(new Sample("aaa", "ccc"));
}

導入時に気をつけたところ・ハマったところ

今回Database Riderを導入するにあたり、既存のテストを一度に置き換えるのは難しいため、まずは新しく実装するテストだけ使用するようにしました。
しかし、既存のテストとデータベースを共有してしまうと、Database Riderによってテーブルがクリアされることにより、既存のテストが動かなってしまいます。
それを回避するために、今回はDatabase Riderを使用するテストのためにデータベースを分けるという対応にしました。
具体的なやり方ですが、テストで使用するデータベースは上で記載したとおりMySQLのコンテナを実行時に立ち上げています。
コンテナで使用するポートはテスト用のconfigファイルで定義しているため、新たなconfigファイルを作成して別のポート番号を指定するようにしました。
そして、Database Riderを使用するテストでProfileを新しく作成したconfigを向くように指定することで、テストごとに使用するデータベースを変えることが実現できました。
それによって、既存のテストには影響を与えずに導入できました。

しかし、Profileを分けたことにより別の問題が発生しました。
新しく作ったテストとControllerのテストをまとめて実行(gradlew test)すると、エラーが起きるようになりました。
どうも使用しているMockライブラリが悪さをしているようなのですが、解決方法がわからなかったため、今回はテスト実行用のGradleタスクを作成し、パッケージごとに分けて実行できるようにしました。
Profileを分けただけでエラーになるとは思わず、新たなテストの課題が出来てしまったのは残念ですが、いずれ解決していきたいです。

まとめ

新たなライブラリを導入することで、データベースのテストをより書きやすくできました。
これでチームのテスト作成の効率化に繋がれば幸いです。
また、今後もテスト環境の改善を進めてテストを充実させて、サービスの品質も高めていきたいです。

今回ご説明した機能はDatabase Riderの基本的なものと一部のオプションになります。
他にも様々な機能がありますので、Database Riderを使ってみたいという方はご自身のテスト環境に合った機能を見つけてみてください。
この記事の内容が、Javaのテストを作成する方の何かしらの助けになれば幸いです。

Github Copilot を使ってみた感想

アイキャッチ

はじめまして。株式会社ブックリスタ プロダクト開発部でエンジニアをしている姚と申します。

私は一年前から個人で Github Copilot を使っていて、最近会社でも GitHub Copilot for Business を試験導入されました。
今回は、Github Copilot の概要と、会社での導入状況、先行利用者が使ってみた感想や利用時の注意点などを紹介します。

Github Copilot とは

Github Copilot は、AI によるコード補完機能を提供するサービスです。
コメントからコードへの変換、コードブロック、重複コード、メソッドや関数全体の自動補完など、プログラマーを支援する機能が含まれています。

会社での導入状態

ブックリスタでは、AI を積極的に活用し、エンタメテックを目指しています。
先月から少人数で GitHub Copilot for Business を試験導入して、先行利用者から好評を得ていますので、今後は利用範囲を拡大していく予定です。

使ってみた感想

良いところ

  • 生産性が向上する
    • 利用したことがない言語やフレームワークでも実装が容易になる
    • ほぼ修正が不要なレベルの、簡単でよくある実装が出てくる
    • テスト観点を書けば、テストコードを生成してくれる
    • IDE より高度な補完が効く
      • 変数の命名すると型を提案してくれる
      • 関数の入出力パターンを他と合わせてくれる
      • コードを書いている最中の文脈に合わせて、コードを補完してくれる
      • プロジェクト全体のコードを分析して、コードの一貫性を保つようにサジェストしてくれる
  • 開発体験が良くなった
    • 基本サジェストを採用し、サジェスト内容を少し修正する程度で済む
    • もう一人の開発者のような存在で、ペアプログラミングのような開発体験ができる

使える事柄

メソッドの自動補完

メソッドや関数の名前を入力すると、そのメソッドや関数の中身を自動補完してくれます。

灰色の文字は、GitHub Copilot が自動補完した部分です。

コードブロックの自動補完

テーブル定義やテストケースをコメントとして貼り付けたら、コードブロックの中身を自動補完してくれます。

複数のサジェストを提案してくれます

デフォルトのサジェストが気に入らない場合は、 GitHub Copilot を開く (別のペインに追加の候補) の機能があり、複数のサジェストを提案してくれます。


悪いところ

  • 言語によって向いている向いていないがある
    • 型がある言語の提案は良いけど、型がない言語の提案はイマイチ
    • HTML/CSS だと指定したデザインの自動生成は難しい
  • 更新が早いライブラリだと古い実装を提案される
  • 提案されたコードが良いコードである保証はない
    • 特に汚いコードの中で使おうとすると提案も悪くなる
      Copilot は既存コードと一貫性のあるコードをサジェストしてくれるため、既存のコードが汚いと、良いコードをサジェストしてくれない
  • インライン候補は行の末尾にしか表示されないため、行の途中でサジェストさせることができない


バグや脆弱性があるコードが含まれる可能性がある

例えばうるう年の判定は例外として、西暦年号が 100 で割り切れて 400 で割り切れない年は平年としていますが、Github Copilot が生成したコードにこの判定が含まれていませんでした。


利用時の注意点

  • 良いコードをサジェストさせるには、コードの前に具体的な処理内容のコメントを書いたり、関数名や変数名に適切な名前を付ける必要がある
  • Copilot から提案されたコードは、必ずしも良いコードではないので、開発者がコードを読んで理解した上で、正しいかどうかを判断する
  • Copilot は重複コードを生成することがあるので、DRY 原則を意識する必要がある



まとめ

Github Copilot を使うことで、開発体験が良くなり、生産性が向上すると感じました。
今後も、Github Copilot を使い続けていきます。

今後の展望

Github Copilot は既存コードのリファクタリングやバグの修正などにまだ不足点がありますが、それらは Github Copilot Labs で改善されることを期待しています。

Next.jsのブラウザーエラーログを収集するためのNew Relic導入方法

アイキャッチ

株式会社ブックリスタ プロダクト開発部エンジニアの土屋です。 現在、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」におけるフロントエンド領域の刷新に取り組んでいます。 今回、表題の通りブラウザーエラーログ収集を目的とし、Next.jsにNew Relicを導入したため、その経緯と導入方法についてまとめました。

New Relicについて

公式サイトから引用。

New Relicは、モバイルやブラウザのエンドユーザーモニタリングや、外形監視、バックエンドのアプリケーションとインフラモニタリングなど、オンプレやクラウド、コンテナからサーバレスまであらゆるシステム環境での性能管理を実現するプラットフォームです。

New Relicは上記の通りアプリケーション監視の多岐にわたる機能を備えているサービスです。 Reader Storeではサーバーのトラフィックや性能監視のために以前から利用しています。

導入経緯

現在、Reader Storeではレガシーなフロントエンド環境(PHP/Java+JQuery)からNext.jsへ置き換えを進めています。その中でブラウザーのエラーログを収集したいという要件がありました。 ブラウザーエラーを収集することで、不具合の早期発見や、特定のOS/ブラウザーでのみ発生するような不具合の解析に役立ちます。 以上の理由から今回、ブラウザーエラー収集の機能を持つNew Relicブラウザーモニタリングを導入しました。 Datadog, Sentry等ブラウザーエラーの収集ができるサービスは他にもいくつかあります。New RelicはAPIサーバー等の監視で既に導入済みのため、管理を一元化できることから選択しています。

導入方法

New Relicブラウザーモニタリングの導入方法は、コピー&ペースト方式とAPMエージェントを利用する方式があります。
https://newrelic.com/jp/blog/how-to-relic/what-is-the-difference-between-apm-agent-and-copy-paste

  • コピー&ペースト方式

    名前の通りNew Relicが提供するjsコードスニペットをそのままコピー&ペーストする方式です。

  • APMエージェント利用方式

    サーバーサイドでAPMエージェントを利用するためのスクリプトを生成し、それをhtmlに埋め込みます。APMエージェントの接続先を環境変数等で指定できるメリットがあります。

コピー&ペースト方式はデプロイする環境ごとにコードスニペットを用意しなければならず、管理が煩雑になるため、APMエージェントを利用する方式を選びました。 すこし前までは、APMエージェントを利用する方式だとSSGで生成したページに適用できず、静的ページには別途コピー&ペースト方式で導入する必要がありましたが、 v9.8.0からSSGにも対応しました。 そのため初期導入のしやすさを除いてコードスニペット埋め込み方式を選ぶメリットは無くなっています。 https://docs.newrelic.com/jp/docs/release-notes/agent-release-notes/nodejs-release-notes/node-agent-9-8-0/

この記事ではnode.js向けパッケージ(newrelic)を利用してAPMエージェントをインストールします。 @newrelic/nextというパッケージがありますが、こちらはNext.jsサーバーのパフォーマンスを監視するものになり、この記事では触れません。

前提条件

  • New Relicのアカウントが作成してある
  • newrelicパッケージのv9.12時点での導入方法

導入手順

  1. New Relicでブラウザーモニタリングの機能を有効にします

    https://docs.newrelic.com/jp/docs/browser/browser-monitoring/installation/install-browser-monitoring-agent/

  2. Next.jsプロジェクトにnewrelicパッケージをインストール

     npm i newrelic
    
  3. _document.tsxでnewrelicのスクリプトを埋め込む

    _document.tsxは主に共通のメタタグを埋め込むために利用します。以下はnewrelicのスクリプトを埋め込むサンプルです。

     const newrelic = require("newrelic");
    
     type ExtendedDocumentProps = DocumentInitialProps & {
       browserTimingHeader: string;
     };
    
     class MyDocument extends Document<ExtendedDocumentProps> {
       static getInitialProps = async (
         ctx: DocumentContext
       ): Promise<ExtendedDocumentProps> => {
         const initialProps = await Document.getInitialProps(ctx);
    
         // see https://github.com/newrelic/newrelic-node-nextjs#client-side-instrumentation
         if (!newrelic.agent.collector.isConnected()) {
           await new Promise((resolve) => {
             newrelic.agent.on("connected", resolve);
           });
         }
    
         const browserTimingHeader = newrelic.getBrowserTimingHeader({
           hasToRemoveScriptWrapper: true,
           allowTransactionlessInjection: true,
         });
    
         return {
           ...initialProps,
           browserTimingHeader,
         };
       };
    
       render = () => {
         const { browserTimingHeader } = this.props;
    
         return (
           <Html lang="ja">
             <Head>
               <script
                 type="text/javascript"
                 dangerouslySetInnerHTML={{ __html: browserTimingHeader }}
               />
             </Head>
             <body>
               <Main />
               <NextScript />
             </body>
           </Html>
         );
       };
     }
    
     export default MyDocument;
    

    以下の部分でAPMエージェントを読み込むためのスクリプトを取得しています。

     const browserTimingHeader = newrelic.getBrowserTimingHeader({
       hasToRemoveScriptWrapper: true,
       allowTransactionlessInjection: true,
     });
    
    • hasToRemoveScriptWrapperをtrueにすることで、scriptタグを除いた状態で取得できるのでhead内のscriptタグの中に埋め込む
    • allowTransactionlessInjectionはv9.8.0から追加された新しいオプションで、これをtrueにすることでSSGのタイミングでもスクリプトが生成される

    allowTransactionlessInjectionオプションを有効にする場合はnewrelic.getBrowserTimingHeaderを呼び出す前に、以下のようにNew Relicエージェントとnode.jsのコネクションが確立するまで待つ必要があります。

     if (!newrelic.agent.collector.isConnected()) {
       await new Promise((resolve) => {
         newrelic.agent.on("connected", resolve);
       });
     }
    
  4. 環境変数設定

    newrelicに必要な環境変数を.envに定義します。

    アプリ名は1の手順で有効にしたアプリの名前、ライセンスキーはこちらのライセンスキーになります。

     NEW_RELIC_APP_NAME={アプリ名}
     NEW_RELIC_LICENSE_KEY={ライセンスキー}
    

以上でAPMエージェントが利用できるようになり、コアウェブバイタルなどの測定が可能になりました。

しかし、これだけではjsエラーログを収集するには不十分です。実際にブラウザー側に配信されるjsはバンドルされたものであるため、トレースログを表示するにはsourcemapをNew Relicに連携する必要があります。

sourcemapの連携

Next.jsの場合、next buildを行うと、通常.next/static/chunks配下にsourcemapが生成されるため、それをNew Relicにアップロードします。

以下がNew Relicにsourcemapをアップロードする際に利用しているスクリプトのサンプルです。

const fs = require("fs");
const path = require("path");

const {
  publishSourcemap,
  deleteSourcemap,
  listSourcemaps,
} = require("@newrelic/publish-sourcemap");
const recursive = require("recursive-readdir");

// 以下に記載のブラウザーアプリIDを設定
// https://docs.newrelic.com/jp/docs/apis/rest-api-v2/get-started/get-app-other-ids-new-relic-one/
const NR_APP_ID = /* ブラウザーアプリID */;

// 以下に記載のブラウザーキーを設定
// https://docs.newrelic.com/jp/docs/apis/intro-apis/new-relic-api-keys/
const NR_API_KEY = /* APIキー */;

// srcmapの配置ディレクトリー
const SRC_MAP_DIR = "path/to/.next/static/chunks";
// jsファイルを配信する際のベースURL
const STATIC_FILE_BASE_URL = "https://yourdomain/_next/static/chunks";

const SRC_MAP_CHUNK_SIZE = 50;

// newrelic上のソースマップを全件取得
const listSrcMap = (list = [], offset = 0) => {
  return new Promise((resolve, reject) => {
    listSourcemaps(
      {
        applicationId: NR_APP_ID,
        apiKey: NR_API_KEY,
        limit: SRC_MAP_CHUNK_SIZE,
        offset: offset * SRC_MAP_CHUNK_SIZE,
      },
      (err, res) => {
        if (err) return reject(err);

        const allMaps = [...list, ...res.sourcemaps];

        // 取得した件数がlimit未満なら抜ける
        if (res.sourcemaps.length < SRC_MAP_CHUNK_SIZE) {
          return resolve(allMaps);
        }

        // limitと同値ならこのメソッドを再起的に呼びだす
        listSrcMap(allMaps, offset + 1)
          .then((list) => resolve(list))
          .catch((e) => reject(e));
      }
    );
  });
};

// newrelic上のソースマップ削除
const deleteSrcMap = (srcMaps) => {
  return Promise.all(
    srcMaps.map((srcMap) => {
      return new Promise((resolve, reject) => {
        deleteSourcemap(
          {
            sourcemapId: srcMap.id,
            applicationId: NR_APP_ID,
            apiKey: NR_API_KEY,
          },
          (err, res) => {
            console.log(err || `Sourcemap ${srcMap.javascriptUrl} deleted.`);
            if (err) return reject(err);
            resolve();
          }
        );
      });
    })
  );
};

const IGNORE_FILES = ["*.js"];
// newrelic上にソースマップ登録
const uploadSrcMap = () => {
  recursive(SRC_MAP_DIR, IGNORE_FILES, (err, files) => {
    // mapファイル以外のものは無視
    const mapFiles = files.filter((file) => file.endsWith(".js.map"));

    mapFiles.forEach((file) => {
      // scriptタグのsrc属性に設定されるjsリソースパス
      const jsFileSrcUrl = `${STATIC_FILE_BASE_URL}/${path.relative(
        SRC_MAP_DIR,
        file
      )}`.slice(0, -4);

      publishSourcemap(
        {
          sourcemapPath: file,
          javascriptUrl: jsFileSrcUrl,
          applicationId: NR_APP_ID,
          apiKey: NR_API_KEY,
        },
        (err) => {
          console.log(err || `Sourcemap ${jsFileSrcUrl} upload done.`);

          // mapファイルは公開しないため削除
          fs.unlinkSync(file);
        }
      );
    });
  });
};

listSrcMap().then(deleteSrcMap).then(uploadSrcMap);
  • @newrelic/publish-sourcemapパッケージを利用し、sourcemapをアップロードしている
  • アップロード最大容量の制限(50MB)があるため、事前に古いsourcemapを削除している

Reader StoreではAWSのcodebuildを使ってNext.jsアプリのビルドを行う際、上記スクリプトを実行してsourcemapを更新するようにしています。 アップロード手段についてはcurlでapiエンドポイントを直接叩いたりnpmスクリプトを利用する方法が提供されているため、運用に合わせてどうアップロードするかを選んでください。
https://docs.newrelic.com/jp/docs/browser/new-relic-browser/browser-pro-features/upload-source-maps-api/

以上でNew Relicのブラウザーエラー収集の準備は完了です。 ブラウザーでエラーが発生した際は、New Relic管理画面 > Browser > JS Errorsにエラーが表示され、Error Instancesタブから以下のように発生箇所がトレースできるようになります。

トレースログ

苦労したところ

  • 必要のない大量のエラーを拾ってしまう

    本番環境で運用したところ、Google Tag Managerで読み込んでいるサードパーティjsなどで大量のエラーが発生しており、Next.jsアプリのエラーを見つけにくい状態になっていました。

    そのためNext.jsアプリで発生したエラーのみ一覧できるよう、以下のnrqlを作成し外部スクリプト起因のエラーをフィルターした状態のダッシュボードを作成しました。

    ダッシュボードであれば無料のアカウントでも参照できるため、まずはこのダッシュボードでエラーが発生していないか確認する運用をしています。

      SELECT
          * 
      FROM
          JavaScriptError 
      WHERE 
          errorMessage != 'Script error.' 
          AND 
          stackTrace != ''
      SINCE 7 day ago
    
    • ErrorBoundaryでエラーをキャッチし、newrelicのnoticeError APIを使ってエラーを送付することでアプリのエラーだけ拾う方法も考えられます。ErrorBoundaryで拾えるのはレンダリング時のエラーだけでイベントハンドラのエラーは拾えないという問題がありReader Storeでは採用しませんでした
  • エラーを拾えているのかわからない

    現在Reader StoreでNext.jsを利用しているページはごく僅かで、開発規模もそこまで大きくないため、現状プロダクトコード起因のエラーが発生していません。それによりエラーが発生していないのか拾えていないのかわからない状態になってしまいました。 対策として意図的にエラーを発生させるhooksを作成し、エラーが拾えるか各環境で確認しています。

      // 特定のコマンドを入力した際にエラーを発生させるhookの例
      const COMMAND = [
        "ArrowUp",
        "ArrowUp",
        "ArrowDown",
        "ArrowDown",
        "ArrowLeft",
        "ArrowRight",
        "ArrowLeft",
        "ArrowRight",
        "a",
        "b",
      ];
      export const useTestError = () => {
        useEffect(() => {
          let typedKeys: string[] = [];
          const clear = debounce(() => {
            typedKeys = [];
          }, 10000);
          const handler = (e: KeyboardEvent) => {
            typedKeys.push(e.key);
            if (
              JSON.stringify(typedKeys.slice(-COMMAND.length)) ===
              JSON.stringify(COMMAND)
            ) {
              typedKeys = [];
              throw new Error("TEST ERROR!!");
            }
            clear();
          };
          window.addEventListener("keyup", handler);
          return () => {
            window.removeEventListener("keyup", handler);
          };
        }, []);
      };
    

今後やっていきたいこと

  • 現状はダッシュボードを日々確認する運用だが、有意にエラーの数が増えたらslackに通知するような仕組みも入れて、不具合の早期発見に役立てていきたい
  • 今回はほとんど触れてないがNew RelicはAPMとしての機能が豊富なため、アプリのパフォーマンス測定と改善にも取り組んでいきたい

Datadog Synthetics Testsによる外形監視の自動化について

アイキャッチ

こんにちは。プロダクト開発部QAエンジニアの岡と申します。
弊社は「auブックパス (運営:KDDI株式会社)」と包括的なパートナーシップを結び、開発を行なっています。
こちらのサイトの監視に、Datadog Synthetics Tests(ブラウザーテスト)を導入して自動化したことについてお伝えします。

サイトの監視を自動化した結果、障害の検知と解決を迅速に行うことができるようになりました。

この記事で伝えたいこと

Datadog Synthetics Tests 導入理由

auブックパスは24時間365日営業しているため、ユーザーが本を購入できない、または読めないといった障害が発生することは許容できません。これまでサイトの監視を外部に委託していましたが、今後は障害の検知と解決を迅速に行うため、サイトの監視を内製化することにしました。また、内製化によるコスト削減も狙いのひとつです。システム構築コストも抑えるため、サイトの監視を簡単に実現できる有償ツールであるDatadog Synthetics Tests(ブラウザーテスト)を採用しました。

Datadog Synthetics Tests の概要

Datadog 公式URL

公式引用

Synthetic ブラウザテストを使用して、エンドツーエンドのテストで世界中の Web ページを顧客がどのように体験しているかを監視します。

Datadog Synthetics Testsには、公式サイトで紹介されている通り、ブラウザーテスト機能があります。レコーディング機能を使ってブラウザーの操作を記録し、ノーコードでブラウザーテストを作成できます。また、外形監視として障害の検知やサイトのレスポンスタイムの確認などを簡単に行うことができます。

ブラウザーテスト作成手順

まず、ブラウザーテスト作成手順について、「考慮したポイント」を交えて紹介します。

1. ブラウザーテストの作成開始

Datadogにアクセスし、ブラウザーテストを作成するためのダッシュボードに移動します。
Ux Monitoring > Synthetic Testsを選択し、画面右上の+New Testsをクリックして Browser Testをクリックします。

2. 設定

Set your test details

以下を設定します。

  • Starting URL:テスト対象のURL
  • Name:テスト名
  • Envrionment:環境(任意)
  • Additional Tags:タグ(任意)
    • 分類に使用
  • Advanced Options(任意)
    • Cookie設定、Proxyの設定、Basic認証などの設定が可能

Browsers & Devices

実行クライアント環境は「Laptop Large」「Tablet」「Mobile Small」「Chrome」「Firefox」「Edge」の組み合わせが可能です。

Select locations

アクセス元の設定をします。

  • 設定例:Tokyo (AWS)を選択

Define retry conditions

テスト失敗時の待機時間と再実行回数を設定します。

  • 設定例:テスト失敗後10000ミリ秒待機し2回リトライ

Define scheduling and alert conditions

テスト実行間隔の設定をします。

  • 設定例:テストを5分間隔で実行
  • Advancedでは詳細な時間設定が可能

アラートの閾値の設定をします。

  • 設定例:過去8分間に1か所でエラーが発生した場合、テストはアラートステータスになり通知が送信される

(ポイント) 実行コストとのバランスを考慮して、監視対象の重要度に応じて間隔を設定しました。参考:Datadog料金 (15ドル / 1,000テスト)

Configure the monitor for this test

障害検知時の通知方法を設定します。
通知方法としては、メール、Slack、PagerDuty、Webhookなど、豊富な選択肢があります。
※ここは通知作成方法で詳しく触れます。

設定が完了したらSave & Edit Recordingをクリックしてレコーディングに進みます。

4. レコーディングの開始

レコーディング開始前にChrome拡張機能「Datadog test recorder」をインストールする必要があります。

レコーディング画面では、 ノーコードで簡単にブラウザーテストが作成できます。
Start Recordingでレコーディングを開始します。右側に表示されたブラウザーでの操作が、左側に記録されていきます。

(ポイント) テストの対象は、外部連携している箇所など障害が発生しやすい箇所に絞って監視しています。 監視対象が増えると誤検知が増え、不要なアラートが上がってしまい、テストの実行コストも増えてしまいます。

(ポイント) 障害の発生頻度が高い箇所を中心にしてテストシナリオのステップ数を最小限に抑えました。
テストシナリオのステップ数が多いと、本来検出したい障害ではない箇所でテストが失敗して原因の特定が困難になります。そのため、障害の発生頻度が高い箇所にアサーション(検証)を配置し、ステップ数を絞りました。

5. 自動記録された内容を確認して調整

以下を必要に応じて調整します。

  • Step Name: シナリオのステップ名
  • Target Element: 操作対象
  • Click type: 操作対象のクリック方法
  • Advanced Options > User Specified Locator: ユーザー指定のロケーターが使用可能
    • 例えば、idやテキストを起点として要素を指定するためにXPathを使用するなど、柔軟な対応が可能
  • Wait for 60s before declaring this step as failed: 検証実行前にwait(待機ステップ)を挿入
  • Continue with test if this step fails: このステップが失敗したときにテストを続行するか選択

自動記録以外にも手動でアサーション(検証)ステップを追加できます。

(ポイント) テストステップ名を編集し、各ステップが何をテストしているのかを分かりやすくすることで、障害の発生箇所の特定が容易になります。

(ポイント) テストシナリオに分岐機能がないため、Continue with test if this step failsの設定を使用して分岐を実現しました。 例えば、特定の要素が表示された場合にのみアサーションを実施したい場合があります。シナリオステップのContinue with test if this step failsにチェックを入れることで、要素がある場合のみアサーションを実施するように設定することで分岐を実現しました。

(ポイント) 部分的にユーザー指定のロケーターを使用しました。
auブックパスのフロントエンドではReactを使用しています。Reactを使用する場合、デフォルトでは要素にIDが付与されないため、テキスト表示のない要素を特定したり、階層が変更された場合のテストメンテナンスが困難です。この課題に対処するため、開発者に対してテスト対象の要素にIDを付与するように依頼しました。付与されたIDを使用して、Advanced Options > User Specified Locatorでカスタムロケーターを指定しました。これにより、画面変更の際にもテストメンテナンスが容易になりました。

6. カスタムスクリプトの作成

✔︎Assertion > 下から2番目のTest custom JavaScript assertionを選択すると、JavaScriptの記述が可能になります。
クリックを繰り返す、サイト内に表示された値を変数に格納し後で検証に使用するなどの処理をJavaScriptで記述することにより、テストが拡張できます。
JavaScriptの記述以外にも、メールの受信テスト、DLファイルのテスト、サブテスト機能など機能が豊富です。

7. テストシナリオの実行

Run Test Nowで作成したシナリオを実行します。
テスト実行結果はダッシュボードで確認できます。結果をクリックして詳細を確認します。

  • Faild(失敗)の場合は作成したテストシナリオに問題が無いか、内容を見直す
  • 実行時の画面キャプチャーがあり、レスポンスタイムの確認が可能なため、テスト失敗の原因を特定できる

上記の場合、「モーダル画面が表示されてしまい要素のクリックができず、次の画面に遷移できなかった」がテスト失敗の原因です。そのためモーダル画面を閉じる処理を追加するとテストが成功します。

8. テストの修正

さきほど実行したテストが失敗しているので、「モーダル画面が表示された場合にクリック」というステップを追加します。

テストが成功したので、ブラウザーテストは完成です。

通知作成方法

つぎに、作成したブラウザーテストが障害を検知したときに通知する方法を紹介します。
電話通知設定と、Slack通知設定の2つを実施します。

電話通知設定

以前、弊社のテックブログ Amazon Connectを使った障害発生時の自動オンコール実現について でご紹介した方法を実施します。

使用する要素は以下の4つです。

  • Datadog
  • AWS SNS
  • AWS Lambda
  • Amazon Connect

1. 初期設定

電話番号を取得するための申請などを行います。 参照:初期設定について

2. 架電設定

参照:架電用のAmazon Connectの問い合わせフロー(SNS, Datadog設定, Lambda)について

3. Datadog側の設定

2の最後ではモニターに対して設定する例がありますが、シナリオに設定もできます。
今回は、シナリオテストの失敗(Faild)を検知して電話の着信で知らせるため、以下のように設定をします。

  • ブラウザテストの設定 > Configure the monitor for this test > Notify your team

4.調整

障害検知の条件を調整します。
Datadog Monitors機能とalert conditionsを設定し、リトライでテストが成功した場合は即時復旧したとみなし、障害とはみなさないように調整しました。
これにより、不要な電話通知を防ぎつつ、実際の障害を正確に検知できています。

Slack通知設定

1. Slack側の設定

Datadog公式参照

2. Datadog側の設定

こちらも、シナリオテストの失敗(Faild)を検知してSlackに通知するため、以下のように設定をします。

  • ブラウザテストの設定 > Configure the monitor for this test > Notify your team

定期実行を開始

テストシナリオの「Resume Scheduling」で定期実行を開始します。 障害発生時にSlackと電話に通知されます。

運用手順

ブラウザーテストの作成と、障害の検知したときの通知方法の設定が完了したので、さいごに運用手順を紹介します。

1. 障害の発生

障害が発生すると、電話による通知があり、同時にSlackチャンネルにも通知が行われます。

2. 障害の特定

Slackのメッセージリンクからテスト失敗箇所へアクセスし、障害発生時のスクリーンショットやテストステップを確認できます。
テストシナリオ数とステップ数を厳選し、テストステップ名の編集により、各ステップが何をテストしているのかを分かりやすくすることにより、障害の発生箇所を迅速に特定できるようになりました。

3. 障害対応、復旧

障害の発生箇所が特定されたら、それに基づいて障害対応が行われます。 復旧した場合にも電話での通知とSlackへの通知が行われるように設定できます。(障害が発生している間もテストのスケジュール実行は継続されるため、復旧も即時検知されます)

4. 分析

電話通知やSlack通知により、障害発生から解消までの経過や復旧までの期間などをダッシュボードで確認できます。
これにより、障害の計測や原因分析が可能となり、障害の傾向やパフォーマンスの改善点を特定し、将来の問題を予防するための情報を得ることもできます。

さいごに

Datadog Synthetics Tests(ブラウザーテスト)を導入し、活用することで、障害の検知と解決を迅速に行うことができるようになりました。 弊社ではSynthetics Testに限らず、様々な方法でサイトの品質を担保しています。
お客様が快適にサイトをご利用いただけるよう、品質の向上を目指します。

NFTを入館証代わりにしてみた話

アイキャッチ

自己紹介

株式会社ブックリスタでエンジニアをしている城、椛澤、尾崎と申します。 私たちは日頃の主業務が異なりますので、それぞれ簡単に紹介させていただきます。

  • 城 :スマートフォンアプリ「YOMcoma」の開発
  • 椛澤:電子書籍ストアのプロジェクトマネージャー
  • 尾崎:電子書籍関連システムのプロジェクトマネージャー

活動概要

プロジェクト発足の経緯

「NFT」というワードについて、近年耳にする機会が増えています。 ブックリスタでは「エンジニアラボ」という取り組みを実施しており、 その中で「NFT」をテーマに1年間研究し、ブックリスタでの「NFT」の活用可能性を模索しました。

エンジニアラボとは

業務とは直接関係しない研究開発プロジェクトを行うことができる制度です。 有志で集まったメンバーが業務時間の最大20%を使い、1年間テーマに沿って研究開発することで、 自己研鑽を高めつつ、成果によってはサービス化に繋がることがあります。

  • 2023/1/30 公開の「コミックの類似性の算出とそのシステム構成について」でご紹介した「似た商品を探す」機能が、エンジニアラボの研究成果によってサービス化されました

NFTとは

NFTは非代替性トークン(Non-Fungible Token)の略称で、電子データに一意のトークンを付加することで 電子データに固有の価値をもたらすことができます。 画像、動画、音声はもちろん、チケットやコミュニティへの参加権など本来複製可能なデータに対して一意のアイテムとして扱うことができます。 トークン自体はブロックチェーン上に記録されるため、システム構成としてはブロックチェーンを中心としたシステム構成になります。

ftnft

活動内容

今回参加したメンバーは全員NFTおよびブロックチェーンの開発未経験者でした。 そのため、まずは座学による知識獲得から開始しましたが、実際に手を動かさないと得られない成果もあります。 ある程度の知識を獲得したところで、実装に向けてより具体的にサービスにアクセスしてきたユーザーが、特定のNFTを所有しているのかを確認する方法を調査しました。 調査結果をふまえてサービス案の検討をしていきました。

サービス案

「もしブックリスタがカントリークラブを始めたら」というテーマを設け、NFTで発行した会員証を保持しているユーザーだけが入場できるサービスと仮定して進めていきます。

アプリケーション概要

今回作成するアプリケーションのシステム構成イメージ図です。

システム構成

今回作成するアプリケーションはカントリークラブアプリと記載しました。

NFT Gardenを使ってPolygonEthreumのサイドチェーン)に独自のコントラクトを作成し、会員証に見立てたNFT(以降、会員証NFT)を複数発行しました。 PolygonやEthereumにおけるNFTとはERC721規格のトークンを指します。 ERC721のIF仕様はOpenZeppelinのドキュメントに記載があります。 これによるとownerOfを叩けば所有者のaddress、すなわちWalletIDを得られることがわかりました。 ではownerOfはどう叩くのか、調べるとRPCプロバイダーがブロックチェーンにリクエストを行いレスポンスを返してくれることが分かったので、今回はAnkrというRPCプロバイダーを使ってみます。

ユーザーを一意に特定するにはWeb3Authを使用します。 GoogleアカウントやSNS等で認証が行え、同じアカウントで認証すれば常に同じ一意の秘密鍵が得られます。 Web3Authで得た秘密鍵をPolygonで使用可能なEthereumAddressに変換し、WalletIDとして使用します。 EthereumAddressへの変換はハッシュ関数を使用するため、同じ秘密鍵を元にした場合は何度作成しても同じEthereumAddressが得られます。

実装

開発環境

プロジェクト作成

  1. Visual Studio Codeを開きshift + command + pFlutter new ProjectApplication の順に選択
  2. プロジェクトを配置するディレクトリを選択
  3. 任意のプロジェクト名を入力する

Web3Authでのウォレット認証

Web3AuthのFlutterSDKに実装手順の記載がありますが、それだけでは動かなかったので実際に動作確認した手順を記載します。

Web3AuthのSDKを入れる

  1. 作成したプロジェクト直下でflutter pub add web3auth_flutter
  2. Web3AuthのFlutterSDKをインストールflutter pub add web3auth_flutter
  3. web3dartをインストールflutter pub add web3dart
  4. ./android/app/build.gradletargetSdkVersion33に変更
  5. ./android/app/build.gradlecompileSdkVersion24に変更
  6. ./android/build.gradlerepositories {}maven { url "https://jitpack.io" }追加
  7. ./android/app/src/main/AndroidManifest.xml<manifest>直下に下記を追加
    • <uses-permission android:name="android.permission.INTERNET" />
  8. ./ios/Runner.xcworkpaceをXcodeで開きFile→Add Packagesを開く
  9. 右上の検索窓にhttps://github.com/web3auth/web3auth-swift-sdkを入れweb3auth-swift-sdkadd Package
  10. ./ios/Runner.xcodeproj/project.pbxprojIPHONEOS_DEPLOYMENT_TARGET3箇所を14.0に変更
  11. ./ios直下でpod install

Web3AuthのDeveloper Dashboardに登録

Web3AuthのDeveloper Dashboardにプロジェクトを作成しスキーマを登録することで、認証完了後にFlutterアプリ側で認証情報を得る事ができます。

画面イメージ

  1. Developer Dashboardにサインアップ
  2. サイドバーのPlug and playを開く(画像①)
  3. Create Projectで任意プロジェクト名を入力しTestnetプロジェクトを作成(画像②)
  4. 作成したプロジェクトを選択しAdd a new whitelist URLにAndroid用とiOS用のURLを登録(画像③)
    1. Android: {SCHEME}://{YOUR_APP_PACKAGE_NAME}
      • {SCHEME}は任意文字列
      • {YOUR_APP_PACKAGE_NAME}はAndroidManifest.xmlのpackageに記載されたもの
    2. iOS: {bundleId}://openlogin
      1. {bundleId}は任意文字列
  5. ClientIDはコピーしておく(画像④)
  6. Flutterにスキーマを登録 (SCHEMEとYOUR_APP_PACKAGE_NAMEはwhitelist URLに登録した内容へ置換)
    1. ./android/app/src/main/AndroidManifest.xml<intent-filter>直下に下記を追記
      • <data android:scheme="{SCHEME}" android:host="{YOUR_APP_PACKAGE_NAME}" android:path="/auth" />
    2. ./ios/Runner/Info.plist4行目付近の<dict>と書かれた直後に下記を追加 <key>FlutterDeepLinkingEnabled</key> <true/> <key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleTypeRole</key> <string>Editor</string> <key>CFBundleURLName</key> <string>web3auth</string> <key>CFBundleURLSchemes</key> <array> <string>{bundleId}</string> // Web3Auth dashboard で入力した内容に置換 </array> </dict> </array>

実処理を実装する

./lib/main.dartをを下記の通り修正しflutter runで実行してみましょう。

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:web3auth_flutter/enums.dart';
import 'package:web3auth_flutter/input.dart';
import 'package:web3auth_flutter/web3auth_flutter.dart';
import 'package:web3dart/credentials.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
    // 省略
}

class MyHomePage extends StatefulWidget {
    // 省略
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  void initState() {
    super.initState();

    Uri redirectUrl;
    if (Platform.isAndroid) {
      redirectUrl = Uri.parse('{SCHEME}://{HOST}/auth');    // Web3Auth dashboard でホワイトリスト登録したURLに変更
    } else if (Platform.isIOS) {
      redirectUrl = Uri.parse('{bundleId}://openlogin');       // Web3Auth dashboard でホワイトリスト登録したURLに変更 
    } else {
      throw UnKnownException('Unknown platform');
    }
    await Web3AuthFlutter.init(Web3AuthOptions(
        clientId: "XXXXXXX",       // コピーしておいたClientID
        network: Network.testnet,
        redirectUrl: redirectUrl));
  }

  Future<void> login() async {
    final result = await Web3AuthFlutter.login(LoginParams(
      loginProvider: Provider.google,
      mfaLevel: MFALevel.DEFAULT,
    ));
    final credentials = EthPrivateKey.fromHex(result.privKey ?? "");
    print(result.userInfo);
    print('authenticated WalletID: ${credentials.address.hex}');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Container(),
      floatingActionButton: FloatingActionButton(
        onPressed: login,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ), 
    );
  }
}

画面に表示されたFABを押し、自身のGoogleアカウントで認証すると、Flutterのログに自身のGoogleアカウント情報やWalletIDが表示されました。

簡単な解説です。 StatefulWidgetの生成時に呼ばれるinitState()Web3AuthFlutter.init()を呼び出すことで初期化しています。 初期化時のパラメーターにredirectUrlを渡していますが、これはDeveloper Dashboardでホワイトリスト登録したURLと同じ文字列です。 ホワイトリスト登録したURLと同じURLが指定されているのにリダイレクトされない場合は下記を確認してみてください。

  • iOS: ./ios/Runner/Info.plistに設定したスキーマ
  • Android: ./android/app/src/main/AndroidManifest.xmlに設定したスキーマ

会員証NFTの保持確認

会員証NFTを持っている人だけが入場できるサービスを作るので、ユーザーが会員証NFTを持っているかを確認する処理を実装する必要があります。

Web3Auth認証で得たユーザー情報から、所有するNFTの一覧が取得できるのでは…と少し期待しましたができないようです。 ここでは前述のRPCプロバイダーAnkrを使用して、Polygonから会員証NFT所有者のWalletIDを取得していきます。 PolygonのNFTであるERC721規格のトークンに関する情報を得るには、RPCを介してコントラクト関数が記述されたABIというJSON形式のインターフェースを使用します。

複数存在する会員証NFTの所有者が1つでも認証ユーザーのWalletIDと一致すれば、会員証NFTの所有者であると証明できます。

実装

認証時に作成したlogin()を下記に変更して実行してみます。

  Future<void> login() async {
    final result = await Web3AuthFlutter.login(LoginParams(
      loginProvider: Provider.google,
      mfaLevel: MFALevel.DEFAULT,
    ));

    // 認証したユーザーのWalletIDを取得
    final credentials = EthPrivateKey.fromHex(result.privKey ?? "");
    print('authenticated WalletID: ${credentials.address.hex}');
    
    // Polygon上に生成したコントラクトアドレス
    const contractAddress = "0x{contractAddress}";

    final client = Web3Client("https://rpc.ankr.com/polygon", http.Client());

    // ERC721のabi
    // https://gist.github.com/olegabr/45d659bec5f068eb9d82af4d3f712a23
    const erc721abi =
        '[{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"approved","type":"address"},{"indexed":true,"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"operator","type":"address"},{"indexed":false,"internalType":"bool","name":"approved","type":"bool"}],"name":"ApprovalForAll","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":true,"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"Transfer","type":"event"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"approve","outputs":[],"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"balance","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"getApproved","outputs":[{"internalType":"address","name":"operator","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"operator","type":"address"}],"name":"isApprovedForAll","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"ownerOf","outputs":[{"internalType":"address","name":"owner","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"safeTransferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"safeTransferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"operator","type":"address"},{"internalType":"bool","name":"_approved","type":"bool"}],"name":"setApprovalForAll","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"tokenURI","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"transferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"}]';

    final contract = DeployedContract(
        ContractAbi.fromJson(erc721abi, "erc721abi"),
        EthereumAddress.fromHex(contractAddress));

    int nftTokenIndex = 0;
    bool isOwner = false;
    while (true) {
      try {
        final ownerOfResponse = await client.call(
            contract: contract,
            function: contract.function('ownerOf'),
            params: [
              BigInt.from(nftTokenIndex),   // intだとエラーになるので注意
            ]);
        final ownerWalletID = ownerOfResponse.first.toString();
        if (ownerWalletID == credentials.address.hex) {
          isOwner = true;
          break;
        }
        nftTokenIndex++;
      } catch (e) {
        break;
      }
    }
    print("isOwner: $isOwner");
  }

ユーザーに会員証NFTを転送する前後で実行するとログのisOwnerの値が意図通りに変わる事が確認できました。

今回はWeb3Client()のパラメータにPolygon向けRPCのURLが設定されていますが、別のチェーンを使用する場合こちらに各エンドポイントが記載されていますので参照ください。

その後のwhileブロック内では下記箇所でownerOf()を実行してNFTの所有者を取得し、認証したWalletIDのcredentials.address.hexと一致するかをみています。 今回で一番大事なところです。

final ownerOfResponse = await client.call(
    contract: contract,
    function: contract.function('ownerOf'),
    params: [
      BigInt.from(nftTokenIndex),
    ]);
if (ownerWalletID == credentials.address.hex) {

最後に

ブロックチェーンを取り巻く環境がめまぐるしく変化していること、筆者らの知見が浅かったことにより、こんなにコンパクトな実装にはふさわしくないであろう時間がかかりました。 時間がかかった大きな要因は、下記3点が大きかったと感じています。

  • 開発目線で調べると、「コントラクト」とは何を指すのか、ERC721のIFを叩くには、など調べることが多いが当初検索するキーワードがわからなかった
  • 情報が古い記事を見て実装してうまく動かないこと
  • 公式のページであってもリンク切れがあるなど、正しい仕様が不明なケースも存在した

この記事の内容が、これからアプリ開発をされる方の何かしらのヒントになれば幸いです。

auブックパスのストア開発におけるスクラム

アイキャッチ

株式会社ブックリスタ プロダクト開発部の開発・運用を担当している片山と申します。

今回は、弊社が包括的なパートナーシップを結び開発を行なっている「auブックパス (運営:KDDI株式会社)」のストア開発におけるスクラムについて記載させていただきます。

本記事はauブックパスのストア開発でのスクラムについての説明であり、純粋なスクラムとしては正しくない内容が含まれている可能性がありますので、ご注意ください。

また、本記事は筆者が開発者であるため、開発者が参加するイベントのみに絞り込んで記載しており、開発者が参加しないイベントについては記載しておりません。

開発に集中できる仕組み

auブックパスのストア開発におけるスクラムは、開発者が開発に集中できる仕組みとなっています。

以下は、1スプリントの開発者の大まかな時間割です。

時間割

図を見ていただいて分かる通り、開発者は自分の時間の大部分を開発に使用することが出来ます。

また、スクラムとしては当然なのですが、開発者は2週間のスプリントで実施する作業をスプリントプランニングで明確にします。作業の優先度も必ず決めます。そのため、自分が何をすればよいか迷うことはありません。

メンバー紹介

スクラムチームのメンバーは以下のようになっております。(通常のスクラムとは違うメンバー構成です)

  • プロダクトマネージャー
    • マーケティング戦略に則ったプロダクト改善の立案
    • プロジェクトマネージャーと連携した各ステークスホルダーとの調整
    • リリース後の効果検証のリード
  • プロジェクトマネージャー
  • 開発者
    • ブロダクトゴールを実現するたための開発(設計・実装・テスト)
    • プロダクトの運用
  • インフラ担当者
    • AWSにおけるインフラの設計・構築
    • プロダクトの運用
  • QA担当者
    • テストの計画・実施
    • 開発者に対する品質面のレビュー
  • スクラムマスター
    • MTGのファシリテート
    • プロセスの監視・改善

スクラムの内容

朝会

毎朝10時30分から30分間だけ朝会を実施します。 朝会にはプロジェクトマネージャー、開発者、インフラ担当者、QA担当者、スクラムマスターが参加します。

プロジェクトマネージャーを除く参加者全員が前日やったこと、本日やること、今問題になっていることを報告し、情報共有します。

報告はだいたいいつも15分程度で完了し、残り15分は雑談をしています。趣味の話など仕事には関係のない会話が多いです。auブックパスのチームは全員がリモートで仕事していることが多いため、朝会の雑談は、チームワークを維持するための貴重な時間となっています。

スプリントプランニング

スプリントプランニングは、次回スプリントで各担当者が実施する作業を決める MTG です。 プロジェクトマネージャー、開発者、インフラ担当者、QA担当者、スクラムマスターが参加します。

スプリントプランニングを始める前までに、以下の作業を実施しておきます。

  • バックログの優先順位付
    • チケット管理ツール Jira を使用
    • プロジェクトを要求管理用と開発用に分けており、プロダクトマネージャーが要求管理用プロジェクトで付けた優先順位を元に、プロジェクトマネージャーが開発用プロジェクトの優先順位を決める
  • ストーリーポイントの算定
    • プランニングポーカーを用いて、開発者全員で見積もる

auブックパスのスプリントプランニングは2回に分けて実施します。スプリントを開始する4日前の月曜日にスプリントプランニング1を実施します。2日前の木曜日にスプリントプランニング2を実施します。

スプリントプランニング1では、優先度の高い順番にチケットの担当を決めます。チケットの優先度が付いていることは非常に重要で、開発者は自分が担当している未完了のチケットの中で一番優先度が高いチケットを集中して作業できるようになります。

また、チケットの担当者の割り当ては、できる限り希望者を優先して割り当てるようにしています。自分が希望したチケットを作業する方が、誰かが決めたチケットの作業をするよりもモチベーションが上がります。

スプリントプランニング1と2の間で開発者は、自分が担当となったチケットをサブタスクに分割し、作業時間を見積もります。ここで重要なのは、各チケットに対するサブタスクの洗い出しになります。出来る限り粒度の細かいサブタスクに分割することで、見積もりが正確になり、かつ、作業進捗も分かりやすくなります。

スプリントプランニング2では、各担当者のチケットにおける見積もり時間を確認し、次回スプリントで実施可能かどうかを判断します。見積もり時間が多い場合は、チケットを分割したり、チケットをバックログへ戻すなどして調整します。見積もり時間が少ない場合は、バックログにあるチケットを新たに割り当てます。

開発

ある程度の規模の開発であれば、設計、テスト仕様書作成、実装、テスト実施の順番に作業します。1スプリントで収まらない開発である場合は、それぞれの工程を別々のスプリントで実施することもあります。

設計

設計書は ConfluenceGoogle Sheets で記載することが多いです。

書き方は決めておらず、各開発者が好きな書き方で設計書を書いています。

そして、設計書は必ず他メンバーがレビューします。レビューすることで、担当者が気づかない問題を実装前に気づくことがよくあり、手戻りを防ぐことが出来ています。

テスト仕様書作成

テスト仕様書を実装前に記載することで、実装するプログラムのインプットとアウトプットを明確化します。

設計書と同様にテスト仕様書もレビューすることで、手戻りを防ぐことが出来ています。

以前は、テスト内容が十分でなく、不具合をリリースしてしまうことがありました。そこで、どの画面・機能で何を確認するべきかのテスト観点を資料化することで、全ての開発者が十分な品質を保証できるテスト仕様書を作成できるようになりました。

実装

テストファースト

可能な限りテストファーストで、Unit テストを実装するルールとしています。ただ、UI 部分など Unit テストが十分に実装できていない箇所があり、今後の課題となっています。

Linter、Formatter

Linter にて事前に分かる問題は自動で検出するようにし、Formatter にてコードのスタイルは自動で統一するようにしています。

CI/CD

GitHub でプルリクを作成したときに、 ビルド、Unit テスト、Linter を実行し、問題がない場合のみマージできるようにしています。これがあるおかげで、コードレビューの手間がかなり削減されています。

GitHub のプルリクをマージすると、AWS CodePipeline にて、自動で開発環境・ステージング環境にリリースされ、ステークホルダーがすぐに確認できるようになっています。

コードレビュー

GitHub のプルリクでは、最低一人の reviewer から approve が出ないとマージ出来ないルールとしています。

reviewer の選択は、GitHub の Code review assignment を使用し、各開発者が均等にレビュー担当となります。これにより、実装内容がバランスよく情報共有されています。

テスト実施

事前に作成しておいたテスト仕様書に従ってテストを実施します。

開発規模がある程度大きい場合は、開発者がテストを実施した後に、QA 担当者がさらにテストを実施することで、品質を保証するようにしています。

リリース

リリースは master ブランチにマージして、 AWS CodePipeline にて承認ボタンをクリックするだけで出来るように単純化されています。

ただし、一部のサービスは Jenkins を使用したリリースとなっております。Jenkins でも画面からいくつかの項目を入力して実行するだけなので、難しくはありません。

スプリントレトロスペクティブ

スプリントレトロスペクティブは終了したスプリントの反省会のようなものです。 スプリントレトロスペクティブには、プロジェクトマネージャー、開発者、インフラ担当者、QA担当者、スクラムマスターが参加します。

スプリントレトロスペクティブでは以下について、話し合います。

  • 各自の振り返り
  • ベロシティの推移の確認
  • 次回スプリントの改善目標

各自の振り返りでは、各自が個人、相互作用、プロセス、ツールに関して検査し、うまくいったこと・うまくいかなかったことを発表します。そして、うまくいかなかったことに関しては改善点をみんなで考え、うまくいったことに関してはみんなが真似できるように情報共有します。

ベロシティの推移の確認では、過去6回のスプリントと今回のスプリントのコミットメント1と完了2比較し、今回のベロシティが過去に比べてどうだったかを確認します。ベロシティの結果が過去と大きく異なる場合は、原因と対策を話合います。

スプリントレトロスペクティブの最後に、次回スプリントの改善目標をまとめます。

ナレッジ共有会

ナレッジ共有会はスクラムには関係のないイベントです。

参加は任意参加として、時間に余裕のあるメンバーのみが参加します。

開発者各自がauブックパスサービスに関わる知識、もしくは、システム開発に関わる知識を共有するための MTG です。

例えば、最近のナレッジ共有会では、以下のような知識を共有しました。

  • RDS のバックアップ
  • RDS の認証方法
  • Lambda の Layer
  • OpenVPN
  • トイルについて
  • jest の spyOn
  • 冪等性について
  • 書籍「はじめて学ぶソフトウェアのテスト技法」
  • auブックパスのアクセスログについて

用意する資料は箇条書きの簡単な文章程度で、資料作成など事前準備に時間をかけることはありません。

まとめ

以上は、2023年3月末時点のauブックパスのストア開発におけるスクラムです。

auブックパスのストア開発の今の体制が出来て2年半が経過しました。2年半の間に色々試行錯誤することで、記載したような開発方法となりました。この方法は、開発者中心の方法となっており、開発者が開発に集中出来る環境であると自負しています。


  1. スプリントプランニングで決めたストーリーポイントの合計。
  2. スプリントで完了にできたストーリーポイントの合計。

DatadogのRUM Session Replay費用削減について

アイキャッチ

こんにちは。プロダクト開発部でクラウドインフラエンジニアとして業務を行っている高澤です。

今回は、Datadogの料金削減の一環として行った、「RUM Session Replay の費用削減」についてお伝えします。

費用削減の効果を先に書くと、RUM Session Replayの料金がほぼ0ドルになりました。

この記事で伝えたいこと

Datadog の概要

Datadogとは、Datadog社が提供する監視・モニタリングのSaaSサービスです。

各リソースやクラウド(AWS,GCP,Azureなど)からメトリクス・ログなどを取り込み・連携し、モニタリングできるサービスです。

その他できることは多岐に渡ります。

Datadog の具体的な使用例

Datadogを利用し、現在以下をメインで行っています。

  • AWSのメトリクス連携をし、監視・通知
  • リソース監視・通知
  • ログを取り込み、可視化・検知
  • ダッシュボードを使用し、リソース状況やKPIを把握
  • SLO監視
  • Syntheticsテストの実施・監視・通知
  • アラート・オンコールの集約

Datadog費用削減に至る背景と見直しポイントについて

Datadogは上記機能を提供してくれて大変便利なサービスなのですが、データの取り込み量が増えるに従い、料金が増えていきます。

料金の削減が課題となり、プラン見直しも含め、Datadog営業担当の方と相談する機会を設けていただきました。

相談の結果、個々のサービスについて、最適なプラン変更などを実施していただき料金を抑える目処は付きました。

しかし「On-Demand RUM Session Replay」という請求項目については、「利用する・しない」を設定するものではありませんでした。

データを送っているとその分課金される種類の機能であり、呼び出し側でデータ送信の設定をしないといけない、と判明しました。

RUM Session Replayという機能について

この「RUM Session Replay」は、Datadogの請求から確認できる項目名です。

「RUM Session Replay」の具体的な機能としてはどのようなものでしょう、ということをまず確認しました。

を確認し、ポイントは以下です。

Datadogのセッションリプレイは、レビュー、分析、トラブルシューティングのために、ユーザーのWebブラウジング体験をキャプチャして視覚的に再生することができます。DatadogのエラートラッキングやAPMトレースによってフラグが立てられたユーザーセッションを再生することで、UXの問題をより迅速に特定し、修正することができます。

つまり、ユーザーのブラウズ操作(ユーザーセッション)をリプレイし、エラー発生や遅延を再現させ、問題解決に役立てる機能のようです。

RUM Session Replayの費用削減設定について

機能の利用有無についてチーム内確認

上記の機能説明にて「〜のようです。」という表現で書いている通り、このデータは意図して送信していたわけではなく、デフォルト設定のまま運用した結果、Datadogに送信されていたデータでした。

念の為、チーム内にて「RUM Session Replay」のデータを使った機能を利用している人がいないかを確認し、特にいないことが確認できたため、データ送信を止めることになりました。

対応方法についての確認

セッションリプレイ機能を無効にするドキュメントを確認しました。

上記ドキュメントで記述されている対応方法としては、以下です。

セッションの記録を停止するには、startSessionReplayRecording() を削除し、sessionReplaySampleRate を 0 に設定します。 これにより、リプレイを含む Browser RUM & セッションリプレイプランのデータ収集が停止します。

上記対応を実施していきます。

対応時の細かな問題

現在この機能のために使用している @datadog/browser-rum パッケージのバージョンは 3.11.0でした。

該当バージョンの場合、取りうる設定値の中に sessionReplaySampleRate という値がありませんでした。

そのため、以下パッケージのドキュメントを確認しました。

sessionReplaySampleRate の以前の値が、 replaySampleRate であったと読み取れます。

replaySampleRate については非推奨の値ですが、今回の対応ではパッケージのアップデートは見送ることとし、replaySampleRateを0としてみます。

設定及びデプロイ・設定効果確認

replaySampleRate を0に修正してデプロイします。

その結果を見るためには、Datadogにログインします。

左側メニューから「 UX Monitoring → Sessions & Replay 」を辿り、Real User Monitoring画面へ遷移します。

以下スクリーンショットのように Session Replay available にチェックを入れ、結果を見ます。

スクリーンショット

こちらで確認した結果、データが検索で出なくなっていることを確認できたため、対応完了となりました。

費用削減の結果

はじめに書いた内容の繰り返しになりますが、対応の結果、RUM Session Replayの料金がほぼ0ドルになりました。

まとめ

Datadogの料金削減ポイント・注意点を合わせてまとめると以下となります。

デフォルトのままだと意図していないデータが自動で送られ、料金高騰に繋がりますので、随時見直していきましょう。

  • 対応方法についてはドキュメントに記載されていることが多いので確認する
  • どうしてもわからない場合はDatadogサポートへ問い合わせる

機能のローンチ直後は無料であっても、有料になることが散見されますので、料金削減の観点からはなるべく明示的にデータを送らない設定にすることが有効です。

  • まず利用してみてから見極める
  • Datadogからのお知らせはよく確認し、料金改定・有料化のお知らせの場合は注意し対応を検討する

以上です。