ZOZO大忘年会でFirebaseを使った1,000人規模のリアルタイムアンケートを制作した話

f:id:vasilyjp:20190109111412p:plain

こんにちは!
ZOZOテクノロジーズ フロントエンドエンジニアの高橋(ニックネームはQ)です(@anaheim0894

昨年12/26、毎年年末に行われる大忘年会(ZOZOCAMP2018)で、グループ会社も含めた1,000人規模でのリアルタイムアンケートを、FirebaseとVue.jsを使って制作しました。

当日会場にて弊社の昨年の事業紹介や、「楽しく働く」というコンセプトの動画を流し、動画の合間で質問をし動画と一体となるような演出を行いました。 その質問に対して全社員それぞれのスマートフォンで回答できるシステムを作ったので、その制作の裏側や、当日の様子などご紹介させていただきます。

まずは当日の様子の紹介

f:id:vasilyjp:20190108162935j:plain

f:id:vasilyjp:20190108164036j:plain

f:id:vasilyjp:20190108164200j:plain

f:id:vasilyjp:20190108164220j:plain

f:id:vasilyjp:20190108164239j:plain

これを実現するまでの様子をご紹介いたします。

CAMP運営してくれている社員にもらった要件

CAMPの2週間前、運営の社員の方からこのような依頼をもらいました。

  • 会場で、リアルタイムでアンケートをWebでとり、その集計を即時に、目の前のプロジェクタに映し出す
  • 選択肢はYES / NOの2つ
  • 質問は全部で7〜8問
  • QRコードを読み取って、スマホで回答できる
  • いい感じのデザインでプロジェクタに出したい
  • 質問と回答は動画と動画の間に行いたい

なかなか厳しい要件でした。

どうやったら実現できるか考える

依頼に答えるべく実現方法を検討しました。

  1. SlackのPollyを使って全社員が投票、Slack APIを使ってプロジェクタに表示
    → グループ会社含めるとSlackには全社員のアカウントがないため、NGに。

  2. 外部のサービスを使う
    →リアルタイムアンケートのサービス、responがあったものの、カスタマイズができるか不明なこと、2週間で要件を完璧に実現できないと感じたためNGに。

  3. PBグローバル を担当しているフロントエンドチームで自作
    → Firebaseならリアルタイムデータ集計の可能性があったため、こちらで進めていくことに決定しました

自作にあたって整理した要件

CAMP運営からの要件を元に、自作するための要件を整理しました。

  • 質問は7〜8問
  • 動画演出の間に質問を差し込み
  • 動画と質問の切り替えはプロジェクタのスイッチングのみ
  • 選択肢 Yes / No の二択
  • QRコードで読み取ってサイトを表示
  • 回答結果がリアルタイムで集計され、すぐに会場のプロジェクタで表示
  • 次の質問に移るときはリロードなし
  • 質問の切り替えは管理画面を用意
  • スマホでの回答は1回のみで解除は不可
  • 回答時間は15秒(質問画面10秒その後5秒のカウントダウン演出)
  • 結果はYES/NOのパーセンテージ割合で表示
  • 当日その場で質問の追加はなし

準備 / 事前に確認したこと

CAMPは1,000人規模のイベントです。 そのため、当日起きることをどれだけ予測できるか
予測外の事態にどれだけすばやく対応できるか が重要になると考え、様々なケースを想定して情報を集めました。

  • 参加人数
    • 約1,000人
  • 1,000人に耐えられるネットワークの準備
    • イベント自体はイベント会社が入っていたため、イベント会社にWi-Fiの用意を依頼(NTT様にご協力頂きました)
  • 会場スクリーンスペック
    • 解像度:1920×1080
      コンテナ:MOV
      コーデック:映像H.264 / 音声PCM 48kHz
      フレームレート:59.94または29.9
  • 会場スクリーンの台数
    • 4画面
  • スクリーンに繋ぐマシンのネットワーク環境
    • 有線で用意
  • 当日のリハーサルの可否
    • 15時から30分〜40分リハーサルができた
  • 1,000人規模のテスト
    • 人数を用意できないため、ぶっつけ本番
      (擬似的にコードレベルでの負荷テストは実施)
  • 当日不具合があった場合の対応
    • 質問だけのスライドを用意する。最悪挙手なども考えられる
  • デザイン
    • 社内のデザイナーに依頼
  • さまざまなスマホでの表示確認
    • 社内のテスト検証を専門に行なっているチームに依頼

選定した技術

上記の情報から自作するために、以下の技術を選定し、作成しました。

作成した画面

以下の3画面を作成しました。

  • 回答画面
    • 各ユーザーがスマートフォン上で回答を送信する画面
    • 質問の開始・終了時に画面の活性を制御

f:id:vasilyjp:20190108164343g:plain

  • 質問/結果画面
    • 会場のスクリーンに表示される質問や結果の画面
    • 質問の開始・終了時に質問/結果画面を切り替え、結果画面には各ユーザーの回答を集計して表示

f:id:vasilyjp:20190108164359g:plain

  • 管理画面
    • 進行管理者が質問を切り替えるための画面
    • 質問を切り替えると同時に、質問の終了時刻も設定

f:id:vasilyjp:20190108164417g:plain

Firebaseセットアップ

質問の表示切り替えと投票結果の集計をリアルタイムで行うため、リアルタイムリスナー搭載のFirestoreがあるFirebaseを選定しました。
開発に携わったのが全員フロントエンドのメンバーだということもあって、サーバーサイドの開発はせずクライアントJSからデータベースに直接アクセスする構成を取りました。
ここでは、FirebaseをWebアプリケーションで使用するためのセットアップ方法をご紹介します。

プロジェクトのセットアップ

  • Googleアカウントが無ければ作成します。
  • Googleにログインして、Firebaseコンソールからプロジェクトを追加します。
    • プロジェクト名を入力します。
    • アナリティクスやテクニカルサポートについては必要無さそうなのでチェックを入れずに進んでいきます。

f:id:vasilyjp:20190108175118j:plain

f:id:vasilyjp:20190108175137j:plain

  • 自動でプロビジョニングが行われ、プロジェクトページに飛びます。
    • デフォルトでは無料のSparkプランが設定されています。ちょっとしたアプリケーションなら無料枠で問題なく動くと思います。
    • 今回は1,000人規模のユーザーがリアルタイムで操作するということだったので、念のため従量課金のBlazeプランに設定しました。

Firestoreセットアップ

Firestoreを設定していきます。

  • 左側のナビゲーションから開発Databaseを選択し、データベースを作成します。
    • セキュリティルール:今回は要件上セキュリティ面をさほど気にする必要がないので、スピード重視でテストモードを選択します。

f:id:vasilyjp:20190108175154j:plain

  • コレクション(RDBでいうテーブルのようなもの)が無い空のデータベースが作成されます。
    FirestoreはNoSQLなのでスキーマの作成も必要ありません。
  • Webアプリケーションとの連携を行います。コンソールトップのHTMLタグ風のアイコンをクリックします。

f:id:vasilyjp:20190108175210j:plain

ポップアップでスニペットが出てくるのでこれを開発中のHTMLコードに貼り付けます。

f:id:vasilyjp:20190108175225j:plain

  • (アプリケーション側のコードをまだ何も作成していなかったので、ここでVue.jsのプロジェクトをinitializeします)
    • Vue.jsのセットアップはこちらを参考にしてください。
  • ポップアップのメッセージにあるHTMLの一番下、他のスクリプトタグの前ではなく、main.jsに貼り付けます。

f:id:vasilyjp:20190108175244j:plain

  • .vueファイル内でimport firebase from 'firebase'と記述すれば、firebase変数でFirebaseのAPIが利用できます。
    GUIコンソール上でガイドにしたがって画面操作するだけでセットアップが完了しました。

Firebase Hostingセットアップ

WebアプリケーションのホスティングにもFirebaseを利用することにしました。
こちらはFirestoreと比較しても、とても簡単に設定できます。

  • 左側のナビゲーションから開発Hostingを選択し、使ってみるボタンから開始します。
    • ポップアップのガイドにしたがってfirebaseのコマンドラインツールをインストールします。

f:id:vasilyjp:20190108175303j:plain

f:id:vasilyjp:20190108175321j:plain

  • $ firebase loginでGoogleアカウントにログインします。
  • 開発中のvueプロジェクトのディレクトリに移動し、$ firebase initでデプロイ設定ファイルを作成します。
  • 生成されたfirebase.jsonhosting.publicにデプロイ対象ディレクトリを設定します。
    今回はvueプロジェクトをwebpackでビルドしたコードがdistディレクトリに出力される構成なので、distと入力します。
  • $ firebase deployで、デプロイ完了です。
  • firebaseコンソールのHosting画面にデプロイ先のサーバーのドメインが表示されているので、このURLでブラウザからアクセスできます。

f:id:vasilyjp:20190108175337j:plain

以上でFirebase側の設定は完了です。

実際20分くらいでインフラセットアップ作業が完了し、すぐさまアプリケーション側の開発に入れる状況まで持ってこられました。

Firestoreと画面の連携

アプリケーション側のFirestore連携部分の実装を紹介します。 3画面の状態を各端末で同期的に制御するために、Firestoreで以下を管理することにしました。

  • 現在の質問id
  • 回答の締切時間
  • ユーザーの回答

実際には以下のようなデータ構造でこれらを管理しました。
(実際のデータはJSONではありませんが、便宜上JSON形式で書いています)

{
  "questions": {
    "current": {
      "id": 1, // 現在の質問id
      "endTime": 1545906141 // 回答の締切時間
    },
  },
  "votes": { // 各ユーザーの回答
    "001EjYAwtSMXlrWWTP5r": { // (Firestoreが自動付与するid)
      "answerId": 0, // 回答id
      "questionId": 2 // 質問id
    },
    "004gO0YzXUJ2bNFSbc5y": {
      "answerId": 1,
      "questionId": 3
    },
    .
    .
    .
  }
}

回答・質問/結果画面でこれらのデータの変更を購読します。

  • 現在の質問idと回答の締切時間(回答画面・質問/結果画面)
import firebase from 'firebase'

export default {
  .
  .
  .
  created() {
    // this.db = firebase.firestore()
    this.unsubscribe = this.db.collection('questions').doc('current')
      .onSnapshot((doc) => {
        const currentQuestion = doc.data()
        // =>
        //   {
        //     "id": 1,
        //     "endTime": 1545906141
        //   }
      })
  }
  .
  .
  .
}
  • 各ユーザーの回答(質問/結果画面)
import firebase from 'firebase'

const db = firebase.firestore()
export default {
  .
  .
  .
  created() {
    // this.db = firebase.firestore()
    this.db.collection('votes').onSnapshot((collection) => {
      this.votes = collection.docChanges().reduce((votes, c) => {
        const vote = c.doc.data()
        // =>
        //   {
        //     "answerId": 0,
        //     "questionId": 2
        //   }
        return {
          ...votes,
          [vote.questionId]: [...(votes[vote.questionId] || []), vote]
        }
      }, this.votes)
      // =>
      //   {
      //     .
      //     .
      //     .
      //     "2": [ // 質問id毎に集計
      //       {
      //         "answerId": 0,
      //         "questionId": 2
      //       },
      //       .
      //       .
      //       .
      //     ],
      //     "3": [
      //       {
      //         "answerId": 1,
      //         "questionId": 3
      //       },
      //       .
      //       .
      //       .
      //     ],
      //     .
      //     .
      //     .
      //   }
    })
  }
}

結果画面では質問毎に集計を行うので、質問id毎に回答のデータをまとめています。 また、回答のデータに関しては課金を最小限にするためdocChanges()を用いて差分だけ取得するようにしています。 一方、管理画面では以下のようなコードでFirestoreに書き込みを行います。

import firebase from 'firebase'
import moment from 'moment'

export default {
  .
  .
  .
  methods: {
    setQuestion() {
      db.collection('questions').doc('current').set({
        id: questionId,
        endTime: moment().add(questionTime, 'second').unix()
      })
    }
  }
  .
  .
  .
}

管理画面で質問を開始するときには、Firestoreに質問idと締切時間を書き込みます。 書き込みが行われた時点でFirestoreから会場の全端末に通知され、一斉に質問が開始されます。 そして、各端末が締切時間に応じて質問の回答を締め切ります。

以上でFirestore連携は完了です。 ここまで来たら、後は画面にアニメーションや装飾を施すのみです。

アニメーションについて

質問/結果画面のアニメーションに関してはTweenMaxで実装しました。

TweenMaxについて

GSAP(グリーン・ソック・アニメーション・プラットフォーム)モジュールの1つです。

使用可能ライブラリとプラグイン以下のとおりです(要は全部入り)

  • TweenLite
  • TimelineLite
  • TimelineMax
  • CSSPlugin
  • AttrPlugin
  • RoundPropsPlugin
  • BezierPlugin
  • EasePack

(後々考えると今回の実装内容だとTweenLiteのみでよかったかも…)

採用した理由

  • Vue.jsのデータ駆動設計と相性が良さそうだった
  • 複雑なアニメーション作成に適している
  • 構文が直感的でわかりやすい

使用方法

import

import { TweenMax } from 'gsap'

Methods

今回は主に下記Methodsを使用しました。

値(初期値)を設定する

TweenMax.set( target:Object, vars:Object )

初期値から設定した値にアニメーションさせる

TweenMax.to( target:Object, duration:Number, vars:Object )

設定した初期値から設定した値にアニメーションさせる

TweenMax.fromTo( target:Object, duration:Number, fromVars:Object, toVars:Object )

配列化されたtargetをindex順に設定した初期値から設定した値にアニメーションさせる

TweenMax.staggerFromTo( targets:Array, duration:Number, fromVars:Object, toVars:Object, stagger:Number )

実装サンプル

See the Pen countdown by masahito.ando (@masahito_ando) on CodePen.

質問画面にて、questions.current.endTime(Firestoreに書き込まれた締切時間)をフロント側でもsetIntervalにて監視し、締切時間が残り5秒になったタイミングで上記アニメーションを開始しています。

See the Pen graph by masahito.ando (@masahito_ando) on CodePen.

締切時間に到達した時点で結果画面を表示します。
そのタイミングでvotes.[Firestoreが自動付与するid].questionIdquestions.current.idでフィルタリングして現在の質問の回答データを取得し、YESとNOのパーセンテージとグラフの高さを計算します。計算完了後、上記アニメーションを開始しています。

当日起きたこと

動画と動画の合間に質問を入れるタイミングを画面のスイッチャーと息を合わせる必要があったため、リハーサルで入念にタイミングを確認しました。

実際にリハーサルしてみると、動画と質問の間に違和感があり、スムーズに見えませんでした。 そのため、動画と一体になっているような演出に見えるよう質問の前に真っ白な画面を用意しました。また、動画が真っ白にフェードアウトするようにしてもらいました。

1,000人同時アクセスされた場合ちゃんと動くかテストできずぶっつけ本番だったため、1問目が終わった時にちゃんとデータが入った(動いた)時に全員でガッツポーズをしました。

裏方の様子

f:id:vasilyjp:20190108164313j:plain

イベント会社の方が、 こんなリアルタイムでアンケートなんて、これまでやったことないです!
どうやってるんですか?
と興味津々に食いついていただけて、嬉しかったです。

料金について

Firebaseのプランですが、今回1,000人規模かつ初の試みということもあり、従量課金Blazeプランにしました。

イベント終了後、請求金額を見てみると…。

2円

なんと2円でした! 笑
データの作り方にもよりますが、2円の予算で実現できます。

今後の課題

全体を通してうまく作ることできたのですが、当日、質問と質問の間の動画の尺が長く、開いていたiPhoneなどの端末がスリープしておりました。 質問の回答時間15秒だったため、スリープ解除している間に回答時間が終了してしまうという事態が発生してしまいました。

アンケートとしては十分な回答時間でしたが、動画と合わせたときの状況を考え、回答時間が適切かを考えるべきだったなと思いました。

最後に制作メンバーの紹介

今回の制作は弊社PBグローバルのフロントエンドチームで制作をしましたので、ご紹介させていただきます。

こんな突発的かつ刺激的なことや、ZOZOを作ってみたい! という方。

弊社では一緒に作ってくれる方を大募集しています。
ご興味がある方は以下のリンクから是非ご応募ください。

www.wantedly.com

カテゴリー