Steven
Steven10 phút đọc

Gỡ Backend Ra Khỏi Đường Đi Của Upload

GeekBye quay lại màn hình của bạn và lưu video vào Google Drive của bạn. Phiên bản đầu tiên đẩy mọi bản ghi qua chính máy chủ của GeekBye trên đường tới đó; một bản phát hành sau, tập tin đi thẳng từ máy của bạn tới Drive, và backend bị giáng xuống thành thứ chỉ giữ một con trỏ. Phần thú vị là cái phiên bản «trực tiếp, có thể tiếp tục» ấy thực ra chứa ít code đến mức nào — vì khả năng tiếp tục đến từ việc xóa đi một proxy, chứ không phải từ việc viết ra một cái.

Kỹ thuật
Kiến trúc
Desktop
Bản phát hành GeekBye
Gỡ Backend Ra Khỏi Đường Đi Của Upload

GeekBye có thể quay lại màn hình của bạn — video, âm thanh hệ thống, và mic — rồi tự động thả bản ghi đã hoàn tất vào Google Drive của bạn. Giữa v1.8.11 và v1.8.13, phần mô tả hướng-tới-người-dùng của tính năng gần như không đổi, nhưng con đường mà video đi để tới Drive đã bị đổi tuyến hoàn toàn. Ở phiên bản đầu tiên, bản ghi của bạn du hành qua các máy chủ của GeekBye. Ở phiên bản thứ hai, nó đi thẳng từ máy của bạn tới Drive của bạn, và backend của GeekBye chưa bao giờ thấy dù một byte của nó. Cú đổi tuyến đó là toàn bộ câu chuyện, và phần thỏa mãn là cái phiên bản «tốt hơn» chứa lượng code ít hơn hẳn một cách thấy rõ so với cái mà nó thay thế.

Pipeline, từ đầu đến cuối

Việc quay diễn ra trong renderer. ScreenRecordingService.ts tạo một MediaRecorder trên capture stream với { mimeType: 'video/webm;codecs=vp9,opus', videoBitsPerSecond: 1_000_000 } và khởi động nó với một timeslice một giây (CHUNK_INTERVAL_MS = 1000). Mỗi blob từ ondataavailable được chuyển qua IPC tới main process, nơi handler recording:save-chunk nối nó vào một tập tin trên đĩa bằng fs.promises.appendFile. Đáng gắn cờ sớm, vì đây là thứ dễ đọc nhầm: cái nhịp một giây ấy là nhịp ghi-đĩa, không phải một kích thước chunk upload. Đến lúc upload bắt đầu, trên đĩa đã có một tập tin WebM hoàn tất duy nhất nằm đó.

Thứ khác nhau giữa hai bản phát hành chỉ là điều gì xảy ra với cái tập tin hoàn tất đó.

Trước đó: mọi thứ qua backend (v1.8.11)

Đường upload của v1.8.11, trong CloudUploader.processRecording(), là thiết kế hiển nhiên và là một phiên bản đầu tiên hoàn toàn hợp lý:

  1. POST /api/geekbye/recordings để tạo một backend record với metadata của bản ghi.
  2. Đọc toàn bộ video vào bộ nhớ và POST nó tới backend dưới dạng multipart — const fileBuffer = fs.readFileSync(filePath) — gọi tới POST /api/geekbye/recordings/${id}/upload. Backend sau đó upload tập tin ấy lên Drive và trả về một driveUrl.
  3. POST …/process để gửi transcript đi phân tích bằng AI.

Chi tiết chịu tải là bước 2: client nạp toàn bộ bản ghi vào bộ nhớ và mọi byte của nó quá cảnh qua chính máy chủ của GeekBye trên đường tới Drive. Với một clip ngắn thì ổn. Với một bản quay màn hình dài thì đó là ba vấn đề cùng lúc — áp lực bộ nhớ lên client từ readFileSync, chi phí băng thông và chuyển tiếp trên backend cho một tập tin mà nó thậm chí không giữ, và một POST nguyên khối đơn lẻ phải khởi động lại từ số không nếu mạng nấc cụt giữa chừng.

Cũng đáng thành thật rằng cái «trước đó» này không phải một thiết kế nguyên bản tinh khôi nào cả. Tính năng v1.8.11 nén gộp lại một cú xoay trục trong-tuần: một bản cắt sớm upload lên Cloudflare R2 qua pre-signed URL và chuyển WebM thành MP4 bằng một ffmpeg-static đóng kèm; cái đó bị thay bằng luồng Drive do backend làm trung gian; rồi việc chuyển đổi ffmpeg bị nhổ bỏ hoàn toàn để chuyển sang upload WebM y nguyên (commit ghi rằng nó hạ bitrate xuống và cho ra các tập tin nhỏ hơn cỡ bốn lần — một ước lượng trong dòng, không phải một benchmark). Nên ngay cả «trước đó» cũng đã học được cách xóa một native dependency một lần rồi. Bản phát hành kế tiếp sẽ xóa cả proxy nữa.

Sau đó: thẳng tới Drive (v1.8.13)

v1.8.13 viết lại CloudUploader.processRecording() quanh một câu từ chính commit đã ship nó: upload trực tiếp tới Drive, "keeping backend for metadata record + AI transcript analysis only." Đường đi mới:

  1. POST /api/geekbye/recordingschỉ metadata (tiêu đề, thời lượng, kích thước tập tin, format: 'webm'). Comment trong code nói thẳng thừng: // Create backend record (metadata only — no file upload).
  2. Upload video trực tiếp lên Google Drive, bỏ qua hẳn kẻ trung gian backend.
  3. PATCH /api/geekbye/recordings/${backendRecordingId} với { driveUrl, driveFolderId } thu được — backend record được báo cho biết tập tin cuối cùng nằm ở đâu.
  4. Transcript vẫn đi tới …/process để phân tích.

Backend đi từ chỗ nằm trong đường đi của tập tin sang chỗ được báo về tập tin sau khi sự việc đã rồi. Nó giữ một hàng và một con trỏ; các byte chỉ sống trên đĩa của người dùng và trong Drive của người dùng.

Trở thành một Drive client thực thụ

Để client nói chuyện trực tiếp với Drive, nó phải là một Google Drive API client đúng nghĩa, và v1.8.13 thêm vào bộ máy cho việc đó: một DriveService mới (cái Drive client), một DriveAuthRepository mới được chống lưng bởi một bảng drive_auth SQLite mới, và googleapis SDK như một dependency.

Việc cấp phép là một cú trao tay năng lực gọn gàng. Endpoint app-config của backend chuyển tiếp client credentials của Google OAuth — một clientId và một clientSecret — xuống ứng dụng, cái mà CloudUploader đọc và truyền cho driveService.initialize(...). Từ đó ứng dụng desktop tự chạy toàn bộ OAuth flow: nó dựng một server http tạm thời trên một cổng 127.0.0.1 ngẫu nhiên, mở màn hình consent trong trình duyệt hệ thống bằng shell.openExternal, bắt cú redirect trên một /callback, và đổi code lấy token bằng google-auth-library. Những token đó được lưu cục bộ — saveDriveAuth(access_token, refresh_token, expiry_date) vào drive_auth — với scope drive.file, thứ cho phép ứng dụng chỉ quản lý các tập tin do chính nó tạo. Việc refresh cũng cục bộ: một listener oauth2Client.on('tokens', …) ghi thẳng các token đã refresh trở lại bảng. Backend trao năng lực một lần rồi đứng ngoài vòng lặp.

Phần «resumable», nói cho thật

Đây là chi tiết tôi muốn nói thẳng nhất, vì nó đúng là loại thứ mà một changelog làm tròn số. Ghi chú phát hành nói "resumable uploads," và điều đó đúng từ chỗ người dùng đứng. Nhưng GeekBye đã không triển khai giao thức resumable. Không có hằng số chunk-size, không có byte-offset tracker, và không có xử lý Content-Range / 308 Resume Incomplete ở bất cứ đâu trong mã nguồn của GeekBye. Grep tìm chúng và bạn chẳng được gì — chúng sống bên trong googleapis SDK.

Điều mà chính code của GeekBye làm là thế này:

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

Hai lựa chọn làm hết mọi việc. Thứ nhất, media.body là một stream (createReadStream), không phải một buffer — và truyền một stream chính xác là thứ khiến googleapis thương lượng một phiên resumable thay bạn thay vì làm một phát ăn ngay. Thứ hai, tiến độ được đọc ra từ callback onUploadProgress của SDK, đối chiếu với fileStats.size. Đó là toàn bộ phần triển khai «resumable upload» ở cấp ứng dụng: stream vào, tiến độ ra. Cái giao thức khó — mở một phiên, upload theo chunk, tiếp tục từ một offset sau khi rớt — là việc của SDK, và quyết định kỹ thuật đúng đắn là để nó làm việc đó thay vì phát minh lại.

Điều đó đóng khung lại cả bản phát hành. Cải thiện độ tin cậy không đến từ việc viết một upload engine. Nó đến từ việc xóa cái proxy ngồi chắn phía trước một cái — và, sớm hơn trong cùng tuần, xóa cái bước ffmpeg chắn phía trước cái đó. Ít code hơn, dẻo dai hơn, vì cái thứ dẻo dai ấy là một thư viện dày dạn ngay khoảnh khắc bạn thôi bón buffer cho nó bằng tay.

Những mẩu thực sự là của GeekBye

Trao việc truyền cho SDK không có nghĩa là chẳng còn gì để dựng. Hai mẩu logic ứng dụng thật sự bao quanh nó.

Một orphaned-folder rollback. Mỗi bản ghi có một thư mục con Drive của riêng nó (được tạo với mimeType: 'application/vnd.google-apps.folder'), và transcript đáp xuống đó như một tập tin thứ hai cạnh video. Nếu upload throw, cái catch xóa thư mục đó — this.drive.files.delete({ fileId: folderId }), được log là Rolled back orphaned Drive folder — để một lần upload thất bại không để lại một vệt thư mục rỗng trong Drive của bạn. Tạo một container trước khi bạn biết thao tác sẽ thành công là một khoản nợ nhỏ; làm cho đường thất bại dọn dẹp nó là bản sửa.

Vòng đời quanh một singleton. DriveService là một singleton giữ một OAuth client trong bộ nhớ, và cái client trong bộ nhớ đó không sống sót qua một lần khởi động lại ứng dụng dù các token thì sống sót (chúng nằm trong cơ sở dữ liệu). Nên CloudUploader khởi tạo lại DriveService từ các token đã lưu trước mỗi lần upload, nghĩa là một bản ghi thực hiện ngay sau khi khởi động lại vẫn upload mà không đòi bạn kết nối lại. Và lượt review biến handler drive-connect thành fire-and-forget, vì một lời gọi connect chặn đang làm nghẽn việc polling trạng thái của renderer — UI không thể hiển thị «đang kết nối…» nếu lời gọi connect giữ chặt thread. Hai thứ đó — khởi-tạo-lại-từ-lưu-trữ và connect không chặn — là các edge case thật sự của đường trực tiếp, và đáng chú ý là không cái nào trong chúng liên quan đến việc chia chunk upload. Khi bạn ủy thác phần khó, những con bug còn lại cho bạn là bug vòng đời.

Ba điều bản phát hành này dạy chúng tôi

  1. Hãy hỏi các byte chảy về đâu, không chỉ hỏi liệu nó có chạy không. Proxy của v1.8.11 chạy được. Nhưng định tuyến mọi bản ghi qua chính máy chủ của bạn khiến bạn tốn băng thông, bộ nhớ, và khả năng khởi động lại cho một tập tin bạn không giữ. Vẽ lại đường đi dữ liệu để client nói chuyện trực tiếp với đích thường là toàn bộ sự tối ưu.
  2. Resumable upload tốt nhất là cái bạn đã không viết. Truyền một stream cho một SDK trưởng thành đã mua về một phiên resumable đầy đủ chỉ với hai dòng code. Cái bản năng muốn tự làm tay việc chia chunk và số học offset hẳn đã cho ra nhiều code hơn và ít độ tin cậy hơn.
  3. Khi bạn ủy thác phần lõi, bạn thừa hưởng vòng đời, không phải thuật toán. Những con bug thật của client trực tiếp là «khởi tạo lại singleton sau khi khởi động lại» và «đừng chặn lời gọi connect», không phải byte offset. Đó là một cú đổi chác tốt — bug vòng đời thì thấy được và cục bộ; bug giao thức thì chẳng cái nào.

Về chương trước trong câu chuyện v1, xem Ra Đời Và Được Sửa Trong Mười Hai Phút (v1.8.10); và cho toàn bộ vòng cung, xem giải phẫu việc xuất xưởng phần mềm đến độ hoàn hảo.

Bài Viết Liên Quan

Ba Động Từ Giữ Cho Web Audio Sống
Steven
Steven11 phút đọc

Ba Động Từ Giữ Cho Web Audio Sống

Hai bản point release của GeekBye, cách nhau hai tháng và nằm trong hai file khác nhau, đã dạy code âm thanh của chúng tôi cùng một bài học từ hai đầu đối nghịch: thôi coi AudioContext của browser như một thứ dùng-rồi-bỏ. Một bản học được cách resume() một context mà macOS đã lặng lẽ suspend giữa lúc ghi; bản kia học được cách suspend() thay vì close() để những session nối tiếp nhau thôi đâm sầm vào cái trần khoảng-sáu-context của Chromium. resume, suspend, close — đó là toàn bộ cốt truyện.

Kỹ thuật
Audio
Desktop
Phân Biệt Một Cuộc Gọi Với Một App Đang Mở
Steven
Steven11 phút đọc

Phân Biệt Một Cuộc Gọi Với Một App Đang Mở

GeekBye có thể nhận ra bạn vừa vào một cuộc họp video và đề nghị ghi lại nó chỉ bằng một cú nhấp. Việc phát hiện hóa ra là nửa dễ — một Swift binary đọc tiêu đề cửa sổ mỗi mười giây. Nửa khó là độ chính xác: không phát hỏa khi Zoom chỉ đang mở, không nhắc cho một cuộc họp bạn đã đang ghi, và không tắt tiếng cái mic trong cuộc gọi bạn thực sự đang ngồi. Ba bản phát hành, và mỗi bản là một guard phải học cách không tự đánh bại chính mình.

Kỹ thuật
macOS
Desktop
Ra Đời Và Được Sửa Trong Mười Hai Phút
Steven
Steven9 phút đọc

Ra Đời Và Được Sửa Trong Mười Hai Phút

Changelog của GeekBye v1.8.10 nói nó đã sửa một crash khi chỉnh sửa phím tắt bàn phím. Nó có sửa thật — nhưng con crash đó được đưa vào rồi được sửa trong cùng một pull request, cách nhau mười hai phút, và chẳng bao giờ chạm tới một người dùng nào. Câu chuyện thật là chuỗi phản ứng dây chuyền về độ tin cậy đã sinh ra nó: một thay đổi nhỏ, đúng đắn về việc khi nào thì tạm ngưng các phím tắt, và con bug teardown của React rơi ra từ một dòng vốn định làm cho editor an toàn hơn.

Kỹ thuật
React
Desktop