ニコニコ生放送のフロントエンドを改善するためにやってきたこと

この記事は ドワンゴ Advent Calendar 2021 の12/21の記事です。

さて、早いもので私がドワンゴに再入社してから2年もの月日が流れました。この2年間で様々な仕事を担当してきましたが、このエントリではニコニコ生放送のフロントエンドを改善するためにやってきたことの内、汎用性のある内容に焦点を絞って書いていきます。
2021年にわざわざ記事にするレベルの内容ではないものも含まれるかと思いますが改善の記録としてご笑覧いただければと思います。なお、各施策の時系列は整理していないため施策の前提に矛盾があるかもしれません

Origin-Agent-Cluster ヘッダーによるページのフレームレート向上

ニコニコ生放送のトップページには埋込み型プレーヤーを設置されています。このプレーヤーは hls を処理している影響もあって非常に負荷が高く、トップページ自体のパフォーマンスにも大きく影響していました。 このパフォーマンスを改善するには、プレーヤー自体が重たい問題と、トップページとプレーヤーとの間でリソースが共有されていることによって相互に悪影響を与えている問題の2つを解消する必要があります。 トップページとプレーヤーとの間でリソースが共有されている問題の解決策として Origin-Agent-Cluster ヘッダーを採用しました。 web.dev 詳しい説明は記事を見ていただくとして、簡単に説明すると Origin-Agent-Cluster ヘッダーを埋め込み元のレスポンスヘッダーに付与することで、 same-site cross-origin なページにおいてブラウザは埋め込み元と埋め込み先のページそれぞれで独立したリソースを確保することが可能になります。この機能は現時点では Chrome/Edge 88 以降でのみサポートされていますが、firefox も好反応を示しているため対応ブラウザは増加していく可能性が高いです。

ニコニコ生放送のトップページでは導入前のフレームレートが30fps程度だったものが60fps近くまで向上しました。ただし当然ながら Origin-Agent-Cluster ヘッダーを追加したところで処理の総量が減るわけではないので、低リソースな端末に与えた影響は軽微なものでした。 Origin-Agent-Cluster ヘッダーは一つのテクニックとして有用なものではありますが、やはり地道にアプリケーションの処理の軽量化を行っていく必要があるでしょう。

ブラウザの初期レンダリング時に ReactDOM.hydrate を使う

ニコニコ生放送のアプリケーションの開発開始時点では hydrate の仕組みは提供されておらず、renderToString で SSR されたものをブラウザで ReactDOM.render する実装になっていました。初回レンダリングを ReactDOM.hydrate に置き換えることで。 hydrate に対応するにあたって障壁になったのは、SSR で生成される html とブラウザの初回レンダリングで生成される html が同一である必要がある点です。ニコニコ生放送のアプリケーションにおいては TTFB を高速化するために renderToString の結果をキャッシュして使いまわし、CSR 時にユーザーのログイン状態などを反映するアーキテクチャになっていました。このアーキテクチャのままでは hydrate をした際に DOM 構造が意図しない形になってしまうため、 SSRレンダリング結果が異なる要素においては SSR 時点ではレンダリングを行わず、ブラウザの初回レンダリング終了後に state を更新して再描画を行うアーキテクチャに変更しました。なお、SSR 時点でレンダリングされる必要のないコンテンツについては IntersectionObserver 等を利用して、データの fetch およびレンダリングを遅延させる実装も順次進めています。 現代では Next.js のような便利な存在があるため、新規開発においてこういったアーキテクチャを自分の手で開発する必要はないでしょう。

content-visibility で遅延レンダリング

content-visibilitycss のプロパティの一つで、指定されたエリアがビューポートに入るまでレンダリングを遅延させることが出来ます。 content-visibility が登場する前にレンダリングを遅延させるには、IntersectionObserver などを用いて css の display を切り替えるか、 動的にコンテンツを追加するなどの作業が必要でした。そしてこういったテクニックを使う場合、ページをスクロールするなどしてコンテンツを表示しない限り、ブラウザの検索機能などを使った場合に検索に引っ掛からないといった問題が存在しています。 content-visibility で auto を指定した場合は html 上にコンテンツは存在するがブラウザ上に描画されていない扱いであるため、ブラウザの検索機能が正しく機能します。こういった機能的な利点も備えつつ、js で処理を書く必要がなくなるためファイルサイズを減らすこともできるメリットもあるため、非常に有用な css プロパティと言えそうです。デメリットとしてはchromium 系のブラウザでしか利用できない点 *1です。非対応ブラウザでは content-visibility プロパティは単に無視されるため、遅延レンダリングが行えないことを許容できる場合はそのまま利用することが出来ます。

tsconfig の設定変更でバンドルサイズの削減

ニコニコ生放送では内製のライブラリを複数組み合わせてアプリケーションを構築しています。それぞれのライブラリは TypeScript で作成されており、以下のような tsconfig でビルドを行っていました。

{
  "module": "commonjs",
  "target": "es5",
  // 以下省略
}

module が CommonJS になっていることでバンドル時に tree shaking が上手く効かない*2問題が発生することや、最終的にブラウザに読み込ませる成果物を es5 にするかどうかはアプリケーションのレイヤーで決定する方が望ましいといった観点から、ブラウザ向けのビルドでは以下の設定でビルドするように変更しました。

{
  "module": "esnext",
  "target": "esnext",
  // 以下省略
}

これによりアプリケーション側は tree shaking の恩恵を受ける事ができるようになり、かつ es5 以降の言語レベルで成果物を生成できるようになったことで大幅なバンドルサイズの削減に成功しました。 なお、 webpack5 で CommonJS の tree shaking がサポートされたので tree shaking を目的に CommonJS から ES Modules に乗り換える必要性は無くなっているのかもしれません。しかしながら2021年において CommonJS をあえて選択し続ける必要はないでしょう。

バンドラーが CommonJS 形式のファイル経由で ES Modules 形式のファイルを読み込むことに起因する tree shaking 不備

前述の「tsconfig の設定変更でバンドルサイズの削減」の施策にてライブラリを ES Modules 形式で出力するようにしましたが、サーバーサイド向けに CommonJS のビルドも同時に出力するようにしていました。*3 ここで問題になってくるのはライブラリのディレクトリを掘って import { Hoge } from "my-library/foo/bar" のように import を行う場合です。 import { Hoge } from "my-library" のように import を行っていれば、webpack のようなバンドラーは ES Modules 形式のものが提供されていればそちらを優先して使用してくれますが、ディレクトリを掘って import するということは、CommonJS 向けのファイルか ES Modules 向けのファイルかを強制することになってしまいます*4。 ライブラリの依存が A->B->C の順である時に、A が ディレクトリを掘って CommonJS 形式の B を import してしまった場合、バンドラーは A(ES Modules) -> B(CommonJS) -> C(ES Modules) の形でバンドルを生成します。この場合 CommonJS 経由で読み込まれた C は適切に tree shaking されませんでした。原因さえ分かってしまえば解決は簡単で、ディレクトリを掘った import を廃止してパッケージ直下から import を行えば OK でした。

critical css で初期描画を高速化

ブラウザは css ファイルがダウンロード完了するまではレンダリングをブロックする仕組みとなっており、css ファイルが巨大であればある程ページの初期描画が遅れてしまいます。critical css のテクニックは、「ファーストビューに表示される要素のみに限定した css(= critical css)」 と「それ以外の css」で css を分割し、ファーストビューに入らない要素は非同期で読み込むことで初期描画を高速化させることが出来ます。 critical css は手動で作成することも可能ですが、ファーストビューに入るコンテンツを変更するたびに更新が必要であるため保守が大変です。保守コストを軽減するには www.npmjs.com のような critical css 生成ライブラリを使用して自動生成する形を取るのが望ましいでしょう。 critical に html と css を与えると自動で critical css を生成してくれるので、基本的には生成されたファイルをブラウザに配信すれば OK です。しかし、障害時のお知らせヘッダーといった稀にファーストビューに入ってくるコンテンツがある場合は個別の調整が必要となるので注意が必要です。そういった要素がある場合は、その要素が存在しない html を使って critical css を生成した上で、forceInclude オプションを使って該当要素の css を追加する形を取ると良いでしょう。
また、前述の通りcritical で critical css を生成するには html が必要となります。静的に html を作成している場合はそのまま html ファイルを渡せば良いですが、ニコニコ生放送のように SSR で動的にコンテンツを作成するアプリケーションでは critical に渡す html がありません。つまりcritical に渡す html も生成する必要が生まれます。ニコニコ生放送では各ページを Storybook でカタログ化していたため、そのカタログを生成するための story ファイルに書かれた props を流用して ReactDOM.renderToStaticMarkup を使って作成しています。story ファイルを流用することで、Storybook のカタログを保守するだけで critical css の保守もできる一石二鳥な形を実現できています。

IE11 などのレガシーブラウザとモダンブラウザでバンドルを分ける

ニコニコ生放送では IE11 といったレガシーブラウザでも動作するように babel を用いて構文の変換や polyfill の挿入を行って動作を担保しています。当然 babel によって変換されたされた js ファイルは polyfill 等が入る分サイズが大きくなってしまう訳ですが、ニコニコ生放送ではモダンブラウザ上でも IE11 向けと同一の js ファイルを利用していました。モダンブラウザからすれば、ブラウザ上にネイティブで実装されている機能も polyfill されたコードが動作してしまう為処理速度に悪影響を及ぼす上、polyfill がある分ファイルサイズが増加してしまいます。 この問題を解決するために IE11 向けのバンドルとモダンブラウザ向けのバンドルを分けることにしました。*5
生成した js は以下のように script タグの属性を使って読み分けをさせています。

<script type="module" src="app.js" />
<script noModule src="app.legacy.js" defer />

type="module" がついた script は ES Modules に対応したブラウザで読み込まれますが、レガシーブラウザでは対応していない type になるため無視されます。反対に、noModule 属性がついた script は ES Modules に対応したブラウザでは読み込まれず、レガシーブラウザでは noModule 属性の意味を解釈できないため普通に読み込みが行われます。
この手法を用いれば ES Modules に対応したブラウザとそうでないブラウザで簡単に読み込ませるスクリプトを変えられるためお手軽です。反面、 分岐点がES Modules 対応の有無であるため、ES Modules に対応したブラウザの中から更に細分化して出し分けを行うことができないことになります。
例えば MS Edge15 といった ES2017 に対応していないブラウザもサポートしたい場合は結局 ES2015 相当まで babel で変換する必要があります。<script type="module"> に対応したブラウザの中で更に細分化した polyfill や構文変換を提供したい場合には別の手法も組み合わせる必要があるでしょう。ニコニコ生放送ではまだそういった実装はありませんが、js の構文レベルを es2015 に変換*6した上で polyfill を含めないバンドルを配信し、 www.npmjs.com のようなライブラリを用いてブラウザのバージョン別に polyfill 部分を出し分けることで実現できるのではないかと考えています。*7

js のチャンク分割

js を細かい粒度で分割することでパフォーマンスを向上させる(低下させない)ことはとても大切で、現代のアプリケーションにおいては必須のテクニックと言えますが、特に何も考えずにファイルを分割してしまうとチャンク分割の恩恵を最大限受けることが難しくなってしまいます。 ニコニコ生放送のアプリケーションでチャンクを分割する際は

  • 分割後のサイズは十分に小さいか
  • dynamic import で遅延読み込み可能であるか
  • ファイルの更新頻度

辺りを意識して分割していました。特に「ファイルの更新頻度」は重要な観点で、頻度が考慮されていないチャンク分割をしてしまった場合は高頻度で更新されるコードの更新に引きづられて chunk hash が変わってしまい、結果として全体のキャッシュ効率が落ちてしまいます。 一例としてニコニコ生放送スマートフォン版ページでは以下のようなチャンク構成としました。(一部抜粋)

  • store.js(redux の store,action 系をまとめたチャンク。redux 関係のコードに変更がなければチャンクは更新されない)
  • page-*.js(特定ページで使うコード(React コンポーネントなど)をまとめたチャンク。SPA であるためページごとに分割している。変更のないページのチャンクは更新されない)
  • styles.js(css modules で生成されたクラス名が入ったチャンク。css に変更がなければチャンクは更新されない)
  • vc.js(全ページ共通で使う React コンポーネントをまとめたチャンク。変更がなければチャンクは更新されない)

最後に

いかがでしたでしょうか?
さて、ここまでの改善内容を読んだ方の中には「Next.js でできることも多いのでは?移行すれば?」と思った方もいるかと思います。それについてはおっしゃるとおりで、移行も一つの選択肢として考えています。
しかしながら Next.js に移行することになったとしても移行には時間がかかることは明白です。それであれば今のアーキテクチャでもできることを並行して進めつつ移行を検討する道を選びたいと考えました。その上、改善作業をする際には Next.js のアーキテクチャや思想に合わせた実装を行うことでいわば移行コストを先払いしている状況を作り出せているので、後に移行することになったとしても今の改善作業が完全に無駄になることはないでしょう。
また、Next.js がいかに素晴らしいフレームワークであったとしても、アプリケーション自体の品質に問題があれば真価は発揮できないという事実から目を背けることはできません。Next.js に移行するか否かに関わらず改善を続けていくことが大切だと考えています。

*1:2021年12月時点

*2:この施策の実行時点ではwebpack4を利用していました

*3:ニコニコ生放送では Node.js 上でES Modules を使う準備を進めている途中であるため、サーバーサイドでは CommonJS 形式でのビルドを利用しています

*4:この施策を実施した時点では package.json の exports フィールドは存在していない

*5:なお2021年8月をもってニコニコ生放送での IE11 のサポートが終了したため、レガシーブラウザ向けのバンドルは廃止しました。

*6:構文は polyfill で対応できないため

*7:構文のみ ES2015 に変換しているのは、変換することで発生するバンドルサイズへの影響が軽微であることと、構文ごとにバンドルを分ける事によって発生するバンドル生成コストが無視できないレベルであるためです

WebHID を使ってブラウザ上で Joy-Con のジャイロの値を取得する

WebHID を使ってブラウザ上で Joy-Con のジャイロの値を取得する

はじめに

第二のドワンゴ Advent Calendar 2019の23日目の記事です。再入社エントリも公開したのでもしよかったら見てください。
Google Chrome 78 で WebHID API が experimental features として追加されたので試しに Joy-Con を接続してみる話です。
Joy-ConGamePad API を使用することでブラウザ上からボタンやスティックの情報を取得することができるのですが、残念ながらジャイロの取得には対応していません。
WebHID API を使用すれば Joy-Con のすべての機能にアクセスできるので、ブラウザ上でジャイロや IR センサーなどの情報を利用することができるはずです。

WebHID を有効にする

WebHID はまだ experimental なので、使用するためには chrome://flags/#enable-experimental-web-platform-features で有効にする必要があります。
これで navigator に hid プロパティが追加されるようになります。

WebHID を使用して Bluetooth 端末とブラウザを接続する

WebHID を使用して端末と接続するには navigator.hid.requestDevice を使用します。
ただし、この function はユーザーアクションなしに呼び出すことができないため、ボタンのクリックイベントなどを通して呼び出す必要があります。

document.querySelector("#start-button").addEventListener("click", async () => {
  const device = await navigator.hid.requestDevice({ filters: [] });
});

このコードを実行すると、以下のようなダイアログが現れます。
f:id:thiry:20191220192720p:plain
ここで選択したデバイスHIDDevice インターフェースとして device 変数に格納されます。
requestDevice に引数として渡している filters に条件を追加すると、該当するデバイスのみをダイアログに表示することができます。

document.querySelector("#start-button").addEventListener("click", async () => {
  const device = await navigator.hid.requestDevice({ 
    filters: [
      {
        vendorId: 0x057e, // Nintendo vendor ID
        productId: 0x2007 // joy-con R
      }
    ]
  });
});

これでデバイスの接続が完了しました。

Joy-Con から送られてきたデータをコンソールに出力する

いつもの addEventListener 経由で行います。

device.addEventListener("inputreport", (event) => {
  console.log(event.data);
});

Joy-Con にコマンドを送信する

EventListener の設定は終わりましたが、Joy-Con にコマンドを送ってデータを要求しないと何も console にアウトプットされません。
コマンドの送信は HIDDevice#sendReport 経由で行います。インターフェースは以下の通り

interface HIDDevice : EventTarget {
    Promise<void> sendReport(octet reportId, BufferSource data);
};

コマンドを送る方法が分かったので、https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering にまとまっている Joy-Con の制御データをリバースエンジニアリングした情報を使用して実際にコマンドを送信してみます。 Bluetooth HID Informationを読んでいくと OUTPUT 0x01 で指定したサブコマンドを実行することができることが分かります。
サブコマンドには色々なコマンドがありますが、Subcommand 0x03: Set input report modeを使用すると Joy-Con のボタンやスティックの入力データが Joy-Con から送信されるようになるようです。
コマンドとサブコマンドが分かったところで、OUTPUT 0x01 にある C 言語で書かれたサンプルや、Chromium の GamePad API における Joy-Con サポートの実装をもとに WebHID API 経由 で HID に送るデータを作っていきます。

let globalPacketNumber = 0x00; // 0x0 ~ 0xf でループするパケットナンバー.
const createQuery = (subCommand, subCommandArguments) => {
    const query = new Array(48).fill(0x00);
    query[0] = globalPacketNumber % 0x10; // 0x0 ~ 0xf になるように % 0x10 する
    query[1] = 0x00;
    query[2] = 0x01;
    query[5] = 0x00;
    query[6] = 0x01;
    query[9] = subCommand;
    query[10] = subCommandArguments;

    globalPacketNumber++;

    return Uint8Array.from(query);
};

await device.sendReport(0x01, createQuery(0x03, 0x3f));// 第2引数はサブコマンドの args. 0x3f = ボタンやスティックを操作するとボタンの各種情報を送信するようになる

これで Joy-Con のボタンを押すと console に Joy-Con の情報が表示されるようになったはずです。

Joy-Con からジャイロの情報を取得する

Joy-Conジャイロセンサーを有効にしないとデータを送ってこないので設定を有効にします
例によってドキュメントを読んでいくとSubcommand 0x40: Enable IMU (6-Axis sensor)が該当するコマンドであることが分かります。

await device.sendReport(0x01, createQuery(0x40, 0x01)); // 0x01 = enable, 0x00 = disable

これでジャイロが有効になりました。
さて、次に Input Report Mode を変更してジャイロの値をリアルタイムで取得できるようにします。

await device.sendReport(0x01, createQuery(0x03, 0x30)); // 0x30 = 60Hz Standard full mode.

注意点として、ジャイロの有効化を行ってから実際にジャイロセンサーの値が Input Report Mode に含まれるまでに若干のラグがあります。
Input Report Mode を有効にする前に 0.5 秒程度待つと良いでしょう。

const delay = (delayMs) => new Promise(resolve => setTimeout(resolve, delayMs));
// ジャイロセンサー有効化
await device.sendReport(0x01, createQuery(0x40, 0x01));
// 0.5秒待つ
await delay(500);
// Input Report Mode を 60Hz Standard full mode に変更
await device.sendReport(0x01, createQuery(0x03, 0x30));

まとめ

これでブラウザ上から Joy-Conジャイロセンサーの値を取得できるようになりました。ジャイロの値を使ったアプリケーションを作るところまでたどり着きたかったのですが時間切れでした。
ここまでの実装は https://github.com/Thiry1/joycon-webhid にアップしておくので、興味を持った方は覗いてみてください。

一年ぶりにドワンゴに再入社した

一年(厳密に言うと11ヶ月)ぶりにドワンゴに再入社しました。

 

なぜドワンゴを辞めたのか

新卒としてドワンゴに入社して3年目の夏、友人の務めているITベンチャーが異業種大手の会社に買収され、IT関連事業の新規創設を行うとの事で、そこへの参画を依頼されました。この時ドワンゴで比較的大きな仕事の区切りがついたところで、次の会社へ行くには良いタイミングと考え退職を決めました。

なぜ転職先を辞めたのか

多くは語りませんが、転職先企業の社風や、合理性よりも感情が優先される風潮に馴染めなかったことが主な理由です。

通常業務をこなしつつ業務の自動化や効率化を行っていたことがサボり扱いされる理不尽がまかり通るような会社が世の中にどれくらいあるのかは分かりませんが、少なくとも転職先の会社はそうでした。(一番悲しかったのは、僕を誘った友人からも同じ扱いを受けたことです。出世欲って怖いですね。)

転職して3ヶ月も経つ頃には鬱といって過言ではない状態になっていたと思います。会社を退職して数ヶ月経った今ならば、当時を振り返ればすぐに自身のメンタルがヤバい状態になっていることに気付けるものですが、渦中にいるとなかなか気づけないものなんだなと改めて思いました。(事実在職中は味覚を失うまで自覚できなかった)

個人的な反省点も色々ありますが、おおまかには

  • 非IT企業への転職を甘く見ていた
  • 他人の仕事に対する期待値が高く、その期待の水準に届かないと勝手に裏切られたような気持ちになって精神を消耗していた
  • 自分が成長できない、停滞している感覚がこれ程ストレスに感じるとは思っていなかった
  • 社会を知らなすぎた

あたりでしょうか。とにかく色々と学びを得た11ヶ月でした。

ドワンゴに戻った理由

転職活動を始めてすぐ知人に紹介していただいた数社から内定を頂けましたが、一度転職で失敗しているのもあって慎重になり、回答を保留させて頂いていました。

どの会社を選択するか悩んでいる時に、かつてドワンゴで所属していたチームから飲み会に誘われ、そこで転職を考えている話をしたところ、「戻ってこない?」と言われ面接を受けることにしました。トントン拍子で内定をいただけたので、改めてドワンゴに戻るべきか考えました。色々と悩みましたが、

  • 内情を知っている会社のほうがメンタルへの負担が少ないこと
  • 在職時に完遂できなかった仕事を自分の手で進められること
  • 会社の働きやすさ
  • 技術スタックの幅広さ
  • 自分が成長できる環境であること
  • 「戻ってこない?」と言ってもらえて嬉しかったこと

などからドワンゴに戻る事を決意しました。一番会社が大変な時期に離職していた後ろめたさがある状態での入社でしたが、皆さんに暖かく迎えていただけて嬉しかったです。

前職(ややこしいですが、ドワンゴのあとに入った会社)との相性があまりに良くなかったというのもあって補正がかかっている部分もありそうですが、再入社をして改めてドワンゴの仕事や環境はとても恵まれているのだなと思いました。今ならば、中途で入社した人ほど「ドワンゴは良い会社だよ」と言う気持ちが良く分かりますね。

例を上げると

  • 上下関係にとらわれず意見を言える
  • チームで問題を解決していこうという前向きな姿勢がある
  • 技術的負債に向き合う工数を確保できる
  • 技術選定の自由さ
  • コードレビューのクオリティが高い
  • 強い人達がたくさんいる(昨今の退職ラッシュで外部の人達からは強い人がいなくなったと思われていそうですが、少なくとも自分より強い人達がたくさんいます!)
  • 出退勤時間の柔軟さ

などです。「いやいや、こんなの他の会社にも当然あるよ!」と思う人たちも多いと思いますが、そうではない会社もあるのです。*1(余談ですが、この辺の環境は所属チームによって異なったりもするので転職活動時に知るのって難しいですよね)

特に出退勤時間の柔軟さは他社を経験していると異常に感じるほどです。きっとドワンゴから転職していった人たちの多くが出勤時間の縛りに苦労しているのではないでしょうか。

この環境や働きやすさを実現するためにマネージャーのレイヤーの方々が苦労をされているんだなと思うと同時に、それを享受するだけでなく僕自身も貢献していかなければならないなと思っています。

今何をしているか

以前ドワンゴに在籍していた時と同じく、ニコニコ生放送のフロントエンド+BFF領域を担当しています。

直近ではカテゴリ別番組一覧ページのフロントエンド分離や、既存コードのリファクタリングなどを行なっています。(退職前に自分で作った負債が自分に跳ね返ってきたのはちょっと面白かったです)

歴史あるモノリシックなシステムをモダンにしていくのは大変ですが、とてもやりがいのある仕事です。

また、多数の優秀な技術者から知識を吸収できる日々はとてもワクワクして毎日が楽しいです。

最後に

まだまだ厳しい状況に置かれているドワンゴですが、一エンジニアとしてできる限りサービスの成長・改善に貢献していけるよう頑張っていきたいと思います。

PS

例のリストです。

https://www.amazon.jp/hz/wishlist/ls/1QDZXBHVIOMXT?ref_=wl_share

*1:サンプル数1