

本文為英文版的機器翻譯版本，如內容有任何歧義或不一致之處，概以英文版為準。

# 步驟 2：檢查資料模型和實作詳細資訊
<a name="TicTacToe.Phase2"></a>

**Topics**
+ [2.1：基本資料模型](#TicTacToe.Phase2.DataModel)
+ [2.2：應用程式的實際運作 (程式碼演練)](#TicTacToe.Phase2.AppInAction)

## 2.1：基本資料模型
<a name="TicTacToe.Phase2.DataModel"></a>

此範例應用程式的 DynamoDB 資料模型概念重點如下：
+ ****Table**** (資料表)：在 DynamoDB 中，資料表為項目 (即紀錄) 的集合，而每一個項目都是名稱/值對的集合，稱為屬性。

  在此井字遊戲範例中，應用程式會將所有遊戲資料存放在 `Games` 資料表。應用程式會為每一場遊戲在資料表中建立一個項目，並將所有遊戲資料以屬性的形式存放。一場井字遊戲最多可移動九步。因為 DynamoDB 資料表沒有結構描述，在必要屬性僅為主索引鍵的情況下，應用程式可針對每個遊戲項目存放不同數目的屬性。

  `Games` 資料表具有由單一屬性 `GameId` 組成的簡易主索引鍵，其類型為字串。該應用程式會為每一場遊戲指派唯一的 ID。如需 DynamoDB 主索引鍵的詳細資訊，請參閱 [主索引鍵](HowItWorks.CoreComponents.md#HowItWorks.CoreComponents.PrimaryKey)。

  當使用者透過邀請另一名使用者玩遊戲，來起始井字遊戲時，應用程式會在 `Games` 資料表中，以存放遊戲中繼資料的屬性建立新的項目，如下列範例：
  + `HostId`，起始遊戲的使用者。
  + `Opponent`，獲邀玩遊戲的使用者。
  + 輪到玩遊戲的使用者。起始遊戲的使用者先開始玩遊戲。
  + 在棋盤上使用 **O** 符號的使用者。起始遊戲的使用者使用 **O** 符號。

  此外，應用程式也會建立 `StatusDate` 串連屬性，將初始遊戲狀態標記為 `PENDING`。下列螢幕擷取畫面顯示範例項目在 DynamoDB 主控台中的樣子：  
![屬性表的主控台螢幕擷取畫面。](https://docs.aws.amazon.com/zh_tw/amazondynamodb/latest/developerguide/images/tic-tac-toe-10.png)

  隨著遊戲的進行，應用程式會為遊戲中的每步移動，新增一個屬性到資料表。屬性名稱為棋盤位置，例如 `TopLeft` 或 `BottomRight`。例如，移動可能是值為 `TopLeft` 的 `O` 屬性、值為 `TopRight` 的 `O` 屬性，及值為 `BottomRight` 的 `X` 屬性。屬性值不是 `O` 就是 `X`，取決於移動的使用者。例如，請參閱下列棋盤。  
![顯示終局為平手的井字遊戲螢幕擷取畫面。](https://docs.aws.amazon.com/zh_tw/amazondynamodb/latest/developerguide/images/tic-tac-toe-30.png)
+ ****Concatenated value attributes**** (串連值屬性)：`StatusDate` 屬性為串連值屬性的範例。在此方法中，並非透過建立個別屬性來存放遊戲狀態 (`PENDING`、`IN_PROGRESS` 和 `FINISHED`) 及日期 (最後一步的移動時間)，而是將其合併為單一屬性，例如 `IN_PROGRESS_2014-04-30 10:20:32`。

  然後應用程式會使用 `StatusDate` 屬性，將 `StatusDate` 指定為索引的排序索引鍵，來建立次要索引。使用 `StatusDate` 串連值屬性的好處會在接下來討論的索引中進一步說明。
+ ****Global secondary indexes**** (全域次要索引)：您可以使用資料表的主索引鍵 (`GameId`) 有效率地查詢資料表，來尋找遊戲項目。若要查詢資料表上主索引鍵屬性以外的屬性，DynamoDB 支援建立次要索引。在此範例應用程式中，您會建置下列兩個次要索引：  
![顯示在範例應用程式中建立的 hostStatusDate 和 oppStatusDate 全域次要索引螢幕擷取畫面。](https://docs.aws.amazon.com/zh_tw/amazondynamodb/latest/developerguide/images/tic-tac-toe-indexes-10.png)
  + **HostId-StatusDate-index**。此索引的分割區索引鍵為 `HostId`，排序索引鍵為 `StatusDate`。您可以使用此索引查詢 `HostId`，例如尋找由特定使用者發起的遊戲。
  + **OpponentId-StatusDate-index**。此索引的分割區索引鍵為 `OpponentId`，排序索引鍵為 `StatusDate`。您可以使用此索引查詢 `Opponent`，例如尋找對手為特定使用者的遊戲。

  這些索引稱為全域次要索引，因為這些索引中的分割區索引鍵與資料表主索引鍵所使用的分割區索引鍵 (`GameId`) 不同。

  請注意，這兩種索引皆指定 `StatusDate` 做為排序索引鍵。這可讓您進行下列作業：
  + 您可以使用 `BEGINS_WITH` 比較運算子查詢。例如，您可以尋找由特定使用者發起且屬性為 `IN_PROGRESS` 的所有遊戲。在此案例中，`BEGINS_WITH` 運算子會檢查開頭為 `StatusDate` 的 `IN_PROGRESS` 值。
  + DynamoDB 會根據排序索引鍵值，將項目依排序存放於索引中。因此若所有狀態字首都相同 (例如 `IN_PROGRESS`)，日期部分所使用的 ISO 格式會將項目從最舊排序到最新。此方法可讓特定查詢有效率地執行，例如下列查詢：
    + 擷取最多 10 個最近由登入使用者發起的 `IN_PROGRESS` 遊戲。針對此查詢，您要指定 `HostId-StatusDate-index` 索引。
    + 擷取最多 10 個最近登入使用者為對手的 `IN_PROGRESS` 遊戲。針對此查詢，您要指定 `OpponentId-StatusDate-index` 索引。

如需次要索引的詳細資訊，請參閱「[在 DynamoDB 中使用次要索引來改善資料存取](SecondaryIndexes.md)」。

## 2.2：應用程式的實際運作 (程式碼演練)
<a name="TicTacToe.Phase2.AppInAction"></a>

此應用程式有兩個主要頁面：
+ ****Home page**** (首頁)：此頁面為使用者提供簡易登入、可建立新井字遊戲的 **CREATE** (建立) 按鈕、正在進行的遊戲清單、遊戲歷史記錄，以及任何作用中的待定遊戲邀請。

  首頁不會自動重新整理，您必須重新整理頁面才能重新整理清單。
+ ****Game page**** (遊戲頁面)：此頁面會顯示使用者玩的井字遊戲方格。

  應用程式會每秒自動更新遊戲頁面一次。您瀏覽器中的 JavaScript 會每秒呼叫 Python Web 伺服器一次，查詢 Games 資料表以檢查資料表中的遊戲項目是否已變更。若已變更，JavaScript 會觸發頁面重新整理，讓使用者可看到更新的棋盤。

讓我們了解應用程式的詳細運作方式。

### 首頁
<a name="TicTacToe.Phase2.AppInAction.HomePage"></a>

在使用者登入後，應用程式會顯示下列三項資訊清單。

![顯示具有 3 個清單 (待定邀請、進行中遊戲和最近歷程記錄) 的應用程式首頁螢幕擷取畫面。](https://docs.aws.amazon.com/zh_tw/amazondynamodb/latest/developerguide/images/tic-tac-toe-homepage-10.png)

+ ****Invitations**** (邀請)：此清單會顯示最多 10 則最近其他使用者發送並正等待登入使用者接受的邀請。在先前的螢幕擷取畫面中，user1 有來自 user5 和 user2 的待定邀請。
+ ****Games In-Progress**** (正在進行的遊戲) - 此清單會顯示最多 10 個最近正在進行的遊戲。這些遊戲是使用者正在玩的遊戲，其狀態為 `IN_PROGRESS`。在螢幕擷取畫面中，user1 正在與 user3 和 user4 玩井字遊戲。
+ ****Recent History**** (近期歷史記錄) - 此清單會顯示最多 10 個最近使用者完成的遊戲，其狀態為 `FINISHED`。在螢幕擷取畫面中顯示的遊戲裡，user1 先前與 user2 玩過遊戲。清單會為每個完成的遊戲顯示遊戲結果。

在程式碼中，`index` 函數 (位於 `application.py` 中) 會發出下列三個呼叫，來擷取遊戲狀態資訊：

```
inviteGames     = controller.getGameInvites(session["username"])
inProgressGames = controller.getGamesWithStatus(session["username"], "IN_PROGRESS")
finishedGames   = controller.getGamesWithStatus(session["username"], "FINISHED")
```

這些呼叫各會從 DynamoDB 傳回 `Game` 物件所包裝的項目清單。在檢視中從這些物件擷取資料非常容易。index 函數會將這些物件清單傳遞到檢視，以轉譯 HTML。

```
return render_template("index.html",
                       user=session["username"],
                       invites=inviteGames,
                       inprogress=inProgressGames,
                       finished=finishedGames)
```

井字遊戲應用程式會定義 `Game` 類別，主要用以存放從 DynamoDB 擷取的遊戲資料。這些函數會傳回 `Game` 物件的清單，讓您可將應用程式剩餘的部分與有關 Amazon DynamoDB 項目的程式碼隔離。因此，這些函數可協助您將應用程式程式碼和資料存放層的詳細資訊分離。

此處說明的架構模式又稱為模型-檢視-控制器 (MVC) UI 模式。在此案例中，`Game` 物件執行個體 (代表資料) 為模型，而 HTML 頁面為檢視。控制器則分為兩個檔案。`application.py` 檔案具有 Flask 框架的控制器邏輯，商業邏輯則隔離在 `gameController.py` 檔案中。意即應用程式會將與 DynamoDB 軟體開發套件有關的所有事物存放在 `dynamodb` 資料夾中其自身的個別檔案裡。

讓我們檢閱三個函數，及其如何使用全域次要索引查詢 Games 資料表，來擷取相關資料。

#### 使用 getGameInvites 擷取待定遊戲邀請的清單
<a name="TicTacToe.Phase2.GameInAction.ListInvitations"></a>

`getGameInvites` 函數會擷取 10 個最近待定邀請的清單。這些遊戲已由使用者建立，但對手尚未接受遊戲邀請。在對手接受邀請前，這些遊戲皆會維持在 `PENDING` 狀態。若對手拒絕邀請，應用程式會從資料表移除對應項目。

函數指定查詢的方式如下所示：
+ 其指定 `OpponentId-StatusDate-index` 索引，以搭配下列索引鍵值和比較運算子使用：
  + 分割區索引鍵為 `OpponentId`，並使用索引鍵 `{{user ID}}`。
  + 排序索引鍵為 `StatusDate`，並使用比較運算子和索引鍵值 `beginswith="PENDING_"`。

  您可以使用 `OpponentId-StatusDate-index` 索引擷取登入使用者獲邀的遊戲，即對手為登入使用者的遊戲。
+ 查詢會將結果限制為 10 個項目。

```
gameInvitesIndex = self.cm.getGamesTable().query(
                                            Opponent__eq=user,
                                            StatusDate__beginswith="PENDING_",
                                            index="OpponentId-StatusDate-index",
                                            limit=10)
```

在索引中，針對每個 `OpponentId` (分割區索引鍵)，DynamoDB 都會依 `StatusDate` (排序索引鍵) 讓項目保持排序。因此，查詢傳回的遊戲將會是 10 個最近的遊戲。

#### 使用 getGamesWithStatus 擷取特定狀態的遊戲清單
<a name="TicTacToe.Phase2.GameInAction.ListGamesInProgressHistory"></a>

對手接受遊戲邀請後，遊戲狀態會變更為 `IN_PROGRESS`。遊戲完成後，該狀態會變更為 `FINISHED`。

尋找正在進行之遊戲的查詢與尋找已完成遊戲的查詢皆相同，唯一相異點在於狀態值不同。因此，應用程式會定義 `getGamesWithStatus` 函數，其使用狀態值做為參數。

```
inProgressGames = controller.getGamesWithStatus(session["username"], "IN_PROGRESS")
finishedGames   = controller.getGamesWithStatus(session["username"], "FINISHED")
```

下節會討論正在進行的遊戲，但該說明也同樣適用於已完成的遊戲。

指定使用者正在進行的遊戲清單同時包含下列項目：
+ 由使用者發起之正在進行的遊戲 
+ 對手為該使用者之正在進行的遊戲 

`getGamesWithStatus` 函數會執行下列兩項查詢，每次都會使用適當的次要索引。
+ 函數會使用 `HostId-StatusDate-index` 索引查詢 `Games` 資料表。針對該索引，查詢會指定主索引鍵值：即分割區索引鍵 (`HostId`) 和排序索引鍵 (`StatusDate`) 兩者的值，以及比較運算子。

  ```
  hostGamesInProgress = self.cm.getGamesTable ().query(HostId__eq=user,
                                                 StatusDate__beginswith=status,
                                                 index="HostId-StatusDate-index",
                                                 limit=10)
  ```

  請注意比較運算子的 Python 語法：
  + `HostId__eq=user` 會指定對等比較運算子。
  + `StatusDate__beginswith=status` 會指定 `BEGINS_WITH` 比較運算子。
+ 函數會使用 `OpponentId-StatusDate-index` 索引查詢 `Games` 資料表。

  ```
  oppGamesInProgress = self.cm.getGamesTable().query(Opponent__eq=user,
                                               StatusDate__beginswith=status,
                                               index="OpponentId-StatusDate-index",
                                               limit=10)
  ```
+ 函數接著會合併兩個清單、排序，然後為前 0 到 10 個項目建立 `Game` 物件的清單，並將清單傳回呼叫函數 (即 index)。

  ```
  games = self.mergeQueries(hostGamesInProgress,
                          oppGamesInProgress)
  return games
  ```

### 遊戲頁面
<a name="TicTacToe.Phase2.AppInAction.GamePage"></a>

遊戲頁面是使用者玩井字遊戲的位置。它會顯示遊戲方格和有關遊戲的資訊。下列螢幕擷取畫面顯示正在進行的範例遊戲：

![顯示進行中井字遊戲的螢幕擷取畫面。](https://docs.aws.amazon.com/zh_tw/amazondynamodb/latest/developerguide/images/tic-tac-toe-example-board-10.png)


應用程式會在下列情況中顯示遊戲頁面：
+ 使用者建立遊戲，並邀請另一名使用者玩遊戲。

  在此案例中，頁面會顯示使用者為發起人，且遊戲狀態為 `PENDING`，等待對手接受。
+ 使用者在首頁上接受其中一個待定邀請。

  在此案例中，頁面會顯示使用者為對手，且遊戲狀態為 `IN_PROGRESS`。

使用者在棋盤上的選擇會產生對應用程式的表單 `POST` 請求。意即，Flask 會使用 HTML 表單資料呼叫 `selectSquare` 函數 (位於 `application.py` 中)。然後此函數會呼叫 `updateBoardAndTurn` 函數 (位於 `gameController.py` 中) 來更新遊戲項目，如下所示：
+ 它會新增移動專屬的屬性。
+ 它會將 `Turn` 屬性值更新為移動下一步的使用者。

```
controller.updateBoardAndTurn(item, value, session["username"])
```

如果項目更新成功，函數會傳回 true，否則傳回 false。請注意下列關於 `updateBoardAndTurn` 函數的事項：
+ 函數會呼叫 SDK for Python 的 `update_item` 函數，對現有項目進行有限次數的更新。函數會映射到 DynamoDB 中的 `UpdateItem` 操作。如需詳細資訊，請參閱 [UpdateItem](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html)。
**注意**  
`UpdateItem` 和 `PutItem` 操作之間的差異在於 `PutItem` 會取代整個項目。如需詳細資訊，請參閱 [PutItem](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_PutItem.html)。

在 `update_item` 呼叫方面，程式碼會識別下列項目：
+ `Games` 資料表的主索引鍵 (即 `ItemId`)。

  ```
  key = { "GameId" : { "S" : gameId } }
  ```
+ 要新增的屬性 (目前使用者移動專屬的屬性) 及其值 (例如 `TopLeft="X"`)。

  ```
  attributeUpdates = {
      position : {
          "Action" : "PUT",
          "Value" : { "S" : representation }
      }
  }
  ```
+ 條件必須為 true 才能更新：
  + 遊戲必須正在進行。即 `StatusDate` 屬性值的開頭必須為 `IN_PROGRESS`。
  + 目前輪次必須是 `Turn` 屬性指定的有效使用者輪次。
  + 方格必須可供使用者選擇。即對應到該方格的屬性不得存在。

  ```
  expectations = {"StatusDate" : {"AttributeValueList": [{"S" : "IN_PROGRESS_"}],
      "ComparisonOperator": "BEGINS_WITH"},
      "Turn" : {"Value" : {"S" : current_player}},
      position : {"Exists" : False}}
  ```

函數現在會呼叫 `update_item` 更新項目。

```
self.cm.db.update_item("Games", key=key,
    attribute_updates=attributeUpdates,
    expected=expectations)
```

函數傳回後，`selectSquare` 函數會呼叫 redirect，如下列範例所示。

```
redirect("/game="+gameId) 
```

此呼叫會使瀏覽器重新整理。在此重新整理中，應用程式會檢查遊戲是否以獲勝或平手結束。若遊戲已結束，則應用程式會根據結果更新遊戲項目。