如何使用 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
。
在一个新的仓库中,创建一个 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
以检查它是否存在。如果不存在,则使用 user
和 user.id
.set()
键前缀 "user"
和 "user_by_email"
。否则,由于 user.email
可能已更改,我们将通过删除键 ["user_by_email", oldUser.value.email]
处的值来删除 "user_by_email"
中的值。
我们使用 .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 条目,其键前缀分别为 "user"
、"user_by_email"
和 "user_address"
,因此我们需要使用 kv.atomic()
。
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
。
与其他变更函数类似,我们将检索 userRes
并在 .delete()
三个键之前使用 .atomic().check(userRes)
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
我们可以使用 CURL 测试我们的应用程序。让我们添加一个新用户
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
时,我们应该看到
让我们看看是否可以通过将浏览器指向 localhost:8000/users/email/[email protected]
来按电子邮件检索用户
让我们发送一个 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
实现了目标
让我们用新名称更新 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
中
最后,让我们删除用户
curl -X DELETE https://127.0.0.1:8000/users/1
当我们把浏览器指向 localhost:8000/users
时,我们应该什么也看不到
下一步是什么
这只是使用 Deno KV 构建有状态 API 的入门介绍,但希望您可以看到入门有多么快速和容易。
使用此 CRUD API,您可以创建一个简单的前端客户端来与数据交互。
不要错过任何更新 — 在 Twitter 上关注我们。