Micro Frontends

Слабая связанность

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

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

Рассмотрим возможность прямой связи. Для примера возьмем следующую реализацию:

// микро-фронтенд A
window.callMifeA = msg => {
  //обработка сообщения;
};

// микро-фронтенд B
window.callMifeA({
  type: 'show_dialog',
  name: 'close_file'
});

Поначалу все выглядит просто: мы установили связь от микро-фронтенда B к A. Формат сообщения позволяет обрабатывать различные сценарии. Однако если изменить имя микро-фронтенда A (например, на mifeA), то код сломается.

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

На диаграмме ниже изображена проблема разъединенной связи.

Связь между разъединенными микро-фронтендами.

Единственное преимущество этого способа заключается в том, что мы точно уверены в установке связи с микро-фронтендом A (по крайней мере, в случае вызова рабочей функции). Однако, как убедиться, что callMifeA не был изменен другим микро-фронтендом?

Попробуем отделить его с помощью центральной оболочки приложения:

// оболочка приложения
const mife = [];
window.registerMife = (name, call) => {
  mife.push({
    name,
    call,
  });
};

window.callMife = (target, msg) => {
  mife.filter(m => m.name === target).forEach(m => m.call(msg));
};

// микро-фронтенд A
window.registerMife('A', msg => {
  //обработка сообщения;
});

// микро-фронтенд B
window.callMife('A', {
  type: 'show_dialog',
  name: 'close_file'
});

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

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

Развязка через пул обработчиков.

До этого момента соглашение об именах не соблюдалось. Такие имена микро-фронтендов, как A, B и т. д. — не самые лучшие варианты.

Соглашение об именах

Есть несколько способов структурировать имена в таком приложении. Можно разделить их на три категории:

  • Предназначенные для их домена (например, машины).
  • Согласно их предложению (например, рекомендации).
  • Предложение домена (например, машинные рекомендации).

В некоторых случаях в больших системах можно использовать старую иерархию пространства имен (например, world.europe.germany.munich). Однако чаще всего она быстро теряет последовательность.

Самая важная часть соглашения об именах — это его соблюдение. Непоследовательная схема именования намного хуже, чем просто плохая схема.

Несмотря на существование таких инструментов, как настраиваемые правила линтинга, для обеспечения согласованности схемы имен, на практике поможет только проверка кода и централизованное управление. Правила линтинга можно использовать лишь для проверки наличия определенных шаблонов (например, использование такого регулярного выражения, как /^[a-z]+(\.[a-z]+)*$/). Сопоставить отдельные части с реальными именами — гораздо более сложная задача.

Наименование всегда будет одной из нерешенных проблем.

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

Обмен событиями

Соглашения об именах также важны для связи с точки зрения событий.

Использование пользовательских событий для развязки.

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

// микро-фронтенд A
window.addEventListener('mife-a', e => {
  const { msg } = e.detail;
  //обработка сообщения;
});

// микро-фронтенд B
window.dispatchEvent(new CustomEvent('mife-a', {
  detail: {
    type: 'show_dialog',
    name: 'close_file'
  }
}));

Однако у этого подхода есть несколько явных недостатков:

  • Какое событие повторно вызывает микро-фронтенд А?
  • Как напечатать это правильно?
  • Можно ли в данном случае поддерживать и другие механизмы, такие как fan-out, direct и т. д.?
  • Отложенные сообщения и т.д.

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

const handlers = {};

window.publish = (topic, message) => {
  window.dispatchEvent(new CustomEvent('pubsub', {
    detail: { topic, message },
  }));
};

window.subscribe = (topic, handler) => {
  const topicHandlers = handlers[topic] || [];
  topicHandlers.push(handler);
  handlers[topic] = topicHandlers;
};

window.unsubscribe = (topic, handler) => {
  const topicHandlers = handlers[topic] || [];
  const index = topicHandlers.indexOf(handler);
  index >= 0 && topicHandlers.splice(index, 1);
};

window.addEventListener('pubsub', ev => {
  const { topic, message } = ev.detail;
  const topicHandlers = handlers[topic] || [];
  topicHandlers.forEach(handler => handler(message));
});

Этот код помещается в оболочку приложения. Теперь его могут использовать различные микро-фронтенды:

// микро-фронтенд A
window.subscribe('mife-a', msg => {
  //обработка сообщения;
});

// микро-фронтенд B
window.publish('mife-a', {
  type: 'show_dialog',
  name: 'close_file'
});

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

Развязка через event bus, предоставляемый оболочкой приложения.

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

Обмен данными

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

Есть несколько способов решения этой проблемы:

  • одно местоположение, несколько владельцев: у каждого есть доступ к чтению и редактированию;
  • одно местоположение, один владелец: у каждого есть доступ к чтению, но только владелец может редактировать;
  • один владелец, остальные должны получить копию непосредственно от него;
  • одна ссылка, по которой можно изменить оригинал.

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

Начнем с первого варианта:

const data = {};
window.getData = name => data[name];
window.setData = (name, value) => (data[name] = value);

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

Ниже показаны API чтения и записи, прикрепленные к DOM.

Чтение и запись общих данных через DOM.

Добавление событий изменения влияет только на функцию setData:

window.setData = (name, current) => {
  const previous = data[name];
  data[name] = current;
  window.dispatchEvent(new CustomEvent('changed-data', {
    detail: {
      name,
      previous,
      current,
    },
  }));
};

Несмотря на некоторые преимущества нескольких «владельцев», при использовании этого способа возникает множество проблем и путаницы. В качестве альтернативы можно применить поддержку только одного владельца:

const data = {};
window.getData = name => {
  const item = data[name];
  return item && item.value;
}
window.setData = (owner, name, value) => {
  const previous = data[name];

  if (!previous || previous.owner === owner) {
    data[name] = {
      owner,
      name,
      value,
    };

    window.dispatchEvent(new CustomEvent('changed-data', {
      detail: {
        name,
        previous: previous && previous.value,
        current: value,
      },
    }));
  }
};

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

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

Один из способов обойти их — применить прокси для всех запросов.

Централизованный API

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

Самое простое решение — рассматривать каждый микро-фронтенд как плагин, который связывается с оболочкой приложения через собственный прокси-сервер.

Начальная установка может выглядеть следующим образом:

// микро-фронтенд A
document.currentScript.setup = api => {
  api.setData('secret', 42);
};

// микро-фронтенд B
document.currentScript.setup = api => {
  const value = api.getData('secret'); // 42
};

Каждый микро-фронтенд можно представить набором файлов (в основном JS), собранных путем ссылки на один входной сценарий.

Используя список доступных микро-фронтендов (например, хранящийся в переменной microfrontends), можно загрузить их все и передать в индивидуально созданный прокси API.

const data = {};
const getDataGlobal = name => {
  const item = data[name];
  return item && item.value;
}
const setDataGlobal = (owner, name, value) => {
  const previous = data[name];

  if (!previous || previous.owner === owner) {
    data[name] = {
      owner,
      name,
      value,
    };

    window.dispatchEvent(new CustomEvent('changed-data', {
      detail: {
        name,
        previous: previous && previous.value,
        current: value,
      },
    }));
  }
};

microfrontends.forEach(mife => {
  const api = {
    getData: getDataGlobal,
    setData(name, value) {
      setDataGlobal(mife.name, name, value);
    },
  };

  const script = document.createElement('script');
  script.src = mife.url;
  script.onload = () => {
    script.setup(api);
  };
  document.body.appendChild(script);
});

Обратите внимание, что для этой технологии необходим currentScript, поэтому IE 11 или более ранние версии потребуют особого внимания.

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

API для передачи общих данных распространяются после глобальной регистрации.

Приятная особенность этого подхода заключается в том, что объект api можно полностью напечатать. Кроме того, он допускает постепенное улучшение, поскольку объявляет связующий слой (функция setup).

Функции активации

В мире микро-фронтендов постоянно возникают вопросы, связанные с очередями и рендерингом. Наиболее естественный способ реализации — введение простой компонентной модели.

Например, ввести пути и отображение путей:

const checkActive = location => location.pathname.startsWith('/sample');
window.registerApplication(checkActive, {
  // здесь находится жизненный цикл
});

Методы жизненного цикла теперь полностью зависят от компонентной модели. В простейшем подходе используются loadmount и unmount.

Проверка выполняется из общей среды выполнения, которую можно назвать «Activator», поскольку она будет определять активные элементы.

Активатор времени выполнения для проверки активности.

Внешний вид этого подхода в значительной степени зависит от вас. Например, можно предоставить элемент нижележащего компонента, приводящий к иерархии активатора. Предоставление URL для каждого компонента и возможность соединять их вместе — достаточно эффективные методы.

Агрегация компонентов

Другой способ — использовать агрегацию компонентов. У этого подхода есть несколько преимуществ, однако он также требует общего слоя для медиации.

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

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

Рассмотрим следующим код:

@customElement('product-page')
export class ProductPage extends LitElement {
  render() {
    return html`
      <div>
        <h1>My Product Page</h1>
        <!-- ... -->
        <component-reference name="recommendation"></component-reference>
        <!-- ... -->
        <component-reference name="catalogue"></component-reference>
      </div>
    `;
  }
}

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

Мы не будем знать, откуда поступают эти компоненты. Однако с помощью компонента-агрегатора (component-reference) можно создать ссылку.

Пример реализации подобного агрегатора:

const componentReferences = {};

@customElement('component-reference')
export class ComponentReference extends LitElement {
  @property() name = '';

  render() {
    const refs = componentReferences[this.name] || [];
    const content = refs.map(r => `<${r}></${r}>`).join('');
    return html([content]);
  }
}

Также нужно добавить возможности регистрации:

window.registerComponent = (name, component) => {
  const refs = componentReference[name] || [];
  componentReference[name] = [...refs, component];
};

В этом примере не были упомянуты многие вопросы, такие как избежание столкновений, переадресация атрибутов/props, повышение устойчивости и надежности, например, для реактивности при изменении ссылок и т. д.

Список отсутствующих функций можно продолжать бесконечно. Приведенный выше код отображает лишь основную идею.

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

Использование компонента-агрегатора для совместного использования компонентов.

Использование выглядит довольно просто:

@customElement('super-cool-recommender')
export class SuperCoolRecommender extends LitElement {
  render() {
    return html`<p>Recommender!</p>`;
  }
}

window.registerComponent('recommendation', 'super-cool-recommender');

Читайте также:ъ


Перевод статьи Florian Rappl: Communication Between Micro Frontends