對於剛接觸 DynamoDB 和 NoSQL 的使用者而言,其中一項最大的調整就是如何對資料建模,以在整個資料集中進行篩選。例如,在我們的遊戲中,我們需要尋找具有開放位置的遊戲工作階段,以便向使用者顯示他們可以加入哪個遊戲工作階段。
在關聯式資料庫中,您將編寫某些 SQL 來查詢資料。
SELECT * FROM games WHERE status = “OPEN”
DynamoDB 可以透過查詢或掃描操作篩選結果,但 DynamoDB 不能像關聯式資料庫那樣工作。在擷取到與查詢或掃描操作相符的初始項目之後,將套用 DynamoDB 篩選器。篩選器減小了從 DynamoDB 服務傳送的承載大小,但是最初擷取的項目數受 DynamoDB 大小限制的制約。
幸運的是,您可以透過多種方式允許對 DynamoDB中 的資料集進行篩選查詢。為了在 DynamoDB 資料表上提供有效的篩選器,您需要從一開始就將篩選器規劃到資料表的資料模型中。記住我們在本實驗室第二個單元中學習的課程:考慮您的存取模式,然後設計資料表。
在以下步驟中,我們使用全域次要索引來尋找開放式遊戲。具體來說,我們將使用稀疏索引技術來處理此存取模式。
完成單元的時間:40 分鐘
-
步驟 1:為稀疏次要索引建模
次要索引是 DynamoDB 中的關鍵資料建模工具。它們讓您可以調整資料的形狀,以允許使用替代查詢模式。若要建立次要索引,您可以指定索引的主要金鑰,就像之前建立資料表時一樣。請注意,全域次要索引的主要金鑰不必針對每個項目都是唯一的。然後,DynamoDB 將根據指定的屬性將項目複製到索引中,並且您可以像查詢資料表一樣對其進行查詢。
使用稀疏次要索引是 DynamoDB中 的進階策略。對於次要索引,DynamoDB 僅在項目具有次要索引中的主要金鑰元素的情況下,才會從原始資料表中複製項目。沒有主要金鑰元素的項目不會被複製,這就是為什麼這些次要索引稱為「稀疏」的原因。
我們來看看這對我們的影響。您可能還記得我們有兩種存取開放式遊戲的模式:
- 尋找開放式遊戲 (讀取)
- 透過地圖尋找開放式遊戲 (讀取)
我們可以使用複合主要金鑰建立次要索引,其中 HASH 金鑰是遊戲的 map 屬性,而 RANGE 金鑰是 open_timestamp 遊戲的屬性,指示開啟遊戲的時間。
對我們來說重要的是,當遊戲滿載時,open_timestamp 屬性將被刪除。刪除屬性後,滿載的遊戲將從次要索引中刪除,因為它沒有 RANGE 金鑰屬性的值。這就是讓索引保持稀疏的原因:它僅包含具有 open_timestamp 屬性的開放式遊戲。
在下一步中,我們將建立次要索引。
-
步驟 2:建立稀疏的次要索引
在此步驟中,我們為開放式遊戲 (尚不完整的遊戲) 建立稀疏的次要索引。
建立次要索引類似於建立資料表。在下載的程式碼中,您將在 scripts/ 目錄中找到一個名稱為 add_secondary_index.py 的指令碼檔案。該檔案內容如下。
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)
每當在資料表或次要索引的主要金鑰中使用屬性時,都必須在 AttributeDefinitions 中定義屬性。然後,我們在 GlobalSecondaryIndexUpdates 屬性中建立新的次要索引。對於此次要索引,我們指定索引名稱、主要金鑰架構、佈建輸送量,以及我們要投射的屬性。
注意,我們不必指定次要索引目的是用作稀疏索引。這純粹是您輸入的資料函數。若將不具有次要索引屬性的項目寫入資料表中,則這些項目將不會包含在次要索引中。
透過執行以下命令來建立次要索引。
python scripts/add_secondary_index.py
您應會在主控台中看到以下訊息:「資料表已成功更新。」
在下一步中,我們將使用稀疏索引,按地圖尋找開放式遊戲。
-
步驟 3:查詢稀疏的次要索引
現在我們已經設定了次要索引,讓我們使用它來滿足某些存取模式。
若要使用次要索引,您可以使用兩種 API 叫用模式:查詢和掃描。使用查詢時,必須指定 HASH 金鑰,它會返回目標結果。使用掃描時,無需指定 HASH 金鑰,該操作將遍歷整個資料表。除特定情況外,不建議在 DynamoDB 中使用掃描,因為它們會存取資料庫中的每個項目。若資料表中有大量資料,則掃描可能需要很長時間。在下一步中,我們向您展示為什麼掃描與稀疏索引一起使用時,可以成為功能強大的工具。
我們可以對上一步中建立的次要索引使用查詢 API,以按地圖名稱尋找所有開放式遊戲。次要索引按地圖名稱進行分區,讓我們能夠進行針對性的查詢以尋找開放式遊戲。
在您下載的程式碼中,find_open_games_by_map.py 檔案位於 application/ 目錄中。該指令碼的內容如下。
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)
在前面的指令碼中,find_open_games_by_map 函數類似於您在應用程式中擁有的函數。該函數接受地圖名稱,並針對 OpenGamesIndex 進行查詢,以尋找該地圖的所有開放式遊戲。然後,它將返回的實體組合到可以在您的應用程式中使用的遊戲物件中。
透過在終端機中執行以下命令來執行此指令碼。
python application/find_open_games_by_map.py
終端機將顯示以下輸出,以及針對綠色草地地圖的四個開放式遊戲。
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/
在下一步中,我們使用掃描 API 掃描稀疏的次要索引。
-
步驟 4:掃描稀疏的次要索引
在上一步中,我們了解了如何尋找特定地圖的遊戲。有些玩家可能更喜歡玩特定地圖,因此這會很有用。其他玩家可能願意在任何地圖上玩遊戲。在本節中,我們將展示如何在應用程式中尋找任何開放式遊戲,而不管地圖的類型如何。為此,我們使用掃描 API。
通常,您不希望將資料表設計為使用 DynamoDB 掃描操作,因為 DynamoDB 是針對獲取所需確切實體的外科手術查詢而建置的。掃描操作會在資料表中獲取隨機的實體集合,因此,尋找所需的實體可能需要多次往返資料庫。
但是,有時掃描可能會有用。在這種情況下,我們有稀疏的次要索引,這意味著索引中不得包含太多實體。此外,索引僅包括那些開放的遊戲,而這正是我們所需要的。
對於此使用案例,掃描非常有用。我們來看看它的運作方式。在您下載的程式碼中,find_open_games.py 檔案位於 application/ 目錄中。該檔案的內容如下。
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)
該程式碼與上一步中的程式碼相似。但是,我們不是使用 DynamoDB 用戶端上的 query() 方法,而是使用 scan() 方法。由於我們使用的是 scan(),因此不需要像 query() 那樣指定任何主要條件。我們只是讓 DynamoDB 不按特定順序返回一堆項目。
在終端機中使用以下命令執行指令碼。
python application/find_open_games.py
您的終端機應列印九張在各種地圖上開啟的遊戲清單。
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>
在這一步中,我們看到了在特定情況下如何使用掃描操作是正確的選擇。我們使用掃描從稀疏的次要索引中獲取各種實體,以向玩家展示開放式遊戲。
在接下來的步驟中,我們將滿足兩種存取模式:
- 將使用者加入遊戲 (寫入)
- 開始遊戲 (寫入)
若要在以下步驟中滿足「將使用者加入遊戲」的存取模式,我們將使用 DynamoDB 交易。交易在關聯式系統中很常見,因為操作會一次影響多個資料元素。例如,假設您正在經營一間銀行。一位客戶 Alejandra 將 100 USD 轉帳給另一位客戶 Ana。在記錄此交易時,您將使用交易來確保將變更套用於兩位客戶的餘額,而不僅僅是一個。
透過 DynamoDB 交易,可以更輕鬆地建置可將多個項目更改為單一操作一部分的應用程式。使用交易,您可以將多個項目作為單一交易請求的一部分,處理多達 10 個項目。
在 TransactWriteItem API 叫用中,可以使用以下操作:
- 放置:用於插入或覆寫項目。
- 更新:用於更新現有項目。
- 刪除:用於刪除項目。
- 條件檢查:用於在不更改項目的情況下對現有項目聲明條件。
在下一步中,我們使用 DynamoDB 交易,向遊戲中新增使用者時,同時防止遊戲變得過載。
-
步驟 5:將使用者加入遊戲
我們在本單元中解決的第一個存取模式是,將新的使用者新增至遊戲中。
在向遊戲新增使用者時,我們需要:
- 確認遊戲中還沒有 50 位玩家 (每個遊戲最多可以有 50 位玩家)。
- 確認使用者尚未參與遊戲。
- 建立一個新的 UserGameMapping 實體以將該使用者新增至遊戲中。
- 遞增遊戲實體上的人員屬性,以追蹤遊戲中的玩家數量。
請注意,完成所有這些操作需要跨越現有遊戲實體和新的 UserGameMapping 實體,以及每個實體的條件邏輯進行寫入操作。這種操作非常適合 DynamoDB 交易,因為您需要在同一請求中處理多個實體,並且希望整個請求一起成功或失敗。
在您下載的程式碼中,join_game.py 指令碼位於 application/ 目錄中。該指令碼中的函數使用 DynamoDB 交易將使用者新增至遊戲中。
該指令碼的內容如下。
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)
在該指令碼的 join_game_for_user 函數中,transact_write_items() 方法執行寫入交易。此交易有兩個操作。
在交易的第一個操作中,我們使用「放置」操作插入一個新的 UserGameMapping 實體。作為該操作的一部分,我們指定一個條件,即該實體不得存在 SK 屬性。這樣可以確保不存在具有此 PK 和 SK 的實體。若確實存在這樣的實體,則意味著該使用者已經加入遊戲。
第二個操作是對遊戲實體的更新操作,用於逐個遞增人員屬性。作為此操作的一部分,我們新增了條件檢查,以確保人員的目前值不大於 50。一旦有 50 個人加入遊戲,該遊戲便準備就緒,可以開始遊戲。
在終端機中使用以下命令執行此指令碼。
python application/join_game.py
終端機的輸出應表明該使用者已新增至遊戲。
Added vlopez to game c6f38a6a-d1c5-4bdf-8468-24692ccc4646
請注意,若您嘗試再次執行該指令碼,則該函數將失敗。使用者 vlopez 已被新增至遊戲中,因此嘗試再次新增使用者不符合我們指定的條件。
新增 DynamoDB 交易大大簡化了圍繞此類複雜操作的工作流程。若沒有交易,這將需要具有復雜條件的多個 API 叫用,並在發生衝突時手動復原。現在,我們可以使用不到 50 行的程式碼來實作這種複雜的操作。
在下一步中,我們將處理「開始遊戲 (寫入)」存取模式。
-
步驟 6:開始遊戲
只要遊戲擁有 50 位使用者,遊戲的建立者即可啟動遊戲以開始遊戲。在這一步中,我們展示了如何處理這種存取模式。
當我們的應用程式後端收到開始遊戲的請求時,我們將檢查三件事:
- 遊戲中有 50 人登入。
- 發出請求的使用者是遊戲建立者。
- 遊戲尚未開始。
我們可以在請求更新遊戲的條件表達式中處理所有這些檢查。若所有這些檢查都通過,則我們需要透過以下方式更新我們的實體:
- 移除 open_timestamp 屬性,使其在上一單元所述的稀疏次要索引中不顯示為開放式遊戲。
- 新增 start_time 屬性以指示遊戲何時開始。
在您下載的程式碼中,start_game.py 指令碼位於 application/ 目錄中。該檔案的內容如下。
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))
在該指令碼中,start_game 函數類似於您在應用程式中將具有的函數。它需要使用 game_id、requesting_user 和 start_time,並執行更新遊戲實體的請求以開始遊戲。
update_item() 叫用中的 ConditionExpression 參數指定我們在此步驟之前所列三項檢查中的每一項,即遊戲必須有 50 人、請求該遊戲開始的使用者必須是遊戲建立者,並且遊戲不能具有 start_time 屬性,因為這表明遊戲已經開始。
在 UpdateExpression 參數中,您可以看到我們要對實體做出的變更。首先,我們從實體中移除 open_timestamp 屬性,然後將 start_time 屬性設定為遊戲的開始時間。
在終端機中使用以下命令執行此指令碼。
python application/start_game.py
您應會在終端機中看到表明遊戲已成功開始的輸出。
Started game: Game<c6f38a6a-d1c5-4bdf-8468-24692ccc4646 -- Urban Underground>
嘗試再次在終端機中執行指令碼。這次,您應看到一則錯誤訊息,表明您無法開始遊戲。這是因為您已經開始遊戲,所以存在 start_time 屬性。因此,該請求未能對實體進行條件檢查。
您可能還記得,遊戲實體和關聯的使用者實體之間存在多對多關係,並且該關係由 UserGameMapping 實體表示。
通常,您想要查詢關係的雙方。透過我們的主要金鑰設定,我們可以找到遊戲中的所有使用者實體。我們可以透過使用反轉索引來為使用者查詢所有遊戲實體。
在 DynamoDB 中,反轉索引是次要索引,它是主要金鑰的反轉。RANGE 金鑰成為 HASH 金鑰,反之亦然。這種模式可以翻轉您的資料表,並允許您在多對多關係的另一端進行查詢。
在以下步驟中,我們將反轉索引新增至資料表中,並顯示如何用它來擷取特定使用者的所有遊戲實體。
-
步驟 7:新增反轉索引
在這一步中,我們向資料表中新增一個反轉索引。像任何其他次要索引一樣,將會建立一個反轉索引。
在您下載的程式碼中,add_inverted_index.py 指令碼位於 scripts/ 目錄。該 Python 指令碼向您的資料表新增一個反轉索引。
該檔案的內容如下。
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)
在該指令碼中,我們在 DynamoDB 用戶端上叫用 update_table() 方法。透過該方法,我們傳遞有關要建立次要索引的詳細資訊,包括索引的關鍵架構、佈建輸送量,以及要投影至索引中的屬性。
在終端機中輸入以下命令執行指令碼。
python scripts/add_inverted_index.py
您的終端機將顯示成功建立索引的輸出。
Table updated successfully.
在下一步中,我們將使用反轉索引來擷取特定使用者的所有遊戲實體。
-
第 8 步:為使用者擷取遊戲
現在,我們已經建立了反轉索引,讓我們用它來擷取使用者所玩的遊戲實體。若要處理此操作,我們需要使用要查看其遊戲實體使用者來查詢反轉索引。
在您下載的程式碼中,find_games_for_user 指令碼位於 application/ 目錄中。該檔案的內容如下。
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)
在該指令碼中,我們有一個名稱為 find_games_for_user() 的函數,與您在遊戲中擁有的函數相似。此函數採用使用者名稱,並傳回指定使用者玩的所有遊戲。
在終端機中使用以下命令執行此指令碼。
python application/find_games_for_user.py
該指令碼應列印使用者 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>
在此單元中,我們已向資料表新增次要索引。這滿足了兩種附加的存取模式:
- 透過地圖尋找開放式遊戲 (讀取)
- 尋找開放式遊戲 (讀取)
為實現此操作,我們使用了稀疏索引,該索引僅包含仍對其他玩家開放的遊戲。然後,我們針對索引使用查詢和掃描 API,來尋找開放式遊戲。
另外,我們看到了如何滿足應用程式中的兩個進階寫入操作。首先,當使用者加入遊戲時,我們使用了 DynamoDB 交易。透過交易,我們在單一請求中跨多個實體處理複雜的條件寫入。
其次,我們實作了遊戲建立者可在遊戲就緒時開啟的函數。在此存取模式中,我們進行了更新操作,該操作需要檢查三個屬性值並更新兩個屬性。您可以透過條件表達式和更新表達式功能,在單一請求中表達這種複雜的邏輯。
第三,我們透過擷取使用者玩的所有遊戲實體,來滿足最終存取模式。若要處理此存取模式,我們使用反轉索引模式建立了次要索引,以允許在使用者實體與遊戲實體之間多對多關係的另一端進行查詢。
在下一個單元中,我們將討論如何清理建立的資源。