こえのブログは「声で書くブログ、声で聴くブログ、声で観るブログ」をコンセプトに書き手、読み手双方にバージョンアップしたブログを提供するアメブロの新機能です。

投稿者は端末に向かって喋るだけで、その音声が活字化されブログとして公開できます。閲覧者は活字化された文字を通常のブログと同じように読めるほか、音声を聴きながらでもコンテンツ消費できます。

モバイル端末、テレビ端末やスマートスピーカーなどの普及により、今後ますます増えると予想される利用形態に対して、それぞれに適した形でコンテンツを提供できるように挑戦しています。

この記事では、技術的な側面を中心にこえのブログを紹介します。



こえのブログは、文字でも音声でもコンテンツ消費できるのが特徴です。読者の方へのメッセージや質問に回答など利用法は様々です。龍玄としさんやクロちゃんさんなど著名人の方の声も聴けます。


できる限りサーバーにアクセスしないキャッシュ戦略

こえのブログはアメブロに貼り付けられるため、多くの参照リクエストが想定されていました。リクエストが増えた場合にも安定して表示できるようにContent Delivery Network (以下CDN)キャッシュを有効活用しました。

こえのブログには投稿機能も備えられており、参照リクエストとは違いキャッシュできない内容も含まれています。そこで、キャッシュするもの、されないものを適切に定義し、サーバーでの無駄な計算処理を減らすように工夫しました。

参照、投稿どちらの機能も備えたアプリケーションにおける「できる限りサーバーにアクセスしない」キャッシュ戦略は、以下の手順で作成、実装しました。

  1. 静的コンテンツと動的コンテンツの割り振り
  2. CDN前提のURL設計とキャッシュ設定
  3. CDNの活用
  4. イベント駆動のCDNキャッシュパージ

静的コンテンツと動的コンテンツの割り振り

静的コンテンツを「どのユーザーでも変わらないコンテンツ」、動的コンテンツを「ユーザーによって変わるコンテンツ」と定義します。こえのブログでは、できるだけキャッシュしやすい静的コンテンツで構成するようにしています。

Webアプリケーション自体に必要なリソースは、事前にビルドしHTMLやJavaScriptファイルとして配信します。それらからはどのユーザーでも同じファイルを参照できるように、ユーザー固有の情報を取り除いておきます。記事データやそのアセットである音声ファイルや画像データも静的ファイルとして配信します。

対して、記事の投稿、更新用のAPIやログインユーザーの状態確認のAPIなど、ユーザー情報に依存するデータは動的コンテンツとして配信します(図1)。

静的コンテンツにはWeb appを構成するHTML、JS、JSON、PNGなどのファイル、MP3音声ファイル、JPEG画像ファイル、多くのAPIデータ(JSON)が含まれます。動的コンテンツにはセッションを含むいくつかのAPIが含まれます。
図1: こえのブログのリソースの多くは、キャッシュしやすい静的コンテンツで構成されています。

CDN前提のURL設計とキャッシュ設定

静的コンテンツと動的コンテンツを割り振ったあとには、それらをURLに落とし込みます。その際には、エンドポイントのグルーピングや各エンドポイントがどのくらいキャッシュできるのか定義しておくとCDNの利用時に役立ちます。

こえのブログではCDNとしてFastlyを利用しているため、Surrogate-ControlSurrogate-Key HTTPヘッダーを利用してCDNキャッシュの指定をします。静的コンテンツはGETメソッドのURLを定義し、できる限り長いキャッシュ時間を指定します。動的コンテンツには、一部例外を除きPOSTやPUT、DELETEなどのメソッドで定義し、CDNでキャッシュしないようにします。

さらに、それぞれのリソースを属性に応じてグルーピングし、Surrogate keyを指定しておくと、後述するイベント駆動でのCDNキャッシュパージに役立ちます。例えば、記事に関連するリソースには「entry/$ENTRY_ID」、APIには「api」、リリース毎にパージしたいアセットには「web/release」を付与しておきます(表1, 2)。

表1: 静的コンテンツはイベント駆動のパージができるようにSurrogate Keyでグルーピングし、長い時間のCDNキャッシュを設定しています。
Method Path Surogate Control Surrogate Key
GET / max-age=2592000 web, web/release
GET /src/components/voice-app.js max-age=2592000 web, web/release
GET /assets/audios/stadard/$USER_ID/$ENTRY_ID.mp3 max-age=2592000 api, entry/$ENTRY_ID, blogger/$USER_ID
GET /api/entries/$USER_ID/$ENTRY_ID/ max-age=2592000 api, entry/$ENTRY_ID, blogger/$USER_ID
GET /api/playcounts/$USER_ID/$ENTRY_ID/ max-age=30, stale-while-revalidate=120 api, entry/$ENTRY_ID, blogger/$USER_ID
表2: 動的コンテンツはユーザー毎に異なるデータのため、キャッシュしないように設定します。
Method Path Cache Control
POST /api/entries/ private
PUT /api/entries/$USER_ID/$ENTRY_ID/ private
DELETE /api/entries/$USER_ID/$ENTRY_ID/ private
GET /api/auth/status/ private

CDNの活用

CDNは分散ネットワークとキャッシュによるパフォーマンス向上や、サーバーでの計算処理の削減に役立ちます。こえのブログではオリジンサーバーとしてパブリッククラウドであるGoogle Cloud (以下GCP)を使っているため、できるだけCDNのキャッシュに乗せ、運営費用の削減も試みています。

CDNでのキャッシュ効率を向上させるため、Fastlyの設定ファイルであるVCLにはキャッシュキーに必要のないクエリストリングを削除したり、クエリストリングを一律並べ替えたり、リクエストURLによってはSurrogate keyを付与したりといった記述を追加しています。

また、利用者にとって最新データが必ずしも必要でなく、キャッシュ更新時にバックエンドで更新しておけばよいリソースにはstale-while-revalidateを指定し、失効済みコンテンツとして配信しています。

# Fastly用のVCL記述の一部サンプルです
import querystring;

# リクエストを受けた時
sub vcl_recv {
  # リクエストパスが「/assets/」で始まる場合は、
  # カスタムリクエストヘッダーx-originにstorageを指定します
  if (req.url.path ~ "^/assets/") {
    set req.http.x-origin = "storage";
  }

  # キャッシュキーはURLをベースに作られるため、
  # クエリストリングからキャッシュキーに必要のあるものだけ残し、
  # キーの順番も一律並べ替えます
  set req.url = querystring.regfilter_except(
    req.url,
    "^(format|offset|limit|fields)$"
  );
  set req.url = querystring.sort(req.url);
}

# オリジンサーバーから返却された時
sub vcl_fetch {
  # ストレージのリソースへのアクセス時には一律Surrogate keyとしてassetsを付与します
  if (req.http.x-origin == "storage") {
    set beresp.http.Surrogate-Key = "assets";
  }

  # オリジンサーバーでstale-while-revalidateが指定されている場合には
  # 失効済みコンテンツとして配信します
  if (beresp.http.Surrogate-Control ~ "stale-while-revalidate") {
    set beresp.stale_while_revalidate =
      parse_time_delta(
        subfield(
         beresp.http.Surrogate-Control,
         "stale-while-revalidate"
        )
      );
  }
}

イベント駆動のCDNキャッシュパージ

最後に、データ更新イベント駆動によるCDNキャッシュパージシステムを構築します。データの変更があるまでずっとCDNにキャッシュを残しておけるので、オリジンサーバーへの無駄なリクエストを最小限にできます。

「CDN前提のURL設計とキャッシュ設定」の段階で定義したSurrogate keyを使い、それぞれのイベントごとにグルーピングされたキャッシュを更新します。例えば、IDが12345の記事が更新された際には、「entry/12345」を、Webアプリケーションがリリースされた際には「web/release」をパージします。

こえのブログではイベント駆動のキャッシュパージがしやすいように、Cloud FirestoreCloud FunctionsのイベントトリガーやCircleCIを利用して、Fastly APIにパージリクエストを送っています(図2)。

これらのシステム構成は、CDNによるキャッシュ機能を備えた上で、サーバーレスによるスケーラビリティも享受できるため、サーバー起因によるシステム障害のリスクを最小限にできました。

Cloud Firestoreで記事データが更新、削除された場合には、Cloud Functionを通じて「entry/$ENTRY_ID」というSurrogate keyでFastlyのキャッシュパージリクエストを送信します。CircleCIからデプロイしたときには、「web/release」というSurrogate keyを使ってキャッシュパージします。
図2: イベント駆動CDNキャッシュパージの概要です。データベースでの更新イベントやCircleCIでのビルドスクリプト内で適切なSurrogate keyを利用してCDNキャッシュパージをします。

高キャッシュヒット率でCDNをフル活用

上述の手順を経た結果、CDNエッジサーバーでのキャッシュヒット率はほとんど100%で、CDNのキャッシュ機能をフル活用できました。

さらにCDNサーバーにも無駄にアクセスしないようにService WorkerとCache APIでのブラウザキャッシュもしていますが、それについては「初回表示とリピート表示の最適化」で記述します。

なお、より詳しくCDNを使ったWebアプリケーションの構築を知りたい方は、『WEB+DB PRESS Vol.109 最新CDN入門』が参考になります(宣伝)。

標準対応としてのアクセシビリティ

サイバーエージェントやAmebaの開発チームでは、ここ数年間アクセシビリティに関する輪読会や実装経験から知見をためてきました。そういった取り組みを活かして、こえのブログでは開発の初期段階からアクセシビリティ項目を考慮しました。

仕様やデザインを決める段階からAmeba Accessibility Guidelinesに準拠しつつ、より幅広いユーザーや閲覧方法を想定し、アクセシブルで使いやすいユーザー体験が提供できるようにチームで議論を重ねてきました。「声で書くブログ、声で聴くブログ、声で観るブログ」をコンセプトにしたこえのブログとアクセシビリティとの相性も良いものでした。

alt属性やWAI-ARIAを利用した適切なラベリングはもちろん、フォーカスのフィードバック(ダメ、outline:none;)やダイアログでのフォーカス調整は、特にこだわって作りました。

Amebaのアクセシビリティに関する取り組みやこえのブログでの実装については、アクセシブルなブログ開発、 その後どうなったの (Slideshare)をご覧ください。

Progressive Web App (PWA)

エディタや音声プレイヤーが主機能なこえのブログはClient Side Rendering(以下、CSR)のProgressive Web App(以下、PWA)です。CSRのアプリケーションでは、JavaScriptサイズがサイトパフォーマンスを決める重要な要因になります。

JavaScriptサイズの削減や分割、適切なタイミングでの読み込みを目的としてWeb ComponentsやService Worker、ES Modules、HTTP/2などを利用しています(注: 厳密にはブラウザ毎にESMかES5バージョンを配信していますが、本記事ではESMバージョンを中心に記載しています)。

その結果、Lighthouseさんにもある程度満足してもらえたようです(注: Lighthouseで高得点を取ることだけが目的ではないですが)(図2)。

また、ホームスクリーンからの起動、オフラインでの録音やマテリアルデザインをモチーフにしたUIパーツなど、ネイティブアプリと同様にモバイルアプリケーションとして動作するように作られています。レスポンシブで作られているので、Chrome 72で対応されたDesktop PWAとしても利用できます。

voice.ameba.jpのLighthouse測定結果のスクリーンショット
図2: Lighthouseでhttps://voice.ameba.jp/をMobile、Simulated Fast 3G、4x CPU Slowdown環境で測定しました。

ブラウザでの音声録音とクラウドでの音声認識

こえのブログでは音声録音、音声認識を実現するために近年開発された多くの機能を取り入れています。

ブラウザからマイクへアクセス

ブラウザからマイクにアクセスするために、navigator.mediaDevices.getUserMediaを利用しています。getUserMediaには引数として要求するメディアの種類を指定できます。こえのブログでは、必須となる音声のみ指定しています。

getUserMediaを利用する際には利用者のマイクアクセス権限をnavigator.permissionsを使って取得、その結果による表示処理をすると良いでしょう。こえのブログでは、マイクのアクセス権限の変更を監視し、利用者に対して適切な表示をしています(図3)。

ダイアログ表示のスクリーンショット。マイクが許可されていない旨の通知と、マイクの利用を許可する方法が記載されたヘルプページへの導線が表示されています。
図3: マイクが許可されていないときは、確認ダイアログを表示しています。

音声の認識 (書き起こし)

音声の認識には、Cloud Speech-to-Textを利用しています。こえのブログでは「聴く・見る」というコンテンツ消費体験を担保するために、音声だけでなく文字での記録も残しています。認識精度は音声によってまちまちで、現時点では投稿者による編集を必要としていますが、今後は精度の向上が見込まれます。

Cloud Speech-to-Textでなくても、Web Speech APIを利用すればブラウザ環境で動作します。プロトタイプの時点ではChromeブラウザ向けに利用していましたが、最終的には動作を統一するためにすべての環境でCloud Speech-to-Textを利用しています。

WebAssemblyを利用した音声の圧縮

マイクから録音された音声は多くのブラウザではwav形式で保存されます。wav形式は高音質ですが、ある程度の長さの音声をクライアントで処理したり、ネットワークを通じて送信するにはデータ量が大きすぎます。

こえのブログではKagami/vmsgを利用して、ブラウザ上でmp3形式に変換しています。vmsgはWebAssemblyを利用しており、重い処理をUIスレッドと分けることで、安定したクライアントサイドでの録音機能を提供でき、リモートストレージへアップロードするファイルサイズも大幅に削減できました。

WebAssemblyを利用することで、WebViewで利用できないなど対応できるブラウザは限られますが、こえのブログはその他にもブラウザの新しい機能を導入しており、スタンドアローンでの起動を前提としたWebアプリケーションであるため大きな問題となりませんでした。

Web Componentsでのコンポーネント指向

こえのブログは、サーチエンジンに対する対応は控えめかつCSR中心のアプリケーションであるため、実験的にWeb Componentsを利用しています。投稿機能のサポートブラウザではpolyfillなく動作するため、JavaScriptサイズを小さくできます。

Custom Elementsでコンポーネント指向のアプリケーションを構築しつつ、Shadow DOMでスコープを区切ったスタイルを指定します。実際のアプリケーションではLitElementを使い、より高機能なコンポーネントを作成していきす。React.Componentを使ったことのある開発者であれば、比較的容易に記述できるでしょう。

Web Componentsでのスタイル指定

Web Componentsを利用することで、スタイルを指定する際にCSSがもつカスケーディングとShadow DOMがもつカプセル化の特性を活かせます。

コンポーネント内のセレクタはShadow DOMによりカプセル化されているので、セレクタ名の衝突を避けやすくなります。

コンポーネント利用時にスタイルを上書きしたい場合には、カスタムプロパティを利用します。デフォルトCSSルールをコンポーネント内部に持ち、特別に指定させたいルールはカスタムプロパティとして受け取ります。

/**
 * voice-micコンポーネントのスタイル一例です。
 * コンポーネント内部ではデフォルト値として72pxを指定しつつ、
 * 外部から指定できるようにカスタムプロパティとして
 * --voice-mic-sizeを開放します
 */
:host {
  --mic-size: var(--voice-mic-size, 72px);
}

.mic {
  height: var(--mic-size);
  width: calc(var(--mic-size) * 1.7);
}

各コンポーネント内ではドキュメントルートや親コンポーネントで指定されたCSSルールを継承して利用することもできます。

:root {
  --app-background-color: #e2e2e2;
}

voice-app {
  font-family: sans-serif;
}

child-component {
  background-color: var(--app-background-color);
  /* font-familyはsans-serifが適用されます */
}

初回表示とリピート表示の最適化

こえのブログでは、HTTP/2やES Modules、Service Workerの機能を利用し、初回表示とリピート表示の最適化をしています。

初回表示時には、HTTP/2 Server Push※1で表示に必要なリソースをいち早く取得し、その他のアプリケーション内リソースをService Worker経由で取得、ブラウザに保存しておきます。それぞれのリソースにはファイルの中身に応じてハッシュ値が付与されており、Service Workerではその値が変わるまで再度取得しないため、ネットワーク転送量の削減にもなります。

リピート表示時にはそれら保存されたデータを利用します。これはネイティブアプリのインストールと同様の動作といえ、リピート表示時にも素早く表示できます。

それぞれの読み込みフロー概要は以下のとおりです。

初回表示フロー

  1. ブラウザからhttps://voice.ameba.jp/ をリクエスト (他のパスでも動きは同様)
  2. サーバーからHTMLが返却される
  3. App Shell(src/components/voice-app.js)とページに関連するスクリプトがサーバーからHTTP/2 Server Pushされる
  4. 各リソースをブラウザが取得、評価した後、画面表示される
  5. ブラウザから遅延してもいいスクリプト(src/components/lazy-resources.js)が読み込まれる
  6. ブラウザのonloadイベントでService Workerが登録される
  7. 次回表示やオフラインでの利用に備えて、Service Worker経由で全てのスクリプトがプリキャッシュされる

リピート表示フロー

  1. ブラウザからhttps://voice.ameba.jp/ をリクエスト (他のパスでも動きは同様)
  2. Service WorkerからプリキャッシュされたHTML、スクリプトが返却される
  3. 各スクリプトをブラウザが評価した後、画面表示される

※1 Fastlyでは同一コネクションの場合同じリソースを2度以上プッシュしないようです。

パフォーマンスバジェットの設定

パフォーマンスバジェットはWebアプリケーションが継続的にそのパフォーマンスを維持するために設定されるものです。パフォーマンスバジェットの指標にはFirst Contentful Paint(以下、FCP)、Time To Interactive(以下、TTI)などのパフォーマンス指標やJavaScriptサイズ、画像サイズ、リクエスト数やパフォーマンス測定ツールの得点などを設定します。

財政的な予算管理と同様に、設定されたパフォーマンスバジェットの残りが少ない場合には、機能削除や新規機能追加の停止などパフォーマンスを向上させるための意思決定をおこなうことが推奨されます。

こえのブログのパフォーマンスバジェット

こえのブログのパフォーマンスバジェットは、Fast 3G環境においてFCP 1.8秒1.5秒、TTI 4秒3秒にしています。また、JavaScriptサイズの肥大化を避けるため、App Shell用のスクリプトは120KB、ルートごとのチャンクスクリプトは20KBというサイズ制限も設けました。これらは以下の手順で設定しました。

  1. こえのブログとその競合と定義したサービスそれぞれを計測し、一覧化します
  2. 競合から20%早いと利用者がその速度を体感できると言われていますが、こえのブログはすでに最速であったため、競合の中で最速のサービスに比べ20%早い値を参考にバジェットを設定します
  3. ただし、その場合にはこえのブログ2~3倍の遅延となり現実的でないため、現状の1.2倍の遅延まで許容するというバジェットにしました現状の数値を維持するバジェットを設定しました。最初に設定したバジェットでは1.2倍のバッファをもたせていましたが、パフォーマンス劣化を早期に防ぐため、現状維持の数値に変更しました
  4. 将来的に達成したい目標値としてSlow 4G(≒Fast 3G)通信環境の理想値である、FCP 1秒、TTI 3秒もバジェットとは別に設定しました

パフォーマンスバジェットの監視

パフォーマンスバジェットの監視はSpeedCurve Statusでおこないます。正常である場合は緑色、異常である場合は赤色の背景が表示されます(図4)。JavaScriptサイズはビルド時にsiddharthkp/bundlesizeでチェックし、サイズを超過するとデプロイできないような設定にしています。

Speed Curve Statusのスクリーンショット。FCP, Speed Index, TTIの値が緑色背景で表示されています。
図4: SpeedCurve Statusの一例です。計測した値がパフォーマンスバジェット内に収まっている場合には緑色の背景で表示されます。

パフォーマンスバジェットの実運用

先日、ある機能を開発している際にチャンクスクリプトのファイルサイズがバジェットを超過してしまうことがありました。ファイルサイズのバジェット自体はパフォーマンス指標のバジェットに悪影響を与えなければ調整する余地があります。

リリース前に開発環境で計測したところ、パフォーマンス指標には変化を与えなかったため、ファイルサイズのバジェットを変更しました。パフォーマンスバジェットの設定があったからこそ、適切なタイミングで意思決定ができた一例といれるでしょう。

また、SpeedCurveでの定点観測では良かった数値が、分布を出してみると思った以上にロングテールで、想定より遅いリクエストも発見されました。現時点では詳細に分析できていませんが、将来的にはバジェット内に収まるリクエストの割合もモニタリングしようと思っています。

新機能開発にあたって

こえのブログはアメブロをアップデートした体験を提供できるように作られました。また、新機能開発にあたって、コストパフォーマンスのよい開発も目指してきました。

音声で投稿という新規感を出しながらアメブロの拡張されたコンテンツとして認識されるように工夫をしました。新しくもありアメブロに馴染むようなロゴをたくさん制作したり、ブログ埋め込み時に違和感がないようにレイアウトを調整しました。

低コストですばやくリリースできるように、Webアプリケーションを中心にプロトタイプを作成し、ある程度動くようにしてからユーザーインターフェースの更新、コード品質の改善やユーザーテスト、品質テスト、セキュリティ診断などすばやく開発サイクルをまわしました(社内では「餅つき開発」と呼ばれました)。

プロジェクトの経緯がわかりやすいように、READMEとしてドキュメントを残しました。この記事はそのドキュメントから一部抜粋したものですが、そこにはさらに詳細の記述があります。より詳しく知りたい方はサイバーエージェントへの入社をご検討ください(笑)。

今後は利用してくださる方々の動向を踏まえながら新機能を開発するほか、ネイティブアプリ版のエディタもリリース予定ですのでお楽しみにしていてください。