Здравствуйте, меня зовут Дмитрий Карловский, и я.. обожаю MAM. MАМ управляет Агностик Модулями, избавляя меня от львиной доли рутины.
Агностик Модуль, в отличие от традиционного, это не файл с исходником, а директория, внутри которой могут быть исходники на самых разных языках: программная логика на JS/TS, тесты к ней на TS/JS, шейдеры на GLSL, композиция компонент на view.tree, конфиги на json.tree, стили на CSS / CSS.TS, локализация в locale=*.json, картинки и тд, и тп. При желании не сложно прикрутить поддержку любого другого языка. Например, Stylus для написания стилей, или HTML для описания шаблонов.
Зависимости между модулями трекаются автоматически путём анализа исходников. Если модуль включается, то включается целиком - каждый исходник модуля транспилируется и попадает в соответствующий бандл: скрипты - отдельно, стили - отдельно, тесты - отдельно. Для разных платформ - свои бандлы: для ноды - свои, для браузера - свои.
Полная автоматизация, отсутствие конфигурирования и бойлерплейта, минимальные размеры бандлов, автоматическое выкачивание зависимостей, разработка сотен отчуждаемых библиотек и приложений в одной кодовой базе без боли и страданий. Ух какая наркомания! Уберите от мониторов слабонервных беременных детей и добро пожаловать на подлодную водку!
Философия
МАМ - это смелый эксперимент по радикальному изменению способа организации кода и процесса работы с ним. Вот основные принципы:
Соглашения вместо конфигурирования. Разумные, простые и универсальные соглашения позволяют автоматизировать всю рутину, сохраняя при этом удобство и единообразие между разными проектами.
Инфраструктура отдельно, код отдельно. Не редка ситуация, когда надо разрабатывать десятки, а то и сотни библиотек и приложений. Не разворачивать же инфраструктуру сборки, разработки, деплоя и тп для каждого из них. Достаточно задать её один раз и далее клепать приложения как пирожки.
Не платить за то, что не используешь. Используешь какой-то модуль - он включается в бандл со всеми своими зависимостями. Не используешь - не включается. Чем меньше модули, тем больше гранулярность и меньше лишнего кода в бандле.
Минимум лишнего кода. Разбивать код на модули должно быть так же просто, как и писать весь код в одном файле. Иначе разработчик будет лениться разбивать крупные модули на мелкие.
Никаких конфликтов версий. Есть только одна версия - актуальная. Незачем тратить ресурсы на поддержку старых версий, если можно потратить их на актуализацию последней.
Держать руку на пульсе. Максимально быстрая обратная связь касательно несовместимостей не позволит коду протухнуть.
Самый простой путь - самый верный. Если правильный путь требует дополнительных усилий, то будьте уверены, что никто им не пойдёт.
И моё любимое: Ни одной строчки полезного кода. 20 строчек перекладывания значений из кучи модулей в один, чтобы потом импортировать из одного модуля, а не из двадцати.
Всё это бойлерплейт, который приводит к тому, что разработчики ленятся выделять небольшие куски кода в отдельные модули, предпочитая крупные модули мелким. А даже если и не ленятся, то получается либо много кода для импортирования мелких модулей, либо специальные модули, которые импортируют в себя много модулей и экспортируют их все скопом.
Всё это приводит к низкой гранулярности кода и раздуванию размеров бандлов неиспользуемым кодом, которому повезло оказаться рядом с тем, который используется. Эту проблему для JS худо-бедно пытаются решить усложнением сборочного пайплайна, путём добавления так называемого "tree-shaking", вырезающего лишнее из того, что вы наимпортировали. Это замедляет сборку, но вырезает далеко не всё.
Идея: Что если мы не будем импортировать, а будем просто брать и использовать, а сборщик уже сам разберётся что нужно заимпортировать?
Современные IDE умеют автоматически генерировать импорты для использованных вами сущностей. Если это может сделать IDE, то что мешает сделать это сборщику? Достаточно иметь простое соглашение об именовании и расположении файлов, которое было бы удобно для пользователя и понятно для машины. В PHP давно есть такое стандартное соглашение: PSR-4. MAM вводит аналогичное для .ts и .jam.js файлов: имена, начинающиеся с $ являются Fully Qualified Name какой-либо глобальной сущности, код которой подгружается по пути, получаемому из FQN путём замены разделителей на слеши. Простой пример из двух модулей:
$my_alert( 'Hello!' ) // Ага, зависимость от /my/alert/
Целый модуль из одной строки - что может быть проще? Результат не заставляет себя долго ждать: простота создания и использования модулей приводит к минимизации их размеров. Как следствие - к максимизации гранулярности. И как вишенка - минимизации размеров бандлов без каких-либо tree-shaking.
Наглядный пример - семейство модулей валидации JSON /mol/data. Если вы воспользуетесь где-либо в своём коде функцией $mol_data_integer, то в бандл будут включены модули /mol/data/integer и /mol/data/number, от которого зависит $mol_data_integer. А вот, например, /mol/data/email сборщик даже не прочитает с диска, так как от него никто не зависит.
Разгребая бардак
Раз уж мы начали пинать Angular, то не будем останавливаться. Как вы думаете, где искать объявление функции applyStyles? Ни за что не догадаетесь, в /packages/core/src/render3/styling_next/bindings.ts. Возможность помещать что угодно куда угодно приводит к тому, что в каждом проекте мы наблюдаем уникальную систему расположения файлов, часто не поддающуюся никакой логике. И если в IDE зачастую спасает "прыжок к определению", то просмотр кода на гитхабе или обзор пулреквеста лишены такой возможности.
Идея: Что если имена сущностей будут строго соответствовать их расположению?
Чтобы расположить код в файле /angular/packages/core/src/render3/stylingNext/bindings.ts, в МАМ архитектуре придётся назвать сущность $angular_packages_core_src_render3_stylingNext_applyStyles, но так, конечно, никто не поступит, ведь тут столько всего лишнего в имени. А ведь имена в коде хочется видеть короткими и лаконичными, поэтому из названия разработчик постарается исключить всё лишнее, оставив лишь важное: $angular_render3_applyStyles. А расположится это соответственно в /angular/render3/applyStyles/applyStyles.ts.
Обратите внимание как MAM использует слабости разработчиков, чтобы добиваться нужного результата: у каждой сущности получается короткое глобально уникальное имя, которое можно использовать в любом контексте. Например, в сообщениях комитов эти имена позволяют быстро и точно уловить о чём они:
73ebc45e517ffcc3dcce53f5b39b6d06fc95cae1 $mol_vector: range expanding support
3a843b2cb77be19688324eeb72bd090d350a6cc3 $mol_data: allowed transformations
24576f087133a18e0c9f31e0d61052265fd8a31a $mol_data_record: support recursion
export class Foo {
get bar() {
return new Bar();
}
}
export class Bar extends Foo {}
console.log(new Foo().bar);
Не смотря на циклическую зависимость он работает корректно. Разобьём его на 3 файла:
my/foo.js
import { Bar } from './bar.js';
export class Foo {
get bar() {
return new Bar();
}
}
my/bar.js
import { Foo } from './foo.js';
export class Bar extends Foo {}
my/app.js
import { Foo } from './foo.js';
console.log(new Foo().bar);
Опа, ReferenceError: Cannot access 'Foo' before initialization. Что за бред? Чтобы это починить, наш app.js должен знать, что foo.js зависит от bar.js. Поэтому нам надо сначала заимпортировать bar.js, который заимпортирует foo.js. После чего мы уже можем заимпортировать foo.js без ошибки:
my/app.js
import './bar.js';
import { Foo } from './foo.js';
console.log(new Foo().bar);
Что браузеры, что NodeJS, что Webpack, что Parcel - все они криво работают с циклическими зависимостями. И ладно бы они их просто запрещали - можно было бы сразу усложнить код так, чтобы циклов не было. Но они могут работать нормально, а потом бац, и выдать непонятную ошибку.
Идея: Что если при сборке мы будем просто склеивать файлы в правильном порядке, как если бы весь код был изначально написан в одном файле?
Давайте разделим код, используя принципы МАМ:
my/foo/foo.ts
class $my_foo {
get bar() {
return new $my_bar();
}
}
my/bar/bar.ts
class $my_bar extends $my_foo {}
my/app/app.ts
console.log(new $my_foo().bar);
Всё те же 7 строчек кода, что были изначально. И они просто работают без дополнительных шаманств. Всё дело в том, что сборщик понимает, что зависимость my/bar от my/foo более жёсткая, чем my/foo от my/bar. А значит включать в бандл эти модули следует именно в таком порядке: my/foo , my/bar , my/app.
Как сборщик это понимает? Сейчас эвристика простая - по числу отступов в строке, в которой обнаружена зависимость. Обратите внимание, что более сильная зависимость в нашем примере имеет нулевой отступ, а слабая - двойной.
Разные языки
Так уж получилось, что для разных вещей у нас есть разные языки под эти разные вещи заточенные. Из наиболее распространённых это: JS, TS, CSS, HTML, SVG, SCSS, Less, Stylus. У каждого своя система модулей, никак не взаимодействующая с другими языками. Что и говорить про 100500 видов более специфичных языков. В результате, чтобы подключить компонент, приходится отдельно подключать его скрипты, отдельно стили, отдельно регистрировать шаблоны, отдельно настраивать деплой необходимых ему статических файлов и тд, и тп.
Webpack благодаря лоадерам пытается решить эту проблему. Но у него точкой входа является скрипт, который подключает уже файлы на остальных языках. А если нам не нужен скрипт? Например, у нас есть модуль с красивыми стилями для табличек и мы хотим, чтобы в светлой теме они имели одни цвета, а в тёмной другие:
При этом, если мы зависим от темы, то должен быть подгружен скрипт, который установит нужную тему в зависимости от времени суток. То есть CSS фактически зависит от JS.
Идея: Что если модульная система не будет зависеть от языков?
Так как в MAM модульная система отделена от языков, то зависимости могут быть кроссязыковыми. CSS может зависеть от JS, который может зависеть от TS, который может зависеть от другого JS. Достигается это за счёт того, что в исходниках обнаруживаются зависимости от модулей, а модули подключаются целиком и могут содержать исходники на любых языках. В случае примера с темами это выглядит так:
Используя эту технику, кстати, можно реализовать свой Modernizr, но без 300 ненужных вам проверок, ведь в бандл будут включены лишь те проверки, от которых реально зависит ваш CSS.
Много библиотек
Обычно точкой входа для сборки бандла является какой-то файл. В случае Webpack это JS. Если вы разрабатываете множество отчуждаемых библиотек и приложений, то вам нужно множество же и бандлов. И для каждого бандла нужно создавать отдельную точку входа. В случае Parcel точкой входа является HTML, который для приложений в любом случае придётся создавать. Но для библиотек это как-то не очень подходит.
Идея: Что если любой модуль можно будет собрать в независимый бандл без предварительной подготовки?
Давайте соберём последнюю версию сборщика MAM проектов $mol_build:
mam mol/build
А теперь запустим этот сборщик и пусть он соберёт сам себя ещё раз чтобы убедиться, что он всё ещё способен сам себя собрать:
node mol/build/-/node.js mol/build
Хотя, нет, давайте вместе со сборкой попросим его ещё и тесты прогнать:
node mol/build/-/node.test.js mol/build
И если всё прошло успешно, опубликуем результат в NPM:
npm publish mol/build/-
Как можно заметить, при сборке модуля создаётся поддиректория с именем - и туда помещаются все артефакты сборки. Давайте пройдёмся по файлам, которые можно там обнаружить:
web.dep.json - вся информация о графе зависимостей
web.js - бандл скриптов для браузеров
web.js.map - сорсмапы для него
web.esm.js - он же в виде es-модуля
web.esm.js.map - и для него сорсмапы
web.test.js - бандл с тестами
web.test.js.map - и для тестов сорсмапы
web.d.ts - бандл с типами всего, что есть в бандле скриптов
web.css - бандл со стилями
web.css.map - и сорсмапы для него
web.test.html - точка входа, чтобы запустить тесты на исполнение в браузере
web.view.tree - декларации всех включённых в бандл view.tree компонент
web.locale=*.json - бандлы с локализованными текстами, для каждого обнаруженного языка свой бандл
package.json - позволяет тут же опубликовать собранный модуль в NPM
node.dep.json - вся информация о графе зависимостей
node.js - бандл скриптов для ноды
node.js.map - сорсмапы для него
node.esm.js - он же в виде es-модуля
node.esm.js.map - и для него сорсмапы
node.test.js - тот же бандл, но ещё и с тестами
node.test.js.map - и для него сорсмапы
node.d.ts - бандл с типами всего, что есть в бандле скриптов
node.view.tree - декларации всех включённых в бандл view.tree компонент
node.locale=*.json - бандлы с локализованными текстами, для каждого обнаруженного языка свой бандл
К сожалению, в общем случае сборщик не может знать, что нам понадобятся эти файлы в рантайме. Но мы можем ему это подсказать положив рядом специальный файл:
Теперь директорию /mol/app/quine/- можно выложить на любой статический хостинг и приложение будет полностью работоспособно.
Целевые платформы
JS может исполняться как на клиенте, так и на сервере. И как же классно, когда можно написать один код и он будет работать везде. Однако, порой реализации одной и той же штуки на клиенте и сервере кардинально отличаются. И хочется, чтобы, например, для ноды использовалась одна реализация, а для браузера - другая.
Идея: Что если предназначение файла будет отражено в его имени?
В MAM используется система тегов в именах файлов. Например, модуль $mol_state_arg предоставляет доступ к задаваемым пользователем параметрам приложения. В браузере эти параметры задаютя через строку адреса. А в ноде - через аргументы командной строки. $mol_sate_arg абстрагирует всё остальное приложение от этих нюансов путём реализации обоих вариантов с единым интерфейсом, располагая их в файлах:
/mol/state/arg/arg.web.ts - реализация для браузеров
/mol/state/arg/arg.node.ts - реализация для ноды
Исходники не помеченные этими тегами включаются независимо от целевой платформы.
Аналогичная ситуация наблюдается и с тестами - их хочется хранить рядом с остальными исходниками, но не хочется включать в тот бандл, что пойдёт конечному пользователю. Поэтому тесты тоже помечаются отдельным тегом:
/mol/state/arg/arg.test.ts - тесты модуля, они попадут в бандл с тестами
Теги могут быть и параметрическими. Например, с каждым модулем могут идти тексты на самых разных языках и они должны быть включены в соответствующие языковые бандлы. Файл с текстами - это обычный JSON-словарь, именованный с указанием локали в имени:
/mol/app/life/life.locale=ru.json - тексты для русского языка
/mol/app/life/life.locale=jp.json - тексты для японского языка
Наконец, что если мы хотим расположить рядом файлы, но хотим, чтобы сборщик их проигнорировал и не включал автоматически в бандл? Достаточно добавить в начале их названия любой не цифробуквенный символ. Например:
/hyoo/toys/.git - начинается с точки, поэтому сборщик эту директорию проигнорирует
Версионирование
Сперва Гугл выпустил AngularJS и опубликовал его в NPM как angular. Потом он создал совершенно новый фреймворк с похожим названием - Angular и опубликовал его под тем же самым именем, но уже версии 2. Теперь эти два феймворка развиваются независимо. Только у одного ломающие API изменения происходят между мажорными версиями. А у другого - между минорными. А так как поставить две версии одной зависимости на одном уровне невозможно, то ни о каком плавном переходе, когда в приложении какое-то время сосуществуют одновременно две версии библиотеки, не может быть и речи.
Кажется команда Ангуляра наступила уже на все возможные грабли. И вот ещё одни: код фреймворка разбит на несколько крупных модулей. Сначала они версионировали их независимо, но очень быстро даже сами начали путаться какие версии модулей совместимы между собой, что уж говорить о рядовых разработчиках. Поэтому Angular перешёл на сквозное версионирование, где мажорная версия модуля может меняться даже без каких-либо изменений в коде. Поддержка множества версий множества модулей - это большая проблема как для самих мейнтейнеров, так и для экосистемы в целом. Ведь куча ресурсов всех участников сообщества тратится на обеспечение совместимости с уже устаревшими модулями.
Красивая идея Semantic Versioning разбивается о суровую реальность - вы никогда не знаете, сломается ли у вас что-то, при изменении минорной версии или даже версии патча. Поэтому во многих проектах фиксируют конкретную версию зависимости. Однако, такая фиксация не действует на транзитивные зависимости, которые могут притянуться последней версии при установке с нуля, а могут остаться прежней, если уже стоят. Эта неразбериха приводит к тому, что вы никогда не можете положиться на зафиксированную версию и вам регулярно необходимо проверять совместимость с актуальными версиями (как минимум транзитивных) зависимостей.
А как же лок-файлы? Если вы разрабатываете библиотеку, устанавливаемую через зависимости, лок-файл вам не поможет, ибо будет проигнорирован менеджером пакетов. Для конечного же приложения лок-файл даст вам так называемую "воспроизводимость сборок". Но давайте будем честными. Сколько раз вам нужно собирать конечное приложение из одних и тех же исходников? Ровно один раз. Получая на выходе, не зависящий ни от каких NPM, артефакт сборки: исполнимый бинарник, докер-контейнер или просто архив со всем необходимым для запуска кодом. Надеюсь вы не делаете npm install на проде?
Некоторые находят пользу лок-файлов в том, чтобы CI-сервер собрал именно то, что закомитил разработчик. Но постойте, просто собрать может и сам разработчик на своей локальной машине. Более того, он должен это сделать, чтобы убедиться, что ничего не сломал. Continuous Integration не только и не столько про сборку, сколько про проверку совместимости того, что написал один разработчик, с тем, что написал кто-то другой. Концепция CI заключается в как можно более скором обнаружении несовместимостей, и как следствие как можно более раннем старте работ по их устранению.
С фиксацией версий же зависимости очень быстро протухают, создавая вам даже больше проблем, чем решают. Например, однажды в одной компании стартанули проект на актуальном на тот момент Angular@4 (или даже 3). Фреймворк развивался, но никто его не обновлял, ибо "это не входит в скоуп задачи" и "мы не брали это в спринт". Была написана куча кода под Angular@4 и никто даже не знал, что он не совместим с Angular@5. Когда же на горизонте замаячил Angular@6, команда решила таки взять обновление этой зависимости в спринт. Новый Angular потребовал новый TypeScript и кучу других зависимостей. Потребовалось переписать кучу собственного кода. В итоге, по истечении 2 недель спринта, было решено.. отложить обновление фреймворка до лучших времён, так как business value сам себя не создаст, пока команда возвращает технический долг, взятый с, как выяснилось, адскими процентами.
А вишенкой на торте из версионных граблей является спонтанное появление в бандле нескольких версий одной и той же зависимости, о чём вы узнаёте, лишь когда замечаете аномально долгую загрузку приложения, и лезете разбираться, почему размер вашего бандла вырос в 2 раза. А всё оказывается просто: одна зависимость требует одну версию Реакта, другая - другую, третья - третью. В результате на страницу грузится аж 3 React, 5 jQuery, 7 lodash.
Идея: Что если у всех модулей будет только одна версия - последняя?
Мы фундаментально не можем решить проблему несовместимости при обновлениях. Но мы можем научиться как-то с этим жить. Признав попытки фиксации версий несостоятельными, мы можем отказаться от указания версий вовсе. При каждой установке любой зависимости, будет скачан самый актуальный код. Тот код, который сейчас поддерживается мейнтейнером. Тот код, который сейчас видят все остальные потребители библиотеки. И все вместе решают проблемы с этой библиотекой, если они вдруг возникают. А не так, что одни уже обновились и бьются над проблемой, а у других хата с краю и они ни чем не помогают. А помощь может быть самая разная: завести issue, объяснить мейнтейнерам важность проблемы, найти workaround, сделать pull request, форкнуть в конце концов, если мейнтейнеры совсем забили на поддержку. Чем больше людей одновременно испытывают одну и ту же боль, тем скорее найдётся тот, кто эту боль устранит. Это объединяет людей для улучшения единой кодовой базы. В то же время версионирование фрагментирует сообщество по куче разных используемых версий.
Без версионирования мейнтейнер гораздо быстрее получит обратную связь от своих потребителей и либо выпустит хотфикс, либо попросту откатит изменения для лучшей их проработки. Зная, что неосторожный комит может сломать сборку всем потребителям, мейнтейнер будет более ответственен ко внесению изменений. Ну либо его библиотеками никто не будет пользоваться. И тут появится запрос на более продвинутый тулинг. Например такой: репозиторий зависимости, рассылает уведомления всем зависимым проектам о том, что появился комит в фиче-ветке. Те проверяют с этой фиче-веткой интеграцию и если обнаруживаются проблемы - присылают подробности о них в репозиторий зависимости. Таким образом, мейнтейнер библиотеки мог бы получать обратную связь от потребителей ещё до того, как влить свою фиче-ветку в мастер. Такой пайплайн был бы очень полезен и при версионировании, но, как видим, в экосистеме NPM ничего такого до сих пор не распространено. Всё потому, что нет острой потребности в этом. Отказ от версий же форсирует развитие экосистемы.
Но что если всё же надо сломать обратную совместимость, но не хочется ломать всем сборку? Всё просто - создаём новый модуль. Был mobx, стал mobx2 и меняй в нём API как захочешь. Казалось бы - это то же версионирование, но есть фундаментальная разница: раз это два разных модуля, то они могут быть оба поставлены одновременно. При этом последняя реализация mobx может быть реализована как легковесный адаптер к mobx2, реализующий на его основе старый API. Таким образом можно осуществлять плавный переход между несовместимыми API, не раздувая бандл дублирующимся кодом.
Отсутствие версионирования даёт и ещё один неожиданный эффект. Обнаружив зависимость сборщик всегда знает какую именно версию надо установить - последнюю. То есть, чтобы воспользоваться снипетом из интернета вида:
var pages_count = $mol_atom2_sync( ()=> $lib_pdfjs.getDocument( uri ).promise ).document().numPages
Вам не нужно ставить модули mol_atom2_sync и lib_pdfjs, подбирая подходящие для этого снипета версии:
npm install mol_atom2_sync@2.1 lib_pdfjs@5.6
Всё, что вам нужно, - это написать код, а все зависимости установятся автоматически при сборке. Но как сборщик узнаёт откуда брать какие модули? Всё очень просто - не найдя ожидаемой директории, он смотрит файлы *.meta.tree, где может быть указано какие директории из каких репозиториев брать:
Это фрагмент корневого мапинга. Таким же образом вы можете выносить любые подмодули вашего модуля в отдельные репозитории.
Интеграция с NPM
MAM - совершенно иная нежели NPM экосистема. Однако, пытаться перекладывать код из одной системы в другую - контрпродуктивно. Поэтому, мы работаем над тем, чтобы использовать опубликованные в NPM модули было бы не слишком болезненно.
Если вам нужно на сервере обратиться к уже установленному NPM модулю, то можете воспользоваться модулем $node. Например, давайте найдём какой-нибудь свободный порт и поднимем на нём статический веб-сервер:
Если же надо именно включить в бандл, то тут всё немного сложнее. Поэтому-то и появился пакет lib содержащий адаптеры к некоторым популярным NPM библиотекам. Например, вот как выглядит подключение NPM-модуля pdfjs-dist:
Надеюсь в будущем нам удастся упростить эту интеграцию, но пока что так.
Окружение разработчика
Для старта нового проекта часто приходится настраивать очень много вещей. Именно поэтому появились всякие create-react-app и angular-cli, но ни прячут от вас свои конфиги. Вы, конечно, можете сделать eject и эти конфиги переедут в ваш проект. Но тогда он станет намертво привязан к этой эджектнутой инфраструктуре. Если вы разрабатываете множество библиотек и приложений, то хотели бы единообразно работать с каждым из них, и вносить свои кастомизации сразу для всех.
Идея: Что если инфраструктура будет отделена от кода?
Инфраструктура в случае MAM живёт в отдельном от кода репозитории. У вас может быть множество проектов в рамках одной инфраструктуры.
git clone https://github.com/eigenmethod/mam.git ./mam && cd mam
npm install
npm start
На порту 8080 поднимется сервер разработчика. Всё, что вам останется - лишь писать код в соответствии с принципами MAM.
Заведите себе собственный неймспейс (для примера - acme) и пропишите в нём ссылки на ваши проекты (для примера - hello и home ):
/acme/acme.meta.tree
pack hello git \https://github.com/acme/hello.git
pack home git \https://github.com/acme/home.git
Для сборки конкретных модулей достаточно приписать пути до них после npm start:
npm start acme/hello acme/home
Переводить уже существующий проект на эти рельсы довольно затруднительно. А вот начать новый - самое то. Попробуйте, будет сложно, но вам понравится. А если столкнётесь трудностями - пишите нам телеграммы: https://t.me/mam_mol
https://page.hyoo.ru/#!=vgddrz_bvd8ys
Здравствуйте, меня зовут Дмитрий Карловский, и я.. обожаю MAM. MАМ управляет Агностик Модулями, избавляя меня от львиной доли рутины.
Агностик Модуль, в отличие от традиционного, это не файл с исходником, а директория, внутри которой могут быть исходники на самых разных языках: программная логика на
JS
/TS
, тесты к ней наTS
/JS
, шейдеры наGLSL
, композиция компонент наview.tree
, конфиги наjson.tree
, стили наCSS
/CSS.TS
, локализация вlocale=*.json
, картинки и тд, и тп. При желании не сложно прикрутить поддержку любого другого языка. Например, Stylus для написания стилей, или HTML для описания шаблонов.Зависимости между модулями трекаются автоматически путём анализа исходников. Если модуль включается, то включается целиком - каждый исходник модуля транспилируется и попадает в соответствующий бандл: скрипты - отдельно, стили - отдельно, тесты - отдельно. Для разных платформ - свои бандлы: для ноды - свои, для браузера - свои.
Полная автоматизация, отсутствие конфигурирования и бойлерплейта, минимальные размеры бандлов, автоматическое выкачивание зависимостей, разработка сотен отчуждаемых библиотек и приложений в одной кодовой базе без боли и страданий. Ух какая наркомания! Уберите от мониторов слабонервных беременных детей и добро пожаловать на подлодную водку!
Философия
МАМ - это смелый эксперимент по радикальному изменению способа организации кода и процесса работы с ним. Вот основные принципы:
Соглашения вместо конфигурирования. Разумные, простые и универсальные соглашения позволяют автоматизировать всю рутину, сохраняя при этом удобство и единообразие между разными проектами.
Инфраструктура отдельно, код отдельно. Не редка ситуация, когда надо разрабатывать десятки, а то и сотни библиотек и приложений. Не разворачивать же инфраструктуру сборки, разработки, деплоя и тп для каждого из них. Достаточно задать её один раз и далее клепать приложения как пирожки.
Не платить за то, что не используешь. Используешь какой-то модуль - он включается в бандл со всеми своими зависимостями. Не используешь - не включается. Чем меньше модули, тем больше гранулярность и меньше лишнего кода в бандле.
Минимум лишнего кода. Разбивать код на модули должно быть так же просто, как и писать весь код в одном файле. Иначе разработчик будет лениться разбивать крупные модули на мелкие.
Никаких конфликтов версий. Есть только одна версия - актуальная. Незачем тратить ресурсы на поддержку старых версий, если можно потратить их на актуализацию последней.
Держать руку на пульсе. Максимально быстрая обратная связь касательно несовместимостей не позволит коду протухнуть.
Самый простой путь - самый верный. Если правильный путь требует дополнительных усилий, то будьте уверены, что никто им не пойдёт.
Импорты/экспорты
Открываем первый попавшийся проект с использованием современной системы модулей: Модуль меньше чем на 300 строк, 30 из них - импорты.
Но это ещё цветочки: Для функции из 9 строк требуется 8 импортов.
И моё любимое: Ни одной строчки полезного кода. 20 строчек перекладывания значений из кучи модулей в один, чтобы потом импортировать из одного модуля, а не из двадцати.
Всё это бойлерплейт, который приводит к тому, что разработчики ленятся выделять небольшие куски кода в отдельные модули, предпочитая крупные модули мелким. А даже если и не ленятся, то получается либо много кода для импортирования мелких модулей, либо специальные модули, которые импортируют в себя много модулей и экспортируют их все скопом.
Всё это приводит к низкой гранулярности кода и раздуванию размеров бандлов неиспользуемым кодом, которому повезло оказаться рядом с тем, который используется. Эту проблему для JS худо-бедно пытаются решить усложнением сборочного пайплайна, путём добавления так называемого "tree-shaking", вырезающего лишнее из того, что вы наимпортировали. Это замедляет сборку, но вырезает далеко не всё.
Идея: Что если мы не будем импортировать, а будем просто брать и использовать, а сборщик уже сам разберётся что нужно заимпортировать?
Современные IDE умеют автоматически генерировать импорты для использованных вами сущностей. Если это может сделать IDE, то что мешает сделать это сборщику? Достаточно иметь простое соглашение об именовании и расположении файлов, которое было бы удобно для пользователя и понятно для машины. В PHP давно есть такое стандартное соглашение: PSR-4. MAM вводит аналогичное для .ts и .jam.js файлов: имена, начинающиеся с $ являются Fully Qualified Name какой-либо глобальной сущности, код которой подгружается по пути, получаемому из FQN путём замены разделителей на слеши. Простой пример из двух модулей:
my/alert/alert.ts
my/app/app.ts
Целый модуль из одной строки - что может быть проще? Результат не заставляет себя долго ждать: простота создания и использования модулей приводит к минимизации их размеров. Как следствие - к максимизации гранулярности. И как вишенка - минимизации размеров бандлов без каких-либо tree-shaking.
Наглядный пример - семейство модулей валидации JSON /mol/data. Если вы воспользуетесь где-либо в своём коде функцией
$mol_data_integer
, то в бандл будут включены модули/mol/data/integer
и/mol/data/number
, от которого зависит$mol_data_integer
. А вот, например,/mol/data/email
сборщик даже не прочитает с диска, так как от него никто не зависит.Разгребая бардак
Раз уж мы начали пинать Angular, то не будем останавливаться. Как вы думаете, где искать объявление функции
applyStyles
? Ни за что не догадаетесь, в/packages/core/src/render3/styling_next/bindings.ts
. Возможность помещать что угодно куда угодно приводит к тому, что в каждом проекте мы наблюдаем уникальную систему расположения файлов, часто не поддающуюся никакой логике. И если в IDE зачастую спасает "прыжок к определению", то просмотр кода на гитхабе или обзор пулреквеста лишены такой возможности.Идея: Что если имена сущностей будут строго соответствовать их расположению?
Чтобы расположить код в файле
/angular/packages/core/src/render3/stylingNext/bindings.ts
, в МАМ архитектуре придётся назвать сущность$angular_packages_core_src_render3_stylingNext_applyStyles
, но так, конечно, никто не поступит, ведь тут столько всего лишнего в имени. А ведь имена в коде хочется видеть короткими и лаконичными, поэтому из названия разработчик постарается исключить всё лишнее, оставив лишь важное:$angular_render3_applyStyles
. А расположится это соответственно в/angular/render3/applyStyles/applyStyles.ts
.Обратите внимание как MAM использует слабости разработчиков, чтобы добиваться нужного результата: у каждой сущности получается короткое глобально уникальное имя, которое можно использовать в любом контексте. Например, в сообщениях комитов эти имена позволяют быстро и точно уловить о чём они:
Или, допустим, вы хотите найти все упоминания модуля $mol_fiber в интернете - сделать это проще простого благодаря FQN.
Циклические зависимости
Напишем в одном файле 7 строк простого кода:
Не смотря на циклическую зависимость он работает корректно. Разобьём его на 3 файла:
my/foo.js
my/bar.js
my/app.js
Опа,
ReferenceError: Cannot access 'Foo' before initialization
. Что за бред? Чтобы это починить, нашapp.js
должен знать, чтоfoo.js
зависит отbar.js
. Поэтому нам надо сначала заимпортироватьbar.js
, который заимпортируетfoo.js
. После чего мы уже можем заимпортироватьfoo.js
без ошибки:my/app.js
Что браузеры, что NodeJS, что Webpack, что Parcel - все они криво работают с циклическими зависимостями. И ладно бы они их просто запрещали - можно было бы сразу усложнить код так, чтобы циклов не было. Но они могут работать нормально, а потом бац, и выдать непонятную ошибку.
Идея: Что если при сборке мы будем просто склеивать файлы в правильном порядке, как если бы весь код был изначально написан в одном файле?
Давайте разделим код, используя принципы МАМ:
my/foo/foo.ts
my/bar/bar.ts
my/app/app.ts
Всё те же 7 строчек кода, что были изначально. И они просто работают без дополнительных шаманств. Всё дело в том, что сборщик понимает, что зависимость
my/bar
отmy/foo
более жёсткая, чемmy/foo
отmy/bar
. А значит включать в бандл эти модули следует именно в таком порядке:my/foo
,my/bar
,my/app
.Как сборщик это понимает? Сейчас эвристика простая - по числу отступов в строке, в которой обнаружена зависимость. Обратите внимание, что более сильная зависимость в нашем примере имеет нулевой отступ, а слабая - двойной.
Разные языки
Так уж получилось, что для разных вещей у нас есть разные языки под эти разные вещи заточенные. Из наиболее распространённых это: JS, TS, CSS, HTML, SVG, SCSS, Less, Stylus. У каждого своя система модулей, никак не взаимодействующая с другими языками. Что и говорить про 100500 видов более специфичных языков. В результате, чтобы подключить компонент, приходится отдельно подключать его скрипты, отдельно стили, отдельно регистрировать шаблоны, отдельно настраивать деплой необходимых ему статических файлов и тд, и тп.
Webpack благодаря лоадерам пытается решить эту проблему. Но у него точкой входа является скрипт, который подключает уже файлы на остальных языках. А если нам не нужен скрипт? Например, у нас есть модуль с красивыми стилями для табличек и мы хотим, чтобы в светлой теме они имели одни цвета, а в тёмной другие:
При этом, если мы зависим от темы, то должен быть подгружен скрипт, который установит нужную тему в зависимости от времени суток. То есть CSS фактически зависит от JS.
Идея: Что если модульная система не будет зависеть от языков?
Так как в MAM модульная система отделена от языков, то зависимости могут быть кроссязыковыми. CSS может зависеть от JS, который может зависеть от TS, который может зависеть от другого JS. Достигается это за счёт того, что в исходниках обнаруживаются зависимости от модулей, а модули подключаются целиком и могут содержать исходники на любых языках. В случае примера с темами это выглядит так:
/my/table/table.css
/my/theme/theme.js
Используя эту технику, кстати, можно реализовать свой Modernizr, но без 300 ненужных вам проверок, ведь в бандл будут включены лишь те проверки, от которых реально зависит ваш CSS.
Много библиотек
Обычно точкой входа для сборки бандла является какой-то файл. В случае Webpack это JS. Если вы разрабатываете множество отчуждаемых библиотек и приложений, то вам нужно множество же и бандлов. И для каждого бандла нужно создавать отдельную точку входа. В случае Parcel точкой входа является HTML, который для приложений в любом случае придётся создавать. Но для библиотек это как-то не очень подходит.
Идея: Что если любой модуль можно будет собрать в независимый бандл без предварительной подготовки?
Давайте соберём последнюю версию сборщика MAM проектов $mol_build:
А теперь запустим этот сборщик и пусть он соберёт сам себя ещё раз чтобы убедиться, что он всё ещё способен сам себя собрать:
Хотя, нет, давайте вместе со сборкой попросим его ещё и тесты прогнать:
И если всё прошло успешно, опубликуем результат в NPM:
Как можно заметить, при сборке модуля создаётся поддиректория с именем
-
и туда помещаются все артефакты сборки. Давайте пройдёмся по файлам, которые можно там обнаружить:web.dep.json
- вся информация о графе зависимостейweb.js
- бандл скриптов для браузеровweb.js.map
- сорсмапы для негоweb.esm.js
- он же в виде es-модуляweb.esm.js.map
- и для него сорсмапыweb.test.js
- бандл с тестамиweb.test.js.map
- и для тестов сорсмапыweb.d.ts
- бандл с типами всего, что есть в бандле скриптовweb.css
- бандл со стилямиweb.css.map
- и сорсмапы для негоweb.test.html
- точка входа, чтобы запустить тесты на исполнение в браузереweb.view.tree
- декларации всех включённых в бандл view.tree компонентweb.locale=*.json
- бандлы с локализованными текстами, для каждого обнаруженного языка свой бандлpackage.json
- позволяет тут же опубликовать собранный модуль в NPMnode.dep.json
- вся информация о графе зависимостейnode.js
- бандл скриптов для нодыnode.js.map
- сорсмапы для негоnode.esm.js
- он же в виде es-модуляnode.esm.js.map
- и для него сорсмапыnode.test.js
- тот же бандл, но ещё и с тестамиnode.test.js.map
- и для него сорсмапыnode.d.ts
- бандл с типами всего, что есть в бандле скриптовnode.view.tree
- декларации всех включённых в бандл view.tree компонентnode.locale=*.json
- бандлы с локализованными текстами, для каждого обнаруженного языка свой бандлСтатика просто копируется вместе с путями. В качестве примера, возьмём приложение, которое выводит собственные исходные коды. Его исходники лежат тут:
/mol/app/quine/quine.view.tree
/mol/app/quine/quine.view.ts
/mol/app/quine/index.html
/mol/app/quine/quine.locale=ru.json
К сожалению, в общем случае сборщик не может знать, что нам понадобятся эти файлы в рантайме. Но мы можем ему это подсказать положив рядом специальный файл:
/mol/app/quine/quine.meta.tree
В результате сборки
/mol/app/quine
, они будут скопированы по следующим путям:/mol/app/quine/-/mol/app/quine/quine.view.tree
/mol/app/quine/-/mol/app/quine/quine.view.ts
/mol/app/quine/-/mol/app/quine/index.html
/mol/app/quine/-/mol/app/quine/quine.locale=ru.json
Теперь директорию
/mol/app/quine/-
можно выложить на любой статический хостинг и приложение будет полностью работоспособно.Целевые платформы
JS может исполняться как на клиенте, так и на сервере. И как же классно, когда можно написать один код и он будет работать везде. Однако, порой реализации одной и той же штуки на клиенте и сервере кардинально отличаются. И хочется, чтобы, например, для ноды использовалась одна реализация, а для браузера - другая.
Идея: Что если предназначение файла будет отражено в его имени?
В MAM используется система тегов в именах файлов. Например, модуль
$mol_state_arg
предоставляет доступ к задаваемым пользователем параметрам приложения. В браузере эти параметры задаютя через строку адреса. А в ноде - через аргументы командной строки.$mol_sate_arg
абстрагирует всё остальное приложение от этих нюансов путём реализации обоих вариантов с единым интерфейсом, располагая их в файлах:Исходники не помеченные этими тегами включаются независимо от целевой платформы.
Аналогичная ситуация наблюдается и с тестами - их хочется хранить рядом с остальными исходниками, но не хочется включать в тот бандл, что пойдёт конечному пользователю. Поэтому тесты тоже помечаются отдельным тегом:
Теги могут быть и параметрическими. Например, с каждым модулем могут идти тексты на самых разных языках и они должны быть включены в соответствующие языковые бандлы. Файл с текстами - это обычный JSON-словарь, именованный с указанием локали в имени:
Наконец, что если мы хотим расположить рядом файлы, но хотим, чтобы сборщик их проигнорировал и не включал автоматически в бандл? Достаточно добавить в начале их названия любой не цифробуквенный символ. Например:
Версионирование
Сперва Гугл выпустил AngularJS и опубликовал его в NPM как
angular
. Потом он создал совершенно новый фреймворк с похожим названием - Angular и опубликовал его под тем же самым именем, но уже версии 2. Теперь эти два феймворка развиваются независимо. Только у одного ломающие API изменения происходят между мажорными версиями. А у другого - между минорными. А так как поставить две версии одной зависимости на одном уровне невозможно, то ни о каком плавном переходе, когда в приложении какое-то время сосуществуют одновременно две версии библиотеки, не может быть и речи.Кажется команда Ангуляра наступила уже на все возможные грабли. И вот ещё одни: код фреймворка разбит на несколько крупных модулей. Сначала они версионировали их независимо, но очень быстро даже сами начали путаться какие версии модулей совместимы между собой, что уж говорить о рядовых разработчиках. Поэтому Angular перешёл на сквозное версионирование, где мажорная версия модуля может меняться даже без каких-либо изменений в коде. Поддержка множества версий множества модулей - это большая проблема как для самих мейнтейнеров, так и для экосистемы в целом. Ведь куча ресурсов всех участников сообщества тратится на обеспечение совместимости с уже устаревшими модулями.
Красивая идея Semantic Versioning разбивается о суровую реальность - вы никогда не знаете, сломается ли у вас что-то, при изменении минорной версии или даже версии патча. Поэтому во многих проектах фиксируют конкретную версию зависимости. Однако, такая фиксация не действует на транзитивные зависимости, которые могут притянуться последней версии при установке с нуля, а могут остаться прежней, если уже стоят. Эта неразбериха приводит к тому, что вы никогда не можете положиться на зафиксированную версию и вам регулярно необходимо проверять совместимость с актуальными версиями (как минимум транзитивных) зависимостей.
А как же лок-файлы? Если вы разрабатываете библиотеку, устанавливаемую через зависимости, лок-файл вам не поможет, ибо будет проигнорирован менеджером пакетов. Для конечного же приложения лок-файл даст вам так называемую "воспроизводимость сборок". Но давайте будем честными. Сколько раз вам нужно собирать конечное приложение из одних и тех же исходников? Ровно один раз. Получая на выходе, не зависящий ни от каких NPM, артефакт сборки: исполнимый бинарник, докер-контейнер или просто архив со всем необходимым для запуска кодом. Надеюсь вы не делаете
npm install
на проде?Некоторые находят пользу лок-файлов в том, чтобы CI-сервер собрал именно то, что закомитил разработчик. Но постойте, просто собрать может и сам разработчик на своей локальной машине. Более того, он должен это сделать, чтобы убедиться, что ничего не сломал. Continuous Integration не только и не столько про сборку, сколько про проверку совместимости того, что написал один разработчик, с тем, что написал кто-то другой. Концепция CI заключается в как можно более скором обнаружении несовместимостей, и как следствие как можно более раннем старте работ по их устранению.
С фиксацией версий же зависимости очень быстро протухают, создавая вам даже больше проблем, чем решают. Например, однажды в одной компании стартанули проект на актуальном на тот момент Angular@4 (или даже 3). Фреймворк развивался, но никто его не обновлял, ибо "это не входит в скоуп задачи" и "мы не брали это в спринт". Была написана куча кода под Angular@4 и никто даже не знал, что он не совместим с Angular@5. Когда же на горизонте замаячил Angular@6, команда решила таки взять обновление этой зависимости в спринт. Новый Angular потребовал новый TypeScript и кучу других зависимостей. Потребовалось переписать кучу собственного кода. В итоге, по истечении 2 недель спринта, было решено.. отложить обновление фреймворка до лучших времён, так как business value сам себя не создаст, пока команда возвращает технический долг, взятый с, как выяснилось, адскими процентами.
А вишенкой на торте из версионных граблей является спонтанное появление в бандле нескольких версий одной и той же зависимости, о чём вы узнаёте, лишь когда замечаете аномально долгую загрузку приложения, и лезете разбираться, почему размер вашего бандла вырос в 2 раза. А всё оказывается просто: одна зависимость требует одну версию Реакта, другая - другую, третья - третью. В результате на страницу грузится аж 3 React, 5 jQuery, 7 lodash.
Идея: Что если у всех модулей будет только одна версия - последняя?
Мы фундаментально не можем решить проблему несовместимости при обновлениях. Но мы можем научиться как-то с этим жить. Признав попытки фиксации версий несостоятельными, мы можем отказаться от указания версий вовсе. При каждой установке любой зависимости, будет скачан самый актуальный код. Тот код, который сейчас поддерживается мейнтейнером. Тот код, который сейчас видят все остальные потребители библиотеки. И все вместе решают проблемы с этой библиотекой, если они вдруг возникают. А не так, что одни уже обновились и бьются над проблемой, а у других хата с краю и они ни чем не помогают. А помощь может быть самая разная: завести issue, объяснить мейнтейнерам важность проблемы, найти workaround, сделать pull request, форкнуть в конце концов, если мейнтейнеры совсем забили на поддержку. Чем больше людей одновременно испытывают одну и ту же боль, тем скорее найдётся тот, кто эту боль устранит. Это объединяет людей для улучшения единой кодовой базы. В то же время версионирование фрагментирует сообщество по куче разных используемых версий.
Без версионирования мейнтейнер гораздо быстрее получит обратную связь от своих потребителей и либо выпустит хотфикс, либо попросту откатит изменения для лучшей их проработки. Зная, что неосторожный комит может сломать сборку всем потребителям, мейнтейнер будет более ответственен ко внесению изменений. Ну либо его библиотеками никто не будет пользоваться. И тут появится запрос на более продвинутый тулинг. Например такой: репозиторий зависимости, рассылает уведомления всем зависимым проектам о том, что появился комит в фиче-ветке. Те проверяют с этой фиче-веткой интеграцию и если обнаруживаются проблемы - присылают подробности о них в репозиторий зависимости. Таким образом, мейнтейнер библиотеки мог бы получать обратную связь от потребителей ещё до того, как влить свою фиче-ветку в мастер. Такой пайплайн был бы очень полезен и при версионировании, но, как видим, в экосистеме NPM ничего такого до сих пор не распространено. Всё потому, что нет острой потребности в этом. Отказ от версий же форсирует развитие экосистемы.
Но что если всё же надо сломать обратную совместимость, но не хочется ломать всем сборку? Всё просто - создаём новый модуль. Был
mobx
, сталmobx2
и меняй в нём API как захочешь. Казалось бы - это то же версионирование, но есть фундаментальная разница: раз это два разных модуля, то они могут быть оба поставлены одновременно. При этом последняя реализацияmobx
может быть реализована как легковесный адаптер кmobx2
, реализующий на его основе старый API. Таким образом можно осуществлять плавный переход между несовместимыми API, не раздувая бандл дублирующимся кодом.Отсутствие версионирования даёт и ещё один неожиданный эффект. Обнаружив зависимость сборщик всегда знает какую именно версию надо установить - последнюю. То есть, чтобы воспользоваться снипетом из интернета вида:
Вам не нужно ставить модули
mol_atom2_sync
иlib_pdfjs
, подбирая подходящие для этого снипета версии:Всё, что вам нужно, - это написать код, а все зависимости установятся автоматически при сборке. Но как сборщик узнаёт откуда брать какие модули? Всё очень просто - не найдя ожидаемой директории, он смотрит файлы
*.meta.tree
, где может быть указано какие директории из каких репозиториев брать:/.meta.tree
Это фрагмент корневого мапинга. Таким же образом вы можете выносить любые подмодули вашего модуля в отдельные репозитории.
Интеграция с NPM
MAM - совершенно иная нежели NPM экосистема. Однако, пытаться перекладывать код из одной системы в другую - контрпродуктивно. Поэтому, мы работаем над тем, чтобы использовать опубликованные в NPM модули было бы не слишком болезненно.
Если вам нужно на сервере обратиться к уже установленному NPM модулю, то можете воспользоваться модулем $node. Например, давайте найдём какой-нибудь свободный порт и поднимем на нём статический веб-сервер:
/my/app/app.ts
Если же надо именно включить в бандл, то тут всё немного сложнее. Поэтому-то и появился пакет
lib
содержащий адаптеры к некоторым популярным NPM библиотекам. Например, вот как выглядит подключение NPM-модуляpdfjs-dist
:/lib/pdfjs/pdfjs.ts
/lib/pdfjs/pdfjs.meta.tree
Надеюсь в будущем нам удастся упростить эту интеграцию, но пока что так.
Окружение разработчика
Для старта нового проекта часто приходится настраивать очень много вещей. Именно поэтому появились всякие
create-react-app
иangular-cli
, но ни прячут от вас свои конфиги. Вы, конечно, можете сделатьeject
и эти конфиги переедут в ваш проект. Но тогда он станет намертво привязан к этой эджектнутой инфраструктуре. Если вы разрабатываете множество библиотек и приложений, то хотели бы единообразно работать с каждым из них, и вносить свои кастомизации сразу для всех.Идея: Что если инфраструктура будет отделена от кода?
Инфраструктура в случае MAM живёт в отдельном от кода репозитории. У вас может быть множество проектов в рамках одной инфраструктуры.
Проще всего начать работать с MAM форкнув репозиторий с базовой MAM инфраструктурой, где уже всё настроено:
На порту 8080 поднимется сервер разработчика. Всё, что вам останется - лишь писать код в соответствии с принципами MAM.
Заведите себе собственный неймспейс (для примера -
acme
) и пропишите в нём ссылки на ваши проекты (для примера -hello
иhome
):/acme/acme.meta.tree
Для сборки конкретных модулей достаточно приписать пути до них после
npm start
:Переводить уже существующий проект на эти рельсы довольно затруднительно. А вот начать новый - самое то. Попробуйте, будет сложно, но вам понравится. А если столкнётесь трудностями - пишите нам телеграммы: https://t.me/mam_mol