HireRoo TechBlog

HireRooの技術ブログ

オンラインIDEの編集過程を再現する

こんにちは!株式会社ハイヤールーの新谷(@s__shintani)です。

HireRooでは全ての問題形式において候補者が問題を解く過程を再現することのできるプレイバック機能が搭載されています。本記事ではその中でも技術特化形式のプレイバック機能を実現する上で用いた技術とその設計について執筆いたします。

技術特化形式で使用するオンラインIDEではエディタ上でファイルを編集するだけでなく、ターミナル上でコマンドを実行することでもファイルに変更を加えることもできます。そのため両方の編集過程を正確に再現するためにやや複雑な実装となっていますが、技術的にも非常に面白い内容となっておりますので是非ご一読ください。

プレイバック機能とは?

プレイバック機能とは候補者が問題を解いた過程を全て再現するもので、思考過程の分析やコピペ検知に役立てることができます。HireRooでは技術特化形式を含む全ての問題形式においてプレイバック機能がご利用いただけます。

技術特化形式ではエディタ上部のタブを切り替えることで、ファイルごとにコーディングの過程を再生することができます。

動画1: 候補者のコーディングの過程が再生される様子

テキストの編集過程を再現する

テキストの編集過程を再現するためには、時間軸ごとにテキストのスナップショットを全て保存するか、文字の追加や削除といった操作単位で履歴を保存し、それらを組み合わせてテキストを復元するかの二つの選択肢が考えられます。前者はデータサイズが膨大となり現実的ではないので、私たちは後者の方法を選択しました。

OT(operational transform)

テキストを操作単位で保存するというアイデアは共同編集で用いられるOT(Operational Transform)と呼ばれるアルゴリズムから流用したものです。

二人のユーザーが同時に一つのテキストを編集する場合、ピアの変更を自身のファイルに取り込む必要がありますが、同じ箇所を編集すると処理の競合が起きてしまい、お互いのファイルの状態が乖離してしまうことが起こり得ます。こういった処理競合を解消するために、自身の操作とピアの操作をマージして適用するというのがOTのコンセプトです。

OTの詳細な実装については割愛しますが、私たちはアルゴリズム形式の共同編集機能を実現する上でOTを利用しており、技術特化形式のプレイバック機能を実装する際にも共同編集が可能な設計となるようにOTベースでテキスト操作を保存するようにしました。

テキスト操作の保存から復元まで

それでは実際にテキストの編集過程が操作単位で分割され、それがどのように復元されるのか具体的に見ていきましょう。

クライアント側ではエディタにリスナー関数を登録しておき、候補者がエディタ上で行った全てのテキスト操作を検知して、それらをFirebase Realtime Databaseに保存しています。

例えば src/App.tsxに対して変更を加える場合、nodes/src/App.tsx/history というパスに全ての操作が時系列順に格納されていきます。ヒストリーはauthor(編集者)、operation(操作内容)、timestamp(編集時刻)の3つの情報を持っています。

画像1: Firebase Realtime Databaseに保存されているヒストリーの構造

一つのoperationは文字列または数字の配列として表現されます。配列要素が文字列の場合はカーソル位置にその文字列を挿入し、負数の場合はカーソル位置から右側に向けてその数だけ文字列を削除し、正数の場合はその数だけカーソルを右に移動します。

例えば、「abc」というテキストに対して[2,e,-1]という操作が加わった場合、カーソルを右に2つ動かした位置に「e」を挿入した後、その位置から右側に1文字削除するため「c」が削除され、操作適用後のテキストは「abe」となります。

このテキスト操作を全てヒストリーとして保存しておけば、以下のような復元関数を繰り返し適用することで時間軸をずらしながら編集過程をコマ送りで再生することができます。

func composeTextOperation(prev string, operation []interface{}) string {
  var index int
  composed := []rune(prev)

  for _, o := range operation {
     switch textOp := o.(type) {
     case string:
        // addition
        inserted := []rune(textOp)
        composed = append(composed[:index], append(inserted, composed[index:]...)...)
        index += len(inserted)
     case float64:
        if textOp >= 0 {
           // retain
           chars := int(textOp)
           index += chars
        } else {
           // deletion
           chars := int(-textOp)
           composed = append(composed[:index], composed[(index+chars):]...)
        }
     }
  }
  return string(composed)
}

ファイル同期システムのリアーキテクチャ

全体像

技術特化形式では問題を解く上で必要な環境がインストールされた開発インスタンスが候補者ごとに割り当てられ、候補者はそのインスタンスの中で開発を進めていきます。リモートの開発インスタンス上のファイルとUI上で表示されるファイルを常に同期させる必要があり、その役割を担っているのが開発インスタンスのバックグラウンドで起動しているエージェントサーバーです。エージェントサーバーの概要についてはこちらの記事をご参照ください。

従来は一定間隔ごとにファイルのコンテンツをクライアントとサーバー間で直接やり取りすることでファイル同期を行っていました。今回プレイバック機能を実現する上で、クライアントとサーバーに加えFirebase Realtime Databaseとの3者間でファイルの状態を同期させる必要がありました。そこで既存のアーキテクチャを見直し、ファイルの変更についてはFirebaseを経由して同期し、ファイルの追加や削除といった操作は従来通りクライアントとサーバー間で直接同期する形を取りました。

図1: ファイル同期システムの全体像

当初のプランではファイルの変更も全てクライアントとサーバー間で直接やり取りすることを想定していましたが、実装が必要以上に複雑となってしまったためこのような設計に変更しました。ファイルの内容については常にFirebaseのデータがSingle Source of Truthとなるため、クライアントもエージェントサーバーも同じファイルに対して変更を加えるピアとして捉えることができ、結果として直感的でシンプルな設計になったかと思います。

ワークスペースの概要

続いて、クライアント、サーバー、Firebaseの3者間のファイル同期を実現する上で重要な役割を果たしているWorkspaceと呼ばれるモジュールについて解説します。

図2: Workspaceによるファイルの同期

エージェントサーバー起動時にバックグラウンドでWorkspaceと呼ばれるプロセスを起動します。このWorkspaceがファイル同期全体の責務を担っています。

クライアントはWebsocketでサーバーとのコネクションを確立する際に、Workspace内のHubと呼ばれるコネクションプールに自分自身を登録します。これによりWorkspaceはクライアントからWebsocket経由で送られてくるメッセージを受け取ることができ、また全てのクライアントに対してメッセージをブロードキャストしたり、あるいは特定のクライアントに対してのみメッセージを送信することができます。

Workspaceを起動するとその中でさらにRun、Syncの2つのプロセスが走り、加えてLocalとRemoteと呼ばれるサブモジュールのプロセスが起動します。以下詳しく見ていきます。

Run

Workspaceの中核を担うプロセスであり、イベントとして入ってくるクライアント、サーバー、Firebaseで生じた全ての変更を解釈し、LocalやRemoteなどに対して必要な処理を行うように指示するオーケストレーターとしての役目を果たします。

func (w *Workspace) runFn(ctx context.Context) func() error {
  return func() error {
     for {
        select {
        case <-ctx.Done():
           return ctx.Err()
           // Localからのイベントを受け取ってハンドリングする
        case event := <-w.local.SendCh:
           switch event.Type {
           case local.Add:
              w.handleLocalAddEvent(event)
           case local.Delete:
              w.handleLocalDeleteEvent(event)
           case local.Update:
              w.handleLocalUpdateEvent(event)
           }
           // Remoteからのイベントを受け取ってハンドリングする
        case event := <-w.remote.SendCh:
           switch event.Type {
           case remote.Update:
              w.handleRemoteUpdateEvent(event)
           }
        case msg := <-w.ReceiveCh:
           // クライアントからWebsocket経由で送られてくるメッセージを受け取ってハンドリングする
           switch msg.Method {
           case Initialize:
              if err := w.handleInitializeMsg(msg); err != nil {
                 continue
              }
           case FileDidOpen:
              if err := w.handleFileDidOpenMsg(msg); err != nil {
                 continue
              }
           case FileDidClose:
              if err := w.handleFileDidCloseMsg(msg); err != nil {
                 continue
              }
           case FileDidCreate:
              if err := w.handleFileDidCreateMsg(msg); err != nil {
                 continue
              }
      // (中略)
           }
        }
     }
  }
}

各ハンドラの中ではLocalやRemoteに対してイベントを送信するか、またはHubに登録されているクライアントに対してWebsocket経由でメッセージを送信します。

Sync

Firebase上の履歴とサーバー上のファイルに乖離が発生した場合に状態を収束させる役割を担うプロセスです。Syncの中でLocalとRemoteが現在のファイルの状態を交換し合い、乖離があった場合は同期させるために必要な処理がそれぞれのモジュール内部で行われます。

func (w *Workspace) syncFn(ctx context.Context) func() error {
  return func() error {
     var (
        remoteState remote.State
        localState  local.State
     )
     for {
        select {
        case <-ctx.Done():
           return ctx.Err()
           // Remoteから最新のファイルの状態を定期的に受け取る
        case remoteState = <-w.remote.SyncCh:
           if localState != nil {
              // Localに送信し、乖離があった場合はLocal内部で必要な処理が行われる
              w.syncLocalFromRemote(localState, remoteState)
           }
           // Localから最新のファイルの状態を定期的に受け取る
        case localState = <-w.local.SyncCh:
           if remoteState != nil {
              // Remoteに送信し、乖離があった場合はRemote内部で必要な処理が行われる
              w.syncRemoteFromLocal(localState, remoteState)
           }
        }
     }
  }
}

ファイルの中身についてはFirebaseのデータが正、ファイルツリーの構造についてはディスク上のデータが正として扱われます。例えば、既に存在しないファイルのヒストリーがFirebase上に残っている場合、Localから正しいファイルツリーの状態を受け取ったRemoteがFirebaseからそのパスを削除します。

Local

サーバー上のファイルに関する処理を担うモジュールです。サーバー上のファイルを監視し、変更があった場合はイベントを発火します。外部からイベントを受け取った際はその種類に応じて適切なファイル操作を実行します。

func (l *Local) receiveFn(ctx context.Context) func() error {
  return func() error {
     for {
        select {
        case <-ctx.Done():
           return ctx.Err()
        case event := <-l.ReceiveCh:
           switch event.Type {
           case Add:
              // ファイルを追加する操作
              if err := l.addFromEvent(event); err != nil {
                 continue
              }
           case Delete:
              // ファイルを削除する操作
              if err := l.deleteFromEvent(event); err != nil {
                 continue
              }
           case Update:
              // ファイルを書き換える操作
              if err := l.updateFromEvent(event); err != nil {
                 continue
              }
           }
        }
     }
  }
}

Remote

Firebaseに関する処理を担うモジュールです。ヒストリーが保存されているパスを一定間隔でポーリングし、差分があった場合はイベントを発火します。外部からイベントを受け取った際はその種類に応じて、ヒストリーを追加または削除します。

func (r *Remote) receiveFn(ctx context.Context) func() error {
  return func() error {
     for {
        select {
        case <-ctx.Done():
           return ctx.Err()
        case event := <-r.ReceiveCh:
           switch event.Type {
           case Add:
              // Firebaseの指定のパスにヒストリーを追加する
              if err := r.createHistoryFromEvent(ctx, event); err != nil {
                 continue
              }
           case Delete:
              // Firebaseから指定のパスのヒストリーを全て削除する
              if err := r.deleteHistoryFromEvent(ctx, event); err != nil {
                 continue
              }
           }
        }
     }
  }
}

ここで重要なのはサーバー上のファイル操作を担うLocalとFirebaseに関する処理を担うRemoteの責務が分割されている点です。モジュール同士のやり取りは全てchannelを経由して行われ、Runがオーケストレーターとして仲介することでLocalとRemoteが疎結合に保たれ、クリーンな設計となっています。

また万が一ファイルの同期に失敗して乖離が生じてしまった場合でも、候補者が試験を継続して受けられるようにファイルの状態を最終的に収束させるSyncのような仕組みを用意しておくことも今回のように実装が複雑になるケースでは重要ではないかと思います。

具体的な同期の流れ

最後に各モジュールが協調してどのようにファイルが同期されているかについて2つ例を挙げて見ていきます。

候補者がエディタ上で変更を加えた場合

候補者がエディタ上で加えた変更はFirebaseに送信され、Remoteがポーリングによってその変更を検知してイベントを発火します。イベントを受け取ったRunはLocalに対して変更のあったファイルを更新するようにイベントを送信します。Localはそれを受けて実際にそのファイルに対して書き込みを行い、変更内容がサーバー上に反映されます。

候補者がターミナルからファイルを削除した場合

ファイルの変更を監視しているLocalはファイルが削除されたことを検知してイベントを発火します。イベントを受け取ったRunはRemoteに対して該当のファイルのヒストリーを削除するようにイベントを送信します。Remoteはそれを受けてFirebaseから該当のパスを削除します。

それと同時に全てのクライアントに対して、そのファイルが削除されたというメッセージをWebsocket経由でブロードキャストします。メッセージを受け取ったクライアントはファイルツリーからそのファイルを削除してUIに反映されます。

まとめ

最後まで読んでいただきありがとうございます。本記事を読んでファイル同期システムやテキスト操作といったトピックに関して少しでも理解が深まったのであれば幸いです。

HireRooではオンラインIDEといったコアとなるシステムをスクラッチから実装しているのでプレイバックのような面白い機能も追加することができます。こういったチャレンジングな課題に私たちと一緒に挑戦してみたいという方がいたらぜひWantedlyの採用ページからご応募ください!