Sự cộng tác thời gian thực trông có vẻ dễ dàng cho đến khi bạn vén màn bí mật đằng sau nó. Một người đang gõ. Một người khác xóa một dòng ở ba đoạn văn phía trên. Người thứ ba dán một đoạn mã từ Stack Overflow. Bằng cách nào đó, tài liệu vẫn ổn định ở một trạng thái duy nhất và nhất quán. Xây dựng sự mượt mà đó từ con số không, mà không có kinh nghiệm trước đó về WebSockets hay trạng thái phân tán (distributed state), nghe có vẻ liều lĩnh. Nhưng nó cũng nghe có vẻ là cách đúng đắn để thực sự học hỏi.

Dự án này bắt đầu từ con số không. Không sử dụng mã mẫu (boilerplate) có sẵn. Không có các video hướng dẫn trau chuốt trên YouTube, nơi những phần khó nhất bị bỏ qua trong một đoạn phim cắt ghép dài ba mươi giây. Mục tiêu là một trình soạn thảo mã nguồn cộng tác, nơi nhiều người dùng có thể chỉnh sửa cùng một tệp đồng thời, nhìn thấy những thay đổi của nhau — và cả con trỏ của nhau — ngay khi chúng diễn ra. Để đạt được điều đó, chúng ta sẽ cần tìm hiểu về các lớp truyền tải (transport layers), các mô hình nhất quán (consistency models), và vấn đề hóc búa về việc hợp nhất các chỉnh sửa đồng thời mà không làm hỏng tài liệu.

Ý nghĩa thực sự của "Thời gian thực"

Hầu hết các ứng dụng web đều hoạt động thoải mái với chu kỳ yêu cầu-phản hồi (request-response). Bạn gửi một biểu mẫu, máy chủ lưu trữ, và bạn tải lại trang. Sự cộng tác thời gian thực phá vỡ hoàn toàn quy ước đó. Mỗi lần nhấn phím là một sự kiện phải được lan truyền đến mọi client khác đang kết nối, thường là trong vài mili giây, và phải đến theo một thứ tự nhằm bảo toàn ý nghĩa của nội dung.

WebSockets là lựa chọn truyền tải hiển nhiên ở đây vì chúng duy trì một kết nối song công (full-duplex) liên tục giữa client và server. Không giống như HTTP polling, vốn gây lãng phí băng thông khi cứ vài giây lại hỏi "có gì mới không?", một WebSocket sẽ luôn mở. Khi người dùng A gõ một dấu chấm phẩy, ký tự đó trở thành một tin nhắn truyền qua socket đến máy chủ trung tâm, sau đó được phân phối đến người dùng B và C. Phần đó tương đối đơn giản.

Phần khó là chuyện gì sẽ xảy ra khi B và C gõ vào cùng một thời điểm chính xác. Nếu cả hai thay đổi đều đến máy chủ gần như cùng lúc, thay đổi nào sẽ thắng? Nếu bạn chỉ đơn thuần phát tán các tin nhắn theo thứ tự chúng đến, bạn sẽ đối mặt với rủi ro mất ký tự hoặc văn bản bị xáo trộn. Các chiến lược "lần ghi cuối cùng thắng" (last-write-wins) ngây thơ sẽ thất bại vì chúng bỏ qua ý định của người dùng. Nếu tôi gõ "hello" ở đầu dòng một trong khi bạn gõ "world" ở đầu dòng một, kết quả không nên là một sự xung đột khiến một trong hai chúng ta bị xóa mất. Nó nên là "helloworld" hoặc "worldhello", được lựa chọn một cách xác định. Để đạt được điều đó, cần có một chiến lược đồng bộ hóa hiểu được cấu trúc của tài liệu.

Tại sao việc bắt đầu từ con số không lại quan trọng

Có những framework tuyệt vời giúp che giấu sự phức tạp này. Yjs, Automerge và Socket.IO có thể trừu tượng hóa mọi khó khăn và tạo ra một bản mẫu hoạt động được chỉ trong một buổi chiều. Nhưng sử dụng chúng mà không hiểu các thành phần cơ bản (primitives) bên dưới giống như lái một chiếc máy bay ở chế độ tự động mà không biết cách đọc các thiết bị đo lường. Khi sự nhiễu loạn xảy ra — và trong các hệ thống phân tán, nó luôn xảy ra — bạn cần biết liệu vấn đề nằm ở lớp mạng, ở việc giải quyết xung đột, hay ở mô hình dữ liệu của mình.

Cam kết ở đây là học các khái niệm trước khi dựa dẫm vào các thư viện. Điều đó có nghĩa là phải tự mình suy luận xem điều gì sẽ xảy ra khi:

  • Một client ngắt kết nối khi đang gõ phím và kết nối lại sau mười giây
  • Hai người dùng chèn văn bản tại cùng một vị trí con trỏ cùng một lúc
  • Một người dùng xóa một khối văn bản mà người dùng khác đang tích cực chỉnh sửa
  • Máy chủ bị sập và một nút (node) mới phải tái cấu trúc trạng thái tài liệu từ đầu

Operational Transformation (OT) và Conflict-free Replicated Data Types (CRDTs) là hai nhóm giải pháp chủ đạo cho những vấn đề này. Google Docs nổi tiếng với việc xây dựng kiến trúc ban đầu dựa trên OT, vốn yêu cầu một máy chủ trung tâm để biến đổi các thao tác tương ứng với nhau trước khi áp dụng chúng. Ngược lại, CRDTs được thiết kế để các cập nhật đồng thời có thể được hợp nhất cục bộ mà không cần sự điều phối, giúp chúng trở nên hấp dẫn cho các thiết lập ngang hàng (peer-to-peer) hoặc dựa trên edge. Việc lựa chọn giữa chúng — hoặc các phương pháp tiếp cận hỗn hợp — đòi hỏi sự hiểu biết về các đánh đổi về mức độ sử dụng bộ nhớ, đảm bảo sự hội tụ và độ phức tạp khi triển khai. Chỉ đọc về những sự đánh đổi đó là chưa đủ; kế hoạch là sẽ triển khai cả các phiên bản ngây thơ và các phiên bản tinh chỉnh để xem chúng sẽ gặp lỗi ở đâu.

Những lần xây dựng lại, sai lầm và những ngõ cụt

Kỳ vọng được thiết lập một cách trung thực. Sẽ có những giai đoạn mà chẳng có gì hoạt động cả. Lần thử đầu tiên có thể sử dụng các bản vá JSON đơn giản để biểu diễn các thay đổi văn bản, chỉ để rồi phát hiện ra rằng JSON không có khái niệm về “vị trí thứ 5 trong một đoạn văn”, vì vậy hai lần chèn đồng thời tại cùng một vị trí sẽ ghi đè lên nhau thay vì hợp nhất chúng. Lần thử thứ hai có thể xây dựng một nhật ký lịch sử tuyến tính tùy chỉnh, để rồi nhận ra rằng việc phát lại nhật ký đó là một cơn ác mộng về độ phức tạp Big O khi tài liệu lớn dần. Lần thử thứ ba có thể làm cho WebSockets hoạt động được ở môi trường local, nhưng rồi lại đổ vỡ trên một mạng thực tế, nơi mà việc mất gói tin và độ trễ biến thiên sẽ viết lại mọi quy tắc.

Sự ma sát đó chính là mục đích. Việc sao chép một kho lưu trữ (repository) đang hoạt động sẽ khiến bạn bỏ qua quá trình tìm hiểu tại sao hàng đợi lại được đẩy đi (flush) theo thứ tự cụ thể đó, hoặc tại sao máy chủ lại duy trì một version vector. Việc xây dựng lại cùng một thành phần ba lần tuy chậm, nhưng nó buộc bạn phải hiểu được ranh giới giữa những gì framework thực hiện và những gì logic của riêng bạn phải xử lý.

Tài liệu về quá trình này sẽ không phải là một bản tổng hợp những thành tựu rực rỡ. Nó sẽ bao gồm cả những bước đi sai lầm. Ví dụ, việc xây dựng khả năng nhận biết sự hiện diện (presence awareness)—biết ai đang trực tuyến và con trỏ của họ đang ở đâu—có vẻ như là một tính năng mang tính thẩm mỹ cho đến khi bạn nhận ra nó phụ thuộc vào cùng một mô hình nhất quán (consistency model) như chính văn bản đó. Nếu người dùng A thấy con trỏ của người dùng B ở cột 10, sau đó người dùng B chèn thêm bốn ký tự, thì con trỏ đó sẽ di chuyển đi đâu? Nếu không có sự hiểu biết chung về cấu trúc topo của tài liệu, dữ liệu về sự hiện diện sẽ bị lệch khỏi thực tế. Giải quyết vấn đề đó đòi hỏi phải gắn kết vị trí con trỏ với định danh của cấu trúc dữ liệu nền tảng, chứ không chỉ là chỉ số số học của nó. Đây là những loại chi tiết mà các bài hướng dẫn thường lướt qua vì chúng tẻ nhạt, chứ không phải vì chúng không quan trọng.

Những gì tiếp theo

Lộ trình ngay lập tức được thiết kế một cách thưa thớt. Các cột mốc đầu tiên sẽ là:

  • Một máy chủ WebSocket thô để phản hồi (echo) các sự kiện ký tự, nhằm trực tiếp cảm nhận độ trễ và vòng đời kết nối
  • Một bộ đệm chuỗi đơn giản ở phía client để hiểu tại sao thứ tự chèn ngây thơ lại thất bại trong điều kiện đồng thời (concurrency)
  • Một CRDT xây dựng từ đầu cho các chuỗi có thứ tự, dù có kém hiệu quả đến đâu, để thấy tính chất giao hoán (commutative property) hoạt động trong thực tế
  • Tích hợp dần dần với một giao diện trình soạn thảo mã thực thụ, có khả năng là thứ gì đó như CodeMirror hoặc Monaco, để vật lộn với sự không tương thích giữa API mệnh lệnh (imperative API) của trình soạn thảo và bản chất hàm (functional nature) của lịch sử vận hành

Mỗi bước sẽ đi kèm với một phần giải thích lý do bằng văn bản. Tại sao lại là cách tiếp cận này mà không phải cách kia? Những giả định nào đã bị bác bỏ? Sự trừu tượng hóa nào đã bị rò rỉ?

Một bài học thực tế

Bắt đầu một dự án như thế này mà không có kinh nghiệm về WebSockets hay CRDTs thật đáng sợ, nhưng chuyên môn thường chỉ là sự bối rối lặp đi lặp lại nhưng được gắn những cái nhãn tốt hơn. Mục tiêu không phải là kết thúc nhanh chóng. Đó là xây dựng một hệ thống mà hành vi của nó có thể dự đoán được vì mọi lớp đều được xây dựng với mục đích rõ ràng thay vì được nhập về với sự hy vọng.

Nếu bạn đã từng xây dựng phần mềm cộng tác trước đây—cho dù đó là một trình soạn thảo văn bản, một công cụ thiết kế, hay một công cụ đồng bộ trạng thái trò chơi—hãy chia sẻ những kịch bản lỗi đã khiến bạn bất ngờ. Nếu bạn cũng đang học những hệ thống này, hãy cùng theo dõi. Mã nguồn sẽ đến một cách chậm rãi, và nó sẽ được viết lại thường xuyên. Ngày 0 bắt đầu ngay bây giờ.