Иногда потребности настолько разнообразны, что их невозможно удовлетворить, передав параметры, вам нужно разрешить пользователю настраивать сам код.

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

Где вы проводите линию?

Иногда люди хотят ввести число слева, чтобы сформировать строку фиксированной длины, чтобы преобразовать 17 в 0017. Репозитории вроде left-pad имеют четкую направленность, но они не удовлетворяют тесно связанные потребности, которые могут возникнуть у людей, например:

  • Я хочу показать 2 десятичных знака.
  • Я хочу, чтобы отображаемые десятичные разряды были округлены или нет.
  • Я хочу, чтобы были показаны разделители цифр, например 1,234.
  • Я хочу, чтобы цифры и десятичный разделитель были переменными, например 1 234,56.
  • Я хочу, чтобы отображался конечный десятичный разделитель, например 1234. без десятичных знаков.
  • Я хочу, чтобы отображался переменный символ валюты, выравниваемый по левому краю или плавающий, например $1234 или $ 1234.

Представьте, что вы получаете эти запросы, если являетесь автором left-pad. Вы написали репо, чтобы удовлетворить вашу конкретную общую потребность, и опубликовали его, чтобы помочь другим. Теперь вас просят потратить время на внедрение новых функций, которые вы сами не будете использовать.

Честно говоря, долго это поддерживать нельзя. Адриан Головатый, один из Доброжелательных пожизненных диктаторов из Django, писал о своем уходе из Django, упомянул, что нет особой необходимости в интернационализации на немецкий язык, где он работал в ежедневной газете Lawrence Journal-World, издаваемой в Лоуренсе, штат Канзас, США.

Я буду ждать на другой стороне

Репо могут быть наиболее успешными, если они имеют конкретную направленность и хорошо справляются с этой задачей. Но что делать пользователю, у которого немного другая потребность?

Если ему повезет, он может обернуть репо и реализовать свои особые потребности в оболочке. Ведущий символ валюты может быть реализован с помощью

const leftPad = require('left-pad');
module.exports = (amt, width, symbol = '$') =>
  `${symbol}${leftPad(value, width - 1})`;

Однако большую часть времени необходимо изменить код в репозитории или добавить дополнительный код. Это означает, что вам нужно разветвить репо, создать свою собственную версию и настроить эту копию. У этого есть свои недостатки:

  • Ваш форк не получит автоматически выгоды от исправлений кода и улучшений, внесенных в исходное репо.
  • Другие разработчики не знакомы с вашей разветвленной версией и могут даже запутаться в ней.

Мыслить нестандартно

Некоторые репозитории связаны с проблемами, которые непостоянны и неуловимы по самой своей природе. Аутентификация - одна из таких областей. Эти репозитории могут рассчитывать на получение большего количества запросов функций, чем другие типы репозиториев, и эти запросы будут для разумных случаев использования.

Мы столкнулись с этой ситуацией при переписывании пера-плюс / аутентификация-локальное-управление (также известного как alm) для FeathersJS . Это был третий крупный рефакторинг репо (обратные вызовы Promises для async / await), поэтому мы хорошо понимали требования. У нас также был опыт работы со специализированными вариантами использования, к которым обращались пользователи.

Мы решили внедрить некоторые методы, которые сделали новое репозиторий более удобным для настройки, сохранив при этом его направленность.

Линейная перспектива

Мы не говорим о некоторой гибкости здесь и немного там. Некоторые сведения могут помочь вам оценить масштаб нашей проблемы.

FeathersJS - это уровень GraphQL, REST и API реального времени с открытым исходным кодом для современных приложений. Имеет 10 тысяч звезд на GitHub; это одна из 8 серверных фреймворков, выбранных исследованием State of JavaScript в 2017 году, и одна из 6 в 2018 году.

a-l-m Feathers использует электронную почту и SMS, чтобы

  • пригласить потенциального нового пользователя, рекомендованного текущим пользователем
  • проверить нового пользователя
  • проверить нового пользователя, если исходное сообщение подтверждения было утеряно
  • реализовать двухфакторную аутентификацию (2FA) при входе в систему
  • сбросить пароль, если его забыли
  • проверьте вход, если с нового устройства
  • проверьте вход, если последний вход был слишком давным
  • уведомить об изменении пароля
  • подтвердить изменение адреса электронной почты, номера телефона, PIN-кода, значка и т. д.

Пакет работает вместе с веб-интерфейсом и поставщиками push-уведомлений. Один из более простых сценариев, который он обрабатывает, - это сброс забытого пароля с помощью SMS-сообщения. Последовательность событий показана ниже, где a-l-m непосредственно участвует в красных взаимодействиях.

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

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

Способы переступить черту

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

  • Модификация кода в репо.
  • Добавление кода в репо.

Мы уже отказались от форков и теперь рассмотрим варианты и плагины.

Мы решили внедрить некоторые методы, которые сделали новое репо удобным для настройки, сохранив при этом его первоначальную направленность.

Варианты сошли с ума

Большие репозитории часто поддерживают параметры конфигурации, и они отлично подходят для многих вещей, и a-l-m определенно использует параметры:

const optionsDefault = {
  app: null, // Value set during configuration.
  service: '/users',
  path: 'authManagement',
  emailField: 'email',
  dialablePhoneField: 'dialablePhone',
  passwordField: 'password',
  longTokenLen: 15,
  shortTokenLen: 6,
  shortTokenDigits: true,
  resetDelay: 1000 * 60 * 60 * 2,
  delay: 1000 * 60 * 60 * 24 * 5,
  identifyUserProps: ['email', 'dialablePhone'],
  actionsNoAuth: [
    'resendVerifySignup', 'verifySignupLong', 'verifySignupShort',
    'sendResetPwd', 'resetPwdLong', 'resetPwdShort',
  ],
  ownAcctOnly: true,
  plugins: null,
};

Параметры не так хороши для объединения фрагментов кода. Рассмотрим эти обращения в службу поддержки Feathers:

// Feathers service calls to get record 'id' in the users DB
// as server
user = await users.get(id);
// as an unauthenticated client in a REST request
user = await users.get(id, { provider: 'rest' });
// as an authenticated client
user = await users.get(id, { users: authenticatedClient });

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

Вот что произошло, когда мы попытались использовать параметры для разделов кода. Исходный код был:

const users = await usersService.find({
  { query: identifyUser }, provider
});

const user3 = await usersService.patch(
  user2[usersServiceIdName], {
     resetExpires: user2.resetExpires,
     resetToken: user2.resetToken,
     resetShortToken: user2.resetShortToken,
  }
);

Код на основе опций стал:

const users = await options.customizeCalls.sendResetPwd.find(
  usersService, { { query: identifyUser }, provider }
);

const user3 = await options.customizeCalls.sendResetPwd.patch(
  usersService, user2[usersServiceIdName], {
     resetExpires: user2.resetExpires,
     resetToken: user2.resetToken,
     resetShortToken: user2.resetShortToken,
  }
);
// The default options contain the original code:
const optionsCustomizedCalls = {
  sendResetPwd: {
    find: async (usersService, params) =>
      await usersService.find(params),
    patch: async (usersService, id, data, params = {}) =>
      await usersService.patch(id, data, params),
  },
}:

Теперь пользователь может заменять критические участки кода в репозитории, так что это улучшение. Однако проблемы остаются. Во-первых, изрядное количество возможных настроек включает добавление строки кода до или после ключевого фрагмента кода в репо. Это привело бы к неудобным ситуациям, когда настраиваемые параметры продолжали повторять код заменяемого параметра.

// Default option
const optionsCustomizedCalls = {
  sendResetPwd: {
    patch: async (usersService, id, data, params) =>
      await usersService.patch(id, data, params),
  },
}:
// Replacement option
const optionsCustomizedCalls = {
  sendResetPwd: {
    patch: async (usersService, id, data, params) => {
      await usersService.patch(id, data, params);
      // Add password to list of user's passwords
      // so it cannot be reused
      await usersPasswordsService.create({ id, data.password });
    }
  },
}:

Во-вторых, если пользователь хочет, чтобы alm поддерживал новую команду - например, отправку SMS-сообщения всякий раз, когда вход был обнаружен с нового устройства, - пользователю придется переопределить «весь код отправки команд. " показано ниже.

try {
  switch (data.action) {
    case 'checkUnique':
      return await checkUnique(
        options, data.value, data.ownId || null, data.meta || {},
        data.authUser, data.provider);
    case 'resendVerifySignup':
      return await resendVerifySignup(
        options, data.value, data.notifierOptions,
        data.authUser, data.provider);
    case 'verifySignupLong':
      return await verifySignupWithLongToken(
        options, data.value,
        data.authUser, data.provider);
    case 'verifySignupShort':
      return await verifySignupWithShortToken(
        options, data.value.token, data.value.user,
        data.authUser, data.provider);
    case 'sendResetPwd':
      return await sendResetPwd(
        options, data.value, data.notifierOptions,
        data.authUser, data.provider);
    case 'resetPwdLong':
      return await resetPwdWithLongToken(
        options, data.value.token, data.value.password,
        data.authUser, data.provider);
    case 'resetPwdShort':
      return await resetPwdWithShortToken(
        options, data.value.token, data.value.user,
        data.value.password,
        data.authUser, data.provider);
    case 'passwordChange':
      return await passwordChange(
        options, data.value.user, data.value.oldPassword,
        data.value.password,
        data.authUser, data.provider);
    case 'identityChange':
      return await identityChange(
        options, data.value.user, data.value.password,
        data.value.changes,
        data.authUser, data.provider);
    default:
      return Promise.reject(
        new errors.BadRequest(`Action '${data.action}' is invalid.`,
          { errors: { $className: 'badParams' } }
        )
      );
  }
} catch (err) {
  return options.catchErr(err, options, data);
}

Этот настроенный код не мог воспользоваться никакими изменениями, внесенными в репозиторий, включая введение новых команд.

Наконец, использование подобных вариантов выглядело хрупким. Мы должны создать абстракцию, если мы часто будем зависеть от чего-то вроде этого. Так мы стали использовать плагины.

Плагины

Подключаемый модуль - это программный компонент, который добавляет определенную функцию в существующую программу. Популярная утилита линтинга JavaScript eslint имеет подключаемый дизайн. WordPress насчитывает более 50 000 плагинов.

Плагины - это исполняемые фрагменты кода. Для наших целей здесь мы будем рассматривать даже одну строку кода в качестве потенциального плагина.

Наше репо очень похоже на то, используются ли в нем опционы.

const users = await options.customizeCalls.sendResetPwd.find(
  usersService, { { query: identifyUser }, provider }
);

const user3 = await options.customizeCalls.sendResetPwd.patch(
  usersService, user2[usersServiceIdName], {
     resetExpires: user2.resetExpires,
     resetToken: user2.resetToken,
     resetShortToken: user2.resetShortToken,
  }
);

или плагины

const users = await plugins.run('sendResetPwd.find', {
  usersService, params: { { query: identifyUser }, provider }
});
const user4 = await plugins.run('sendResetPwd.patch', {
  usersService, id: user3[usersServiceIdName], data: {
    resetExpires: user2.resetExpires,
    resetToken: user2.resetToken,
    resetShortToken: user2.resetShortToken,
  },
});

Но то, что происходит за кулисами в плагине, совсем другое.

Мы должны создать абстракцию, если мы часто будем зависеть от чего-то вроде этого. Так мы перешли к использованию плагинов.

надстройка надстройки

Мы опубликовали нашу инфраструктуру плагинов как перья-плюс / плагин-скаффолдинг. Его плагины характеризуются:

Массивы

Плагин - это массив асинхронных функций, которые выполняются последовательно, когда плагин запускается. Уменьшенный результат возвращается как значение плагина, очень похоже на то, как работает Javascript Array.reducemethod.

// Plugin function
async (accumulator, data, pluginsContext, pluginContext) =>
  accumulator === undefined ? 1: accumulator + 1;

порядок

Когда вы регистрируете новый плагин для определенного триггера, вы указываете, должен ли он запускаться до или после существующих плагинов для этого триггера, или он должен их заменить.

plugins.register({
  name: 'sendResetPwd.find',
  desc: 'sendResetPwd.find - default plugin',
  version: '1.0.0',
  trigger: 'sendResetPwd.find',
  position: 'clear', // or 'before', 'after'
  run: async (accumulator, { usersService, params}, pluginsContext,
   pluginContext) => await usersService.find(params),
});

Поэтому репо может регистрировать плагины по умолчанию для триггера, и пользователь может добавлять дополнительные плагины до или после плагина по умолчанию. Пользователь также может заменить значение по умолчанию.

Черепахи полностью вниз

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

Настройка и разборка

Каждый плагин может иметь функцию, которая запускается до того, как плагины будут готовы к использованию. Допустим, ваш плагин добавляет новую команду. При настройке плагина могут быть добавлены значения по умолчанию к параметрам репозитория для этой новой возможности. Затем пользователь отменяет только те, которые ему нужны.

plugins.register({
  trigger: 'customCommand',
  setup: async (pluginsContext, pluginContext) =>
    merge(pluginsContext.options, { /* new default options */ }),
  teardown: async (pluginsContext, pluginContext) => {},
  run: async (...) {...),
});

Точно так же есть функция разборки.

Контекст

Все плагины имеют общий контекст для общения между собой. Этот контекст обычно содержит параметры родительского репо и доступ ко всем плагинам.

plugins.register({
  trigger: 'customCommand',
  setup: async (pluginsContext, pluginContext) =>
    // Add custom props to repo's options
    merge(pluginsContext.options, { bar: 'baz }),
  run: async (accumulator, data, pluginsContext, pluginContext) => {
    // This plugin calls another
    const plugins = pluginsContext.plugins;
    let users = await plugins('faz', { ... });
    // Set a flag which only other plugins for this trigger can see
    pluginContext.foo = true;
  },
});

Инициализация плагина

Инициализировать плагины очень просто. Здесь пользовательские плагины передаются в userOptions.plugins.

const Plugins = require('@feathers-plus/plugin-scaffolding');
const pluginsDefault = require('./plugins-default');
const defaultOptions: { ... }; // repos's default options
function constructor(userOptions) { // user's options
  // Load plugins. They may add additional default options.
  const pluginsContext = { options: defaultOptions };
  const plugins = new Plugins(pluginsContext);
  plugins.register(pluginsDefault);

  if (userOptions.plugins) {
    plugins.register(userOptions.plugins);
  }

  (async function() {
    await plugins.setup();
  }());

  // Get final options
  options = pluginsContext.options =  Object.assign(
    defaultOptions, userOptions, { plugins }
  );
};

Диспетчеризация команд

Диспетчеризация команд репозитория теперь может автоматически отправлять новые, настроенные команды, избегая проблем, которые были у нас с подходом, основанным на параметрах.

const trigger = data.action;
if (!plugins.has(trigger)) { // Check plugin exists.
  return Promise.reject(
    new errors.BadRequest(`Action '${trigger}' is invalid.`,
      { errors: { $className: 'badParams' } }
    )
  );
}
try {
  return await plugins.run(trigger, data);
} catch (err) {
  return await plugins.run('catchError', err);
}

… То, что происходит за кулисами в плагине, очень отличается.

Возникающие преимущества

Плагины предоставили несколько неожиданных преимуществ.

Меньше вариантов

Ранее a-l-m имел параметр уведомления, значением которого была функция, вызываемая всякий раз, когда должно было быть отправлено уведомление. a-l-m теперь просто запускает подключаемый модуль notifier.

Это показывает, что плагины могут заменять некоторые типы опций.

Точки вставки кода

Мы можем предсказать некоторые места, где пользователи могут захотеть добавить код. Даже если нам самим нечего там делать, мы все равно можем вызвать плагин no-op. Пользователь может переопределить этот плагин для выполнения своего кода.

run: async (accumulator, data, pluginsContext, pluginContext)
  => accumulator || data

Экосистема плагинов

Пользователи могут публиковать свои плагины для использования другими. Это может быть особенно полезно для новых команд.

Мы рассматриваем возможность поощрения этого, добавив папку plugin-extract в a-l-m или опубликовав репозиторий authentication-local-management-architecture. Однако выполнение любого из этих действий может означать, что мы несем ответственность за сохранение этих взносов.

Когда вилка встречается с розеткой

a-l-m вызывает notifier всякий раз, когда пользователь может захотеть отправить push-уведомление. Этот вызов выполняется запуском плагина notifier:

await plugins.run('notifier', {
  type: 'sendResetPwd', // Type of notification.
  sanitizedUser: user3, // Contains all info for push.
  notifierOptions,      // Customization options.
});

Необработанный плагин по умолчанию не работает:

run: async (accum, { type, sanitizedUser, notifierOptions },
    { options }, pluginContext) => return sanitizedUser

Набор тестов a-l-m подтверждает, что notifier вызывается всякий раз, когда это необходимо путем переопределения подключаемого модуля notifier:

// Setup test that 'sendResetPwd' calls the notifier
beforeEach(async () => {
  stack = [];
  app.configure(authLocalMgnt({
    longTokenLen: 15,
    shortTokenLen: 6,
    shortTokenDigits: true,
    plugins: [{
      trigger: 'notifier',
      position: 'clear',
      run: async (accum, { type, sanitizedUser, notifierOptions },
          { options }, pluginContext) => {
        // Cache notifier's input and output
        stack.push({
          args: clone([type, sanitizedUser, notifierOptions]),
          result: clone(sanitizedUser),
        });
        return sanitizedUser;
      },
    }],
  }));
});

и проверка параметров, с которыми был вызван уведомитель:

// Test notifier is called
it('Notifier is called', async function () {
  const result = await authLocalMgntService.create({
    action: 'sendResetPwd',
    value: { email: 'b' },
    notifierOptions: { transport: 'sms' }
  });
  // ...
  const actual= stack[0].args;
  assert.deepEqual(actual, expected);
});

Тест подтверждает, что плагин notifier был заменен.

Плюсы и минусы наших плагинов

Плагины удовлетворяют ключевые потребности. Теперь пользователи могут:

  • Добавляйте новые команды для совершенно новых сервисов, таких как отправка SMS-сообщения при обнаружении входа с нового устройства, путем создания нового плагина.
  • Эти новые команды автоматически вызывают свою службу из-за характера новой маршрутизации.
  • Заменить всю существующую службу, переопределив плагин службы.
  • Отключите существующую службу, заблокировав ее службу.
  • Запускайте специализированный код до и / или после каждого обслуживания.
  • Измените некоторые критические строки в сервисе.
  • Запустите специализированный код до и / или после этих критических строк.
  • Внедрить новый код в службу в точках вставки кода.

Плюсы

  • Результирующее репо обладает большой гибкостью, обусловленной не набором параметров, а возможностью изменять или добавлять код.
  • Добавление новой или изменение существующей службы приводит к достаточно чистому коду.

Минусы

  • Вы получаете доступ ко всем деталям службы, когда вы меняете плагин, вызываемый в этой службе. Альтернатива, возможно, худшая - это форк проекта.

  • Код сложнее понять, потому что он вызывает плагины.
  • Вы должны определить, какие функции плагина будут запускаться (по умолчанию и пользовательские), прежде чем вы узнаете, что делает код.

В заключение

Эта статья является частью серии статей о Управление пользователями Feathers. В следующей статье мы обсудим, как настроить a-l-m.

Итак, мы еще не закончили. Подпишитесь на публикацию The Feathers Flightpath, чтобы быть в курсе будущих статей.

Как всегда, не стесняйтесь присоединяться к Feathers Slack, чтобы присоединиться к обсуждению или просто подкрасться.

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