All TypeScript で開発したLINEで動くリアルタイムクイズアプリの裏側

LINE株式会社フロントエンド開発センター(通称: UIT)の折原です。

先日、6月17日に開催した UIT meetup vol.9 で、本編の前にウォームアップとして、フロントエンドに関するクイズ企画を開催しました。そこで使うことを目的として、UIT App という名前で LIFF のアプリを作成しました。

UIT App は LIFF で動作するクイズアプリです。現状クイズだけですが、今後はいろんな取り組みをこのアプリ上で動作させることができるようになっていく予定です。

この UIT App を実装するにあたって、フロントエンドでは StencilJS を、サーバーサイドでは NestJS を採用しました。これらを使ってみての所感や、こだわった箇所などを紹介したいと思います!

フロントエンドの技術スタックと運用

Stencil

Stencil は Web Component・Web アプリを生成するためのコンパイラです。開発をしている Ionic チームではフレームワークではないと強く主張していますが、React や Vue のようなフレームワークと捉えて Stencil だけで Web アプリを構築することも可能です。Stencil を採用した理由はこのフレームワークとして用いれる点ともう一つ、Web Component を生成できる点が挙げられます。

シンプルな例を見てみましょう。これは article-item という名前でコンポーネントを作成した例です。

 import { Component, Prop, h } from '@stencil/core';
 
@Component({
  tag: 'article-item',
})
export class ArticleItem {
  @Prop() title: string;
  @Prop() body: string;
 
  render() {
    return (
      <h1>{ this.title }</h1>
      <p>{ this.body }</p>
    );
  }
} 
 <div class="conainter">
    <article-item title="記事のタイトル" body="こちらが記事本文です"></article-item>
</div> 

デコレーターと Class で書き、React とだいぶ似ています。コンポーネントは Web Component として書き出され、定義されるため、使用するところで import する必要ありません。

使用できる tag 名はケバブケース(ハイフン繋ぎ)でなければいけないなど細かいルールはありますが、非常にシンプルに実装をすすめることができます。もちろん Web Component 互換のあるライフサイクルも存在しています。

Ionic チームは頑なに Stencil をコンパイラと表現しています。その表現の通り、Stencil で実装されたコンポーネントは Web Component として Build、書き出しができます。再利用しやすいように実装しておくとあとあと公開することも簡単です。

<glitch-image />

UIT App の中でグリッチエフェクトを簡単にかけれるコンポーネントを作成しました。src に URL を入れて、時間設定などの属性値を入れるとエフェクトが掛けれます。このコンポーネントは Web Component として公開できるよう準備を進めています!

ユーザー体験とデザインチューニング

UIT App はシンプルなクイズアプリにするというよりは、インタラクション・アニメーションを多めに盛り込むことにしました。ウォームアップを目的とした “賑やかし” をメインにしたかったので、不要と思われるだろうところまで作り込みました。

インタラクション・アニメーションを盛り込むことにはいくつかのトレードオフが発生しますよね、たとえば

  • 高速なコンポーネント切り替え ←→ アニメーションの終了を待つ必要
  • 初回レンダリング時間 ←→ 高品質な画像・動画の読み込み時間

なのでユーザーがすぐに情報が欲しい場合とそうでない場合に切り分けて、時間を浪費するようにメリハリをつけるようにしました。

バックグラウンド動画

UIT App の背景にはこんな動画が常に流れていました。

この動画は 30 秒で 1 ループになっています。Web アプリに 30 秒の動画、しかもただの飾りであれば普通は嫌がると思います、容量も大きいでしょうから。ですが、この動画は 50.5kb まで圧縮されています。

  • 動画サイズを 135px x 240px の小ささにしている
  • 黒ベースで作られている
  • webp で圧縮されている
  • 可能な限り低ビットレートでエンコードしている

こうすると画質の悪い小さな動画が出来上がりますが、これをそのまま使うことはしたくないのでもうひと工夫しました。

ドットは SVG の画像を background-repeat で繰り返された <div> が重なって表示されています。動画の上レイヤーに細かいドットを敷き詰めることで、画質の悪い小さい動画を全画面にまで引き伸ばしても粗さを目立なくしています。結果的に動画容量を減らし、コンポーネントの描画スピードを格段に引き上げることができました。

正解・不正解のアニメーション

UIT App 内で一番長い時間使ったアニメーションが正解・不正解表示になります。

“ゲーム” のようなユーザー体験で面白さを出すために、ユーザーにとっての目的を予め明確にした上で、その目的まで遠回りだったり紆余曲折させる手法が用いられることがあります。”攻撃したらすぐ敵が倒せる” のではなくて、これは緩急やクライマックスをつけるためですね。

クイズの答えがでてくるとき、ページをめくるとシンプルに答えが出てるという体験は避けたい思いがありました。「あ、答えがくるな」と目に見えてわかる瞬間で、丸が画面中央へ縮小していき、そのアニメーション終了後に正解・不正解がアイコンとして出現する表現をしています。

クイズアプリのリアルタイム性も必要となるため、刹那の時間ではありますが緩急を入れています。微々たる差かもしれませんが、緩急があるとないで全体的なアプリの世界観が変わって見えることもあります。

背景の動画の色合いが変わる部分は backdrop-filter で行っています。

このようにフロントエンドといえど、デザインもチューニングできるポイントを2点紹介させていただきました。使いまわせそうなコンポーネントは Web Component として公開する予定なので、使ってみたい/ソースコードを見たい方がいましたら楽しみに待っていてください!

サーバーサイドの技術スタックと運用

サーバーサイドの様子については、フロントエンド開発センター花谷(@potato4d)から紹介します。サーバーサイドでは、今回 NestJS を利用した JSON API と WebSocket エンドポイントを実装しました。

NestJS について

NestJS は、JavaScript の世界では珍しい、TypeScriptをベースとした高機能なサーバーサイドのフレームワークです。DI の考えを軸としており、優れたアプリケーション設計を実現しやすい観点や、多くのオフィシャルのエコシステムのサポートによって、スピード感を持った開発にも利用できる技術です。

技術特性としては Angular を強く意識しており、オフィシャルにも以下のように記述されています。

Nest provides an out-of-the-box application architecture which allows developers and teams to create highly testable, scalable, loosely coupled, and easily maintainable applications. The architecture is heavily inspired by Angular.

https://docs.nestjs.com/#philosophy

NestJS の選定理由

一般的に Node.js でサーバーサイドの API を構築する際は Express を利用することが一般的です。しかし今回は、私自身が手慣れているからということも勿論ありますが、特に以下の理由から NestJS を選定しました。

TypeScript との親和性

今回のクイズアプリケーションは、UIT では珍しくクライアント・サーバーを monorepo として管理、共通箇所を common パッケージとして管理する戦略をとりました。そのため、必然的に型定義を共有した開発フローで進みます。このような実装方針において、TypeScript によるインターフェース定義と相性の良い O/R マッパーである TypeORM。そして TypeORM を円滑に利用しやすい NestJS を採用することは自然な流れでした。

JSON API と WebSocket の共存

また、今回ようなのクイズアプリケーションでは、クイズ情報などのメタデータや、RDB に保存されたユーザーデータを管理するための JSON API と、クイズ当日の結果をリアルタイムで反映するための WebSocket の API の二つを用意する必要がありました。Express と Socket.io のような構成では、このような仕組みを導入する場合、お互いのコミュニケーションの実装を綺麗に書くコストが高くなりがちですが、NestJS の強力な DI と Module システムによって、気軽にクリーンに書くことができます。

その点への期待もあり、これまで NestJS は JSON API 目的の実装の経験のみで、 WebSocket 機能は利用したことがなかったのですが、試しに利用することとしました。 

エコシステムとの活用

最後に上げながらも非常に大きい選定理由として、豊富なオフィシャルのエコシステムが挙げられます。後述しますが、今回の場合は実装に合わせて自動で更新される Swagger プラグインを活用したいモチベーションで採用しました。

NestJS での実装のポイント

今回、サーバーサイドの実装では WebSocket にかかる負担を最低限にすることと、できるだけメンバーの実装をスムーズにすることを目標として実装を進めました。UIT のメンバーは多くのプロジェクトを兼任するケースが多く、UIT App もその中の一つと位置づけられるので、可能な限り直接的な開発以外の考慮事項を減らすことが狙いです。

実際に、上記を実現するために、以下の2点を工夫しました。

Module による JSON API Endpoint と WebSocket 間の相互アクセス

前述の通り、アプリケーション全体としてではなく、サーバーサイド観点での NestJS の採用理由は JSON API のエンドポイントと WebSocket の機能をクリーンに相互で取り回したいというモチベーションからきたものとなります。

今回、アプリケーション上での支配的な実装は JSON API となり、その中でユーザーからのリクエストを契機として WebSocket を叩く形となります。そのため、今回は NestJS の WebSockets モジュールをベースに、 WebSocket 層をそのままモジュール化。JSON API のエンドポイントからは、DI を介してインスタンスを受け取ってイベントを伝播させることとしました。

これによりそれぞれのレイヤーが明確に切り分けられて見通しの良い実装が可能となるほか、JSON API を単体で検証したいタイミングで WebSocket 層のモックを差し込みやすくなるなど、NestJS の持つ機能を十分に享受することが可能でした。

これに限らず今回はカスタムデコレータベースをベースとしたユーザー情報取得など、デコレータや Module を活用することで、よりテストしやすいコードベースを実現できました。

https://github.com/nestjs/nest/tree/master/packages/websockets

https://docs.nestjs.com/modules

ただ一方で課題として、 LINEのプロバイダに依存する認証がいくつかあり、厳密なテストができていない部分が存在します。継続的にメンテナンスしていくアプリケーションではあるため、この点についてはもう少し改善の余地が存在しています。

型定義ファーストでのデータ構造の定義と Swagger による実装ファーストなドキュメンテーション

次の工夫は、データとレスポンス構造の定義フローです。

Firebase などのクライアントとデータストアが直接繋がっているツールを使う場合を除き、小規模アプリケーションの開発は凡そデータ構造の定義とコンポーネント間のコミュニケーションのスキーマ定義にかかるコストが支配的なものとなります。また、開発途中で構造が変わることもあるという前提のもと、追従しやすい仕組みが必要です。

そこで今回は、はじめにそれぞれのリソースを monorepo の `common` パッケージ上に TypeScript Interface として定義。クライアントはその型定義を直接利用し、サーバーはその Interface を implements する形の TypeORM Entity を定義して実装を進めることとしました。
これによってある程度事前にデータ構造が決まっていることにより、お互いパラレルに開発をすすめることができるほか、変更があった場合も、型定義が変わっていることによって、コンパイルエラーなどで検知することができます。

このあたりは NestJS というよりは、データストアとの接続のために使っている TypeORM のパワーを存分に活かす構成となったかと思います。

とはいえ Web API はこれだけでは実現できません。API を構築するには、それぞれのエンドポイントの必要な payload と response の情報が必要です。

JSON API の場合、こういった API ドキュメントでは Open API(Swagger) の swagger.yml を編集することがよくありますが、今回は code-gen を行うわけでもないほか、 Swagger ファーストというよりも、アプリケーションファーストにしたほうが齟齬が少なくなると判断し、NestJS の Swagger モジュールを導入してドキュメントはこちらに一任しました。

NestJS の Swagger モジュールは非常に優秀であり、デコレータによるメタ情報を付加することはあれど、基本的には Controller でのルーティングおよびレスポンスの定義を元に自動的に Swagger のドキュメントを出力してくれます。

イベント向けのアプリケーションという性質上、特定の用途のための API を生やすことが増えることは簡単に想定できます。今回はそういった個別のルーティングの定義とドキュメンテーションを並列で行うことより、制定のコストを最小限に抑えました。

https://github.com/nestjs/swagger

現実的に Swagger だけで賄えなえずにコミュニケーションが発生する部分は勿論存在しますが、最大公約数をスピーディーに実現するためには Swagger モジュールは強くおすすめできる選択肢です。

サーバーサイドでは、LINE ではほとんど事例のない NestJS を採用した形となりましたが、今回のユースケースにもマッチしてくれたかなと思います。

おわりに

今回は LIFF で動作するクイズアプリ実装の裏側について紹介させていただきました。実際に参加いただいた方に「いわゆるクイズアプリだね」と思っていただけたようであれば、設計的にも UX 的にも満足です。

最後に宣伝になりますが、LINEではフロントエンドエンジニアを募集しております。ご興味のある方は、以下よりご応募ください。

求人情報: フロントエンドエンジニア / フロントエンド開発センター

また、カジュアル面談も受け付けています。LINEの開発についてお聞きになりたいことがありましたら、以下のリンク先からお気軽にお申し込みください(応募締め切り:7/10)

https://line.connpass.com/event/179229/

Related Post