Fondo
Roblox proporciona una serie de API para interactuar con almacenes de datos a través de DataStoreService . El caso de uso más común para estas API es para guardar, cargar y replicar datos del jugador . Esto es, los datos relacionados con el progreso, las compras y otras características de sesión que persisten entre sesiones de juego individuales.
La mayoría de las experiencias en Roblox usan estas API para implementar alguna forma de un sistema de datos del jugador. Estas implementaciones difieren en su enfoque, pero generalmente buscan soluciones para resolver el mismo conjunto de problemas.
Problemas comunes
A continuación, se muestran algunos de los problemas más comunes que los sistemas de datos del jugador intentan resolver:
En el acceso a la memoria: DataStoreService solicitudes hacen que las solicitudes servidoroperen de forma asíncrona y estén sujetas a límites de tasa. Esto es adecuado para un carga inicial en el comienzo de la sesión, pero no para operaciones de lectura y escritura de alta frecuencia durante el curso normal del juego. La mayoría de
- Lee inicialmente al comienzo de una sesión
- Escriba finalmente al final de la sesión
- Periodic escribe en un intervalo para mitigar el escenario donde la escritura final falla
- Escribe para asegurarse de que los datos se guarden mientras se procesa una comprar
Almacenamiento eficiente: Almacenar todos los datos de la sesión de un jugador en una sola tabla te permite actualizar múltiples valores a nivel de átomo y manejar la misma cantidad de datos en menos solicitudes. También elimina el riesgo de desincronización de valores y facilita la razonabilidad de los rollback.
Algunos desarrolladores también implementan personalización de serialización para comprprimir grandes estructuras de datos (típicamente para guardar el contenido generado en el juego por el usuario).
Replicación: El cliente necesita un acceso regular a los datos de un jugador (por ejemplo, para actualizar la interfaz de usuario). Un enfoque genérico para replicar los datos del jugador al cliente le permite transmitir esta información sin tener que crear sistemas de replicación personalizados para cada componente de los datos. Los desarrolladores a menudo quieren la opción de ser selectivo sobre lo que se replica y no se replica al cliente.
Manipulación de errores: Cuando no se pueden acceder a los almacenes de datos, la mayoría de las soluciones implementarán un mecanismo de intento de nuevo y un fallback a los datos predeterminados. Se requiere mucha atención para asegurar que los datos de fallback no sobreescriban los datos reales, y que esto se comunicará al jugador adecuadamente.
Reintentos: Cuando los almacenes de datos no son alcanzables, la mayoría de las soluciones implementan un mecanismo de reintentos y un fallback a los datos predeterminados. Tenga cuidado especial para asegurar que los datos fallback no sobreescriban los datos "real", y comúníquese la situación al jugador adecuadamente.
Bloqueo de sesión: Si los datos de un jugador se cargan y están en memoria en múltiples servidores, pueden ocurrir problemas en el que un servidor guarda información desactualizada. Esto puede conducir a pérdidas de datos y problemas comunes de duplicación de artículos.
Procesamiento de Compras Atómicas: Verificar, otorgar y registrar compras atómicas para evitar que se pierdan o se otorguen múltiples veces.
Ejemplo de código
Roblox tiene un código de referencia para ayudarlo a diseñar y construir sistemas de datos del jugador. El resto de esta página examina el fondo, los detalles de implementación y las limitaciones generales.
Después de importar el modelo en Studio, deberías ver la siguiente estructura de carpeta:
Arquitectura
Este diagrama de alto nivel ilustra los sistemas de clave en la muestra y cómo interactúan con el código en el resto de la experiencia.
Intentos fallidos
Clase: DataStoreWrapper >
Fondo
Como DataStoreService hace solicitudes web debajo del capó, sus solicitudes no están garantizadas para tener éxito. Cuando esto sucede, los métodos DataStore lanzan errores, lo que te permite manejarlos.
Un "tí ya" común puede ocurrir si intenta manejar fallos de almacenamiento de datos como estos:
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
Mientras que este es un mecanismo de intento perfectamente válido para una función genérica, no es adecuado para solicitudes de DataStoreService porque no garantiza el orden en que se hacen las solicitudes. Preservar el orden de las solicitudes es importante para las solicitudes de DataStoreService porque interactúan con el estado. Considere el siguiente escenario:
- Se hace una solicitud A para establecer el valor de la llave K a 1.
- La solicitud falla, por lo que se programó una reintenta en 2 segundos.
- Antes de que ocurra el intento de nuevo, solicite a B que establezca el valor de K a 2, pero el intento de solicitud A inmediatamente sobrescribe este valor y establece K a 1.
A pesar de que UpdateAsync opera en la última versión del valor de la clave, UpdateAsync solicitudes deben procesarse aún para evitar estados transitorios no válidos (por ejemplo, una compra resta monedas antes de que se procese una adición de monedas, lo que resulta en monedas negativas).
Nuestro sistema de datos del jugador usa una nueva clase, DataStoreWrapper , que proporciona reintentos que son garantizados para ser procesados por clave.
Acercarse
DataStoreWrapper proporciona métodos que coinciden con los métodos de DataStore : DataStore:GetAsync() , 0> Class.GlobalDataStore:SetAsync()|DataStore:
Estos métodos, cuando se llaman:
Agregue la solicitud a una cola. Cada clave tiene su propia cola, donde se procesan las solicitudes en orden y en serie. El subproceso solicitante se genera hasta que la solicitud se complete.
Esta funcionalidad se basa en la clase ThreadQueue, que es un programador de tareas y límite de velocidad basado en coroutine. En lugar de返回 una promesa, ThreadQueue genera el hilo actual hasta que la operación esté completa y se lanza un error si falla. Esto es más consistente con los patrones de Lua idiomático.
Si se falla una solicitud, se reintenta con un backoff exponencial configurable. Estas reintenta forman parte del llamado de retorno enviado a la ThreadQueue , por lo que se garantiza que completen antes de la próxima solicitud en la cola para esta llave.
Cuando se completa una solicitud, el método de solicitud devuelve el patrón success, result
DataStoreWrapper también muestra métodos para obtener la longitud de la cola para una llave específica y limpiar solicitudes estancas. La opción anterior es particularmente útil en escenarios cuando el servidor se está cerrando y no hay tiempo para procesar ninguna pero las solicitudes más recientes.
Cavernas
DataStoreWrapper sigue el principio de que, fuera de escenarios extremos, cada solicitud de almacenamiento de datos debe permitirse para completarse (con éxito o de otra manera), incluso si una solicitud más reciente lo hace redundante. Cuando ocurre una nueva solicitud, las solicitudes obsoletas no se eliminan de la cola, sino que se permiten para completarse antes de que
Es difícil decidir un conjunto intuitivo de reglas para cuando una solicitud es segura de eliminar de la cola. Considere el siguiente conjunto:
Value=0, SetAsync(1), GetAsync(), SetAsync(2)
El comportamiento esperado es que GetAsync() devuelva 1 , pero si eliminamos la solicitud SetAsync() de la cola debido a que se hizo redundante por el más reciente, devolvería 1> 01> .
La progresión lógica es que cuando se agrega una nueva solicitud de escritura, solo se prueban las solicitudes estelares más antiguas hasta la fecha de la solicitud de lectura más reciente. UpdateAsync() , por far la operación más común (y la única que usa este sistema), puede leer y escribir, por lo que sería difícil de reconciliar dentro de este diseño sin agregarle demasiada complejidad.
DataStoreWrapper podría requerir que especifiques si una solicitud UpdateAsync() está permitida para leer y/o escribir, pero no tendría aplicación a nuestro sistema de datos del jugador, donde esto no se puede determinar antes del tiempo debido al mecanismo de sincronización de sesión ( cubierto en más detalle más adelante).
Una vez eliminado de la cola, es difícil decidir una regla intuitiva para cómo se debe manejar esto. Cuando se hace una solicitud de DataStoreWrapper , se genera el hilo actual hasta que se complete. Si eliminamos subprocesoobsoletas de la cola, deberíamos decidir si devolver false,
En última instancia, nuestra opinión es que el enfoque simple (procesar cada solicitud) es preferible aquí y crea un entorno más claro para navegar cuando se acerca a problemas complejos como el bloqueo de sesión. La única excepción a esto es durante DataModel:BindToClose(), donde limpiar la col
Bloqueo de sesión
Clase: SessionLockedDataStoreWrapper
Fondo
Los datos del jugador se almacenan en el memoria del servidor y solo se lee y escribe a los almacenes de datos subyacentes cuando sea necesario. Puede leer y actualizar los datos del jugador en el momento sin necesidad de solicitudes web y evitar exceder los límites de DataStoreService .
Para que este modelo funcione como se espera, es imperativo que no más de un servidor sea capaz de cargar los datos de un jugador en el DataStore al mismo tiempo.
Por ejemplo, si el servidor A carga los datos de un jugador, el servidor B no puede cargar esos datos hasta que el servidor A publique su bloqueo en él durante un guardado final. Sin un mecanismo de bloqueo, el servidor B podría cargar datos de jugador desactualizados desde el almacén de datos antes de que el servidor A tenga la oportunidad de guardar la versión más reciente que tiene en memoria. Luego
Aunque Roblox solo permite que un cliente se conecte a un servidor a la vez, no puede asumir que los datos de una sesión siempre se guardan antes de que la siguiente sesión comience. Considere los siguientes escenarios que se pueden producir cuando un jugador deja el servidor A:
- El servidor A hace una solicitud de DataStore para guardar sus datos, pero la solicitud falla y requiere varios intentos para completarse con éxito. Durante el período de intentos, el jugador se une al servidor B.
- El servidor A hace demasiados UpdateAsync() llamadas a la misma clave y se acelera. La solicitud de guardado final se coloca en una cola. Mientras que la solicitud está en la cola, el jugador se une al servidor B.
- En el servidor A, algunos códigos conectados al evento PlayerRemoving producen antes de que se guarden los datos del jugador. Antes de que esta operación se complete, el jugador se une al servidor B.
- El rendimiento del servidor A se ha degradado hasta el punto de que la última guardado se guarda hasta que el jugador se una al servidor B.
Estos escenarios deberían ser raros, pero producirse, especialmente en situaciones donde un jugador se desconecta de un servidor y se conecta a otro en rápida sucesión (por ejemplo, mientras se teletransporta). Algunos usuarios maliciosos incluso pueden intentar abusar de este comportamiento para completar acciones sin que persistan. Esto puede ser particularmente dañoso en juegos que permiten a los jugadores comerciar y es una fuente común de duplicaciones de artículos.
La sesión bloquea direcciones de esta vulnerabilidad al asegurar que cuando la llave de un jugador es leída por el servidor primero, el servidor escribe un bloqueo en el metadato de la clavedentro del mismo DataStore llamada. Si este valor de bloqueo está presente cuando cualquier otro servidor intenta leer o escribir la clave, el servidor no se hace responsable.
Acercarse
SessionLockedDataStoreWrapper es un metaverso alrededor de la clase DataStoreWrapper. DataStoreWrapper proporciona funciones de cola y reintentos, que se complementan con la función de bloqueo de sesión.
SessionLockedDataStoreWrapper pasa cada solicitud
La función de transformación pasó en UpdateAsync para cada solicitud realiza las siguientes operaciones:
Verifica que la clave es segura para acceso, abandonando la operación si no lo es. "Seguro para acceso" significa:
El objeto de metadatos de la claveno incluye un valor no reconocido de LockId que se actualizó por última vez menos de la expiración del bloqueo. Esto se debe a respetar un bloqueo colocado por otro servidor y para ignorar ese bloqueo si ha expirado.
Si este servidor ha colocado su propio valor de LockId en el metadato de la claveanteriormente, entonces este valor sigue estando en el metadato de la clave. Esto representa la situación en la que otro servidor ha tomado el control de la clavede este servidor (por expiración o por fuerza) y luego la ha lanzado. Alternativamente expresado, incluso si
Class.GlobalDataStore:UpdateAsync()|UpdateAsync realiza la operación DataStore solicitada por el consumidor de SessionLockedDataStoreWrapper. Por ejemplo, 0> Class.GlobalDataStore:GetAsync()|GetAsync()0> se traduce a UpdateAsync3> .
Dependiendo de los parámetros que se pasan en la solicitud, UpdateAsync tenga o no la clave:
Si la clave está bloqueada, UpdateAsync establece el LockId en el metadato de la clave en el servidor para almacenarlo en el servidor en el tiempo máximo de expiración. Esta clave se almacena en el memoria del servidor para que se pueda verificar la pró
Si la clave se tiene que desbloquear, UpdateAsync elimina el LockId en los metadatos de la clave.
Un gestor de intento personalizado se pasa a la capa subyacente DataStoreWrapper para que la operación se intente si se interrumpió en el paso 1 debido a que la sesión estaba bloqueada.
También se devuelve un mensaje de error personalizado al consumidor, lo que permite que el sistema de datos del jugador informe de un error alternativo en el caso de la sesión de bloqueo al cliente.
Cavernas
El régimen de sincronización de sesiones se basa en un servidor siempre liberando su bloqueo en una llave cuando termina con él. Esto siempre debe ocurrir a través de una instrucción para desbloquear la llave como parte de la escritura final en PlayerRemoving o BindToClose() .
Sin embargo, el desbloqueo puede fallar en ciertas situaciones. Por ejemplo:
- El servidor se cerró o DataStoreService estaba inoperable para todos los intentos de acceder a la clave.
- Debido a un error en la lógica o un error similar, la instrucción para desbloquear la llave no se hizo.
Para mantener la cerradura en una clave, debes acceder a ella con regularidad para que se cargue en memoria. Esto se haría normalmente como parte del flujo de guardado automático que se ejecuta en el fondo en la mayoría de los sistemas de datos del jugador, pero este sistema también expone un método refreshLockAsync si lo necesitas hacer manualmente.
Si el tiempo de caducidad de la firma ha expirado sin que se haya actualizado la firma, entonces cualquier servidor es gratuito para tomar el control del bloque. Si un servidor diferente toma el bloque, las solicitudes del servidor actual para leer o escribir la clave fracasan a menos que establezca un nuevo bloque.
Procesando productos del desarrollador
Singleton: ReceiptHandler >
Fondo
El llamado de función ProcessReceipt realiza el trabajo crítico de determinar cuándo finalizar una comprar. ProcessReceipt se llama en escenarios muy específicos. Para su conjunto de garantías, consulte MarketplaceService.ProcessReceipt.
Aunque la definición de "manejo" de una compra puede diferir entre experiencias, usamos los siguientes criterios
La compra no ha sido tratada anteriormente.
La compra se refleja en la sesión actual.
Esto requiere llevar a cabo las siguientes operaciones antes de返回 PurchaseGranted :
- Verifique que el PurchaseId no ya ha sido registrado como manejado.
- Otorga la compra en los datos del jugador en memoria.
- Registra el PurchaseId como se maneja en los datos del jugador en memoria.
- Escribe los datos del jugador en el espacio de datos del jugador en el DataStore .
La sesión de bloqueo simplifica este flujo, ya que ya no necesitas preocuparte por los siguientes escenarios:
- Los datos del jugador en el servidor actual pueden ser potencialmente desactualizados, lo que requiere que obtenga el último valor del DataStore antes de verificar el historial de PurchaseId
- El llamado para la misma compra que se ejecuta en otro servidor, requiriendo que leas y escribas la historia de PurchaseId y guardes los datos del jugador actualizados con la compra reflejada para evitar condiciones de carrera
Bloqueo de sesión que garantiza que, si se intenta escribir en el jugador's Class.GlobalDataStore|DataStore , el juego es exitoso, no hay otro servidor que lea o escriba con éxito en el jugador's Class.GlobalDataStore|DataStore entre la carga y el guardado de este servidor. En resumen, los datos del jugador en este servidor son la versión más actualizada disponible
Acercarse
Los comentarios en ReceiptProcessor resaltan el enfoque:
Verifique si los datos del jugador se cargan actualmente en este servidor y se cargan sin ningún error.
Dado que este sistema usa la verificación de sesión, esta verificación también verifica que los datos en el memoria son la versión más reciente.
Si los datos del jugador no se han cargado aún (lo que se espera cuando un jugador se une a un juego), espere a que los datos del jugador se cargar. El sistema también escucha si el jugador está saliendo del juego antes de que se carguen sus datos, ya que no debería generar en forma indefinida y bloquear este llamado de vuelta en este servidor para esta compra si el jugador se une.
Verifique si el PurchaseId no se ha registrado ya como procesado en los datos del jugador.
Debido a la sincronización de sesión, el arreglo de PurchaseIds el sistema tiene en memoria es la versión más reciente. Si el arreglo de PurchaseId se registra
Actualiza los datos del jugador localmente en este servidor para "recompensar" la comprar.
ReceiptProcessor toma un enfoque genérico de llamada y asigna un diferente llamado de vuelta para cada DeveloperProductId .
Actualice los datos del jugador localmente en este servidor para almacenar el PurchaseId .
Enviar una solicitud para guardar los datos en el DataStore, devolviendo PurchaseGranted si la solicitud es exitosa. Si no, devolviendo NotProcessedYet .
Si esta solicitud de guardado no es exitosa, un later request para guardar los datos de sesión del jugador en el memoria aún podría tener éxito. Durante la siguiente llamada ProcessReceipt, el paso 2 maneja esta situación y devuelve PurchaseGranted .
Datos del jugador
Singletons: PlayerData.Server > , PlayerData.Client >
Fondo
Los módulos que proporcionan una interfaz para que el código del juego se lea y escriba los datos de la sesión del jugador son comunes en las experiencias de Roblox. Esta sección cubre PlayerData.Server y PlayerData.Client .
Acercarse
PlayerData.Server y PlayerData.Client manejan lo siguiendo:
- Cargar los datos del jugador en el almacén, incluida la gestión de casos en los que no se puede cargar
- Proporcionar una interfaz para que el código del servidor para buscar y cambiar los datos del jugador
- Replicar los cambios en los datos del jugador al cliente para que el código del cliente pueda acceder a él
- Replicando errores de carga y/o guardado a cliente para que pueda mostrar diálogos de error
- Guardar los datos del jugador periódicamente, cuando el jugador se va, y cuando el servidor se cierra
Cargando Datos del Jugador
SessionLockedDataStoreWrapper hace una solicitud de sincronización de getAsync a la tiendade datos.
Si esta solicitud falla, se usan los datos predeterminados y el perfil se marca como "errored" para asegurarse de que no se escriba al almacén de datos más tarde.
Una opción alternativa es expulsar al jugador, pero recomendamos dejar que el jugador juegue con datos predeterminados y limpie el mensaje de error al eliminarlos de la experiencia.
Se envía un cargamento inicial a PlayerDataClient que contiene los datos cargados y el estado de error (si se requiere).
Cualquier hilo producido utilizando waitForDataLoadAsync para el jugador se reanuda.
Proporcionar una interfaz para el código del servidor
- PlayerDataServer es un singleton que se puede requerir y acceder a él por cualquier código de servidor que se ejecuta en el mismo entorno, ambiente.
- Los datos del jugador se organizan en un diccionario de llaves y valores. Puedes manipular estos valores en el servidor usando los métodos setValue, getValue, updateValue y 2>RemoveValue2>. Estos métodos operan todos sincronizados sin rendir.
- Los métodos hasLoaded y waitForDataLoadAsync están disponibles para garantizar que los datos se hayan cargado antes de que accedas a ellos. Recomendamos hacer esto una vez durante una pantalla de carga antes de que otros sistemas comienzan a verificar errores de carga antes de cada interacción con los datos en el client.
- Un método hasErrored puede consultar si el cargador inicial no se ha fallado, lo que hace que el jugador use datos predeterminados. Compruebe este método antes de permitir que el jugador realice ninguna compra, ya que las compras no se pueden guardar a los datos sin una cargarexitosa.
- Un playerDataUpdated señal se activa con el player , key y 1> value1> cuando los datos de un jugador cambian. Los sistemas individuales pueden suscribirse a esto.
Replicando Cambios al Cliente
- Cualquier cambio en los datos del jugador en PlayerDataServer se replica a PlayerDataClient , a menos que se marque ese clave como privada usando setValueAsPrivate
- setValueAsPrivate se usa para designar claves que no deben enviarse al cliente
- PlayerDataClient incluye un método para obtener el valor de una llave (obtener) y una señal que se activa cuando se actualiza (actualizado). Un método hasLoaded y un método loaded también están incluidos, para que el cliente pueda esperar a que se cargue y replica el sistema antes de comenzar sus sistemas
- PlayerDataClient es un singleton que se puede requerir y acceder a cualquier código de cliente que se ejecuta en el mismo entorno, ambiente
Replicando Errores al Cliente
- Estados de error encontrados al guardar o cargar los datos del jugador se replican a PlayerDataClient .
- Acceda a esta información con los métodos getLoadError y getSaveError, junto con las señales loaded y 1>Sentido1>.
- Hay dos tipos de errores: DataStoreError (la solicitud DataStoreService falló) y SessionLocked (ver 1>Bloqueo de sesión1>).
- Usa estos eventos para desactivar las solicitudes de compra del cliente y implementar diálogos de advertencia. Esta imagen muestra un diálogo de ejemplo:
Guardando Datos del Jugador
Cuando el jugador salga del juego, el sistema tomará los siguientes pasos:
- Asegúrese de que sea seguro escribir los datos del jugador en el tiendade datos. Los escenarios en los que no sería seguro incluyen los datos del jugador fallando para cargar o aún estando en curso de carga.
- Haga una solicitud a través del SessionLockedDataStoreWrapper para escribir el valor de datos actual en el almacén de datos y eliminar la sesión de bloqueo una vez completa.
- Borrar los datos del jugador (y otras variables, como el estado de metadatos y los errores) de la memoria del servidor.
En un ciclo periódico, el servidor escribe los datos de cada jugador en el almacén de datos (siempre que sea seguro guardarlos). Esto mitiga la pérdida en caso de un error del servidor y es también necesario para mantener la sesión bloqueada.
Cuando se recibe una solicitud para apagar el servidor, lo siguiente ocurre en un BindToClose llamada de retorno:
- Se solicita guardar los datos de cada jugador en el servidor, siguiendo el proceso normalmente completado cuando un jugador se va del servidor. Estas solicitudes se hacen en paralelo, ya que los llamados de BindToClose solo tienen 30 segundos para completarse.
- Para acelerar los guardados, todas las otras solicitudes en la cola de cada clave se eliminan del DataStoreWrapper subyacente (ver Retries).
- El llamador no se返a hasta que todas las solicitudes se hayan completado.