Как создать CRUD API с помощью Oak и Deno KV
(Первоначально опубликовано на deno.com/blog.)
Deno KV — одна из первых баз данных, встроенных прямо в среду выполнения. Это означает, что вам не нужно выполнять какие-либо дополнительные действия, такие как подготовка базы данных или копирование и вставка ключей API для создания приложений с отслеживанием состояния. Чтобы открыть соединение с хранилищем данных, вы можете просто написать:
const kv = await Deno.openKv();
Помимо того, что это хранилище ключей и значений с простым, но гибким API, это готовая к работе база данных с атомарными транзакциями, контролем согласованности и высочайшей производительностью.
Из этого вводного руководства вы узнаете, как использовать Deno KV для создания простого API CRUD с отслеживанием состояния, написанного на Oak. Мы рассмотрим:
Прежде чем мы начнем, Deno KV в настоящее время доступен с флагом --unstable
в Deno 1.33 и выше. Если вы заинтересованы в использовании Deno KV в Deno Deploy, пожалуйста, присоединитесь к списку ожидания, поскольку он все еще находится в стадии закрытого бета-тестирования.
Следуйте инструкциям ниже или ознакомьтесь с исходным кодом.
Настройте модели базы данных
Этот 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 со следующими функциями:
- Добавление пользователя
- Добавление адреса, привязанного к пользователю
- Список всех пользователей
- Список одного пользователя по идентификатору
- Список одного пользователя по электронной почте
- Список адресов по идентификатору пользователя
- Удалить пользователя и любой связанный адрес
Мы можем легко сделать это с помощью Oak (вдохновленного Koa), который поставляется со своим собственным 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, написав функции базы данных.
Дено КВ
Вернемся к нашему файлу 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"
и id
с kv.get()
.
Но как добавить 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
, чтобы проверить, существует ли он. Если нет, .set()
префикс ключа "user"
и "user_by_email"
с user
и user.id
. В противном случае, поскольку user.email
мог измениться, мы удаляем значение "user_by_email"
, удаляя значение ключа ["user_by_email", oldUser.value.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.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
.
Подобно другим функциям мутации, мы будем получать 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
Мы можем протестировать наше приложение с помощью CURL. Добавим нового пользователя:
curl -X POST http://localhost: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 http://localhost: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 http://localhost: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 http://localhost:8000/users/1
Когда мы указываем нашему браузеру localhost:8000/users
, мы ничего не должны видеть:
Что дальше
Это всего лишь введение в создание API-интерфейсов с отслеживанием состояния с помощью Deno KV, но, надеюсь, вы видите, насколько быстро и легко начать работу.
С помощью этого CRUD API вы можете создать простой внешний клиент для взаимодействия с данными.
Не пропустите обновления — подпишитесь на нас в Twitter.