Кастомизация бессерверных функций без применения промежуточного ПО

от автора

Когда пишешь код для серверного 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.


ссылка на оригинал статьи https://habr.com/ru/articles/782408/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *