Как создать 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.