JavaScript
WebRTC
Firebase
React
redux
ラクスDay 15

Firebase+WebRTCでAtomのteletypeみたいな同時編集をブラウザでやるやつを作った

15日目担当します。ラクスHR事業部の@t-tonchimです。

今日の担当の人遅いなぁと思ってたら自分でした。1日勘違いしてました。申し訳ありません:bow:
推敲途中だったので見苦しい箇所あるかもしれませんのでご了承ください。

tc39/proposalsを眺めてワクワクするのが趣味です。でもメインはRubyでRailsです。

HR事業部は派遣事業で自分も派遣先で微力ながらお手伝いをさせていただいてます(邪魔になってないといいけど)。

はじめに

Atomのteletypeって話題になりましたね。Atomは重いのが嫌いで使ってないので実はどんな感じなのか全くわかってないんですが・・・。
同時編集できてWebRTCというとこまではわかったんで多分ブラウザでも似たようなことできるだろうと思って雑に作ってみました。WebRTCについては仕事でも使ったことないし多分今後も使うことなさそうなのですが、面白そうなので触ってみたかった程度の素人です。

対象読者は全く意識してなかったんですがredux-sagaがわかって、WebRTCに興味がある人です。多いのか少ないのかよくわからないですが・・。

teletypeのソースコードを少し読んでみる

最初にどのように実現してるのか参考にしようと軽くソースコードを読んでみました。atom/teletypeのpackage.jsonを読むとどうやらteletype-clientというものを作って使っているのがわかります。

teletype-clientにはteletype-serverteletype-crdtというのが使われてますね。teletype-serverの方は何してるかわかりませんがdevDependencyに入ってるあたり、開発用のものだと思います。teletype-crdtについてはコンフリクトフリーのCRDTのことでしょう、同時編集するために上手いことやってくれるんですかね。

teletype-clientのpackage.jsonのdependenciesをみてると他にもpusher-js, uuid, event-kit, google-protobuf, webrtc-adapterなどが使われていました。

もうちょっと踏み込んで調べたいところですが使わないエディタのソース読んでも全く面白くないのでアドベントカレンダーに間に合わないといけないので、ライブラリがどのような用途に使われているか想像してみました。

  • webrtc-adapterでブラウザ間の実装の差を吸収している(jQueryみたいですね)
  • pusher-jsはmBaaSのライブラリだったのでシグナリングのSDPやCandidateの交換に使ってるのでは?
  • event-kitはevent-emitterみたいなものなんで場合によっては必要かも
  • google-protobufはペイロードを小さくするために使われてるぽい
  • uuidは単に相手に通知するためのIDだろう

他にも内部で色々やってるんだと思いますが、WebRTCでデータチャネル使ってやりとりするだけならシンプルに作れそうな気がしました。あ、そうそう、WebRTCというとSkypeみたいな音声とか動画のチャットのイメージが強いんですけど(そんなことない?)テキストとかのデータだけ送ることもできるんです。それが今回使うデータチャネルです。

シグナリングどうするか

ちょいちょい出てくるシグナリングってのは要はLINEのID交換とか電話番号の交換みたいなものです。お互いの連絡先知らないと連絡できませんよね。シグナリングはSDP(peerの情報)とICECandidate(接続候補の情報)を交換さえできればどうやってもいいそうです。チャットでSDPなどをそのまま送ったりとかでもできるらしいですが、手動でいちいちやるのは現実的ではありませんね・・。というわけで通常はシグナリングのためのサーバーを用意します。

yjsというライブラリがあって簡単にできそうな印象を受けたんですが、うまく動かなくてめんどくさくなったんでシグナリング周りは自分で実装することにしました。

しかたないので自分で実装してみる

今回はFirebaseのリアルタイムDBを使ってシグナリングの仕組みを実装することにしました。

構成

ざっくり下図のような感じです。実際には通信経路候補(ICECandidate)の交換もありますが、それも結局sessions/{uuid}に渡してるだけなんで流れはSDPのoffer/answerとほぼ同じです。{uuid}の部分はどちらもホスト側のIDが入ります。@massie_gさんのこちらの記事を参考にさせていただきました。(参考にした割にこんな雑な構成ですいません・・。)あと今回は1対1しか想定してないので、複数人でやったらちゃんと動くか怪しいもんです。

構成ず.jpg

ローカルで試すためにmBaaSとかいちいち使ったりするのが面倒臭いって人はローカルでWebSocketのサーバー立てて上記のFirebaseの代わりにできます。実際にこの構成にする前にWebSocketで開発しました。

余談ですけど、他の方式で情報をやりとりして、接続確率してから高速な通信でやりとりするというのが何となくハイブリッド暗号方式に似てるような気がするなぁと思いました。(全然違うわって怒られそう)

シグナリングの処理を行ってる主な部分は非同期処理が多いのでredux-sagaで行っています。以下はその抜粋です。シグナリング部分のソースはこちら

sagas/index.js
import { eventChannel } from 'redux-saga'

/* ... */

const app = firebase.initializeApp(config)
const db = app.database()

function subscribeSignaling({ clientId, hostId }) {
  const ref = db.ref(`sessions/${hostId}`)

  return eventChannel(emit => {
    ref.on('child_added', data => {
      const { from, type } = data.val()
      if (from !== clientId && type) {
        emit(actions[type]({ ...data.val() }))
      }
    })
    return () => {}
  })
}

/* ... */

firebaseのchild_addedイベントが発火した際に自分のIDと異なるIDから送信されたものであればemitするようにしていて、その後は通常の使い方だと思いますがこのchannelを扱うタスクでactionをdispatch(sagaで言えばput)しています。

actionsはreduxのactionCreatorです。actions[type]type部分に'offer'とか'answer'とか渡してやってそれに応じたactionをsagaで検知してpeer.setRemoteDescription(sdp)とかやってます。他のoncandidateとかondatachannelのイベント発火時もだいたい同じ感じでやってます。

エディタをどうするか?

JavaScriptにはaceという素敵なエディタがあるのでこれをうまく使えば良さそうと思ってたらreact-aceというのがありました。
ただ、このライブラリのせいかわからないんですが、やたらとビルドに時間がかかります・・。あんまり遅いのでWebpackからparcelに移行したくらい。個人で使うぶんにはparcelは楽でいいですね。

エディタ部分の実装

これは難しいことしてないですね。ほとんどreact-aceがよろしくやってくれます。
syntaxだけはお互いに共有されないとまずいのでreduxで管理してますが、それ以外はlocalStateとしてコンポーネントで直接管理しています。ReduxにおけるGlobal stateとLocal stateの共存
注: this.func = this.func.bind(this)とかやるのめんどくさいんで stage-0のFunction bind Syntax 使ってます。::this.funcみたいなやつはthis.func.bind(this)と読み替えてください。

components/Editor.jsx
import React, { Component } from 'react'
import Ace from 'react-ace'
import 'brace'
import 'brace/mode/ruby'
/* ... */
import 'brace/theme/monokai'
import 'brace/keybinding/emacs'
import 'brace/keybinding/vim'
import 'brace/ext/language_tools'
import PropTypes from 'prop-types'

const modes = {
  ruby: 'Ruby',
  /* ... */
}

const keybinds = [null, 'vim', 'emacs']
const fontSizes = [14, 16, 18, 20, 22, 24, 28, 32]

export default class Editor extends Component {
  static get propTypes() {
    return {
      value: PropTypes.string,
      onChange: PropTypes.func.isRequired,
      onChangeMode: PropTypes.func.isRequired,
      mode: PropTypes.string.isRequired
    }
  }

  constructor(props) {
    super(props)
    this.state = {
      fontSize: 14,
      autoComplete: true
    }
  }

  onChangeFontsize(e) {
    this.setState({
      fontSize: parseInt(e.target.value, 0)
    })
  }

  onChangeKeybind(e) {
    this.setState({
      keybind: e.target.value
    })
  }

  toggleAutoComplete() {
    this.setState({
      autoComplete: !this.state.autoComplete
    })
  }

  render() {
    return (
      <div>
        <label htmlFor="syntax">syntax </label>
        <select
          id="syntax"
          onChange={this.props.onChangeMode}
          value={this.props.mode}
        >
          {Object.entries(modes).map(([k, v], i) => (
            <option key={i} value={k}>
              {v}
            </option>
          ))}
        </select>
        <label htmlFor="keybind">keybind </label>
        <select id="keybind" onChange={::this.onChangeKeybind}>
          {keybinds.map((kb, i) => (
            <option key={i} value={kb}>
              {kb}
            </option>
          ))}
        </select>
        <label htmlFor="fontsize">fontsize </label>
        <select id="fontsize" onChange={::this.onChangeFontsize}>
          {fontSizes.map((size, i) => (
            <option key={i} value={size}>
              {size}
            </option>
          ))}
        </select>
        <label htmlFor="autocomplete">
          autocomplete
          <input
            type="checkbox"
            checked={this.state.autoComplete}
            onChange={::this.toggleAutoComplete}
          />
        </label>
        <Ace
          mode={this.props.mode}
          fontSize={this.state.fontSize}
          theme="monokai"
          onChange={newValue => this.props.onChange(newValue)}
          editorProps={{ $blockScrolling: true }}
          value={this.props.value}
          style={{ width: '800px' }}
          keyboardHandler={this.state.keybind}
          enableLiveAutocompletion={this.state.autoComplete}
        />
      </div>
    )
  }
}

できたもの

gif撮るの慣れてないんでたどたどしい感じになってますが・・こんな感じでブラウザ間でP2Pでやりとりしてリアルタイムにコードの編集を相互にできます。(gifでは片方しかやってませんが同時に編集できます)
demo.gif

  1. to be hostのボタンを押して表示されるUUIDを相手側にチャットなどで通知します。
  2. 相手側は受け取ったUUIDでofferを送るとシグナリングが行われます。

firebaseでホスティングしてあるんでよかったら試してみてください。右側に変な余白があるのはチャット機能もつけようと思ってたからです。
シグナリングにSTUNサーバーしか使ってないのでEdgeでは動かないかもしれません。あとファイヤーウォールとかプロキシ挟むとうまくいかないかもしれないです。
https://webrtc-509ce.firebaseapp.com/
全体のソースコードはこちら
https://github.com/t-tonchim/code_share

最後に

WebRTCでなくてもいいんですが、ReduxのActionをJSONとして送って相手側に任意のActionを実行させることができるのが面白いなぁと感じました。UIの状態も簡単に共有できるんでリアルタイムな動きのあるゲームも作れそうですね。

参考資料

@massie_g さんの記事(Qiitaの記事も勉強になりました)
@voluntas さんの記事(WebRTC以外にも時雨堂さんの評価制度のない評価制度とかいろいろと面白く読ませてもらってます)
わかった気がするWebRTC(知識0からでもわかりやすいです)