Um dos maiores ajustes para usuários que estão começando no DynamoDB e NoSQL é como modelar os dados para filtrar todo um conjunto de dados. Por exemplo, em nosso jogo, precisamos encontrar sessões de jogo com lugares disponíveis para que possamos mostrar aos usuários em qual sessão de jogo eles podem ingressar.
Em um banco de dados relacional, você escreveria um pouco de SQL para consultar os dados.
SELECT * FROM games WHERE status = “OPEN”
O DynamoDB pode filtrar os resultados em uma operação Query (Consulta) ou Scan (Varredura), mas não funciona como um banco de dados relacional. Um filtro do DynamoDB se aplica depois que os itens iniciais que correspondem à operação Query ou Scan foram recuperados. O filtro reduz o tamanho da carga útil enviada do serviço DynamoDB, mas o número de itens recuperados inicialmente está sujeito aos limites de tamanho do DynamoDB.
Felizmente, há várias maneiras de permitir consultas filtradas em comparação com seu conjunto de dados no DynamoDB. Para fornecer filtros eficiente sem sua tabela do DynamoDB, é necessário planejar os filtros no modelo de dados de sua tabela desde o início. Lembre-se da lição que aprendemos no segundo módulo deste laboratório: considere seus padrões de acesso e, em seguida, projete sua tabela.
Nas etapas seguintes, usamos um índice global secundário para encontrar jogos abertos. Especificamente, usaremos a técnica de índice esparso para gerenciar esse padrão de acesso.
Tempo de conclusão do módulo: 40 minutos
-
Etapa 1: Modele um índice secundário esparso
Os índices secundários são ferramentas de modelagem de dados cruciais no DynamoDB. Elas permitem reformatar seus dados para permitir padrões de consulta alternativos. Para criar um índice secundário, você especifica a chave primária do índice, exatamente como fez quando criou uma tabela anteriormente. A chave primária de um índice secundário global não tem que ser exclusiva para cada item. Em seguida, o DynamoDB copia os itens para o índice com base nos atributos especificados e você pode consultar esse índice exatamente como faz com a tabela.
Usar índices secundários esparsos é uma estratégia avançada no DynamoDB. Com índices secundários, o DynamoDB copia os itens da tabela original somente se eles tiverem os elementos da chave primária no índice secundário. Os itens que não tem esses elementos não são copiados. É por isso que esses índices secundários são chamados de “esparsos”.
Vamos ver como isso funciona para nós. Talvez se lembre que temos dois padrões de acesso para encontrar jogos abertos:
- Encontrar jogos abertos (Leitura)
- Encontrar jogos abertos por mapa (Leitura)
Podemos criar um índice secundário usando uma chave primária composta em que a chave HASH é o atributo map (mapa) do jogo e a chave RANGE é o atributo open_timestamp do jogo, indicando há quanto tempo o jogo foi aberto.
A parte importante para nós é que, quando um jogo fica cheio, o atributo open_timestamp é excluído. Quando o atributo é excluído, o jogo cheio é removido do índice secundário porque não tem um valor para o atributo da chave RANGE. É isso que mantém nosso índice esparso: ele inclui apenas os jogos abertos que têm o atributo open_timestamp.
Na etapa seguinte, criamos o índice secundário.
-
Etapa 2: Crie um índice secundário esparso
Nesta etapa, criaremos o índice secundário esparso para jogos abertos (jogos que ainda não estão cheios).
Criar um índice secundário é similar à criar uma tabela. No código que você baixou há um arquivo de script no diretório scripts/ chamado add_secondary_index.py. Este é o conteúdo do arquivo:
import boto3 dynamodb = boto3.client('dynamodb') try: dynamodb.update_table( TableName='battle-royale', AttributeDefinitions=[ { "AttributeName": "map", "AttributeType": "S" }, { "AttributeName": "open_timestamp", "AttributeType": "S" } ], GlobalSecondaryIndexUpdates=[ { "Create": { "IndexName": "OpenGamesIndex", "KeySchema": [ { "AttributeName": "map", "KeyType": "HASH" }, { "AttributeName": "open_timestamp", "KeyType": "RANGE" } ], "Projection": { "ProjectionType": "ALL" }, "ProvisionedThroughput": { "ReadCapacityUnits": 1, "WriteCapacityUnits": 1 } } } ], ) print("Table updated successfully.") except Exception as e: print("Could not update table. Error:") print(e)
Sempre que os atributos são usados em uma chave primária de uma tabela ou um índice secundário, eles devem ser definidos em AttributeDefinitions. Em seguida, usamos o comando Create (Criar) para criar um novo índice secundário na propriedade GlobalSecondaryIndexUpdates. Para esse índice secundário, especificamos o nome do índice, o esquema da chave primária, o throughput provisionado e os atributos que queremos projetar.
Não temos que especificar que nosso índice secundário se destina a ser usado como um índice esparso. Isso é puramente uma função dos dados que você insere. Se você gravar itens em sua tabela que não têm os atributos referentes a seus índices secundários, eles não serão incluídos nesses índices.
Crie o índice secundário executando o comando a seguir.
python scripts/add_secondary_index.py
A seguinte mensagem deve ser exibida no console: “Table updated successfully.” (Tabela atualizada com êxito.).
Na próxima etapa, usaremos o índice esparso para encontrar jogos por mapa.
-
Etapa 3: Consulte o índice secundário esparso
Agora que configuramos o índice secundário, vamos usá-lo para atender a alguns dos padrões de acesso.
Para usar um índice secundário, você tem duas chamadas de API disponíveis: Query (Consulta) e Scan (Varredura). Com Query, é necessário especificar a chave HASH, que retorna um resultado direcionado. Com Scan, você não especifica uma chave HASH e a operação roda em toda a sua tabela. Scans são desencorajadas no DynamoDB, salvo em circunstâncias específicas, pois elas acessam todos os itens em seu banco de dados. Se você tem uma quantidade significativa de dados em sua tabela, a varredura pode demorar muito tempo. Na etapa seguinte, mostramos por que Scans pode ser uma ferramenta poderosa quando usada com índices esparsos.
Podemos usar a Query API no índice secundário que criamos na etapa anterior para encontrar todos os jogos abertos por nome de mapa. O índice secundário é particionado por nome de mapa, o que nos permite fazer consultas direcionadas para encontrar jogos abertos.
No código que você baixou, há um arquivo find_open_games_by_map.py no diretório application/. Este é o conteúdo do script:
import boto3 from entities import Game dynamodb = boto3.client('dynamodb') def find_open_games_by_map(map_name): resp = dynamodb.query( TableName='battle-royale', IndexName="OpenGamesIndex", KeyConditionExpression="#map = :map", ExpressionAttributeNames={ "#map": "map" }, ExpressionAttributeValues={ ":map": { "S": map_name }, }, ScanIndexForward=True ) games = [Game(item) for item in resp['Items']] return games games = find_open_games_by_map("Green Grasslands") for game in games: print(game)
No script anterior, a função find_open_games_by_map é similar a uma função que você teria em seu aplicativo. A função aceita um nome de mapa e faz uma consulta em OpenGamesIndex para encontrar todos os jogos abertos do mapa. Em seguida, ela monta as entidades retornadas em objetos Game que podem ser usados em seu aplicativo.
Execute esse script rodando o seguinte comando no terminal:
python application/find_open_games_by_map.py
O terminal mostrará a saída a seguir com quatro jogos abertos par o mapa Green Grasslands.
Open games for Green Grasslands: Game<14c7f97e-8354-4ddf-985f-074970818215 -- Green Grasslands> Game<3d4285f0-e52b-401a-a59b-112b38c4a26b -- Green Grasslands> Game<683680f0-02b0-4e5e-a36a-be4e00fc93f3 -- Green Grasslands> Game<0ab37cf1-fc60-4d93-b72b-89335f759581 -- Green Grasslands> sudo cp -r wordpress/* /var/www/html/
Na etapa seguinte, usamos a Scan API para procurar no índice secundário esparso.
-
Etapa 4: Procure no índice secundário esparso
Na etapa anterior, vimos como encontrar jogos de um determinado mapa. Alguns jogadores podem preferir jogar em um mapa específico, portanto isso é útil. Outros jogadores podem querer jogar em qualquer mapa. Nesta seção, mostramos como encontrar algum jogo aberto no aplicativo, independentemente do tipo de mapa. Para fazer isso, usamos a Scan API.
Em geral, você não quer projetar sua tabela para usar a operação Scan do DynamoDB porque ele foi criado para consultas cirúrgicas que buscam as entidades exatas de que você precisa. Uma operação Scan obtém uma coleção aleatória de entidades em sua tabela, portanto, encontrar as entidades de que você precisa pode exigir várias idas e vindas do banco de dados.
Contudo, algumas vezes a operação Scan pode ser útil. Em nossa situação, temos um índice esparso secundário, o que significa que nosso índice não deve ter muitas entidades nele. Além disso, o índice inclui somente os jogos que estão abertos, e é exatamente disso que precisamos.
Para este caso de uso, Scan funciona muito bem. Vamos ver como ele funciona. No código que você baixou, há um arquivo find_open_games.py no diretório application/. Este é o conteúdo do arquivo:
import boto3 from entities import Game dynamodb = boto3.client('dynamodb') def find_open_games(): resp = dynamodb.scan( TableName='battle-royale', IndexName="OpenGamesIndex", ) games = [Game(item) for item in resp['Items']] return games games = find_open_games() print("Open games:") for game in games: print(game)
O código é similar ao da etapa anterior. No entanto, em vez de usar o método query() no cliente do DynamoDB, usamos o método scan(). Como estamos usando scan(), não precisamos especificar algo sobre as condições essenciais como fizemos com query(). Estamos apenas fazendo o DynamoDB retornar vários itens sem nenhuma ordem específica.
Execute o script com o seguinte comando no terminal:
python application/find_open_games.py
O terminal deve imprimir uma lista de nove jogos que estão abertos em vários mapas.
Open games: Game<c6f38a6a-d1c5-4bdf-8468-24692ccc4646 -- Urban Underground> Game<d06af94a-2363-441d-a69b-49e3f85e748a -- Dirty Desert> Game<873aaf13-0847-4661-ba26-21e0c66ebe64 -- Dirty Desert> Game<fe89e561-8a93-4e08-84d8-efa88bef383d -- Dirty Desert> Game<248dd9ef-6b17-42f0-9567-2cbd3dd63174 -- Juicy Jungle> Game<14c7f97e-8354-4ddf-985f-074970818215 -- Green Grasslands> Game<3d4285f0-e52b-401a-a59b-112b38c4a26b -- Green Grasslands> Game<683680f0-02b0-4e5e-a36a-be4e00fc93f3 -- Green Grasslands> Game<0ab37cf1-fc60-4d93-b72b-89335f759581 -- Green Grasslands>
Nesta etapa, vimos como usar a operação Scan pode ser a opção certa em circunstâncias específicas. Usamos Scan para buscar várias entidades de nosso índice secundário esparso para mostrar jogos abertos a jogadores.
Nas etapas seguintes, satisfazemos dois padrões de acesso:
- Participar do jogo de um usuário (Gravação)
- Iniciar jogo(Gravação)
Para satisfazer o padrão de acesso “Participar do jogo de um usuário” nas etapas seguintes, vamos usar transações do DynamoDB. As transações são bem conhecidas em sistemas relacionais para operações que afetam vários elementos de dados de uma só vez. Por exemplo, imagine que você gerencia um banco. Uma cliente, Alessandra, transfere 100 USD para outra cliente, Ana. Ao registrar essa transação, você usaria uma transação para garantir que as alterações sejam aplicadas aos saldos das duas clientes em vez de uma só.
As transações do DynamoDB facilitam a criação de aplicativos que alteram vários itens como parte de uma única operação. Com as transações, você pode operar em até 10 itens como parte de uma única solicitação de transação.
Em uma chamada à TransactWriteItem API, você pode usar estas operações:
- Put: para inserir ou substituir um item.
- Update: para atualizar um item existente.
- Delete: para remover um item.
- ConditionCheck: para expressar uma condição em um item existente sem alterá-lo.
Na etapa seguinte, usamos uma transação do DynamoDB ao adicionar novos usuários a um jogo enquanto evitamos que o jogo fique superlotado.
-
Etapa 5: Adicione usuários a um jogo
O primeiro padrão de acesso com que lidamos neste módulo é adicionar novos usuários a um jogo.
Ao adicionar um novo usuário a um jogo, precisamos:
- Confirmar que ainda não há 50 jogadores no jogo (cada jogo pode ter no máximo 50 jogadores).
- Confirmar que o usuário ainda não está no jogo.
- Criar uma nova entidade UserGameMapping para adicionar o usuário ao jogo.
- Incrementar o atributo people na entidade Game para monitorar quantos jogadores estão no jogo.
A realização de todos esses itens exige ações de gravação na entidade Game existente e na nova entidade UserGameMapping, bem como lógica condicional para cada uma das entidades. Esse é o tipo de operação que se adéqua perfeitamente às transações do DynamoDB porque você precisa trabalhar em várias entidades na mesma solicitação e quer que toda a solicitação tenha êxito ou falhe em conjunto.
No código que você baixou, há um arquivo join_game.py no diretório application/. A função nesse script usa uma transação do DynamoDB para adicionar um usuário a um jogo.
Este é o conteúdo do script:
import boto3 from entities import Game, UserGameMapping dynamodb = boto3.client('dynamodb') GAME_ID = "c6f38a6a-d1c5-4bdf-8468-24692ccc4646" USERNAME = 'vlopez' def join_game_for_user(game_id, username): try: resp = dynamodb.transact_write_items( TransactItems=[ { "Put": { "TableName": "battle-royale", "Item": { "PK": {"S": "GAME#{}".format(game_id) }, "SK": {"S": "USER#{}".format(username) }, "game_id": {"S": game_id }, "username": {"S": username } }, "ConditionExpression": "attribute_not_exists(SK)", "ReturnValuesOnConditionCheckFailure": "ALL_OLD" }, }, { "Update": { "TableName": "battle-royale", "Key": { "PK": { "S": "GAME#{}".format(game_id) }, "SK": { "S": "#METADATA#{}".format(game_id) }, }, "UpdateExpression": "SET people = people + :p", "ConditionExpression": "people <= :limit", "ExpressionAttributeValues": { ":p": { "N": "1" }, ":limit": { "N": "50" } }, "ReturnValuesOnConditionCheckFailure": "ALL_OLD" } } ] ) print("Added {} to game {}".format(username, game_id)) return True except Exception as e: print("Could not add user to game") join_game_for_user(GAME_ID, USERNAME)
Na função join_game_for_user deste script, o método transact_write_items() executa uma transação de gravação. Essa transação tem duas operações.
Na primeira operação da transação, usamos uma operação Put para inserir uma nova entidade UserGameMapping. Como parte dessa operação, especificamos uma condição de que o atributo SK não deve existir para essa entidade. Isso garante que uma entidade com PK e SK ainda não exista. Se já existir essa entidade, isso significa que esse usuário já ingressou no jogo.
A segunda operação é uma operação Update na entidade Game para incrementar o atributo people em um. Como parte dessa operação, adicionamos uma verificação condicional para que o valor atual de people não seja maior que 50. Assim que 50 pessoas ingressarem no jogo, ele fica cheio e está pronto para começar.
Execute esse script com o seguinte comando no terminal:
python application/join_game.py
A saída no terminal deve indicar que o usuário foi adicionado ao jogo.
Added vlopez to game c6f38a6a-d1c5-4bdf-8468-24692ccc4646
Se você tentar executar o script novamente, a função falhará. O usuário vlopez já foi adicionado ao jogo, então tentar adicioná-lo novamente não satisfaz as condições que especificamos.
A adição de transações do DynamoDB simplifica muito o fluxo de trabalho em torno de operações complexas como essas. Sem as transações, seriam necessárias várias chamadas à API com condições complexas e reversões manuais no caso de conflitos. Agora podemos implementar essas operações complexas com menos de 50 linhas de código.
Na etapa seguinte, gerenciamos o padrão de acesso “Iniciar jogo (Gravação)”.
-
Etapa 6: Iniciar um jogo
Assim que um jogo tiver 50 usuários, o criador do jogo pode começar o jogo para iniciar a jogabilidade. Nesta etapa, mostramos como gerenciar esse padrão de acesso.
Quando o back-end de nosso aplicativo recebe uma solicitação para iniciar o jogo, verificamos três coisas:
- O jogo tem 50 pessoas que já ingressaram.
- O usuário solicitante é o criador do jogo.
- O jogo ainda não começou.
Podemos gerenciar cada uma dessas verificações em uma expressão de condição em uma solicitação para atualizar o jogo. Se todas as verificações forem aprovadas, precisaremos atualizar nossa entidade destas maneiras:
- Remova o atributo open_timestamp para que ele não apareça como um jogo aberto no índice secundário esparso do módulo anterior.
- Adicione um atributo start_time para indicar quando o jogo começou.
No código que você baixou, há um arquivo start_game.py no diretório application/. Este é o conteúdo do arquivo:
import datetime import boto3 from entities import Game dynamodb = boto3.client('dynamodb') GAME_ID = "c6f38a6a-d1c5-4bdf-8468-24692ccc4646" CREATOR = "gstanley" def start_game(game_id, requesting_user, start_time): try: resp = dynamodb.update_item( TableName='battle-royale', Key={ "PK": { "S": "GAME#{}".format(game_id) }, "SK": { "S": "#METADATA#{}".format(game_id) } }, UpdateExpression="REMOVE open_timestamp SET start_time = :time", ConditionExpression="people = :limit AND creator = :requesting_user AND attribute_not_exists(start_time)", ExpressionAttributeValues={ ":time": { "S": start_time.isoformat() }, ":limit": { "N": "50" }, ":requesting_user": { "S": requesting_user } }, ReturnValues="ALL_NEW" ) return Game(resp['Attributes']) except Exception as e: print('Could not start game') return False game = start_game(GAME_ID, CREATOR, datetime.datetime(2019, 4, 16, 10, 15, 35)) if game: print("Started game: {}".format(game))
Neste script,a função start_game é similar à função que você teria em seu aplicativo. Ele precisa de game_id, requesting_user e start_time e executar uma solicitação para atualizar a entidade Game para iniciar o jogo.
O parâmetro ConditionExpression na chamada update_item() especifica cada uma das três verificações que listamos anteriormente nesta etapa. O jogo deve ter 50 pessoas, o usuário que solicita o início do jogo deve ser o criador do jogo e o jogo não pode ter um atributo start_time,o que indicaria que já começou.
No parâmetro UpdateExpression, você pode ver as alterações que queremos fazer em nossa entidade. Primeiro removemos o atributo open_timestamp da entidade e, em seguida, definimos o atributo start_time como o horário de início do jogo.
Execute esse script com o seguinte comando no terminal:
python application/start_game.py
Você deve ver a saída em seu terminal indicando que o jogo foi iniciado com êxito.
Started game: Game<c6f38a6a-d1c5-4bdf-8468-24692ccc4646 -- Urban Underground>
Tente executar o script uma segunda vez no terminal. Dessa vez, você deve ver uma mensagem de erro que indica não ser possível iniciar o jogo. Isso ocorre porque você já iniciou o jogo, portanto o atributo start_time existe. Como resultado, a solicitação falha na verificação condicional na entidade.
Talvez você se lembre que há um relacionamento muitos-para-muitos entre a entidade Game e as entidades User associadas, e o relacionamento é representado por uma entidade UserGameMapping.
Muitas vezes, você quer consultar os dois lados de um relacionamento. Com nossa configuração de chave primária, podemos encontrar todas as entidades User em Game. Podemos habilitar a consulta a todas as entidades do Jogo para um User usando um índice invertido.
No DynamoDB, um índice invertido é um índice secundário que é o inverso de sua chave primária. A chave RANGE se torna sua chave HASH e vice-versa. Esse padrão vira sua tabela e permite consultar o outro lado de relacionamentos muitos-para-muitos.
Nas etapas seguintes, adicionaremos um índice invertido à tabela e mostraremos como usá-lo para recuperar todas as entidades Game de um User específico.
-
Etapa 7: Adicione um índice invertido
Nesta etapa, adicionaremos um índice invertido à tabela. Um índice invertido é criado como qualquer outro índice secundário.
No código obtido por download, há um script add_inverted_index.py no diretório scripts/. Esse script do Python adiciona um índice invertido à sua tabela.
Este é o conteúdo do arquivo:
import boto3 dynamodb = boto3.client('dynamodb') try: dynamodb.update_table( TableName='battle-royale', AttributeDefinitions=[ { "AttributeName": "PK", "AttributeType": "S" }, { "AttributeName": "SK", "AttributeType": "S" } ], GlobalSecondaryIndexUpdates=[ { "Create": { "IndexName": "InvertedIndex", "KeySchema": [ { "AttributeName": "SK", "KeyType": "HASH" }, { "AttributeName": "PK", "KeyType": "RANGE" } ], "Projection": { "ProjectionType": "ALL" }, "ProvisionedThroughput": { "ReadCapacityUnits": 1, "WriteCapacityUnits": 1 } } } ], ) print("Table updated successfully.") except Exception as e: print("Could not update table. Error:") print(e)
Nesse script, chamamos o método update_table() em nosso cliente do DynamoDB. No método, passamos detalhes sobre o índice secundário que queremos criar, incluindo o esquema de chaves do índice, o throughput provisionado e os atributos a projetar no índice.
Execute o script com o seguinte comando no terminal:
python scripts/add_inverted_index.py
Seu terminal exibirá a saída que mostrará que seu índice foi criado com êxito.
Table updated successfully.
Na próxima etapa, usamos nosso índice invertido para recuperar todas as entidades Game de determinado User.
-
Etapa 8: Recupere os jogos de um usuário
Agora que criamos nosso índice invertido, vamos usá-lo para recuperar as entidades Game jogadas por um User. Para gerenciar isso, precisamos consultar o índice invertido com o User cujas entidades Game queremos ver.
No código que você baixou, há um arquivo find_games_for_user.py no diretório application/. Este é o conteúdo do arquivo:
import boto3 from entities import UserGameMapping dynamodb = boto3.client('dynamodb') USERNAME = "carrpatrick" def find_games_for_user(username): try: resp = dynamodb.query( TableName='battle-royale', IndexName='InvertedIndex', KeyConditionExpression="SK = :sk", ExpressionAttributeValues={ ":sk": { "S": "USER#{}".format(username) } }, ScanIndexForward=True ) except Exception as e: print('Index is still backfilling. Please try again in a moment.') return None return [UserGameMapping(item) for item in resp['Items']] games = find_games_for_user(USERNAME) if games: print("Games played by {}:".format(USERNAME)) for game in games: print(game)
Nesse script, adicionamos uma função chamada find_games_for_user() que é similar à função que você teria em seu jogo. Essa função usa um nome de usuário e retorna todos os jogos jogados por ele.
Execute o script com o seguinte comando no terminal:
python application/find_games_for_user.py
O script deve imprimir todos os jogos jogados pelo usuário carrpatrick.
Games played by carrpatrick: UserGameMapping<25cec5bf-e498-483e-9a00-a5f93b9ea7c7 -- carrpatrick -- SILVER> UserGameMapping<c6f38a6a-d1c5-4bdf-8468-24692ccc4646 -- carrpatrick> UserGameMapping<c9c3917e-30f3-4ba4-82c4-2e9a0e4d1cfd -- carrpatrick>
Neste módulo, adicionamos um índice secundário à tabela. Isso satisfez dois padrões de acesso adicional:
- Encontrar jogos abertos por mapa (Leitura)
- Encontrar jogos abertos (Leitura)
Para realizar isso, usamos um índice esparso que incluía somente os jogos que ainda estavam abertos para mais jogadores. Em seguida, usamos as APIs Query e Scan em relação ao índice para encontrar jogos abertos.
Também vimos como satisfazer duas operações de gravação avançadas no aplicativo. Primeiro, usamos transações do DynamoDB quando um usuário ingressou em um jogo. Com as transações, gerenciamos uma gravação condicional complexas em múltiplas entidades em uma única solicitação.
Segundo, implementamos a função para um criador de um jogo iniciá-loo quando estivesse pronto. Nesse padrão de acesso, tivemos uma operação de atualização que exigiu a verificação do valor de três atributos e a atualização de dois atributos. Você pode expressar essa lógica complexa em uma única solicitação por meio do poder das expressões de condição e expressões de atualização.
Terceiro, satisfizemos o padrão de acesso final recuperando todas as entidades Game jogadas por um User. Para gerenciar esse padrão de acesso, criamos um índice secundário usando o padrão do índice invertido para permitir a consulta no outro lado do relacionamento muitos-para-muitos entre as entidades de User e as de Game.
No próximo módulo, limparemos os recursos que criamos.