👻

PortalKeyを支える技術 - WebRTCを使ったコミュニケーションプラットフォーム実装への挑戦の振り返り

2024/12/25に公開

初めまして、PortalKeyの植森です。
Twitter(X)などではwakaba260というHNで活動しており、現在はPortalKeyという会社でCTOをしています。

PortalKeyでは現在、新規アプリとして軸となるサービスを開発中です。
今回、サービスをリリースする前にどんな人が作っているのか・どうやって作っているのかを伝えていこうということで技術ブログを始めることにしました。
よろしくお願いします。

はじめての技術ブログと今年1年の振り返りということで、それを実現するためにどういう技術を採用しているのか・なぜそれを採用したのか、そしてどういった設計をしているのかということを書こうと思います。

はじめに

僕がPortalKeyに入社したのは2024年4月で、このタイミングではまだ社長の倉田と僕の二人だけでした。

僕自身の経歴としてはPortalKeyにジョインする前は Aiming → mixi と主にゲームのサーバサイドを開発しており、フロントエンドの開発を行ったのは管理ツールや開発の補助ツールなど、せいぜい社内向けのものばかりです。
現在エンジニアは3人に増えましたが、他のメンバーはフロントエンドどころかWeb開発自体初で、実はWebサービス開発をしたことのあるメンバーは誰もいません。

そんな中での音声通話を使ったリアルタイムなコミュニケーションのアプリということで難易度の高いアーキテクチャ設計と技術選定でしたが、採用した技術に関してはどれも間違っていなかったと思います。
今年も最後ということで、今回は採用した技術スタックの振り返りをしつつ、似たようなものを実装する人への技術選定や設計の参考になれば嬉しいです。

働き方とドッグフーディング

PortalKeyでは現在フルリモートワークで開発を行っています。

4月に開発を始めた段階ではDiscordを使って通話をしていましたが、一通りアプリが安定した7月ぐらいからは実際に開発しているアプリをみんなで使いながら働いています。
以下は開発中の画面の一部です。

まずは僕たちが欲しい・使いたいツールを実現して、その体験をもとにアプリをリリースするのを目標に現在いろんな機能を実装中です。

プロダクトの目指すこと

技術スタックを検討するにあたって、僕たちがPortalKeyで実現したいことは以下の3つです。

コンセプトについての詳しい話は倉田の記事に譲るとして、これらを実現するためには以下のような要素が必要だと考えました

  • Discord や Slack のようにリアルタイムな通信トラフィックを扱う : ボイスチャットへの参加や退出、ユーザの状態がリアルタイムに変化する
  • 高品質なビデオ通話や音声通話の機能を持つ: Zoom や Google Meetと同等レベルのビデオ通話や音声通話の機能を持ち、Discord に近いレベルの高品質な音声通話を提供する
  • マルチテナント、マルチアカウント: Slackのように企業や組織ごとに独立したワークスペースを提供し、ユーザが複数のワークスペースに所属できる
  • 高品質なUIインタラクションを実現できる: ユーザがツールを立ち上げたくなる、その場に居たくなるような体験を提供する
  • デスクトップアプリの対応: ビジネスシーンで利用しやすいようデスクトップアプリを提供し、ブラウザよりもデスクトップでの体験が最大化されるようにする

また、スタートアップであることメンバーの状況を踏まえて以下の点も考慮が必要です。

  • 少ない開発メンバーでも開発ができること
  • 最近のフロントエンド事情に明るくなくても開発できること
  • フレームワークなどの学習コストをなるべく低くすること

これらを踏まえた上で検討し、現在では次のような技術スタックになっています。

技術スタック

個別の詳細な実装や設計については長くなりすぎるのでまた別記事を書こうと思うので、よければこの技術ブログのフォローをお願いします!

フロントエンド

言語 TypeScript, React
フレームワーク Redux, Redux Toolkit
音声通話 SkyWay(WebRTC)、Krisp(ノイズ除去)
リアルタイム通信 WebSocket, Protocol Buffers
デザイン、CSS Tailwind CSS、Figma
エラー監視 Sentry
デスクトップ開発 Electron
ビルドツール Vite、Electron-Builder、Storybook
エラー監視 Sentry

フロントエンドの開発はTypeScript + Reactをベースに、音声通話にはSkyWay(WebRTC)を採用しています。

フロントエンドの実装要件としてSlackやDiscordレベルのデスクトップアプリを実装したいという要件があり、またなるべく低コストで開発するためにクロスプラットフォームでデスクトップアプリが開発できるElectronを採用しました。
Electronはブラウザアプリを比較的簡単にデスクトップ化することが可能で、アプリの開発コストを抑えられています。
またブラウザをそのまま動作させることが出来るためWebRTCを実装を変えずに利用することができ、将来的にはメインプロセス側で通話関連の処理を実装できるという拡張性も踏まえての採用です。

バックエンド

言語 Golang
API通信 Connect RPC
リアルタイム通信 gorilla/websocket、Protocol Buffers
データベース Cloud Spanner、cloudspannerecosystem/yo
インフラ Google Kubernetes Engine
KVS Etcd
ツール Docker Compose、Make
PubSub NATS JetStream
IDaaS AWS Cognito
ツール Buf、Terraform
CI Github Actions、Cloud Build

サーバの開発言語としてはGolangを採用しています。

データベースはmixi時代に使っていた経験があるGCPのCloud Spannerです。
Cloud Spannerは運用が比較的容易で、かつyoのコード生成やミューテーションによるNoSQLに近い実装感もあり、データベースを初めて触るメンバーでも触りやすいデータベースです。
気になる点は料金面ですが、Google for Startupsの申請が通り20万ドル分(!)のクレジットがもらえたことで不安がなくなったので思い切って移行しました。
Processing Unit単位でのインスタンス管理が出来るようになったあたりからコスト感は低めにはなってきていますし、2024年9月にはSpannerエディションという料金プランの見直しがされたりと、料金面での導入の敷居はかなり下がったと思います。

技術スタックの検討ポイント

技術選定をするにあたり、以下を検討ポイントとしました。

  • 非同期イベントが多いクライアントのステート管理: 自分と他人のリアルタイムなイベントをサーバ側で通知出来ること、クライアント側でUIへの反映が直線的にできること
  • サーバサイドのイベント通知: クライアントの状態がずれにくいイベント通知の仕組みであり、遅延が少ないこと
  • サーバ・クライアント間通信の実装コスト削減: 通信部分の実装になるべく時間をかけず、機能実装に集中できること
  • 音声通話の品質向上と実装コスト: 音声通話をなるべく実装コストを下げつつも、高い品質で提供できること

非同期イベントが多いクライアントのステート管理

クライアントサイドではWebSocket経由でのイベントをクライアントアプリ内でどうUIやステートに反映するかもポイントになります。
通常のWebサイトでは非同期なイベントによるUIの変更の比重が小さく、ページ遷移やユーザのインタラクションの比重が大きくなります。
しかし、PortalKeyでは自身のインタラクションによるUIの変化よりも他人のアクションによるUIの変化の方が頻度が大きく重要なため、非同期なイベントによるクライアントのステート変化が複雑化すると実装コストが高くなります。

この実装コストを下げるために採用したのがReduxです。

Flux FlowではView(UI)からActionがDispatchされReducerによりStateが変更されますが、WebSocketからのメッセージをActionとしてDispatchするContext Providerによって、リアルタイムな変更をStateに反映するFlowを追加しました。

Gatewayのメッセージ定義が増えるたびにContext Providerの実装を変更するのは避けたいので、WebSocket Clientに対しeventListenerを登録してdispatch出来る関数を実装してつなぎこみをするようにしています。

export const gatewayListener = createListener<PortalKeyClientEventMap>((emitter) => {
  // READYメッセージが送信された場合のイベントハンドラ
  emitter.on(GatewayMessageCode.READY, (dispatch, message) => {
    dispatch(GatewayActions.ready(message))
  })

  // NOTIFY_VOICE_STATEメッセージが送信された場合のイベントハンドラ
  emitter.on(GatewayMessageCode.NOTIFY_VOICE_STATE, (dispatch, message) => {
    dispatch(GatewayActions.notifyVoiceState(message.payload))
  })
})

const useListeners = [
  voiceRoomListener.useListener,
  authListener.useListener,
  workspaceListener.useListener,
  gatewayListener.useListener
]

export const useAppListener = () => {
  const observers = useListeners.map((observer) => observer())

  const subscribe = useCallback(
    (emitter: PortalKeyClient) => {
      observers.forEach((observer) => observer.subscribe(emitter))
    },
    [listeners]
  )

  const unsubscribe = useCallback(
    (emitter: PortalKeyClient) => {
      observers.forEach((observer) => observer.unsubscribe(emitter))
    },
    [listeners]
  )

  return { subscribe, unsubscribe }
}

export const PortalkeyClientProvider = ({ children, client }: PortalkeyClientProviderProps) => {
  const { subscribe, unsubscribe } = useAppListener()

  useEffect(() => {
    subscribe(client)

    return () => {
      unsubscribe(client)
    }
  }, [subscribe, unsubscribe, client])

  return <Provider value={{ portalkeyClient: client }}>{children}</Provider>
}

このFlowによってreducerを通常通り実装すればStateに反映され、ViewはUIの表示に集中する設計となっています。

サーバサイドのイベント通知

クライアントにリアルタイムなイベントを通知するにあたって、サーバサイドでどうやってイベントを通知するかの検討も必要になります。
今回はイベントの通知にはNATS JetStreamとEtcdを採用しました。

NATSはCNCF(Cloud Native Container Foundation)のStreaming & Messagingカテゴリーのミドルウェアで、オープンソースのメッセージングシステムです。
NATS JetstreamはNATS Streamingの機能の後継であり、Exactly-Onceなメッセージ配信を実装できます。
サーバ側ではNATS JetstreamをPubSubメッセージ基盤として利用しており、WebSocketクライアントはワークスペースやユーザ、セッションといった単位でのトピックをSubscribeし、サーバでは必要なタイミングで各トピックに対しPublishすることでWebSocket経由でのイベント通知を行っています。

また、Kubernetesのバックエンドとして有名なEtcdをセッションや一時的なデータの保存先のKVSとして採用しています。
さらに、Etcdには特徴的な機能として、特定のキーに対するデータの更新を購読するWatchというAPIがあります。

引用: https://etcd.io/docs/v3.5/tutorials/how-to-watch-keys/

この機能を使うことで、例えばユーザのSessionキーを購読することで削除を検知してユーザがログアウトしたことを通知したり、ユーザのボイスチャットの状態を購読することでユーザのマイク状態の変更を検知して通知したり、といった機能を実装しています。

サーバ・クライアント間通信の実装コスト削減

サーバ・クライアント間の通信はAPI通信にはConnectRPC、リアルタイム通信にはWebSocketを利用しています。

ConnectRPCはBuf Technologies社が開発しているgRPC互換のHTTP APIを実装するためのライブラリです。
Protocol Buffersを使ってスキーマを記述することで、データのマーシャリングや圧縮、ルーティング、ネゴシエーションを処理するコードの自動生成が行えます。
gRPCと違いHTTPがベースになっているためGolangの net/http.Handler が利用できる、デフォルトで content-type: application/json とProtocol Buffersの両形式に対応、Envoyのようなプロキシに依存せずに通信可能など様々なメリットがあり、導入自体も容易なためこちらを採用しました。

リアルタイム通信はWebSocketを利用しています。
ConnectRPCでもgRPC Streamingに互換性のあるリアルタイム通信が行えるのですが、枯れていて安定している技術であり、参考実装も多いWebSocketを採用しています。
WebSocket上のサーバ・クライアント間のメッセージペイロードにもProtocol Buffersにより生成されたコードを利用することでこちらでも型による補助を受けています。

音声通話の品質向上と実装コスト

ブラウザには古くからWebRTCというブラウザ間で直接音声やビデオのストリームを送受信できる規格に沿った実装があり、当初からこれを採用することにしていました。

WebRTCでは直接クライアント間で通信を行うP2Pがありますが、多人数での安定した通信を可能にするにはサーバを介する必要があります。
OSSのWebRTCサーバとしてはXのスペースなどでも実績のあるmediasoupなどがありますが、なるべく運用や実装コストを下げるために外部サービスの検討を行いました。

当初はClubhouseも採用していたらしいAgoraを使っていましたが、問題になったのがAgoraの料金プランと僕たちの提供したいサービスの体験です。
僕たちのサービスでは、メンバーの見える化や誰かに話しかけやすい状況を作るために”ユーザーがボイスチャットにいたくなる”ような体験を目指しています。
また作業部屋のようなルームにみんなで集まってもくもく作業しながら、ふとしたときに相談したり雑談したりするような使い方もしてほしいと考えています。

しかし、Agoraは接続時間に対する従量課金であり、これを実現するためには料金面が問題になりました。
サービスとしてもSlackのようにFreeプランをベースにすることを目指していることもあり、Freeプランユーザーの分の料金負担が有料ユーザーにかかってくると採算を合わせるのは非常に難しいです。

そこで代替サービスを探していてたどり着いたのがNTTコミュニケーションの提供するSkyWayでした。

SkyWay

https://skyway.ntt.com/ja/about/

SkyWayはプロダクションの基本サービス利用料の固定額こそかかりますが、通話で送信したデータ量に対する従量課金、つまり喋ったり動画を流している間だけ料金がかかる方式で、僕たちのやりたいサービスにマッチした料金プランでした。

また、Agoraは手軽で使い始めやすいのですが、使いやすくした結果抽象化されている部分が多く、HTML5のMediaStreamやWebRTCなどの低レベルAPIに触りづらいという問題がありました。
ドキュメントがあまり親切でなく、SDKも公開されていないためunminifyした難読化されたコードを読んだりもしていたので非常につらかったです。

SkyWayはドキュメントも日本語で触りやすく、インターフェースも低レベルAPIに触りやすい程度に使いやすく抽象化されており、SDKも公開されているため安心して実装することが出来ました。

Krisp

音声通話の品質を高くするための機能としてノイズキャンセリングを検討していましたが、ノイズキャンセリングにはKrispを採用しています。
KrispはDiscordでも採用しているAIによるノイズキャンセリングの機能をSDKを通じて提供してくれます。

Krispのノイズキャンセリングはかなり強力で、マウスやキーボード音はもちろん咳払いやビニール袋のガサガサ音といった日常生活で起こるノイズの大部分をカットしてくれます。
またBackground Voice Cancellationの機能もあるため、周囲の会話音などもかなり抑えられます。

SDKはAudioNodeに対しconnectするだけで組み込みが可能で、低コストで実装が可能です。
AIのモデルサイズも数MBと小さめです。

import KrispSDK, { IAudioFilterNode } from "@krispai/javascript-sdk"
import krispBVC from "@/assets/krisp-sdk/bvc-allowed.txt"
import krispModel8 from "@/assets/krisp-sdk/models/model_8.kw"
import krispModel16 from "@/assets/krisp-sdk/models/model_16.kw"
import krispModel32 from "@/assets/krisp-sdk/models/model_32.kw"
import krispModelInbound8 from "@/assets/krisp-sdk/models/model_inbound_8.kw"

export async function createKrispAINode(audioContext: AudioContext): Promise<IAudioFilterNode | undefined> {
  if (!KrispSDK.isSupported()) {
    console.warn("KrispAI is not supported")
    return undefined
  }
  const krispSDK = new KrispSDK({
    params: {
      useBVC: true,
      debugLogs: false,
      models: {
        model8: krispModel8,
        model16: krispModel16,
        model32: krispModel32,
        modelBVC: {
          url: krispModel32,
          preload: true
        }
      },
      inboundModels: {
        model8: krispModelInbound8
      },
      bvc: {
        allowedDevices: krispBVC
      }
    }
  })
  await krispSDK.init()
  const node = await krispSDK.createNoiseFilter(audioContext)
  node.addEventListener("ready", () => {
    node.enable()
  })
  return node
}

const context = new AudioContext()
const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId, echoCancellation: true, noiseSuppression: true, autoGainControl: false } })
const sourceNode = context.createMediaStreamSource(stream)
const destination = context.createMediaStreamDestination()

const krispNode = createKrispAINode(context)
sourceNode.connect(krispNode)
krispNode.connect(destination)

PortalKeyのアーキテクチャ

これらの技術スタックを踏まえた上で、今のPortalKeyのアーキテクチャについて紹介していきます。

PortalKeyはTypeScript+Reactによるフロントエンドクライアント(SPAとElectron)、Golangによるバックエンドサーバ(API、Gateway、Voice)で構成されています。

  • フロントエンド: TypeScript + React
    • SPA
    • Electron
  • バックエンド: Golang
    • API
    • Gateway(WebSocketサーバ)
    • Voice(WebSocketサーバ)

なお、通信周りの設計についてはDiscordのDeveloper Portalを大きく参考にしています。
(リアルタイム通信サーバをGatewayと呼んでいるのもここからきています)
https://discord.com/developers/docs/reference

ちなみに今はクリスマスが近いのでドキュメントのページで雪が降ってます。おしゃれですね。

APIサーバ

APIサーバはConnectRPCによって実装されています。

クライアントはほとんどのメッセージのやり取りをGatewayサーバを通じて行うためAPIの利用頻度は少ないですが、認証・認可や一時的に利用するデータの更新はAPIを通じて行います。

APIはデータの更新が発生するとPubSubのトピックに対してPublishを行い、オンラインのユーザに対してデータ更新を通知します。

Gatewayサーバ

PortalKeyではWebSocketサーバのことをGatewayサーバと呼んでいます。
クライアントは認証が終わるとGatewayサーバと常時接続した状態になります。

Gatewayサーバは以下の処理を行います。

  • クライアントのセッション管理
  • クライアントから送信されたメッセージのハンドリング
  • 各サーバがPubSubにPublishしたメッセージをクライアントへ通知する
  • Etcdのキーの監視を行い、イベントが発生するとメッセージとしてクライアントへ通知する

GatewayとフロントエンドのメッセージはProtocol Buffersで定義し、メッセージコードを見て必要なハンドリングを行います。

enum GatewayMessageCode {
  // 無効
  GATEWAY_MESSAGE_CODE_UNSPECIFIED = 0;

  // 100-: 認証・接続関連

  // Gatewayがコネクション接続直後にクライアントに対して送信する Hello メッセージ。クライアントはこれを受信後、Heartbeatを開始するとともにIdentifyPayloadを送信する。
  GATEWAY_MESSAGE_CODE_HELLO = 100;
  // Gatewayがクライアントに対して送信する Ready メッセージ。クライアントに対して認証が成功したことを通知する。
  GATEWAY_MESSAGE_CODE_READY = 101;
  // Gatewayがクライアントに対して送信する Resumed メッセージ。クライアントに対して再開が成功したことを通知する。
  GATEWAY_MESSAGE_CODE_RESUMED = 102;

  // 200-: ワークスペース関連

  // Gatewayがクライアントに対して送信する WorkspaceCreated メッセージ。ワークスペースが作成された / 復旧し利用可能になったことを通知する。
  GATEWAY_MESSAGE_CODE_WORKSPACE_CREATED = 200;
  // Gatewayがクライアントに対して送信する WorkspaceJoined メッセージ。ワークスペースに参加したことを通知する。
  GATEWAY_MESSAGE_CODE_WORKSPACE_JOINED = 201;

  ...
}

message GatewayMessage {
  // メッセージペイロード
  oneof payload {
    // 100-: 認証・接続関連

    // code = GatewayMessageCode.HELLO
    HelloPayload hello = 100;
    // code = GatewayMessageCode.READY
    ReadyPayload ready = 101;
    // code = GatewayMessageCode.RESUMED
    ResumedPayload resumed = 102;

    // 200-: ワークスペース関連

    // code = GatewayMessageCode.WORKSPACE_CREATED
    WorkspaceCreatedPayload workspace_created = 200;
    // code = GatewayMessageCode.WORKSPACE_JOINED
    WorkspaceJoinedPayload workspace_joined = 201;

    ...
}

また、Gatewayサーバはクライアントの認証時にNATSトピックのSubscribeと、ユーザが関連するEtcdのキーのWatchを開始します。
これによって、NATSのトピックへPublishされたメッセージをクライアントへ通知したり、Etcdのキーの変更を検知してクライアントへのメッセージ送信を行っています。

Voiceサーバ

Voiceサーバは音声通話中に接続するサーバです。
クライアントの実際の音声通話はWebRTCによって行われますが、WebRTCだけではどのユーザがどのルームに接続しているかの全体像を把握できません。
そこで、クライアントはWebRTCに接続している間Voiceサーバに接続することとし、Voiceサーバとのコネクションが確立されている=音声通話への参加が継続しているとみなしています。

Voiceサーバへのコネクション確立時、VoiceサーバはユーザのセッションおよびVoiceサーバへのマイクやスピーカーのミュートなどの状態をEtcdに保存します。
これらの状態の変化はEtcdのWatch APIによってGatewayが変更を検知し、ボイスチャットに参加したことが同じワークスペースに存在するユーザへ通知されます。
このフローによって、クライアントはユーザがボイスチャットに参加したことやマイクの状態が変わったことなどをUIへ反映します。

さいごに

一年の振り返りとして、今年やってきたアーキテクチャ設計や技術選定などについてまとめてみました。
似たようなものを実装しているサービスはなかなかなく、参考実装や資料を参考にするのにかなり苦戦しました。

説明ばかりで参考にならん、という話もあると思うのでより詳細な実装の解説などは引き続き記事を書いていこうと思うので、是非フォローをよろしくお願いします。

また、PortalKeyではチーム開発にフォーカスしたボイスチャットツールを開発中です。
こちらのツール自体や、ツールの開発に興味があるよ、という方も是非フォローをお願いします。

話を聞いてみたいなどあればTwitterやmixi2などで気軽にお声がけください。

https://x.com/wakaba260yen

https://mixi.social/invitations/@wakaba260/7w1xu5fvYeGeXuanvph6SU

ここまでお読みくださりありがとうございました。
それではまた。

PortalKey Tech Blog

Discussion