Rozhodl jsem se vytvořit robustní editor kódu pro spolupráci na webu. Jmenuje se Codr a umožňuje vývojářům spolupracovat v reálném čase – například Dokumenty Google pro kód. Pro webové vývojáře slouží Codr jako sdílená reaktivní pracovní plocha, kde je každá změna okamžitě vykreslena pro všechny diváky. Podívejte se na nově spuštěnou kampaň Codr na Kickstarter, kde se dozvíte více.
Kolaborativní editor umožňuje více lidem upravovat stejný dokument současně a vzájemně si prohlížet své úpravy a změny výběru, jak k nim dojde. Souběžné úpravy textu umožňují poutavou a efektivní spolupráci, která by jinak byla nemožná. Sestavení Codr mi umožnilo lépe porozumět a (doufám) sdělit, jak vytvořit rychlou a spolehlivou aplikaci pro spolupráci.
Výzva
Pokud jste vytvořili společný editor nebo jste mluvili s někým, kdo to udělal, pak víte, že ladné zvládnutí souběžných úprav ve víceuživatelském prostředí je náročné. Ukazuje se však, že několik relativně jednoduchých konceptů tento problém značně zjednodušuje. Níže se podělím o to, co jsem se v tomto ohledu naučil prostřednictvím budování Codr.
Primárním problémem spojeným s kolaborativními úpravami je kontrola souběžnosti. Codr používá kontrolní mechanismus souběžnosti založený na provozní transformaci (OT). Pokud si chcete přečíst něco o historii a teorii OT, podívejte se na stránku wikipedie. Níže představím některé teorie, ale tento příspěvek je zamýšlen jako průvodce implementátorem a je spíše praktický než abstraktní.
Codr je postaven v JavaScriptu a příklady kódu jsou v JavaScriptu. Pro podporu společných úprav je třeba sdílet významnou logiku mezi serverem a klientem, takže backend uzlu/iojs je vynikající volbou. V zájmu čitelnosti jsou příklady kódu v ES6.
Naivní přístup ke společné editaci
V prostředí s nulovou latencí můžete napsat editor pro spolupráci, jako je tento:
Klient
editor.on('edit', (operation) => socket.send('edit', operation)); socket.on('edit', (operation) => editor.applyEdit(operation));
Server
socket.on('edit', (operation) => { document.applyEdit(operation); getOtherSockets(socket).forEach((otherSocket) => otherSocket.emit('edit', operation) ); });
Každá akce je koncipována jako vložení nebo smazat úkon. Každá operace je:
- Místně použito v komponentě pro úpravy
- Odesláno na server
- Použito na kopii dokumentu na straně serveru
- Vysílejte dalším vzdáleným editorům
- Místně použito na kopii dokumentu každého vzdáleného editora
Latency Breaks Things
Když však zavedete latenci mezi klientem a serverem, narazíte na problémy. Jak jste pravděpodobně předpokládali, latence v kolaborativním editoru představuje možnost konfliktů verzí. Například:
Počáteční stav dokumentu:
bcd
Uživatel 1 vloží a
na začátku dokumentu. Operace vypadá takto:
{ type: 'insert', lines: ['a'], range: { start: { row: 0, column: 0} end: {row: 0, column: 1} } }
Zároveň Uživatel 2 typy e
na konci dokumentu:
{ type: 'insert', lines: ['e'], range: { start: { row: 0, column: 3} end: {row: 0, column: 4} } }
Co by měl stalo se, že Uživatel 1 a Uživatel 2 skončí s:
abcde
Ve skutečnosti Uživatel 1 vidí:
bcd <-- Starting Document State abcd <-- Apply local "insert 'a'" operation at offset 0 abced <-- Apply remote "insert 'e'" operation at offset 3
A Uživatel 2 vidí:
bcd <-- Starting Document State bcde <-- Apply local "insert 'e'" operation at offset 3 abcde <-- Apply remote "insert 'a'" operation at offset 0
Jejda! 'abced' != 'abcde'
- sdílený dokument je nyní v nekonzistentním stavu.
Snadná oprava je příliš pomalá
K výše uvedenému konfliktu dochází, protože každý uživatel „optimisticky“ aplikuje úpravy lokálně, aniž by se nejprve ujistil, že úpravy neprovádí nikdo jiný. Od Uživatele 1 změnil dokument z části Uživatel 2 , došlo ke konfliktu. Uživatel 2 operace úprav předpokládá stav dokumentu, který v době, kdy je aplikován na Uživatele 1, již neexistuje 's dokument.
Jednoduchá oprava spočívá v přechodu na pesimistický model řízení souběžnosti, kde každý klient požaduje výhradní zámek proti zápisu ze serveru před místní aplikací aktualizací. Tím se zcela vyhnete konfliktům. Bohužel zpoždění vyplývající z takového přístupu na průměrném internetovém připojení by editor učinilo nepoužitelným.
Operační transformace k záchraně
Operational Transformation (OT) je technika, která podporuje souběžné úpravy bez kompromisů ve výkonu. Pomocí OT každý klient optimisticky aktualizuje svůj vlastní dokument lokálně a implementace OT zjistí, jak automaticky vyřešit konflikty.
OT nařizuje, že když aplikujeme vzdálenou operaci, nejprve operaci „transformujeme“, abychom kompenzovali konfliktní úpravy od jiných uživatelů. Cíle jsou dvojí:
- Zajistěte, aby všichni klienti měli konzistentní stavy dokumentu
- Zajistěte, aby byl zachován záměr každé operace úprav
V mém původním příkladu bychom chtěli transformovat Uživatele 2 operace vložení, která se má vložit s posunem znaků 4
místo offsetu 3
když jej použijeme na Uživatele 1 's dokument. Tímto způsobem respektujeme Uživatele 2 záměr uživatele vložte e
po d
a zajistit, aby oba uživatelé skončili se stejným stavem dokumentu.
Pomocí OT, Uživatel 1 uvidí:
bcd <-- Starting Document State abcd <-- Apply local "insert 'a'" operation at offset 0 abcde <-- Apply TRANSFORMED "insert 'e'" operation at offset 4
A Uživatel 2 uvidí:
bcd <-- Starting Document State bcde <-- Apply local "insert 'e'" operation at offset 3 abcde <-- Apply remote "insert 'a'" operation at offset 0
Životní cyklus operace
Užitečným způsobem, jak vizualizovat, jak jsou úpravy synchronizovány pomocí OT, je představit si společný dokument jako úložiště git:
- Operace úprav jsou potvrzení
- Server je hlavní větev
- Každý klient je tematickou větví od master
Sloučení úprav do hlavního serveru (na straně serveru) Když provedete úpravu v Codr, dojde k následujícímu:
- Klient Codr se větví z master a lokálně použije vaši úpravu
- Klient Codr odešle serveru požadavek na sloučení
Zde je gitův krásný (mírně upravený) diagram. Dopisy odkazující na potvrzení (operace):
Před sloučením:
A topic (client) / D---E---F master (server)
Po sloučení:
A ------ topic / \ D---E---F---G master
K provedení sloučení server aktualizuje (transformuje) operaci A
takže to stále dává smysl ve světle předchozích operací E
a F
, pak použije transformovanou operaci (G
) zvládnout. Transformovaná operace je přímo analogická git merge commit.
Převedení na hlavní (na straně klienta) Poté, co je operace transformována a aplikována na straně serveru, je vysílána ostatním klientům. Když klient obdrží změnu, provede ekvivalent git rebase:
- Vrátí všechny „nevyřízené“ (nesloučené) místní operace
- Použije vzdálené ovládání
- Znovu použije čekající operace a transformuje každou operaci na novou operaci ze serveru
Tím, že změní základ klienta místo sloučení vzdálené operace, jak se to děje na straně serveru, Codr zajistí, že úpravy budou aplikovány ve stejném pořadí u všech klientů.
Vytvoření kanonického pořadí operací úprav
Důležité je pořadí, ve kterém jsou operace úprav aplikovány. Představte si, že dva uživatelé zadají znaky a
a b
současně při stejném ofsetu dokumentu. Pořadí, ve kterém se operace provádějí, určí, zda ab
nebo ba
je ukázáno. Vzhledem k tomu, že latence je proměnná, nemůžeme s jistotou vědět, v jakém pořadí se události skutečně odehrály, ale je důležité, aby se všichni klienti shodli na stejném objednávání akcí. Codr považuje pořadí, ve kterém události přicházejí na server, za kanonické pořadí.
Server ukládá číslo verze dokumentu, které se zvýší při každém použití operace. Když server přijme operaci, označí operaci číslem aktuální verze, než ji rozešle ostatním klientům. Server také odešle klientovi zprávu o zahájení operace s uvedením nové verze. Tímto způsobem každý klient ví, jaká je jeho "verze serveru".
Kdykoli klient odešle operaci na server, odešle také aktuální verzi serveru klienta. To říká serveru, kde se klient "rozvětvil", takže server ví, proti jakým předchozím operacím je třeba novou změnu transformovat.
Transformace operace
Jádrem Codrovy OT logiky je tato funkce:
function transformOperation(operation1, operation2) { // Modify operation2 such that its intent is preserved // subsequent to intervening change operation1 }
Nebudu se zde rozepisovat o celé logice, protože se to zapojuje, ale zde jsou některé příklady:
-
Pokud
op1
vložený řádek(y) předop2
's řádek, zvyšteop2
's řádkový offset odpovídajícím způsobem. -
Pokud
op1
vložený text předop2
na stejném řádku zvyšteop2
příslušně odsazený znak. -
Pokud
op1
došlo zcela poop2
, pak nic nedělejte. -
Pokud
op1
vloží text do rozsahuop2
odstraní a poté zvětšíop2
's rozsah odstranění pro zahrnutí vloženého textu a přidání vloženého textu. Poznámka :Dalším přístupem by bylo rozděleníop2
na dvě akce odstranění, jednu na každé straněop1
vložení 's, čímž se zachová vložený text. -
Pokud
op1
aop2
jsou operace odstranění rozsahu a rozsahy se překrývají, pak se zmenšíop2
rozsah mazání 's tak, aby zahrnoval pouze text NEODSTRANĚNÝop1
.
Synchronizace pozice a výběru kurzoru
Uživatelský výběr je jednoduše rozsah textu. Pokud start
a end
body rozsahu jsou stejné, pak rozsah je sbalený kurzor. Když se změní výběr uživatele, klient odešle nový výběr na server a server tento výběr rozešle ostatním klientům. Stejně jako u editačních operací Codr transformuje výběr proti konfliktním operacím od jiných uživatelů. Transformační logika pro výběr je jednoduše podmnožinou logiky potřebné k transformaci insert
nebo delete
operace.
Vrátit zpět/Znovu
Codr dává každému uživateli vlastní zásobník zpět. To je důležité pro dobrý zážitek z úprav:jinak stiskněte CMD+Z
mohl vrátit zpět úpravy někoho jiného v jiné části dokumentu.
Poskytnutí vlastního zásobníku zpět každému uživateli také vyžaduje OT. Ve skutečnosti jde o jeden případ, kdy by OT bylo nutné i v prostředí s nulovou latencí. Představte si následující scénář:
abc <-- User 1 types "abc" abcde <-- User 2 types "de" ce <-- User 1 deletes "bcd" ?? <-- User 2 hits CMD+Z
Uživatel2 poslední akce uživatele byla:
{ type: 'insert', lines: ['de'], range: { start: { row: 0, column: 3} end: {row: 0, column: 5} } }
Inverzní (zpět) akce by byla:
{ type: 'delete', lines: ['de'], range: { start: { row: 0, column: 3} end: {row: 0, column: 5} } }
Ale evidentně nemůžeme použít jen inverzní akci. Díky Uživateli 1 ' s intervenující změnou, již neexistuje posun znaků 3
v dokumentu!
Opět můžeme použít OT:
var undoOperation = getInverseOperation(myLastOperation); getOperationsAfterMyLastOperation().forEach((operation) => transformOperation(operation, undoOperation); ); editor.applyEdit(undoOperation); socket.emit('edit', undoOperation);
Transformací operace undo proti následným operacím z jiných klientů Codr místo toho použije následující operaci na undo, čímž dosáhne požadovaného chování.
{ type: 'delete', lines: ['e'], range: { start: { row: 0, column: 1} end: {row: 0, column: 2} } }
Správná implementace undo/redo je jedním z nejnáročnějších aspektů vytváření kolaborativního editoru. Úplné řešení je poněkud složitější než to, co jsem popsal výše, protože potřebujete vrátit zpět souvislé vložení a odstranění jako jednotku. Od operací, které byly souvislý se může stát nesouvislým kvůli úpravám provedeným jinými spolupracovníky, to není triviální. Co je ale skvělé, je, že můžeme znovu použít stejné OT, které se používá pro synchronizaci úprav, abychom dosáhli historie vracení pro jednotlivé uživatele.
Závěr
OT je výkonný nástroj, který nám umožňuje vytvářet vysoce výkonné aplikace pro spolupráci s podporou neblokujících souběžných úprav. Doufám, že toto shrnutí společné implementace Codr poskytuje užitečný výchozí bod pro pochopení OT. Velké díky Davidovi za jeho pozvání, abych mohl sdílet tento kousek na jeho blogu.
Chcete se dozvědět více o Codr? Podívejte se na kampaň KickStarter nebo tweetujte na @CodrEditor a požádejte o pozvánku.