サイバーエージェントでは今年、CA PoCMOCK CONTEST 2021というコンテストが開催されました。PoCMOCKは造語でそれぞれPoC(検証)・MOCK(モックアップ)を表し、エンジニア・クリエイターのアイディアやスキルを駆使して技術的・品質的・社会的な何かしらの課題を解決したプロダクトの一部を制作し披露する場になりました。

私自身は運営チームとしてコンテストに関わっていたのですが、社員賞を決めるための投票システムを作るためにクリエイター・PMとともに参加することにしました。

投票システムの前提として、2日間の開発期間で作りきること、また作ったプロダクトはその後すぐに社員が利用できる状態であることが求められていました。

本記事ではその制作を通じて発見できたことや工夫したところを紹介します。

NOTE: 本記事の内容はアプリケーション制作時(2021年8月)の情報に基づいています。現在の情報と異なる場合がありますので、最新情報を確認してください。

投票システムの概要

動画にリアクションするWebアプリケーション

コンテストに提出したときのタイトルは「動画作品の応援視聴システム 感覚的リアクション→ピックアップシーンの作成」でした。感覚的にリアクションできることとリアクション数に応じてその動画のピックアップシーンが自動生成される点が特徴です。

投票システムでは動画を見ながらリアクションを送れます。特にリアクションが多かったポイントにはピックアップアイコンが表示されます

投票システムのシステム概要

投票システムはFirebaseを利用して作られています。今回のアプリケーションはサイバーエージェント社員限定公開のため、動画やリアクションデータの読み込みにアクセス管理が必要でした。それらはFirebase AuthとFirebaseのセキュリティルールで満たせたため、非常に楽でした。

match /b/{bucket}/o {
  function checkAuthentication(auth) {
    return auth != null &&
      auth.token.email.matches('^(.*)@cyberagent[.]co[.]jp$') &&
      auth.token.email_verified &&
      auth.token.firebase.sign_in_provider == 'google.com';
  }

  match /video/{allPaths=**} {
    allow read, write: if checkAuthentication(request.auth);
  }

  ...
}
投票システムのシステム概要図
投票システムは、Firebase Hosting、Firebase Authentification、Cloud Storage、Cloud Firestoreで構成されています。Firebaseのセキュリティールールで社員限定にしている点と、定期的にFirestore Data BundlesをCloud Storageに保存している点が特徴です

Web標準技術で作るLess configなアプリケーション

今回のアプリケーションではできる限りビルドやバンドルなどステップを挟まず、ブラウザでそのまま動くアプリケーションを制作してみました。それは、モックアップであることから作っては壊しを繰り返すので、設定ファイルに時間を費やしたくなかったことやある程度閲覧環境を絞れる要件であったため、モダンブラウザを前提に作れたためでもあります。

Web Components (lit)を使ったFunctional Component

コンポーネントを中心にしてアプリケーションを作成したい、そんな時のWeb標準技術はWeb Componentsです。JavaScriptのよさを活かしつつ、関数ベースで作れるといいなと思っていたところ、matthewp/hauntedというライブラリを見つけたので試しに使ってみました。

このライブラリはlithyperHTMLをHTMLテンプレートに使いつつ、React Hooksのように状態やライフサイクルの処理を関数内に書けるようにしたものです。

すでに主要なHooksが揃っているので、コンポーネント作成時はほとんどReactコンポーネントを作っている感覚でした。

import {
  component,
  html,
  useState,
  useCallback,
} from 'https://unpkg.com/haunted@4.8.2/haunted.js';

import { router } from '../route.js';

import { useEntry } from './useEntry.js';

import './entry-video.js';

function Entry(element: HTMLElement) {
  const entryId = router.location.params.entry;
  const [entry] = useEntry(entryId);

  const [duration, setDuration] = useState(0);

  ...

  const handleVideoMetadataLoaded = useCallback(
    (event: { target: HTMLVideoElement }) => {
      setDuration(event.target.duration);
    },
    [entryId]
  );

  return html`
    <style>
    :host {
      display: grid;
      ...
    }
    </style>

    <header>
      <h1>${entry.title}</h1>
    </header>

    ...

    <entry-video
      src=${entry.video}
      @loadedmetadata=${handleVideoMetadataLoaded}
      ...
    ></entry-video>
  `;
}

customElements.define('app-entry', component(Entry));
huontedを利用してReact Hooks風の関数コンポーネントを作成しています

ES ModulesとURL Importsを利用したバンドルしないリソース配信

今回のアプリケーションではアセット配信を最適化する重要度が高くなかったので、作成したファイル単位でそのまま配信しました。配信したファイルのURLはそのままES Moduleとして利用できるのでバンドル処理を挟む必要がありません。これは設定ファイルの手間を防いだり、デバッグの容易性に役立ちました。

唯一tscを使いコンパイルしています。それは、限られた時間で作っては壊しを繰り返すことが想定されるアプリケーションで余計な調査時間を削減したかったためです。利用したライブラリは比較的新しいものであったため、型定義も用意されていました。

import { Router } from 'https://unpkg.com/@vaadin/router@1.7.4/dist/vaadin-router.js';

const app = document.getElementById('app');
export const router = new Router(app);

router.setRoutes([
  {
    path: '/',
    component: 'app-shell', // アプリの雛形
    children: [
      {
        path: '/',
        component: 'app-home',
        action: async () => {
          await import('./components/app-home.js');
        },
      },
      {
        path: '/entry/:entry',
        component: 'app-entry',
        action: async () => {
          await import('./components/app-entry.js');
        },
      },
      { path: '(.*)', redirect: '/' }, // Fallback
    ],
  },
]);
routerでのDynamic Importの例。各ページが表示される前に必要なスクリプトがロードされます。今回のアプリケーションでは@vaadin/routerを利用しました。ネストやファールバック、遅延読み込みなどモダンなアプリケーションに必要な機能が揃っていました

CSSでのリアクションアニメーション

リアクションの操作感は今回のアプリケーションで肝となる重要な要素でした。感情が表現できて、何度も押したくなるようなリアクションを目指しました。

細部までこだわりたいところでしたが、時間も限られていたので一つ基準となるアニメーションを作り、残りのリアクションは同じキーフレームを使いながら、アニメーションの種類を変化させることでニュアンスを表現する工夫をしました。

アニメーションのサンプル。タップすると動きます

運営費用を抑えるための工夫

モックアップとはいえ実際に社員に利用してもらうため、問題なく動作することに加え運営費用を抑える工夫が必要でした。今回のアプリケーションでは動画配信やリアクションの保存・読み出しがあったためリリース後に費用がかかりすぎて投票機能が停止するというトラブルを避けなくてはなりませんでした。

Firebase HostingとFirebase Cloud Storageでのファイル配信

今回のアプリケーションではサイトを構成するHTML・JavaScript・画像などのアセットと動画の配信をする必要がありました。Firebaseで静的ファイルを配信する方法を調べてみると選択肢はFirebase HostingとFirebase Cloud Storageの2つありました。

さらに詳しく料金や制限を調べて見ると、Firebase Hostingは無料枠の転送量が少なく、大規模な配信には向いていないことがわかりました。動画ファイルはすでに収録済みなためFirebase Hostingで配信してもいいかなと思っていましたが、その選択肢は厳しいようでした。

結果的にサイトを構成する誰もが閲覧可能なアセットはFirebase Hostingで、ファイル容量が大きくアクセス制限をかけたい動画ファイルはFirebase Cloud Storageで配信することにしました。

Firestore Data Bundlesの利用でキャッシュ作成

リアクションのデータはFirestoreに保存することにしました。ただし投票システムではどんどんリアクションしてもらうことを推奨しているため、細かい単位のデータが多く保存されることが予想されます。

しかし、利用者がアクセスするたびにリアクションデータを取得していると課金対象であるドキュメント数があっという間に増えてしまい、驚きの高額請求となってしまいます。無料版でテスト公開して使ってもらった時に数人の利用でもあっという間に無料枠を超過してしまい、焦りました。

キャッシュの仕組みが必要だなぁと思いながら調べたところ、Firestore Data Bundlesという仕組みを使うとFirestoreのクエリ結果をまとめたデータを作成でき、キャッシュとして利用できることがわかりました。ただし、管理者権限でのバンドル作成なので、非公開データが含まれていないか注意が必要です。

今回は定期的に作ったリアクションや記事のデータが含まれるバンドルをFirebase Cloud Storageに配置し、社員だけが閲覧できるようにアクセスコントロールしました。このパターンでは自分のリアクションが反映されたか即座に確認ができませんが、その要望は少ないため時間差で反映されても問い合わせなどはありませんでした。

モダンフォーマットでの画像配信

ファイルの転送量のうち動画を除くと大部分を占めるのは画像です。今回のアプリケーションでは作品のサムネイル、poster画像が配信されていましたが、元ファイルはPNG 24で用意されており、必要以上に大きくなっていました。それが閲覧セッション分転送されるので、驚きの高額請求となってしまう可能性が少しありました。

今回は事前にWebP形式に変換することで結果的に91%のデータ量(6MBから0.5MB)が減りました。WebPは今や対応ブラウザも多いので第一選択肢になりうるでしょう。より新しく圧縮効率の良いアグレッシブな画像形式にしても良かったですが、今回はWebPで十分でした。

Service WorkerとCache APIでの無駄なネットワークリクエスト削減

今回のアプリケーションでは一度ファイルを取得した後に再度ネットワーク経由で取得する必要のないファイルがありました。例えばサイトを構成するHTML・JavaScript・画像やビデオのサムネイル画像はファイルが更新されるまでキャッシュしていいことが保証されています。

そこでサイトのアセットはWorkboxのプリキャッシュ機能でビルド時にハッシュ値を生成し、変更されるまでネットワークリクエストしないようにしました。ビデオのサムネイル画像はURLを一意にし、キャッシュ可能にしました。またリアクションデータは前述の通りリアルタイム性がそこまで必要でないのでキャッシュを優先して利用するようにしました。

今回は転送量を減らす目的でService WorkerとCache APIを利用しましたが、表示速度にも影響がありました。サイトの構成上キャッシュがない状態ではHTMLだけでなくサブリソースの読み込み、評価・実行を待たなければならず決して表示が早いサイトではありませんでした。それがService Workerで管理されキャッシュがある状態だとLCPFCPが早くなりました。

Firebase performanceのスクリーンショット
LCPFCPはService Worker管理対象だと中央値1.05秒に対し、対象外だと2.59秒でした

果たしていくらかかったのか

さて、驚きの高額請求はあったのでしょうか。公開前はどきどきしていましたが、最終的にはコーヒー1杯くらいの料金で無事、投票機能を提供できました。

Firebaseでの費用のスクリーンショット
投票システムにかかった費用は合計375円(Cloud Storage 321円、Cloud Firestore 54円)でした

最後に

今回のイベントはサイバーエージェント全社の技術領域が対象だったため、機械学習やデバイス系など様々な技術を利用したユニークな作品が披露されました。その中でもWebアプリケーションをテーマとしたPoCは非常に少なく個人的には危機感を抱きました。

Web開発技術はコモディティ化していたり、成熟期に入っていると言えますが、これからもチャレンジのタネとなる要素を見つけ試していけたらと思いました。

カフェラテホットのイラスト

スターバックスラテ☕️は374円です。

2008年に新卒でサイバーエージェントに入社。主にAmeba関連の開発を担当。パフォーマンス、アクセシビリティ、アーキテクチャなどWebアプリケーション品質の向上に注力している。最近の趣味は猫の写真撮り。