[Перевод] Кастомизация бессерверных функций без применения промежуточного ПО
Когда пишешь код для серверного 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. Явные и прямые вызовы функции. Поэтому легко проследить, какие изменения происходят и в каком порядке. Зависимости выглядят как обычные аргументы функций (например, как в случае с wrapDatabaseWrite
r).
3. Простые и предсказуемые типы будут в вашем распоряжении на каждом этапе кастомизации. Это полностью типобезопасный TypeScript, не требуется никаких аннотаций типов! Кстати, именно по этой причине такой код является и полноценным JavaScript.
Если хотите, можете посмотреть код / сделать форк / отправить пул-реквест тут:
get-convex/convex-helpers
Сноска
1. Установите convex-helpers при помощи npm i convex-helpers@latest. ↩