Когда пишешь код для серверного API, часто требуется проделывать схожие шаги: аутентифицировать пользователей, уточнять их роли и выставленные флаги функций, т.д. В большинстве фреймворков для бэкенда безупречно организовано взаимодействие с обработчиками запросов. Часто такой софт называют «промежуточное ПО» (middleware), поскольку он находится между кодом приложений и кодом системы. В этой статье я аргументирую, почему стоит обходиться как можно меньшим объёмом промежуточного ПО, и рассказываю, как при этом не сойти с ума.

Зачем он нам вообще может понадобиться?


На первом этапе работы можно заметить, что каждая функция у вас начинается с множества повторяющихся строк, например:

const user = await getUser(ctx);
if (!user) throw new Error("Authentication required");
const session = await ctx.db.get(sessionId);
if (!session) throw new Error("Session not found");

Замечание: это синтаксис для Convex, но такой же общий принцип касается и любого бэкендового фреймворка.

Возможно, вы также соберётесь откорректировать поведение функции: например, попробуете обернуть интерфейс вашей базы данных в такую версию функции, которая перед каждой операцией ещё раз проверяет авторизацию, в зависимости от того, какой пользователь сейчас в системе:

// Передать того пользователя, который будет использоваться в правилах оценки,
// согласно которым проверяются обращения к данным во время доступа / записи.
ctx.db = wrapDatabaseWriter({ user }, ctx.db, rules);

Любой программист, разумеется, хочет выделить это в абстракцию. Один раз изменили её, а потом везде применяем, правильно? Кажется, что эту задачу удобно решать при помощи промежуточного ПО. Так что…

В чём же проблема?


Зачастую кажется, что в основе работы промежуточного ПО лежит какая-то магия, из-за которой легко запутаться новичкам, только знакомящимся с базой кода, либо новым пользователям платформы.

  • Что произошло с моим запросом?
  • Проверяет ли этот код, в самом ли деле пользователь вошёл в систему?
  • Начнёт ли он для меня транзакцию? Очистит ли потом за ней код?
  • Откуда как по волшебству взялся этот сеансовый объект?
  • Как были инициализированы эти глобальные переменные?
  • Какое промежуточное ПО не применяется к моей функции?

Рассматривая обработчик конечной точки, вы не можете чётко себе представить, как именно модифицируются запросы до того, как попадают в ваш код. Задать конфигурацию промежуточного ПО – это не просто нажать Cmd+click и “перепрыгнуть к определению” на расстоянии одного сетевого перехода. Нужно знать, где именно определяется функция, как она конфигурируется для применения (и будет ли применяться), что она делает. Всё это подводит меня к таким ключевым принципам, которыми я руководствуюсь при кастомизации функций:

1. Кастомизация функции должна быть очевидной и легко обнаружимой
Вы должны быть в состоянии уверенно судить, модифицируются аргументы функции или нет, а также находить как код, модифицирующий запрос, так и дополнительную логику. Это делается через Cmd+click. К счастью, при работе на Python в данном случае очень помогает использовать с промежуточным ПО некоторые паттерны, например, декораторы. Если одновременно происходят множественные изменения, все эти вещи также должны оставаться ясными, что и подводит нас к следующему принципу.

2. Кастомизация должна быть явной и прямолинейной

Если в результате композиции сочетается множество вариантов поведения, то иногда можно запутаться, рассуждая о порядке следования операций и о зависимостях, в особенности при наличии вложенности. Если безопасность на уровне строк у вас зависит от того, доступны ли определённые данные – например, от того, вошёл ли пользователь в систему, то это не должно предполагать необходимость запоминать всю цепочку поведений в правильном порядке. Ещё один паттерн, который в данном случае заслуживает внимания – это применение вложенных определений функций, которые выглядят примерно так:

// ПЛОХО: в самом ли деле я должен запоминать, что всё это нужно повсюду проделывать в правильном порядке?
export const myFunction = mutation(
  withUser(
    withSession(
      withRowLevelSecurity(async (ctx, args) => {
        // ...
      })
    )
  )
);

Применяя такой подход, легко забыть, в каком именно порядке выполняются функции, и как в коде передаются типы. Конечно, такие конструкции гораздо сильнее напрягают мозг, чем те простые линии, которые мы ими заменили! В идеале кастомное поведение должно описываться коротко и максимально просто (чтобы не слишком нагружать голову). Как будет показано ниже, если написать это в виде единственной императивной функции, то рассуждать о таком коде будет гораздо проще, чем о многослойной структуре из функций-обёрток, каждая из которых привносит лишь ещё несколько строк кода. Кроме того, так вы можете определить небольшое количество типов функций, которые послужат вам «выручалочками», а не определять заново на каждом месте вызова, что и как компоновать.

Реплика в сторону: если выводить типы, ориентируясь на “обёртки”, то оказывается, что даже в TypeScript в данном случае сложно вывести типы ctx и args. Часто, чтобы заставить типы работать, приходится писать функции с комбинированным поведением. Это подводит нас к третьему принципу.

3. Безопасность типов должна соблюдаться по умолчанию и быть предсказуемой

Когда в промежуточном ПО определяются переменные, область действия которых ограничена одним запросом, не всегда понятно, какие типы будут доступны на конечной точке. На прошлой моей работе, где активно использовался Python, у нас была сущность User, которой сопровождался каждый обработчик, но она не была явно типизирована, поэтому требовалось знать или догадываться, что в этой функции определяется. При работе с Go у ctx, передаваемых каждой функции, тип всегда одинаков (ctx.Context) независимо от того, что было добавлено к коду выше в процессе выполнения программы. В TypeScript эту задачу можно решить лучше.

Правда, задача уладить дела с типами для этих высокоуровневых функций или написать обобщённую функцию, которая послужит обёрткой – быстро становится слишком сложной. Не нужно иметь диплом по математике, чтобы добавить параметр к функции.

Типы могут говорить сами за себя, когда мы предоставляем их обработчику конечной точки, и зачастую сообщают достаточно, чтобы пользователю не приходилось обращаться к определению кастомной функции. У тех функций, которые подвергаются изменениям, должны быть сигнатуры типов, из которых очевидно, что у нас есть в распоряжении, а чего нет. Это же касается и логики кастомизации: если мы добавляем поиск и новые поведения, то они должны восприниматься как типобезопасные и позволять проверить типы, просто просмотрев промежуточные значения. Если при изменении логики будет допущен баг, то вы должны получать ошибки, связанные с нарушением типов.

Как это сделать?


Чтобы добиться всего этого в Convex, я реализовал функции для кастомизации сборщиков query, mutation и action, применяемых в Convex (а также для их внутренних потомков). Их можно импортировать из пакета convex-helpers npm.(1)

Для тех, кто не знаком с Convex, поясню: это хостовый бэкенд, предоставляемый как услуга, в который включено всё: от реактивной базы данных и бессерверных функций до хранилища файлов, механизмов планирования и поиска. Можете почитать документацию, чтобы подробнее изучить его основы.

Собираем за минуты, масштабируемся вечно.

Convex – это серверная платформа приложений, на которой найдётся всё, что нужно для сборки проекта. Здесь есть облачные функции, база данных, хранилище файлов, планировщик, поиск и обновления, работающие в режиме реального времени. Всё это бесшовно сочетается друг с другом.

Здесь и далее я описываю, как обычно работаю с Convex. В общем виде этот подход в той или иной степени должен экстраполироваться и на другие фреймворки.

Модифицируем аргумент ctx у серверной функции для аутентификации пользователя
Вот пример, в котором мы модифицируем всего несколько значений в аргументе ctx, передаваемом функции Convex. Мы ищем пользователя, который вошёл в систему, а затем предоставляем его как ctx.user внутри функции, которую определили при помощи userQuery. Также обёртываем операции чтения базы данных в функцию, обеспечивающую безопасность на уровне строк.

import { query } from "./_generated/server";
import { customQuery, customCtx } from "convex-helpers/server/customFunctions";

// Чтобы добавить это поведение, используем `userQuery` вместо `query`.
const userQuery = customQuery(
  query, // Базовая функция, которую мы расширяем
  // Здесь мы используем помощник `customCtx`, поскольку вносимое нами изменение 
  // касается только аргумента `ctx`, передаваемого функции.
  customCtx(async (ctx) => {
    // Ищем пользователя, который вошёл в систему
    const user = await getUser(ctx);
    if (!user) throw new Error("Authentication required");
    // Передаём пользователя, чтобы проверить его согласно правилам оценки,
    // которые валидируют обращения к данным при доступе / записи.
    const db = wrapDatabaseReader({ user }, ctx.db, rules);
    // Этот новый ctx будет применён в нашей функции.
    // user – это новое поле, db заменит ctx.db
    return { user, db };
  })
);

// Используется где-то ещё

// Определяет общедоступную конечную точку для выполнения мутаций под названием "myInfo"
// Возвращает базовую информацию по аутентифицированному пользователю.
export const myInfo = userQuery({
  args: { includeTeam: v.boolean() },
  handler: async (ctx, args) => {
    // Обратите внимание: `ctx.user` уже определён! Он будет фигурировать и в типах!
    const userInfo = { name: ctx.user.name, profPic: ctx.user.profilePic };
    if (args.includeTeam) {
      // Если существуют какие-то правила, которые должны применяться к таблице teams,
      // то обёрнутый `ctx.db` позволяет гарантировать, что не будет случайно выбрана та команда, 
      // к которой данный пользователь не должен иметь доступа.
      const team = await ctx.db.get(ctx.user.teamId);
      return { ...userInfo, teamName: team.name, teamId: team._id };
    }
    return userInfo;
  }
});


Здесь функция customCtx используется для удобства: на тот случай, когда вы хотите изменить query, mutation или action, и вам не требуется потреблять или модифицировать аргументы.

Потребляем аргумент функции для простейшей аутентификации по ключу API

Вот ещё один пример, в котором мы добавляем дополнительный аргумент к каждой функции apiMutation. Любой клиент, вызывающий эти функции, должен будет передавать параметр apiKey, но реализация этих функций не предполагает, что для этого потребуется получать или указывать валидацию аргумента.

import { mutation } from "./_generated/server";
import { customMutation } from "convex-helpers/server/customFunctions";

// Чтобы применить это поведение, используем `apiMutation` вместо `mutation`.
const apiMutation = customMutation(mutation, {
  // Это расширенная кастомизация, упрощённая при помощи `customCtx`, приведённого выше
  // Можно указывать аргументы, потребляемые  при выполнении логики кастомизации 
  args: { apiKey: v.string() },
  // Подобно `args` и `handler`, применяемых с обычной функцией, по валидируемым выше
  // args определяется форма приведённых ниже `args`.
  input: async (ctx, { apiKey }) => {
    // Добавляем простую проверку в виде сравнения с API_KEY.
    if (apiKey !== process.env.API_KEY) throw new Error("Invalid API key");
    // Возвращаем эти параметры, чтобы ДОБАВИТЬ их к параметрам изменённой функции.
    // В данном случае мы не изменяем ctx или args
    return { ctx: {}, args: {} };
  },
});

//... используется в другом месте

// Определяет общедоступную конечную точку для выполнения мутаций под названием "doSomething"
export const doSomething = apiMutation({
  // Обратите внимание: мы не указываем "apiKey" в каждой точке вызова
  args: { someArg: v.number() },
  // Обратите внимание: здесь в число аргументов не входит "apiKey", поскольку он не был возвращён выше.
  handler: async (ctx, args) => {
    const { someArg } = args;
    // ...
  }
});

Обратите внимание: чтобы организовать более надёжную валидацию по ключу API, я делаю таблицу api_keys и организую её так, чтобы ключ из этой таблицы служил ID документа. В этом документе можно зафиксировать, кому он был выдан, был ли он признан недействительным, когда заканчивается срок его действия и т.д. Вышеприведённый пример с переменными окружения – тактический, на случай, когда у вас найдётся максимум ещё одна доверенная среда.

Модификация ctx и args для реализации сеанса

Вот для примера ещё одна собственная функция:

import { mutation } from "./_generated/server";
import { customMutation } from "convex-helpers/server/customFunctions";

// При помощи `sessionMutation` определяем публичные запросы
export const sessionMutation = customMutation(mutation, {
  // Валидация аргументов для sessionMutation: здесь у нас два именованных аргумента.
  args: { sessionId: v.id("sessions"), someArg: v.string() },
  // Обработчик функции, принимающий валидированные аргументы и контекст.
  input: async (ctx, { sessionId, someArg }) => {
    const user = await getUser(ctx);
    if (!user) throw new Error("Authentication required");
    const session = await ctx.db.get(sessionId);
    if (!session) throw new Error("Session not found");
		    // Передаём пользователя, чтобы проверить его согласно правилам оценки,
    // которые валидируют обращения к данным при доступе / записи.
		const db = wrapDatabaseWriter({ user }, ctx.db, rules);
    // Обратите внимание: здесь мы передаём аргументы насквозь, чтобы они были доступны и ниже 
    return { ctx: { db, user, session }, { sessionId, someArg } };
  }
})

export const checkout = sessionMutation({
  args: {
    // Обратите внимание: если хотите, можете указать это как аргумент, а потом
    // сопоставлять с типом. Или можете пропустить. Так или иначе, работать будет.
    // sessionId: v.id("sessions"), 
  },
  // Здесь среди аргументов — sessionId и someArg (включая тип)
  handler: async (ctx, args) {
    const { user, session } = ctx;
    const cart = await db.get(session.cartId);
    await purchase(ctx, user, cart, args.sessionId);
  }


Дальнейшее расширение

Рекомендую вам не располагать эти функции послойно, а по возможности сделать всё в одном месте. Можете воспользоваться обычной инкапсуляцией функций, чтобы скрыть несущественные детали, но, поступательно внося изменения в эту функцию, вы будете не только видеть в одном месте все вносимые модификации, но и будете наблюдать, как они взаимодействуют.
Если вы хотите варьировать это поведение, напишите кастомные функции на каждый случай и пользуйтесь разделяемыми функциями, чтобы не повторять слишком много кода. Но я склонен всегда писать код как можно более явно, так, чтобы было очевидно, какие аргументы добавляются, а какие удаляются.

Обратите внимание: также можно отменить определение полей в ctx, возвращая undefined. Например, чтобы удалить db, можно возвращать ctx: { db: undefined }.
Каковы недостатки?

Есть ли у такого подхода недостатки? Конечно! Любые варианты проектирования или абстракции несовершенны. Например, по-прежнему приходится держать в уме, что нужно использовать специальную функцию. Вот несколько способов, позволяющих сгладить эту проблему:

• Добавить правило eslint, запрещающее где-либо импортировать голые query или mutation – можете добавить исключения, чтобы потом переопределять их где потребуется.
• Можно не заменять db более “безопасной” версией, а переименовать её, а исходное имя при этом удалить, например, вот так: ctx: { safeDB: db, db: undefined }. Тогда а любом месте, где вы собирались сделать ctx.safeDB, вы получите ошибку типа в том случае, если ваша специальная функция не используется.

Итоги


Вспомогательные функции customFunction:

  1. Легко обнаружимы и очевидны: можно определить, была ли ваша функция изменена – для этого проверьте, используется ли в ней mutation или apiMutation. Чтобы перейти к её определению, просто нажмите command+click apiMutation.
  2. Явные и прямые вызовы функции. Поэтому легко проследить, какие изменения происходят и в каком порядке. Зависимости выглядят как обычные аргументы функций (например, как в случае с wrapDatabaseWriter).
  3. Простые и предсказуемые типы будут в вашем распоряжении на каждом этапе кастомизации. Это полностью типобезопасный TypeScript, не требуется никаких аннотаций типов! Кстати, именно по этой причине такой код является и полноценным JavaScript.

Если хотите, можете посмотреть код / сделать форк / отправить пул-реквест тут:
get-convex/convex-helpers

Сноска
1. Установите convex-helpers при помощи npm i convex-helpers@latest.

Комментарии (0)