Fundo
O Roblox fornece um conjunto de APIs para interagir com armazenamentos de dados via DataStoreService. O caso de uso mais comum para essas APIs é salvar, carregar e replicar dados de jogadores. Isso é, dados associados ao progresso, compras e outros recursos da sessão que persistem entre as sessões de jogo individuais.
A maioria das experiências no Roblox usa essas APIs para implementar alguma forma de um sistema de dados de jogador. Essas implementações diferem em sua abordagem, mas geralmente buscam solucionar o mesmo conjunto de problemas.
Problemas Comuns
Abaixo estão alguns dos problemas mais comuns que os sistemas de dados do jogador tentam resolver:
Acesso à Memória: DataStoreService solicitações fazem solicitações de web que operam assíncronamente e estão sujeitas a limites de taxa. Isso é apropriado para um carregamento inicial no começo da sessão, mas não para operações de leitura e gravação de alta frequência durante o normal curso de jogabilidade. A maioria dos sistemas de dados
- Leitura inicial no começo de uma sessão
- Escreva final no final da sessão
- Periodic writes at an interval to mitigate the scenario where the final write fails
- Escreve para garantir que os dados sejam salvos ao processar uma comprar
Armazenamento Eficiente: Armazenar todos os dados da sessão de um jogador em uma única tabela permite que você atualize múltiplos valores em átomos e lidar com a mesma quantidade de dados em menos pedidos. Isso também remove o risco de desincronização de valores e facilita o retorno de rolagem.
Alguns desenvolvedores também implementam serialização personalizada para compactar grandes estruturas de dados (normalmente para salvar conteúdo gerado pelo usuário no jogo).
Replicação: O cliente precisa de acesso regular aos dados de um jogador (por exemplo, para atualizar a UI). Uma abordagem genérica para replicar dados de jogador para o cliente permite que você transmita essas informações sem precisar criar sistemas de replicação personalizados para cada componente de dados. Os desenvolvedores geralmente querem a opção de ser seletivo sobre o que é e não é replicado para o cliente.
Gerenciamento de erros: Quando armazenamentos de dados não podem ser acessados, a maioria das soluções implementará um mecanismo de redefinição e um mecanismo de retorno à 'defecto' de dados. Cuidado especial é necessário para garantir que os dados de retorno não sobrescrevam 'dados reais', e que isso seja comunicado ao jogador apropriadamente.
Tentativas de novo: Quando armazenamentos de dados ficam inacessíveis, a maioria das soluções implementa um mecanismo de tentativa de novo e um dado padrão. Tenha cuidado especial para garantir que os dados padrão não sobrescrevam "real" dados, e comunique a situação ao jogador apropriadamente.
Sessão Locking: Se os dados de um único jogador forem carregados e em memória em vários servidores, podem ocorrer problemas em que um servidor salva informações desatualizadas. Isso pode levar a perda de dados e falhas comuns de duplicação de itens.
Processando Compra Atômica: Verifique, conceda e registre compras atômicas para evitar que itens sejam perdidos ou concedidos várias vezes.
Código de Exemplo
O Roblox tem código de referência para ajudá-lo a projetar e construir sistemas de dados de jogadores. O resto desta página examina detalhes de fundo, implementação e geralmente caveats.
Depois de importar o modelo no Studio, você deve ver a seguinte estrutura de pasta:
Arquitetura
Este diagrama de alto nível ilustra os sistemas-chave no exemplo e como eles se conectam ao código no resto da experiência.
Tentativas
Classe: DataStoreWrapper
Fundo
Como DataStoreService cria solicitações de web sob o capô, suas solicitações não são garantidas de ter sucesso. Quando isso acontece, os métodos DataStore são apresentados, permitindo que você os lidar.
Um comum "gotcha" pode ocorrer se você tentar lidar com falhas de armazenamento de dados como esta:
local function retrySetAsync(dataStore, key, value)
for _ = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
break
end
task.wait(TIME_BETWEEN_ATTEMPTS)
end
end
Embora este seja um mecanismo de tentativa perfeitamente válido para uma função genérica, não é adequado para solicitações de DataStoreService , pois não garante a ordem em que solicitações são feitas. Preservar a ordem das solicitações é importante para solicitações de DataStoreService, pois elas interagem com o estado. Considere o seguinte cenário:
- A solicitação A é feita para definir o valor da chave K para 1.
- A solicitação falha, então um novo pedido está agendado para ser executado em 2 segundos.
- Antes que ocorra o novo teste, solicite B definir o valor de K para 2, mas o novo teste de solicitação A imediatamente substitui esse valor e configura K para 1.
Mesmo que UpdateAsync opere na versão mais recente do valor da chave, UpdateAsync solicitações ainda devem ser processadas para evitar estados transientes inválidos (por exemplo, uma compra subtrai moedas antes que uma adição de moedas seja processada, resultando em moedas negativas).
Nosso sistema de dados de jogador usa uma nova classe, DataStoreWrapper, que fornece retries que são garantidos de serem processados em ordem por chave.
Aproximação
DataStoreWrapper fornece métodos correspondentes aos métodos DataStore : DataStore:GetAsync() , 0> Class.GlobalDataStore:SetAsync()|DataStore:SetAsync()
Esses métodos, quando chamados:
Adicione a solicitação a uma fila. Cada chave tem sua própria fila, onde solicitações são processadas em ordem e em série. O subprocesso de pedido retorna até que a solicitação seja concluída.
Essa funcionalidade é baseada na classe ThreadQueue, que é um gerenciador de tarefas baseado em coroutine e limite de taxa. Em vez de retornar uma promessa, ThreadQueue retorna o subprocesso atual até que a operação seja concluída e seja um erro se falhar. Isso é mais consistente com padrões de Lua síncronos.
Se uma solicitação falhar, ela é retentada com um backoff exponencial configurável. Essas tentativas de retentada fazem parte do retorno de chamada enviado para o ThreadQueue, para que elas sejam garantidas de completar antes da próxima solicitação na fila para essa chave.
Quando uma solicitação é completa, o método de solicitação retorna com o padrão success, result
DataStoreWrapper também exibe métodos para obter a long度 da fila para uma chave especificada e limpar solicitações estaleiras. A opção mais recente é particularmente útil em cenários quando o servidor está desligando e não há tempo para processar nenhum, mas as solicitações mais recentes.
Cavernas
DataStoreWrapper segue o princípio de que, fora de cenários extremos, todos os pedidos de armazenamento de dados devem ser permitidos para concluir (sucessivamente ou de outra forma), mesmo que uma solicitação mais recente o torne desatualizado. Quando uma nova solicitação ocorre, pedidos estaleiros não são removidos da fila, mas são permitidos para conclu
É difícil decidir um conjunto intuitivo de regras para quando uma solicitação é segura para remover da fila. Considere o seguinte conjunto:
Value=0, SetAsync(1), GetAsync(), SetAsync(2)
O comportamento esperado é que GetAsync() retornasse 1, mas se removermos a solicitação SetAsync() da fila devido a sua remoção, retornaremos 1> 01>.
A progressão lógica é que quando uma nova solicitação de escrita é adicionada, apenas prune solicitações estalejadas tão longe quanto a solicitar / pedirde leitura mais recente. UpdateAsync() , por muito tempo a operação mais comum (e a única usada neste sistema) pode ler e escrever, então seria difícil reconciliar dentro deste design sem adicionar complexidade extra.
DataStoreWrapper pode exigir que você especifique se uma solicitação Class.GlobalDataStore: UpdateAsync()|UpdateAsync() é permitida para ler e/ou escrever, mas não teria aplicabilidade para nosso sistema de dados do jogador, onde isso não pode ser determinado antes do tempo devido ao mecanismo de bloqueio de sessão (coberto em mais detalhes mais tarde).
Uma vez removido da fila, é difícil decidir sobre uma regra intuitiva para como isso deve ser tratado. Quando uma solicitação de DataStoreWrapper é feita, o Subprocessoatual é produzido até que seja concluído. Se removermos solicitações estale de da fila, teremos que decidir se retornaremos
Em última análise, nossa visão é que a abordagem simples (Processar todos os solicitar / pedir) é preferível aqui e cria um ambiente mais claro para navegar quando se aproxima de problemas complexos, como o bloqueio de sessão. A única exceção a isso é durante DataModel:BindToClose(), onde
Bloqueio de Sessão
Classe: SessionLockedDataStoreWrapper
Fundo
Os dados do jogador são armazenados em memória no servidor e só são lidos e escritos para armazenamento de dados subjacente quando necessário. Você pode ler e atualizar dados de jogador em memória instantaneamente sem precisar de solicitações de web e evitar exceder os limites de DataStoreService.
Para que este modelo funcione como deveria, é imperativo que nenhum mais de um servidor seja capaz de carregar os dados de um jogador na memória a partir do DataStore ao mesmo tempo.
Por exemplo, se o servidor A carregar os dados de um jogador, o servidor B não pode carregar esses dados até o servidor A liberar seu bloqueio durante um salvamento final. Sem um mecanismo de bloqueio, o servidor B poderia carregar dados de jogador desatualizados do armazenamento de dados antes do servidor A ter uma chance de salvar a versão mais recente que ele tem em memória. Então, se o servid
Embora o Roblox só permita que um cliente se conecte a um servidor em um momento, você não pode assumir que os dados de uma sessão sejam sempre salvos antes da próxima sessão começar. Considere os seguintes cenários que podem ocorrer quando um jogador sai do servidor A:
- O servidor A faz uma solicitação de DataStore para salvar seus dados, mas a solicitação falha e requer várias tentativas para concluir com sucesso. Durante o período de redefinição, o jogador se juntar ao servidor B.
- O servidor A faz muitos Class.GlobalDataStore:UpdateAsync()|UpdateAsync() chamadas para a mesma chave e fica limitado. A solicitação final de salvamento é colocada em uma fila. Enquanto a solicitação está na fila, o jogador se juntar ao servidor B.
- Em servidor A, alguns códigos conectados ao evento PlayerRemoving produzem dados antes que o jogador salve os dados. Antes que essa operação seja concluída, o jogador se juntar ao servidor B.
- O desempenho do servidor A foi rebaixado ao ponto de que o salvamento final é atrasado até depois que o jogador se juntar ao servidor B.
Esses cenários devem ser raros, mas eles ocorrer, especialmente em situações em que um jogador se desconecta de um servidor e se conecta a outro em sucessão rápida (por exemplo, ao teletransportar). Alguns usuários maliciosos podem até tentar abusar desse comportamento para completar ações sem que eles persistam. Isso pode ser particularmente impactante em jogos que permitem que os jogadores negociem e sejam uma fonte comum de duplicação de itens de ação.
O bloqueio de sessão afeta esta vulnerabilidade, garantindo que quando a chave de um jogador é lida primeiro pelo servidor, o servidor atualmente escreve um bloqueio no metadado da chave dentro da mesma DataStore chamada. Se este valor de bloqueio estiver presente quando qualquer outro servidor tentar ler ou escrever a chave, o servidor não procederá.
Aproximação
SessionLockedDataStoreWrapper é um meta-Wrap ao redor da classe DataStoreWrapper. DataStoreWrapper fornece funcionalidades de sincronização e tente novamente, que 0> SessionLockedDataStore0> suplementa com sincronização de sessão.
SessionLockedDataStoreWrapper passes every
A função de transformação passou para UpdateAsync para cada solicitação executar as seguintes operações:
Verifica se a chave está segura para acesso, abandonando a operação se não for. "Seguro para acesso" significa:
O objeto de metadados da chave não inclui um valor LockId que não foi reconhecido que foi atualizado menos do que o tempo de expiração da chave. Isso conta por respeitar um bloqueio colocado por outro servidor e ignorar esse bloqueio se ele expirou.
Se este servidor colocou seu próprio valor de LockId na metadados da chave anteriormente, então este valor ainda está na metadados da chave. Isso conta a situação onde outro servidor tomou o lugar do servidor neste momento (por expiração ou força) e depois o liberou. Alternativamente, mesmo que o LockId seja um <
Class.GlobalDataStore:UpdateAsync()|UpdateAsync realiza a operação DataStore solicitada pelo cliente de SessionLockedDataStoreWrapper. Por exemplo, 0> Class.GlobalDataStore:GetAsync()|GetAsync()0> traduz para UpdateAsync3> .
Dependendo dos parâmetros passados na solicitar / pedir, UpdateAsync bloqueia ou desbloqueia a chave:
Se a chave for bloqueada, UpdateAsync define o LockId na metadados da chave para um GUID. Este GUID é armazenado em memória no servidor para que possa ser verificado na próxima vez que acessar a chave. Se o servidor já tiver um bloqueio nesta
Se a chave for desbloqueada, UpdateAsync remova o LockId no metadado da chave.
Um gerenciador de tentativa personalizado é passado para o DataStoreWrapper subjacente para que a operação seja tentada novamente se a operação for interrompida no passo 1 devido à sessão estar bloqueada.
Uma mensagem de erro personalizada também é retornada ao consumidor, permitindo que o sistema de dados do jogador relate um erro alternativo no caso de bloqueio de sessão ao cliente.
Cavernas
O regime de bloqueio de sessão confia em um servidor sempre liberar seu bloqueio em uma chave quando ele estiver pronto. Isso deve sempre acontecer através de uma instrução para desbloquear a chave como parte da escrita final em PlayerRemoving ou BindToClose() .
No entanto, o desbloqueio pode falhar em determinadas situações. Por exemplo:
- O servidor caiu ou DataStoreService ficou inoperável para todos os ataques para acessar a chave.
- Devido a um erro na lógica ou bug semelhante, a instrução para desbloquear a chave não foi feita.
Para manter a chave bloqueada, você deve acessá-la regularmente por um tempo que varia de acordo com o tempo de carregamento da chave na memória. Isso geralmente é feito como parte do método de salvamento automático executado no fundo na maioria dos sistemas de dados do jogador, mas este sistema também exibe um método refreshLockAsync se você precisar fazer isso manualmente.
Se o tempo de expiração do bloqueio for excedido sem o bloqueio ser atualizado, então qualquer servidor é gratuito para assumir o bloqueio. Se um servidor diferente tomar o bloqueio, as tentativas do servidor atual para ler ou escrever a chave falharão a menos que seja criado um novo bloqueio.
Processando Produto do Desenvolvedor
Singleton: ReceiptHandler ”
Fundo
O retorno de chamada ProcessReceipt é executado pelo trabalho crítico de determinar quando finalizar uma comprar. ProcessReceipt é chamado em cenários muito específicos. Para sua série de garantias, veja MarketplaceService.ProcessReceipt.
Embora a definição de "manuseio" de uma compra possa diferir entre experiências, usamos os seguintes critérios
A compra não foi processada anteriormente.
A compra é refletida na sessão atual.
Isso requer que você realize as seguintes operações antes de retornar PurchaseGranted :
- Verifique se o PurchaseId já não foi registrado como um gerenciador.
- Recompensar a compra nos dados do jogador na memória.
- Registre o PurchaseId como tratado nos dados do jogador na memória.
- Escreva os dados do jogador na memória do jogador para o DataStore.
O bloqueio de sessão simplifica esse fluxo, pois você não precisa mais se preocupar com os seguintes cenários:
- Os dados do jogador in-memória no servidor atual podem estar desatualizados, o que requer que você obtenha o último valor do DataStore antes de verificar o histórico de PurchaseId
- O retorno de chamada para a mesma compra em outro servidor, exigindo que você leia e escreva a história de PurchaseId e salve os dados atualizados do jogador com a compra refletida para evitar condições de corrida
Garantias de bloqueio de sessão que, se uma tentativa de escrever no Class.GlobalDataStore|DataStore do jogador for bem-sucedida, nenhum outro servidor lê ou escreve com sucesso no Class.GlobalDataStore|DataStore do jogador entre a data a ser carregada e salva neste servidor. Em suma, os dados do jogador na memória neste servidor são a vers
Aproximação
Os comentários em ReceiptProcessor contorno o abordagem:
Verifique se os dados do jogador estão carregando neste servidor e se eles foram carregados sem nenhum erro.
Como este sistema usa sessão de bloqueio, este check também verifica que os dados no memória são a versão mais recente.
Se os dados do jogador ainda não foram carregados (o que é esperado quando um jogador se juntar a um jogo), aguarde os dados do jogador carregarem. O sistema também ouve o jogador sair do jogo antes de seus dados carregarem, como não deve ser gerado indefinidamente e bloquear este retorno de chamada para ser invocado novamente neste servidor para esta aquisição se o jogador se juntar novamente.
Verifique se o PurchaseId não já está registrado como processado nos dados do jogador.
Devido ao bloqueio de sessão, a matriz de PurchaseIds que o sistema tem em memória é a versão mais recente. Se o PurchaseId for registrado como processado e refletido em um valor que
Atualize os Dados do Jogador localmente neste servidor para "recompensar" a comprar.
ReceiptProcessor adota uma abordagem de retorno de chamada genérica e atribui um retorno de chamada diferente para cada DeveloperProductId.
Atualize os dados do jogador localmente neste servidor para armazenar o PurchaseId .
Envie um pedido para salvar os dados em memória ao DataStore, retornando PurchaseGranted se o pedido for bem-sucedido. Se não, retorne NotProcessedYet.
Se esta pedido de salvamento não for bem-sucedido, um pedido de salvamento lateral ainda pode ser bem-sucedido. Durante a próxima chamada ProcessReceipt, o passo 2 lida com essa situação e retorna PurchaseGranted.
Dados do Jogador
Singletons: PlayerData.Server PlayerData.Client PlayerData.Server 0> 1> 2> 3> 4> 5> 6> 7> 8> 9> 1> 2> 3> 4> 5> 6> 7> 8> 9> 0> 1> 2>
Fundo
Módulos que fornecem uma interface para o código do jogo para ler e escrever dados de sessão de jogador de forma sincronizada são comuns em experiências do Roblox. Esta seção cobre PlayerData.Server e PlayerData.Client.
Aproximação
PlayerData.Server e PlayerData.Client lidam com o seguindo:
- Carregar os dados do jogador na memória, incluindo casos de tratamento em que ele não consegue carregar
- Fornecer uma interface para o código do servidor para pesquisar e alterar os dados do jogador
- Replicando alterações nos dados do jogador para o cliente para que o código do cliente possa acessá-lo
- Replicando erros de carregamento e/ou salvamento para o cliente para que ele possa mostrar diálogos de erro
- Salvando os dados do jogador periódicamente, quando o jogador sai e quando o servidor desligado
Carregando Dados do Jogador
SessionLockedDataStoreWrapper faz uma solicitação de getAsync para o lojade dados.
Se esta pedido falhar, os dados padrão são usados e o perfil é marcado como "errored" para garantir que não seja escrito no armazenamento de dados mais tarde.
Uma opção alternativa é expulsar o jogador, mas recomendamos deixar o jogador jogar com dados padrão e limpar a mensageria para saber o que aconteceu, em vez de remover-lo da experiência.
Um payload inicial é enviado para PlayerDataClient contendo os dados carregados e o status de erro (se aplicável).
Quaisquer subprocessos gerados usando waitForDataLoadAsync para o jogador são retomados.
Fornecendo uma Interface para Código do Servidor
- PlayerDataServer é um único que pode ser requerido e acessado por qualquer código de servidor executando no mesmo ambiente.
- Os dados do jogador são organizados em um dicionário de chaves e valores. Você pode manipular esses valores no servidor usando os métodos setValue, getValue, updateValue e 1>RemoveValue1>. Esses métodos operam sincronamente sem sofrer perdas.
- Os métodos hasLoaded e waitForDataLoadAsync são disponíveis para garantir que os dados tenham sido carregados antes de você acessar-los. Recomendamos fazer isso uma vez durante uma tela de carregamento antes que outros sistemas sejam iniciados para evitar ter que verificar erros de carregamento antes de cada interação com dados no cliente.
- Um método hasErrored pode ser chamado se a inicialização do jogador falhar, fazendo com que eles usem dados padrão. Verifique este método antes de permitir que o jogador faça quaisquer compras, pois compras não podem ser salvas em dados sem um carregarbem-sucedido.
- Um sinal de playerDataUpdated com o player, key e 1> value1> ativado sempre que os dados de um jogador são alterados. Os sistemas individuais podem se inscrever nisso.
Replicando Alterações ao Cliente
- Qualquer alteração aos dados do jogador em PlayerDataServer é replicada para PlayerDataClient, a menos que essa chave estivesse marcada como privada usando setValueAsPrivate
- setValueAsPrivate é usado para designar chaves que não devem ser enviadas ao cliente
- PlayerDataClient inclui um método para obter o valor de uma chave (obter) e um sinal que dispara quando é atualizado (atualizado). Um método hasLoaded e um sinal loaded também são incluídos, para que o cliente possa esperar por dados para carregar e replicar antes de iniciar seus sistemas
- PlayerDataClient é um único que pode ser requerido e acessado por qualquer código de cliente executando no mesmo ambiente
Replicando Erros ao Cliente
- Statuses de erro encontrados ao salvar ou carregar dados do jogador são replicados para PlayerDataClient .
- Acesse essas informações com os métodos getLoadError e getSaveError, juntamente com os sinais loaded e 1>saved1>.
- Existem dois tipos de erros: DataStoreError (o pedido DataStoreService falhou) e SessionLocked (veja 1> Locking de sessão1>).
- Use esses eventos para desativar prompts de compra do cliente e implementar diálogos de aviso. Essa imagem mostra um exemplo de diálogo:
Salvando Dados do Jogador
Quando o jogador sai do jogo, o sistema faz os seguintes passos:
- Verifique se é seguro escrever os dados do jogador para o armazenamento de dados. Cenários onde seria perigoso incluem os dados do jogador falhando ao carregar ou ainda sob carregando.
- Faça uma solicitação através do SessionLockedDataStoreWrapper para escrever o valor de dados atual em memória para o armazenamento de dados e remover o bloqueio de sessão quando concluído.
- Limpa os dados do jogador (e outras variáveis, como metadados e status de erro) da memória do servidor.
Em um loop periódico, o servidor escreve dados de cada jogador para o armazenamento de dados (desde que seja seguro salvar). Essa redundância de boas-vindas mitiga a perda em caso de queda de servidor e também é necessária para manter o bloqueio da sessão.
Quando uma solicitação para desligar o servidor é recebida, o seguinte ocorre em um BindToClose retorno de chamada:
- Um pedido é feito para salvar os dados de cada jogador no servidor, seguindo o processo normalmente concluído quando um jogador sai do servidor. Esses pedidos são feitos em paralelo, como os BindToClose chamadas só têm 30 segundos para concluir.
- Para acelerar os salvamentos, todos os outros pedidos na fila de cada chave são limpos a partir do DataStoreWrapper subjacente (veja Retries).
- O retorno de chamada não é retornado até que todas as solicitações tenham sido concluídas.