對於剛接觸 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 屬性。這樣可以確保不存在具有此 PKSK 的實體。若確實存在這樣的實體,則意味著該使用者已經加入遊戲。

    第二個操作是對遊戲實體的更新操作,用於逐個遞增人員屬性。作為此操作的一部分,我們新增了條件檢查,以確保人員的目前值不大於 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_idrequesting_userstart_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 交易。透過交易,我們在單一請求中跨多個實體處理複雜的條件寫入。

    其次,我們實作了遊戲建立者可在遊戲就緒時開啟的函數。在此存取模式中,我們進行了更新操作,該操作需要檢查三個屬性值並更新兩個屬性。您可以透過條件表達式和更新表達式功能,在單一請求中表達這種複雜的邏輯。

    第三,我們透過擷取使用者玩的所有遊戲實體,來滿足最終存取模式。若要處理此存取模式,我們使用反轉索引模式建立了次要索引,以允許在使用者實體與遊戲實體之間多對多關係的另一端進行查詢。

    在下一個單元中,我們將討論如何清理建立的資源。