Steven
Steven2 分で読める

アップロード経路からバックエンドを取り除く

GeekByeはあなたの画面を録画し、その動画をあなたのGoogle Driveへ保存します。最初のバージョンは、そこへ至る途中で、すべての録画をGeekBye自身のサーバーを通して送っていました; ひとつリリースを経て、ファイルはあなたのマシンからDriveへ直行するようになり、バックエンドはただ一つのポインターを保持するだけに格下げされました。面白いのは、その「直接で、再開可能な」バージョンが実際にはどれほど少ないコードしか含んでいないか、です — なぜなら再開可能性は、プロキシを書くことからではなく、それを削除することから来たからです。

エンジニアリング
アーキテクチャ
デスクトップ
GeekByeリリース
アップロード経路からバックエンドを取り除く

GeekByeはあなたの画面 — 映像、システム音声、そしてマイク — を録画し、仕上がった録画をあなたのGoogle Driveへ自動で落とし込めます。v1.8.11とv1.8.13の間で、この機能のユーザー向けの説明はほとんど変わりませんでしたが、動画がDriveへ到達するためにたどる経路はまるごと付け替えられました。最初のバージョンでは、あなたの録画はGeekByeのサーバーを通って旅していました。二つ目では、それはあなたのマシンからあなたのDriveへ直行し、GeekByeのバックエンドはそのバイトを一つも見ませんでした。その付け替えが物語のすべてで、そして満足のいくところは、その「より良い」バージョンが、それが置き換えたものよりも見るからに_少ない_コードしか含んでいないことです。

パイプライン、端から端まで

録画はレンダラーで起こります。ScreenRecordingService.tsはキャプチャストリームの上に{ mimeType: 'video/webm;codecs=vp9,opus', videoBitsPerSecond: 1_000_000 }MediaRecorderを作り、一秒のタイムスライス(CHUNK_INTERVAL_MS = 1000)でそれを開始します。あらゆるondataavailableのblobはIPC越しにメインプロセスへ送られ、そこでrecording:save-chunkハンドラーがfs.promises.appendFileでそれをディスク上のファイルへ追記します。早めに旗を立てておく価値があります、なぜならそれは誤読しやすい類のものだからです: その一秒の拍子は_ディスク書き込み_のリズムであって、アップロードのチャンクサイズではありません。アップロードが始まる頃には、ディスク上には仕上がったWebMファイルが一つ座っています。

二つのリリースの間で異なるのは、その仕上がったファイルに何が起こるか、それだけです。

以前: すべてがバックエンドを通る (v1.8.11)

v1.8.11のアップロード経路は、CloudUploader.processRecording()の中にあり、当たり前の設計であり、まったく妥当な最初のバージョンです:

  1. POST /api/geekbye/recordingsで、録画のメタデータを持つバックエンドレコードを作る。
  2. 動画まるごとをメモリへ読み込み、それをmultipartとしてバックエンドへPOSTする — const fileBuffer = fs.readFileSync(filePath)POST /api/geekbye/recordings/${id}/uploadを叩きながら。それからバックエンドがそのファイルをDriveへアップロードし、driveUrlを返す。
  3. POST …/processで、AI分析のためにトランスクリプトを送る。

荷重を担う細部はステップ2です: クライアントは録画まるごとをメモリへ読み込み、そのあらゆるバイトがDriveへ至る途中でGeekBye自身のサーバーを通過します。 短いクリップならそれで問題ありません。長い画面録画では、それは一度に三つの問題です — readFileSyncによるクライアントへのメモリ圧、保持すらしないファイルのためのバックエンドの帯域と中継コスト、そして途中でネットワークがしゃっくりを起こしたら_ゼロからやり直す_しかない単一の一枚岩なPOSTです。

この「以前」が何か純白の元設計だったわけではない、と正直であることにも価値があります。v1.8.11の機能は週内のピボットを一つに押し込めていました: 早いカットは事前署名URL経由でCloudflare R2へアップロードし、同梱のffmpeg-staticでWebMをMP4へ変換していました; それはバックエンド仲介のDriveフローに置き換えられ; そしてそれからffmpegの変換は、WebMをそのままアップロードすることを選んでまるごと引っこ抜かれました(コミットは、それがビットレートを下げ、おおよそ四倍小さいファイルを生んだと記しています — ベンチマークではなく、行内の見積もりです)。ですから「以前」ですら、すでに一度、ネイティブ依存を削除することを学んでいたのです。次のリリースは、プロキシも削除することになります。

以後: Driveへ直行 (v1.8.13)

v1.8.13は、それを出荷したコミットの一文を軸にCloudUploader.processRecording()を書き直します: Driveへ直接アップロードし、"keeping backend for metadata record + AI transcript analysis only." 新しい経路:

  1. POST /api/geekbye/recordingsメタデータのみ(タイトル、長さ、ファイルサイズ、format: 'webm')。コード内のコメントは無愛想です: // Create backend record (metadata only — no file upload)
  2. 動画をGoogle Driveへ直接アップロードし、バックエンドの仲介者をまるごと飛ばす。
  3. PATCH /api/geekbye/recordings/${backendRecordingId}を、結果の{ driveUrl, driveFolderId }とともに — バックエンドレコードは、ファイルがどこに落ち着いたかを_教えられます_。
  4. トランスクリプトは依然として分析のために…/processへ行きます。

バックエンドは、ファイルの経路の_中に_いる立場から、事後にファイルについて_教えられる_立場へと移りました。それは行とポインターを保持します; バイトはユーザーのディスクとユーザーのDriveの中にだけ生きています。

本物のDriveクライアントになる

クライアントがDriveと直接話すためには、それは実際のGoogle Drive APIクライアントでなければならず、v1.8.13はそのための機構を加えます: 新しいDriveService(Driveクライアント)、新しいdrive_auth SQLiteテーブルに支えられた新しいDriveAuthRepository、そして依存としてのgoogleapis SDKです。

認可はきれいな権能の受け渡しです。バックエンドのapp-configエンドポイントは、Google OAuthのクライアント資格情報clientIdclientSecret — をアプリへ中継し、CloudUploaderがそれを読んでdriveService.initialize(...)へ渡します。そこからデスクトップアプリはOAuthフロー全体を自分で走らせます: ランダムな127.0.0.1のポートに一時的なhttpサーバーを立ち上げ、shell.openExternalでシステムブラウザに同意画面を開き、/callbackでリダイレクトを捕まえ、google-auth-libraryでコードをトークンと交換します。それらのトークンはローカルに保存されます — drive_authsaveDriveAuth(access_token, refresh_token, expiry_date)drive.fileスコープ付きで、それはアプリが自ら作ったファイルだけを管理できるようにします。リフレッシュもローカルです: oauth2Client.on('tokens', …)リスナーがリフレッシュされたトークンをテーブルへ直接書き戻します。バックエンドは権能を一度手渡し、それきりループの外に留まります。

「再開可能」の部分を、正直に

これが、私が最も率直でありたい細部です、なぜならそれはまさにchangelogが丸めてしまう類のものだからです。リリースノートは "resumable uploads," と言い、ユーザーの立ち位置からはそれは本当です。しかしGeekByeは再開可能プロトコルを実装しませんでした。チャンクサイズの定数もなく、バイトオフセットのトラッカーもなく、Content-Range / 308 Resume Incompleteの処理もGeekByeのソースのどこにもありません。それらをgrepしても、何も出てきません — それらはgoogleapis SDKの中に住んでいるのです。

GeekBye自身のコードがすることは、これです:

const fileStats = statSync(videoFilePath)
const videoStream = createReadStream(videoFilePath)

const videoRes = await this.drive.files.create(
  {
    requestBody: { name: `${title}.webm`, parents: [folderId] },
    media: { mimeType: 'video/webm', body: videoStream },
    fields: 'id',
  },
  {
    onUploadProgress: (evt) => {
      const percent = Math.round((evt.bytesRead / fileStats.size) * 100)
      onProgress(percent)
    },
  },
)

二つの選択がすべての仕事をしています。第一に、media.bodyはバッファではなくストリーム(createReadStream)です — そしてストリームを渡すことこそが、googleapisにワンショットをする代わりにあなたに代わって再開可能なセッションを交渉させるものです。第二に、進捗はSDKのonUploadProgressコールバックからfileStats.sizeに対して読み取られます。それがアプリケーションレベルでの「再開可能なアップロード」実装のすべてです: ストリームを入れ、進捗を出す。難しいプロトコル — セッションを開き、チャンクでアップロードし、切断のあとオフセットから再開する — はSDKの仕事であり、正しいエンジニアリング上の決定は、それを再発明するのではなく、その仕事をSDKにさせることでした。

それがリリース全体の見方を組み替えます。信頼性の改善は、アップロードエンジンを書くことからは来ませんでした。それは、その前に座っていたプロキシを_削除する_ことから来ました — そして、同じ週の先には、_その_前にあったffmpegのステップを削除することから。より少ないコード、より多くの回復力です、なぜなら回復力のあるものは、あなたがバッファを手で食わせるのをやめた瞬間の、使い込まれたライブラリだったからです。

本当にGeekByeのものである部分

転送をSDKに手渡すことは、作るべきものが何も残っていなかった、という意味ではありません。二つの本物のアプリケーションロジックの部品が、それを取り囲んでいます。

孤児フォルダのロールバック。 各録画は自分専用のDriveサブフォルダを得(mimeType: 'application/vnd.google-apps.folder'で作られます)、トランスクリプトは動画の隣に二つ目のファイルとしてそこに着地します。アップロードがthrowすると、catchがそのフォルダを削除します — this.drive.files.delete({ fileId: folderId })Rolled back orphaned Drive folderとしてログされます — ので、失敗したアップロードがあなたのDriveに空のフォルダの痕跡を残すことはありません。操作が成功すると分かる前にコンテナを作るのは小さな負債です; 失敗の経路にそれを片づけさせることが、その修正です。

シングルトンをめぐるライフサイクル。 DriveServiceはメモリ上のOAuthクライアントを保持するシングルトンであり、そのメモリ上のクライアントは、トークンが生き延びる(データベースにある)のに対し、アプリ再起動を生き延びません。ですからCloudUploaderは_毎回のアップロードの前に_保存されたトークンからDriveServiceを再初期化します、それはつまり、再起動の直後に作られた録画でも、あなたに再接続を求めることなく依然としてアップロードされる、ということです。そしてレビューのひと回しはdrive-connectハンドラーをfire-and-forgetに変えました、なぜならブロッキングなconnect呼び出しがレンダラーのステータスポーリングを失速させていたからです — connect呼び出しがスレッドを握っていたら、UIは「接続中…」を見せられなかったのです。その二つ — ストレージからの再初期化と、ブロックしないconnect — が直接経路の本当のエッジケースであり、注目すべきことに、そのどちらもアップロードのチャンク分割についてではありません。難しい部分を委ねると、あなたに残るバグはライフサイクルのバグなのです。

このリリースが教えてくれた三つのこと

  1. 動くかどうかだけでなく、バイトがどこを流れるかを問え。 v1.8.11のプロキシは動きました。しかしあらゆる録画を自前のサーバーを通して送ることは、保持しないファイルのために、帯域、メモリ、そして再開可能性をあなたに支払わせます。クライアントが宛先と直接話すようにデータ経路を描き直すことが、しばしば最適化のすべてです。
  2. 最良の再開可能なアップロードは、あなたが書かなかったものだ。 成熟したSDKへストリームを渡すことが、二行のコードで完全な再開可能セッションを買いました。チャンク分割とオフセットの計算を手作りしたいという本能は、より多くのコードとより少ない信頼性を生んでいたでしょう。
  3. コアを委ねると、アルゴリズムではなくライフサイクルを受け継ぐ。 直接クライアントの本当のバグは「再起動のあとシングルトンを再初期化する」と「connect呼び出しをブロックしない」であって、バイトオフセットではありませんでした。それは良いトレードです — ライフサイクルのバグは目に見えてローカルです; プロトコルのバグはそのどちらでもありません。

v1の物語の前の章については、十二分で生まれ、そして直された(v1.8.10)を; そして弧の全体については、ソフトウェアを完璧に出荷することの解剖学をご覧ください。

関連記事

Web Audioを生かし続ける三つの動詞
Steven
Steven2 分で読める

Web Audioを生かし続ける三つの動詞

二ヶ月を隔てて別々のファイルで出た二つのGeekByeポイントリリースが、私たちのオーディオコードに同じ教訓を正反対の端から教えました: ブラウザのAudioContextを使い捨てとして扱うのをやめよ、と。一方のリリースは、macOSが録画中にこっそりsuspendしたコンテキストをresume()することを学びました; もう一方は、連続するセッションがChromiumのおよそ六コンテキストの上限に激突するのをやめさせるべく、close()する代わりにsuspend()することを学びました。resume、suspend、close — それが筋の全部です。

エンジニアリング
Audio
デスクトップ
開いているアプリと通話中を見分ける
Steven
Steven2 分で読める

開いているアプリと通話中を見分ける

GeekByeは、あなたがビデオ会議に参加したことに気づき、ワンクリックでの録画を差し出せます。検出のほうは、実のところ簡単な半分でした — 10秒ごとにウィンドウタイトルを読むSwiftバイナリです。難しい半分は精度です: Zoomがただ開いているだけのときに発火しないこと、すでに録画している会議についてうながさないこと、そしてあなたが実際にいる通話のマイクをミュートしないこと。三つのリリース、そのどれもが、自分自身を打ち負かさないことを学ばねばならなかったガードなのです。

エンジニアリング
macOS
デスクトップ
十二分で生まれ、そして直された
Steven
Steven2 分で読める

十二分で生まれ、そして直された

GeekBye v1.8.10のchangelogは、キーボードショートカットの編集中のクラッシュを直したと言います。直したのは本当です — けれどそのクラッシュは同じプルリクエストの中で、十二分の間を置いて、導入され、そして直され、ただの一人のユーザーにも届きませんでした。本当の物語は、それを生んだ信頼性の連鎖です: ショートカットをいつ一時停止するかへの、小さく正しい変更と、エディターをより安全にするための一行から転がり落ちたReactのティアダウンのバグ。

エンジニアリング
React
デスクトップ