Steven
Steven8 мин четене

Изваждане на backend-а от пътя на качването

GeekBye записва екрана ти и запазва видеото в твоя Google Drive. Първата версия прекарваше всеки запис през собствените сървъри на GeekBye по пътя натам; едно издание по-късно, файлът тръгваше директно от машината ти към Drive, а backend-ът беше понижен до това да държи един-единствен указател. Интересната част е колко малко код всъщност съдържа „директната, resumable" версия — защото resumability дойде от изтриването на един proxy, не от написването на такъв.

Инженерство
Архитектура
Desktop
Издания на GeekBye
Изваждане на backend-а от пътя на качването

GeekBye може да записва екрана ти — видео, системно аудио и микрофон — и да пуска завършения запис в твоя Google Drive автоматично. Между v1.8.11 и v1.8.13 описанието на функцията, видимо за потребителя, почти не се промени, но пътят, който видеото изминава, за да стигне до Drive, беше пренасочен изцяло. В първата версия записът ти пътуваше през сървърите на GeekBye. Във втората тръгваше директно от машината ти към твоя Drive, а backend-ът на GeekBye никога не видя нито байт от него. Това пренасочване е цялата история, а удовлетворяващата част е, че „по-добрата" версия съдържа осезаемо по-малко код от тази, която замени.

Пайплайнът, от край до край

Записът се случва в renderer-а. ScreenRecordingService.ts създава MediaRecorder върху capture stream-а с { mimeType: 'video/webm;codecs=vp9,opus', videoBitsPerSecond: 1_000_000 } и го стартира с едносекунден timeslice (CHUNK_INTERVAL_MS = 1000). Всеки ondataavailable blob се изпраща през IPC към главния процес, където handler-ът recording:save-chunk го добавя към файл на диска с fs.promises.appendFile. Струва си да се отбележи рано, защото е нещо изкушаващо за погрешно тълкуване: тази едносекундна каденция е ритъмът на записа на диска, не размер на chunk за качване. Докато качването започне, на диска стои един-единствен завършен WebM файл.

Това, което се различава между двете издания, е само какво се случва с този завършен файл.

Преди: всичко през backend-а (v1.8.11)

Пътят на качване от v1.8.11, в CloudUploader.processRecording(), е очевидният дизайн и напълно разумна първа версия:

  1. POST /api/geekbye/recordings, за да се създаде backend запис с метаданните на записа.
  2. Прочитане на целия видеофайл в паметта и POST към backend-а като multipart — const fileBuffer = fs.readFileSync(filePath) — удряйки POST /api/geekbye/recordings/${id}/upload. Backend-ът после качва този файл в Drive и връща driveUrl.
  3. POST …/process, за да се изпрати транскриптът за AI анализ.

Носещият детайл е стъпка 2: клиентът зарежда целия запис в паметта и всеки негов байт минава през собствените сървъри на GeekBye по пътя към Drive. За кратко клипче това е добре. За дълъг екранен запис това са три проблема наведнъж — натиск върху паметта на клиента от readFileSync, разход на трафик и препредаване на backend-а за файл, който той дори не пази, и един монолитен POST, който трябва да рестартира от нула, ако мрежата хлъцне по средата.

Струва си също да сме честни, че това „преди" не беше някакъв безупречен оригинален дизайн. Функцията в v1.8.11 сплеска в едно един пивот в рамките на седмицата: ранна версия качваше в Cloudflare R2 през pre-signed URL-и и конвертираше WebM в MP4 с вграден ffmpeg-static; това беше заменено от Drive flow, посредничен от backend-а; и после ffmpeg конверсията беше изкоренена изцяло в полза на качване на WebM-а както е (commit-ът отбелязва, че свали bitrate-а и произведе приблизително четири пъти по-малки файлове — вградена оценка, не benchmark). Така че дори „преди"-то вече беше научило веднъж да изтрие нативна зависимост. Следващото издание щеше да изтрие и proxy-то.

След: директно в Drive (v1.8.13)

v1.8.13 пренаписва CloudUploader.processRecording() около едно изречение от commit-а, който го пусна: качвай директно в 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, прескачайки напълно backend посредника.
  3. PATCH /api/geekbye/recordings/${backendRecordingId} с получения { driveUrl, driveFolderId } — на backend записа му се казва къде е свършил файлът.
  4. Транскриптът все още отива към …/process за анализ.

Backend-ът премина от това да бъде в пътя на файла към това да му се казва за файла постфактум. Той държи ред и указател; байтовете живеят само на диска на потребителя и в Drive на потребителя.

Да станеш истински Drive клиент

За да говори клиентът директно с Drive, той трябва да е реален Google Drive API клиент, и v1.8.13 добавя механиката за това: нов DriveService (Drive клиентът), нов DriveAuthRepository, подкрепен от нова SQLite таблица drive_auth, и SDK-а googleapis като зависимост.

Оторизацията е чисто предаване на способност. app-config endpoint-ът на backend-а препредава Google OAuth клиентски credentialsclientId и clientSecret — надолу към приложението, което CloudUploader чете и подава на driveService.initialize(...). Оттам desktop приложението изпълнява целия OAuth flow само: вдига временен http сървър на случаен 127.0.0.1 порт, отваря consent екрана в системния браузър с shell.openExternal, хваща redirect-а на /callback и разменя кода за token-и с google-auth-library. Тези token-и се запазват локално — saveDriveAuth(access_token, refresh_token, expiry_date) в drive_auth — със scope-а drive.file, който позволява на приложението да управлява само файловете, които създава. Обновяването също е локално: listener oauth2Client.on('tokens', …) записва обновените token-и директно обратно в таблицата. Backend-ът предава способността веднъж и после остава извън цикъла.

Частта „resumable", честно

Ето детайла, за който най-много искам да съм прям, защото е точно от онези неща, които changelog-ът заобля. Бележката към изданието казва „resumable uploads", и това е вярно от мястото, където стои потребителят. Но GeekBye не имплементира resumable протокола. Няма константа за размер на chunk, няма tracker на byte-offset и няма никаква обработка на Content-Range / 308 Resume Incomplete никъде в изходния код на GeekBye. Направи grep за тях и не получаваш нищо — те живеят вътре в SDK-а googleapis.

Това, което прави собственият код на 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 да преговори resumable сесия от твое име, вместо да прави всичко наведнъж. Второ, прогресът се чете от callback-а onUploadProgress на SDK-а спрямо fileStats.size. Това е цялата имплементация на „resumable upload" на ниво приложение: stream навътре, прогрес навън. Трудният протокол — отвори сесия, качвай на chunk-ове, възобнови от offset след прекъсване — е работа на SDK-а, а правилното инженерно решение беше да го оставим да върши тази работа, вместо да я преоткриваме.

Което преформулира цялото издание. Подобрението в надеждността не дойде от написването на upload engine. То дойде от изтриването на proxy-то, което седеше пред такъв — и, по-рано същата седмица, от изтриването на ffmpeg стъпката пред него. По-малко код, повече устойчивост, защото устойчивото нещо беше добре изпробвана библиотека в момента, в който спреш да я храниш на ръка с buffer.

Парчетата, които са наистина на GeekBye

Предаването на трансфера на SDK-а не означава, че не остана нищо за строене. Две парчета реална приложна логика го обграждат.

Rollback за осиротели папки. Всеки запис получава своя собствена Drive подпапка (създадена с mimeType: 'application/vnd.google-apps.folder'), а транскриптът каца там като втори файл до видеото. Ако качването хвърли грешка, catch-ът изтрива тази папка — this.drive.files.delete({ fileId: folderId }), логнато като Rolled back orphaned Drive folder — така че провалено качване да не остави следа от празни папки в твоя Drive. Създаването на контейнер, преди да знаеш, че операцията ще успее, е малко задължение; направянето на пътя за провал да го почисти е поправката.

Жизнен цикъл около singleton. DriveService е singleton, който държи OAuth клиент в паметта, и този клиент в паметта не оцелява при рестарт на приложението, макар token-ите да оцеляват (те са в базата данни). Затова CloudUploader преинициализира DriveService от съхранените token-и преди всяко качване, което означава, че запис, направен веднага след повторно пускане, все още се качва, без да те моли да се свържеш наново. А review минаването превърна handler-а drive-connect във fire-and-forget, защото блокиращо connect извикване спираше status polling-а на renderer-а — UI-ят не можеше да покаже „connecting…", ако connect извикването държеше thread-а. Тези две — преинициализация-от-съхранение и неблокиращ connect — са реалните гранични случаи на директния път и, показателно, нито едно от тях не е за upload chunking. Когато делегираш трудната част, бъговете, с които оставаш, са бъгове на жизнения цикъл.

Три неща, на които ни научи това издание

  1. Питай накъде текат байтовете, не само дали работи. Proxy-то в v1.8.11 работеше. Но рутирането на всеки запис през собствените ти сървъри те струва трафик, памет и способност за рестартиране за файл, който не пазиш. Преначертаването на пътя на данните така, че клиентът да говори директно с дестинацията, често е цялата оптимизация.
  2. Най-доброто resumable качване е това, което не си написал. Подаването на stream към зрял SDK купи цяла resumable сесия за два реда код. Инстинктът да напишеш на ръка chunking-а и математиката на offset-а щеше да произведе повече код и по-малко надеждност.
  3. Когато делегираш ядрото, наследяваш жизнен цикъл, не алгоритми. Реалните бъгове на директния клиент бяха „преинициализирай singleton-а след рестарт" и „не блокирай connect извикването", не byte offset-и. Това е добра сделка — бъговете на жизнения цикъл са видими и локални; бъговете на протокола не са нито едното.

За предишната глава от историята на v1 — роден и поправен за дванадесет минути (v1.8.10); а за цялата дъга — анатомията на доставянето на софтуер до съвършенство.

Свързани статии

Три глагола, които поддържат Web Audio жив
Steven
Steven9 мин четене

Три глагола, които поддържат Web Audio жив

Две точкови издания на GeekBye, на два месеца разстояние и в два различни файла, научиха нашия аудио код на един и същ урок от противоположни краища: спри да третираш AudioContext на браузъра като за еднократна употреба. Едно издание се научи да прави resume() на контекст, който macOS тихо беше суспендирал по средата на записа; другото се научи да прави suspend() вместо close(), за да спрат последователните сесии да се блъскат в тавана на Chromium от приблизително шест контекста. Resume, suspend, close — това е целият сюжет.

Инженерство
Audio
Desktop
Да различиш обаждане от отворено приложение
Steven
Steven8 мин четене

Да различиш обаждане от отворено приложение

GeekBye може да забележи, че си се присъединил към видео среща, и да ти предложи да я запише. Разпознаването се оказва лесната половина — Swift binary, който чете заглавия на прозорци на всеки десет секунди. Трудната половина е прецизността: да не се задейства, когато Zoom е просто отворен, да не пита за среща, която вече записваш, и да не заглушава микрофона в обаждането, в което всъщност си. Три издания, всяко от които е предпазител, който трябваше да се научи да не побеждава сам себе си.

Инженерство
macOS
Desktop
Роден и поправен за дванадесет минути
Steven
Steven7 мин четене

Роден и поправен за дванадесет минути

Changelog-ът на GeekBye v1.8.10 казва, че е поправил срив при редактиране на клавишните комбинации. И наистина го поправи — но сривът беше въведен и поправен в един и същ pull request, на дванадесет минути разстояние, и никога не достигна до нито един потребител. Истинската история е каскадата от надеждност, която го произведе: малка, коректна промяна в това кога комбинациите се спират, и бъгът с React разграждането, който изпадна от един ред, предназначен да направи редактора по-безопасен.

Инженерство
React
Desktop