Opt Technologies Magazine

オプトテクノロジーズ 公式Webマガジン

会社の納会用にクイズアプリを作りました(React / TypeScript)

alt

先日、オプト社内で納会がありました。 その際の出し物の一つとしてクイズ大会があり、技術検証を兼ねてクイズアプリを作成したので中身についてお話します。 (今回はフロントエンド編です)

はじめに

こんにちは。uryyyyyyyです。

先日(といっても7月あたりですが)、オプトの納会が行われました。 有志による多くの出し物があったのですが、その中の「クイズ大会」コンテンツにて、 問題の回答&集計をできるアプリを作れないかと頼まれたため、Webアプリを作ってみました。

せっかくなので会社固有の情報を抜いて公開したリポジトリがこちらです。 (以下ではnoukai_quiz_appのことを「クイズアプリ」と呼ぶことにします。)

github.com

クイズアプリ仕様

概要

  • クイズは4択問題
  • ユーザーは回答者(スマホ)と管理者(ラップトップPC)
  • 回答者は1000人ほど。
  • 回答者は正誤と回答時間を競う。(早押し形式)
  • 問題が表示されるまでは待機画面を表示し、当日に管理者のタイミングで問題を配信する。
  • 回答数がリアルタイムで表示される。(焦りを誘う)
  • 全問題が終わったら、管理者はランキング画面を表示する。
  • ランキング画面では、正答数・回答時間上位50名をカウントダウン表示
  • 上位5名は景品が出るため、大々的に発表
  • さらにおまけ要素も付ける(後述)

このような形です。

おまけ要素についてですが、何かネタを入れようということで、 画面下部に社長の画像を表示して、押したら社長がしゃべる仕様にしてみました。 (社是である「Action!!」を始め、社長に言わせたい言葉を予め収録してランダムに流しました笑) さらに、裏でこっそり押した回数を集計していて、押した回数トップの人は何か変な景品を渡すことにしてました。

使った技術

基本的に技術検証目的でツールを選びました。 当時を振り返ってになりますが、

  • React.js / Redux
    • 都合上SPAっぽい実装が求められそうだった。
    • 次のシステムで使う予定だった。
    • フロントもテスト書きたいので設計大事。
  • TypeScript
    • 複数人開発に型は欠かせない。
    • Scala好き型好き企業なので。
  • WebSocket
    • 画面切り替え、回答数表示に即応性を求められるため。
  • akka-http
    • Node.jsで1000人捌けるか不安だった。
    • システムでもSprayからの移行を検討していた。

という形です。

画面設計

ここまで話してきましたが、画面を見ながらでないとどんなアプリかイメージしにくいと思うので、 以下では、各画面について実際の動作を見ながら説明していきます。

回答者:ログイン画面

alt

回答者は、後で表彰するためユーザー識別する必要があります。 今回は、面倒なことしたくないということでURLに情報を入れることにしました^^;

また一意なユーザー識別も面倒だったので、部署と名前を入れたら一意になるだろうということにしています。 もし名前が被る場合は「開発部 / 山田 偉い方」とかを入れてもらうことを周知しました(運用でカバー)

ログイン画面で部署を選ぶ際に、社内の部署がかなり多く探すのが大変ということを受けて react-select を取り入れてフィルタできるようにしました。

名前など任意の文字をURLに突っ込んで大丈夫かちょっと心配でしたが、当日は問題なかったです。

回答者:待機画面

alt

早押し制のため、問題が出たタイミングではじめて回答画面に切り替えなくてはなりません。 そこで、ログイン後・回答後の表示用の待機画面を用意しています。

画面下にボタンがありますが、これは上述のおまけ要素でして、ボタンを押すと音声が流れます。 社長の音声を公開するのもアレなのでネコの音声に差し替えています。

また、ユーザーがいつリロードしても、待機・回答画面を維持する必要があったため、 フロントではずっと同じURLでSPAっぽく振る舞い、サーバー側で制御する形をとっています。 リロードすると、描画前にサーバーに状況を問い合わせて、「問題 2」の回答中なら回答が表示されます。

管理者:管理画面

alt

こちらはスマホではなくPC画面です。 管理者は、問題を配信したいタイミングで「問題 N」ボタンを押すとその番号の問題が回答者に表示されます。 また、「回答 N」を押すとそれぞれの回答者の回答が正誤が表示されます。

押し間違いを防ぐために、一度押したものは色が変わるようになっています。 (何かトラブルがあったときのために、一応色が変わっても押せるようになっています。そのへんは運用でカバー)

すべての問題が終わると、「go ランキング」ボタンを押してランキング表示画面に飛ぶようになっています。

回答者:回答画面

alt

待機画面時に管理者が「問題 N」を押すと、その問題が表示されます。

問題は4択で、速度を競うために再回答はできなくなっています。(リロードしてもダメです)

また、画面下部には現在の回答者数がリアルタイムに表示されています。

回答者:正誤確認画面

alt

回答者が問題へ回答した後、管理者が「回答 N」を押すと正誤表示画面になります。

正解すると画面上のようにピカピカ光ります。 これは会場が薄暗い中でも誰が正解したかわかりやすくするためです。 失敗時はただ「×」が表示されます。

これらは、30秒ほど続いたあと元の待機画面に戻ります。

管理者:ランキング画面

alt

ランキング画面では、サーバーで集計された上位50位(上のgifでは開発モードのため20位)が順番に表示されます。

時間の関係で、はじめは200msほどでどんどん更新されて、10位〜6位は500msほどで更新していくようにしました。 また、5位からは司会者の好きなタイミングで切り替えたいとのことだったので、ボタンで制御できるようにしています。

さらにオマケ要素として、画面下部の「にゃ〜ん」ボタン(当日は社長の顔の「Action」ボタン)を一番押した人を表示しています。

技術的な話

アーキテクチャ

上述のようにReact / Redux / TypeScript構成で開発しました。 Reduxについては色々な図解がありますが、今回ではこの図が一番イメージに近いです。 (クイズアプリでは、ここでのActionCreatorをDispatchActionsと呼んでいます。)

alt

from http://www.slideshare.net/nikgraf/react-redux-introduction

また、DispatchActionsやReducer、Entityは画面毎に分割しています。 これは複数人開発時に、画面単位で作業を切るのがやりやすいと思ったためです。 (Webアプリの場合、常にネットにつながっていて正しいデータもロジックもサーバーにあることがほぼ前提なので、これで問題なく開発できます。  逆にネイティブなどでフロント側でもロジックを含む場合、分割単位をモデルにしたほうが良いかもしれません。)

フォルダ構成としては以下のようになっています。

├── console(管理者画面)
│   ├── 各画面コンポーネント
│   ├── Reducer.ts
│   ├── DispatchActions.ts
│   └── Entities.ts
├── noukai-app(回答者画面)
│   ├── 各画面コンポーネント
│   ├── Reducer.ts
│   ├── DispatchActions.ts
│   └── Entities.ts
└─ Routingとか
  • コンポーネント
    • 各画面のコンポーネントです。
    • まずは一枚で作ってみて、辛くなってきたら分割する方式でやっています。
  • Reducer
    • ロジックを含まないので、基本やることはないです。
  • DispatchActions
    • 主に非同期処理を扱います。
  • Entities
    • 特にロジックを含まないただの入れ物です。
    • サーバーから受け取るjsonを定義したりしています。

サーバー通信について

fetchAPI(isomorphic-fetch)を使ってやりとりしています。 通信が終わると取得したデータをReducerに送って状態を更新してもらいます。

redux-Middlewareは必要性を感じなかったので使ってません。

//DispatchActions.ts

  public loadQuestions(){
    const successCB = (response: IResponse):Promise<void> => {
      if(response.status === 200){
        return response.json<IQuestion[]>().then(json => this.dispatch({ type: ActionTypes.LOAD_QUESTIONS, questions: json}));
      }
      this.dispatch({ type: ActionTypes.HTTP_FAILURE, msg: 'quiz command is failed!'});
      return
    };

    return fetch(`/api/questions`, {method: 'GET'})
      .then(successCB)
      .catch(failCB)
  }

WebSocketについて

WebSocketについてですが、コネクションはreduxのstateとして管理すべきだろうと考えました。 処理はDispatchActionsに書いています。

//DispatchActions.ts

  //コネクション確立
  public requestWebSocketConnection(name: string, dept: string) {
    if (typeof(WebSocket) == 'undefined') {
      alert('WebSocketに対応してないブラウザです。新しめのブラウザ・スマホでお試しください。');
      return
    }

    const ws = new WebSocket("ws://" + location.host + "/quiz?name=" + name + "&dept=" + dept);

    ws.onopen = ((e: Event) => {
      console.log("connected");
    });
    
    // メッセージ受け取る
    ws.onmessage = ((e: MessageEvent) => {
      console.log("receive message: " + e.data);
      this.dispatch({ type: ActionTypes.RECEIVE_MESSAGE, serverMessage: e.data})
    });

    ws.onclose = ((e: CloseEvent) => {
      console.log("disconnected");
      alert("接続が切れました。リロードしてください。")
    });
    //reducerにコネクションを送る
    this.dispatch({ type: ActionTypes.WEBSOCKET_CONNECT_SUCCESS, ws: ws});
  }
  
  public doAction(ws: WebSocket) {
    const action = {action: true};
    // メッセージ送る
    ws.send(JSON.stringify(action));
    this.dispatch({ type: ActionTypes.SEND_MESSAGE})
  }  

フロントの開発について

サーバーサイドと分業しようということで、開発用サーバーを組んでいます。 WebSocketを使う必要があったためexpressで組んでいますが、 ちょっとした確認をしたいとき(400を返したいとかファイル操作したいとか)に融通が効くのでいい感じです。

//dev-server.es6

const path = require('path');
const express = require('express');
const app = express();
const url = require('url');

var server = require('http').createServer();
var WebSocketServer = require('ws').Server;
var wss = new WebSocketServer({ server: server, path: "/quiz" });
var port = 3000;

//questions
const questions = require('./questions.json');

//[{user: "user1", ws: ws1}, {user: "user2", ws: ws2}, ...]
var connections = [];

function broadcast(message) {
  console.log(message);
  connections.forEach((con) => {
    con.ws.send(message);
  });
}

//wssはwebsocket全体のオブジェクト、wsは各クライアントに割り当てられたsocket
wss.on('connection', (ws) => {
  var location = url.parse(ws.upgradeReq.url, true);
  const name = location.query.name;
  const dept = location.query.dept;
  var answeredNum = 0;

  const user = dept + "_" + name;

  console.log("user: " + user + ', joined');
  connections.push({user: user, ws: ws});

  ws.on('close', () => {
    connections = connections.filter(con => con.ws !== ws);
    console.log(user + " exited!");
  });

  ws.on('message', message => {
    console.log("user: " + user + ', message: ', message);
    if(JSON.parse(message).answer !== undefined){
      const numObj = {answeredNum: ++ answeredNum};
      broadcast(JSON.stringify(numObj))
    }
  });

  console.log(user + " joined!");
});

//静的リソース配置
app.use('/dist', express.static('dist'));
app.use('/public', express.static('public'));

// API系
app.post('/api/quizs/:num', (req, res) => {
  const quizNum = req.params.num;
  const obj = {quizNum: Number(quizNum)};
  broadcast(JSON.stringify(obj));
  res.status(200).json(obj);
});

app.get('/api/questions', (req, res) => {
  res.status(200).json(questions);
});

app.post('/api/answers/:num', (req, res) => {
  const answerNum = req.params.num;
  const obj = {answerNum: Number(answerNum)};
  const isCorrect = obj.answerNum % 2 === 0;
  broadcast(JSON.stringify({isCorrect: isCorrect}));
  res.status(200).json(obj);
});

//上位50位まで表示
app.get('/api/rankList', (req, res) => {
  const obj = [
   (...)
  ];
  res.status(200).json(obj);
});

app.get('/api/mostClicker', (req, res) => {
  const obj = {name: "開発 uryyyyyyy", num: 151};
  res.status(200).json(obj);
});

//react-routerでroutingするためにつけている。
app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'index.html')));

server.on('request', app);
server.listen(port, () => console.log('Listening on ' + server.address().port));

テストについて

クイズアプリではちゃんと書いてないんですが、いつでもユニットテスト書けてカバレッジを取れる状態にしておこうとしています。 テストはWebアプリなので実ブラウザで確認すべきと考えてKarmaでChrome上でテストする形を取りました。 (実際のプロダクトでは1ファイルだけのテストや、 istanbulでTypeScriptのカバレッジを取ってScalaコードとまとめてCoverallsに投げてカバレッジ計測したりしています。)

ファイル単位でのテストについては、テスト対象ファイルを引数に取ることでできました。

//karma.conf.js

//実行時引数から、テスト対象ファイルを選ぶ。
const args = process.argv;
args.splice(0, 4);

//ポリフィルなどグローバルに入れておきたいものを置いておく。
const polyfills = [
    './node_modules/jquery/dist/jquery.min.js'
];

var files = polyfills.concat(args);

module.exports = function(config) {
    config.set({
        basePath: '',
        frameworks: ['jasmine'],
        files: files,
        preprocessors: {
            '**/*.spec.ts': ['webpack'],
            '**/*.spec.tsx': ['webpack']
        },
        
        //ここでtsコードをバンドルしてChromeで動くようにしている。
        webpack: {
            resolve: {
                extensions: ['', '.ts', '.js', ".tsx"]
            },
            module: {
                loaders: [{
                    test: /\.tsx?$/,
                    loader: "ts-loader"
                }]
            }
        },
        reporters: ['mocha'],
        port: 9876,
        colors: true,
        logLevel: config.LOG_INFO,
        autoWatch: false,
        browsers: ['Chrome'],
        singleRun: true,
        concurrency: Infinity
    })
};

まとめ

フロントは僕が主導で他何人かたまに手伝ってもらっていたのですが、 React慣れてなくてもそれなりに書けるようでした。型のおかげかもしれませんね!

僕としても、わりと面倒な画面遷移をそこそこきれいに書けてテストを組み込めたので満足しています。

ちなみに、React / redux / TS / Karmaを用いてSPAを作るやり方についてはQiitaに記事をいくつか上げています。 良ければこちらもご覧ください(少しだけ古くなってるので、WebPack2が出たら更新していきたい気持ち)

qiita.com

オプトでは新しい技術を積極的に使っていく気概のあるエンジニアを募集しています。

次回は納会アプリ記事の後編として、akka-http + websocketの事例をご紹介できればと思います。