Steven
Steven7 min czytania

Wyjmowanie backendu ze ścieżki przesyłania

GeekBye nagrywa twój ekran i zapisuje wideo na twój Google Drive. Pierwsza wersja przesyłała każde nagranie przez własne serwery GeekBye po drodze; wydanie później plik szedł prosto z twojej maszyny na Drive, a backend został zdegradowany do trzymania jednego wskaźnika. Ciekawą częścią jest to, jak mało kodu naprawdę zawiera wersja „bezpośrednia, wznawialna" — bo wznawialność wzięła się z usunięcia proxy, a nie z napisania go.

Inżynieria
Architektura
Desktop
Wydania GeekBye
Wyjmowanie backendu ze ścieżki przesyłania

GeekBye potrafi nagrać twój ekran — wideo, dźwięk systemowy i mikrofon — i automatycznie upuścić gotowe nagranie na twój Google Drive. Między v1.8.11 a v1.8.13 opis funkcji widziany przez użytkownika ledwie się zmienił, ale ścieżka, którą wideo pokonuje, by dotrzeć na Drive, została całkowicie przekierowana. W pierwszej wersji twoje nagranie podróżowało przez serwery GeekBye. W drugiej szło prosto z twojej maszyny na twój Drive, a backend GeekBye nigdy nie zobaczył ani jednego jego bajtu. To przekierowanie jest całą historią, a satysfakcjonującą częścią jest to, że „lepsza" wersja zawiera zauważalnie mniej kodu niż ta, którą zastąpiła.

Potok, od początku do końca

Nagrywanie dzieje się w rendererze. ScreenRecordingService.ts tworzy MediaRecorder na strumieniu przechwytywania z { mimeType: 'video/webm;codecs=vp9,opus', videoBitsPerSecond: 1_000_000 } i uruchamia go z jednosekundowym wycinkiem czasu (CHUNK_INTERVAL_MS = 1000). Każdy blob ondataavailable jest wysyłany przez IPC do procesu głównego, gdzie handler recording:save-chunk dokleja go do pliku na dysku za pomocą fs.promises.appendFile. Warto zaznaczyć od razu, bo łatwo to źle odczytać: ten jednosekundowy rytm to rytm zapisu na dysk, a nie rozmiar fragmentu przesyłania. Zanim rozpocznie się przesyłanie, na dysku leży pojedynczy gotowy plik WebM.

To, co różni oba wydania, to tylko to, co dzieje się z tym gotowym plikiem.

Przedtem: wszystko przez backend (v1.8.11)

Ścieżka przesyłania w v1.8.11, w CloudUploader.processRecording(), to oczywisty projekt i całkowicie rozsądna pierwsza wersja:

  1. POST /api/geekbye/recordings, by utworzyć rekord backendu z metadanymi nagrania.
  2. Wczytaj całe wideo do pamięci i wyślij je POST-em na backend jako multipart — const fileBuffer = fs.readFileSync(filePath) — trafiając w POST /api/geekbye/recordings/${id}/upload. Backend następnie wgrywa ten plik na Drive i zwraca driveUrl.
  3. POST …/process, by wysłać transkrypcję do analizy AI.

Kluczowym szczegółem jest krok 2: klient wczytuje całe nagranie do pamięci, a każdy jego bajt przechodzi przez własne serwery GeekBye w drodze na Drive. Dla krótkiego klipu to w porządku. Dla długiego nagrania ekranu to trzy problemy naraz — presja na pamięć klienta ze strony readFileSync, koszt przepustowości i przekazywania po stronie backendu za plik, którego nawet nie zatrzymuje, oraz pojedynczy monolityczny POST, który musi zacząć od zera, jeśli sieć czknie w połowie.

Warto też być uczciwym, że to „przedtem" nie było jakimś nieskazitelnym pierwotnym projektem. Funkcja z v1.8.11 sklejała w sobie zwrot dokonany w ciągu jednego tygodnia: wczesna wersja wgrywała na Cloudflare R2 przez wstępnie podpisane URL-e i konwertowała WebM na MP4 dołączonym ffmpeg-static; zostało to zastąpione przepływem Drive pośredniczonym przez backend; a potem konwersja ffmpeg została całkowicie wyrwana na rzecz wgrywania WebM tak, jak jest (commit odnotowuje, że obniżyło to bitrate i dało pliki mniej więcej czterokrotnie mniejsze — szacunek z głowy, nie benchmark). Więc nawet „przedtem" już raz nauczyło się usuwać natywną zależność. Następne wydanie usunęłoby też proxy.

Potem: prosto na Drive (v1.8.13)

v1.8.13 przepisuje CloudUploader.processRecording() wokół jednego zdania z commita, który je wysłał: wgrywaj bezpośrednio na Drive, „zostawiając backend tylko dla rekordu metadanych + analizy transkrypcji AI." Nowa ścieżka:

  1. POST /api/geekbye/recordingstylko metadane (tytuł, czas trwania, rozmiar pliku, format: 'webm'). Komentarz w kodzie jest bez ogródek: // Create backend record (metadata only — no file upload).
  2. Wgraj wideo bezpośrednio na Google Drive, całkowicie pomijając pośrednika-backend.
  3. PATCH /api/geekbye/recordings/${backendRecordingId} z wynikowym { driveUrl, driveFolderId } — rekordowi backendu mówi się, gdzie plik wylądował.
  4. Transkrypcja wciąż idzie do …/process po analizę.

Backend przeszedł od bycia w ścieżce pliku do bycia informowanym o pliku po fakcie. Trzyma wiersz i wskaźnik; bajty żyją tylko na dysku użytkownika i na jego Drive.

Stając się prawdziwym klientem Drive

By klient mógł rozmawiać z Drive bezpośrednio, musi być faktycznym klientem Google Drive API, i v1.8.13 dodaje do tego maszynerię: nowy DriveService (klient Drive), nowe DriveAuthRepository oparte na nowej tabeli SQLite drive_auth oraz SDK googleapis jako zależność.

Autoryzacja to czyste przekazanie uprawnień. Endpoint konfiguracji aplikacji na backendzie przekazuje poświadczenia klienta Google OAuth — clientId i clientSecret — w dół do aplikacji, którą CloudUploader odczytuje i przekazuje do driveService.initialize(...). Stamtąd aplikacja desktopowa sama przeprowadza cały przepływ OAuth: stawia tymczasowy serwer http na losowym porcie 127.0.0.1, otwiera ekran zgody w przeglądarce systemowej za pomocą shell.openExternal, łapie przekierowanie na /callback i wymienia kod na tokeny za pomocą google-auth-library. Te tokeny są zapisywane lokalnie — saveDriveAuth(access_token, refresh_token, expiry_date) do drive_auth — z zakresem drive.file, który pozwala aplikacji zarządzać tylko plikami, które sama tworzy. Odświeżanie też jest lokalne: nasłuch oauth2Client.on('tokens', …) zapisuje odświeżone tokeny prosto z powrotem do tabeli. Backend przekazuje uprawnienie raz, a potem trzyma się z dala.

Część „wznawialna", uczciwie

Oto szczegół, co do którego najbardziej chcę być szczery, bo to dokładnie ten rodzaj rzeczy, który changelog zaokrągla. Nota do wydania mówi „wznawialne przesyłanie" i to prawda z perspektywy użytkownika. Ale GeekBye nie zaimplementował protokołu wznawialnego. Nigdzie w źródle GeekBye nie ma stałej rozmiaru fragmentu, trackera przesunięcia bajtowego ani obsługi Content-Range / 308 Resume Incomplete. Poszukaj ich grepem, a nie dostaniesz nic — żyją wewnątrz SDK googleapis.

To, co robi własny kod GeekBye, jest takie:

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)
    },
  },
)

Dwa wybory robią całą robotę. Po pierwsze, media.body to strumień (createReadStream), a nie bufor — i przekazanie strumienia to dokładnie to, co sprawia, że googleapis negocjuje w twoim imieniu sesję wznawialną, zamiast robić to za jednym zamachem. Po drugie, postęp jest odczytywany z callbacku onUploadProgress SDK względem fileStats.size. To cała implementacja „wznawialnego przesyłania" na poziomie aplikacji: strumień w środku, postęp na zewnątrz. Trudny protokół — otwórz sesję, wgrywaj we fragmentach, wznów od przesunięcia po zerwaniu — to zadanie SDK, a poprawną decyzją inżynierską było pozwolić mu wykonać to zadanie, zamiast wynajdywać je od nowa.

Co przeformułowuje całe wydanie. Poprawa niezawodności nie wzięła się z napisania silnika przesyłania. Wzięła się z usunięcia proxy, które stało przed jednym — a wcześniej tego samego tygodnia, z usunięcia kroku ffmpeg stojącego przed tamtym. Mniej kodu, więcej odporności, bo odporną rzeczą była wysłużona biblioteka w chwili, gdy przestałeś ręcznie karmić ją buforem.

Kawałki, które są autentycznie GeekBye

Przekazanie transferu do SDK nie znaczy, że nie zostało nic do zbudowania. Dwa kawałki prawdziwej logiki aplikacji go otaczają.

Wycofanie osieroconego folderu. Każde nagranie dostaje własny podfolder Drive (utworzony z mimeType: 'application/vnd.google-apps.folder'), a transkrypcja ląduje tam jako drugi plik obok wideo. Jeśli przesyłanie rzuci wyjątek, catch usuwa ten folder — this.drive.files.delete({ fileId: folderId }), logowane jako Rolled back orphaned Drive folder — więc nieudane przesyłanie nie zostawia śladu pustych folderów na twoim Drive. Tworzenie kontenera, zanim wiesz, że operacja się powiedzie, to małe ryzyko; sprawienie, że ścieżka błędu go sprząta, jest naprawą.

Cykl życia wokół singletona. DriveService to singleton trzymający w pamięci klienta OAuth, a ten klient w pamięci nie przeżywa restartu aplikacji, mimo że tokeny tak (są w bazie danych). Więc CloudUploader re-inicjalizuje DriveService z zapisanych tokenów przed każdym przesłaniem, co znaczy, że nagranie zrobione tuż po ponownym uruchomieniu wciąż się wgrywa, nie prosząc cię o ponowne połączenie. A przegląd zamienił handler drive-connect w fire-and-forget, bo blokujące wywołanie connect zatrzymywało odpytywanie statusu przez renderer — UI nie mógł pokazać „łączenie…", jeśli wywołanie connect trzymało wątek. Te dwa — re-inicjalizacja z pamięci i nieblokujący connect — to prawdziwe przypadki brzegowe ścieżki bezpośredniej, i co znamienne, żaden z nich nie dotyczy fragmentowania przesyłania. Gdy delegujesz trudną część, błędy, które ci zostają, to błędy cyklu życia.

Trzy rzeczy, których nauczyło nas to wydanie

  1. Pytaj, gdzie płyną bajty, nie tylko czy działa. Proxy z v1.8.11 działało. Ale trasowanie każdego nagrania przez własne serwery kosztuje cię przepustowość, pamięć i wznawialność — za plik, którego nie zatrzymujesz. Przerysowanie ścieżki danych tak, by klient rozmawiał z celem bezpośrednio, jest często całą optymalizacją.
  2. Najlepsze wznawialne przesyłanie to takie, którego nie napisałeś. Przekazanie strumienia dojrzałemu SDK kupiło pełną sesję wznawialną za dwie linijki kodu. Instynkt, by ręcznie sklecić fragmentowanie i matematykę przesunięć, dałby więcej kodu i mniej niezawodności.
  3. Gdy delegujesz rdzeń, dziedziczysz cykl życia, nie algorytmy. Prawdziwymi błędami klienta bezpośredniego były „re-inicjalizuj singleton po restarcie" i „nie blokuj wywołania connect", a nie przesunięcia bajtowe. To dobry handel — błędy cyklu życia są widoczne i lokalne; błędy protokołu nie są ani jednym, ani drugim.

Po poprzedni rozdział historii v1 — narodzony i naprawiony w dwanaście minut (v1.8.10); a po cały łuk — anatomia dostarczania oprogramowania do perfekcji.

Powiązane artykuły

Trzy czasowniki, które utrzymują Web Audio przy życiu
Steven
Steven8 min czytania

Trzy czasowniki, które utrzymują Web Audio przy życiu

Dwa wydania punktowe GeekBye, dwa miesiące od siebie i w dwóch różnych plikach, nauczyły nasz kod audio tej samej lekcji z przeciwnych stron: przestań traktować przeglądarkowy AudioContext jak obiekt jednorazowy. Jedno wydanie nauczyło się resume() kontekstu, który macOS po cichu zawiesił w środku nagrywania; drugie nauczyło się suspend() zamiast close(), żeby kolejne po sobie sesje przestały uderzać w narzucony przez Chromium pułap mniej więcej sześciu kontekstów. Resume, suspend, close — oto cała fabuła.

Inżynieria
Audio
Desktop
Odróżnić rozmowę od otwartej aplikacji
Steven
Steven8 min czytania

Odróżnić rozmowę od otwartej aplikacji

GeekBye potrafi zauważyć, że dołączyłeś do spotkania wideo, i zaproponować jego nagranie. Wykrywanie okazuje się łatwiejszą połową — natywny plik Swift czytający tytuły okien co dziesięć sekund. Trudną połową jest precyzja: nie odpalać, gdy Zoom jest tylko otwarty, nie proponować nagrywania spotkania, które już nagrywasz, i nie wyciszać mikrofonu w rozmowie, w której faktycznie jesteś. Trzy wydania, a każde z nich to zabezpieczenie, które musiało nauczyć się nie pokonywać samego siebie.

Inżynieria
macOS
Desktop
Narodzony i naprawiony w dwanaście minut
Steven
Steven7 min czytania

Narodzony i naprawiony w dwanaście minut

Changelog GeekBye v1.8.10 mówi, że naprawiono awarię podczas edycji skrótów klawiszowych. Owszem — ale awarię wprowadzono i naprawiono w tym samym pull requeście, dwanaście minut od siebie, i nigdy nie dotarła do ani jednego użytkownika. Prawdziwą historią jest kaskada niezawodności, która ją wytworzyła: mała, poprawna zmiana tego, kiedy skróty są zawieszane, i błąd rozbiórki Reacta, który wypadł z jednej linijki mającej uczynić edytor bezpieczniejszym.

Inżynieria
React
Desktop