- Reflect wurde als Framework veröffentlicht, um kollaborative Apps wie Figma, Notion oder Google Sheets schnell zu entwickeln. Es ergänzt Replicaches Synchronisierungs-Engine im Game-Stil um einen vollständig verwalteten Server.
- Kollaborative UIs müssen lokale Änderungen sofort anzeigen, ohne auf Serverantworten zu warten. Deshalb entscheidet die Art der Konfliktbehandlung, wenn gleichzeitig dieselben Daten geändert werden, maßgeblich über die Produkterfahrung.
- Reflect setzt statt CRDTs auf Transactional Conflict Resolution: Client und Server führen denselben Verlauf von Mutator-Aufrufen aus, und der Server erzeugt in der Reihenfolge des Eingangs den autoritativen Zustand.
- Sequence-CRDTs wie Yjs sind stark bei Text, Listen und Maps, doch in Fällen wie Countern, die eigene Merge-Regeln benötigen, können Inkremente verloren gehen. Reflect behandelt Arithmetik, Listenoperationen und übergeordnete Invarianten durch erneutes Ausführen von Mutatoren.
- Da der Server nur den Namen der Mutation und ihre Argumente erhält und das Ergebnis neu berechnet, vertraut er nicht auf vom Client berechnete Ergebnisse. Dadurch lassen sich Berechtigungsprüfungen, Schema-Validierung und Migrationen leichter in das Design integrieren.
Die von Reflect veröffentlichte Developer Experience
- Reflect ist ein neuer Ansatz zum Erstellen von Multiplayer-Web-Apps wie Figma, Notion oder Google Sheets.
- Es ist eine Weiterentwicklung des bestehenden clientseitigen Synchronisierungs-Frameworks Replicache und nutzt dieselbe Synchronisierungs-Engine im Game-Stil.
- Anders als Replicache enthält es einen vollständig verwalteten Server und zielt darauf ab, hochwertige Multiplayer-Apps in wenigen Minuten erstellen zu können.
- Reflect wird erstmals öffentlich angeboten; auf reflect.net findet man die Einführung, und unter hello.reflect.net kann man loslegen.
Wie Konflikte beim kollaborativen Bearbeiten entstehen
- Beim kollaborativen Bearbeiten entstehen zwangsläufig Konflikte.
- Für eine unmittelbar reagierende UI kann man nicht auf den Server warten; Änderungen müssen zuerst lokal auf dem Client stattfinden.
- Da mehrere Nutzer gleichzeitig dasselbe Element bearbeiten können, müssen Konflikte synchronisiert und natürlich aufgelöst werden, sodass alle Nutzer dasselbe Ergebnis sehen.
- Die Synchronisierungs-Engine beeinflusst die Developer Experience, die User Experience, die mögliche Performance und sogar die Arten von Apps, die sich bauen lassen.
CRDTs und ein Counter-Beispiel in Yjs
- Im Web-Ökosystem werden CRDTs häufig als Methode zur Datensynchronisierung verwendet.
- CRDTs sind Datenstrukturen, die auf denselben Wert konvergieren, sobald alle Änderungen zwischen Kollaborateuren ausgetauscht wurden. Yjs und Automerge sind bekannte Open-Source-CRDT-Bibliotheken.
- Reflect ist kein CRDT, sondern nutzt Transactional Conflict Resolution, eine Variante von Server Reconciliation, das in der Videospielbranche seit Langem verwendet wird.
-
Warum ein einfacher Counter in Yjs kaputtgehen kann
- Wenn man einen
count-Wert in einer Yjs Map speichert undprev + 1zurückschreibt, können in gleichzeitigen Situationen Inkremente verloren gehen. - Das korrekte Counter-Beispiel in der Yjs-Dokumentation fügt Zahlen zu einem Array hinzu und berechnet daraus die Summe.
- Yjs ist ein Sequence-CRDT und daher stark bei Listen, Textfragmenten und Maps, aber es ist schwierig, Counter natürlich zu modellieren.
- Der Merge-Algorithmus von Yjs Map arbeitet pro Schlüssel nach dem Prinzip last-write wins; wenn zwei Nutzer gleichzeitig erhöhen, kann eine der beiden Änderungen verschwinden.
- CRDTs passen gut zu bestimmten Problemen, haben aber die Einschränkung, dass sie schwer zu erweitern sind, wenn das Problem nicht dazu gehört.
- Wenn man einen
So funktioniert Transactional Conflict Resolution
- In Reflect werden Änderungen als spezielle JavaScript-Funktionen namens Mutators implementiert.
- Eine Kopie jedes Mutators existiert auf allen Clients und auf dem Server.
- Wenn ein Nutzer eine Änderung erstellt, erzeugt Reflect eine Mutation, also einen Aufrufverlauf eines Mutators.
- Eine Mutation enthält nur den Namen des Mutators und seine Argumente, etwa
increment(delta: 1). - Die daraus entstandenen Änderungen sind nicht in der Mutation enthalten.
- Eine Mutation enthält nur den Namen des Mutators und seine Argumente, etwa
- Reflect wendet die Mutation sofort lokal an, aktualisiert die UI, und der Nutzer sieht seine Änderung direkt.
-
Server-Linearisierung und erneute Ausführung
- Jeder Client fügt weiter Mutationen hinzu, ohne auf den Server zu warten.
- Mutationen werden zum Server gestreamt; der Server linearisiert die Mutationen in der Reihenfolge ihres Eingangs und erzeugt daraus den nächsten autoritativen Zustand.
- Wenn beispielsweise
increment(1)von Client 1 undincrement(2)von Client 2 gleichzeitig auftreten, entsteht der endgültige Zählerstand in der Serverausführung entsprechend der Eingangsreihenfolge. - Ohne gesondertes Wissen darüber, was
incrementtut oder wie es gemergt wird, führt der Server den Ausführungsverlauf linearisiert zusammen und merged so Konflikte. - Der aktuelle autoritative Zustand wird kontinuierlich an jeden Client gestreamt.
- Wenn ein Client erkennt, dass eine seiner ausstehenden Mutationen auf den autoritativen Zustand angewendet wurde, entfernt er sie aus der lokalen Queue.
- Verbleibende ausstehende Mutationen werden durch erneutes Ausführen des Mutator-Codes auf dem neuesten autoritativen Zustand rebased.
- Dieser gesamte Zyklus findet pro Client bis zu 120 Mal pro Sekunde statt.
Implementierungskosten und allgemeine Vorteile
- Für die Implementierung dieses Ansatzes braucht man einen schnellen Datastore, der Zurückspulen, Forks und Branches erstellen kann.
- Auch auf der Serverseite ist ein schneller Speicher nötig, um eingehende Mutationen zu verarbeiten.
- Außerdem braucht man eine Methode zur Synchronisierung von Mutatoren sowie eine Wiederherstellungslogik, wenn Client oder Server während der Synchronisierung in Konflikt geraten.
- Im Gegenzug funktioniert die Linearisierung beliebiger Funktionen als recht universelle Synchronisierungsstrategie.
-
Beispiele, die ohne separaten Synchronisierungscode behandelt werden
- Arithmetische Operationen werden natürlich verarbeitet.
setHighScorespeichert den größeren Wert aus bestehendemhigh-scoreund Kandidaten-Score.- Auch die meisten Listenoperationen funktionieren.
appendfügt ein Element am Ende einer Einkaufsliste hinzu.insertAtfügt ein Element an einer bestimmten Position ein, wobeisplice()die Position korrigiert.removesollte ein Element oder eine stabile ID als Argument erhalten, da sich Indizes ändern können.- Auch übergeordnete Invarianten können erzwungen werden.
addChildaktualisiertchildIDsdes Elternteils undparentIDdes Kindes gemeinsam, sodass sie stets konsistent bleiben.- Solche Beispiele werden auch ohne separaten, synchronisierungsbewussten Code sinnvoll gemergt.
Server-Autorität und Berechtigungsprüfungen
- In Reflect ist der Server autoritativ.
- Was der Client für das Ergebnis einer Änderung hält, wird weder mit dem Server noch mit anderen Clients geteilt.
- An den Server werden nur der Name der Mutation und ihre Argumente gesendet; der Server berechnet das Ergebnis der Mutation selbst neu.
- Der Server muss nicht einmal denselben Code wie der Client ausführen und kann auch externe Dienste abfragen oder Zufallszahlen verwenden.
-
Feingranulare Autorisierung
- Dieses Design macht es natürlich, feingranulare Berechtigungsprüfungen einzubauen.
- Ein Beispiel ist ein kollaboratives Designprogramm, in dem Gäste Kommentare und Hervorhebungen erstellen dürfen, echte Designänderungen aber verboten sind.
- Bei CRDTs ist das schwer umzusetzen, weil es keinen Ort gibt, an dem Logik zum Ablehnen nicht autorisierter Änderungen platziert werden kann.
- In Reflect kann ein auf dem Server ausgeführter Mutator Werte wie
tx.user.canEditprüfen und bei fehlender Berechtigung einenunauthorized-Fehler werfen. - Es ist kein Problem, wenn ein Mutator auf dem Server anderen Code ausführt als auf dem Client; der Server trifft die endgültige Entscheidung.
Schema-Validierung und Nutzungshinweise
- In Reflects Ansatz können auch Schema-Validierung und Migrationen natürlich in das Design integriert werden.
- Die Wahl der Synchronisierungsstrategie ist ein Kernpunkt von Multiplayer-Systemen, und Reflect sieht Transactional Conflict Resolution, gelernt aus Ansätzen der Spielebranche, als einfachen, flexiblen und leistungsfähigen Weg.
- Wer Multiplayer-Apps baut, kann es auf der Reflect-Startseite ausprobieren.
- Für Gespräche mit dem Entwicklerteam kann man discord.reflect.net oder @hello_reflect nutzen.
1 Kommentare
Meinungen auf Hacker News
Die Demo oben auf der Homepage (https://reflect.net/) macht ziemlich viel Spaß.
Wenn man zuschaut, sieht man, wie die Leute jedes Mal, wenn ein Puzzle fertig wird, vor Freude mit dem Cursor wackeln, als wollten sie sagen: „Wir haben es geschafft!“
Bei den anderen Buchstaben bleiben die Konturen weiter sichtbar, daher geht es dort nicht auf dieselbe Weise.
Wenn man ein Teil greift, scheint es für diesen Nutzer gesperrt zu sein, sodass es kaum Konflikte zu lösen gibt; übrig bleibt höchstens, dass es dem Nutzer gegeben wird, der es zuerst gegriffen hat, wenn zwei Personen es gleichzeitig nehmen.
Ich würde gern eine bessere Beispiel-Demo sehen, in der tatsächlich Konfliktlösung stattfindet.
Ein Video der beschriebenen Szene gibt es hier: https://streamable.com/asu261
Vielleicht erinnert man sich daran, dass das früher ein- oder zweimal als Replicache gepostet wurde.
Reflect ist im Grunde die Variante mit einem vollständig verwalteten, sehr schnellen Sync-Server.
Der Local-first-/Realtime-Bereich ist derzeit ziemlich belebt, aber Replicache/Reflect ist wegen seines wunderbar einfachen Daten- und Programmiermodells einen Blick wert.
Der Vorteil gegenüber CRDTs ist, dass man Konflikte direkt mit einfachem sequenziellem Code behandeln kann; ich denke, applikationsspezifische Konfliktlösung zu CRDTs hinzuzufügen, wenn die eingebauten Regeln nicht passen, kann kompliziert werden.
Auch PowerSync hat sich statt CRDTs für eine Server-Reconciliation-Architektur entschieden, und bei Anwendungen mit zentralem Server finde ich die Einfachheit dieser Struktur sehr attraktiv.
Ich möchte Aaron dafür Anerkennung geben, das Konzept der Server Reconciliation bekannt gemacht und verbreitet zu haben: 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 - Oktober 2022
Linear clone, with realtime sync and instant UI - built with Replicache - https://news.ycombinator.com/item?id=31331660 - Mai 2022
Replicache: Easy Offline-First for Existing Applications - https://news.ycombinator.com/item?id=22173500 - Januar 2020
Vor etwa 15 Jahren habe ich mich bei meiner Großmutter ziemlich tief mit diesem Thema beschäftigt, um der Langeweile zu entkommen, und auch eine JavaScript-Implementierung gebaut.
Dass „arithmetische Operationen einfach funktionieren“, ist natürlich sehr leicht als idempotente Operationen umzusetzen, aber der Aussage, dass „Listenoperationen auch einfach funktionieren“, kann ich schwer zustimmen.
Angenommen, in einem Array wie [1, 2, 3, 4, 5] löscht A den Bereich 2–4, B löscht den Bereich 3–5, und C fügt zwischen 3 und 4 etwas ein: Wenn alle drei Updates gleichzeitig beim Server eintreffen, ist die Lösung unklar.
Wenn man sich auf Timestamps oder Last Write Wins verlässt, bricht das Transaktionsmodell; und wenn man einen Gewinner auswählt, stellt sich auch die Frage, was die anderen Nutzer sehen und wie man ihnen das vermittelt.
Wenn die Antwort lautet: „das gesamte Array erneut senden“, dann bricht das Transaktionsmodell ebenfalls.
Gemeint war eigentlich eher: „Viele Listenoperationen funktionieren einfach.“
In Reflect kann man Listenindizes nicht als Bezeichner für Bearbeiten/Löschen verwenden, weil Indizes nicht stabil sind; deshalb heißt es, man solle bei atomaren Elementen das Element selbst verwenden, in der Regel aber eine stabile ID: https://i.imgur.com/IKzmf0q.png
Es gibt auch das Problem, dass Cs Einfügung gelöscht werden kann, aber im Kontext von Echtzeit-Zusammenarbeit kann kein Protokoll das vollständig lösen.
Aus Cs Sicht kann es traurig sein, dass der gerade geschriebene Inhalt verschwindet; solche Dinge entstehen, weil Menschen im selben Raum gleichzeitig mit unterschiedlichen Absichten arbeiten, und soziale Mechanismen wie Undo und Anzeigen der aktuell Bearbeitenden helfen dabei.
Währenddessen kann jeder Client seine eigene Änderung lokal angewendet haben, sodass A [„a“], B [„b“] und C [„c“] sieht; wenn der Server dann den Zustand [„c“, „b“, „a“] schickt, werden die Clients vermutlich ihre ausstehenden Änderungen verwerfen und den Serverzustand als Wahrheit der Welt übernehmen.
Wenn aber jedes Add einen Effekt wie „wenn meine Änderung zuerst angewendet wird, gewinne ich“ hat, frage ich mich, ob dann während der 300 ms Wartezeit auf das Server-Update alle „you win“ sehen.
Dann kann man sicher hinter oder zwischen gelöschten Elementen einfügen.
Die drei Nutzer sehen das Ergebnis, bemerken, dass sie gleichzeitig dieselben Daten angefasst haben und der Zustand durcheinandergeraten ist, und korrigieren ihn dann.
Man kann sich das wie Dokumentbearbeitung vorstellen.
Wenn man nicht sehen kann, dass andere gerade daran arbeiten, kann das überraschend sein, aber am Ende kann man es in den gewünschten Zustand bringen.
Wenn man das Ergebnis nicht prüft oder nicht prüfen kann, ist der Zustand zwar nicht korrekt, aber bei interaktiven Apps wie kollaborativem Editing oder Spielen läuft es normalerweise nicht so.
Ich bin einer der Beteiligten an diesem Projekt und kann Fragen beantworten, falls es welche gibt.
Ich habe auch eine „redux-pubsub“-Bibliothek mit Rebase und serverseitiger Autorität gebaut; soweit ich es verstehe, ähnelt sie TCR.
An diesem Modell gefällt mir vieles, und der verlinkte Artikel ist ebenfalls sehr klar.
Ihr sagt, „Schema-Validierung und Migrationen ergeben sich aus dem Design fast kostenlos auf natürliche Weise“; mich würde interessieren, welche Ansätze bei Migrationen in der Praxis gut funktioniert haben.
Außerdem würde man bei einem TCR-System für Anwendungsfälle mit einem beträchtlichen Anteil an gemeinsamem Text-Editing normalerweise zuerst an Yjs und Tiptap/ProseMirror denken; ich frage mich, ob es am besten ist, CRDT-Dokumente und TCR-Dokumente parallel zu halten.
Ich würde gern wissen, ob die clientseitige Codebase größtenteils gemeinsam genutzt wird und man weiter Updates erwarten kann, oder ob Reflect wahrscheinlich zum Hauptfokus wird und sie ersetzt.
Die Begriffe sind etwas verwirrend.
In Spielen gibt es „Spieler“, daher nennt man das Synchronisierungssystem „Multiplayer“, aber in normaler Software gibt es „Nutzer“, daher scheint Multi-User passender zu sein.
Auf der Seite werden „Nutzer“ und „Multiplayer“ gemischt verwendet, was sich etwas seltsam liest.
HN ist ein Multi-User-Dienst, aber es wäre seltsam, HN als Multiplayer zu bezeichnen.
Bei gleichzeitiger Echtzeit-Interaktion steckt etwas Stärkeres dahinter als nur Multi-User, und Multiplayer im Sinne von mehreren Nutzern, die handeln und interagieren, wirkt wie eine brauchbare Verwendung.
Umgekehrt ist jede Web-Software Multi-User, daher liefert dieser Begriff allein keinerlei Information.
Multiplayer bedeutet Live-Multi-User, bei dem visualisiert wird, was andere Nutzer in jedem Moment tun.
Ich habe mir CRDTs etwa zwei Jahre lang oberflächlich angesehen und mich immer gefragt, wie Autorisierung funktioniert.
Dieser Artikel scheint anzudeuten, dass CRDT-Bibliotheken wie Y.js allein Anwendungen, in denen Änderungen eine Berechtigungsprüfung durchlaufen müssen, nicht gut abbilden können.
Der Grund ist, dass es keine zentrale Autorität, also keinen Server, gibt; Reflect scheint anzunehmen, dass der Server Client-Interaktionen vermittelt. Ist dieses Verständnis richtig?
Mit Aufwand lässt sich Autorisierung auch bei CRDTs umsetzen, zum Beispiel indem man einen Server zwischen alles setzt und den Server nicht autorisierte Änderungen, die er sieht, zurückrollen lässt.
Aber je größer die Anwendung wird, desto wartungsintensiver und fragiler wird das, und außerdem gehen einige Vorteile von CRDTs von vornherein verloren.
Wenn ohnehin schon ein Server dazwischen ist, ist es viel einfacher, von Anfang an ein Protokoll zu verwenden, bei dem der Server Nachrichten ablehnen kann.
Zum Beispiel kann der Server die Authentifizierung von Verbindungen übernehmen.
Wenn sich jemand per P2P verbindet und bestimmte Berechtigungen behauptet, kann man diese Behauptung beim Server verifizieren.
Außerdem bedeutet CRDT nicht zwangsläufig P2P; man kann Nachrichten über einen zentralen Server weiterleiten und trotzdem ein CRDT-Modell beibehalten, bei dem der aktuelle Zustand sowohl auf Server- als auch auf Client-Seite aufgelöst wird.
Dieser Ansatz ermöglicht es CRDTs, auch in einem P2P-Kontext zu funktionieren.
Wenn jedoch der Server die Autorität ist, kann er Nachrichten nicht autorisierter Clients einfach ablehnen.
Mich interessiert, wie Mutator-Upgrades gehandhabt werden.
Wenn ein Client alten Code ausführt, werden seine Operationen anders ausfallen als auf dem Server; die offensichtliche Antwort wäre, sie unabhängig zu versionieren, etwa
increment_v1,increment_v2, aber ich frage mich, ob es einen besseren Weg gibt.Da Persistenz noch nicht aktiviert ist, ist dieser Zeitraum aktuell ziemlich kurz.
Glückwunsch zum Launch.
Ich frage mich, ob das in eine ähnliche Richtung geht wie https://partykit.io/.
Der zentrale Unterschied ist, wie stark opinionated das jeweilige Design ist.
PartyKit ist sehr unvorschreibend und eher ein leichtgewichtiger JavaScript-Server, mit dem man schnell loslegen kann und der automatisch skaliert.
Die meisten scheinen yjs auf PartyKit laufen zu lassen, aber man kann dort auch automerge oder Replicache betreiben.
Reflect ist vollständig darauf fokussiert, die bestmögliche Multiplayer-Erfahrung zu bieten, und plant, viele Entscheidungen über den gesamten Stack hinweg eng zu integrieren, damit Multiplayer einfach funktioniert und man sich auf die Implementierung der Anwendung konzentrieren kann.
Zur Einordnung: Diese Strategie wird in der Spieleentwicklung deterministic lockstep genannt und besonders häufig in Spielen eingesetzt, bei denen viele Entitäten ihren Zustand synchronisieren müssen.
Typische Beispiele sind Echtzeit-Strategiespiele, die fast alle diesen Ansatz verwenden.
Es gibt einen hervorragenden GDC-Vortrag, der die Implementierungen von Mortal Kombat und Injustice 2 ausführlich erklärt: https://youtu.be/7jb0FOcImdg
Deterministic lockstep ist ein P2P-Spielalgorithmus, bei dem jeder Teilnehmer auf die Eingaben aller anderen Spieler wartet und erst dann die Spielsimulation fortsetzt.
„Deterministisch“ ist er, weil nicht das Simulationsergebnis, sondern die Eingaben geteilt werden und die Simulation für dieselben Eingaben deterministisch ist; „Lockstep“, weil alle Clients in einem koordinierten Tempo voranschreiten.
Die Age-of-Empires-Reihe verwendet dieses Verfahren, weshalb Einheiten nach einem Klick nicht sofort loslaufen; StarCraft nutzt es ebenfalls, hat aber Tricks, um das Spielgefühl flüssiger zu machen.
Reflect ist nicht P2P, sondern ähnelt eher einer serverautoritativen Simulation.
Der Client sendet Eingaben an den Server, wartet aber nicht auf das Ergebnis, sondern sagt es lokal voraus; der Server dreht die Zeit zurück und spielt Eingaben erneut ab, um die Latenz einzelner Clients zu kompensieren.
Anschließend korrigiert der Client seine lokale Simulation, sobald er ein Serverergebnis erhält, das seine eigene Eingabe enthält.
Die Schlüsselbegriffe für diesen Algorithmus sind Serverautorität, Prediction, Latenzkompensation und Prediction Reconciliation.
Ich weiß nicht, ob Reflect das hat, aber in FPS-Spielen ist auch clientseitige Interpolation üblich, bei der Entitäten nach einem Welt-Update über eine gewisse Zeit zu ihrer neuen Position und Rotation interpoliert werden.
Da es nur einen autoritativen Server gibt, ist Determinismus nicht extrem wichtig, aber er hilft, Fehlvorhersagen bei der Client-Prediction zu reduzieren.
Fehlvorhersagen entstehen, wenn Eingaben anderer Clients den Weltzustand stark verändern oder wenn die Simulation nicht deterministisch ist, etwa weil Zufallszahlengenerierung nicht synchronisiert wird.
Counter-Strike ist auch ein Beispiel dafür, dass die Zufallswerte für die Streuung von Kugeln nicht synchronisiert werden, um „nospread“-Cheats zu verhindern.
https://www.gabrielgambetta.com/client-side-prediction-live-...
https://developer.valvesoftware.com/wiki/Latency_Compensatin...
https://developer.valvesoftware.com/wiki/Source_Multiplayer_...
Reflect ist großartig.
Wir verwenden derzeit die Alpha-Version in Produktion und sind nicht nur mit dem System, sondern auch mit Aaron und dem Team sehr zufrieden.
Wenn jemand aus Kundensicht Fragen hat, kann ich sie beantworten.