
把后端从上传路径里拿掉
GeekBye 录下你的屏幕,并把视频存进你的 Google Drive。第一个版本在通往那里的路上,把每一段录制都经由 GeekBye 自己的服务器送过去;一次发布之后,文件从你的机器直奔 Drive,后端被降级成只握着一个指针。有意思的地方在于,那个「直接、可续传」的版本实际上包含的代码有多么少——因为可续传性来自删掉一个代理,而不是来自写一个。
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,并以一秒的 timeslice(CHUNK_INTERVAL_MS = 1000)启动它。每一个 ondataavailable blob 都经由 IPC 送到主进程,在那里 recording:save-chunk 处理器用 fs.promises.appendFile 把它追加到磁盘上的一个文件里。值得早早标出来,因为这是一个容易读错的东西:那一秒的节拍是_写磁盘_的节奏,不是一个上传 chunk 大小。等到上传开始时,磁盘上坐着的是一个单独的、完成了的 WebM 文件。
两次发布之间不同的,只是那个完成的文件会遭遇什么。
之前:一切都经过后端 (v1.8.11)
v1.8.11 的上传路径,在 CloudUploader.processRecording() 里,是那个显而易见的设计,也是一个完全合理的第一版:
POST /api/geekbye/recordings用录制的元数据创建一条后端记录。- 把整段视频读进内存,作为 multipart POST 给后端——
const fileBuffer = fs.readFileSync(filePath)——打到POST /api/geekbye/recordings/${id}/upload。后端接着把那个文件上传到 Drive 并返回一个driveUrl。 POST …/process把 transcript 送去做 AI 分析。
那个承重的细节是第 2 步:客户端把整段录制读进内存,而它的每一个字节都在通往 Drive 的路上过境 GeekBye 自己的服务器。 对一小段短片来说那没问题。对一段长的屏幕录制来说,那是一次三个问题:readFileSync 给客户端带来的内存压力、后端为一个它甚至都不保留的文件付出的带宽和中转成本,以及一个单独的、一体化的 POST——一旦网络中途打个嗝,它就得_从零重启_。
也值得诚实地说,这个「之前」并不是什么纯净的原初设计。v1.8.11 这个功能把一次周内的转向压扁到了一起:早期的一版通过预签名 URL 上传到 Cloudflare R2,并用一个内置的 ffmpeg-static 把 WebM 转成 MP4;那被后端中介的 Drive 流程替换掉;然后 ffmpeg 转换被整个拔掉,改为原样上传 WebM(commit 记道,这降低了 bitrate 并产出大约小四倍的文件——一个行内的估计,不是一次基准测试)。所以连「之前」都已经学会过一次删掉一个原生依赖。下一次发布会把代理也删掉。
之后:直奔 Drive (v1.8.13)
v1.8.13 围绕着发布它的那个 commit 里的一句话,重写了 CloudUploader.processRecording():直接上传到 Drive,"keeping backend for metadata record + AI transcript analysis only." 新的路径:
POST /api/geekbye/recordings——只有元数据(标题、时长、文件大小、format: 'webm')。代码里的注释很直白:// Create backend record (metadata only — no file upload)。- 把视频直接上传到 Google Drive,整个跳过后端这个中间人。
PATCH /api/geekbye/recordings/${backendRecordingId},带上得出的{ driveUrl, driveFolderId }——那条后端记录_被告知_文件最终落在了哪里。- transcript 仍然去
…/process做分析。
后端从处在文件路径_之中_,变成事后_被告知_关于文件的信息。它握着一个行和一个指针;字节只活在用户的磁盘里和用户的 Drive 里。
成为一个真正的 Drive 客户端
要让客户端直接跟 Drive 对话,它就得是一个真正的 Google Drive API 客户端,而 v1.8.13 为此加进了机件:一个新的 DriveService(那个 Drive 客户端)、一个由新的 drive_auth SQLite 表撑起来的新的 DriveAuthRepository,以及作为依赖的 googleapis SDK。
授权是一次干净的能力移交。后端的 app-config 端点把 Google OAuth 客户端凭据——一个 clientId 和一个 clientSecret——中转下发给 app,CloudUploader 读取它并传给 driveService.initialize(...)。从那里开始,桌面 app 自己跑整个 OAuth 流程:它在一个随机的 127.0.0.1 端口上立起一个临时的 http 服务器,用 shell.openExternal 在系统浏览器里打开同意界面,在一个 /callback 上接住重定向,并用 google-auth-library 把 code 换成 token。那些 token 被本地保存——saveDriveAuth(access_token, refresh_token, expiry_date) 存进 drive_auth——带着 drive.file scope,它让 app 只能管理它自己创建的文件。刷新也是本地的:一个 oauth2Client.on('tokens', …) 监听器把刷新后的 token 直接写回表里。后端把能力移交一次,然后就待在这个循环之外。
「可续传」那部分,老实说
这就是我最想说清楚的那个细节,因为它恰恰是 changelog 会四舍五入掉的那种东西。发布说明说 "resumable uploads,",从用户站的位置看那是真的。但 GeekBye 并没有实现那个可续传协议。GeekBye 的源码里,任何地方都没有 chunk 大小常量、没有字节偏移追踪器,也没有 Content-Range / 308 Resume Incomplete 的处理。你去 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 是一个 stream(createReadStream),不是一个 buffer——而传一个 stream 恰恰就是让 googleapis 替你去协商一个可续传会话、而不是一枪打完的那个东西。第二,进度是从 SDK 的 onUploadProgress 回调里、对着 fileStats.size 读出来的。那就是应用层面上整个「可续传上传」的实现:stream 进去,进度出来。那个难的协议——开一个会话、分块上传、断开后从一个偏移续传——是 SDK 的活儿,而正确的工程决定,是让它去干那份活儿,而不是重新发明它。
这就把整次发布重新框定了。可靠性的改进不是来自写一个上传引擎。它来自_删掉_那个坐在引擎前面的代理——以及,同一周早些时候,删掉坐在_那个_前面的 ffmpeg 步骤。更少的代码,更多的韧性,因为那个有韧性的东西,在你停止用手给它喂 buffer 的那一刻,是一个久经使用的库。
那些真正属于 GeekBye 的部分
把传输交给 SDK,并不意味着没剩下什么可造的了。有两块真正的应用逻辑围绕着它。
一个孤儿文件夹回滚。 每段录制都拿到它自己的 Drive 子文件夹(用 mimeType: 'application/vnd.google-apps.folder' 创建),而 transcript 作为视频旁边的第二个文件落在那里。如果上传抛出,那个 catch 会删掉那个文件夹——this.drive.files.delete({ fileId: folderId }),记为 Rolled back orphaned Drive folder——好让一次失败的上传不在你的 Drive 里留下一串空文件夹。在你知道操作会成功之前就创建一个容器,是一笔小小的负债;让失败路径把它清理掉,就是那个修复。
围绕一个单例的生命周期。 DriveService 是一个握着一个内存里 OAuth 客户端的单例,而那个内存里的客户端熬不过一次 app 重启,尽管 token 熬得过(它们在数据库里)。所以 CloudUploader _在每次上传前_从存储的 token 重新初始化 DriveService,这意味着重启之后立刻做的一段录制,仍然会上传,而不用要求你重新连接。而 review 那一遍把 drive-connect 处理器变成了 fire-and-forget,因为一个阻塞的 connect 调用正卡住渲染进程的状态轮询——如果 connect 调用握着线程,UI 就没法显示「连接中…」。这两个——从存储重新初始化,和不阻塞的 connect——是直接路径真正的边界情况,而值得注意的是,它们俩都不是关于上传分块的。当你把那个难的部分委托出去,剩给你的 bug 就是生命周期 bug。
这次发布教给我们的三件事
- 去问字节流向哪里,而不只是它能不能用。 v1.8.11 那个代理能用。但把每一段录制都经由你自己的服务器路由,为一个你并不保留的文件,付出了带宽、内存和可重启性。重画数据路径、让客户端直接跟目的地对话,往往就是整个优化。
- 最好的可续传上传,是你没有写的那个。 把一个 stream 传给一个成熟的 SDK,用两行代码就买来了一个完整的可续传会话。那种手搓分块和偏移算术的冲动,本会产出更多的代码和更少的可靠性。
- 当你委托核心,你继承的是生命周期,而不是算法。 这个直接客户端真正的 bug 是「重启后重新初始化那个单例」和「别阻塞那个 connect 调用」,不是字节偏移。那是一笔好交易——生命周期 bug 是可见的、局部的;协议 bug 两样都不是。
关于 v1 故事的上一章,见十二分钟里诞生,又被修好(v1.8.10);整段弧线则见把软件打磨到完美地发布出去的解剖学。