booklista tech blog

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

auブックパスのフロントエンド技術スタック

アイキャッチ

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

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

auブックパスのフロントエンドは、開発の内製化にともない、2022年11月にjQuery/CakePHPの構成から、React/Next.jsの構成にリプレイスしました。リプレイスしてから1年ほど運用を続けているため、リプレイスしてどのような効果があったかも記載させていただきます。

言語

TypeScript

TypeScriptは豊富な型に関する表現をもつ静的型付け言語です。コンパイルすることでJavaScriptとなります。

アプリケーションの実装だけでなく、AWS CDKを使用したインフラの環境構築も含めてTypeScriptで統一しました。

リプレイス前のJavaScript/PHPのソースコードには以下のような問題がありました。

  • 可読性が低い
    • API・関数・ビューテンプレートのIN/OUTの型が明確になっておらず、中身の実装を読まないと型が分からない
  • 保守性が低い
    • 型の誤りやNull参照エラーなど全てのエラーは実行時にしか分からない

リプレイス後のTypeScriptのソースコードは以下のように改善しました。

  • 可読性が高い
    • any型の使用は原則禁止としたので、API・関数・コンポーネントのIN/OUTは型定義の実装部分のみ読めば分かる
  • 保守性が高い
    • 型の誤りやNull参照エラーなどコンパイル時にある程度エラーが分かる

TypeScriptの豊富な型に関する表現を一度使ってしまうと、もうJavaScriptには戻れません。

ライブラリー/フレームワーク

フロントエンドのライブラリー/フレームワークは、React/Next.jsを使用しています。

React

ReactはUI構築のためのJavaScriptライブラリーです。

Vue.js、Angularではなく、なぜReactなのかというと、Reactの経験者がチーム内に居たからというのが理由です。Vue.js、Angularでもきっと問題はなかったのですが、開発者の経験というのは大事な財産であるので、その財産を活用するために、Reactを採用しました。

リプレイス前のCakePHPのビューテンプレートとjQueryを使用したソースコードには、以下のような問題がありました。

  • ビューテンプレートの粒度が大きく、適切に分割されていない
    • 1つのビューテンレートのソースが長い
    • 重複したソースコードが多く存在する
  • ソースコード間の関連が分かりにくい
    • イベントの発生箇所とイベントリスナーの登録箇所がわかりにくい
    • 変更時の影響範囲がわかりにくい

リプレイス後はReactを使用して、以下のように改善しました。

  • Atomic Designを採用することで、コンポーネントの粒度が小さくなり、適切に分割されている
    • 1つのコンポーネントのソースが短い
    • 重複したソースコードが少ない
  • イベントの発生箇所とイベントリスナーの登録箇所が分かりやすい
    • ReactはonClickなどのイベントをHTML要素に直接定義するので一目瞭然
  • ソースコード間の関連が分かりやすい
    • import/exportを使用したため、モジュール間の関連が明確
    • 影響範囲がわかりやすい

上記は、Reactを使ったから改善したというよりも、リプレイスのために設計し直した効果の方が大きいですね。

Reactのコンポーネントは、原則は関数コンポーネントで作成し、状態管理や副作用の実行などはReact Hooksを使用しています。React Hooksはレンダリングのたびに呼び出されるということに慣れが必要ですが、慣れてしまえば分かりやすいコードを記載できます。

Next.js

Next.jsはReactベースのWebアプリケーションフレームワークです。

ゼロからReactでWebアプリケーションを開発するのはやることが多く結構大変です。Webサーバーを用意したり、webpackなどを使ってトランスパイルの処理を作ったり、ローカルで開発するための仕組みを作ったり、ルーティングの定義を作ったりする必要があります。Next.jsには、Webサーバー・トランスパイル・ローカル開発環境・ファイルベースのルーティングが最初から用意されていて、自分で用意する必要がありません。

また、Next.jsには、SSR・SSGによるプレレンダリング、画像最適化など有益な機能が豊富にあります。

インフラ

auブックパスのインフラはAWSを使用しており、構成としては以下の図のようになります。 ( 今回の図では分かりやすくするために最小限のサービスのみ絞り込んでおり、実際にはもっと多くのAWSサービスを使用しています )

CloudFront
CloudFront
S3
S3
ALB
ALB
Fargate
Fargate
/public配下
/_next/static配下
/public配下...
/api配下
/api配下
複数起動している
コンテナインスタンス
に振り分ける
複数起動しているコンテナインスタンスに振り分ける...
Text is not SVG - cannot display

CloudFrontで最初にリクエストを受け付けて、ALB/FargateS3にリクエストを振り分けます。

ALB/Fargateには動的コンテンツ、S3には静的コンテンツを配置しています。

動的コンテンツ(/api配下、SSR、SSG)のために、Fargateのコンテナインスタンスの中でNode.jsを使ってNext.jsのWebサーバーを起動しています。負荷分散のためにコンテナインスタンスは複数起動しているので、ALBでロードバランシングしています。

リプレイス前はEC2インスタンス上でサーバーを起動していたのですが、リプレイス後はサーバーのコンテナ化を実現できました。

環境構築

AWS CDK

インフラの環境構築は、AWS CDKを使用し、TypeScriptで実装しています。

リプレイス前はインフラのコード化は実現できておらず、同じ環境の構築は不可能で、変更履歴も分からない状態でした。

リプレイス後はAWS CDKでインフラのコード化を実現したので、コードを実行するだけで、新たな環境が作れるようになりました。インフラの変更についても、コードを変更して、そのコードを実行するだけで、インフラが変更できるようになりました。コードなのでGitの履歴を見ればインフラの変更履歴が分かります。

インフラの構築だけでなく後述するAWS CodePipeline/AWS CodeBuildを使用したデプロイ環境の構築もAWS CDKでコード化しています。

CI/CD

GitHub Actions

GitHub ActionsはGitHub上でワークフローを自動化できるGitHubの機能です。

リプレイス前は継続的インテグレーションを実現できていませんでした。

リプレイス後はGitHub Actionsにてプルリク作成時にビルド、Unitテストの実行、lintを実行することで継続的インテグレーションを実現しています。

AWS CodePipeline / AWS CodeBuild

AWS CodePipelineは継続的デリバリーを実現するためのサービスです。

AWS CodeBuildはコンパイル・テスト・デプロイを自動化するサービスです。

AWS CodePipelineにてGitHubの特定のブランチの変更を監視し、AWS CodeBuildを呼び出し、AWS CodeBuildでコンパイル・デプロイを実行しています。

監視

Datadog

Datadogはサーバー監視・分析サービスです。

auブックパスではDatadogにてログ監視、サーバー監視、アプリケーション監視、リソース監視を行なっております。

Amazon Connect

Amazon Connectはコンタクトセンター向けのクラウドプラットフォームです。

auブックパスでは障害発生時の自動オンコールを実現するためにAmazon Connectを使用しています。

詳細はこの記事に記載しています。

まとめ

以上のように、auブックパスのフロントエンドはリプレイスにより比較的新しい技術を使用した構成になっています。auブックパスにはまだ古い技術を使用した部分は残っているため、今後もリプレイスを継続いたします。

今回はauブックパスのフロントエンドにおける代表的な技術スタックだけをピックアップして記載させていただきました。

次回以降にもっと具体的なリプレイス内容・効果や、今回は記載できなかったその他の技術スタックについて記載させていただきます。

Metabaseを使用したデータ民主化に取り組んでいる話

アイキャッチ

自己紹介

はじめまして。デジタルマーケティング部データ分析チームのYZrです。主にデータ分析・活用を業務としています。今回はbooklistaで全社的に実施しているデータ民主化について記していきます。

データ民主化とは

本題に入る前に、データ民主化についてご説明します。 その時々で意味合いが変わることもあるかと思いますが、ここでは「全社員が電子書籍ストアのデータを抽出し、データを基に意思決定する土壌を作って行くこと」を意味します。

まずは、データを見る・使うことが当たり前という状態になることを目指し、更には、業務のために必要なデータの内容や欲しいデータの取得方法まで議論できる組織にしたいと考えています。

取り組みの背景

なぜデータ民主化を実施するかというと、端的に言えばもとのデータ分析フローではできることに限界があったからです。

データ民主化前

まず、データ民主化前のデータ分析フローを簡単に示した図がこちらです。

施策担当者が分析したいと思ってからデータ分析チームに分析依頼を出し、データ分析チームが優先度の高い物から分析に着手し、結果が出たものを施策担当者に受け渡すといった形です。 このデータ分析フローでは以下のような問題が生じてしまいます。

  • 意思決定に時間を要する

    • 施策担当者から別のチームに依頼し、ドメイン知識が必要な分析内容である場合は施策担当者からデータ分析チームに細かな説明をする必要がある
    • 分析チームのマンパワーに依存するため順番待ちの時間が生じる場合がある。または対応不可能な場合がある
  • データ活用、知見を得る機会の損失

    • 軽易なデータ抽出も依頼を出さなければならず、データ活用がしづらい
    • データを閲覧する機会を得づらく、知見を得る機会が減少する

このような環境ではデータが溜まっていても活用される機会が減ってしまいます。

データ民主化後

データ民主化が実現して施策担当者もデータ抽出・集計・分析ができるようになるとどうでしょうか。 データ民主化後のデータ分析フローを簡単に示した図がこちらです。

基本的には施策担当者が自らの手で分析をするようになり、煩雑なデータ分析をデータ分析チームが引き受けます。施策担当者が動かすので「分析したい」という意思ではなく「分析しよう」という意志に変わることや、データを見ることの習慣づけにも繋がると期待できます。他にも以下のようなメリットが考えられます。

  • 意思決定の高速化

    • 施策担当者がユーザ行動・購買履歴などのデータを適切に引き出し、それらを用いて速やかな仮説検証や効果検証を実施できる
    • 分析チームのマンパワーに依存しない
  • 意思決定の高精度化

    • 施策担当者が分析することで、データ分析チームでは思いつかない切り口でも分析ができ意思決定の精度を高めることできる
  • データ活用・知見拡充

    • ストアデータを活用することで顧客が求めていることを知ることができる
    • 顧客体験をより良いものするアイデアを生み出すことができる

データ民主化をする上での課題と解決手段

では、データ民主化をどのように実現していけばよいでしょうか。データ分析チームと同じ手段で施策担当者がデータ分析をするとなると、大きく3つの課題が浮かび上がります。

  1. SQL記法の学習
  2. データベースの構造や処理の理解
  3. ストアデータのテーブル構造やリレーションの理解

1.に関しては、学習コストが高過ぎるので可能であれば避けたいところです。 2.3.に関しては、テーブルやスキーマといった言葉の意味や結合の仕方、良く使うテーブルの構造に内容を絞れば実現できそうです。

SQL記法を学ばない、つまりSQLを書かずにデータ分析をする方法といえば真っ先にBIツールが思い浮かびます。社内で使用しているデータベースの種類や金額面、操作の分かりやすさなどから今回はMetabaseを導入することにしました。

学ばなければならない部分はデータ分析チームが資料を作り、簡単な分析をしながら学べる講座を開くことで対応することにしました。

Metabaseについて

MetabaseはオープンソースのBIツールです。SQLを書かずにWeb画面上でデータを抽出・集計しグラフを作成できます。また、集計結果やグラフをまとめてひとつのダッシュボードとして表示する機能もあります。閲覧権限の設定もできるので、ユーザーによって見せたくないデータがある場合にも対応できます。

Metabase自体はデータを持たず接続したデータベースを参照するつくりになっています。メジャーなデータベースにはだいたい対応しているので、とても使い勝手が良いです。対応しているデータベースの詳細は公式サイトをご確認ください。

ダッシュボードの例がこちらです。

Metabaseでのデータの抽出方法は「簡単な質問」「カスタム質問」「ネイティブクエリ」の3種類があります。画像の左から右に向かって難易度が上がります。

簡単な質問

名前の通り、この機能は最も操作が簡単で表示できるデータもシンプルです。データベースとテーブルを選択するとテーブルの中身を表示します。

カスタム質問

GUIベースでテーブルの結合、グループ化、フィルタ、ソート、行数制限などができる機能です。 データ民主化ではこのカスタム質問を使ったデータ抽出と集計ができるようになることを目指します。 ほとんどの項目はプルダウン表示の選択肢をクリックするだけで指定可能です。カスタム質問で設定した内容をMetabase側が自動的にSQLへ変換し、データの取得と表示が行われます。SQLの変換は接続するデータベースの種類に合わせて行われますので、カスタム質問の設定の仕方をデータベースによって変化させる必要はありません。

ネイティブクエリ

SQLを直接書くことができます。SQLを書ける人はこの機能を使っても良いと思います。

取り組み内容

データ分析チームが行っていることは「Metabase環境の整備」と「Metabaseを使った分析の普及活動」です。

Metabase環境の整備

Metabaseの運用管理はデータ分析チームで行っています。Metabaseを動かすためのサーバーを立てるところから、サーバー自体の管理、Metabaseの設定やユーザー管理などを含みます。システム上の問題が生じたときにもデータ分析チームが対応します。 構成を簡単に示した図がこちらです。

また、MetabaseがフリーズしたときなどMetabase用サーバーを再起動したいときに、施策担当者が簡単に再起動をかけられる仕組みも構築しました。Slackで指定のアカウント(metabase再起動くん)にメンションを飛ばすだけで再起動がかかります。 データ分析チームに連絡をして再起動を依頼していたらデータ分析チームが対応するまで作業が止まってしまいますし、煩わしさで使わなくなってしまう可能性もあります。少しでも使いづらさを感じないような工夫をしています。

Metabaseを使った分析の普及活動

Metabaseを使ったデータ分析を社内に根付かせるための普及活動をデータ分析チームで行っています。

  • テーブル定義表の作成

    データベースのよく使うテーブルについて、物理名・論理名だけではなくデータが作られるタイミングや値の説明を記述したテーブル定義表を作成しました。

  • レシピ集の作成

    データベースのに関する基礎知識やカスタム質問の操作方法(テーブル選択、結合、フィルター etc.)などの基礎的な内容と、実務で使うことを想定したカスタム質問の実用的な内容のレシピ集を作成しました。レシピ集では使うテーブルや設定する項目の手順を記述し、基礎編は操作手順を録画した動画を、実用編ではカスタムクエリを設定した画像を添付し視覚的にも分かりやすい作りにしています。

    目次(基礎編)

    データベースの基礎知識

    基礎操作レシピ

    実用レシピ

  • Metabaseの使い方講座

    レシピ集を作るだけでなく、オンラインでMetabaseの使い方やレシピ集の応用方法を説明する講座を、月に1~2回程のペースで実施しています。講座の内容は録画をして残し、講座の時間に都合がつかなかった人や新入社員にも見られるようにしています。

  • 質問を受けるSlackチャンネルの開設

    ストアデータやMetabaseの使い方に関していつでも質問できるSlackのオープンチャンネルを開設しました。オープンチャンネルなので他の人が過去に質問した内容から学びを得ることもできます。

データ民主化を始めて気づいたこと

  • サーバーを落としても問題がない環境と、再起動の簡単さが大事

    結合やフィルターの設定に慣れていないと膨大な量のデータを取得するクエリを実行してしまい、サーバーが落ちることは良くあります。サーバーが落ちることを恐れてMetabaseの使用に抵抗が出てしまっては意味が無いので、サーバーが落ちても問題がない環境を作り、落ちても問題がないことを伝え、簡単に再起動が出来る手順が確立されていることが大事だと感じます。実際に、上述のmetabase再起動くんはたびたび使われています。

  • レシピ集だけでは実務への応用が難しい

    レシピの手順に少し手を加えた程度では取得できないデータについて質問されることや、「もっと実践的な内容をやってほしい」と言われることが何度もありました。これらに対して、講座の中で説明する応用方法の難易度を上げて複雑なカスタムクエリの使い方を実演する、よく質問を受ける内容をレシピ集に追加する、講座で扱う内容を施策担当者から募集する、などの対応をしています。暫定的な対応となっているので、Metabaseを使ったからこそ出てくる疑問や要望を取りこぼさず講座内容に反映していける仕組みを作ることは今後の課題です。

さいごに

データ民主化を始めるまではレシピ集の作成や講座内容の作成など大変な部分もありましたが、Metabaseを使った分析をしている人が少しずつ増えてきている実感もあります。 データ民主化の活動を継続していき、データを最大限に活かせる組織となることを目指していきます。

このブログが少しでも読んでくださった方の参考になれば幸いです。

Lighthouse CLI を使った Basic 認証ありの環境での性能検証

アイキャッチ

はじめまして。プロダクト開発部に所属しているエンジニアの伊藤です。
弊社で開発しているKDDIのサービス、「auブックパス(運営:KDDI株式会社)」の保守開発を担当しています。
今回はそのauブックパスでLighthouse CLIを使用した性能検証の話をしていきます。

Lighthouse CLI使用の背景

auブックパスでは、昨年Webページの内部コードを一新し、メンテナンスがしやすくなったこともあり、今年度からSEO改善にも力を入れています。
今回はその中でもFIDの改善についての話をしていきます。

FIDとは

FID(First Input Delay)とは、ユーザーが最初にそのページ上で操作したときに、その操作処理が開始するまでの時間を計測したものです。
この時間が短いほど、より使いやすいWebページであるという保証になります。GoogleはFIDを「Core Web Vitals」と1つとしてSEOの指標にしています。
FIDは100ミリ秒程度になることが理想と言われています。

参考:https://web.dev/i18n/ja/fid/


FIDの改善策の1つに、画像の遅延読み込みがあります。

auブックパスは電子書籍のストアなので、トップ画面に書籍表紙の画像が多く表示されます。(ストアのトップはこちら)
何も対策しないとすべての画像を表示するまでユーザー操作処理が遅れ、FIDの測定値が悪くなります。
そのため、ユーザーが表示していない領域の画像を後で表示させるLazy Loading(遅延ローディング)を施す必要があります。

この施策がどれだけ効果があるのかを確認するため、Lighthouse CLIを使いました。

Lighthouse CLI とは

Lighthouseとは、Googleが提供するWebページの品質を計測するためのツールです。
ブラウザのGoogle Chromeに標準搭載されていて、Dev Toolsから使用できます。

ブラウザ版Lighthouse

Dev ToolsのLighthouseタブから計測したいカテゴリ(SEO、パフォーマンスなど)を選んで「Analyze page load」ボタンを押すと開いているWebページを計測してくれます。

Lighthouse CLIは、このLighthouseをNode.jsを使用してコマンドラインから実行できるツールです。
より細かい実行時の設定指定ができたり、コマンドを記述して自動化できるので柔軟な計測や複数回実行が手早くできるなどの利点があります。

Lighthouse CLI の導入・実施方法

実行環境

  • Intel MacBook Pro メモリ32GB
  • Node.js 16.17.1
  • npm 8.15.0
  • lighthouse(Lighthouse CLI) 9.6.7

計測環境について

今回は本番反映前に計測したかったので、ステージング環境を対象にしています。
アクセス制限のため、WebページにBasic認証がかけられているので、リクエストのHTTPヘッダー情報に認証情報を載せる必要があります。

CORS(Cross-Origin Resource Sharing)の対応

auブックパスはJavaScriptで外部オリジンから書籍の画像情報を取得しています。
withCredentialsを設定をしないとプリフライトリクエストでbasic認証のauth情報が渡せず、 CORSポリシーでブロックされてしまいます。
今回は性能の計測が目的なので、同一オリジンポリシーを適用させない方法で上記を解決します。

導入手順

  1. 実行環境にLighthouse CLIをインストール
    npm install -g lighthouse

  2. インストール確認
    これでバージョン番号が出たらインストールが正常に完了しています。 lighthouse --version

  3. Basic認証のID・PASSWORDをbase64エンコード
    echo -n '<ID>:<PASSWORD>' | base64

  4. ligthouseコマンドを記述して実施
    今回は以下の条件で計測を実施します。

    • 計測する値
      • 指定なし(計測できる全項目を計測)
    • 計測結果
      • jsonで出力
      • html形式でも出力可能です。
    • リクエストヘッダ
      • Authoricationの情報を搭載する。
    • Chromeのオプション
      • --disable-web-securityを追加して、同一オリジンポリシーを適用外にする
     lighthouse \
        --chrome-flags="--disable-web-security" \
        --extra-headers='{"Authorization": "Basic <3でエンコードしたIDとPASSWORD>"}' \
        --output=json \
        --output-path="[自分がレポートを置きたいディレクトリ]/[レポート名].json" \
        --quiet "[検証したい画面のURL]"
    
  5. 計測結果のjsonファイルから指定の結果を抽出
    計測結果のjsonは、各項目の計測結果の数値の他、コンソールで出たエラー内容や計測過程のスクリーンショットの情報なども入り数千行に及びます。
    目的の数値のみ取り出したい場合はcatなどを活用します。
    今回はFIDの数値が欲しいので、Lighthouse内で相当するTBT(Total Blocking Time)の値を抽出します。
    ※注意
    TBT(Total Blocking Time)は厳密に言えばFIDとは違う概念で、「Webページがユーザー操作をブロックしていた総時間数」です。
    FIDは本番の環境下で実際のユーザーが使用した時にしか測定できない値なので、Lighthouseなどの自動操作での計測値にはTBTを使うのが一般的です。

cat [自分がレポートを置きたいディレクトリ]/[レポート名].json | jq  '.audits."total-blocking-time"'

上記コマンドを打つと、以下の形式で出力されます。

{
  "id": "total-blocking-time",
  "title": "Total Blocking Time",
  "description": "Sum of all time periods between FCP and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds. [Learn more](https://web.dev/lighthouse-total-blocking-time/).",
  "score": 0.66,
  "scoreDisplayMode": "numeric",
  "numericValue": 412.9999999999991,
  "numericUnit": "millisecond",
  "displayValue": "410 ms"
}

このdisplayValueが計測結果なので、この値を抽出します。
あとは上記手順を複数回行って、結果を各自保存していきます。

計測結果をGUIで見たい

CLIからLighthouseを実行したときに、想定外の画面が表示されて正しく計測されていないことに気づけない場合があります。
実際私が計測した時は、--disable-web-securityのオプションをつけず実行してエラー画面が出ていることに気づけませんでした。
そういう時には、出力された結果のファイルを「Lighthouse Report Viewer」にドラックアンドドロップしてください。
最終的に表示されたページの画像・計測過程のスクリーンショットなどが見えるので想定外の計測がされていないかを確認できます。

Lighthouse Report Viewer

まとめ

今回あくまで最低限のLighthouse CLIの活用方法について触れてきました。
この記事が読んでいる方の手助けになれば幸いです。

iOS16の新機能「ロック画面ウィジェット」を推し活アプリに導入してみた話

アイキャッチ

はじめまして。株式会社ブックリスタ プロダクト開発部の酒井です。
普段はスマホアプリエンジニアとして推し活アプリ「Oshibana」の開発を行なっています。
Oshibanaについては以前の記事で紹介をしていますので、ぜひともご一読ください。

App Storeで3位になったアプリをFlutterでつくっている話
https://techblog.booklista.co.jp/entry/2022/07/11/155531

iOS16について

2022年6月に開催されたWWDC2022にて、iOS16の新機能が発表されました。
今回iOS16ではロック画面のカスタマイズが強化されており、中でも「ロック画面にウィジェットを配置できる」という機能が注目されています。

Oshibanaでは推し活に役立つウィジェットを作成できる機能を提供しています。
弊社はiOS16の新機能であるロック画面ウィジェットに目を付け、Oshibanaで作成したウィジェットをホーム画面だけでなくロック画面にも配置できるようにすることで、より推しへのアプローチが増えると考え、いち早くOshibanaへ導入するため開発に着手していました。

この記事では、iOS16で追加された新機能である「ロック画面ウィジェット」の紹介と、 その機能をOshibanaへ組み込んだ方法について紹介していきます。

ロック画面ウィジェットの紹介

<概要>

iOSではホーム画面にウィジェットを配置できる機能がiOS14の頃より提供されています。
今回、iOS16からはこのウィジェットをロック画面にも配置できるようになりました。
ウィジェットはロック画面に固定で表示されている時計の上下に配置できます。
配置できるウィジェットの種類は以下の通りです。
・インラインウィジェット・・・時計の上に配置できるテキスト1行分のウィジェット
・円形ウィジェット・・・時計の下に配置できる円形のウィジェット
・長方形ウィジェット・・・時計の下に配置できる横長(円形2つ分)のウィジェット

ロック画面

上記画像はロック画面上にiOSの標準アプリである「カレンダー」「バッテリー」「アラーム」「天気」のウィジェットを配置してみたものです。
ホーム画面に配置できるウィジェットと比べ、それぞれサイズが非常にコンパクトなものとなっています。
これは今回のロック画面ウィジェットがApple Watchで配置できるウィジェットのデザインを踏襲しているものであり、腕時計のようにアプリのデータが一目で確認できることを目的としているためです。
例えばカレンダーなら直近の予定が一目でわかり、バッテリーなら電池の残量、アラームならアラームが鳴る時刻、天気なら気温が分かるようになっています。
このように、いかにユーザーへ素早く情報を伝えるかがロック画面に配置されるウィジェットの役割として重要なポイントとなります。

また、制約として、配置されるウィジェットは必ずモノクロのカラーリングとなります。
よって、画像は白黒となり、文字や線などに色をつけることができないので注意してください。

<ウィジェット配置方法>

以下の手順でロック画面にウィジェットを配置できます。
1. ロック画面を表示し、画面を長押し(※1)
2. 画面下に現れた「カスタマイズ」ボタンをタップ
3. 時計の上もしくは下のエリアをタップ
4. 表示されたアプリの一覧から配置したいウィジェットを持つアプリを選択
5. 配置したいウィジェットの種類を選択(※2)
6. アプリ一覧を閉じ、右上の完了ボタンをタップ
7. 「壁紙を両方に設定」ボタンをタップ

(※1) パスコードロックを解除しておかないと長押しを行っても反応しません。ホーム画面を表示した状態で端末の一番上から下に指でスライドさせるとパスコードロック解除済の状態でロック画面を表示させることができます。

(※2) 配置スペースが足りないと配置できません。ウィジェットを追加で配置する場合は既に配置済のウィジェットを削除しておく必要があります。

ロック画面 チュートリアル1ロック画面 チュートリアル2ロック画面 チュートリアル3ロック画面 チュートリアル4ロック画面 チュートリアル5



Oshibanaへの導入

Oshibanaでは様々なウィジェットが用意されていますが、今回は既存のウィジェットからロック画面のデザインに適応できそうな下記4つのウィジェットを選定し、開発しました。
・画像ウィジェット(インラインウィジェットでは表示不可)
・推し始めてウィジェット
・生誕からウィジェット
・デビューからウィジェット

ロック画面 Oshibanaイメージ

将来的には他のウィジェットの実装も検討しています。
しかし、ロック画面ウィジェットは前述の通りサイズが小さく、色がモノクロになるため、コンテンツを表示できる範囲に限界があり、画像の上にテキストを表示させるようなデザインだと見辛くなってしまうなどの問題があります。
なので、ウィジェットのデザインはシンプルなものが望ましく、表示される情報も即時性が高いものを優先させるなど工夫が必要であるため、慎重に検討を進めています。

※iOS16の機能を開発するためには、Xcode14以上が必要です。

Widgetターゲットの新規追加

最初にロック画面ウィジェット用のWidgetターゲットを追加します。
プロジェクトファイルのTARGETSで+ボタンを押下し、ターゲットの選択画面を表示し、「Widget Extension」を選択し、Nextを押下します。
2つ目の画面では、Product Nameを入力し、Include Configuration Intentのチェックを外し、ProjectとEmbed in ApplicationでRunnerを選択し、Finishを押下します。

※RunnerはFlutterでiOSアプリを作った際にデフォルトで作成されるプロジェクト名となります。

スクリーンショット1

スクリーンショット2

Widgetターゲットを追加すると、自動的にProduct Nameに設定した名称でフォルダとswiftファイルが作成されます。
自動追加されたswiftファイル内の各クラスやメソッドについてはホーム画面ウィジェットの作成時と同様のため、詳細な解説は省略し、ロック画面ウィジェットに関係ある部分のみ記載します。

サポートファミリーを定義

下記のように、supportedFamiliesに
・accessoryCircular
・accessoryRectangular
・accessoryInline
の3つを定義します。

struct oshibana_lock_widget: Widget {
    let kind: String = "oshibana_lock_widget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            oshibana_lock_widgetEntryView(entry: entry)
        }
        .configurationDisplayName("ロック画面ウィジェット")
        .description("設置したいウィジェットを選択しましょう")
        .supportedFamilies([.accessoryCircular,.accessoryRectangular,.accessoryInline])
    }
}

この時点でロック画面ウィジェットは配置可能になっているので、シミュレーターか実機にデプロイし、動作確認を行なってみてください。

Intentsターゲットの新規追加

ロック画面に表示するウィジェットの種類をユーザーが選択できるようにするため、ロック画面ウィジェット用のIntentsターゲットを追加します。

スクリーンショット3

スクリーンショット4

プロジェクトファイルのTARGETSで+ボタンを押下し、ターゲットの選択画面を表示し、「Intents Extension」を選択し、Nextを押下します。
2つ目の画面では、Product Nameを入力し、Include UI Extensionのチェックを外し、ProjectとEmbed in ApplicationでRunnerを選択し、Finishを押下します。

スクリーンショット5

スクリーンショット6

Intentsターゲットを追加すると、Widgetターゲットの時と同様にフォルダとswiftファイル(デフォルト名はIntentHandler.swift)が作成されます。
こちらも作成されたクラスやメソッドの内容はホーム画面ウィジェットの作成時と同じであるため、ロック画面ウィジェットに関係する部分以外の解説は省略します。

Intentsターゲットを追加したら、IntentsターゲットページのSupported Intentsにアイテムを追加します。
既存でホーム画面ウィジェット用のIntentsが存在しているため、別名で登録します。

スクリーンショット7

SiriKit Intent Definition Fileの作成

次に、作成されたロック画面用widgetフォルダの配下にSiriKit Intent Definition Fileを作成します。
ファイル選択後、Save Asにファイル名を入力し、TargetsでRunnerとロック画面のwidgetターゲットとIntentターゲットを選択してCreateボタンを押下します。

スクリーンショット8

スクリーンショット9

作成された.intentdefinitionファイルを開き、下記画像のように設定します。
画像ではParametersで独自のパラメータを定義してますが、作成したいウィジェットの内容に合わせて変更してください。

スクリーンショット10

IntentHandlerを構成

.intentdefinitionファイルを設定した後は、ウィジェット表示内容の選択肢を定義するため、IntentHandler.swiftの修正を行います。

※下記はサンプルのため、一部省略しています。

class IntentHandler: INExtension, LockConfigurationIntentHandling {

    func provideLockwidgettypeOptionsCollection(
        for intent: LockConfigurationIntent,
        with completion: @escaping (INObjectCollection<LockWidgetType>?, Error?) -> Void) 
    {

        var widgetTypes: [LockWidgetType] = []
        widgetTypes.append(LockWidgetType( 省略 ))

        let allCatIdentifiers = INObjectCollection(items: widgetTypes)
        completion(allCatIdentifiers, nil)
    }

    override func handler(for intent: INIntent) -> Any {
        return self
    }
}

provideLockwidgettypeOptionsCollection関数内に.intentdefinitionファイルで定義したパラメータのリストをIntentに対して設定する処理を実装します。
ここでリストを定義することで、ウィジェットをタップした際に動的な選択肢を表示させることができます。
上記例では省略していますが、Oshibanaではアプリで入力したウィジェットの設定値をローカルのDBに保存し、provideLockwidgettypeOptionsCollection関数内で保存したデータを呼び出してリストに設定しています。

IntentTimelineProviderを継承

次に、Widgetターゲット作成時に自動生成されたswiftファイルの中身をIntentsに適用させるよう変更します。

※下記はサンプルのため、一部省略しています。

struct Provider: IntentTimelineProvider {
    typealias Intent = LockConfigurationIntent

    @available(iOSApplicationExtension 16.0, *)
    func recommendations() -> [IntentRecommendation<LockConfigurationIntent>] {
        []
    }

    func placeholder(in context: Context) -> OshibanaEntry {
        let conf = LockConfigurationIntent()
        return OshibanaEntry(date: Date(), configuration: conf)
    }

    func getSnapshot(for configuration: LockConfigurationIntent, in context: Context, completion: @escaping (OshibanaEntry) -> ()) {
        let entry = OshibanaEntry(date: Date(),configuration: configuration)
        completion(entry)
    }

    func getTimeline(for configuration: LockConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        let entry = OshibanaEntry(date: Date(),configuration: configuration)
        let timeline = Timeline(entries: [entry], policy: .atEnd)
        
        省略

        completion(timeline)
    }
}

struct oshibana_lock_widget: Widget {
    let kind: String = "oshibana_lock_widget"

    var body: some WidgetConfiguration {
        return IntentConfiguration(kind: kind, intent: LockConfigurationIntent.self, provider: Provider()) { entry in
            return oshibana_lock_widgetEntryView(entry: entry)
        }
        .configurationDisplayName("ロック画面ウィジェット")
        .description("設置したいウィジェットを選択しましょう")
        .supportedFamilies([.accessoryCircular,.accessoryRectangular,.accessoryInline])
    }
}

デフォルトではTimelineProviderが継承されていましたが、IntentTimelineProviderを継承するよう変更します。
IntentTimelineProviderへの変更に応じて、placeholder、getSnapshot、getTimelineの引数と戻り値の定義を変更し、typealiasを宣言します。
WidgetConfigurationでreturnしているオブジェクトもStaticConfigurationからIntentConfigurationに変更します。

ロック画面ウィジェットにおける基本的な実装は以上です。
後はEntryViewでウィジェットのデザインを整え、Timelineからパラメータを受け渡してウィジェット毎に表示結果を変更するなどの機能追加を行なっていけば良いと思います。

開発中に苦労したこと

1.Xcode14で実機ビルドするとエラーが発生した

Xcode14をインストールし、ソースをビルドすると以下のエラーが発生しました。

error build: '/Users/User/Library/Developer/Xcode/DerivedData/Runner-ftqsuopsbckjgpfaojzqmthsozpo/Build/Products/Debug-iphoneos/Alamofire/Alamofire.framework/Alamofire' does not contain bitcode. You must rebuild it with bitcode enabled (Xcode setting ENABLE_BITCODE), obtain an updated library from the vendor, or disable bitcode for this target. file '/Users/User/Library/Developer/Xcode/DerivedData/Runner-ftqsuopsbckjgpfaojzqmthsozpo/Build/Products/Debug-iphoneos/Alamofire/Alamofire.framework/Alamofire' for architecture arm64

Oshibanaで利用しているライブラリの1つであるAlamofireでエラーが発生していましたが、ここでは直接関係は無く、原因は「Xcode14からはbitcodeが非推奨になったため」でした。

[参考サイト]
Xcode 14 Release Notes

対策として、TARGETSからRunnerを選択し、Build SettingsのEnable BitcodeをYESからNOに変更すれば解消しました。

2.iOS15.5以下の端末でアプリを起動させたら、ホーム画面に設置済だったウィジェットが全て真っ白になった

iOS15.5以下の端末にロック画面ウィジェットの対応が入ったアプリをインストールしたところ、ホーム画面に設置済だったウィジェットが全て真っ白になる、及びウィジェット配置時のアプリ一覧にOshibanaが表示されなくなる事象が発生しました。

iOS16からIntentRecommendationというクラスが追加されており、watchOSにてIntentを設定する際、推奨インテント構成を記述するためのメソッドであるrecommendations関数を実装する必要があリます。
今回はiOSのみの対応であるため本来なら不要な処理ですが、おそらくXcode14では上記クラスをOSの種類やバージョンに関わらず呼ぶようなデプロイが行われており、iOS15では上記クラスが見つからずにエラーが発生し、WidgetKitフレームワークのシンボルが見当たらないとされ、Oshibanaのウィジェット機能そのものが起動しなくなってしまったものと思われます。
(今後のバージョンアップによって修正される可能性はあります)

対策として、以下のコードを既存のホーム画面ウィジェットの処理であるProvider内にも追加しました。

@available(iOSApplicationExtension 16.0, *)
func recommendations() -> [IntentRecommendation<Intent>] {
    return []
}

[参考サイト]
AppleDeveloperフォーラム #709233
Apple公式documentation - IntentRecommendation

3.VSCodeでビルドエラーになり、シミュレーターが起動できず、デバックができなくなった

Oshibanaのアプリ側はFlutterで実装しているため、VSCodeで開発していましたが、ビルド時にエラーが発生するようになりました。
原因はbeta版のXcodeを開発に使っており、VSCodeのビルドで指定するXcodeが正規版(バージョン:13.4)の方になっていたため、iOS16で新規追加されたAPIがビルドできずエラーになっていました。
下記コマンドをターミナルで実行し、Xcode13.4ではなくXcode14betaを指定するよう変更すればビルドできるようになりました。

sudo xcode-select -s /Applications/Xcode-Beta.app

初歩的なミスでしたが、Xcodeのbeta版を使っていたり、複数バージョンを別名にして使い分けていたりすると、よく忘れる内容だと思います。

4.Codemagicで本番用アプリのデプロイを行うとエラーになった

Oshibanaではipaを生成する際、Flutterで作ったアプリのデプロイを自動化できるCIツール「Codemagic」を採用しています。
開発用ipaのデプロイを行なった時は問題なくipaが作成されたのですが、AppleStoreに申請する本番用ipaを作成するためビルドを行なった際、以下のエラーが発生しました。

Error (Xcode): No signing certificate "iOS Development" found: No "iOS Development" signing certificate matching team ID "XXXXXXXX" with a private key was found.

Xcode14からビルドオプションの「CODE_SIGNING_ALLOWED」のデフォルト値が"NO"から"YES"になったらしく、ビルド時にコード署名を行おうとしますが、Codemagicで署名しようとすると署名証明書が見つからずエラーになってしまうようです。

対策として、以下のコードをPodfileに追記しました。

post_install do |installer|
  installer.generated_projects.each do |project|
      project.targets.each do |target|
          target.build_configurations.each do |config|
              config.build_settings["DEVELOPMENT_TEAM"] = "XXXXXXXX"
          end
      end
  end
  installer.pods_project.targets.each do |target|
      if target.respond_to?(:product_type) and target.product_type == "com.apple.product-type.bundle"
        target.build_configurations.each do |config|
            config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
        end
      end
  end
・・・

「CODE_SIGNING_ALLOWED」を“NO”にする設定を追加することにより、ビルド時にコード署名が行われなくなり、ビルドが通るようになります。
コード署名されてないipaはリリースできませんが、Codemagicの「Build」フェーズの後続に「Distribution」フェーズがあり、そこで自動コード署名を行なっているため、問題ありません。

[参考サイト]
Github CocoaPods issue #11402

5.ロック画面ウィジェットの対応が入ったアプリをAppleStoreに申請してみたが、申請できなかった

iOS16のbeta版が配布されているため、お試しで使える方に使ってもらおうとロック画面ウィジェット機能を導入したバージョンのOshibanaをAppleStoreに申請してみましたが、申請自体ができませんでした。

AppleStore

そもそもbeta版での開発内容(Apple Beta Software Program)は機密情報に該当するため、言われてみればそりゃそうだという感じでした。
この件について認知していない方は意外と少なくはないかもしれないと思ったので、記載させて頂きました。
参考:Apple Beta Software Program よくある質問

やってみた感想

今回の開発はbeta版が提供された時点から着手をしていました。
やはり挙動も少し不安定で、上記で記載した「苦労したこと」以外にも、Oshibana特有の仕様に影響する不具合もいくつか発生し、ブログには記載しづらい部分でも多くの苦労がありました。
何より、新機能ということでネット上に情報が少なく、解消方法を見つけるのも大変で、数時間前にAppleDeveloperフォーラムへ書き込まれた内容を参考に問題が解決したということも多々ありました。

しかし、新しい機能を実際に試して動かせるというところにとても新鮮味があり、なかなか面白い経験をすることができました。
更に、どこよりも早く新機能を実践投入できることへの期待感も高く、苦労したこと以上に楽しさを見いだすことができ、多くの知見を得られました。

2022年夏時点では、iOS16の開発に関する情報も日本だとまだ多くないため、この記事が皆さんのiOSアプリ開発に役立つことを願っています。

電子書籍ストアReader StoreへPWAの導入を検討した話

アイキャッチ

簡単な自己紹介

はじめまして。プロダクト開発部アプリケーションエンジニアの有末と申します。
現在、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のシステム開発を主な業務として日々取り組んでいます。

今回はReader StoreでPWAの導入を検討した際の話をお伝えしていきます。

導入検討の背景

2020年、GoogleからGoogle Playのお支払いポリシーに関する通達が出ました。

デベロッパーからのフィードバックを受け、Google Play をより良くします
Google Play のお支払いに関するポリシーについて
お支払い - Play Console ヘルプ

ポイントとなる箇所をざっくり要約すると“アプリ内でお買い物をする際にはGoogle Playの提供している課金システムを利用してくださいね”ということです。
Reader Storeでは電子書籍ビューワーとして"Reader"アプリを配信しているのですが、Reader Store(Webブラウザでのサービス)(以下、ストア)への導線が設けられており、このまま配信することが難しくなりました。

しかし導線を削除すると、元々アプリからストアへ訪問していたお客様の利便性は損なわれます。
それでは良くないので、代わりとなるよう、今までアプリから訪問していたお客様にも引き続き訪問がしやすいようにする手段を検討しました。
そこで出てきたのが“PWA”です。

PWAとは

PWA(Progressive web apps)はブラウザーだけでネイティブアプリのような使い勝手をもたらすWebアプリです。
参考URL:プログレッシブウェブアプリ (PWA)

通常のWebアプリ同様の機能が利用できる他、以下の機能も利用できます。

  • ホーム画面アイコン追加
  • 全画面表示
  • 起動時URL指定
  • スプラッシュスクリーン
  • オフラインページ表示
  • Push通知
  • アプリアイコンバッチ
  • キャッシュ保持
  • 端末機能(カメラ、GPSなど)

まずは喫緊の課題である"ストアへの導線がなくなってしまう"という点に対応すべく、"ホーム画面からストアに遷移できるよう導線を提供する"という点に絞って対応を進めることにしました。いろいろと機能を提供できるPWAですので、Push通知を送れるようにしたい、などやりたいことは膨らみますが、それらは追々対応を検討したいところです。

導入1

WebアプリをPWAとしてインストールできるようにするにはマニフェストファイルを用意してhtmlのheadに設定を記述します。

▼manifest.json

{
  "lang": "ja-JP",
  "name": "Reader Store",
  "short_name": "Reader Store",
  "description": "電子書籍の総合書店なら「Reader Store」",
  "display": "standalone",
  "theme_color": "#161928",
  "background_color": "#161928",
  "orientation": "any",
  "start_url": "/?param=pwa",
  "icons": [
    {
      "src": "apple-touch-icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "apple-touch-icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

▼html

<head>
  <link rel="manifest" href="/manifest.json">
</head>

これらの設定を完了するとPWAとしての表示ができるようになりました。

画面表示イメージ

マニフェストファイルの作成と設定の追記だけなので導入自体は簡単でした。
Pushなどの機能を導入しようとするとServiceWorker等の設定も必要になりますが今回は不要です。

課題

簡単に導入ができたところでいざテストを実施すると、PWAを導入したことで利用が出来なくなる既存の機能がいくつか発生してしまいました。

Androidと外部ドメインの動き

Reader Storeではログイン・購入時に外部サービスを利用している箇所があります。
外部サービスを利用する際にサービスによっては、対象の外部サービスのページ(=別ドメイン)へ遷移し、認証などを行ったのち、情報を持ってReader Storeに戻ります。戻り先では取得した情報を利用してサービスを継続します。

例えば、"Reader"アプリからログイン時にGoogleのアカウントを利用してログインを行うとします。
まず"Reader"アプリから認証依頼をGoogleに出して、Googleの認証画面へ遷移します。
Googleの認証画面では認証をしてOKであれば認証情報をReader Storeに返します。
認証OKとして返された情報をReader Storeは受け取り、"Reader"アプリのセッションでログイン成功としてサービスを継続します。

これが外部ドメインとの基本的な動きになります。
PWA導入後も別ドメインを経由する動きは、別ドメインに遷移したのち、元のサービスに戻る動きを想定していました。しかしPWA導入時は戻りのときに元サービスへ戻らず、全てPWAへ戻る動きになりました。
これにより別ドメインに遷移し、戻り情報を受け取らないといけない"Reader"アプリ/Webの一部機能(ログイン・購買)が機能しない状態になってしまいました。

Android期待と実際の動き

iOSの外部Appへの遷移後、PWAが一切使えない

iOSで"LINEログイン"を利用するとき、端末にLINEアプリがインストールされているとLINEアプリが立ち上がって認証の処理を行います。
アプリで処理を行ったのち、Reader Storeのサービスに戻ってきてログインを完了するのが基本的な動きです。

PWAを導入したのちの動きも同様の動きを想定していました。しかしPWAを導入し、LINEアカウントでログインをしようとするとLINEアプリが立ち上がったのち、ブラウザーでReader Storeが起動してしまいました。
これではLINEアカウントを利用しているお客様はPWAをインストールしたとしてもPWAでReader Storeが利用できない状態になってしまいます。

iOS期待と実際の動き

これらの問題を抱えた状態でお客様にPWAを提供できませんので、何か対応を考えなければなりません。

導入2

"ホーム画面からストアに遷移できるよう導線を提供する"という要件に立ち返り、それ以外のところを修正して課題を解消できないか検討します。
検討した結果、表示形式を変更することで解消ができそうです。

▼manifest.json

{
  "lang": "ja-JP",
  "name": "Reader Store",
  "short_name": "Reader Store",
  "description": "電子書籍の総合書店なら「Reader Store」",
  "display": "browser",
  "theme_color": "#161928",
  "background_color": "#161928",
  "orientation": "any",
  "start_url": "/?param=pwa",
  "icons": [
    {
      "src": "apple-touch-icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "apple-touch-icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

表示の指定箇所である"display"を"standalone"から"browser"に変更しました。
これにより以下の点が変更になります。

  • アプリ的な全画面表示ではなくなり、直接ブラウザで開くようになる
  • スプラッシュスクリーンも表示されない
  • ホーム追加時のURL指定は影響なし。どこでホーム追加しても指定したURLで開かせられる
  • Pushも追々つける場合影響はない(ServiceWorkerの領分なので)

画面表示イメージ

これで"Androidと外部ドメインの動き", "iOSのLINEログインが一切使えない"という課題が解消されます。
PWAの要素は残っていますが、実体としてはWebClipがよりリッチになったという形でしょうか。

結果、無事にお客様に"ホーム画面からストアに遷移できるよう導線を提供する"ことができるようになりました。

さいごに

PWAの機能を導入すること自体の難易度は高くありませんでしたが、既存の機能との整合性が取れなくなるところは思わぬ落とし穴でした。
今後もお客様が利用のしやすいサイト作りに励んでいきます。

Docker + Capybara + appium_capybara + RSpec + クロスブラウザー(Stable + Beta版) を使用したリグレッションテストの自動化

アイキャッチ

はじめまして。プロダクト開発部QAエンジニアの岡です。
普段は弊社が総合的な運営をサポートしている電子書籍ストアのQA業務を行っています。
今回はQA業務として行っているリリース前のリグレッションテストを自動化したことについてお伝えします。

リグレッションテスト自動化に至る背景

弊社が開発を外部委託から内製へ切り替えるにあたり、品質と開発リードタイム短縮のためテストの自動化を進めるという目標がありました。
しかし、単体テスト自動化には課題があり直ぐに実現できないことから、開発工程で不具合が混入してしまう状況でした。

一例)

  • HTMLテンプレートにJavaScriptが直書きされているなどLinterによる機械的なチェックができずブラウザーの互換性担保が難しい
  • jQueryで実装しているためセレクターの書き方によって、予期せぬところで予期せぬ動作をしてしまうことがある

このように、E2Eによるリグレッションテストが効果を発揮しやすいプロジェクト背景もあり、まず、既存のコードから独立して進めることのできるリグレッションテストの自動化をQA主体で進めることになりました。

自動化で実施したこと

  • テストシナリオの選定
  • クライアント環境の選定
  • 環境構築

テストシナリオの選定

サイトのリグレッションテストを作成するにあたり、どの部分のシナリオを作成するか考える必要があります。
サイト内をすべて網羅できていれば安心ですが、シナリオ作成やメンテナンスのコストを考えると、主要部分にケースを絞ることが必要です。
対象は電子書籍サイトのため、以下の観点でテストケースを絞りました。

  1. ユーザーがサインイン、本を購入する、本を読むなどの読書体験に関わる箇所は最重要
  2. ボタンやリンクをタップしたときにアクションが起きる箇所は重要
  3. レイアウトの崩れはSeleniumのみで検知することは難しいため、1と2が問題無ければ自動化ではOKとする

クライアント環境の選定

次に、クライアント環境の選定です。ブラウザー依存の不具合を検出するため、対応デバイスを網羅するようにしました。

[対応デバイス]

  • デバイス
    • Windows
    • Mac
    • iPhone
    • iPad
    • Android
  • ブラウザー
    • Chrome
    • Safari
    • Firefox
    • Edge

さらにブラウザーのバージョンアップでの不具合も検出するため、ブラウザーのBeta版にも対応することにしました。

環境構築

以下が作成した環境のご紹介です。

使用技術

  • Docker
    • selenium/hub
      • Grid Hubイメージ(ブラウザー構成を一元管理するものです)
    • selenium/node-base
      • Nodeのベースイメージ(ここに各ブラウザーとドライバーをインストールしています。Chromeがすでにインストールされているイメージのselenium/node-chromeもありますが、Beta版に対応させたいためこちらを使用しています)
  • Capybara
    • ブラウザーの操作を自動化するためのgem
    • Seleniumのラッパー
    • RSpecを内包している
  • appium_capybara
    • CapybaraでAppium(Seleniumの一種、モバイル用)が使えるようになる
  • RSpec
    • Rubyのテスティングフレームワーク。Webアプリの総合テスト(FeatureSpec)を記述する際に使用
  • AWS CodeBuild
    • クラウドでの定期実行に使用している

構成

構成図

こちらは、Windows側のdocker-compose.ymlの一部抜粋です。
RSpecコンテナに設置したテストスクリプト(RSpec)でテストを実行しており、全クライアント環境を共通のテストスクリプトで実施しています。

version: '3.8'
services:
  rspec:
    volumes:
      - ./rspec/test_code:/test_code
    build: ./rspec
    tty: true
    shm_size: 2gb
    env_file: .env
    ports:
      - 2222:2222
    depends_on:
      - selenium-hub
      - chrome-beta
      (略)
    environment:
      - TZ=Asia/Tokyo
  selenium-hub:
    image: selenium/hub:latest
    ports:
      - 4442:4442
      - 4443:4443
      - 4444:4444
  chrome-beta:
    build: ./chrome-beta
    environment:
      - TZ=Asia/Tokyo
      - HUB_HOST=selenium-hub
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
    depends_on:
      - selenium-hub
    shm_size: 1gb
    ports:
      - 5900:5900
  (略)

ChromeBeta版コンテナのDockerfileでは、selenium/node-base:latestをベースに、 Beta版のChromeブラウザーとドライバーをインストールしています。
参考URL: docker-selenium/NodeChrome/Dockerfile

ここまで設定したら、 docker-compose -f docker-compose.yml up --build -d でdockerを起動します。
次に、テストを実行します。
こちらはテスト実行時設定(Chrome)の一部抜粋です。

require 'selenium-webdriver'
require 'capybara/rspec'

Capybara.register_driver :chrome do |app|
  capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(
    'goog:chromeOptions': {
      args: ['headless', 'window-size=1200,970', 'no-sandbox', 'disable-gpu', 'disable-dev-shm-usage']
    }
  )

  Capybara::Selenium::Driver.new(
    app,
    browser: :chrome,
    url: 'http://selenium-hub:4444/wd/hub',
    capabilities: capabilities
  )
end


こちらは、テスト実行時設定(モバイルブラウザー)の一部抜粋です。
共通のテストスプリプトで実行させるため、appium_capybaraを使用しています。
※実施時にはホストマシンでAppiumが起動中である必要があります。

# Android Chrome
require 'appium_capybara'

desired_caps_android = {
  deviceName: 'emulator-5554',
  platformName: 'Android',
  browserName: 'Chrome',
  automationName: 'UiAutomator1'
}

url = 'http://host.docker.internal:4723/wd/hub'

Capybara.register_driver(:appium) do |app|
  appium_lib_options = {
    server_url: url
  }
  all_options = {
    appium_lib: appium_lib_options,
    caps: desired_caps_android
  }
  Appium::Capybara::Driver.new app, all_options
end
Capybara.default_driver = :appium


# iPhone Safari
require 'appium_capybara'

desired_caps_ios = {
  platformName: 'iOS',
  deviceName: 'iPhone Simulator',
  browserName: 'Safari',
  platformVersion: '15.5',
  automationName: 'XCUITest',
  locate: 'ja_JP',
  languge: 'Japanese'
}

url = 'http://host.docker.internal:4723/wd/hub'

Capybara.register_driver(:appium) do |app|
  appium_lib_options = {
    server_url: url,
    wait: 30
  }
  all_options = {
    appium_lib: appium_lib_options,
    caps: desired_caps_ios
  }
  Appium::Capybara::Driver.new app, all_options
end

Capybara.default_driver = :appium


特定のクライアント環境は、AWS CodeBuildを利用して定期実行をしています。結果はSlackで通知するようになっています。

環境構築で苦労した点

  • Chromeのバージョンアップでテストコードメンテナンスの発生する場合がある
    例えば、Chrome103ではテスト実行ができない問題が発生していました。 こちらはドライバーが対応されるまでの暫定対応として、headlessモードにすることで動作しています。ブラウザーバージョンアップに対応し、常にテストを実行可能にすることが課題となっています。

  • Safariブラウザーでの要素の操作に難航した
    通常のclick()ではタップがシミュレートできないため、JavaScriptを使用して要素をタップすることで回避しています。

リグレッションテスト自動化運用後の改善点

  • リグレッションテストの実行時間が、3日 → 1日になった
  • リグレッションテスト実行時間短縮により、開発のリードタイムの短縮に貢献した
  • AWS CodeBuildを使用した定期実行(Stable + Beta版)により、ブラウザーのバージョンアップによる不具合も検知しているため、サイトが安定して稼働している

さいごに

リグレッションテストを自動化することにより、開発~運用が改善されました。
弊社では、リグレッションテストだけではなく、単体テストの自動化の促進、ノーコードでテストを自動化するツールの開発などが進められています。
お客様が快適にサイトをご利用いただけるよう、テストを通して品質の向上を目指します。

Amazon Connectを使った障害発生時の自動オンコール実現について

アイキャッチ

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

インフラ構築以外の日常的な業務としては、以下のようなタスクを行っています。

  • 改善内容の発見・提案・実装
  • 異常の確認・対応
  • クラウドサービスが随時発表する機能の情報収集・検証

今回は、導入検討を行った Amazon Connect というサービスでの一斉架電方法についてお伝えします。

この記事で伝えたいこと

  • AWSが提供しているAmazon Connectサービスの概要
  • Amazon Connectを利用し、自動で一斉架電をする方法、コードについて

架電の自動化に至る背景

なんらかの障害が発生した場合、担当者に障害の発生を知らせ、対応の開始を促すことになります。

業務中であればメール・SMS・Slackなどで気づく確率が高いですが、 業務時間外や取り込み中には1回携帯電話が鳴ったり、振動したくらいでは気づかないこともあります。 電話をかけることにより、通知よりも長時間の呼び出しができるため有効な手段となります。

また、電話を架けて障害の発生を気づかせる場合にも、 1人だけがその電話で対応を開始しても対応できる内容に限界があるので、 「自動で」「一斉に電話をかけ」「障害に気づかせたい」という要望が生まれました。

ポイントをまとめると以下となります。

  • 障害が発生した場合に、電話で障害の発生を通知し対応を促したい
  • 複数人に一斉架電をしたい

Amazon Connectの技術紹介

AWSにて提供されているサービスのうち、電話を架ける、ということが可能なサービスであるAmazon Connectについて軽く紹介をします。

Amazon Connectの利用用途として主に想定されているのは、コンタクトセンターとして電話を受けたり、電話をかけたりすることによりお客様対応を効率的に実現することです。 Amazon Connectを使うことでウェブブラウザー上で電話を受けたり、別の人に電話を転送したり、など様々なことができます。 音声、SMS、メールなどを通してお客様とのコミュニケーションを取ることができるサービスです。

このAmazon Connectで、今回は電話を架ける、という機能を主に使っていきます。

作成した環境の紹介

構成図

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

  1. Datadog(Monitorを使用)
  2. AWS SNS
  3. AWS Lambda
  4. Amazon Connect

要素は以下のような流れで連携します。

Datadogから一斉架電までの流れ

初期設定について

Amazon Connectの導入・初期設定については、 以下の記事を参考とさせていただき、構築しました。

参考URL: DatadogとAmazon Connectを用いた電話通知実施してみた【監視】

現在は追加で以下の2点の申請が必要でした。

  1. 電話番号を取得するために申請が必要1

  2. 携帯電話に架電したい場合は、電話番号を取得後に別途緩和申請が必要

一斉架電の実現について

ここから、複数人に同時に架電をする+容易に架電対象とするかどうかの設定変更を可能にしていきます。

同時に架けられる数の調査

一斉に電話を架ける場合、同時に何人にかけられるのかを確認してみます。

参考URL: Amazon Connect サービスクォータ

この中の、「インスタンスあたりのアクティブな同時呼び出しの数」が「同時に何人にかけられるのか」に該当します。

デフォルトは10ですので、10人に同時に架けられるようです。

こちらは気になったので AWS Support Centerにて問い合わせをし、確認が取れています。

発信と着信の両方を合算した数 であり、インスタンス内の電話番号すべての音声通話数 とのことです。

今回は発信しかしませんので、同時であれば10人までの一斉架電が可能です。

10人以上に同時架電したい場合は、緩和申請にて依頼してみることになると思われます。

一斉架電の実現パターンについて

一斉架電の実現パターンとしては、有効な選択肢として以下の2つが考えられました。

  • パターン1

    • 1つのSNSから1つのLambdaを呼び、その中で複数人に架電
      • メリット:SNSを1つ指定するだけでグループに架電可能
      • デメリット:変更があった場合にグループ情報の編集が必要
  • パターン2

    • 複数のSNSから複数のLambdaを呼び、1つのLambdaでは1人に架電
      • メリット:変更があった場合にトリガからSNSを外すだけでOK
      • デメリット:トリガに複数のSNSの設定が必要

架電の対象とする人数がそこまで多くならないのではないか、という見込み、および 対象者のつけ外しのしやすさを選択し、今回はパターン2を選択しました。 この場合、1つのSNS=1人への架電、となります。 人数がとても多い場合はパターン1のほうが良いと思われます。

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

Amazon Connectの問い合わせフロー

参考とさせていただいた記事と同じです。

こちらは1回作れば変更はない想定のため、コードではなく手動で作成しました。

SNSについて

コードで管理したい、人数分作らなければならない、ということもあり、 AWS CDKにてSNS,Lambda部分を作成しました。 SNSとLambdaが一対一となるように作成します。

SNS-Phone-Call-for-A → Lambda-for-A
SNS-Phone-Call-for-B → Lambda-for-B

といった形で作っています。

以下、Amazon Connect設定用Stackと、interface,settingの該当部分のコード例です。

/* eslint-disable no-new */
import { Duration, Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as sns from "aws-cdk-lib/aws-sns";
import * as subs from "aws-cdk-lib/aws-sns-subscriptions";
import * as environment from "../environment"; //設定用に作成しているファイルです

/**
 * Amazon Connect設定用Stack
 */
export class AmazonConnectSettingStack extends Stack {
  constructor(
    scope: Construct,
    id: string,
    target: string, // dev,staging,prod,などのデプロイターゲットのstringです
    setting: environment.EnvironmentSetting, // 設定です
    props?: StackProps
  ) {
    super(scope, id, props);

    /**
     * Amazon Connect呼び出し用のLambdaの作成
     */
    // Lambda関数に付与するIAMロール
    const connectRole = new iam.Role(
      this,
      `${target}-amazon-connect-lambda-role`,
      {
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
        path: "/service-role/",
        inlinePolicies: {
          ConnectPolicy: new iam.PolicyDocument({
            statements: [
              new iam.PolicyStatement({
                actions: [
                  "logs:CreateLogGroup",
                  "logs:CreateLogStream",
                  "logs:PutLogEvents",
                ],
                resources: ["*"],
              }),
              // Amazon Connectの権限
              new iam.PolicyStatement({
                actions: [
                  "connect:Start*",
                  "connect:Describe*",
                  "connect:List*",
                ],
                resources: ["*"],
              }),
            ],
          }),
        },
      }
    );

    // ユーザーごとに回してLambda関数を作成
    setting.AMAZON_CONNECT_DEFINITION!.ConnectUserLists.forEach(
      (ConnectUserList) => {
        // Lambda関数を作成
        const callFunction = new lambda.Function(
          this,
          `${ConnectUserList.UserName}-call`,
          {
            runtime: lambda.Runtime.PYTHON_3_9,
            handler: "lambda_function.lambda_handler",
            architecture: lambda.Architecture.ARM_64,
            code: lambda.Code.fromAsset("lib/resources/lambda/connect-call/"),// Lambdaのファイルを置いている場所を指定します
            timeout: Duration.seconds(10),
            role: connectRole,
            environment: {
              DESTINATION_NAME: ConnectUserList.UserName,
              DESTINATION_PHONE_NUMBER: ConnectUserList.PhoneNumber,
              CONTACT_FLOW_ID: setting.AMAZON_CONNECT_DEFINITION!.ContactFlowId,
              INSTANCE_ID: setting.AMAZON_CONNECT_DEFINITION!.InstanceId,
              SOURCE_PHONE_NUMBER:
                setting.AMAZON_CONNECT_DEFINITION!.SourcePhoneNumber,
            },
          }
        );

        // SNS,サブスクリプション作成
        const snsTopic = new sns.Topic(
          this,
          `${ConnectUserList.UserName}-call-sns`,
          {
            displayName: `Phone-Call-for-${ConnectUserList.UserName}`,
            topicName: `Phone-Call-for-${ConnectUserList.UserName}`,
          }
        );
        snsTopic.addSubscription(new subs.LambdaSubscription(callFunction));
      }
    );
  }
}


//Amazon CONNECT 該当interfaceの抜粋
//(略)

/**
 * Amazon CONNECT用の宛先名・電話番号
 */
export interface ConnectUserList {
  UserName: string; // 受話する人の名前(主に識別のため)なるべく英数字
  PhoneNumber: string; // 電話番号 (日本の 080-xxxx-yyyyの場合は +8180xxxxyyyy 形式)
}

/**
 * Amazon Connectの定義
 */
export interface AmazonConnectDefinition {
  InstanceId: string; // AmazonConnectのインスタンスID
  ContactFlowId: string; // AmazonConnectのコンタクトフローID
  SourcePhoneNumber: string; // 発信元電話番号
  ConnectUserLists: ConnectUserList[]; // 受話するユーザーリスト
}

//(略)


//setting の該当設定例の抜粋
//(略)

  AMAZON_CONNECT_DEFINITION: {
    InstanceId: "ABCDE",
    ContactFlowId: "ABCDE",
    SourcePhoneNumber: "+8100000000", //発信元電話番号
    // ConnectUserListsはAmazon Connectの同時発信制限により、10までとしてください(緩和可能)
    ConnectUserLists: [
      { UserName: "A-user", PhoneNumber: "+818000000000" },
      { UserName: "B-user", PhoneNumber: "+818000000000" },
    ],
  },

//(略)

上記のようにCDKで作らない場合も、SNSとLambdaを作成していけばOKです。

Lambda functionについて

こちらも参考とさせていただいた記事とほぼ同じです。

違いは、1つのLambdaにて1人に電話を掛けるようにしているため、電話番号の取得場所を直接環境変数から取っていることです。

冗長とはなりますが、CDKで管理されているため、あまり気にする必要はなくなります。

# -*- coding: utf-8 -*-
# Amazon Connectを利用し、電話をかける
# Datadogでの指定を細かく可能とするため、1ユーザーごととする
import logging
import os
import boto3

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, context):
    logger.info("Event: " + str(event))

    # Datadogからのイベントからアラート名を取得し、読み上げに使う
    datadogSubject = event["Records"][0]["Sns"]["Subject"]
    message = datadogSubject + "が発生しました。確認・対応をしてください。"

    # ログ用
    logger.info("DESTINATION_NAME: " + os.environ["DESTINATION_NAME"])  # 電話宛先名
    logger.info("Message: " + str(message))

    # Amazon Connectで架電
    connect = boto3.client("connect")
    response = connect.start_outbound_voice_contact(
        DestinationPhoneNumber=os.environ["DESTINATION_PHONE_NUMBER"],
        ContactFlowId=os.environ["CONTACT_FLOW_ID"],
        InstanceId=os.environ["INSTANCE_ID"],
        SourcePhoneNumber=os.environ["SOURCE_PHONE_NUMBER"],
        Attributes={"alarm": message * 2},  # 2回メッセージを繰り返した文言を読み上げる
    )
    logger.info(str(response))

Datadog設定について

Datadogのモニタが変化した場合、電話をしたい場合は、DatadogのモニタのNotify your teamにて、 以下の例のように設定をします。 一人ずつ別れているため、ここでつけ外しが容易です。

Datadogモニタ設定

テスト

Datadogでテストを行います。 複数のSNSをつけてテストし、ほぼ同時に複数の電話に着信があれば完成です。

では、楽しいAmazon Connect架電ライフをお送りください。


  1. AWS Support Center にて、Amazon Connectで使用する電話番号を取得したい旨を伝えると、必要な書類や手続きについて回答が貰えます。内容は変更になる可能性がありますので、詳細はAWS Support Center にてお問い合わせください。