- Reflect é um framework lançado para criar rapidamente apps colaborativos como Figma, Notion e Google Sheets, adicionando um servidor totalmente gerenciado ao mecanismo de sincronização no estilo de jogos do Replicache
- Como interfaces colaborativas precisam mostrar mudanças locais imediatamente, sem esperar resposta do servidor, a forma de lidar com conflitos ao editar os mesmos dados ao mesmo tempo define a experiência do produto
- Em vez de CRDT, o Reflect adota Transactional Conflict Resolution, em que cliente e servidor executam o mesmo histórico de chamadas de mutator e o servidor cria o estado autoritativo na ordem de chegada
- CRDTs de sequência como o Yjs são fortes para texto, listas e mapas, mas em casos que exigem regras de mesclagem próprias, como contadores, incrementos podem se perder; o Reflect lida com aritmética, manipulação de listas e invariantes de nível superior reexecutando mutators
- Como o servidor recebe apenas o nome da mutation e seus argumentos para recalcular o resultado, ele não confia no resultado calculado pelo cliente e fica mais fácil incorporar verificação de permissões, validação de esquema e migrações ao design
A experiência de desenvolvimento apresentada pelo Reflect
- Reflect é uma nova forma de criar apps web multiplayer como Figma, Notion e Google Sheets
- É uma evolução do framework de sincronização no lado do cliente Replicache e usa o mesmo mecanismo de sincronização no estilo de jogos
- Diferente do Replicache, inclui um servidor totalmente gerenciado e tem como objetivo permitir a criação de apps multiplayer de alta qualidade em poucos minutos
- O Reflect está sendo disponibilizado publicamente pela primeira vez, e é possível ver a apresentação em reflect.net e começar em hello.reflect.net
Como surgem conflitos na edição colaborativa
- Na edição colaborativa, conflitos são inevitáveis
- Para criar uma UI com resposta imediata, não dá para esperar o servidor, então as mudanças precisam acontecer primeiro no cliente local
- Como vários usuários podem editar o mesmo item ao mesmo tempo, é necessário sincronizar os conflitos e resolvê-los de forma natural para que todos vejam o mesmo resultado
- O mecanismo de sincronização influencia a experiência de desenvolvimento, a experiência do usuário, o desempenho possível e até os tipos de app que podem ser criados
CRDT e o exemplo de contador no Yjs
- No ecossistema web, CRDT é amplamente usado como forma de sincronizar dados
- CRDT é uma estrutura de dados que converge para o mesmo valor quando todas as mudanças entre colaboradores são trocadas, e Yjs e Automerge são bibliotecas CRDT open source representativas
- O Reflect não é um CRDT; ele usa Transactional Conflict Resolution, uma adaptação de Server Reconciliation, técnica usada há muito tempo na indústria de videogames
-
Por que um contador simples quebra no Yjs
- Armazenar um valor
countem um Yjs Map e regravarprev + 1pode perder incrementos em cenários de concorrência - O exemplo correto de contador na documentação do Yjs adiciona números a um array e calcula a soma
- O Yjs é um CRDT de sequência, então é forte para listas, fragmentos de texto e mapas, mas tem dificuldade para modelar contadores de forma natural
- O algoritmo de mesclagem de Yjs Map é last-write wins por chave, então se dois usuários incrementarem ao mesmo tempo, uma das mudanças pode desaparecer
- CRDT funciona muito bem para certos problemas, mas tem a limitação de ser difícil de estender quando o problema é outro
- Armazenar um valor
Como funciona o Transactional Conflict Resolution
- No Reflect, mudanças são implementadas como funções JavaScript especiais chamadas mutators
- Uma cópia de cada mutator existe em todos os clientes e no servidor
- Quando o usuário faz uma mudança, o Reflect cria uma mutation, que é o registro da chamada do mutator
- A mutation contém apenas o nome do mutator e seus argumentos, como
increment(delta: 1) - O conteúdo da mudança produzida não é incluído na mutation
- A mutation contém apenas o nome do mutator e seus argumentos, como
- O Reflect aplica a mutation imediatamente no ambiente local para atualizar a UI, então o usuário vê sua própria alteração na hora
-
Linearização no servidor e reexecução
- Cada cliente continua adicionando mutations sem esperar o servidor
- As mutations são transmitidas em streaming para o servidor, e o servidor as lineariza pela ordem de chegada para gerar o próximo estado autoritativo
- Por exemplo, se
increment(1)do cliente 1 eincrement(2)do cliente 2 acontecerem ao mesmo tempo, a contagem final no servidor será criada conforme a ordem de chegada - O servidor mescla conflitos linearizando o histórico de execução, sem precisar de conhecimento separado sobre o que
incrementfaz ou como ele deve ser mesclado - O estado autoritativo mais recente continua sendo transmitido em streaming para cada cliente
- Quando o cliente sabe que suas mutations pendentes já foram aplicadas ao estado autoritativo, ele as remove da fila local
- As mutations pendentes restantes são rebaseadas sobre o estado autoritativo mais recente, reexecutando o código do mutator
- Todo esse ciclo pode acontecer até 120 vezes por segundo por cliente
Custo de implementação e vantagens generalizáveis
- Para implementar essa abordagem, é preciso um datastore rápido capaz de voltar no tempo, fazer fork e criar branches
- No lado do servidor, também é necessário um armazenamento rápido para processar as mutations recebidas
- Também são necessários mecanismos para sincronizar mutators e para recuperar o sistema quando cliente ou servidor entram em conflito durante a sincronização
- Em troca, a linearização de funções arbitrárias funciona como uma estratégia de sincronização bastante genérica
-
Exemplos que funcionam sem código de sincronização separado
- Operações aritméticas funcionam naturalmente
setHighScoregrava o maior valor entre ohigh-scoreexistente e a pontuação candidata- A maioria das manipulações de lista também funciona
appendadiciona um item ao fim de uma lista de comprasinsertAtinsere um item na posição especificada, esplice()ajusta a posiçãoremoveprecisa receber o item ou um ID estável como argumento, porque o índice pode mudar- Também é possível impor invariantes de nível mais alto
addChildatualiza junto ochildIDsdo pai e oparentIDdo filho para que permaneçam sempre consistentes- Esses exemplos são mesclados de forma razoável mesmo sem código separado pensado especificamente para sincronização
Autoridade do servidor e verificação de permissões
- No Reflect, o servidor é a autoridade
- O que o cliente acha que foi o resultado de uma mudança não é compartilhado com o servidor nem com outros clientes
- Apenas o nome da mutation e seus argumentos são enviados ao servidor, e o servidor recalcula diretamente o resultado da mutation
- O servidor nem precisa executar exatamente o mesmo código do cliente; ele pode consultar serviços externos ou usar números aleatórios
-
Autorização detalhada
- Nesse design, é natural inserir uma verificação detalhada de permissões
- Um exemplo é um programa colaborativo de design em que convidados podem comentar e destacar trechos, mas não podem alterar o design em si
- Em CRDT, é difícil implementar isso porque não há um lugar adequado para colocar a lógica que rejeita mudanças sem permissão
- No Reflect, o mutator executado no servidor pode verificar um valor como
tx.user.canEdite lançar um errounauthorizedse não houver permissão - Não há problema se o mutator executar código diferente no servidor e no cliente, porque o servidor toma a decisão final
Validação de esquema e orientação de uso
- Na abordagem do Reflect, validação de esquema e migrações também podem ser incorporadas naturalmente ao design
- Escolher a estratégia de sincronização é central em sistemas multiplayer, e o Reflect vê o Transactional Conflict Resolution, aprendido com a indústria de jogos, como uma abordagem simples, flexível e poderosa
- Se você está criando um app multiplayer, pode testar em página inicial do Reflect
- Para conversar com a equipe de desenvolvimento, use discord.reflect.net ou @hello_reflect
1 comentários
Opiniões no Hacker News
A demo no topo da página inicial (https://reflect.net/) é bem divertida
Ao assistir, sempre que o quebra-cabeça é concluído, as pessoas parecem comemorar balançando o cursor, como se dissessem “conseguimos!”
Com as outras letras, o contorno continua aparecendo, então não dá para fazer do mesmo jeito
Quando você pega uma peça, ela parece ficar bloqueada para aquele usuário, então quase não há conflitos a resolver; sobra basicamente o caso de duas pessoas pegarem ao mesmo tempo e a peça ficar com quem pegou primeiro
Gostaria de ver uma demo melhor em que a resolução de conflitos de fato aconteça
O vídeo com a cena descrita está aqui: https://streamable.com/asu261
Talvez você se lembre de isso já ter aparecido uma ou duas vezes antes como Replicache
O Reflect é uma versão que adiciona um servidor de sincronização totalmente gerenciado e muito rápido
A área de local-first/tempo real está bem movimentada hoje em dia, e vale a pena conferir Replicache/Reflect porque o modelo de dados e o modelo de programação são belamente simples
Em comparação com CRDTs, a vantagem é que você pode tratar conflitos diretamente como código sequencial simples; acho que pode ser complexo adicionar resolução de conflitos específica da aplicação a CRDTs quando as regras embutidas não servem
O PowerSync também escolheu uma arquitetura de reconciliação pelo servidor em vez de CRDTs, e acho a simplicidade dessa estrutura muito atraente em aplicações que têm um servidor central
Quero dar crédito ao Aaron por divulgar e disseminar o conceito de reconciliação pelo servidor: 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 - outubro de 2022
Linear clone, with realtime sync and instant UI - built with Replicache - https://news.ycombinator.com/item?id=31331660 - maio de 2022
Replicache: Easy Offline-First for Existing Applications - https://news.ycombinator.com/item?id=22173500 - janeiro de 2020
Cerca de 15 anos atrás, na casa da minha avó, mergulhei bastante nesse assunto para fugir do tédio e até fiz uma implementação em JavaScript
É claro que “operações aritméticas simplesmente funcionam” é algo muito fácil de tornar idempotente, mas tenho dificuldade em concordar com a afirmação de que “operações de lista também simplesmente funcionam”
Por exemplo, imagine um array como [1, 2, 3, 4, 5], em que A remove o intervalo de 2 a 4, B remove o intervalo de 3 a 5, e C insere algo entre 3 e 4; quando as três atualizações chegam ao servidor ao mesmo tempo, a solução fica ambígua
Se você se apoia em timestamps ou em Last Write Wins, o modelo transacional quebra; se escolhe uma operação como vencedora, também surge o problema do que os outros usuários verão e de como comunicar isso
Se a resposta for “reenviar o array inteiro”, o modelo transacional também quebra
Na verdade, a intenção era mais próxima de “muitas operações de lista simplesmente funcionam”
No Reflect, você não pode usar índices de lista como identificadores de edição/exclusão, porque índices não são estáveis; é por isso que a recomendação é usar o próprio item quando ele for atômico e, normalmente, um ID estável: https://i.imgur.com/IKzmf0q.png
Também existe o problema de a inserção de C poder ser apagada, mas, no contexto de colaboração em tempo real, nenhum protocolo consegue resolver isso completamente
Do ponto de vista de C, pode ser triste ver o que acabou de escrever desaparecer; esse tipo de coisa acontece porque humanos trabalhando simultaneamente no mesmo espaço têm intenções diferentes, e mecanismos sociais como desfazer e indicação de quem está trabalhando agora ajudam
Enquanto isso, cada cliente pode ter aplicado localmente a própria adição, então A vê [“a”], B vê [“b”] e C vê [“c”]; quando o servidor envia o estado [“c”, “b”, “a”], imagino que os clientes descartem as mudanças pendentes e passem a tratar o estado do servidor como a verdade do mundo
Mas, se cada adição tiver um efeito como “se a minha mudança for aplicada primeiro, eu venço”, fico curioso se, durante os 300 ms de espera pela atualização do servidor, todo mundo verá “you win”
Assim, é possível inserir com segurança depois ou entre itens excluídos
Os três usuários verão o resultado, perceberão que mexeram nos mesmos dados ao mesmo tempo e que o estado ficou bagunçado, e então corrigirão
Pense em edição de documentos
Se eles não conseguem ver que estão trabalhando ao mesmo tempo, pode ser surpreendente, mas no fim conseguem corrigir para chegar ao estado desejado
Se não verificarem o resultado, ou não puderem verificá-lo, o estado não estará correto; mas, em apps interativos como edição colaborativa ou jogos, normalmente não é assim que as coisas acontecem
Sou uma das pessoas que participou deste projeto e posso responder se houver perguntas
Também criei uma biblioteca “redux-pubsub” com rebase e autoridade do servidor, que, pelo que entendi, é parecida com TCR
Há muitas partes desse modelo de que gosto, e o artigo linkado também é muito claro
Vocês disseram que “validação de esquema e migrações vêm naturalmente, quase de graça, do design”, e fiquei curioso sobre quais abordagens realmente funcionaram bem para migrações
Também me pergunto se, em casos de uso que lidam com uma quantidade considerável de edição de texto compartilhada em um sistema TCR, o padrão normalmente seria pensar primeiro em Yjs e Tiptap/ProseMirror; nesse caso, deixar um documento CRDT e um documento TCR em paralelo seria a melhor opção?
Gostaria de saber se a maior parte da codebase do lado do cliente é compartilhada e podemos continuar esperando atualizações, ou se é mais provável que o Reflect substitua isso como foco principal
A terminologia é meio confusa
Em jogos há “jogadores”, então faz sentido chamar o sistema de sincronização de “multiplayer”, mas em software comum há “usuários”, então parece mais correto chamar de multiusuário
Na página, misturar “usuário” e “multiplayer” soa estranho
O HN é um serviço multiusuário, mas seria estranho chamar o HN de multiplayer
Interação simultânea em tempo real tem algo mais forte do que multiusuário, e multiplayer, no sentido de várias pessoas agindo e interagindo, parece um uso aceitável
Por outro lado, todo software web é multiusuário, então essa palavra sozinha não informa nada
Multiplayer significa multiusuário ao vivo, em que se visualiza o que outros usuários estão fazendo a cada momento
Passei uns 2 anos olhando CRDTs por alto e sempre me perguntei como fica a autorização
Este texto parece sugerir que apenas bibliotecas CRDT como Y.js têm dificuldade para lidar bem com aplicações em que alterações precisam passar por verificação de permissão
Isso seria porque não há uma autoridade central, isto é, um servidor, e o Reflect parece assumir que o servidor media as interações dos clientes; queria saber se esse entendimento está correto
Com esforço, é possível tratar autorização em CRDTs; por exemplo, colocando um servidor entre tudo e fazendo o servidor reverter alterações sem permissão que ele vê
Mas, à medida que a aplicação cresce, isso exige mais manutenção, fica frágil e também elimina parte das vantagens do CRDT desde o início
Se o servidor já está no meio, é muito mais simples usar um protocolo em que o servidor possa rejeitar mensagens desde o começo
Por exemplo, a autenticação de conexão pode ficar a cargo do servidor
Se alguém se conectar via P2P e reivindicar determinada permissão, é possível verificar essa reivindicação com o servidor
Além disso, CRDT não significa necessariamente P2P; é possível encaminhar mensagens por um servidor central e ainda manter um modelo CRDT em que tanto servidor quanto clientes resolvem o estado atual
Essa abordagem permite que CRDTs funcionem também em contexto P2P
Mas, se o servidor for a autoridade, basta rejeitar as mensagens de clientes sem permissão
Fiquei curioso sobre como tratam upgrades de mutators
Se um cliente estiver executando código antigo, a operação vai divergir do servidor; a resposta óbvia seria versionar de forma independente, como
increment_v1,increment_v2, mas queria saber se há uma forma melhorComo ainda não ativamos persistência, atualmente esse período é bem curto
Parabéns pelo lançamento
Fiquei curioso se é algo da mesma família que https://partykit.io/
A principal diferença é o quanto cada um tem uma arquitetura opinativa
O PartyKit é bem não prescritivo, mais próximo de um servidor JavaScript leve, com início rápido e escalabilidade automática
Parece que a maioria roda yjs no PartyKit, mas também é possível rodar automerge ou Replicache
O Reflect é totalmente focado em oferecer a melhor experiência multiplayer possível e planeja integrar fortemente muitas escolhas em toda a stack para fazer o multiplayer simplesmente funcionar e permitir que você se concentre na implementação da aplicação
Para referência, essa estratégia é chamada de lockstep determinístico no desenvolvimento de jogos, e é usada com bastante frequência em jogos com muitas entidades cujo estado precisa ser sincronizado
Exemplos representativos são os jogos de estratégia em tempo real, que quase todos usam esse método
Há uma ótima palestra da GDC que explica em profundidade as implementações de Mortal Kombat e Injustice 2: https://youtu.be/7jb0FOcImdg
Lockstep determinístico é um algoritmo para jogos P2P, em que cada participante espera as entradas de todos os outros jogadores antes de avançar a simulação do jogo
Ele é “determinístico” porque o que se compartilha não é o resultado da simulação, mas as entradas, e a simulação é determinística para as mesmas entradas; e é “lockstep” porque todos os clientes avançam em um ritmo coordenado
Como a série Age of Empires usa esse método, as unidades não se movem imediatamente quando você clica; StarCraft também o usa, mas tem truques para suavizar a sensação de jogo
Reflect está mais perto de uma simulação autoritativa no servidor do que de P2P
O cliente envia entradas ao servidor, mas não espera o resultado e faz predição localmente; o servidor volta no tempo e reproduz as entradas para compensar a latência de cada cliente
Depois, quando o cliente recebe o resultado do servidor que inclui sua própria entrada, ele corrige a simulação local
As palavras-chave desse algoritmo são autoridade do servidor, predição, compensação de latência e reconciliação de predição
Não sei se isso existe no Reflect, mas em jogos FPS também é comum a interpolação do lado do cliente, em que, ao receber uma atualização do mundo, as entidades são interpoladas para a nova posição e rotação ao longo de certo período
Como a única autoridade é o servidor, o determinismo não é tão importante, mas é útil para reduzir erros de predição na predição do cliente
Erros de predição ocorrem quando a entrada de outros clientes altera muito o estado do mundo, ou quando a simulação não é determinística, por exemplo por a geração de números aleatórios não estar sincronizada
Counter Strike também é um exemplo de não sincronizar a aleatoriedade da dispersão dos tiros para impedir cheats de “nospread”
https://www.gabrielgambetta.com/client-side-prediction-live-...
https://developer.valvesoftware.com/wiki/Latency_Compensatin...
https://developer.valvesoftware.com/wiki/Source_Multiplayer_...
O Reflect é excelente
Estamos usando a versão alfa em produção atualmente, e estamos muito satisfeitos não só com o sistema, mas também com Aaron e a equipe
Se alguém tiver perguntas do ponto de vista de cliente, posso responder