Что такое функтор? Функциональное программирование

2 года назад·5 мин. на чтение

В этой статье на простых и доступных примерах рассмотрим одну из концепций функционального программирования - Функтор.

Это серия статей о функциональном программировании:
  1. Парадигмы программирования
  2. Композиция
  3. Функторы (рассматривается в этой статье)
  4. Каррирование
  5. Чистые функции
  6. Функции первого класса

Что такое функтор?

Функтор (functor) это:
  • обертка над значением,
  • предоставляет интерфейс для преобразование (map),
  • подчиняется законам функтора (поговорим о них позже).

Примеры функторов

  • Массив (Array),
  • Промис (Promise).

Почему массив - функтор?

Вспомним определение функтора:
  • обертка над списком значений,
  • предоставляет интерфейс для преобразования - метод map,
  • подчиняется законам функтора.
[1, 2, 3]      // обернутое значение
  .map(        // интерфейс для преобразования значения
    x => x * 2
  )

Почему промис - функтор?

Промис это:
  • обертка над любым значением из JavaSctipt типов,
  • предоставляет интерфейс для преобразования - метод then,
  • подчиняется законам функтора.
const promise = new Promise((resolve, reject) => {
  resolve(
    { data: "value" } // обернутое значение, в данном случае объект
  )
});
 
promise
  .then(              // интерфейс для преобразования значения
    response => console.log(response)
  );

Что объединяет массив (или промис) и функтор?

Функтор - это паттерн проектирования, а Array и Promise - типы данных, которые основаны на этом паттерне.

Почему мы говорим, что массив и промис - функторы?

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

Где использовать функторы?

Немного поговорив о функторах и связав их с нашим повседневным использованием, было бы разумно рассмотреть их подробнее. Чтобы лучше понять идею функтора, создадим свои собственные функторы. Для начала рассмотрим такую задачу. Предположим, есть следующий кусочек данных.
{
  products: [
    {
      name: "All about JavaScript",
      type: "book",
      price: 22,
      discount: 20
    }
  ]
}

Постановка задачи

Найти финальную цену первого товара с учетом скидки. Если по какой-либо причине будут переданы неправильные данные, вывести строку "No data".

Шаги алгоритма

  1. Найти первый продукт со скидкой,
  2. Применить скидку,
  3. Продолжать проверку данных на валидность. Если данные не валидны - вернуть "No data".

Традиционное решение

const isProductWithDiscount = product => {
  return !isNaN(product.discount)
}
const findFirstDiscounted = products => {
  products.find(isProductWithDiscount)
}
 
const calcPriceAfterDiscount = product => {
  return product.price - product.discount
}
 
const findFinalPrice = (data, fallbackValue) => {
  if(!data || !data.products) return fallbackValue
 
  const discountedProduct = findFirstDiscounted(data.products)
  if(!discountedProduct) return fallbackValue
 
  return calcPriceAfterDiscount(discountedProduct)
}
 
findFinalPrice(data, "No data")

Комментарии к традиционному решению

Достоинства:
  • Атомарные логические единицы (isProductWithDiscount, findFirstDiscounted и calcPriceAfterDiscount),
  • Логику защищена от невалидных данных.
Что можно улучшить:
  • Cлишком много защитных проверок. (Защитное программирование (Defensive programming) является обязательным в любом отказоустойчивом программном обеспечении. Однако, в нашем коде 50% тела функции findFinalPrice — проверка на валидность данных. Это слишком много).
  • fallbackValue почти везде.

Почему нас волнуют эти улучшения?

Потому что данный код заставляет слишком в него вникать. Это негативно влияет на DX (Developer Experience) - уровень удовлетворенности разработчика от работы с кодом. Проанализируем код, чтобы прийти к лучшему решению. Части, которые мы стремимся улучшить, формируют паттерны (защита (defence) и откат (fallback)). Хорошо то, что эти части на самом деле цельные и атомарные. Мы должны иметь возможность абстрагировать этот паттерн в оболочку, которая могла бы обрабатывать эти крайние случаи вместо нас. Обертка позаботится о крайних случаях, а нам останется позаботиться только о бизнес-логике.

Функтор Maybe

Как мы обсуждали ранее, нам нужна только обертка, которая абстрагируется от обработки данных. Итак, роль функтора Maybe состоит в том, чтобы обернуть наши данные (потенциально невалидные данные) и обработать для нас крайние случаи.

Имплементация функтора Maybe

function Maybe(value) {
  const isNothing = () => {
    return value === null || value === undefined
  }
  
  const map = (fn) => {
    return isNothing() ? Maybe() : Maybe(fn(value))
  }
 
  const getValueOrFallback = {
    return (fallbackValue) => isNothing() ? fallbackValue : value;
  }
 
  return {
    map,
    getValueOrFallback,
  };
}

Пояснения к имплементации

  • isNothing проверяет валидно ли обернутое в функтор Maybe значение
  • map - интерфейс для преобразования обернутого значения, с помощью которого мы применяем функции с бизнес логикой к обернутому значению. map возвращает новое значение в другом экземпляре Maybe. Таким образом, мы можем сделать цепочку вызовов map - .map().map().map....
  • getValueOrFallback возвращает обернутое значение или запасное значение fallbackValue.

Как использовать функтор Maybe?

С валидными данными:
Maybe('Hello')
  .map(x => x.substring(1))
  .getValueOrFallback('fallback') // 'ello'
С невалидными данными:
Maybe(null)
  .map(x => x.substring(1))       // функция не будет запущена
  .getValueOrFallback('fallback') // 'fallback'
Функтор Maybe обработал крайние случаи вместо нас и не запустил функцию с невалидными данными. Нам нужно лишь позаботиться о бизнес логике. Таким образом, мы внедрили улучшение, о котором говорили в традиционном решении. Внедрим это решение в задачу.

Решение задачи с функтором Maybe

const isProductWithDiscount = product => {
  return !isNaN(product.discount)
}
const findFirstDiscounted = products => {
  return products.find(isProductWithDiscount)
}
const calcPriceAfterDiscount = product => {
  return product.price - product.discount
}
 
Maybe(data)
 .map((x) => x.products)
 .map(findFirstDiscounted)
 .map(calcPriceAfterDiscount)
 .getValueOrFallback("No data")

Комментарии к решению с функтором Maybe

Мы смогли улучшить традиционное решение при помощи функтора Maybe:
  • мы не защищаем код сами, вместо нас это делает функтор Maybe,
  • мы указали fallbackValue только один раз.
Как функтор Maybe соответствует определению функтора? Функтор Maybe это:
  1. обертка над любым значением из JavaScript типов,
  2. предоставляет интерфейс для преобразования - метод map,
  3. подчиняется законам функтора.

Законы функторов

Закон идентичности (Identity law)

Если при выполнении операции преобразования, значения в функторе преобразовываются сами на себя, результатом будет немодифицированный функтор.
const m1 = Maybe(value)
const m2 = Maybe(value).map(v => v)
// m1 и m2 эквивалентны

Закон композиции (Composition law)

Если две последовательные операции преобразования выполняются одна за другой с использованием двух функций, результат должен быть таким же, как и при одной операции отображения с одной функцией, что эквивалентно применению первой функции к результату второй.
const m1 = Maybe(value).map(v => f(g(v)))
const m2 = Maybe(value).map(v => g(v)).map(v => f(v))
// m1 и m2 эквивалентны

Зачем использовать функторы?

  • Абстракция над применением функции,
  • Усиление композиции функций,
  • Уменьшение количества защитного кода (как в функторе Maybe),
  • Более чистая структура кода,
  • Переменные более явно указывают на то, что мы ожидаем (что Maybe моделирует значение, которое может присутствовать, а может и не присутствовать).

Что означает Абстракция над применением функции?

То, что мы передаем функцию (т.е. x => x.products) в интерфейс преобразования (т.е. map) обертки (т.е. Maybe), и она знает, как позаботиться о себе (посредством своей внутренней реализации). Нас не интересуют детали реализации оболочки, которые она содержит (детали реализации скрыты), и тем не менее мы знаем, как использовать обертку (Array или Promise), используя их интерфейсы преобразования (map). И это на самом деле крайне важно в мире программирования. Это снижает планку того, как много мы, как программисты, должны понимать, чтобы иметь возможность что-то сделать. Функторы могут быть реализованы на любом языке, поддерживающем функции высшего порядка (а таких в наши дни большинство).

Почему функторы не используются повсеместно?

Просто потому, что мы к ним не привыкли. До .map.then) мы мутировали массивы или перебирали их значения вручную. Но как только мы обнаружили .map, мы начали адаптировать его в качестве нового инструмента преобразования. Я надеюсь, что, поняв ценность функторов, мы начнем чаще внедрять их в наши ежедневные задачи как привычный инструмент. Функтор Maybe - лишь пример функтора. Существует множество функторов, которые выполняет различные задачи. В этой статье мы рассмотрели самый простой из них, чтобы понять саму идею функторов.

Итоги

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

Микрофронтенд и Module Federation

год назад·6 мин. на чтение

Module Federation - это плагин для Webpack. С его помощью можно разбивать приложение на микрофронтенды, подключив их в хост приложение.

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

Низкоуровневые концепции

Различаются два вида модулей - локальные и удаленные модули. Локальные модули — это обычные модули, которые являются частью текущей сборки. Удаленные модули (remote modules) — это модули, которые не являются частью текущей сборки, но загружаются во время выполнения из удаленного контейнера. Загрузка удаленных модулей считается асинхронной операцией. Невозможно использовать удаленный модуль без загрузки его чанка. Операция загрузки чанка обычно является вызовом import(), но также поддерживаются более старые конструкции, такие как require.ensure или require([...])). Контейнер создается с помощью точки входа контейнера, которая предоставляет асинхронный доступ к определенным модулям. Доступ к предоставляемым (expose) моделям разделен на два этапа:
  1. загрузка модуля (асинхронная)
  2. выполнение модуля (синхронная).
Шаг 1 будет выполнен во время загрузки чанка. Шаг 2 будет выполнен во время выполнения модуля, чередующегося с другими (локальными и удаленными) модулями. Таким образом, порядок выполнения не зависит от преобразования модуля из локального в удаленный или наоборот. Возможно вложение контейнеров. Контейнеры могут использовать модули из других контейнеров. Также возможны циклические зависимости между контейнерами.

Высокоуровневые концепции

Каждая сборка действует как контейнер, а также потребляет другие сборки в качестве контейнеров. Таким образом, каждая сборка может получить доступ к любому другому предоставленному модулю, загрузив его из своего контейнера. Общие модули — это модули, которые являются переопределяемыми и предоставляются в качестве переопределений для вложенных контейнеров. Они обычно указывают на один и тот же модуль в каждой сборке, например, на одну и ту же библиотеку. Параметр packageName позволяет задать имя пакета для поиска requiredVersion. Он автоматически выводится для запросов модуля по умолчанию. Установите requiredVersion в false, когда автоматический вывод должен быть отключен.

Основные части

ContainerPlugin (низкий уровень)

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

ContainerReferencePlugin (низкий уровень)

Этот плагин добавляет конкретные ссылки на контейнеры как внешние и позволяет импортировать удаленные модули из этих контейнеров. Он также вызывает API override этих контейнеров для предоставления им переопределений. Локальные переопределения (через __webpack_override__ или override API когда сборка является контейнером) и указанные переопределения предоставляются всем контейнерам, на которые имеются ссылки.

ModuleFederationPlugin (высокий уровень)

ModuleFederationPlugin объединяет ContainerPlugin и ContainerReferencePlugin.

Какие цели преследует Module Federation

  • Должна быть возможность предоставлять и использовать любой тип модуля, поддерживаемый webpack.
  • При загрузке чанков должно загружаться все необходимое параллельно (за один запрос к серверу).
  • Управление от потребителя к контейнеру
    • Переопределение модулей представляет собой однонаправленную операцию.
    • Родственные контейнеры не могут переопределять модули друг друга.
  • Концепция должна быть независимой от среды.
    • Можно использовать в web, Node.js и т. д.
  • Относительный и абсолютный запрос в shared:
    • Всегда будет предоставлен, даже если не используется.
    • Будет разрешаться относительно config.context.
    • Не использует requiredVersion по умолчанию.
  • Запросы модулей в shared:
    • Предоставляются только тогда, когда они используются.
    • Будет соответствовать всем используемым равным запросам модулей в вашей сборке.
    • Предоставит все соответствующие модули.
    • Будет извлекать requiredVersion из package.json в этой позиции в графе.
    • Может предоставлять и использовать несколько различных версий при наличии вложенных node_modules.
  • Запросы модулей с / в shared будут сопоставлять все запросы модулей с этим префиксом.

Примеры использования

Отдельные сборки для каждой страницы

Каждая страница одностраничного приложения предоставляется из контейнерной сборки в отдельной сборке. Оболочка приложения (application shell) также представляет собой отдельную сборку, ссылающуюся на все страницы как на удаленные модули. Таким образом, каждая страница может быть развернута отдельно. Оболочка приложения заново развертывается при обновлении маршрутов или добавлении новых маршрутов. Оболочка приложения определяет часто используемые библиотеки как разделяемые модули (shared), чтобы избежать их дублирования в сборках страниц.

Библиотека компонентов в качестве контейнера

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

Динамические удаленные контейнеры

Интерфейс контейнера поддерживает методы get и init. init — это async метод, который вызывается с одним аргументом: объектом общей области (shared scope). Этот объект используется в качестве общей области в удаленном контейнере и заполняется предоставленными модулями от хоста. Его можно использовать для динамического подключения удаленных контейнеров к контейнеру хоста во время выполнения.
// init.js

(async () => {
  // Инициализирует общую область. Заполняет его предоставленными модулями 
  // из текущего билда и из удаленных билдов
  await __webpack_init_sharing__('default');
  const container = window.someContainer; // или получить контейнер откуда-либо еще
  // Проинициализируйте контейнер, он может предоставлять общие модули
  await container.init(__webpack_share_scopes__.default);
  const module = await container.get('./module');
})();
Контейнер пытается предоставить общие модули, но если общий модуль уже использовался, предупреждение и предоставленный общий модуль будут проигнорированы. Контейнер может по-прежнему использовать его в качестве запасного варианта. Таким образом, вы можете динамически загружать A/B-тест, который предоставляет другую версию общего модуля. Убедитесь, что контейнер загружен, прежде чем пытаться динамически подключить удаленный контейнер. Пример:
// init.js

function loadComponent(scope, module) {
  return async () => {
    // Инициализирует общую область. Заполняет его предоставленными модулями 
    // из текущего билда и из удаленных билдов
    await __webpack_init_sharing__('default');
    const container = window[scope]; // или получить контейнер откуда-либо еще
    // Проинициализируйте контейнер, он может предоставлять общие модули
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

loadComponent('abtests', 'test123');

Динамические удаленные модули на основе промисов

Как правило, удаленные модули настраиваются с использованием URL-адресов, как в этом примере:
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: 'app1@http://localhost:3001/remoteEntry.js',
      },
    }),
  ],
};
Но вы также можете передать промис в этот модуль, который будет зарезолвлен во время выполнения. Вы должны зарезолвить этот промис с помощью любого модуля, который соответствует интерфейсу get/init, описанному выше. Например, если вы хотите передать, какую версию fedarated модуля вы должны использовать, с помощью параметра запроса вы можете сделать что-то вроде следующего:
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: `promise new Promise(resolve => {
      const urlParams = new URLSearchParams(window.location.search)
      const version = urlParams.get('app1VersionParam')
      // Эта часть зависит от того как вы планируете хостить
      // и версионировать ваши federated модули
      const remoteUrlWithVersion = 'http://localhost:3001/' + version + '/remoteEntry.js'
      const script = document.createElement('script')
      script.src = remoteUrlWithVersion
      script.onload = () => {
        // внедренный скрипт загружен и доступен через объект window
        // теперь можно зарезолвить Promise
        const proxy = {
          get: (request) => window.app1.get(request),
          init: (arg) => {
            try {
              return window.app1.init(arg)
            } catch(e) {
              console.log('remote container already initialized')
            }
          }
        }
        resolve(proxy)
      }
      // внедрим этот скрипт с src с версионированным remoteEntry.js
      document.head.appendChild(script);
    })
    `,
      },
      // ...
    }),
  ],
};
Обратите внимание, что при использовании этого API необходимо зарезолвить объект, содержащий API get/init.

Динамический publicPath

Установка publicPath

Можно разрешить хосту задавать publicPath удаленного модуля во время выполнения, предоставляя метод из этого удаленного модуля. Этот подход особенно полезен при подключении независимо развернутых дочерних приложений по подпути домена узла. Сценарий: У вас есть хост приложение, размещенное на https://my-host.com/app/*, и дочернее приложение, размещенное на https://foo-app.com. Дочернее приложение также монтируется на хост-домене, следовательно, ожидается, https://foo-app.com будет доступно через https://my-host.com/app/foo-app, а запросы https://my-host.com/app/foo-app/* перенаправляются https://foo-app.com/* через прокси-сервер. Пример:
// webpack.config.js (удаленный)

module.exports = {
  entry: {
    remote: './public-path',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remote', // это имя должно совпадать с именем точки входа
      exposes: ['./public-path'],
      // ...
    }),
  ],
};
public-path.js (remote)

export function set(value) {
  __webpack_public_path__ = value;
}
// src/index.js (host)

const publicPath = await import('remote/public-path');
publicPath.set('/your-public-path');

//bootstrap app  e.g. import('./bootstrap.js')

Вывод publicPath из скрипта

Можно вывести publicPath из тега script из document.currentScript.src и задать его с переменной __webpack_public_path__ во время выполнения. Пример:
// webpack.config.js (удаленный)

module.exports = {
  entry: {
    remote: './setup-public-path',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remote', // this name needs to match with the entry name
      // ...
    }),
  ],
};
setup-public-path.js (удаленный)

// вычислите publicPath и установите его в  __webpack_public_path__
__webpack_public_path__ = document.currentScript.src + '/../';
Существует также значение 'auto', доступное для output.publicPath, которое автоматически определяет publicPath для вас.