Честные модули внутри PHP: теперь они существуют
Низкий порог входа и строгость языка программирования — вещи обычно несовместимые. Потому что ты либо, как Rust, бьёшь по рукам borrow checker’ом — либо, как PHP, позволяешь не задумываться о типах и быстро прототипировать.
На самом деле, если писать код грамотно, это становится неважным и язык перестаёт иметь значение. Архитектура важнее языка, и хороший код на PHP ничем не отличается от аналогичного кода на любом другом ООП-языке. Другое дело, что возможность «любой домохозяйке» писать на PHP сопровождается и риском наворотить полное неподдерживаемое безобразие. Поэтому нам нужны тайпхинты, линтеры, статические анализаторы и подобные инструменты.
Но в PHP есть и ещё один изъян: в нём любой класс, функция или константа — глобальны. Можно создать класс из любого места в коде, и нет способа скрыть его или сделать деталью реализации где-то в отдельной папке. Иными словами, в PHP нет того, что в других языках называется модулями.
Наша новая open-source разработка называется Modulite и внедряет в PHP модули. Это сквозная технология: мы внедряемся в IDE, в PHPStan, в KPHP, в CI, в Composer — и делаем так, будто бы модули нативно есть в языке PHP.
Совсем недавно я рассказывал о Modulite на PHP Russia в рамках HighLoad++. Уже есть видео доклада — можно посмотреть его вместо чтения, если это удобнее, а к статье вернуться за ссылками.
Modulite + internal
Представим, что в проекте работают Месси и Адам. Месси пишет мессенджер (папка Messinger
), а Адам пишет админку (папка Adaminka
). В мессенджере есть каналы и папки. Ещё есть нотификации: при добавлении в канал, при выходе и т. п.
Адам пишет добавление юзера в канал из админки. Он вставляет напрямую юзера в базу через MsgDatabase
и создаёт JoinNotification
— «потому что релиз через 2 часа» или «а в чём проблема вообще?». Действительно, в чём? Ведь работает же. Сегодня — работает.
Месси узнаёт про этот произвол. Что, мол, за дела? Ведь только внутренности каналов должны слать нотификации, внешний код вообще не должен туда лезть. Внешний код не знает про нюансы, про флуд-контроль и т. п. Рассылка пушей, работа с базой и подобное — это удел имплементации мессенджера, в реальности там много бизнес-логики и проверок. Это очень плохо, если внешний код вызывает такие классы напрямую. Но увы, PHP позволяет так делать, и этим всегда пользуются.
И Месси решает: нужно запретить! Чтобы даже в админке такое не писали.
Месси понимает, что эффективно сделать это можно только на уровне инструментов — не документации или договорённостей. К счастью, у него в арсенале есть Modulite, и он делает фокус: кликает New → Modulite from Folder…
и создаёт модуль @messi-channels
из папки Channels/
. Он убирает галочки в дереве Notifications/
— таким образом, неотмеченные классы становятся internal.
И вуаля! Теперь Адам не может создать недоступный класс. Ошибка видна в IDE, а в структуре файлов показываются @названия и internal-бейджи.
Физически это привело к тому, что создался файл Channels/.modulite.yaml
, который содержит, в частности, список export.
Modulite + require
Когда Месси создал модуль, он не только зафиксировал export'ы: он также зафиксировал внутреннее состояние (зависимости, dependencies, requires — это синонимы).
Допустим, в команду к Месси пришёл джун. У джуна задание: сделать метод isUserSubscribed()
, который будет проверять, подписан ли пользователь на канал. Окей, подумал джун, пишет код, но видит ошибку:
В чём ошибка? Функция currentUser()
никогда раньше не вызывалась изнутри модуля, она не добавлена в requires
. На самом деле причина ошибки в том, что $user_id
нужно передавать снаружи, а не брать id текущего пользователя: вот функция и не вызывалась.
«Окей», — подумал джун, — «Делов-то. Возьму и добавлю»:
От ошибки-то джун избавился. Вот только это привело к изменению .modulite.yaml
: добавилась зависимость. А значит, это будет видно на ревью.
В данном случае Месси скажет, что код неверный, модуль не должен зависеть от текущего пользователя. Но если бы был другой пример (не currentUser()
, а что-то действительно нужное), было бы окей. В любом случае — появление новых зависимостей не пройдёт незамеченным. А если и пройдёт, это останется в истории Git.
Modulite + определение
Итого. Модуль — это обычная папка с PHP-кодом.
С одной стороны, она определяет доступ «внутрь» через export.
Всё, что не публичное, — значит, приватное.
С другой, она определяет доступ «наружу» через requires.
Нельзя использовать внешние символы, не разрешив это явно.
Modulite + монолит
Цель модульности формулируется так: не допустить неконтролируемого разрастания энтропии внутри монолита.
Предпосылки — именно изоляция отдельных папок в существующем коде.
VKCOM, как и другие огромные проекты, — это клубок кода с крайне высокой связностью. Хочется его распутывать, только все друг другу мешаются, лишь добавляя новые связи. Многие порываются выносить папки в отдельные Composer-пакеты — но пока код не автономен, пока есть хоть один внешний вызов, это невозможно. И в 100% случаев у нас именно такая ситуация. Любой крупный неймспейс тесно связан с остальным кодом — как в прямом, так и в обратном направлении.
Вынос в пакет должен происходить итеративно, постепенно уменьшая зависимости от остального монолита. Проблема в том, что пока вы это делаете, другие разработчики всё равно используют что не нужно, заново связывая код с монолитом. Это происходит непредумышленно, поскольку нет инструментов для контроля. Точнее, не было. Теперь есть.
Модульность позволяет изолировать отдельные участки кода, которые подчиняются правилам, заданным владельцами этого участка. Это позволяет рефакторить код, постепенно уменьшая число зависимостей — и гарантируя, что новых не появляется. В идеале модуль стремится к полной автономности, и тогда его можно вынести в пакет.
Из «существующих решений» можно отметить разве что аннотацию @psalm-internal
и deptrac. Первая позволяет задать неймспейс, где функция или класс могут быть использованы. В целом эта аннотация решила бы вопрос публичного интерфейса, но если забыть пометить новый класс, то вся концепция пошатнётся. В нашей же концепции всё новое по умолчанию приватное, нужен явный export. К тому же фиксировать requires не менее важно для итеративного рефакторинга. Про deptrac — это вообще другое, распишу отдельно в конце статьи.
И кстати, важный момент. Когда модуль становится автономным — да, его можно вынести в пакет. А можно и не выносить, потому что зачем? Если не предполагается его подключать в другую репу, то лучше просто оставить в монолите. Ведь желание «вынести в пакет» возникает лишь потому, что есть ассоциация «пакет — это хорошо, это изоляция». А если изоляция обеспечивается модульностью — то Composer уже и не нужен для этих целей.
Modulite + PHPStorm
Modulite полностью интегрирован в PHPStorm. Можно создавать модули из существующего кода, хоткеями делать приватные классы и сразу в редакторе видеть ошибки.
Создание модуля из папки
В контекстном меню папки кликаем New -> Modulite from Folder…
.
Там указывается имя модуля (по умолчанию по имени папки) и видимость символов (галочками). Отмеченные — это export, неотмеченные — internal. Мы оперируем конкретными символами: никаких масок «по звёздочке» (что при создании, что впоследствии). Символы — это не только классы. Это также обычные функции, глобальные константы, дефайны. Да, Modulite оперирует символами гранулярно. Зато можно насоздавать дефайнов в модуле, и если они internal, то внешний код их не увидит.
Плагин автоматически сгенерирует requires и, если нужно, перегенерирует зависимости других модулей. После нажатия «OK» откроется созданный .modulite.yaml
.
Делаем символы internal
Возле символов модуля @name есть надпись exported from @name
(или internal in @name
). Менять состояние можно либо через Alt+Enter, либо через контекстное меню прямо на этом хинте. Область видимости есть не только у классов: ещё у методов, у обычных функций и даже у дефайнов. Также помним, что все новые символы по умолчанию приватные (т. к. не экспортированы) — об этом и подсказки лишний раз напоминают.
Делаем internal уже используемый класс
Представим ситуацию: есть класс SortPolicy
в @messi-folders
. Он, по идее, деталь реализации и должен быть приватным, но уже где-то внешний код его использует. Если просто сделать его internal, то существующий код сломается. Что делать?
Ответ такой: можно сделать его internal, но добавить конкретные места, в которых он уже используется, в исключения. Таким образом, существующий код будет работать (и от исключений в будущем нужно избавиться), а новый код уже не сможет использовать internal-класс. Так мы зафиксируем текущее состояние, но не позволим ему становиться хуже.
Действие Make internal in @name
делает это автоматически! Плагин анализирует использования в текущем коде и добавляет их в исключения.
Новый код и require
В секции конфига require
указываются все внешние символы, разрешённые к использованию. При первичном создании модуля плагин делает это автоматически. Там перечисляются другие модули, Composer-пакеты, внешние классы, дефайны и константы, глобальные переменные.
При написании нового кода плагин будет проверять, что используются только указанные зависимости, иначе выдаст ошибку. Есть quick fix для её исправления, который приведёт к изменению yaml-файлика и явной фиксации новой зависимости. Это будет видно на ревью и останется в Git.
Пока в проекте модулей мало, каждый новый будет содержать десятки внешних зависимостей от глобальных классов и функций. С течением времени, когда всё больше кода будет оформляться в модули, зависимости от конкретных символов будут заменяться на зависимости от модулей. В идеале модули должны стремиться только к зависимостям от других модулей и пакетов.
Таким образом, в yaml-файле мы всегда видим, насколько модуль привязан к внешнему коду — и к какому конкретно.
Вложенные модули, подмодули
Модули могут быть вложены друг в друга. При этом имя подмодуля должно начинаться с имени его родителя. Например, если родительский @messi
, то дочерние @messi/folders
и т. п.
С точки зрения родительского модуля, подмодуль может как экспортироваться наружу, так и остаться приватным. Пусть есть структура мессенджера, с тремя модулями:
Messinger/
Channels/ @messi-channels
...
Folders/ @messi-folders
...
Kernel/ @messi-kernel
...
В таком виде это три независимых модуля, объединённых только общим неймспейсом, но не общими правилами. Внешний код может использовать любой из них (в рамках разрешённых export’ов). Иными словами, в этой структуре админка может лезть в ядро мессенджера, и ровно по тем же правилам, что и другой код мессенджера рядышком.
Грамотнее — не так. Грамотнее — сделать внешний модуль @messi
и три подмодуля. При этом чтобы @messi/kernel
был приватным.
Messinger/ @messi
Channels/ @messi/channels
...
Folders/ @messi/folders
...
Kernel/ @messi/kernel (internal)
...
В общем, просто делаем New Modulite from Folder
на Messinger/
и настраиваем галочки:
Как и ожидается, из админки @messi/kernel
недоступен, даже если указать его вручную в requires. А вот из каналов и других внутренностей мессенджера — по-прежнему без проблем (конечно, следуя правилам на export, которые наложены kernel’ом). По сути, вложенный модуль тоже может быть атомарной деталью реализации.
Вообще, это нормальный (и даже рекомендуемый) процесс: сначала делать модулями какие-нибудь небольшие и внутренние вещи, называя модули длинно, по типу @feed-smart-blocks-proxy
. А потом, по мере того как код рефакторится и появляется структура, уже оформлять родительские модули, укорачивая дочерние.
Find usages внутри модуля
Одна из важных фич плагина — возможность отвечать на вопросы «Где?» и «Что?». Допустим, вы лазите по коду и видите вызов currentUser()
. Либо смотрите yaml-файлик и видите его в requires. И сразу возникает вопрос: окей, мой модуль зависит от этой функции, но насколько сильно? Если там пара вызовов, это легко переделать, а если 100, то печаль.
В контекстном меню любого символа, помимо обычного Find usages, добавляется новый пункт — Find usages in @{current}
:
Плагин покажет нативное окошко — только с фильтрацией внутри текущего модуля. И даже если в проекте символ используется тысячи раз, а внутри модуля только два — будет видно только два.
И другие интерфейсные штуки
Есть и другие удобства, но чтобы не раздувать статью, не буду их перечислять. Вкратце — влазим в IDE везде, где только можно. И это становится настолько интуитивно, что даже документацию писать не нужно.
Modulite + yaml config
Плагин — это удобный UI над конфигом (файликом .modulite.yaml).
Всё это можно делать и без плагина — просто не так удобно.
Именно файлик хранится под Git’ом, именно изменения в нём
видны на ревью при добавлении зависимостей или исключений.
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 — пространство имён. Служит для резолвинга относительных имён в конфиге: так, Relative\\Symbol
резолвится в класс \Some\Namespace\Relative\Symbol
.
export — публичные символы модуля, список строк. Символы, не перечисленные здесь явно, являются приватными (internal). Без лидирующего слеша (относительно namespace).
someFunction()
делает публичной функцию вне класса; особенно актуально для модулей, содержащих просто набор функций в global scope.SOME_DEFINE
экспортирует глобальную константу или define.ClassName
делает публичный класс; по умолчанию все члены этого класса тоже доступны, то есть вступают в игру обычные public / private / protected модификаторы, с помощью которых разработчик контролирует видимость членов. Решение для более сложной логики покажу далее.
force-internal — убрать видимость у членов публичных классов. По умолчанию класс в export открывает доступ ко всем public-символам, а здесь можно их форсированно скрыть.
require — список внешних символов, к которым разрешено обращаться из кода модуля. Если обратиться к символу, который не перечислен, будет ошибка. Здесь строки уже начинаются со слеша, ведь они не локальны относительно namespace.
@another-modulite
подключает другой модуль и все публичные символы в нём.#composer/package
подключает Composer-пакет и все публичные символы в нём.\VK\SomeClass
подключает глобальный класс (т. е. не принадлежащий никакому модулю; ведь если он принадлежит, то нужно подключать модуль, а не сам класс).\someFunction()
подключает глобальную функцию.\SOME_DEFINE
подключает глобальную константу или define.some_var
для использования внутри выраженияglobal $some_var
.
allow-internal-access задаёт исключения, по каким правилам внешнему коду разрешено использовать internal-символы. Это стоит раскрыть подробнее.
Modulite + исключения
Ввиду того что код в монолите очень связный, не всегда удаётся обеспечить один публичный интерфейс для всех. Часто бывает, что хочется сделать класс внутренним, но уже где-то есть его использования. И публичным оставлять его не хочется, чтобы новых использований не появлялось.
Здесь важно, что если А хочет подлезть в модуль В, то не А пишет исключение, а именно В.
На примере. Пусть Адам в своей админке всё-таки хочет залезть в ядро мессенджера, а оно приватное. Но он не может написать у себя «я разрешаю себе лезть в ядро». А как тогда? А вот так: Адам идёт к Месси и объясняет, зачем ему лезть в мессенджер. И вот тут уже возможны варианты. Может быть, Месси что-то не предусмотрел, и функциональности в его модуле действительно не хватает. Тогда он должен её реализовать, сделать публичный API, и Адам будет его использовать. Может быть, Месси просто забыл экспортировать символ — тогда он изменит свой конфиг, и всё. А может быть, действительно там какой-то corner case, который решать долго — вот тогда Месси и правда через конфиг @messi-kernel
разрешит подлезть куда нужно. Но — только Адаму, только из конкретного места админки, и больше никому.
Сами правила пишутся так. Ключ — это функция, класс или модуль, которому разрешаем. Значение — это список символов, ровно такой же, как в export. Пример:
allow-internal-access:
"@adaminka":
- "MsgDatabase::insertUser()"
- "MsgDatabase::TABLE_MESSAGES"
"\\SomeGlobalClass\\itsMethod()":
# more exceptions
Modulite + PHPStan
Именно благодаря конфигу работают проверки
во время компиляции, в Git-хуках и в Teamcity.
Поэтому даже если кто-то не пользуется IDE,
он не сможет запушить код в обход модульности.
Чтобы модульность можно было использовать в обычных PHP-проектах, мы решили сделать плагин Modulite для PHPStan. Мы научили его парсить yaml-файлы, резолвить символы через рефлексию, а потом проверять модульность на классах, методах и функциях — по тем же правилам, что и в IDE.
Из неочевидного — это PHPStan-кеш, который так и не удалось победить. Модульность устроена так, что при изменении yaml-правил могут появляться или исчезать ошибки в PHP-коде, который вообще не менялся. Но PHPStan не запускает анализ на нетронутых файлах, поэтому может выдавать старые ошибки. Если будет запрос от сообщества, можно поизучать эту проблему и связаться с разработчиками PHPStan. А пока при сбросе кеша это работает всегда.
Ошибки анализа выдаются в традиционном для PHPStan виде:
Modulite + KPHP
KPHP помимо php-исходников теперь читает yaml-файлики. Если они содержат ошибки, компиляция прерывается в самом начале. Далее идёт обычный анализ кода с параллельной проверкой модульности. Из неочевидного — анализ кода разбит на этапы: сначала встраивание констант, потом применение PHPDoc, потом связка функций (call graph) и т. д. Поэтому, если ошибка возникает при инлайне констант, KPHP не пойдёт дальше. Аналогично, если невалидно используются классы в тайпхинтах, он не будет анализировать вызовы функций. Кстати, KPHP расценивает Composer-пакеты как неявные модули, так что автоматически проверяет нужные requires, а также то, что пакеты не лезут в код монолита.
Более того, именно для KPHP была придумана и сделана изначальная концепция. И лишь потом устоявшаяся версия была выложена как PHPStan-плагин, чуть ли не полной копипастой плюсовой имплементации. Поэтому при дальнейшем расширении нужно будет поддерживать три независимых решения: в IDE, в KPHP и в PHPStan. Но от этого никуда не уйдёшь.
Modulite + деплой
Давайте резюмируем, где поддерживается Modulite и как обеспечиваются проверки при деплое:
в KPHP полная поддержка, используется в VKCOM на продакшене;
в PHPStan та же версия, у нас не используется, возможны специфические PHPStan-баги;
в noverify нет поддержки, но она и не нужна;
в Psalm не делали, но теоретически можно;
в Git Hooks и TeamCity-пайплайнах проверяется либо PHPStan, либо KPHP;
в PHPStorm всё поддерживается плагином;
для других IDE не делали, но можно (наверное).
Modulite + Composer
Modulite удобно использовать не только в монолите.
Ещё, например, разрабатывая любой Composer-пакет:
почему бы не писать его внутренности модульными?
И даже задавать export у пакета, чего в Composer вообще нет.
Философия такая: разрабатывая пакет, можно тоже пользоваться модульностью. Как и в монолите, создавать внутренние папочки со своими областями ответственности, контролировать requires и т. п. Это помогает структурировать код, что особенно актуально для больших пакетов.
На примере. Пусть Герасим пишет отдельный пакет для преобразования речи в текст. Это отдельный репозиторий voice-text
, который тоже включает модули (в частности, @impl
):
Как и ожидается, в EmojiTable
нельзя залезть снаружи, это ведь internal. Сам модуль @impl
обязан перечислять requires и т. п. В общем, пакет Герасима ничем не отличается от обычного проекта.
А теперь Месси внедряет расшифровку аудиосообщений. Он подключает пакет через Composer, как обычно. Чтобы использовать его внутри мессенджера, должен явно существовать #vk/voice-text
в requires (при использовании любого символа плагин это сам предложит и вставит).
На самом деле при подключении пакета в монолит он становится неявным модулем. С точки зрения модульности, папка vendor/vk/voice-text
— это модуль с названием #vk/voice-text
. Все внутренние модули префиксируются: так, модуль @impl
внутри пакета имеет название #vk/voice-text/@impl
внутри монолита. Это позволяет избежать конфликта имён. У пакета не задан export, и для неявных модулей действует логика «в таком случае разрешено всё». Впрочем, у модуля #vk/voice-text/@impl
собственный export вполне себе есть, и все проверки будут срабатывать.
Так, если Месси решит залезть внутрь имплементации Герасима, защищённой модулем внутри пакета, он получит ошибку компиляции:
Более того, Modulite может расширить функциональность Composer: даже в корне пакета есть возможность явных export’ов из него. Делается это так: рядом с composer.json
создаём файлик .modulite.yaml
.
name
="<composer_root>"
.namespace
="The\\Same\\As\\PSR4\\"
.export
перечисляем как обычно.force-internal
тоже работает.require
оставляем пустым, он всё равно берётся из composer.json.
Например, Герасим укажет TextToSpeech
и WaveMultiplier
внутри export, поэтому Transliteration
будет internal:
Теперь из монолита нет доступа к Transliteration
. И к неймспейсу VK\VoiceText\impl
тоже нет. А вот если бы Герасим упомянул @impl
в export, то был бы (в рамках публичных символов @impl
опять же).
Modulite + Deptrac
Сразу отвечу на возможный коммент вроде «так ведь есть Deptrac, это одно и то же». Не одно. Modulite и Deptrac изначально созданы для разных вещей и с разными концепциями.
Главная цель Modulite — зафиксировать текущую энтропию монолита и не позволить ей бесконтрольно увеличиваться. Зафиксировать со всеми текущими исключениями, которые есть в кодовой базе, — а потом постепенно от них избавляться, распутывая монолит. Для этого нужны два направления: внутрь (export) и наружу (require). Так уж вышло, что подобная задача на самом деле выражается через модули, поэтому мы сделали модули с примесями исключений. И спроектировано всё отталкиваясь от того, как можно влезть в IDE.
Визуализация в IDE — это и правда важно, на самом деле это главное для реального использования. Плюс файлики под Git’ом контролируются именно теми code owners, в чьём коде они лежат, а не одним общим стационарным конфигом. Это важно при настройке прав мержа веток.
Deptrac — это про архитектурные слои (ха-ха, будто бы в клубке монолита с бесконечной цикломатичностью есть слои). Условно, описать «контроллеры не могут лезть в модели», оперируя неймспейсами и классами по маске (вот это с помощью модулей как раз не опишешь). Это не про internal, не про явные зависимости и уж тем более не про замену Composer’а в плане изоляции кода. Deptrac скорее про некоторые «разрешённые паттерны вызовов». Если уж сравнивать, то Deptrac больше похож на цвета, чем на модульность.
Modulite + цвета
Про концепцию цветных функций я тоже уже писал.
Главное отличие — для цветов не сделать плагин для IDE, потому что там нужен полный call graph, цвета смешиваются сквозь произвольные цепочки вызовов. А в модульности для любой проверки нужны всего два ребра, заранее проиндексированные. И мы можем за О(1) проверять совместимость — это уже легко вписывается в PHPStorm и PHPStan.
В общем, цвета — это более гибкая и красивая концепция. А Modulite — более практичная.
Modulite + этимология
Расскажу, почему Modulite называется именно так :)
Я недолюбливаю слово «модуль», оно слишком общее и встречается везде. Не хочется, чтобы наши модули путали с JS-модулями, с Composer-модулями и так далее. Так что искали какое-то похожее, но в то же время уникальное название. И чтобы была связь с монолитом — всё-таки модули прежде всего для него.
Модулит — это модуль внутри монолита. Можно говорить и «модуль», считаем это синонимами.
Modulite + вы
Естественно, все наши наработки лежат на GitHub.
Во-первых, ставим плагин в IDE вот отсюда.
Во-вторых, скачиваем репу modulite-example-project. Это PHP-проектик, чтобы посмотреть концепцию на примере. Он содержит несколько ошибок, которые видны и в IDE, и в PHPStan, и в KPHP. Можно открыть, разобраться, в чём ошибки, починить — в общем, исправить косяки Месси и Адама.
И поскроллить большой красивый лендос — на нём та же информация, что и в статье, но чуть в другой форме.
Modulite + ссылки
Подводим итоги: Modulite дополняет язык PHP модулями,
не вмешиваясь в его синтаксис, а удобно находясь рядом.
Он подходит как для старта новых проектов, так и чтобы
зафиксировать состояние монолита и рефакторить итеративно.
Продублирую здесь все нужные ресурсы, а также видео с последнего выступления: