Aperçu
Roblox fournit une série d'API pour interagir avec les magasins de données via DataStoreService. Le cas d'utilisation le plus courant pour ces API est de sauvegarder, de charger et de répliquer les données des joueurs player. C'est-à-dire, les données associées au progrès, aux achats et à d'autres caractéristiques de session qui persistent entre les sessions de jeu individuelles.
La plupart des expériences sur Roblox utilisent ces API pour implémenter une forme de système de données du joueur. Ces implémentations diffèrent dans leur approche, mais généralement cherchent à résoudre le même ensemble de problèmes.
Problèmes communs
Ceux-ci sont quelques-uns des problèmes les plus courants que les systèmes de données utilisateur essaient de résoudre :
Dans l'accès à la mémoire : DataStoreService les demandes font des demandes web qui fonctionnent en asynchrone et sont soumises à des limites de taux. C'est approprié pour un chargement initial au début de la session, mais pas pour des opérations de lecture et d'écriture de haute fréquence pendant le partienormal. La plupart des systèmes de données du joueur de
- Lire initialement au début d'une session
- Écrivez la fin de la session
- Les périodes écrire à un intervalle pour mitiger le scénario où l'écriture finale échoue
- Écrit pour s'assurer que les données sont enregistrées pendant le traitement d'un acheter
Efficient Storage : En stockant tous les données de session d'un joueur dans une seule table, vous pouvez mettre à jour plusieurs valeurs atomiques et gérer la même quantité de données dans moins de demandes. Il supprime également le risque de désynchronisation inter-valeur et facilite les retours faciles à comprendre.
Certains développeurs implémentent également la serialisation personnalisée pour compresser de grandes structures de données (généralement pour enregistrer du contenu généré par l'utilisateur dans le jeu).
Réplication : Le client a besoin d'un accès régulier aux données d'un joueur (par exemple, pour mettre à jour l'interface utilisateur). Une approche générique de la réplication des données du client vous permet de transmettre cette information sans avoir à créer de systèmes de réplication personnalisés pour chaque composant de données. Les développeurs aiment souvent l'option d'être sélectif sur ce qui est et ne sera pas répliqué au client.
Gestion des erreurs : Lorsque DataStores ne peut pas être accédés, la plupart des solutions implémenteront un mécanisme de réessayer et un remplacement par les données par défaut. Une attention spéciale est nécessaire pour s'assurer que les données par défaut ne s'écrasent pas plus tard sur les données « réelles », et que cela est communiqué au joueur de manière appropriée.
Réessayes : Lorsque les magasins de données sont inaccessibles, la plupart des solutions implémentent un mécanisme de réessayage et un fallback vers les données par défaut. Prenez soin spécial de vérifier que les données de réessayage ne couvrent pas plus tard les données « réelles », et communiquez la situation au joueur de manière appropriée.
Verrouillage de la session : Si les données d'un seul joueur sont chargées et en mémoire sur plusieurs serveurs, des problèmes peuvent se produire dans lequel un serveur sauvegarde des informations obsolètes. Cela peut entraîner la perte de données et des loop de duplication d'objets communs.
Gestion de l'achat atomique : Vérifier, attribuer et enregistrer les achats atomiques pour empêcher les articles de se perdre ou de se voir attribuer plusieurs fois.
Code d'échantillon
Roblox a un code de référence pour vous aider à concevoir et à construire des systèmes de données sur les joueurs. Le reste de cette page examine les détails de l'arrière-plan, l' implementation et les points généraux.
Après avoir importé le modèle dans Studio, vous devriez voir la structure de dossier suivante :
Architecture
Ce haut niveau de diagramme montre les systèmes clés dans l'échantillon et comment ils interagissent avec le code dans le reste de l'expérience.
Réessayes
Classe : DataStoreWrapDataStoreWrapper
Aperçu
Comme DataStoreService fait des demandes Web sous le capot, ses demandes ne sont pas garanties de réussir. Lorsque cela se produit, les méthodes DataStore lancent des erreurs, vous permettant de les gérer.
Une erreur "gotcha" courante peut se produire si vous essayez de gérer les interruptions de magasin de données comme celle-ci :
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
Bien que ce soit un mécanisme de réessayer parfaitement valide pour une fonction générique, il n'est pas approprié pour les demandes de DataStoreService car il ne garantit pas l'ordre dans lequel les demandes sont faites. La préservation de l'ordre des demandes est importante pour les demandes de DataStoreService car elles interagissent avec l'état. Considérez le scénario suivant :
- La demande A est faite pour définir la valeur de la clé K à 1.
- La demande échoue, il est donc prévu d'essayer à nouveau dans 2 secondes.
- Avant que la tentative de nouveau ne se produise, demandez à B de définir la valeur de K à 2, mais la tentative de demande A immédiatement écrase cette valeur et définie K à 1.
Même si UpdateAsync opère sur la dernière version de la valeur de la clé, UpdateAsync les demandes doivent toujours être traitées pour éviter les états transitoires non valides (par exemple, un achat soustrait des pièces avant qu'une ajout de pièces se déclenche, ce qui donne des pièces négatives).
Notre système de données sur le joueur utilise une nouvelle classe, DataStoreWrapper , qui fournit des essais de réessayer qui sont garantis d'être traités par clé.
Approche
DataStoreWrapper fournit des méthodes correspondant aux méthodes DataStore : DataStore:GetAsync(), 0> Class.GlobalDataStore:SetAsync()|DataStore:SetAsync()
Ces méthodes, lorsqu'elles sont appelées :
Ajoutez la demande à une file d'attente. Chaque clé a sa propre file d'attente, où les demandes sont traitées en ordre et en série. Le fil de demande génère jusqu'à ce que la demande soit terminée.
Cette fonctionnalité est basée sur la classe ThreadQueue, qui est un programmateur de tâche basé sur la coroutine et limite de taux. Au lieu de renvoyer une promesse, ThreadQueue génère le fil actuel jusqu'à ce que l'opération soit terminée et lance une erreur si elle échoue. Cela est plus cohérent avec les modèles Lua idiomatique.
Si une demande échoue, il réessaye avec un backoff exponentiel configurable. Ces réessayes font partie du rappel soumis à la ThreadQueue , de sorte qu'ils soient garantis d'être terminés avant la prochaine demande dans la file d'attente pour cette clé.
Lorsqu'une demande est terminée, la méthode de demande renvoie le success, result modèle
DataStoreWrapper également exposer les méthodes pour obtenir la longueur de la file d'attente pour une clé donnée et vider les demandes périmées. L'option dernière est particulièrement utile dans les scénarios lorsque le serveur s'arrête et il n'y a pas de temps pour traiter les demandes mais les plus récentes.
Grotte
DataStoreWrapper suit le principe que, en dehors des scénarios extrêmes, chaque demande de stockage de données devrait être autorisée à se terminer (avec succès ou autrement), même si une demande plus récente la rend inutile. Lorsqu'une nouvelle demande se produit, les demandes stalées ne sont pas supprimées de la file d'attente, mais sont plutôt autorisées à se terminer avant la
Il est difficile de décider d'un ensemble de règles intuitives pour savoir quand une demande est sûre à retirer de la file d'attente. Considérez le cas suivant :
Value=0, SetAsync(1), GetAsync(), SetAsync(2)
Le comportement attendu est que GetAsync() retournerait 1, mais si nous supprimons la demande SetAsync() de la file d'attente en raison de sa suppression par le plus récent, il retournerait 1> 01>.
La progression logique est que lorsqu'une nouvelle demande d'écriture est ajoutée, ne prune que les demandes périmées aussi loin que la dernière demande de lecture. Class.GlobalDataStore:UpdateAsync()|UpdateAsync() », parmi les opérations les plus courantes (et la seule utilisé par ce système), peut lire et écrire, il serait donc difficile de concilier dans ce design sans ajouter de la complexité supplémentaire.
DataStoreWrapper pourrait vous obliger à spécifier si une demande UpdateAsync() est autorisée à lire et/ou écrire, mais elle n'aurait aucune application dans notre système de données de joueur, où cela ne peut pas être déterminé à l'avance en raison du mécanisme de verrouillage de la session (couvert dans plus de détails plus tard).
Une fois retiré de la file d'attente, il est difficile de décider sur une règle intuitive pour comment cela devrait être géré. Lorsqu'une demande DataStoreWrapper est faite, le fil actuel est produit jusqu'à ce qu'il soit terminé. Si nous avons retiré les demandes stales de la file d'attente, nous dev
En fin de compte, notre approche est que l'approche simple ( traiter chaque demande ) est préférable ici et crée un environnement plus clair pour naviguer dans quand vous approchez de complexes problèmes comme la session de verrouillage. La seule exception à cela est pendant DataModel:BindToClose(), où le nettoyage de la file
Verrouillage de session
Classe : SessionLockedDataStoreWrapperSessionLockedDataStoreWrapper
Aperçu
Les données du joueur sont stockées dans la mémoire sur le serveur et ne sont lues et écrites que lorsque nécessaire. Vous pouvez lire et mettre à jour les données du joueur dans la mémoire instantanément sans avoir besoin de demandes Web et éviter d'excéder les limites DataStoreService.
Pour que ce modèle fonctionne comme prévu, il est impératif que plus d'un serveur ne soit pas en mesure de charger les données d'un joueur dans la mémoire à partir du DataStore au même moment.
Par exemple, si le serveur A charge les données d'un joueur, le serveur B ne peut pas charger ces données jusqu'à ce que le serveur A publie son verrouillage sur lui pendant une sauvegarde finale. Sans mécanisme de verrouillage, le serveur B pourrait charger des données plus récentes du magasin de données avant que le serveur A n'ait la possibilité de sauvegarder la dernière version qu'il a en mémoire. Puis si le
Bien que Roblox ne permette qu'à un client de se connecter à un serveur à la fois, vous ne pouvez pas supposer que les données d'une session sont toujours enregistrées avant la prochaine session. Considérez les scénarios suivants qui peuvent se produire lorsqu'un joueur quitte le serveur A :
- Le serveur A fait une demande DataStore pour enregistrer leurs données, mais la demande échoue et nécessite plusieurs tentatives pour être terminée avec succès. Pendant la période de retour, le joueur rejoint le serveur B.
- Le serveur A fait trop de UpdateAsync() appels à la même clé et est limité. La demande de sauvegarde finale est placée dans une file d'attente. Pendant que la demande est dans la file d'attente, le joueur rejoint le serveur B.
- Sur le serveur A, un certain nombre de codes connectés à l'événement PlayerRemoving produit avant que les données du joueur ne soient enregistrées. Avant que cette opération se termine, le joueur rejoint le serveur B.
- La performance du serveur A a dégénéré au point que la sauvegarde finale est retardée jusqu'à ce que le joueur se joigne au serveur B.
Ces scénarios devraient être rares, mais ils se survernir, en particulier dans des situations où un joueur se déconnecte d'un serveur et se connecte à un autre dans une succession rapide (par exemple, pendant la téléportation). Certains utilisateurs malveillants peuvent même essayer d'abuser de ce comportement pour terminer des actions sans qu'ils persistent. Cela peut être particulièrement impactant dans les jeux qui permettent aux joueurs de commercer et est une source fréquente de duplication d'objets d'action.
Le verrouillage de session affecte cette vulnérabilité en assurant que lorsqu'une clé de DataStore est lue en premier par le serveur, l'atomique du serveur écrit un verrouillage sur la métadonnée de la clé dans la même Class.GlobalDataStore:UpdateAsync()|UpdateAsync() appel. Si cette valeur de verrouillage est présente lorsque n'importe quel autre serveur essaie de lire
Approche
SessionLockedDataStoreWrapper est un meta-Wrap autour de la classe DataStoreWrapper. DataStoreWrapper fournit des fonctions de file d'attente et de réessayer, ce qui complète la fonctionnalité de verrouillage de la session.
SessionLockedDataStoreWrapper passe chaque demande
La fonction de transformation est passée dans UpdateAsync pour chaque demande d'exécuter les opérations suivantes :
Vérifie que la clé est sûre à accéder, abandonnant l'opération si elle ne l'est pas. "Sécuritaire d'accès" signifie :
L'objet de métadonnées de la clé ne contient pas de valeur LockId non reconnue qui a été mis à jour il y a moins de la durée d'expiration de la serrure. Cela tient compte du respect d'une clé placée par un autre serveur et de l'ignorance de cette clé si elle expire.
Si ce serveur a déjà placé sa propre valeur LockId dans le métadonnées de la clé précédemment, alors cette valeur est toujours dans le métadonnées de la clé. Cela représente la situation où un autre serveur a pris le contrôle de la clé (par expiration ou par force) et l'a ensuite supprimé. Alternativement phrase, même si LockId est
Class.GlobalDataStore:UpdateAsync()|UpdateAsync exécute l'opération DataStore demandée par le client de SessionLockedDataStoreWrapper. Par exemple, 0> Class.GlobalDataStore:GetAsync()|GetAsync()0> traduit en UpdateAsync3> .
En fonction des paramètres passés dans la demande, UpdateAsync verrouille ou déverrouille la clé :
Si la clé est verrouillée, UpdateAsync met le LockId dans le métadonnées de la clé à un GUID. Ce GUID est stocké en mémoire sur le serveur pour qu'il puisse être vérifié la prochaine fois qu'il accède à la clé. Si le serveur a déjà un
Si la clé est à déverrouiller, UpdateAsync supprime le LockId dans les métadonnées de la clé.
Un gestionnaire de réessayer personnalisé est passé dans le DataStoreWrapper sous-jacente afin que l'opération soit réessayée si elle a été interrompue au niveau 1 en raison de la session qui est verrouillée.
Un message d'erreur personnalisé est également renvoyé au consommateur, ce qui permet au système de données du joueur de signaler une erreur alternative dans le cas de la verrouillage de la session au client.
Grotte
Le mécanisme de verrouillage de session se base sur un serveur qui publie toujours son verrou sur une clé lorsqu'il est terminé avec elle. Cela devrait toujours se produire via une instruction pour débloquer la clé en tant que partie de l'écriture finale dans PlayerRemoving ou BindToClose() .
Cependant, le déverrouillage peut échouer dans certaines situations. Par exemple :
- Le serveur s'est écrasé ou DataStoreService était inopérable pour toutes les tentatives d'accès à la clé.
- En raison d'une erreur dans la logique ou d'un bug similaire, l'instruction pour déverrouiller la clé n'a pas été faite.
Pour maintenir la clé verrouillée, vous devez l'accéder régulièrement pendant que vous la chargez en mémoire. Cela se fait généralement comme partie du boucle de sauvegarde automatique s'exécutant dans la plupart des systèmes de données du joueur, mais ce système expose également une méthode refreshLockAsync si vous devez le faire manuellement.
Si le délai d'expiration du verrou a été dépassé sans que le verrou soit mis à jour, alors n'importe quel serveur est gratuit de prendre le verrou. Si un autre serveur prend le verrou, les tentatives par le serveur actuel pour lire ou écrire la clé échouent sauf s'il établit un nouveau verrou.
Développer un produit
Singleton : ReceiptHandler
Aperçu
Le ProcessReceipt rappel fait le travail critique de déterminer quand finaliser un acheter. ProcessReceipt est appelé dans des scénarios très spécifiques. Pour son ensemble de garanties, voir MarketplaceService.ProcessReceipt.
Bien que la définition du « traitement » d'un achat puisse varier entre les expériences, nous utilisons les critères suivants
L'achat n'a pas été traité précédemment.
L'achat est reflété dans la session actuelle.
Ceci nécessite de conduire les opérations suivantes avant de renvoyer PurchaseGranted :
- Vérifiez que le PurchaseId n'a pas déjà été enregistré comme géré.
- Récompense l'achat dans les données du joueur dans la mémoire.
- Enregistre le PurchaseId comme manipulé dans les données du joueur dans la mémoire.
- Écrivez les données du joueur dans la mémoire du joueur au DataStore .
La session de verrouillage simplifie ce flux, car vous n'avez plus à vous inquiéter des scénarios suivants :
- Les données du joueur dans la mémoire du serveur potentiellement étant hors de date, vous obligeant à obtenir la dernière valeur à partir du DataStore avant de vérifier l'historique PurchaseId
- Le rappel pour le même achat s'exécutant sur un autre serveur, vous obligeant à lire et à écrire l'historique PurchaseId et à enregistrer les données du joueur mis à jour avec l'achat reflété atomiquement pour éviter les conditions de course
Verrouillage de session garantissant que, si une tentative d'écriture dans le DataStore du joueur est réussie, aucun autre serveur n'a lue ou écrit avec succès dans le DataStore entre la donnée chargée et enregistrée dans ce serveur. En bref, les données du joueur dans ce serveur sont la version
Approche
Les commentaires dans ReceiptProcessor détaillent l'approche :
Vérifiez que les données du joueur sont actuellement chargées sur ce serveur et qu'elle est chargée sans erreurs.
Comme ce système utilise la verrouillage de session, ce contrôle vérifie également que les données en mémoire sont la dernière version.
Si les données du joueur ne se sont pas encore chargées (ce qui est attendu lorsqu'un joueur rejoint un jeu), attendez que les données du joueur se charger. Le système écoute également le joueur quittant le jeu avant que ses données ne se chargent, car cela ne devrait pas générer indéfiniment et bloquer ce rappel d'appeler à nouveau sur ce serveur pour cet achat si le joueur rejoint.
Vérifiez que le PurchaseId n'est pas déjà enregistré comme traité dans les données du joueur.
En raison de la serrure de session, l'arrêt de mémoire de l'ensemble des PurchaseIds du système est la version la plus récente. Si le PurchaseId est enregistré comme traité
Mise à jour des données du joueur localement dans ce serveur pour "award" l'acheter.
ReceiptProcessor prend une approche de rappel générique et attribue un rappel différent pour chaque DeveloperProductId.
Mise à jour des données du joueur localement sur ce serveur pour stocker le PurchaseId .
Envoyez une demande pour enregistrer les données dans la mémoire dans le DataStore, en retournant PurchaseGranted si la demande est réussie. Sinon, retournez NotProcessedYet.
Si cette demande de sauvegarde n'est pas réussie, une demande de sauvegarde ultérieure des données de session en mémoire du joueur pourrait toujours réussir. Pendant la prochaine ProcessReceipt appel, le pas 2 gère cette situation et renvoie PurchaseGranted.
Données du joueur
Singletons : PlayerData.Server PlayerData.Client
Aperçu
Les modules qui fournissent une interface pour le code de jeu pour lire et écrire les données de session du joueur de manière synchrone sont communs dans les expériences Roblox. Cette section couvre PlayerData.Server et PlayerData.Client.
Approche
PlayerData.Server et PlayerData.Client gèrent les suivre:
- Chargement des données du joueur dans la mémoire, y compris les cas où il ne peut pas charger
- Fournir une interface pour que le code du serveur soit consulté et modifié par le joueur
- Répliquer les modifications dans les données du joueur au client afin que le code client puisse y accéder
- Réplication des erreurs de chargement et/ou de sauvegarde vers le client afin qu'il puisse afficher des dialogues d'erreur
- Enregistrement des données du joueur périodique, lorsque le joueur quitte et lorsque le serveur s'arrête
Chargement des données utilisateurs
SessionLockedDataStoreWrapper fait une demande getAsync à la boutique de données.
Si cette demande échoue, les données par défaut sont utilisées et le profil est marqué comme « échoué » pour s'assurer qu'il n'est pas écrit dans le stock de données plus tard.
Une option alternative est de botter le joueur, mais nous recommandons de laisser le joueur jouer avec des données par défaut et de supprimer clairement le messagerie pour voir ce qui s'est produit plutôt que de les retirer de l'expérience.
Un chargement initial est envoyé à PlayerDataClient contenant les données chargées et le statut d'erreur (si applicable).
Tous les threads générés en utilisant waitForDataLoadAsync pour le joueur sont reprends.
Fournir une interface pour le code du serveur
- PlayerDataServer est un singleton qui peut être requis et accédé par n'importe quel code de serveur s'exécutant dans le même environnement.
- Les données du joueur sont organisées en un dictionnaire de clés et de valeurs. Vous pouvez manipuler ces valeurs sur le serveur en utilisant les méthodes setValue, getValue, updateValue et 1>RemoveValue1>. Ces méthodes fonctionnent toutes de manière synchronisée sans générer.
- Les méthodes hasLoaded et waitForDataLoadAsync sont disponibles pour vous assurer que les données ont été chargées avant que vous y accédiez. Nous recommandons de le faire une fois pendant un écran de chargement avant que d'autres systèmes ne soient lancés pour éviter de devoir vérifier les erreurs de chargement avant chaque interaction avec les données sur le client.
- Une méthode hasErrored peut rechercher si le chargement initial du joueur a échoué, ce qui les oblige à utiliser des données par défaut. Vérifiez cette méthode avant d'autoriser le joueur à effectuer des achats, car les achats ne peuvent pas être enregistrés dans les données sans un chargerréussi.
- Un signal playerDataUpdated avec les player, key, et 2>value2> chaque fois qu'un joueur change de données. Les systèmes individuels peuvent s'abonner à cela.
Réplication des modifications au client
- Toute modification des données du joueur dans PlayerDataServer est répliquée à PlayerDataClient, à moins que cette clé n'ait été marquée comme privée en utilisant setValueAsPrivate
- setValueAsPrivate est utilisé pour décrire les clés qui ne devraient pas être envoyées au client
- PlayerDataClient inclut une méthode pour obtenir la valeur d'une clé (obtenir) et un signal qui se déclenche lorsqu'il est mis à jour (mis à jour). Une méthode hasLoaded et un signal loaded sont également inclus, afin que le client puisse attendre que les données se chargent et se répliquent avant le démarrage de ses systèmes
- PlayerDataClient est un singleton qui peut être requis et accédé par n'importe quel client s'exécutant dans le même environnement
Réplication des erreurs vers le client
- Les états d'erreur rencontrés lors de l'enregistrement ou du chargement des données du joueur sont répliqués à PlayerDataClient .
- Accédez à cette information avec les méthodes getLoadError et getSaveError, ainsi que les signaux loaded et 1>Saved1>.
- Il y a deux types d'erreurs : DataStoreError (la demande DataStoreService a échoué) et SessionLocked (voir 1> session de verrouillage1>).
- Utilisez ces événements pour désactiver les invitations d'achat des clients et implémenter les dialogues d'avertissement. Cette image montre un exemple de dialogue :
Enregistrement des données utilisateurs
Lorsque le joueur quitte le jeu, le système prend les étapes suivantes :
- Vérifiez si il est sûr d'écrire les données du joueur dans le boutiquede données. Les scénarios où il serait dangereux d'écrire les données du joueur échouent à charger ou sont toujours en cours de chargement.
- Faites une demande via le SessionLockedDataStoreWrapper pour écrire la valeur de données en mémoire actuelle dans le stock de données et enlever la session verrouillée une fois terminée.
- Efface les données du joueur (et d'autres variables telles que les métadonnées et les erreurs d'état) de la mémoire du serveur.
Sur un boucle de期, le serveur écrit les données de chaque joueur dans le stock de données (sous réserve qu'il soit sûr de pouvoir enregistrer). Cette rédundance de bienvenue mitige la perte en cas de crash du serveur et est également nécessaire pour maintenir la session verrouillée.
Lorsqu'une demande d'arrêt du serveur est reçue, le suivant se produit dans un BindToClose rappel :
- Une demande est faite pour enregistrer les données de chaque joueur sur le serveur, en suivant le processus normalement effectué lorsqu'un joueur quitte le serveur. Ces demandes sont effectuées en parallèle, car les appels Class.DataModel:BindToClose()|BindToClose « ne ont que 30 secondes pour être terminés.
- Pour accélérer les sauvegardes, toutes les autres demandes dans la file d'attente de chaque clé sont effacées à partir du DataStoreWrapper (voir Réessayes).
- Le rappel ne se déclenche pas tant que toutes les demandes ne sont pas terminées.