HireRoo TechBlog

HireRooの技術ブログ

オンラインIDEをイチから作ってみたお話

こんにちは!株式会社ハイヤールー代表葛岡(@kkosukeee)です。

前回の記事では弊社エンジニア伊藤(@icchy_san)が『技術特化選考を支える基盤を俯瞰して見る』というお題で技術特化選考の設計や仕組みなどを執筆しました。本記事では前記事でカバーできなかった技術特化型選考に必須である、オンラインIDEの開発・設計について執筆いたします。

対話型シェルやオンラインIDEの設計構築に関する知識はあまり発信されていないため、私達自身構築に少し苦労しましたが、本記事が少しでもオンラインIDEの開発や設計の参考になれば幸いです!

技術特化型コーディング試験とは

アルゴリズム形式の選考と異なり、一週間程度まとまった時間内に指定されたフレームワークを使ってサーバーを構築するといった問題形式の選考です。日本企業では自社でコーディング試験を導入する際によく用いられる方式で、HireRooでも機能としてサポートしています。対話型のシェルと、オンラインIDEが用意されており、候補者様の方はHireRooが提供する環境内で問題を解きます。

f:id:KKosukeee:20210504162055p:plain
図1:オンラインIDEを使用してサーバー構築などのお題を解く形式

技術特化型の問題は実際に業務で使うようなシチュエーションを問題として出題できるため、採用後のパフォーマンスと期待値のギャップを減らす目的で実際に導入企業様に使っていただいています。問題は弊社が全て用意しているため、企業様は開発コストゼロでコーディング試験を導入いただけます(頂いた要望を元に順次問題を追加しています)。

候補者視点からみたプロジェクト形式のコーディング試験に関してはこちらの記事で説明されているので、どんな機能かもしご興味があれば是非ご一読下さい。

プロジェクトサービス

前回の記事にあったように、候補者様ごとに1つのGoogle Compute Engine(以下GCE)のインスタンスを開発環境として割り当てています。割り当てられたインスタンスの中には、各問題に必要な環境が事前にインストールされているので候補者様は環境の用意が不要ですぐに試験を開始することができます。

f:id:KKosukeee:20210504162228p:plain
図2:1人1インスタンス(赤枠)が開発用に割り当てられる

実際には候補者様がオンラインIDEを通してファイルの作成や編集、必要なパッケージのインストールなどをターミナルを通して行うことにより開発を進めます。これを実現するのに欠かせない機能の一つとしてオンラインIDEがあります。この記事を読んでいる方はおそらくお気に入りのIDEがあると思いますが、ローカルのIDE同様コードを書いたりターミナルにアクセスすることがインターネット経由でできるものがオンラインIDEとなります。

ファイル同期(クライアント → GCEインスタンス)

オンラインIDEは割り当てられたGCEインスタンスとクライアントアプリケーションを繋ぎ、クライアント側での入力を即座にGCEインスタンス上に反映させることで実現しています。具体的にはファイルの編集やターミナルの入力など、全てのアクションはWebsocket経由でGCEインスタンスに送られ、各GCEインスタンスはそれらのアクションを解釈し必要なオペレーションを行います。

f:id:KKosukeee:20210504162312p:plain
図3:エージェントサーバーとWebSocketプロキシの会話

クライアントからWebsocket経由で送られるメッセージはそのままではGCEインスタンスに何も反映されません。正しくクライアントの動作を反映させるには上図のようにエージェントサーバーと呼ばれるGo言語で実装された軽量サーバーが動作している必要があります。「UIを通してファイルを作成する」ということを例として説明するとエージェントサーバーはGCEインスタンス上の指定されたパスにファイルを作成することで応答し、常にクライアントのStateとリモートのStateを同期しています。

ロジックは非常に簡単で、以下のようにWebsocketのメッセージには Type フィールドが含まれています。エージェントサーバーはこの Type を元に条件分岐し、必要なオペレーションをGCEインスタンス上で行います。

switch msg.Type {
case "REMOVE":
    // クライアント側で削除が行われた場合、GCE上からも削除する
    if err := os.Remove(path); err != nil {
        s.logger.Errorf("failed to remove a file %s", path, zap.Error(err))
        return err
    }
case "ADD", "UPDATE":
    // クライアント側で作成・更新が行われた際にはファイルを上書きする
    if err := ioutil.WriteFile(path, []byte(msg.Body), 0744); err != nil {
        s.logger.Errorf("failed to write to a file %s", path, zap.Error(err))
        return err
    }
}

このようにしてクライアントアプリケーションとリモートインスタンスは同期を取っており、逆(GCEインスタンス内に何らかの理由でファイルが作られた時など)のパターンも同様で、リモートでの変更を検知(詳細は後述)しWebsocketを使うことで瞬時に双方向通信が実現されています。

ファイル同期(GCEインスタンス → クライアント)

リモートでの変更はエージェントサーバーにて検知され、即座にWebsocket経由で送信されクライアントのStateに反映されます。具体的にはリモート上で新たにファイルが作成・編集・削除された時ファイルを監視しているエージェントサーバーは即座に検知し、Websocketのメッセージを作成しクライアントに送信することでStateを同期しています。

ファイルの監視にはfsnotifyを使用しており、環境変数で指定された監視対象ディレクトリをインスタンスが起動中は常にGo-routine内で監視しています。

// fsnotifyを用いてファイルの監視を行い、クライアントに同期する前の処理
switch {
case event.Op&fsnotify.Write == fsnotify.Write:
    // ファイル更新用のWebSocket Messageの作成処理
    msg.Type = "UPDATE"
    msg.Body = readFile(event.Name)
case event.Op&fsnotify.Create == fsnotify.Create:
    // ファイル作成用のWebSocket Messageの作成処理
    msg.Type = "ADD"
    msg.Body = readFile(event.Name)
case event.Op&fsnotify.Remove == fsnotify.Remove, event.Op&fsnotify.Rename == fsnotify.Rename:
    // ファイル削除用のWebSocket Messageの作成処理
    // 削除のためファイルのコンテンツをロードする必要なし
    msg.Type = "REMOVE"
}

工夫としては、変更のあったファイルを一つづつクライアント側に送ると即座にReactのStateが更新され、不必要に再レンダリングが走りパフォーマンスが悪くなるため、自作のメッセージキューである Queue に一定数のメッセージをいれバッチで同期しています。これによりReactの側のStateの更新が一度だけになり、必要以上に計算量を使いません。さらにメッセージの数に関わらず数秒に一度同期を行いキューの中にあるメッセージをデキューすることにより少数のメッセージも時差なく同期することができます。

対話型シェル(双方向)

ターミナル部分に関してはpty(疑似ターミナル)を使用して実現しており、プロジェクト開始時に exec.Command(“bash”) を走らせ、Go-routine内で tty ファイル(ttyの説明については割愛しますのでこちらをご覧ください)を監視し、出力があった際にはクライアントにWebsocketで送信しています。

// WebSocketのコネクションを張る際に `bash` コマンドを走る
cmd := exec.Command(“bash”)

// 疑似ターミナルに必要なttyファイル( `os.File` )の読み込み
tty, err := pty.Start(cmd)
defer tty.Close()

// 中略...

eg, ectx := errgroup.WithContext(ctx)
// os.ReadRuneでターミナルに出力があった際即座にClientに結果を送信
// Clientに出力を送信するときは `conn.WriteMessage` で送信する
eg.Go(s.outputWriter(ectx, tty, conn))

// conn.ReadJSONでClientからの入力を即座にtty(os.File)に書き込み
// tty.Write()で書き込むと、結果がoutputWriterに出力される仕組み
eg.Go(s.inputReader(ectx, tty, conn))

上述のようにGo-routine内で tty ファイルを読むことで出力があった際に即座にクライアントと同期することが可能です。クライアント側はターミナルっぽいUI(xterm.jsを使用しています。)でWebsocket経由で送られてくるメッセージを表示することによってあたかも同期しているかのように見せることができます。

このようにエージェントサーバーを経由しクライアントからWebsocketでメッセージを送ることでHireRooのオンラインIDEは実現されています。エージェントサーバーをさらに拡張するとGithubとの連携や、GCPとの連携など様々なことができるので可能性が広がります。

まとめ

最後まで読んでいただきありがとうございました。オンラインIDEの開発は一見難しそうに見えますがやっていることは至ってシンプルです(実際にフロント含め2週間ほどで完成しました)。鍵となるコンポネントはWebsocketとエージェントサーバーで、これらを正しく使うと様々なことができます。

オンラインIDEはサービスと垂直統合ができるため、サービス独自の機能を盛り込めます(たとえば異常検知など)。さらにローカルのIDEでは難しいコードの共有などが用意になる側面があるため、工夫次第では非常に有用なツールだと思っています。

チャレンジ・コンパイルに続き(記事はこちら)オンラインIDEの開発も情報量の少ない領域ではありましたが、無事にリリースすることができました。本記事が少しでもオンラインIDEを開発しようとしている方の参考になると幸いです。

最後に『HireRoo(ハイヤールー)』では優秀なエンジニアに囲まれて一緒にエンジニア採用の課題を解くプロダクトを開発する仲間を絶賛募集中です。エンジニアメンバーには元メルカリ、ディー・エヌ・エー、Retty、レバレジーズや現役ディー・エヌ・エー、ZOZOテクのエンジニアなど、粒ぞろいのエンジニアばかりです。挑戦できる環境で開発を経験したい方や少しでも興味を盛った方は是非以下のリンク、もしくはTwitterのDMからご連絡下さい。

www.wantedly.com

これからもエンジニアのスキルが正当に評価される社会を実現するため、社外への透明性を意識しどしどし情報を発信していきます。それではまた!