Внешние микросервисы. Отличная идея? Табу? Ересь? Невозможно? Вероятно, любой из них или все, в зависимости от контекста. Сама идея не нова. Моя команда была заражена этим из-за видео Spotify, в котором объясняется организация их команды, а также из-за разговора на конференции о решении Zalando Mosaic (https://www.mosaic9.org/). Конечно, если такие технологические гиганты этим занимаются, это хорошая идея.

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

Мотивация

Команда была частью Gogo, ведущего бортового провайдера Интернета и развлечений. В основном мы работали над Gogo Sphere, B2B и внутренним порталом компании, который мы любим называть GGS из-за нашей необъяснимой привязанности к трехбуквенным аббревиатурам (распространенная внутренняя шутка состоит в том, что все в компании отказались придумывать хорошие имена после называя саму компанию Gogo). GGS - это то, что мы называем мульти-SPA, одностраничным приложением, которое содержит то, что пользователь логически воспринимает как несколько независимых приложений, созданных с помощью React. Представьте себе приложения Google или Office 365 с их небольшим меню, которое позволяет переключаться между электронной почтой, календарем, документами и другими приложениями, но все приложения являются частью одного SPA, поэтому переключение происходит намного быстрее и проще для конечного пользователя.

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

Однако Monorepo оказался полезным. Что может не понравиться при работе с одним репо, импорте из файловой системы? Мы разделили приложения на каталоги верхнего уровня в репо. Управление пользователями и авторизация были разделены в отдельное приложение под названием Admin, одно из немногих, не имевшее трехбуквенного сокращения. Наше разделение интересов было хорошим, без тесной связи… по крайней мере, мы так думали. Компоновка монорепозитория в конце срока службы выглядела примерно так:

Стрелки представляют импорт модулей или, по крайней мере, то, как мы думали, что импортировали.

Реальная ситуация была примерно такой:

Это было плохо, но это было не самое страшное из наших бед. Что действительно начало беспокоить нас, так это то, как у нас было единое развертывание для всего этого. Во-первых, это означало, что у нас был единый пакет, и пользователи должны были загрузить его все, прежде чем даже войти в систему, а это означает, что весь код и зависимости даже для приложений, к которым у них не было доступа. А если серьезно, то у нас были реальные проблемы, когда нужно было срочно исправить ошибку в одном приложении, но мы не могли выполнить развертывание в производственной среде, потому что в другом приложении была известная ошибка. Мы могли бы исправить это с помощью git reset и rebases, но это было неприятно и неудобно, и разработчикам часто приходилось переделывать разрешение конфликтов слияния, если они не были осторожны. Что-то должно было быть сделано.

Первым шагом, который мы сделали, было разделение зверя на отдельные репозитории, но существовала некоторая неуверенность в том, как это будет собираться и развертываться. Обычно наши проекты строятся в Jenkins, и каждая фиксация необходима. Результатом сборки является пакет (не пакет NPM, обычно RPM для экземпляров EC2 или tarball для сегментов S3), и пакет подбирается конвейером Spinnaker, который запускает набор заданий Jenkins для развертывания пакета в среда разработки. Затем мы можем вручную продвигать один и тот же пакет без другого процесса сборки до стадии подготовки и производства. Каким образом отдельные репозитории будут создавать развертывание в этой настройке? В конечном итоге мы решили публиковать каждое отдельное репо как пакет NPM в частном реестре NPM, который был любезно создан нашей замечательной командой DevOps. Это будет означать, что сборка и публикация будут происходить в Jenkins, а отдельные репозитории будут отделены от Spinnaker и любого вида развертывания. Основное репо будет содержать глобальный код, который будет импортировать пакеты приложений, создавать окончательный пакет и развертывать его через Spinnaker. Окончательный результат выглядел примерно так:

Важным решением, которое нам пришлось принять здесь, было то, каким будет главный файл NPM-пакета каждого репо. Для общих репозиториев это было простое решение: файл index.js, который просто реэкспортирует все компоненты, объекты или функции, которые мы хотели сделать доступными для приложений, как именованный экспорт. Для приложений логичной идеей был бы компонент верхнего уровня приложения. Однако в приложениях не было компонента верхнего уровня. Они были просто набором страниц, одна из которых будет целевой страницей для приложения. Хорошо, тогда мы могли бы экспортировать маршрут или группу маршрутов? Не так быстро. response-router 2 ожидал, что все маршруты будут дочерними по отношению к компоненту Router, без исключений и компонентов, вложенных между маршрутами. В конце концов, мы решили, что каждое приложение должно иметь файл manifest.js в качестве основного файла, который будет просто экспортировать объект, содержащий сведения о приложении, такие как имя, значок, флаг функции и, что наиболее важно, массив страниц. в приложении и разрешения, необходимые для каждой страницы. Введение такого объекта может оказаться очень полезным в дальнейшем.

Это решило одну из наших проблем. Больше нет тесной связи между приложениями. Весь общий код был перемещен в общие репозитории, которые были импортированы приложениями, но импорт модуля из другого приложения был категорическим запретом. Однако это не решило наших проблем с единым пакетом и единичным развертыванием. Похоже, что отменить коммиты для исправлений будет проще с несколькими репозиториями, но часто это оказывалось еще сложнее, поскольку некоторые изменения требовали коммитов в нескольких репозиториях. Со временем проблема усугубилась, потому что наши сквозные тесты на основе Selenium начали занимать часы по мере роста проекта. Когда ошибка была обнаружена, разработчики должны были исправить ее, и QE пришлось бы заново запустить все тесты с самого начала. Иногда это задерживало выпуск продукции на несколько дней. Все это время приложение могло ждать срочного исправления.

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

Идея

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

  • Сможете ли вы создать отдельный блок с помощью Webpack самостоятельно и заставить его работать при подключении к проекту?
  • Очистка кеша. У нового чанка должен быть новый хэш. Как обновить имена файлов в тегах скриптов и ссылок и во время выполнения Webpack?
  • Очевидно, нам нужно обновить некоторый код, сгенерированный сборкой основного репо, когда мы создаем отдельное репо. Как это будет работать? Наше строгое правило - одно репо, одна сборка, один конвейер развертывания. Задание сборки или развертывания одного репо не может касаться артефактов сборки другого репо. Развертывание одного репо не может даже коснуться цели развертывания (экземпляра или корзины) другого репо.

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

Решение

Примечание. Эта часть статьи основана на ранних версиях Webpack 4 и Babel 7. Описанная работа была проделана более чем за год до публикации статьи. Хотя 4 и 7, соответственно, все еще являются текущими основными версиями Webpack и Babel на момент публикации, имейте в виду, что некоторые вещи могли измениться.

Первым шагом было добавление разделения кода. Обычно с Webpack 4 это оставляется на усмотрение самых разумных настроек Webpack с нулевой конфигурацией и / или разделяется компонентами React с использованием библиотеки, такой как react-loadable. Мы решили разделить приложения на компоненты с использованием реактивных загружаемых и общих пакетов, определив группы кеша в конфигурации Webpack. Однако эта идея серьезно противоречила тому, как наши приложения были интегрированы в основной портал. Основному порталу нужна была информация о приложении, чтобы проверить, разрешено ли пользователю просматривать приложение и получать к нему доступ, а также чтобы иметь возможность отображать значок и имя приложения на целевой странице и в меню переключателя приложений. Поскольку эта информация находится в файле manifest.js в пакете приложения, все фрагменты кода приложения должны быть загружены, как только пользователь входит в систему, даже для приложений, которые недоступны пользователю. Пришлось провести рефакторинг. Манифесты приложений были перемещены в основное хранилище с долгосрочным планом по перемещению содержащейся в них информации в API. Пакеты приложений были изменены для экспорта компонента. Логика маршрутизации была перенесена на shared-components, что теперь стало возможным, потому что мы тем временем перешли на react-router 4. В манифестах приложений будет указываться основной компонент для каждого приложения, импортированный из пакета приложения и завернутый в response-loadable. В конце концов, манифест приложения будет выглядеть примерно так:

Отлично, но как используется вся эта информация? Манифесты приложений реэкспортируются как массив манифестов в файл с именем app.js. Он импортируется компонентом верхнего уровня GGS, называемым Routes.jsx, что является возвратом тех времен, когда маршруты должны были быть верхними уровнями. Routes.jsx отображает маршрутизатор со статическими маршрутами и динамическими маршрутами приложений, созданными из массива манифестов. Каждый маршрут приложения отображает manifest.component как опору рендеринга, а объект manifest пересылается ей как опора. manifest.component всегда заключен в withLoadable, нашу собственную абстракцию response-loadable, которая инкапсулирует параметры, одинаковые для всех приложений. С другой стороны, массив манифестов также пересылается в качестве опоры на целевую страницу и в меню переключателя приложений, которые используют одну и ту же логику, чтобы определить, какие приложения доступны пользователю, и отобразить для них значки.

Присутствовали разделение кода и отложенная загрузка, но как насчет остального? Можем ли мы перестроить один кусок, поменять его местами и выйти в лунную походку, как будто ничего не произошло? Создание блока, содержащего те же модули, что и другой, не должно быть слишком сложным. Шаги, чтобы попытаться это сделать, были ясны: добавить Webpack в репо, добавить группу кеша в конфигурацию Webpack, которая отражает способ создания чанка в основном репо. Однако есть предостережения, и чтобы изучить их подробно, мы должны погрузиться в пакеты Webpack, особенно в код времени выполнения Webpack. Вы также можете сделать это дома, просто открыв файл фрагмента Webpack, желательно не минифицированный. Даже глядя на минифицированные файлы, мы видим, что не все фрагменты одинаковы. Один из них начинается с (function (mod), очевидной самоисполняющейся функции, в то время как остальные нажимают что-то под названием window.webpackJsonp. Выделяется то, что мы использовали для вызова «основного» блока, тот, который содержит код времени выполнения Webpack. Этот код позволяет всем вашим модулям, которые, возможно, написаны в разных шаблонах модулей, требовать друг друга в браузере. Проще говоря, Webpack достигает этого, загружая все модули в одну карту, которая отображает модуль Идентификатор модуля и замена всех ваших требований и импорта вызовом __webpack_require __ (), функцией, которая загружает модуль из этой карты на основе идентификатора.

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

Проще, чем кажется, группа кода приложения отфильтровывает весь код из node_modules, следя за тем, чтобы мы включали только наш собственный код, а группа dependencies делает обратное, включая все из node_modules, которые указаны в записи зависимостей пакета и не начинаются с gogo -phere- (мы не хотим, чтобы код формировался из других репозиториев, они могут создавать свой собственный код). Код немного сложнее для учета среды, связанной с символическими ссылками. Эта настройка извлечет код репо и прямые зависимости, оставив все остальное в основном блоке, который можно отбросить. У общих репозиториев не может быть прямых зависимостей, поэтому первая группа кеша будет только у единственной.

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

Взгляните на любой блок с настройками Webpack по умолчанию. Вы увидите, что по умолчанию Webpack назначает числовые идентификаторы блоков и модулей с автоинкрементом. Он заботится о том, чтобы эти идентификаторы были уникальными, поэтому, если ваш основной фрагмент использует все числа от 0 до 25, следующий фрагмент начнет использовать идентификаторы с 26. Когда мы создаем другое репо, фрагменты и модули будут другими, поэтому идентификаторы заканчиваются. быть другим. В итоге Webpack загружает неправильный фрагмент и неправильный модуль.

К счастью, в Webpack есть плагины для изменения этого поведения, а именно NamedChunksPlugin и NamedModulesPlugin. Используя их, вы можете гарантировать, что ваши чанки и модули будут иметь одинаковые имена независимо от того, как они построены. К сожалению, NamedModulesPlugin не совсем справляется с задачей. Мы создаем в разных средах, и, поскольку NamedModulesPlugin использует путь к модулю, он все равно будет другим. Посмотрев на альтернативы, мы создали наш собственный плагин: https://github.com/gogoair/custom-module-ids-webpack-plugin

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

Хорошо, теперь, когда у нас есть согласованные идентификаторы, обмен работает, и мы почти готовы, но есть еще одна проблема. Когда мы меняем местами, мы переименовываем новый файл, чтобы он имел то же имя, что и старый, но на самом деле мы не хотим переименовывать, чтобы мы могли очистить кеш в браузерах. Как, черт возьми, мы это делаем? Если вы посмотрите на «основной» блок любого приложения, созданного с помощью Wepback, вы увидите карту имен блоков, примерно такую:

{shared_app1:"98de488cc0787aedfa66",app2:"cc94f88c6a37551a79ec",shared_app3:"fe8d8e7bf89fedded691",app3:"135ec09920f5653d8eb0","app4.vendor":"91c17143ec2dd93049a0",app4:"91c17143ec2dd93049a0","app5.vendor":"94fcc81df13b6f9c679c",app5:"94fcc81df13b6f9c679c",app6:"f9d838193f21ebb7c229",app7:"950b8acbddf07e8dfedf",app8:"ea404d4bf35d53263996",app1:"a9c3cbd4576e93da65a6","app9.vendor":"c91b73d7fae738301652",app9:"c91b73d7fae738301652",app10:"07a0efe9a84234b42ab8","app11.vendor":"d8d7dd5ea540419850d8",app11:"d8d7dd5ea540419850d8","app12.vendor":"7cd80ccb3644aa4d3b6c",app12:"7cd80ccb3644aa4d3b6c",app13:"9c9df603074a1aa0c6d9","app13.vendor":"9c9df603074a1aa0c6d9","app14":"4a0ade0094b3c1998070"}

Эта небольшая карта сопоставляет имена блоков с хэшами, так что среда выполнения Webpack может создать имя файла, чтобы сделать запрос, когда необходимо загрузить блок. Это то, что нам нужно обновить, и теоретически код для замены одного хэша на другой на карте, подобной этой, был бы довольно простым, но обновление фрагмента при перестроении другого фрагмента звучит как кошмар, особенно если мы перестраиваем фрагмент, содержащий эта карта, но должны поддерживать старые хеши. Https://github.com/almothafar/webpack-inline-manifest-plugin спешит на помощь. Этот маленький парень, страдающий от множества плагинов с похожими именами, извлекает код манифеста Webpack в виде отдельных фрагментов и позволяет вам встроить его в свой html-файл с помощью HtmlWebpackPlugin. Используя этот плагин, мы можем обновлять html-файл при перестроении чанка, и в будущем этот файл будет меняться крайне редко.

Создание нового html-файла из существующего - это не то, что может сделать Webpack, поэтому другой плагин спасает положение. На этот раз это https://github.com/kimjoar/generate-asset-webpack-plugin. Мы превратили нашу конфигурацию Webpack в обещание получить текущий index.html, а затем использовали плагин следующим образом:

Это сработало, но, к сожалению, мы наткнулись на стену с нашей инфраструктурой развертывания, о чем DevOps предупреждал нас очень давно. Без прямого доступа к сегментам AWS S3, в которые были загружены наши файлы, у нас не было возможности заменить старый файл index.html новым. Нам пришлось отказаться от этой идеи, и вместо этого мы разместили index.html в собственном сегменте S3 за пределами конвейера развертывания и написали AWS Lambda, которая обновляла файл каждый раз, когда одно из репозиториев завершало развертывание.

Результат

Все, мечта реальна. Мы преобразовали наше развертывание примерно так:

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

Стоит ли попробовать это дома?

На самом деле зависит от проекта и окружающей среды. Для наших нужд - единая команда фронтенд, разрабатывающая и обслуживающая больше приложений, чем ее членов, все приложения являются частью одной и той же платформы с общими зависимостями, и желание продолжать использовать последние версии последних версий - это было идеальное решение, которое стоит того. усилие. В большинстве случаев я предлагаю полностью разделить приложения, каждое со своим набором зависимостей, или объединить наше решение для развертывания с монорепозиторием, что наше одно репо = ›одна сборка =› одна инфраструктура развертывания не может поддерживать.