

# 第 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 数据模型概念：
+ ****表**** – 在 DynamoDB 中，表是项目（即记录）的集合，而每个项目是称为属性的名称-值对的集合。

  在本井字游戏示例中，应用程序将所有游戏数据存储在表 `Games` 中。应用程序表为每款游戏在表中创建一个项目，并将所有游戏数据存储为属性。井字游戏最多可以移动九次。由于 DynamoDB 表采用的架构并不是只有主键是必需属性，应用程序可以为每个游戏项目存储不同数量的属性。

  `Games` 表具有简单主键，由一个字符串类型的属性 `GameId` 组成。应用程序将唯一 ID 分配给每款游戏。有关 DynamoDB 主键的更多信息，请参阅 [主键](HowItWorks.CoreComponents.md#HowItWorks.CoreComponents.PrimaryKey)。

  当用户通过邀请其他用户玩游戏的方式发起井字游戏之后，应用程序会在 `Games` 表中使用存储游戏元数据的属性创建一个新项目，如下所示：
  + `HostId`，发起游戏的用户。
  + `Opponent`，受邀参加游戏的用户。
  + 轮到进行移动的用户。发起游戏的用户首先移动。
  + 在面板上使用 **O** 符号的用户。发起游戏的用户使用 **O** 符号。

  此外，该应用程序创建 `StatusDate` 连接属性，将初始游戏的状态标记为 `PENDING`。以下屏幕截图显示了示例项目在 DynamoDB 控制台中的外观：  
![属性表的控制台屏幕截图。](http://docs.aws.amazon.com/zh_cn/amazondynamodb/latest/developerguide/images/tic-tac-toe-10.png)

  随着游戏继续，游戏中每一次进行移动时，本应用程序都会将一个属性添加到表中。属性名称是面板上的位置，例如 `TopLeft` 或 `BottomRight`。例如，移动可能具有值为 `O` 的 `TopLeft` 属性、值为 `O` 的 `TopRight` 属性以及值为 `X` 的 `BottomRight` 属性。属性值为 `O` 或 `X`，具体取决于进行移动的用户。例如，请考虑以下面板。  
![显示以平局结束的已完成的井字游戏的屏幕截图。](http://docs.aws.amazon.com/zh_cn/amazondynamodb/latest/developerguide/images/tic-tac-toe-30.png)
+ ****连接值属性**** – `StatusDate` 属性说明了一个连接值属性。在此方法中，您无需创建单独的属性用于存储游戏状态（`PENDING`、`IN_PROGRESS` 和 `FINISHED`）以及日期（上一次移动的时间），而是可以将它们复合为单个属性，例如 `IN_PROGRESS_2014-04-30 10:20:32`。

  然后，该应用程序在创建二级索引时，通过将 `StatusDate` 指定为索引的排序键来使用 `StatusDate` 属性。使用 `StatusDate` 连接值属性的优势将在接下来讨论的索引中进一步说明。
+ ****全局二级索引**** – 您可以使用表的主键 `GameId` 来高效地查询表以查找游戏项目。为了查询表中的属性而不是主键属性，DynamoDB 支持创建二级索引。在本示例应用程序中，您可以构建以下两个二级索引：  
![显示在示例应用程序中创建的 hostStatusDate 和 oppStatusDate 全局二级索引的屏幕截图。](http://docs.aws.amazon.com/zh_cn/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` 运算符会检查以 `IN_PROGRESS` 开头的 `StatusDate` 值。
  + 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>

此应用程序有两个主要页面：
+ ****主页**** – 此页面向用户提供简单的登录界面、用于创建新的井字游戏的**创建**按钮、正在进行的游戏列表、游戏历史记录以及任何正在等待接受的游戏邀请。

  主页不会自动刷新；您必须手动刷新页面才能刷新列表。
+ ****游戏页面**** – 此页面显示用户玩游戏的井字游戏网格。

  应用程序每秒自动更新游戏页面。浏览器中的 JavaScript 每秒调用 Python Web 服务器来查询 Games 表中的游戏项目是否有更改。如果有更改，则 JavaScript 触发页面刷新，这样用户可以看到更新后的面板。

让我们详细了解应用程序的工作方式。

### 主页
<a name="TicTacToe.Phase2.AppInAction.HomePage"></a>

用户登录后，应用程序显示以下三个信息列表。

![显示包含 3 个列表：待定邀请、正在进行的游戏和最近历史记录的应用程序的屏幕截图。](http://docs.aws.amazon.com/zh_cn/amazondynamodb/latest/developerguide/images/tic-tac-toe-homepage-10.png)

+ ****邀请**** – 此列表显示其他人向登录用户发出的最近 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` 对象包装的项目列表。可以很方便地从视图中的这些对象提取数据。索引函数将这些对象列表传递到视图以呈现 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 SDK 相关的所有内容存储到 `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)
  ```
+ 然后，函数将两个列表组合并排序，对前 10 个项目创建 `Game` 对象列表，然后将列表返回到调用函数（即索引）。

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

### 游戏页面
<a name="TicTacToe.Phase2.AppInAction.GamePage"></a>

游戏页面是玩井字游戏的位置，其中显示游戏网格以及与游戏相关的信息。以下屏幕截图显示了正在进行的游戏示例：

![显示正在进行的井字游戏的屏幕截图。](http://docs.aws.amazon.com/zh_cn/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 }
      }
  }
  ```
+ 必须满足以下条件才能进行更新：
  + 游戏必须在进行中。也就是说，`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("/game="+gameId) 
```

此调用导致浏览器刷新。作为此次刷新的一部分，应用程序检查以查看游戏以获胜还是平手结束。如果已结束，则应用程序将相应地更新游戏项目。