7 điểm bởi GN⁺ 2023-10-19 | 1 bình luận | Chia sẻ qua WhatsApp
  • Reflect là một framework để nhanh chóng xây dựng các ứng dụng cộng tác như Figma, Notion và Google Sheets, được ra mắt cùng với máy chủ được quản lý hoàn toàn bổ sung cho engine đồng bộ hóa kiểu game của Replicache
  • UI cộng tác cần hiển thị ngay các thay đổi cục bộ mà không chờ phản hồi từ máy chủ, vì vậy cách xử lý xung đột khi nhiều người cùng sửa một dữ liệu sẽ quyết định trải nghiệm sản phẩm
  • Thay vì CRDT, Reflect chọn Transactional Conflict Resolution, trong đó client và server cùng thực thi lịch sử gọi mutator giống nhau, còn server tạo ra trạng thái có thẩm quyền theo thứ tự đến
  • Sequence CRDT như Yjs mạnh ở văn bản, danh sách và map, nhưng trong các trường hợp cần quy tắc hợp nhất riêng như bộ đếm thì phần tăng thêm có thể bị mất; Reflect xử lý phép toán số học, thao tác danh sách và bất biến cấp cao bằng cách chạy lại mutator
  • Vì server chỉ nhận tên mutation và đối số rồi tự tính lại kết quả, nó không tin kết quả tính toán của client và dễ đưa kiểm tra quyền, xác thực schema và migration vào thiết kế

Trải nghiệm phát triển mà Reflect công bố

  • Reflect là một cách tiếp cận mới để xây dựng ứng dụng web nhiều người chơi như Figma, Notion và Google Sheets
  • Đây là phiên bản tiến hóa của framework đồng bộ hóa phía client Replicache, sử dụng cùng một engine đồng bộ hóa kiểu game
  • Khác với Replicache, nó bao gồm máy chủ được quản lý hoàn toàn, với mục tiêu cho phép tạo ra ứng dụng nhiều người chơi chất lượng cao chỉ trong vài phút
  • Reflect hiện được công khai lần đầu; có thể xem giới thiệu tại reflect.net và bắt đầu tại hello.reflect.net

Cấu trúc phát sinh xung đột trong chỉnh sửa cộng tác

  • Trong chỉnh sửa cộng tác, xung đột là điều không thể tránh khỏi
  • Để tạo UI phản hồi tức thì, không thể chờ server và thay đổi phải diễn ra trước ở cục bộ trên client
  • Vì nhiều người dùng có thể cùng lúc chỉnh sửa một mục, cần đồng bộ xung đột và giải quyết chúng một cách tự nhiên để mọi người nhìn thấy cùng một kết quả
  • Engine đồng bộ hóa ảnh hưởng đến trải nghiệm của nhà phát triển, trải nghiệm người dùng, hiệu năng có thể đạt được và cả loại ứng dụng có thể xây dựng

CRDT và ví dụ bộ đếm trong Yjs

  • Trong hệ sinh thái web, CRDT được dùng rộng rãi như một cách đồng bộ dữ liệu
  • CRDT là cấu trúc dữ liệu hội tụ về cùng một giá trị khi mọi thay đổi giữa các bên cộng tác đã được trao đổi; YjsAutomerge là những thư viện CRDT mã nguồn mở tiêu biểu
  • Reflect không phải CRDT mà dùng Transactional Conflict Resolution, một biến thể của Server Reconciliation đã được ngành game sử dụng từ lâu
  • Vì sao bộ đếm đơn giản bị hỏng trong Yjs

    • Cách lưu giá trị count trong Yjs Map rồi ghi lại prev + 1 có thể làm mất phần tăng thêm trong tình huống đồng thời
    • Ví dụ bộ đếm đúng trong tài liệu Yjs là thêm số vào mảng rồi tính tổng
    • Yjs là một sequence CRDT, nên rất mạnh với danh sách, đoạn văn bản và map, nhưng khó mô hình hóa bộ đếm một cách tự nhiên
    • Thuật toán hợp nhất của Yjs Map là last-write wins theo từng khóa, nên nếu hai người dùng cùng tăng tại một thời điểm thì một trong hai thay đổi có thể biến mất
    • CRDT rất phù hợp với một số bài toán nhất định, nhưng có hạn chế là khó mở rộng nếu bài toán không thuộc nhóm đó

Cách Transactional Conflict Resolution hoạt động

  • Trong Reflect, thay đổi được triển khai bằng một hàm JavaScript đặc biệt gọi là mutator
  • Bản sao của mỗi mutator tồn tại trên mọi client và server
  • Khi người dùng tạo thay đổi, Reflect tạo ra một mutation, tức bản ghi lời gọi mutator
    • Mutation chỉ chứa tên mutator và đối số, như increment(delta: 1)
    • Nội dung thay đổi tạo ra từ kết quả không được đưa vào mutation
  • Reflect áp dụng mutation ngay tại cục bộ để cập nhật UI, nên người dùng có thể thấy thay đổi của mình lập tức
  • Tuyến tính hóa trên server và thực thi lại

    • Mỗi client tiếp tục thêm mutation mà không cần chờ server
    • Mutation được stream lên server, và server tuyến tính hóa chúng theo thứ tự thời gian đến rồi tạo ra trạng thái có thẩm quyền tiếp theo
    • Ví dụ, nếu increment(1) từ client 1 và increment(2) từ client 2 xảy ra đồng thời, thì trong lần thực thi trên server, số đếm cuối cùng sẽ được tạo theo thứ tự đến
    • Server hợp nhất xung đột bằng cách tuyến tính hóa lịch sử thực thi, không cần tri thức riêng về increment làm gì hay cách hợp nhất nó
    • Trạng thái có thẩm quyền mới nhất tiếp tục được stream về từng client
    • Khi client biết mutation đang chờ của mình đã được áp dụng vào trạng thái có thẩm quyền, nó sẽ loại mutation đó khỏi hàng đợi cục bộ
    • Các mutation đang chờ còn lại sẽ được chạy lại mã mutator trên trạng thái có thẩm quyền mới nhất để rebase
    • Toàn bộ chu trình này có thể diễn ra tối đa 120 lần mỗi giây cho mỗi client

Chi phí triển khai và những lợi thế có thể khái quát hóa

  • Để triển khai cách này, cần một datastore nhanh có thể tua ngược, fork và tạo branch
  • Phía server cũng cần một kho lưu trữ nhanh để xử lý các mutation đi vào
  • Cần có cách đồng bộ mutator, cùng cơ chế phục hồi khi client hoặc server gặp xung đột trong quá trình đồng bộ
  • Đổi lại, việc tuyến tính hóa các hàm tùy ý hoạt động như một chiến lược đồng bộ khá tổng quát
  • Ví dụ xử lý không cần mã đồng bộ riêng

    • Các phép toán số học được xử lý một cách tự nhiên
    • setHighScore lưu giá trị lớn hơn giữa high-score hiện có và điểm ứng viên
    • Phần lớn thao tác với danh sách cũng hoạt động
    • append thêm mục vào cuối danh sách mua sắm
    • insertAt chèn mục vào vị trí chỉ định, còn splice() điều chỉnh vị trí
    • remove nên nhận mục hoặc ID ổn định làm đối số vì chỉ số có thể thay đổi
    • Cũng có thể áp đặt bất biến ở mức cao hơn
    • addChild cập nhật cùng lúc childIDs của cha và parentID của con để luôn nhất quán
    • Những ví dụ này được hợp nhất hợp lý mà không cần mã riêng có ý thức về đồng bộ

Thẩm quyền của server và kiểm tra quyền

  • Trong Reflect, server là bên có thẩm quyền
  • Cách client nghĩ rằng kết quả thay đổi sẽ ra sao không được chia sẻ với server hay các client khác
  • Thứ được gửi lên server chỉ là tên mutation và đối số; server tự tính lại kết quả của mutation
  • Server thậm chí không cần chạy cùng mã với client, và cũng có thể truy vấn dịch vụ bên ngoài hoặc dùng số ngẫu nhiên
  • Phân quyền chi tiết

    • Với thiết kế này, có thể đưa kiểm tra quyền chi tiết vào một cách tự nhiên
    • Ví dụ là trong một chương trình thiết kế cộng tác, khách được phép bình luận và tô sáng nhưng bị cấm sửa chính thiết kế
    • Với CRDT, khó triển khai vì không có chỗ phù hợp để đặt logic từ chối thay đổi không có quyền
    • Trong Reflect, mutator chạy trên server có thể kiểm tra giá trị như tx.user.canEdit, và nếu không có quyền thì ném lỗi unauthorized
    • Việc mutator trên server chạy mã khác với client cũng không gây vấn đề, vì server đưa ra quyết định cuối cùng

Xác thực schema và hướng dẫn sử dụng

  • Với cách tiếp cận của Reflect, xác thực schema và migration cũng có thể được đưa tự nhiên vào thiết kế
  • Việc chọn chiến lược đồng bộ là cốt lõi của hệ thống nhiều người chơi, và Reflect xem Transactional Conflict Resolution học từ ngành game là một cách tiếp cận đơn giản, linh hoạt và mạnh mẽ
  • Nếu đang xây dựng ứng dụng nhiều người chơi, có thể thử tại trang bắt đầu của Reflect
  • Nếu muốn trao đổi với nhóm phát triển, có thể dùng discord.reflect.net hoặc @hello_reflect

1 bình luận

 
GN⁺ 2023-10-19
Ý kiến trên Hacker News
  • Demo ở đầu trang chủ (https://reflect.net/) khá thú vị
    Khi xem, mỗi lần hoàn thành một mảnh ghép, mọi người lại vui vẻ lắc con trỏ như thể nói “chúng ta làm được rồi!”

    • Có lúc bắt đầu ghép các mảnh thành từ FUCK, nhưng khi những người khác nhận ra và cùng tham gia thì bất ngờ lại trở thành một trải nghiệm khá ấm áp
    • Có một lỗi hiển thị cho phép giấu mảnh e phía sau phần e đã hoàn thành
      Với các chữ khác thì đường viền vẫn hiện nên không làm theo cách đó được
    • Tôi không nghĩ demo này là ví dụ tốt để thể hiện các chức năng của thư viện
      Khi cầm một mảnh, có vẻ mảnh đó bị khóa cho người dùng đó, nên hầu như không có xung đột nào cần giải quyết; cùng lắm chỉ còn chuyện nếu hai người nhặt cùng lúc thì trao cho người nhặt trước
      Tôi muốn xem một demo ví dụ tốt hơn, nơi giải quyết xung đột thực sự xảy ra
    • Thật sự là một trải nghiệm thú vị
      Video ghi lại cảnh được mô tả ở đây: https://streamable.com/asu261
  • Có thể bạn còn nhớ nó từng được đưa lên một hai lần trước đây dưới tên Replicache
    Reflect là dạng bổ sung thêm một máy chủ đồng bộ được quản lý hoàn toàn và rất nhanh
    Mảng local-first/thời gian thực dạo này khá đông đúc, nhưng Replicache/Reflect đáng để xem vì mô hình dữ liệu và mô hình lập trình của chúng đơn giản một cách đẹp mắt
    So với CRDT, lợi thế là bạn có thể tự xử lý xung đột bằng mã tuần tự đơn giản; tôi cho rằng việc thêm cơ chế giải quyết xung đột theo từng ứng dụng vào CRDT có thể phức tạp khi các quy tắc tích hợp sẵn không phù hợp

  • Khoảng 15 năm trước, để tránh buồn chán ở nhà bà, tôi đã đào khá sâu chủ đề này và cũng thử triển khai bằng JavaScript
    Việc “các phép toán số học cứ thế hoạt động” dĩ nhiên là rất dễ biến thành phép toán lũy đẳng, nhưng tôi khó đồng ý với câu “các thao tác trên danh sách cũng cứ thế hoạt động”
    Ví dụ, với một mảng như [1, 2, 3, 4, 5], giả sử A xóa phạm vi 2~4, B xóa phạm vi 3~5, còn C chèn một thứ gì đó vào giữa 3 và 4; khi ba cập nhật này đến máy chủ cùng lúc thì cách giải quyết trở nên mơ hồ
    Nếu dựa vào timestamp hoặc ghi sau thắng (Last Write Wins) thì mô hình giao dịch bị phá vỡ; còn nếu chọn một bên thắng, thì việc những người dùng khác sẽ thấy gì và truyền đạt điều đó thế nào cũng là vấn đề
    Nếu câu trả lời là “gửi lại toàn bộ mảng” thì mô hình giao dịch cũng vẫn bị phá vỡ

    • Nhận xét đó hợp lý
      Thực ra cách diễn đạt mà chúng tôi muốn nói gần với “nhiều thao tác trên danh sách cứ thế hoạt động” hơn
      Trong Reflect, bạn không thể dùng chỉ mục danh sách làm định danh cho sửa/xóa, vì chỉ mục không ổn định; đây là lý do chúng tôi nói nếu là nguyên tử thì hãy dùng chính mục đó, còn thông thường hãy dùng ID ổn định: https://i.imgur.com/IKzmf0q.png
      Cũng có vấn đề là phần chèn của C có thể bị xóa, nhưng trong bối cảnh cộng tác thời gian thực, không giao thức nào có thể giải quyết hoàn toàn
      Từ góc nhìn của C, nội dung vừa viết có thể biến mất và gây buồn; những chuyện như vậy xảy ra do ý định khác nhau của những người cùng lúc làm việc trong cùng một không gian, nên các cơ chế xã hội như hoàn tác và hiển thị người đang thao tác sẽ hữu ích
    • Không cần lôi chuyện xóa vào, nếu “đồng thời” A thêm “a” vào danh sách L, B thêm “b”, và C thêm “c”, thì máy chủ sẽ áp dụng các phép thêm theo một thứ tự tùy ý rồi gửi kết quả cho client
      Trong lúc đó, mỗi client có thể đã áp dụng phần thêm của mình cục bộ, nên A thấy [“a”], B thấy [“b”], C thấy [“c”]; nếu máy chủ gửi trạng thái [“c”, “b”, “a”], có vẻ các client sẽ bỏ các thay đổi đang chờ và lấy trạng thái máy chủ làm sự thật của thế giới
      Nhưng nếu mỗi lần thêm tạo ra hiệu ứng kiểu “nếu thay đổi của tôi được áp dụng trước thì tôi thắng”, tôi tò mò liệu trong 300ms chờ cập nhật từ máy chủ, mọi người có đều thấy “you win” hay không
    • Phần lớn các hệ thống kiểu này xử lý xóa bằng tombstone
      Nhờ vậy có thể chèn an toàn sau hoặc giữa các mục đã bị xóa
    • Trong ứng dụng cộng tác, máy chủ có thể giải quyết ba thao tác theo bất kỳ thứ tự nào
      Ba người dùng sẽ nhìn kết quả, nhận ra rằng họ đã đồng thời đụng vào cùng dữ liệu và trạng thái bị rối, rồi sửa lại là được
      Cứ hình dung việc chỉnh sửa tài liệu
      Nếu không nhìn thấy nhau đang làm gì thì có thể sẽ bất ngờ, nhưng cuối cùng vẫn có thể sửa về trạng thái mong muốn
      Nếu không kiểm tra kết quả hoặc không thể kiểm tra, trạng thái sẽ không đúng, nhưng trong các ứng dụng tương tác như chỉnh sửa cộng tác hay trò chơi, luồng thường không diễn ra như vậy
  • Tôi là một trong những người tham gia dự án này, nếu có câu hỏi thì tôi có thể trả lời

    • Aaron khiêm tốn nên tôi nói thay: anh ấy đã tạo ra Greasemonkey và trong nhiều năm đã tham gia vào nhiều đổi mới mang tính đột phá của trình duyệt
    • Trước đây tôi từng xây dựng nhiều hệ thống đồng bộ multiplayer trong codebase không công khai, nên đã theo dõi lĩnh vực này
      Tôi cũng từng tạo một thư viện “redux-pubsub” có rebase và server là nguồn sự thật, theo hiểu biết của tôi thì khá giống TCR
      Có nhiều điểm trong mô hình này tôi thích, và bài viết được liên kết cũng rất rõ ràng
      Bạn nói “xác thực schema và migration gần như tự nhiên đi kèm miễn phí trong thiết kế”, tôi muốn biết cách nào thực sự hiệu quả với migration
      Ngoài ra, với use case xử lý lượng đáng kể chỉnh sửa văn bản dùng chung trong hệ thống TCR, thường tôi sẽ nghĩ ngay đến Yjs và Tiptap/ProseMirror; tôi thắc mắc liệu cách tốt nhất có phải là để tài liệu CRDT và tài liệu TCR chạy song song hay không
    • Tôi tò mò việc phát triển Replicache trong tương lai sẽ ra sao
      Tôi muốn biết codebase phía client có được chia sẻ phần lớn để có thể tiếp tục kỳ vọng các bản cập nhật, hay Reflect nhiều khả năng sẽ thay thế làm trọng tâm chính
    • Tôi muốn biết các bạn nghĩ gì về ElectricSQL
    • Tôi tò mò số người dùng tối đa có thể cùng vào một phòng là khoảng bao nhiêu
  • Thuật ngữ hơi gây nhầm lẫn
    Trong game có “người chơi” nên gọi hệ thống đồng bộ là “multiplayer”, nhưng trong phần mềm thông thường có “người dùng”, nên có vẻ gọi là đa người dùng thì đúng hơn
    Trên trang lại dùng lẫn “người dùng” và “multiplayer” nên đọc khá gượng

    • Có thể là vậy, nhưng “người dùng” nghe giống người tiêu dùng
      HN là một dịch vụ đa người dùng, nhưng gọi HN là multiplayer thì lại kỳ
      Tương tác đồng thời thời gian thực có điều gì đó mạnh hơn đa người dùng, và multiplayer với nghĩa nhiều người dùng cùng hành động và tương tác có vẻ là cách dùng ổn
    • Logic này dựa trên logic đã được dùng trong game multiplayer suốt nhiều thập kỷ
      Ngược lại, mọi phần mềm web đều là đa người dùng, nên chỉ nói vậy thì không cung cấp thông tin gì
    • Dạo này thuật ngữ đã được cố định như vậy rồi
      Multiplayer nghĩa là đa người dùng trực tiếp, nơi bạn trực quan hóa người dùng khác đang làm gì ở từng khoảnh khắc
  • Khoảng 2 năm nay tôi xem qua CRDT ở mức nhẹ và luôn thắc mắc phân quyền sẽ được xử lý thế nào
    Bài này dường như gợi ý rằng chỉ riêng các thư viện CRDT như Y.js thì khó xử lý đúng những ứng dụng mà thay đổi cần được kiểm tra quyền
    Vì không có một quyền uy trung tâm, tức server, còn Reflect có vẻ giả định server điều phối tương tác của client; không biết tôi hiểu vậy có đúng không

    • Nói “không làm được” thì hơi mạnh
      Nếu nỗ lực, vẫn có thể xử lý quyền trong CRDT; ví dụ có thể đặt server ở giữa mọi thứ và để server hoàn tác những thay đổi không có quyền mà nó thấy
      Nhưng khi ứng dụng lớn hơn, việc bảo trì tăng lên và dễ vỡ, đồng thời ngay từ đầu cũng làm mất một phần lợi thế của CRDT
      Nếu server đã nằm ở giữa, dùng một giao thức để server có thể từ chối message ngay từ đầu thì đơn giản hơn nhiều
    • CRDT cũng có thể bao gồm server, chỉ là server không nhất thiết phải luôn nằm chính giữa mọi thứ
      Ví dụ server có thể đảm nhiệm xác thực kết nối
      Nếu ai đó kết nối P2P và tuyên bố có một quyền cụ thể, có thể xác minh tuyên bố đó với server
      Ngoài ra CRDT không nhất thiết đồng nghĩa với P2P; vẫn có thể chuyển tiếp message qua server trung tâm mà duy trì mô hình CRDT giải quyết trạng thái hiện tại ở cả server lẫn client
    • Có thể xử lý quyền bằng cách từng thao tác hoặc message và xác minh xem có khớp với khóa công khai trong danh sách kiểm soát truy cập hay không
      Cách này cho phép CRDT hoạt động cả trong bối cảnh P2P
      Tuy nhiên nếu server là bên có thẩm quyền, chỉ cần từ chối message của client không có quyền là được
  • Tôi tò mò nâng cấp mutator được xử lý thế nào
    Nếu client đang chạy code cũ thì thao tác sẽ khác với server; câu trả lời hiển nhiên là quản lý phiên bản độc lập kiểu increment_v1, increment_v2, nhưng tôi muốn biết có cách nào tốt hơn không

    • Hiện tại không có câu trả lời nào tốt hơn việc không xóa mutator cũ cho đến khi biết rằng không còn client nào có thay đổi đang chờ xử lý
      Vì hiện chưa bật tính bền vững, nên khoảng thời gian đó hiện khá ngắn
  • Chúc mừng ra mắt
    Tôi tò mò liệu nó có cùng nhóm với https://partykit.io/ không

    • Cùng một lĩnh vực
      Khác biệt cốt lõi là mỗi bên có thiết kế mang tính định hướng mạnh đến mức nào
      PartyKit rất ít áp đặt, gần giống một server JavaScript nhẹ, khởi động nhanh và tự động mở rộng
      Có vẻ hầu hết mọi người chạy yjs trên PartyKit, nhưng cũng có thể chạy automerge hoặc Replicache
      Reflect hoàn toàn tập trung vào việc mang lại trải nghiệm multiplayer tốt nhất có thể, và kế hoạch là tích hợp chặt chẽ nhiều lựa chọn trên toàn bộ stack để multiplayer cứ thế hoạt động, giúp bạn tập trung vào việc triển khai ứng dụng
  • Nhân tiện, chiến lược này trong lĩnh vực phát triển game được gọi là deterministic lockstep, và đặc biệt thường được dùng trong các game có nhiều thực thể cần đồng bộ trạng thái
    Ví dụ tiêu biểu là các game chiến thuật thời gian thực, gần như đều dùng cách này

    • Chính xác hơn thì vì client không chờ phản hồi từ server trước khi hiển thị thay đổi cục bộ, nên nó gần với deterministic rollback hơn
      Có một bài thuyết trình GDC rất hay giải thích sâu về cách triển khai trong Mortal Kombat và Injustice 2: https://youtu.be/7jb0FOcImdg
    • Đây không phải là deterministic lockstep
      Deterministic lockstep là một thuật toán game P2P, trong đó mỗi bên tham gia chờ input của tất cả người chơi khác rồi mới tiến hành mô phỏng game
      Lý do gọi là “deterministic” là vì họ chia sẻ input chứ không phải kết quả mô phỏng, và mô phỏng là xác định với cùng một input; còn gọi là “lockstep” vì tất cả client cùng tiến lên theo một tốc độ được điều phối
      Vì series Age of Empires dùng cách này nên khi bấm chuột, đơn vị không di chuyển ngay lập tức; StarCraft cũng dùng cách này nhưng có các mẹo để cảm giác chơi mượt hơn
      Reflect gần với mô phỏng server-authoritative hơn là P2P
      Client gửi input lên server nhưng không chờ kết quả mà dự đoán cục bộ, còn server quay ngược thời gian và phát lại input để bù độ trễ của từng client
      Sau đó, khi client nhận kết quả từ server có bao gồm input của chính mình, nó sẽ hiệu chỉnh mô phỏng cục bộ
      Các từ khóa cốt lõi của thuật toán này là server-authoritative, prediction, lag compensation và prediction reconciliation
      Tôi không biết Reflect có phần này không, nhưng trong game FPS, nội suy phía client — khi nhận world update thì nội suy thực thể sang vị trí và góc xoay mới trong một khoảng thời gian — cũng rất phổ biến
      Vì chỉ có một server là bên có thẩm quyền nên tính xác định không quá quan trọng, nhưng nó hữu ích để giảm dự đoán sai trong client prediction
      Dự đoán sai xảy ra khi input từ client khác làm thay đổi lớn trạng thái thế giới, hoặc khi mô phỏng không xác định, chẳng hạn như sinh số ngẫu nhiên không được đồng bộ
      Counter Strike cũng có ví dụ không đồng bộ số ngẫu nhiên cho độ tản đạn để ngăn cheat “nospread”
      https://www.gabrielgambetta.com/client-side-prediction-live-...
      https://developer.valvesoftware.com/wiki/Latency_Compensatin...
      https://developer.valvesoftware.com/wiki/Source_Multiplayer_...
  • Reflect rất tuyệt
    Hiện chúng tôi đang dùng bản alpha trong production, và rất hài lòng không chỉ với hệ thống mà cả Aaron và đội ngũ
    Nếu có câu hỏi từ góc độ khách hàng, tôi có thể trả lời