Modulite

Modulite

Честные модули внутри PHP

Что такое
Modulite?

В языке PHP очень не хватает нативных модулей: internal классов, private неймспейсов, явных export'ов. Любой класс доступен из любого места. Отсюда растёт связность кода, и растёт неконтролируемо: язык этому не препятствует.

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

Плагин — это по сути удобный UI над конфигами — файликами .modulite.yaml. Во время компиляции и на CI уже внешние интеграции проверяют код на соответствие правилам.

Ниже — подробности, скриншоты, примеры и ответы на все вопросы.

Модульность, часть 1: явные export

Представим, что в проекте работают Месси и Адам. Месси пишет мессенджер (папка Messinger), а Адам пишет админку (папка Adaminka). В мессенджере есть каналы и папки. Ещё есть нотификации: при добавлении в канал, при выходе и т.п.

screen-messi-adam

Адам пишет добавление юзера в канал из админки. Он вставляет напрямую юзера в базу через MsgDatabase и создаёт JoinNotification — "потому что релиз через 2 часа" или "а в чём проблема вообще?". Действительно, в чём? Ведь работает же. Сегодня — работает.

screen-adaminka-1

Месси узнаёт про этот произвол. Что, мол, за дела? Ведь только внутренности каналов должны слать нотификации, внешний код вообще не должен туда лезть. Внешний код не знает про нюансы, про флуд-контроль и т. п. Рассылка пушей, работа с базой и подобное — это удел имплементации мессенджера, в реальности там много бизнес-логики и проверок. Это очень плохо, если внешний код вызывает такие классы напрямую. Но увы, PHP позволяет так делать, и этим всегда пользуются.

И Месси решает: нужно запретить! Чтобы даже в админке такое не писали.

Он делает фокус: создаёт модуль @messi-channels из папки Channels/. Он убирает галочки в дереве Notifications/ — таким образом, неотмеченные классы становятся internal.

new-messi-channel

И вуаля! Теперь Адам не может создать недоступный класс. Ошибка видна в IDE, а в структуре файлов показываются @названия и internal-бейджи.

screen-adaminka-2

Физически это привело к тому, что создался файл Channels/.modulite.yaml, который содержит, в частности, список export.

new-messi-channel-yaml

Модульность, часть 2: явные require

Когда Месси создал модуль, он не только зафиксировал export'ы: он также зафиксировал внутреннее состояние (зависимости, dependencies, requires — это синонимы).

Допустим, в команду к Месси пришёл джун. У джуна задание: сделать метод isUserSubscribed(), который будет проверять, подписан ли пользователь на канал. Окей, подумал джун, пишет код, но видит ошибку:

current-user-err

В чём ошибка? Функция currentUser() никогда раньше не вызывалась изнутри модуля, она не добавлена в requires. На самом деле, причина ошибки в том, что $user_id нужно передавать снаружи, а не брать id текущего пользователя: вот функция и не вызывалась. "Окей", — подумал джун, — "Делов-то. Возьму и добавлю":

current-user-add

От ошибки-то джун избавился. Вот только это привело к изменению .modulite.yaml: добавилась зависимость. А значит, это будет видно на ревью.

В данном случае Месси скажет, что код не верный, модуль не должен зависеть от текущего пользователя. Но если бы был другой пример (не currentUser(), а что-то действительно нужное), было бы окей. В любом случае — появление новых зависимостей не пройдёт незамеченным. А если и пройдёт, это останется в истории Git.

current-user-yaml

Итого. Модуль — это обычная папка с PHP-кодом.
С одной стороны, она определяет доступ "внутрь" через export.
Всё, что не публичное, — значит, приватное.
С другой, она определяет доступ "наружу" через requires.
Нельзя использовать внешние символы, не разрешив это явно.

* * *

Цель модульности формулируется так: не допустить неконтролируемого разрастания энтропии внутри монолита. Предпосылки — именно изоляция отдельных папок в существующем коде.

VKCOM, как и другие огромные проекты, — клубок кода с крайне высокой связностью. Хочется его распутывать, только все друг другу мешаются, лишь добавляя новые связи. Многие порываются выносить папки в отдельные Composer-пакеты — но пока код не автономен, пока есть хоть один внешний вызов, это невозможно. И в 100% случаев у нас именно такая ситуация. Любой крупный неймспейс тесно связан с остальным кодом — как в прямом, так и в обратном направлении.

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

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

Из "существующих решений" можно отметить разве что аннотацию @psalm-internal. Она позволяет задать неймспейс, где функция или класс могут быть использованы. В целом эта аннотация решила бы вопрос публичного интерфейса, но если забыть пометить новый класс, то вся концепция пошатнётся. В нашей же концепции всё новое по умолчанию приватное, нужен явный export. К тому же фиксировать requires не менее важно для итеративного рефакторинга.

И кстати, важный момент. Когда модуль становится автономным — да, его можно вынести в пакет. А можно и не выносить, потому что зачем? Если не предполагается его подключать в другую репу, то лучше просто оставить в монолите. Ведь желание "вынести в пакет" возникает лишь потому, что есть ассоциация "пакет это хорошо, это изоляция". А если изоляция обеспечивается модульностью — то Composer уже и не нужен для этих целей.

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

Создание модуля из папки

В контекстом меню папки New -> Modulite from Folder.... Если папка большая, нужно чуть подождать, пока плагин сканирует содержимое. Покажется форма создания:

new-modulite-window-kernel

Там указывается имя модуля (по умолчанию по имени папки) и видимость символов галочками. Отмеченные это export, неотмеченные internal. Мы оперируем конкретными символами: никаких масок "по звёздочке" (что при создании, что впоследствии). Символы — это не только классы. Это также обычные функции, глобальные константы, дефайны. Да, Modulite оперирует символами гранулярно: зато можно насоздавать дефайнов в модуле, и если они internal, то внешний код их не увидит. Также в форме есть namespace и folder — но это скорее для сверки, они неизменяемы. Если модуль вложенный, то ещё информация про родительский.

Плагин автоматически сгенерирует requires, а также если нужно, перегенерирует зависимости других модулей. После нажатия "OK" откроется созданный .modulite.yaml.

Если вы сделали все символы публичными, то код остался валиден. Если же некоторые приватные и есть внешний код, который их использует — компиляция упадёт с ошибкой.

Делаем символы internal и обратно

Возле символов модуля @name есть надпись exported from @name (или internal in @name). Менять состояние можно либо через Alt+Enter, либо контекстным меню прям на этом хинте.

ui-make-symbol-internal

Область видимости есть не только у классов: у методов, у обычных функций и даже у дефайнов. Также помним, что все новые символы по умолчанию приватные (т.к. не экспортированы), о чём подсказки лишний раз напоминают.

ui-export-define

Кстати, эти хинты справа интерактивные: на @name можно кликнуть и перейти в yaml-файл.

Правила видимости следующие:

Делаем internal уже используемый класс

Представим ситуацию: есть класс SortPolicy в @messi-folders, он по идее деталь реализации и должен быть приватным, но уже где-то внешний код его уже использует. Если просто сделать его internal, то существующий код перестанет компилироваться. Что делать?

code-make-internal-used-class

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

Действие "Make internal in @name" делает это автоматически! Плагин анализирует использования в текущем коде и добавляет их в исключения.

После этого хинт будет "internal in @name (visible for ...)", а само использование хоть и разрешено, но перечёркнуто, будто бы deprecated.

code-made-internal-used-class

Физически это привело к тому, что в конфиг добавилось allow-internal-access:

allow-internal-access-sort

Разрешаем символ самостоятельно

Выше мы доверялись плагину в открытии доступа ко внутренним символам извне. Однако мы можем сделать это и вручную. Например, мы хотим разрешить использовать SortPolicy не в конкретной глобальной функции, а во всём модуле @api. Можно нажать "Allow internal access for specific modulite" и там уже выбрать из списка.

code-make-internal-for-modulite
window-make-internal-for-modulite

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

Скрываем члены публичного класса

Вряд ли это будет частым, но иногда хочется запретить доступ к конкретному методу снаружи, оставив его public. Спрашивается, зачем, ведь можно сделать метод private? Не всегда. Если это класс, который используется из других классов модуля — то модификатор доступа не подходит. При этом сам класс зачем-то должен быть export. Вряд ли это хорошая архитектура — но напомню, что модульность в первую очередь обеспечивает потребности уже существующего кода с порой сомнительной архитектурой, чтобы не допускать ещё хуже.

Здесь на помощь приходит форсирование internal для членов класса. Опять-таки, это можно сделать как через Alt+Enter, так и мышкой на хинте.

code-make-internal-method

Это привело к модификации конфига, а конкретнее — секции force-internal:

code-make-internal-method-yaml

Если у метода уже были внешние использования, то как и раньше, плагин это обнаружит и также дополнит allow-internal-access.

Новый код и require

Надеюсь, вы помните, что в секции require конфига указываются все вншение символы, разрешённые к использованию. При первичном создании модуля плагин его создаёт автоматически. Что там перечисляется:

code-requires-list-yaml

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

code-requires-list-yaml-hover

При написании нового кода плагин будет проверять, что используются только указанные зависимости, иначе ошибка, что символ не прописан в requires. Есть quick fix для исправления, это приведёт к изменению yaml-файлика — явная фиксация новой зависимости, что видно на ревью и остаётся в гите.

code-requires-unknown-global

Если подключен другой модуль — можно использовать любой его символ, аналогично с композер-пакетами. Если подключен класс — можно использовать его константы, поля, и инстанс-методы, а вот статик-методы нужно перечислять отдельно каждый. Quick fix обо всём этом знает и будет предлагать "Add @api to requires", если обращаешься к любому символу из @api.

В текущем VKCOM-коде, пока модулей мало, каждый новый модуль будет содержать десятки внешних зависимостей на глобал классы и функции. С течением времени, когда всё больше кода будет оформляться в модули, зависимости на конкретные символы будут переделываться на зависимости от модулей. В идеале, модули должны стремиться только к зависимостям от других модулей и пакетов. Так, в примере выше явно перечисляются \Messinger\Kernel\... символы. А вот когда Месси сделает из этого модуль, то эти зависимости схлопнутся в одну (опять-таки автоматически при создании @messi-kernel):

code-requires-after-messi-kernel

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

Регенерация зависимостей модуля

По мере рефакторинга, некоторые символы в requires становятся уже не нужны, то есть список устаревает. Чтобы привести его в актуальное состояние, полезно иногда нажимать "click to regenerate" — над пунктом или в контекстном меню:

ui-menu-regenerate-requires

Плагин заново пересчитает зависимости, проапдейтит yaml-файл и выдаст сообщение справа внизу. Клик по "Show details" покажет, что конкретно изменилось:

window-requires-regenerated

Вложенные модули, подмодули

Модули могут быть вложены друг в друга. При этом имя подмодуля должно начинаться с имени его родителя. Например, если родительский @messi, то дочерние @messi/folders и т.п.

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

  Messinger/
    Channels/    @messi-channels
      ...
    Folders/     @messi-folders
      ...
    Kernel/      @messi-kernel
      ...

В таком виде, это 3 независимых модуля, объединённых только общим namespace, но не общими правилами. Внешний код может использовать любой из них (в рамках разрешённых export'ов). Иными словами, в этой структуре админка может лезть в ядро мессенджера, и ровно по тем же правилам, что и каналы.

Грамотнее — не так. Грамотнее — сделать внешний модуль @messi и 3 подмодуля. При этом @messi/kernel чтоб был приватным. Такая структура не позволит админке лезть в ядро мессенджера, а каналы и папки будут работать по-прежнему:

  Messinger/     @messi
    Channels/    @messi/channels
      ...
    Folders/     @messi/folders
      ...
    Kernel/      @messi/kernel (internal)
      ...

Итого, просто делаем "New Modulite from Folder" и настраиваем галочки:

window-create-messi-outer

Выглядит это так:

code-messi-outer-before-rename-yaml

Обратите внимание, что все дочерние модули переменовались: @any-child стал называться @messi/any-child. Вероятно, их захочется вручную укоротить, чтобы было лаконичнее. Это легко делать через Rename, когда курсор на имени модуля в yaml-файлике.

ui-messi-channel-rename

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

ui-messi-outer-tree

Как и ожидается, из админки @messi/kernel недоступно, даже если указать вручную в requires. А вот из каналов и других внутренностей мессенджера — по-прежнему без проблем (конечно же, следуя правилам на export, которые наложены kernel'ом). По сути, вложенный модуль тоже может быть атомарной деталью реализации.

code-messi-kernel-denied-yaml

Вообще, это нормальный (и даже рекомендуемый) процесс: сначала делать модулями какие-нибудь небольшие и внутренние вещи, называя модули длинно, по типу @feed-smart-blocks-proxy. А потом по мере того как код рефакторится и появляется структура, уже оформлять родительские модули, укорачивая дочерние.

Кстати, внешний код может подключать как @messi/folders и другие подмодули поодиночке, так и подключить @messi целиком и использовать все доступные публичные символы, которые существуют сейчас и появятся в будущем.

Find usages внутри модуля

Одной из важных фичей плагина является возможность отвечать на вопросы "Где?" и "Что?". Допустим, вы лазите по коду и видите вызов currentUser(). Либо смотрите yaml-файлик и видите его в requires. И сразу же возникает вопрос: окей, мой модуль зависит от этой функции, но насколько сильно? Если там пара вызовов, это легко переделать, а если 100, то печаль.

В контекстом меню любого символа, помимо обычного "Find usages", добавляется новый пункт: "Find usages in @{current}...":

ui-find-usages-in-api

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

Пунктом ниже в том же меню есть "Find usages in Modulite...". Выскочит окошко, где нужно выбрать модуль, и поиск будет идти внутри него, а не в текущем.

window-find-usages-in

Степень зависимости от другого модуля

Мы же помним, что когда используем символ из модуля, то добавляем в requires сам модуль. Так, для использования MsgDatabase подключаем @messi/kernel. Впоследствии, если используем другие символы из модуля, requires уже менять не нужно. И бывает, хочется узнать: насколько сильно я завишу от @messi/kernel? Возможно, я использую только один класс оттуда, а возможно, целых десять.

Делается в контекстном меню на интересующем модуле в yaml-файлике:

ui-find-usages-of-another-m

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

window-find-usages-of-another-m

Имена модулей в интерфейсе

Интерфейс IDE чуть модифицируется, чтобы визуально подсвечивать наличие модулей.

Во-первых, возле классов, статик-методов и некоторых других символов всегда пишется напоминание, internal он или нет. При этом, если вложенность длинная, то хинт сворачивается до @.../childname, а при клике на многоточие раскрывается.

code-static-method-hint-collapsed

Хинты кликабельные, можно перейти в любого родителя прям здесь:

code-static-method-hint-hover

В дереве файлов тоже отображаются имена модулей, пропуская родительские, для читаемости. То же самое и в строке навигации. В дереве файлов есть постфикс (internal). Меняются иконки папок-модулей: если нет дочерних, то один квадратик, а если есть, то четыре; если экспортирован из родителя, то яркий, иначе оранжевый.

ui-names-in-tabbar ui-names-in-tree

Плагин — это удобный UI над конфигом (файликом .modulite.yaml).
Всё это можно делать и без плагина — просто не так удобно.
Именно файлик хранится под гитом, именно изменения в нём
видны на ревью при добавлении зависимостей или исключений.

Структура файла .modulite.yaml

name: "@modulite-name"
description: "..."
namespace: "Some\\Namespace\\"

export:
  - "ClassInNamespace"
  - "OrConcrete::CLASS_MEMBER"
  and others

force-internal:
  - "ClassInNamespace::staticMethod()"
  and others

require:
  - "@another-modulite"
  - "#composer/package"
  - "\\GlobalClass"
  and others

allow-internal-access:
  "@rpc":
    - "ClassAllowedForRpcModule"
    - "OrConcrete::method()"
  and others

name — уникальное в пределах проекта, начинается с @. По нему можно ссылаться на модуль. Дочерние модули префиксированы именем родителя, типа @api/exceptions. Когда курсор на имени, работает "Refactor | Rename".

description — произвольная строка, на логику никак не влияет.

namespace — пространство имён, в целом согласно autoload-стандарту равно пути к папке. Служит для резолвинга относительных имён в конфиге: так, "Relative\\Symbol" резолвится в класс \Some\Namespace\Relative\Symbol. Если модуль находится в глобальном пространстве имён, то здесь пустая строка или просто слеш.

export — публичные символы модуля, список строк. Символы, не перечисленные здесь явно, являются приватными (internal). Без лидирующего слеша (относительно namespace), см. выше. Естественно, нельзя указывать здесь символы, объявленные за пределами модуля.

force-internal — убрать видимость у членов публичных классов. По умолчанию класс в export открывает доступ ко всем public-символам, а здесь можно их форсированно скрыть.

require — список внешних символов, к которым разрешено обращаться из кода модуля. Если обратиться к символу, который не перечислен, будет ошибка. Здесь строки уже начинаются со слеша, ведь они не локальны относительно namespace.

allow-internal-access задаёт исключения, по каким правилам внешнему коду разрешено использовать internal символы. Это стоит раскрыть подробнее.

allow-internal-access

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

Здесь важно, что если А хочет подлезть в модуль В, то не А пишет исключение, а именно В.

На примере. Пусть Адам в своей админке всё-таки хочет залезть в ядро мессенджера, а оно приватное. Но он не может написать у себя "я разрешаю себе лезть в ядро". А как тогда? А вот так: Адам идёт к Месси и объясняет, зачем ему лезть в мессенджер. И вот тут уже возможны варианты. Может быть, Месси что-то не предусмотрел, и функциональности в его модуле действительно не хватает. Тогда он должен её реализовать, сделать публичный API, и Адам будет его использовать. Может быть, Месси просто забыл экспортировать символ — тогда он изменит свой конфиг, и всё. А может быть, действительно там какой-то corner case, который решать долго — вот тогда Месси и правда через конфиг @messi-kernel разрешит подлезть куда нужно. Но — только Адаму, только из конкретного места админки, и больше никому. А то место в конфиге самому Месси будет мозолить глаза, напоминая избавиться от этого техдолга в будущем.

Сами правила пишутся так. Ключ — это функция, класс или модуль, которому разрешаем. Значение — это список символов, ровно такой же, как в export. Пример:

allow-internal-access:
  "@adaminka":
    - "MsgDatabase::insertUser()"
    - "MsgDatabase::TABLE_MESSAGES"
  "\\SomeGlobalClass\\itsMethod()":
    more exceptions

Именно благодаря конфигу работают проверки
во время компиляции, в Git-хуках и в Teamcity.
Поэтому даже если кто-то не пользуется IDE,
он не сможет запушить в обход модульности.

Компиляция, инструменты, деплой

Для полноценного использования должен существовать не только плагин.
Ведь не все пользуются IDE, а код в master может попасть и в обход.

KPHP

В KPHP встроена полная поддержка Modulite. Он читает yaml-файлики и проверяет код на все правила.

noverify

В наш линтер Modulite не встроен, т.к. это несовместимо с diff-режимом. Да и не нужно: у нас везде KPHP.

PHPStan

Для сообщества мы сделали PHPStan-плагин. Это позволяет использовать Modulite и в обычных PHP-проектах.

CI и git hooks

На препуше гоняется KPHP, так что пройдут все проверки. А если пушнуть в обход, то при сборке уж точно свалится.

PHPStorm

В PHPStorm все проверки выполняются этим плагином. Он автоматически генерит конфиги и показывает ошибки.

Другие IDE

Для других IDE плагинов нет. Я не понимаю, как можно разрабатывать большие проекты, сидя в vim.

Modulite + KPHP

KPHP помимо php-исходников теперь читает yaml-файлики. Если они содержат ошибки, компиляция прерывается в самом начале. Далее идёт обычный анализ кода с параллельной проверкой модульности. Из неочевидного — анализ кода разбит на этапы: сначала встраивание констант, потом применение PHPDoc, потом связка функций (call graph) и т.д. Поэтому, если ошибка модульности при инлайне констант, KPHP не пойдёт дальше. Аналогично, если невалидно используются классы в тайпхинтах, он не будет анализировать вызовы функций. Кстати, KPHP расценивает Composer-пакеты как неявные модули, так что автоматически проверяет нужные requires, а также то, что пакеты не лезут в код монолита.

Ошибки компиляции выглядят максимально понятно:

screen-err-kphp

Modulite + PHPStan

Чтобы модульность можно было использовать в обычных PHP-проектах, мы решили сделать плагин Modulite для PHPStan. Это совершенно отдельное от KPHP решение, полностью с нуля, с учётом PHPStan-специфики. Мы научили его парсить yaml-файлы, резолвить символы через рефлексию, а потом проверять модульность на классах, методах и функциях — по тем же самым правилам, что в IDE и в KPHP. Поэтому при дальнейшей модернизации нужно будет поддерживать 3 независимых решения. Из неочевидного — это PHPStan-кеш, который так и не удалось победить. Модульность устроена так, что при изменении yaml-правил могут появляться или исчезать ошибки в PHP-коде, который вообще не менялся. Для KPHP это не проблема: он анализирует проект целиком. А вот PHPStan не запускает анализ на нетронутых файлах, поэтому может выдавать старые ошибки. Если будет запрос от сообщества, можно поизучать эту проблему и связаться с разработчиками PHPStan, а пока что при сбросе кеша это работает всегда.

Ошибки анализа выдаются в традиционном для PHPStan виде:

screen-err-phpstan

Modulite удобно использовать не только в монолите.
Ещё, например, разрабатывая любой Composer-пакет:
почему бы не писать его внутренности модульными?
И даже задавать export у пакета, чего в Composer вообще нет.

Modulite + Composer

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

На примере. Пусть Герасим пишет отдельный пакет для преобразования речи в текст. Это отдельный репозиторий voice-text, который тоже включает в себя модули (в частности, @impl):

code-voice-text-screen

Как и ожидается, в EmojiTable залезть нельзя снаружи, это ведь internal. Сам модуль @impl обязан перечислять requires и т.п. В общем, пакет Герасима ничем не отличается от обычного проекта.

А теперь Месси внедряет расшифровку аудиосообщений. Он подключает пакет через Composer, как обычно. Чтобы использовать внутри мессенджера, должен явно существовать #vk/voice-text в requires (при использовании любого символа плагин это сам предложит и вставит):

yaml-require-vk-voicetext

На самом деле при подключении пакета в монолит он становится неявным модулем. С точки зрения модульности, папка vendor/vk/voice-text — это модуль с названием #vk/voice-text. Все внутренние модули префиксируются: так, модуль @impl внутри пакета имеет название #vk/voice-text/@impl внутри монолита. Это позволяет избежать конфликта имён. У пакета не задан export, и для неявных модулей действует логика "в таком случае разрешено всё". Впрочем, у модуля #vk/voice-text/@impl собственный export вполне себе есть, и все проверки будут срабатывать.

Так, если Месси решит залезть внутрь имлементации Герасима, защищённой модулем внутри пакета, он получит ошибку компиляции:

screen-use-vk-voicetext-kphp-error

export из Composer-пакета

Отвлечёмся от модулей и переключимся на обычный Composer. Что такое пакет? Это просто классы/функции, лежащие отдельно, и способ автолоадить их. Но по факту, ничто нам не мешает из кода монолита обращаться к любым классам, которые есть в пакете. Даже если пакет в документации описывает предполагаемое использование, в любом случае у него есть какие-то внутренние хелперы и приватные части — которые тем не менее доступны из монолита. Просто в случае с пакетами такое происходит значительно реже, чем когда код в одном репозитории, но всё равно: препятствий никаких нет.

Modulite расширяет Composer-пакеты: появляется возможность явных export'ов, чтобы уже точно никто не мог использовать что не предполагается.

Делается это так: на корневом уровне, рядом с composer.json, создаём файлик .modulite.yaml.

Важно: вам следует писать name = "<composer_root>" как есть. Не следует заменять "<composer_root>" именем вашего пакета из композера.

Например, Герасим укажет TextToSpeech и WaveMultiplier внутри export, поэтому Transliteration будет internal:

code-modulite-at-composer-root

Теперь из монолита нет доступа к Transliteration. И к неймспейсу VK\VoiceText\impl тоже нет. А вот если бы Герасим упомянул @impl в export, то был бы (в рамках публичных символов @impl опять же):

screen-use-vk-voicetext-impl-error

Подводим итоги: Modulite дополняет язык PHP модулями,
не вмешиваясь в его синтаксис, а удобно находясь рядом.
Он подходит как для старта новых проектов, так и чтобы
зафиксировать состояние монолита и рефакторить итеративно.

Почему Modulite так называется

Я недолюбливаю слово "модуль", оно слишком общее и встречается везде. Не хочется, чтобы наши модули путали с JS-модулями, с Composer-модулями и так далее. Тем более, вдруг в PHP когда-то появятся нативные модули. Так что хотелось какое-то похожее, но в то же время уникальное название. И чтобы была связь с монолитом — всё-таки модули прежде всего для него.

modulite-etimology

Модулит — это модуль внутри монолита. Можно говорить и "модуль", считаем это синонимами. А в IDE всегда пишется modulite, чтобы однозначно определять контекст.

Как начать использовать Modulite?

Специально для старта мы создали отдельную репу — modulite-example-project.
Это PHP-проектик, который содержит несколько ошибок. Они видны и в IDE, и в PHPStan, и в KPHP.
Открыть, разобраться, в чём ошибки, починить — в общем, исправить косяки Месси и Адама.

Ну либо пойти сразу фигачить реальный код :)

Не забудьте плагин только поставить в IDE. Это немножко нетривиально в случае РФ.
Подробнее тут: Установка