跳至主要内容
Deno 2 终于来了 🎉️
了解更多
Building a CRUD API with Oak and Deno KV.

如何使用 Oak 和 Deno KV 构建 CRUD API

Deno KV 是第一个直接内置于运行时的数据库之一。这意味着您无需执行任何额外步骤,例如配置数据库或复制粘贴 API 密钥来构建有状态应用程序。要打开与数据存储的连接,您只需编写

const kv = await Deno.openKv();

除了是具有 简单而灵活的 API 的键值存储之外,它还是一个生产就绪的数据库,具有 原子事务一致性控制 和尖端的性能。

在本入门教程中,您将学习如何使用 Deno KV 构建一个用 Oak 编写的简单有状态 CRUD API。我们将介绍

在我们开始之前,Deno KV 目前在 Deno 1.33 及更高版本中使用 --unstable 标志提供。如果您有兴趣在 Deno Deploy 上使用 Deno KV,请加入候补名单,因为它仍在封闭测试阶段。

请按照下面的步骤操作,或者 查看源代码

设置数据库模型

这个 API 非常简单,使用了两个模型,每个 user 都会有一个可选的 address

A user and address model for this simple API

在一个新的仓库中,创建一个 db.ts 文件,它将包含所有关于数据库的信息和逻辑。让我们从类型定义开始

export interface User {
  id: string;
  email: string;
  name: string;
  password: string;
}

export interface Address {
  city: string;
  street: string;
}

创建 API 路由

接下来,让我们使用以下函数创建 API 路由

  • 更新用户
  • 更新与用户绑定的地址
  • 列出所有用户
  • 按 ID 列出单个用户
  • 按电子邮件列出单个用户
  • 按用户 ID 列出地址
  • 删除用户和所有相关地址

我们可以使用 Oak(灵感来自 Koa)轻松做到这一点,Oak 自带其自己的 Router

让我们创建一个新的文件 main.ts 并添加以下路由。现在,我们先将部分路由处理程序中的逻辑保留为空白

import {
  Application,
  Context,
  helpers,
  Router,
} from "https://deno.land/x/[email protected]/mod.ts";

const { getQuery } = helpers;
const router = new Router();

router
  .get("/users", async (ctx: Context) => {
  })
  .get("/users/:id", async (ctx: Context) => {
    const { id } = getQuery(ctx, { mergeParams: true });
  })
  .get("/users/email/:email", async (ctx: Context) => {
    const { email } = getQuery(ctx, { mergeParams: true });
  })
  .get("/users/:id/address", async (ctx: Context) => {
    const { id } = getQuery(ctx, { mergeParams: true });
  })
  .post("/users", async (ctx: Context) => {
    const body = ctx.request.body();
    const user = await body.value;
  })
  .post("/users/:id/address", async (ctx: Context) => {
    const { id } = getQuery(ctx, { mergeParams: true });
    const body = ctx.request.body();
    const address = await body.value;
  })
  .delete("/users/:id", async (ctx: Context) => {
    const { id } = getQuery(ctx, { mergeParams: true });
  });

const app = new Application();

app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

接下来,让我们通过编写数据库函数来深入了解 Deno KV。

Deno KV

回到我们的 db.ts 文件,让我们在类型定义下方开始添加数据库帮助程序函数。

const kv = await Deno.openKv();

export async function getAllUsers() {
}

export async function getUserById(id: string): Promise<User> {
}

export async function getUserByEmail(email: string) {
}

export async function getAddressByUserId(id: string) {
}

export async function upsertUser(user: User) {
}

export async function updateUserAndAddress(user: User, address: Address) {
}

export async function deleteUserById(id: string) {
}

让我们先从填充 getUserById 开始

export async function getUserById(id: string): Promise<User> {
  const key = ["user", id];
  return (await kv.get<User>(key)).value!;
}

这很简单,我们使用键前缀 "user"kv.get() 中的 id

但是我们如何添加 getUserByEmail 呢?

添加辅助索引

辅助索引是 非主键的索引,可能包含重复项。在本例中,我们的辅助索引是 email

由于 Deno KV 是一个简单的键值存储,我们将创建一个第二个键前缀 "user_by_email",它使用 email 创建键并返回关联的用户 id。以下是一个示例

const user = (await kv<User>.get(["user", "1"])).value!;
// {
//   "id": "1",
//   "email": "[email protected]",
//   "name": "andy",
//   "password": "12345"
// }

const id = (await kv.get(["user_by_email", "[email protected]"])).value;
// 1

然后,为了获取 user,我们将在第一个索引上执行一个单独的 kv.get()

有了这两个索引,我们现在就可以编写 getUserByEmail

export async function getUserByEmail(email: string) {
  const userByEmailKey = ["user_by_email", email];
  const id = (await kv.get(userByEmailKey)).value as string;
  const userKey = ["user", id];
  return (await kv<User>.get(userKey)).value!;
}

现在,当我们 upsertUser 时,我们将不得不更新 "user" 主键前缀中的 user。如果 email 不同,那么我们还必须更新辅助键前缀 "user_by_email"

但是我们如何确保当两个更新事务同时发生时,我们的数据不会不同步呢?

使用原子事务

我们将使用 kv.atomic(),它保证要么事务中的所有操作都成功完成,要么事务在发生故障时回滚到其初始状态,使数据库保持不变。

以下是我们定义 upsertUser 的方式

export async function upsertUser(user: User) {
  const userKey = ["user", user.id];
  const userByEmailKey = ["user_by_email", user.email];

  const oldUser = await kv.get<User>(userKey);

  if (!oldUser.value) {
    const ok = await kv.atomic()
      .check(oldUser)
      .set(userByEmailKey, user.id)
      .set(userKey, user)
      .commit();
    if (!ok) throw new Error("Something went wrong.");
  } else {
    const ok = await kv.atomic()
      .check(oldUser)
      .delete(["user_by_email", oldUser.value.email])
      .set(userByEmailKey, user.id)
      .set(userKey, user)
      .commit();
    if (!ok) throw new Error("Something went wrong.");
  }
}

我们首先获取 oldUser 来检查它是否存在。如果不存在,则使用 useruser.id 设置键前缀 "user""user_by_email"。否则,由于 user.email 可能已更改,因此我们将通过删除键 ["user_by_email", oldUser.value.email] 处的值来删除 "user_by_email" 处的 value。

我们使用 .check(oldUser) 完成所有这些操作,以确保没有其他客户端更改了这些值。否则,我们将容易受到竞态条件的影响,从而可能更新错误的记录。如果 .check() 通过并且值保持不变,那么我们就可以使用 .set().delete() 完成事务。

kv.atomic() 是在多个客户端发送写入事务时(例如在银行/金融和其他数据敏感应用程序中)确保正确性的绝佳方法。

列表和分页

接下来,让我们定义 getAllUsers。我们可以使用 kv.list() 来实现这一点,它会返回一个键迭代器,我们可以枚举它来获取值,并将这些值 .push()users 数组中

export async function getAllUsers() {
  const users = [];
  for await (const res of kv.list({ prefix: ["user"] })) {
    users.push(res.value);
  }
  return users;
}

请注意,这个简单的函数遍历并返回整个 KV 存储。如果这个 API 与前端交互,我们可以传递一个 { limit: 50 } 选项来检索前 50 个项目

let iter = await kv.list({ prefix: ["user"] }, { limit: 50 });

当用户需要更多数据时,使用 iter.cursor 检索下一个批次

iter = await kv.list({ prefix: ["user"] }, { limit: 50, cursor: iter.cursor });

添加第二个模型,Address

让我们将第二个模型 Address 添加到我们的数据库中。我们将使用一个新的键前缀 "user_address",后面跟着标识符 user_id (["user_address", user_id]),作为这两个 KV 子空间之间的“连接”。

现在,让我们编写 getAddressByUser 函数

export async function getAddressByUserId(id: string) {
  const key = ["user_address", id];
  return (await kv<Address>.get(key)).value!;
}

我们可以编写 updateUserAndAddress 函数。请注意,我们需要使用 kv.atomic(),因为我们要更新三个 KV 项,它们使用 "user""user_by_email""user_address" 作为键前缀。

export async function updateUserAndAddress(user: User, address: Address) {
  const userKey = ["user", user.id];
  const userByEmailKey = ["user_by_email", user.email];
  const addressKey = ["user_address", user.id];

  const oldUser = await kv.get<User>(userKey);

  if (!oldUser.value) {
    const ok = await kv.atomic()
      .check(oldUser)
      .set(userByEmailKey, user.id)
      .set(userKey, user)
      .set(addressKey, address)
      .commit();
    if (!ok) throw new Error("Something went wrong.");
  } else {
    const ok = await kv.atomic()
      .check(oldUser)
      .delete(["user_by_email", oldUser.value.email])
      .set(userByEmailKey, user.id)
      .set(userKey, user)
      .set(addressKey, address)
      .commit();
    if (!ok) throw new Error("Something went wrong.");
  }
}

添加 kv.delete()

最后,为了完善应用程序的 CRUD 功能,让我们定义 deleteByUserId

与其他 mutator 函数类似,我们将检索 userRes 并使用 .atomic().check(userRes).delete() 三个键之前。

export async function deleteUserById(id: string) {
  const userKey = ["user", id];
  const userRes = await kv.get(userKey);
  if (!userRes.value) return;
  const userByEmailKey = ["user_by_email", userRes.value.email];
  const addressKey = ["user_address", id];

  await kv.atomic()
    .check(userRes)
    .delete(userKey)
    .delete(userByEmailKey)
    .delete(addressKey)
    .commit();
}

更新路由处理程序

现在我们已经定义了数据库函数,让我们在main.ts中导入它们,并填写路由处理程序中的其余功能。以下是完整的main.ts文件

import {
  Application,
  Context,
  helpers,
  Router,
} from "https://deno.land/x/[email protected]/mod.ts";
import {
  deleteUserById,
  getAddressByUserId,
  getAllUsers,
  getUserByEmail,
  getUserById,
  updateUserAndAddress,
  upsertUser,
} from "./db.ts";

const { getQuery } = helpers;
const router = new Router();

router
  .get("/users", async (ctx: Context) => {
    ctx.response.body = await getAllUsers();
  })
  .get("/users/:id", async (ctx: Context) => {
    const { id } = getQuery(ctx, { mergeParams: true });
    ctx.response.body = await getUserById(id);
  })
  .get("/users/email/:email", async (ctx: Context) => {
    const { email } = getQuery(ctx, { mergeParams: true });
    ctx.response.body = await getUserByEmail(email);
  })
  .get("/users/:id/address", async (ctx: Context) => {
    const { id } = getQuery(ctx, { mergeParams: true });
    ctx.response.body = await getAddressByUserId(id);
  })
  .post("/users", async (ctx: Context) => {
    const body = ctx.request.body();
    const user = await body.value;
    await upsertUser(user);
  })
  .post("/users/:id/address", async (ctx: Context) => {
    const { id } = getQuery(ctx, { mergeParams: true });
    const body = ctx.request.body();
    const address = await body.value;
    const user = await getUserById(id);
    await updateUserAndAddress(user, address);
  })
  .delete("/users/:id", async (ctx: Context) => {
    const { id } = getQuery(ctx, { mergeParams: true });
    await deleteUserById(id);
  });

const app = new Application();

app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

测试我们的 API

让我们运行我们的应用程序并进行测试。要运行它

deno run --allow-net --watch --unstable main.ts

我们可以使用 CURLs 测试我们的应用程序。让我们添加一个新用户

curl -X POST https://127.0.0.1:8000/users -H "Content-Type: application/json" -d '{ "id": "1", "email": "[email protected]", "name": "andy", "password": "12345" }'

当我们将浏览器指向localhost:8000/users时,我们应该看到

JSON response of our new user

让我们看看是否可以通过将浏览器指向localhost:8000/users/email/[email protected]来按电子邮件检索用户

JSON response of our new user by email

让我们发送一个 POST 请求,将地址添加到此用户

curl -X POST https://127.0.0.1:8000/users/1/address -H "Content-Type: application/json" -d '{ "city": "los angeles", "street": "main street" }'

让我们看看是否通过转到localhost:8000/users/1/address来实现

JSON response of the address of our new user

让我们使用新名称更新 id 为1的相同用户

curl -X POST https://127.0.0.1:8000/users -H "Content-Type: application/json" -d '{ "id": "1", "email": "[email protected]", "name": "an even better andy", "password": "12345" }'

我们可以看到我们的浏览器在localhost:8000/users/1处反映了此更改

JSON response of an updated user

最后,让我们删除用户

curl -X DELETE https://127.0.0.1:8000/users/1

当我们将浏览器指向localhost:8000/users时,我们应该什么也看不到

No more users left

下一步

这只是使用 Deno KV 构建有状态 API 的入门介绍,但希望你能看到开始使用它有多快和容易。

有了这个 CRUD API,你可以创建一个简单的前端客户端来与数据进行交互。

不要错过任何更新——在 Twitter 上关注我们.