如何让 GraphQL 和 DynamoDB 完美配合

Avatar of Ryan Bethel
Ryan Bethel

DigitalOcean 为您的每个旅程阶段提供云产品。 立即开始使用 200 美元的免费积分!

无服务器GraphQLDynamoDB 是构建网站的强大组合。 前两者深受喜爱,但 DynamoDB 常常被误解或积极避免。 它经常被那些认为它只值得“大规模”努力的人所摒弃。

我也是这样假设的,并且我试图在我的无服务器应用程序中坚持使用 SQL 数据库。 但是,在学习和使用 DynamoDB 后,我看到了它对任何规模的项目的益处。

为了向您展示我的意思,让我们从头到尾构建一个 API——没有任何沉重的对象关系映射器 (ORM) 或 GraphQL 框架来隐藏真正发生的事情。 也许在我们完成之后,您可能会考虑重新考虑 DynamoDB。 我认为它是值得努力的。

对 DynamoDB 和 GraphQL 的主要异议

对 DynamoDB 的主要异议是它很难学习,但很少有人会质疑它的强大功能。 我同意学习曲线感觉非常陡峭。 但是,SQL 数据库并不适合无服务器应用程序。 您在哪里支持该 SQL 数据库? 您如何管理与它的连接? 这些事情与无服务器模型不太匹配。 DynamoDB 本身就是无服务器友好的。 您正在用学习一些难学的東西的预先痛苦来换取避免未来痛苦。 随着应用程序的增长,未来的痛苦只会加剧。

反对将 GraphQL 与 DynamoDB 结合使用的理由更为微妙。 GraphQL 似乎与关系数据库很匹配,部分原因是许多文档、教程和示例都假设了这一点。 Alex Debrie 是一位 DynamoDB 专家,他撰写了 DynamoDB 手册,这是一本深入学习它的宝贵资源。 即使是他,也建议 不要将两者结合使用,主要是因为 GraphQL 解析器通常被写成顺序独立的数据库调用,这会导致过多的数据库读取。

另一个潜在问题是,DynamoDB 在您事先知道访问模式时效果最佳。 GraphQL 的优势之一是,它可以通过设计比 REST 更轻松地处理任意查询。 对于用户可以编写任意查询的公共 API 来说,这是一个更大的问题。 实际上,GraphQL 通常用于您控制客户端和服务器的私有 API。 在这种情况下,您可以知道并控制运行的查询。 对于 GraphQL API,可以编写查询来破坏任何数据库,而无需采取措施来避免它们。

基本数据模型

对于此示例 API,我们将对具有团队、用户和认证的组织进行建模。 实体关系图如下所示。 每个团队都有许多用户,每个用户可以拥有许多认证。

关系数据库模型

我们的最终目标是在 DynamoDB 表中对这些数据进行建模,但是如果我们在 SQL 数据库中对其进行建模,它看起来将类似于以下图表

为了表示用户与认证的多对多关系,我们添加了一个称为“凭证”的中间表。 此表上唯一的唯一属性是过期日期。 每个表将有其他属性,但为简单起见,我们将其简化为每个表的名称。

访问模式

为 DynamoDB 设计数据模型的关键是事先了解您的访问模式。 在关系数据库中,您从规范化数据开始,并在数据之间执行联接以访问它。 DynamoDB 没有联接,因此我们构建一个与我们打算访问它时的方式相匹配的数据模型。 这是一个迭代过程。 目标是识别最常见的模式以开始。 其中大多数将直接映射到 GraphQL 查询,但有些可能仅用于后端内部进行身份验证或检查权限等。 很少使用的访问模式(例如,管理员每周运行一次的检查)不需要进行设计。 可以用效率很低的东西(例如表扫描)来处理这些查询。

最常访问的

  • 按 ID 或名称查找用户
  • 按 ID 或名称查找团队
  • 按 ID 或名称查找认证

经常访问的

  • 按团队 ID 查找团队中的所有用户
  • 查找给定用户的全部认证
  • 所有团队
  • 所有认证

很少访问的

  • 团队中所有用户的全部认证
  • 拥有认证的所有用户
  • 团队中拥有认证的所有用户

DynamoDB 单表设计

DynamoDB 没有联接,您只能根据主键或预定义索引进行查询。 数据库没有对项目施加固定的模式,因此可以在单个表中存储许多不同类型的项目。 事实上,数据模式的推荐最佳实践是将所有项目存储在单个表中,以便您可以使用单个查询访问相关项目。 下面是代表我们数据的单表模型。 为了设计此模式,您需要采用上述访问模式并为匹配的键和索引选择属性。

此处的primaryKey 是分区/哈希键 (pk) 和排序键 (sk) 的组合。 要检索 DynamoDB 中的项目,您必须完全指定分区键,并为排序键指定单个值或值范围。 如果它们共享一个分区键,这将允许您检索多个项目。 此处的索引显示为 gsi1pk、gsi1sk 等。 这些通用属性名称用于索引(即 gsi1pk),以便可以使用相同的索引以不同的访问模式访问不同类型的项目。 使用组合键时,排序键不能为空,因此当排序键不需要时,我们使用“#”作为占位符。

访问模式查询条件
按 ID 查找团队、用户或认证主键,pk=”T#”+ID,sk=”#”
按名称查找团队、用户或认证索引 GSI 1,gsi1pk=type,gsi1sk=name
所有团队、用户或认证索引 GSI 1,gsi1pk=type
按 ID 查找团队中的所有用户索引 GSI 2,gsi2pk=”T#”+teamID
按 ID 查找用户的全部认证主键,pk=”U#”+userID,sk=”C#”+certID
按 ID 查找拥有认证的所有用户索引 GSI 1,gsi1pk=”C#”+certID,gsi1sk=”U#”+userID

数据库模式

我们在应用程序中强制执行“数据库模式”。 DynamoDB API 功能强大,但也冗长且复杂。 许多人直接跳到使用 ORM 来简化它。 在这里,我们将使用下面的辅助函数直接访问数据库来创建 Team 项目的模式。

const DB_MAP = {
  TEAM: {
    get: ({ teamId }) => ({
      pk: 'T#'+teamId,
      sk: '#',
    }),
    put: ({ teamId, teamName }) => ({
      pk: 'T#'+teamId,
      sk: '#',
      gsi1pk: 'Team',
      gsi1sk: teamName,
      _tp: 'Team',
      tn: teamName,
    }),
    parse: ({ pk, tn, _tp }) => {
      if (_tp === 'Team') {
        return {
          id: pk.slice(2),
          name: tn,
          };
        } else return null;
        },
    queryByName: ({ teamName }) => ({
      IndexName: 'gsi1pk-gsi1sk-index',
      ExpressionAttributeNames: { '#p': 'gsi1pk', '#s': 'gsi1sk' },
      KeyConditionExpression: '#p = :p AND #s = :s',
      ExpressionAttributeValues: { ':p': 'Team', ':s': teamName },
      ScanIndexForward: true,
    }),
    queryAll: {
      IndexName: 'gsi1pk-gsi1sk-index',
      ExpressionAttributeNames: { '#p': 'gsi1pk' },
      KeyConditionExpression: '#p = :p ',
      ExpressionAttributeValues: { ':p': 'Team' },
      ScanIndexForward: true,
    },
  },
  parseList: (list, type) => {
    if (Array.isArray(list)) {
      return list.map(i => DB_MAP[type].parse(i));
    }
    if (Array.isArray(list.Items)) {
      return list.Items.map(i => DB_MAP[type].parse(i));
    }
  },
};

要将新团队项目放入数据库,请调用

DB_MAP.TEAM.put({teamId:"t_01",teamName:"North Team"})

这将形成传递给数据库 API 的索引和键值。 parse 方法将从数据库中获取一个项目,并将其转换回应用程序模型。

GraphQL 模式

type Team {
  id: ID!
  name: String
  members: [User]
}
type User {
  id: ID!
  name: String
  team: Team
  credentials: [Credential]
}
type Certification {
  id: ID!
  name: String
}
type Credential {
  id: ID!
  user: User
  certification: Certification
  expiration: String
}
type Query {
  team(id: ID!): Team
  teamByName(name: String!): [Team]
  user(id: ID!): User
  userByName(name: String!): [User]
  certification(id: ID!): Certification
  certificationByName(name: String!): [Certification]
  allTeams: [Team]
  allCertifications: [Certification]
  allUsers: [User]
}

使用解析器弥合 GraphQL 和 DynamoDB 之间的差距

解析器是执行 GraphQL 查询的地方。 您可以无需编写解析器就在 GraphQL 中走得很远。 但是,为了构建我们的 API,我们将需要编写一些解析器。 对于上述 GraphQL 模式中的每个查询,下面都有一个根解析器(此处仅显示团队解析器)。 此根解析器返回一个承诺或一个包含查询结果一部分的对象。

如果查询返回一个Team类型的结果,那么执行将传递给Team类型的解析器。该解析器为Team中的每个值都有一个函数。如果没有给定值的解析器(例如id),它会查看根解析器是否已将其传递下来。

查询接受四个参数。第一个称为rootparent,是从上面的解析器传递下来的对象,包含任何部分结果。第二个称为args,包含传递给查询的参数。第三个称为context,可以包含应用程序解析查询所需的任何内容。在本例中,我们将对数据库的引用添加到context中。最后一个参数称为info,此处未使用。它包含有关查询的更多详细信息(如抽象语法树)。

在下面的解析器中,ctx.db.singletable是对包含所有数据的 DynamoDB 表的引用。getquery方法直接对数据库执行,而DB_MAP.TEAM....使用我们之前编写的辅助函数将模式转换为数据库。parse方法将数据转换回 GraphQL 模式所需的格式。

const resolverMap = {
  Query: {
    team: (root, args, ctx, info) => {
      return ctx.db.singletable.get(DB_MAP.TEAM.get({ teamId: args.id }))
        .then(data => DB_MAP.TEAM.parse(data));
    },
    teamByName: (root, args, ctx, info) =>; {
      return ctx.db.singletable
        .query(DB_MAP.TEAM.queryByName({ teamName: args.name }))
        .then(data => DB_MAP.parseList(data, 'TEAM'));
    },
    allTeams: (root, args, ctx, info) => {
      return ctx.db.singletable.query(DB_MAP.TEAM.queryAll)
        .then(data => DB_MAP.parseList(data, 'TEAM'));
    },
  },
  Team: {
    name: (root, _, ctx) => {
      if (root.name) {
        return root.name;
      } else {
        return ctx.db.singletable.get(DB_MAP.TEAM.get({ teamId: root.id }))
          .then(data => DB_MAP.TEAM.parse(data).name);
      }
    },
    members: (root, _, ctx) => {
      return ctx.db.singletable
        .query(DB_MAP.USER.queryByTeamId({ teamId: root.id }))
        .then(data => DB_MAP.parseList(data, 'USER'));
    },
  },
  User: {
    name: (root, _, ctx) => {
      if (root.name) {
        return root.name;
      } else {
        return ctx.db.singletable.get(DB_MAP.USER.get({ userId: root.id }))
          .then(data => DB_MAP.USER.parse(data).name);
      }
    },
    credentials: (root, _, ctx) => {
      return ctx.db.singletable
        .query(DB_MAP.CREDENTIAL.queryByUserId({ userId: root.id }))
        .then(data =>DB_MAP.parseList(data, 'CREDENTIAL'));
    },
  },
};

现在让我们跟踪下面查询的执行过程。首先,team根解析器通过id读取团队并返回idname。然后,Team类型解析器读取该团队的所有成员。然后,为每个用户调用User类型解析器以获取其所有凭据和认证。如果团队中有五个成员,每个成员有五个凭据,则总共需要对数据库进行七次读取。你可以争辩说这太多了。在 SQL 数据库中,这可能会减少到四次数据库调用。我认为,在许多情况下,七次 DynamoDB 读取将比四次 SQL 读取更便宜、更快。但这取决于很多因素。

query { team( id:"t_01" ){
  id
  name
  members{
    id
    name
    credentials{
      id
      certification{
        id
        name
      }
    }
  }
}}

过度获取和 N+1 问题

优化 GraphQL API 涉及权衡许多因素,我们在这里不会详细介绍。但 DynamoDB 与 SQL 之间决策中最重要的两个因素是过度获取和 N+1 问题。在许多方面,它们是同一枚硬币的正反面。过度获取是指解析器从数据库请求的数据多于响应查询所需的。这通常发生在尝试在根解析器或类型解析器中(例如上面的Team类型解析器中的成员)进行一次数据库调用以获取尽可能多的数据时。如果查询没有请求name属性,那么它可以被视为浪费的努力。

N+1 问题几乎是相反的。如果所有读取都推送到最低级别的解析器,那么team根解析器和成员解析器(对于Team类型)将只对数据库进行最少或不进行请求。它们只会将 ID 传递到Team类型和User类型解析器。在这种情况下,成员不会进行一次调用来获取所有五个成员,而是会向下推送到User进行五次单独的读取。这将导致上面的查询可能进行 36 次或更多次单独的读取。实际上,这种情况不会发生,因为优化的服务器会使用类似于DataLoader库的东西,该库充当中间件来拦截这 36 次调用并将它们批处理到可能只有四次数据库调用。这些较小的原子读取请求是必要的,以便 DataLoader(或类似工具)能够有效地将它们批处理到更少的读取中。

因此,为了使用 SQL 优化 GraphQL API,通常最好在最低级别拥有较小的解析器,并使用 DataLoader 等工具来优化它们。但对于 DynamoDB API,最好拥有更高层的“更智能”的解析器,这些解析器更符合为其编写的单表数据库的访问模式。在这种情况下产生的过度获取通常是较小的弊端。

在 60 秒内部署此示例

在这里,你会意识到将 DynamoDB 与无服务器 GraphQL 一起使用带来的全部好处。我使用Architect构建了此示例。它是一个开源工具,用于在 AWS 上构建无服务器应用程序,无需大多数直接使用 AWS 的麻烦。克隆仓库并运行npm install后,你可以使用单个命令启动应用程序进行本地开发(包括数据库的内置本地版本)。不仅如此,当你准备好时,还可以使用单个命令将它直接部署到 AWS 上的生产基础设施(包括 DynamoDB)。