- 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; Yjs và Automerge 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ị
counttrong Yjs Map rồi ghi lạiprev + 1có 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 lưu giá trị
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
- Mutation chỉ chứa tên mutator và đối số, như
- 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ề
incrementlà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
setHighScorelưu giá trị lớn hơn giữahigh-scorehiệ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
appendthêm mục vào cuối danh sách mua sắminsertAtchèn mục vào vị trí chỉ định, cònsplice()điều chỉnh vị tríremovenê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
addChildcập nhật cùng lúcchildIDscủa cha vàparentIDcủ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ỗiunauthorized - 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
Ý 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!”
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
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
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
PowerSync cũng chọn kiến trúc điều phối bởi máy chủ thay vì CRDT, và trong các ứng dụng có máy chủ trung tâm, sự đơn giản của cấu trúc này rất hấp dẫn
Tôi muốn ghi công Aaron vì đã giới thiệu và truyền bá khái niệm điều phối bởi máy chủ: https://www.gabrielgambetta.com/client-side-prediction-serve...
A JavaScript framework to build offline-first but collaborative webapp - https://news.ycombinator.com/item?id=33269440 - tháng 10 năm 2022
Linear clone, with realtime sync and instant UI - built with Replicache - https://news.ycombinator.com/item?id=31331660 - tháng 5 năm 2022
Replicache: Easy Offline-First for Existing Applications - https://news.ycombinator.com/item?id=22173500 - tháng 1 năm 2020
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ỡ
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
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
Nhờ vậy có thể chèn an toàn sau hoặc giữa các mục đã bị xóa
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
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 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
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
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
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ì
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ế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
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á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ôngVì 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
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
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
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