20200916のReactに関する記事は5件です。

マルチステージによるDockerイメージの軽量化-Reactアプリ編

はじめに

はじめまして、最近Docker、Kubernetes辺りをいじるのにハマっております。
今回はDockerイメージの軽量化に挑戦してみました!

今回は create-react-app コマンドを用いてデフォルトで作成したものをコンテナ化しております。

  • 事前準備
    • create-react-appでアプリ作成
// アプリ作成
$ create-react-app sample-react-app
$ cd sample-react-app

問題のDockerfile

Dockerfileを準備します。流れとしては
- production用にbuild
- 静的ファイルを実行するモジュールをインストール、実行

という感じです。

Dockerfile
FROM node:12
COPY . /react-app
WORKDIR /react-app
RUN npm install && npm run build && npm install -g serve
CMD ["serve","-s","build" ]

これでデフォルトだと5000番でListenするので以下のようにポートフォワードして実行すれば localhost:5000 でアプリが動くことが確認できると思います。

$ docker run -p 5000:5000 sample-react-app:0.0.1

昨日までの私はおー!Reactアプリコンテナ化できたじゃんと満足していました。
けど思いました。

最終的に静的ファイル実行するだけなんだからnodeの環境要らなくない?

ちなみにここでDockerイメージのサイズを見てみると...

$ docker images
REPOSITORY                                                   TAG                 IMAGE ID            CREATED             SIZE
sample-react-app                                             0.0.1               6e7bc9096dd7        9 minutes ago       1.31GB

...でかいっす。

ただ、gitにproductionのファイル群をあげたりはそんなにしないかなと思うので、CIツールとか使ってgitのリポジトリからビルドしていくならnpm install, npm run buildはDockerでビルドしていく段階で欲しいと思うのです。

そこでマルチステージ!これがあるじゃん。

ということでマルチステージを使ってproductionのファイル群をnginxの方に持っていく、というのをやってみました。

改良Dockerfile

改良Dokerfile
#第一段階(productionファイル生成まで)
FROM node:12 as node
COPY . /react-app
WORKDIR /react-app
RUN npm install && npm run build

#第二段階(一段階目のコンテナの中身から静的ファイル群だけをコピーする)
FROM nginx:1.19.2-alpine
COPY --from=node ./react-app/build /usr/share/nginx/html
CMD nginx -g "daemon off;"

ドキドキの起動です。。。タグを0.0.2にしてビルド、実行してみます

$ docker build -t sample-react-app:0.0.2 .
$ docker run -p 5000:80 sample-react-app:0.0.2

無事起動できました!
image.png

そしてサイズは...

$ docker images
REPOSITORY                                                   TAG                 IMAGE ID            CREATED             SIZE
sample-react-app                                             0.0.2               9148ac06ddbe        3 minutes ago       22.5MB
かなりスマートになりました!サイズだけなら1/60になりました!

おわりに

個人的にここまで劇的に変えられたと結構な達成感を持てました!
今後はコンテナ化だけで満足せず、どのように作っていくかもじっくり考えてみたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Pythonしか触ったことのなかった大学生がReact(Gatsby.js)でWeb開発した話

作ったLP(ランディングページ)

SynQ Remote LP https://www.synq-platform.com/

大学1年の10月から福岡市内のITベンチャーでインターンをしており、そこで自社プロダクトのLP(ランディングページ)開発プロジェクトを任せてもらっています。
今月でインターン開始1年になるので、記念に(?)LP開発までの流れを書いていきたいと思います。

LPを作るまでの自分のスキルについて

インターン開始前は授業でPythonを触ったのみ

インターンを始める前、大学1年前期の講義で「プログラミング演習」という必修科目を取っていました。これは関数を定義するくらいまでのPythonの基礎を学ぶ科目で、昔からパソコンをいじるのが好きだった自分はとても面白い講義だなと思い、これをきっかけにプログラミングを学びつつバイト代わりにもなるインターンを探すことを決意します。

インターン先を見つけ、プロジェクトに配属される

2ヶ月ほど期間を要しましたが、ようやくWantedlyで自分に合ったインターン先を見つけることができました。早速、鉄板加工工場のオートメーション化プロジェクトに配属され、主にPythonベースのDjango(バックエンド)の開発を任せてもらいます。初めの方はわからないことだらけでしたが、3, 4ヶ月経つと一定の成果をあげることができるようになっていきました。

Python以外に手を出してみたくなる

すると、そろそろPython以外の言語にも手を出してみたくなりました。インターン先CTOに相談したところ、ReactでQiitaトップページを模写するという課題をいただきました。2月に合宿免許に行くことになりインターンできない状況にあったので、その期間をReact勉強期間に充てたのち、Qiita模写を1週間くらいで開発しました。(https://github.com/horri1520/qiita-toppage/)
スクリーンショット 2020-09-16 13.23.12.png

LP開発プロジェクトを任せてもらうことに

Qiita模写を通してフロントエンド楽しい!!!もっといろいろ作りたい!!!となっていた自分を見て、前述のCTOがLP開発プロジェクトを任せてくれることになりました。当時僕はフロントエンドの技術スタックについて全然詳しくなかったので、CTOがGatsbyなるものを提案してくれました。ここから、LP開発についての詳細を書いていきたいと思います。

技術スタックについて

フレームワークはGatsby.js

Reactベースの静的サイトジェネレータ、「Gatsby.js」を採用しています。僕のアイデアではないですが(笑
しかし開発を通して、Gatsbyを使ったことによる恩恵を十分に享受することができました。Gatsby製のサイトはなんと言ってもパフォーマンスに長けています。爆速です。
爆速になるカギは静的サイトホスティングサービス「Netlify」と、サイト内の画像を最適化してくれるGatsby専用プラグインgatsby-imageにあると思っています。

Netlifyとgatsby-image

Netlifyはサーバーレスで静的サイトをビルド・ホスティングできるサービスです。主な特徴は、GitHubへのpushをトリガーにサイトのビルド・デプロイが走ることと、その際行われるサーバーサイドレンダリングです。(後者は諸刃の剣な側面があって、、、詳しくは後述します。)
gatsby-imagelazy-loadやサイズ最適化など、画像を軽量化して扱うための機能がひとまとめになっているプラグインです。詳しいことは割愛しますが、GraphQLのクエリを叩けば軽量化された画像ファイルを生成してくれます。
Netlifyとgatsby-imageを組み合わせるとどういうことが起こるのかについて説明します。まず、GitHub上のコードから静的サイトのビルドが走ります。サイト上の各要素はこの段階で描画(レンダリング)され、1枚のページビューにまとめられます。その際サイト上の画像はぼやけた、モザイクに近いようなものとしていったん生成されます。
そしてそのページにアクセスすると、最初はモザイクに近い画像が表示されますが、ページを読み進めていくにつれ、高解像度の画像の読み込みが走り(lazy-load)順次置き換えられていきます。一連の流れを視覚化すると、このような挙動になります。
1.gif

レンダリングがすでに行われているというだけでも十分高速なのですが、さらに重たい原因になる画像まで軽量化してくれるところがGatsbyならではの強みです。

技術スタックまとめ

Gatsby.jsで書いたコード
 ↓push
GitHub
 ↓pushをトリガーにビルド&デプロイ
Netlify

実装してみて

とある日、いつものようにビルドを走らせると…

image.png

ページがぐっちゃぐちゃになっていた。。。

image.png

めっちゃくちゃ萎えました。このスクショ以外にも様々な見た目で崩壊(画像など各要素は表示されるけど位置がはちゃめちゃとか)していましたが、萎えたのでこのスクショしか撮れていないほどです。やはり自分が開発したサイトがバリエーション豊かに崩壊しているのを見るのはへこみますね。しかし、そうも言ってられないので崩壊原因の調査を始めることにしました。

原因調査

いろいろ調べているうちに、同じ現象に悩まされている方々が執筆したいくつかのブログ記事やフォーラムにたどり着きました。おそらく、導入していたMaterial UIのmakeStylesとレスポンシブ対応プラグインのreact-responsiveがサーバーサイドレンダリングに対応していないことが全ての原因であるとのことです。しかし、どのページにも有効な解決策は示されておらず、GitHubのissueに至っては解決していないのに閉じられていました。絶望です。

解決策を求めて

いったん状況を整理するために、コードを書き換えてみたりコメントアウトしてみたりしました。その結果、

  • react-responsiveは完全にアウト。発生条件は定かではないが、動作しない箇所が出てきてしまう。
  • makeStylesはindexページのみの適用だと動作するが、他のページに適用するとそのページのCSSが崩壊してしまう。404ページに至っては、404ページ自体もindexページも共に崩壊してしまった。
  • これらの現象はlocalhostで立てた開発用サーバーでは発生せず、ビルドしてみて初めて異変に気づくのでそもそも気づくのも遅れる上デバッグもしづらい。

という結果が得られました。
もうGatsbyやめようかな…という考えも一瞬頭をよぎりましたが、上述したGatsbyのメリットが大きすぎるため、Gatsbyでも正常に動作するプラグインを探し、それに置き換えることにしました。

公式ドキュメントを読むことの重要性

ユーザーで困っている人々がいるのなら、公式はそれに対しどのような見解を持っているのだろうかと気になり、ここでいったんGatsby公式のドキュメントに立ち返ってみることにしました。すると、GatsbyでのCSSスタイリングをどうするのかについてのページにたどり着き、(https://www.gatsbyjs.com/tutorial/part-two/) styled-componentsが推奨されているということを知りました。(逆にmakeStylesが非推奨とはどこにも書かれていませんでしたが!笑)

このことで、

  • 公式ドキュメントをきっちり読むこと
  • サーバーサイドレンダリング下において、特に画面描画周りのプラグインとは相性があるので事前に対応状況を確認するべきであるということ

この2点の重要性を再認識できました。また、レスポンシブ対応プラグインについては、同様にGatsby公式プラグインライブラリ(https://www.gatsbyjs.com/plugins/) からgatsby-plugin-breakpointsという対応プラグインを見つけ出すことができました。

一連の流れについて振り返ると、発見した時は慌てふためきましたが、今となっては
得られた知見だけでなく問題について調査し、解決に向けて試行錯誤していくプロセスにもとても意義があったと思っています。今後新たな技術に触れる際、何かトラブルが起きてももう怖くなさそうです。

続いて、gatsby-plugin-breakpointsが便利だったので詳しく紹介していきます。

gatsby-plugin-breakpointsの使い方

詳しくはドキュメント(https://www.gatsbyjs.com/plugins/gatsby-plugin-breakpoints/) を参照していただきたいのですが、gatsby-config.js内にbreakPointsを定義すれば各コンポーネントで手軽に使えます。

僕は下のようにbreakPointsを定義しました。

const breakPoints = {
  smartphone: "(max-width: 420px)",
  mobileWide: "(min-width: 421px)",
  wxga: "(min-width: 1280px)",
  mobile: "(max-width: 1023px)",
  pc: "(min-width: 1024px)",
  portrait: '(orientation: portrait)',
}

そして、使用したいファイル上で

import { useBreakpoint } from "gatsby-plugin-breakpoints"

const Hoge = () => {
    const breakPoints = useBreakpoint()

    return (
        <>
            {breakPoints.pc
                ?
                    // PC用のコンポーネント
                :
                    null
            }
            {breakPoints.mobile
                ?
                    // モバイル用のコンポーネント
                :
                    null
            }
        </>
    )
}

のように、閲覧しているデバイスの画面サイズに応じてreturnするコンポーネントを切り替えることができます。
breakPointsの中には、

{ pc: true, mobile: false, wxga: true, ... }

このようにconfigで定義したbreakPointsを満たしているかどうかがboolean値で格納されているので、画面サイズに応じた条件処理を自由度高く書くことができました。

1.gif

その結果、CSSのメディアクエリと遜色ないレスポンシブ対応ページをJavaScriptだけで実装することができました。JavaScriptだけでの実装にこだわってよかったなと思っています。

Gatsby.jsでのWeb開発まとめ

  • CSS in JSプラグインはstyled-componentsgatsby-plugin-styled-componentsとセットで使う
  • レスポンシブ対応プラグインは専用のgatsby-plugin-breakpointsを使う
  • Gatsbyのサーバーサイドレンダリングに非対応のプラグインはまともに動作しないので注意!事前によく調べるべき

LPの開発を終えて

Gatsby.jsはサーバーサイドレンダリング起因のCSS崩壊や、有効なプラグインを調べてきてカスタマイズ・チューニングが必要なことなど、調整に手のかかるところがあるという欠点もありますが、それ以上に、gatsby-imageによる画像ファイルの最適化だったり、NetlifyやGitHubとの連携だったり、上で紹介しませんでしたがpagesにJSファイルを置けばルーティングまで勝手にやってくれることだったりと、Webサイト開発において優れた点が多いです。今後静的なWebサイトを開発する時も、今回得た知見をもとにGatsbyで開発したいなと思っています。
そして、インターンの今後のプロジェクトですが、LPと並行して、

  • インターン先のHP https://quando.jp
  • インターン先自社プロダクト SynQ Remote
  • 鉄板加工工場オートメーション化プロジェクト Webアプリ開発(Vue.jsとDjango)
  • 同プロジェクト用CNN(画像認識)モデル

なども任せてもらっているので、フロントエンドだけでなくバックエンド・機械学習など、フルスタックエンジニアを目指して様々な分野の技術に触れていきたいです。

最後に

https://www.wantedly.com/companies/quando
インターン先である株式会社クアンドでは、エンジニア・デザイナーさんを募集しています!
特にフロントエンド・バックエンド・インフラ・モバイルアプリ・UIデザインなどの分野で募集していますが、Webに限らず様々なプロジェクトをやっているので、分野関係なく純粋に技術が大好きな方の応募をお待ちしております!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ReactのFunctional ComponentとClass Componentの特徴

Functional Componentの特徴

・ES6のアロー関数で記述する
・stateを持たない
(※2019.02.06に関数コンポーネントにステートを持たせられるAPIが出ている↓)
https://ja.reactjs.org/docs/hooks-state.html

・propsを引数に受け取る
・JSXをreturnする
(※主に使用されているのが、Functional Component)

Class Componentの特徴

・Classを宣言及びReact Componentを継承
・constructorで初期化、propsを引数に受け取る
・renderメソッド内で、JSXをreturnし、引数(props)を受け取るには、thisが必要
・ライフサイクルやStateを持つことができる
(※基本、使用するのはClass Componentではなく、Functional Componentを推奨)

Stateが肥大化した場合の管理の問題

① stateを多くの場所で使用する→Reduxのstoreで管理
② stateを特定の少数の場所で使用→Class Componentで管理

基本的には上記の軸として考えてみると良いかも。

Functional Componentのコード例

import React from 'react';

const Hoge = (props) => {
  return (
   <div>
    <h1>{props.title}</h1>
   </div>
  );
};

Class Componentのコード例

import React from 'react';

Class Hoge extends from React.Component{
  constructor(props){
    super(props);
  }
  render(){
    return(
      <div>
       <h1>{this.props.title}</h1>
      </div>
    );
  }
}

いずれも、最後に

export

を記述し、

export 例)↓

export default Hoge;

さらに、index.js側で

import

import 例)↓

import Hoge from './Hoge';



ここは違う、ここはこうした方が?
等々ございましたら、ご指摘いただけますと幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【個人的】 React ディレクトリ構成 のベストプラクティス

Moduleとか色々

  • React
  • Redux-observable
  • Typescript
  • Jest

構成

Project名
  L config
    L .env.dev
    L .env.prod
    L .env.test
  L node_modules(インストールされたモジュール群)
    L ...
    L ...
    L ...
  L public(共通で使用するイメージや、設定ファイルなど)
    L images(イメージファイルは拡張子每で分けておく)
      L jpg
        L logo.jpg
      L png
        L logo.png
      L svg
        L logo.svg
    L favicon.ico
    L index.html
    L manifest.json
  L src(ソースファイル格納先)
    L __test__(テストファイル各要素每で分ける)
      L components
      L pages
      L reducers
    L _assets(イメージファイルやフォントファイル)
      L images(イメージファイル)
        L jpg
          L front-1.jpg
        L png
          L front-1.png
        L svg
          L icon-log.svg
      L data(データファイル)
        L posts.csv
    L _constants(定数ファイル)
      L path.tsx(URIの定数)
      L common.tsx(色や、基本の高さなどの定数)
    L components(共通コンポーネント)
      L Header.tsx
      L Footer.tsx
      L SimpleLink.tsx
    L pages(pagesのルートにcomponentsは作らない)
      L home(ページ単位でディレクトリを作成)
      L signin
      L signup
        L components(ページ内だけで使うコンポーネント)
        L index.component.tsx
        L index.tsx(container/connectorに値するもの)
    L services(APIなど)
      L api
        L signin.tsx
      L calc
        L add.tsx
    L stores(redux関連)
      L user(格納するstate単位)
      L posts
        L services
          L api.tsx
          L sort.tsx
        L action.tsx
        L epic.tsx
        L reducer.tsx
        L interface.tsx
      L actions.tsx
      L epics.tsx
      L index.tsx
      L reducers.tsx
    L App.tsx
    L index.tsx
  L package.json
  L README.md
  L ...
  L ...
  L ...

envの使用について

package.json に スクリプトを記述しておき、切り替え出来るようにしておく

"scripts": {
  "start": "cp ./config/.env.dev .env && react-app-rewired start",
  "start:prod": "cp ./config/.env.prod .env && react-app-rewired start",
  "start:test": "cp ./config/.env.test .env && react-app-rewired start"
}

manifestの使用について

基本的に react-app create で作成されたものだが、

本番対応に合わせて、マニフェストに記述されているファイルは最低でも用意しておく

  • favicon.ico
  • logo192.png
{
  "short_name": "React App",
  "name": "Create React App Sample",
  "icons": [
    {
      "src": "favicon.ico",
      "sizes": "64x64 32x32 24x24 16x16",
      "type": "image/x-icon"
    },
    {
      "src": "logo192.png",
      "type": "image/png",
      "sizes": "192x192"
    }
  ],
  "start_url": ".",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff"
}

imagesの使用について

public下においている場合

<img
  src={`${process.env.PUBLIC_URL}/images/png/logo.png`}
  alt={'sample'}
/>

assets下においている場合

import { ReactComponent as IconLog } from '@/assets/images/svg/icon-log.svg'

...

<IconLog />

もしくは

import samplePng from '@/assets/images/png/sample.png'

...

<img src={samplePng} alt="sample" />

testの使用について

テストファイルは /src/__test__ に集約しておき、他コンポーネントやページファイルの直下には置かない

内部で

src
  L __test__
    L components
    L pages
    L reducers

などとして、場合分けをして、テストファイルはここで完結するようにする

pagesの使用について

pages直下のディレクトリは ページ単位で作成し、それぞれの基本となる

component => index.component.tsx
container/connector => index.tsx

とする。そうすることで、router からの呼び出しはシンプルになる

import signin from '@/pages/signin'

構成

~/pages
  L signin
  L signup
  L passwordForgot

ページ要素の component郡は 各ページ直下に components ディレクトリを用意し、
ここに作成する。

~/pages
  L signin
    L components
      L Header.tsx
      L Footer.tsx
    L index.component.tsx
    L index.tsx

servicesの使用について

APIや何らかの共通処理や重い処理をしたい場合、こちらに置いておく
APIは勿論、精密な計算が必要なロジックはしっかり分けておくと、可読性も上がるし、ミスも発見しやすい

epicで処理したいものもここに置いておく

~/services
  L api
    L signin.tsx
  L calc
    L add.tsx

storesの使用について

主としてstate/reducer/epicの流れをまとめる。
他ベストプラクティスでは、

~/stores
  L actions
    L user.tsx
  L epics
    L user.tsx
  L reducers
    L user.tsx

のような工程も多いが、今回は、使用する state 単位でまとめたかったので、以下のように state 単位でまとめる形とした。
代わりに stores のルートに それぞれをまとめるファイルを一つ挟んでいる

~/stores
  L header
  L ui
  L home
  L users
  L posts
    L action.tsx
    L epic.tsx
    L reducer.tsx
  L actions.tsx
  L epics.tsx
  L reducers.tsx

state は pages/header/common 単位 でまとめる
epicから呼び出すものは基本的にservice層に置いておく

@/pages/signin みたいにファイルをインポートする

tsconfig.paths.json を用意して以下を記述 「@」のところを「~」にしたら ~/pages/signin みたいにして呼び出せる

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

tsconfig.jsontsconfig.paths.json を読み込ませる

{
  "extends": "./tsconfig.paths.json",
  "compilerOptions": {
    ...

  }
}

一言

src
  L components
  L pages

より

src
  L pages
    L components

こっちのほうがいいかな~

みなさんも意見があればお気軽にコメントください!

twitter ?https://twitter.com/tk_r1d3r

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ReactとWebRTCでZoomのようなビデオチャットアプリを作ってデータフローを図解してみた

はじめに

ご無沙汰しています。
約2年ぶりの投稿です?
早速本題から外れて自分のお話になってしまうのですが、
僕はとある会社にて約1年半ほどReactとWebRTCを用いて映像配信のアプリケーション開発を行ってきました。

そこでは開発をスムーズに進める為にWebRTCのSDKを利用していて、
本来学習コストが高いとされているWebRTCをカジュアルに利用することができています。
しかし、より入り組んだ実装をしたり映像配信特有の問題(後述)を解決するとなると以下3つのWebAPIの理解は避けて通れません。

詳しくは文中に記載しますがこれらの理解を深めないと開発の進行に大きな影響があると思ったので、WebRTC関連のライブラリ等を利用せずに映像配信のアプリケーションを作って学習しようという考えになり、実際に作ってみました。

それがこれです!

スクリーンショット 2020-09-11 9.24.00.png
リンク: https://react-webrtc-starter.herokuapp.com

※ Herokuに上げたので初回起動の場合コンテンツレンダリングが遅めです?

このスクショでは何をやっているのか端的に説明しますと、まずMacで上記リンクにアクセスして部屋を作成します。
続いてその部屋にiPhoneからアクセスをすると、既に部屋に入っているユーザー(Mac)と映像や音声(MediaStream)を交換することで相手と会話をすることが可能となります。

これはSFU(後述)と呼ばれる仕組みを使った双方向通信といわれるもので、別々のデバイスから流す映像や音声を特定のサーバーを経由して送り合うということをやっています。

この記事で話す内容

表題の通り、別々のデバイスで映像と音声のやり取りを行う上でのデータフローについて自分なりの解釈を述べていきたいと思っています。
アプリケーションの技術スタックであったり実際のコードについて興味がある方は、公開リポジトリを用意してるのでそちらを見てもらえればよいのかなと思うので、今回はWebRTCに焦点を当ててこんなハマりどころがあるとか、こんなところが歯痒いとかそういう使用感について取り上げていきたいと思います。

※ 本文では後述するSFUという通信方式である前提で執筆しているので、一部偏った表現があるかもしれませんが予めご了承くださいまし。

リポジトリ: https://github.com/yuyake0084/react-webrtc-starter

WebRTCについて

webrtc_wide.png

1. そもそもWebRTCって何?

アカデミックな話はできないし知らないので端的かつ自分の理解で言ってしまうと、Webブラウザというツールを介して音声や映像を相互に送り合ってリアルタイムコミュニケーションをWebで実現することができる仕組みのことを指します。
※ WebRTCとはWeb Real-Time Communicationの略称

WebRTC以外でWebというプラットフォームを使ってリアルタイムコミュニケーションをするとなれば、WebSocketを用いてのテキストチャット等が挙げられますね。
そのテキストチャットと明確に異なるのは、MediaStream(音声・映像)を用いてのリアルタイムコミュニケーションが実現可能になるということです。

2. リアルタイム通信を行うにあたって必要な情報

Webを介しているとはいえお互いの情報を交換し合わなければMediaStreamを送り合うことができないので、
RTCPeerConnection(以下、PC)から提供されているAPIを利用して、通信している人同士で特定のデータを送り合う必要があります。
それが以下2つで、どちらもWebSocketを介して相手に送る。

⭐️ SDP(Session Description Protocol)

利用しているブラウザで配信可能なコーデックの種類だったり、セッション情報、通信相手の情報等が記載されている文字列。
PCのcreateOffercreateAnswerというものを行って以下のオブジェクトを作成する。
かなり長いのでDevToolのconsoleで読むのはちょっと辛い。

{
  type: "offer", // or answer
  sdp: "v=0↵o=- 7548328979379926014 2 IN IP4 127.0.0.1↵s=-↵t=0 0↵a=group:BUNDLE 0 1↵a=msid-semantic: WMS HLYst9oarpp0MHhOHH47iyqzgQypSVIAM3Zq↵m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126↵c=IN IP4 0.0.0.0↵a=rtcp:9 IN IP4 0.0.0.0↵a=ice-ufrag:uJVj↵a=ice-pwd:iDqHChd7qahzFuRfZMiAdnN5↵a=ice-options:trickle....." // まだまだ続く
}

⭐️ ICE(Interactive Connectivity Establishment)

相手との通信経路が記載された文字列。
SDPをsetLocalDescriptionを使って自身が保持しているPCに格納すると、
そのPCでicecandidateイベントが発火するのでそのイベント内に格納されているのが以下オブジェクト。

{
  type: "candidate",
  candidate: "candidate:669691712 1 udp 2122260223 172.16.100.225 63152 typ host generation 0 ufrag xLmL network-id 1 network-cost 10"
}

3. 通信方式は主に3種類

特定の相手と通信するにあたってお互いの
WebRTCを用いてサービス開発をする方にとってはその要件次第で以下の3つから通信方式を選定すると思います。

  • P2P(Peer-to-peer)
  • MCU(Multipoint Control Unit)
  • SFU(Selective Forwarding Unit)

? P2P(Peer-to-peer)

サーバーを介さず直接端末(ブラウザ)同士で接続する通信方式。
高解像度で視聴できるがそれ故にモバイル端末に於いてはデコードにかかるCPUへの負荷が高い。

? MCU(Multipoint Control Unit)

音声と映像(ストリーム)をサーバーで結合してそれをクライアントに提供する通信方式。
PCのコネクションが1本しか無い為クライアントサイドの負荷は低いが、
サーバーサイドは音声と映像を結合する為のエンコード処理が走る為負荷が高い。(それに付随して遅延が発生するリスクがある)

⭐ SFU(Selective Forwarding Unit)

MCUと同じくサーバーを介してストリームを提供し合う通信方式。
しかしMCUと異なるのは、サーバーはクライアントから送られたストリームを結合せずに他の配信者に流す役割を持っている。

冒頭にも記載しましたが、本記事で紹介したアプリケーションはSFUを採用しています。
理由は単純で、チームでSFUを利用しているので同じ仕組みを使った上で挙動を把握したかった為です。

複数人でビデオチャットをするにあたってのデータフロー

ここまででビデオチャットを行う上で必要最低限の前提知識は紹介させて頂いたので、
実際にお互いの映像が映るまでのデータフローを追いかけてみましょう。

1. 部屋の作成

video_chat01.png
特定の人同士でクローズドなチャットを行うにはまず最初に部屋の作成を行う必要があります。
下記図ではAさんが「XXXX-XXX-XXXX」というRoomIdの部屋を作成しました。

2. Bさんが入室

video_chat02.png
続いてBさんはAさんが作成したXXXX-XXX-XXXXの部屋に入室します。
前提として、この時のBさんはWebSocketに接続しているけれどAさんの映像は写っていない状態です。
なのでBさんはまず、「入室しましたよ!」という旨をWebSocketを介して入室中のユーザーに伝えます。

3. AさんからBさんへOfferを送る

video_chat03.png
ちょっとここはやや複雑なので最初にここでやってることを簡潔に述べると、 「私(Aさん)はこういう者ですけれど、あなたはどちら様ですか??」 という質問を新しく入室してきたユーザーに対して投げかける、ということをやっています。

Callが届いたAさんはまず最初に pc.createOffer() でSDPを作成します。
作成されたSDPはCall主であるBさんに返すのですが、Bさんとの通信経路を確立する必要があります。
ここでいう通信経路とは互いの映像と音声を送る上でのPC上での MediaStream の通り道をイメージしてもらうと良いと思います。
簡易的なコードを用いて説明すると以下の通り。

const pc = new RTCPeerConnection(...) // 接続するクライアントごとにPCを生成。引数にはSTUNサーバーの情報とか色々渡す
const sessionDescription = await pc.createOffer() // SDP生成

// setLocalDescriptionが実行されると発火
pc.onicecandidate = (e: RTCPeerConnectionIceEvent): void => {
  console.log(e.candidate) // イベント経由で得られる経路情報。これをWebSocketを使って相手に送る
}

await pc.setLocalDescription(sessionDescription)

具体的に説明すると、まず最初に pc.setLocalDescription(SDP) を実行します。
引数のSDPは上記の pc.createOffer() で生成されたSDPと同一のものです。
pc.setLocalDescription(SDP)が実行されると該当するpcから通信経路が確定するまで icecandidate イベントが発火され続けます。
このイベントを契機に通信相手に対してSDPを送るのですが、方法としては以下の2種類があります。

⭐ Trickle ICE
上記のicecandidateイベントは経路情報が確定するまで何度か発火するのですが、発火される度に収集した経路情報があればWebSocketで即座にサーバに送りつけるのがTrickle ICEです。

pc.onicecandidate = (e: RTCPeerConnectionIceEvent): void => {
  if (!!e.candidate) {
    const data = {
      toId: clientId,
      roomId: this.roomId,
      sdp: {
        type: 'candidate',
        ice: e.candidate,
      },
    }

    this.socket?.emit(types.CANDIDATE, data)
  }
}

⭕ メリット
Vanilla ICEと比較して部屋にいるユーザーとの通信確立の時間が短い

❌ デメリット
イベントが発火される度に小出しで送る為、仮にネットワーク不安定等の理由でうまく送れなかった場合経路情報に欠損があるとうまく通信ができなくなる可能性がある

⭐ Vanilla ICE
経路情報が確立するとpc内部にlocalDescriptionが用意されるので、それを1度だけ送るようにするのがVanilla ICEです。

pc.onicecandidate = (e: RTCPeerConnectionIceEvent): void => {
  if (this.pc?.localDescription) {
    this.sendSDP(this.pc.localDescription)
  }
}

⭕ メリット
Trickle ICEと比較して安定した通信経路の送信が可能

❌ デメリット
Trickle ICEと比較して通信経路確立までの時間が長い

今回僕が作ったアプリケーションではTrickle ICEを採用していますが、どちらが良いかは作成するアプリケーションの性質によって異なると思うので適宜使い分けると良いのかなと思います。

4. BさんからAさんに対してAnswerを返す

AさんからSDPが送られたらBさんの手元にもAさんとのPCが作成され、
Aさんに対してBさんのSDPを乗せてAnswerを返します。
また、3同様Aさんとの通信経路を確立します。

video_chat04.png

5. AさんとBさんのコネクションが確立?

SDPの交換が成立した時点で、PCの addstream イベントが発火し、相手のMediaStreamを受け取ることができます。
MediaStreamを受け取ったタイミングで新たにvideoタグを生成し、srcObject属性に受け取ったMediaStreamを渡すことで自身のブラウザ上で相手の映像と音声を再生することができる為、ここでようやくコネクションが確立したと言えるのかなと思います。

video_chat05.png

長くなってしまいましたがデータフローの説明については以上です。
厳密には異なりますが、Cさんが入室した場合でも上記の入室フローとほぼ一緒の挙動となります。

(記事の尺的な都合上Cさんの出番無くなってしまった。。。)

映像配信特有の問題とは?

例を上げると、配信映像が意図しないタイミングで切断してしまったり、音声は聴こえるが映像が固まったままの状態になってしまうことが稀にあって、要因については様々です。

ネットワーク帯域幅が低かったり、利用しているブラウザとそのバージョン、果てには利用している端末でWebRTCとの相性なんてものもあったりしますし、これら以外にも存在するあらゆる問題全てを網羅的にカバーするのはほぼ不可能です。

しかし、映像系のサービスに対して課金を行なったが、映像が止まってしまったことによって課金額に見合うだけのサービスが提供されなかったエンドユーザーにとっては、そんなことは関係ないですし、まともに動作しないサービスとして不信感を持たれてしまうことになります。

網羅的にカバーすることは不可能であるにしても、エンドユーザーからお問い合わせがあった際にどこに原因があったのかを特定できるようにログを残して根気強くその調査を行って、そのログから得られた知見を基に映像停止の発生を抑制できるような方法を模索し続けていく努力を怠ってはいけないので、手探りしながらでも改善の糸口を掴んでいきたいと思っています。

まとめ

最後の方は映像配信特有の問題のところでマイナスな表現をしてしまいましたが、その仕組自体はとてもおもしろいものですし、コロナ時代で他者と円滑なコミュニケーションを取るにあたってWebRTCという技術はなくてはならない存在だと思うので、引き続き情報をキャッチアップしていきたいと思います。
ではでは✋

大変参考になった記事

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む