Цветные функции: ищем плохие архитектурные паттерны
Когда у языка нет цветовой дифференциации функций… то у языка нет цели?
Я уже много лет занимаюсь компиляторами и языками в целом. Хочу поделиться интересной мыслью, которая когда-то пришла мне в голову. Почему-то такого я нигде не видел.
Если немного расширить понятие функции (ввести атрибут »цвет»), можно описывать паттерны вида «вызывать логгер из performance-critical мест — это плохо» или «ходить в базу при рендеринге шаблонов запрещено».
Идея абсолютно не зависит от языка и применима к любому: хоть JS, хоть Go. Разберу её подробно в статье, это будет интересно больше с теоретической точки зрения. Хотя мы даже сделали практическую реализацию для PHP, чтобы использовать у себя. Ссылки на GitHub и видео приложу в конце, а пока обо всём по порядку.
Красные, зелёные и прозрачные
Представим, что есть атрибут @color
над декларацией. Пусть будут красные функции и зелёные. А те, что без цвета — прозрачные.
Когда f1()
вызывает f2()
, их цвета смешиваются (добавляются в цепочку):
/** @color green */
function f1() { f2(); }
// без тега @color, прозрачная (transparent)
function f2() { f3(); }
/** @color red */
function f3() { /* ... */ }
Мы определили цвета, а теперь опишем палитру. Палитра — это паттерны смешивания цветов. К примеру, запретим вызывать красные из зелёных:
green red: "calling red from green is prohibited"
Цепочка "green transparent red"
попадает под правило "green red"
, и поэтому "f1 → f2 → f3"
триггерит ошибку. А просто "f1 → f2"
— это цепочка "green transparent"
, уже нет. Это как паттерн-матчинг: "green yellow blue red"
тоже попадает под "green red"
.
Думаем в терминах цветов: находим медленные места
Допустим, у нас есть медленные функции (работающие с базой данных или диском) и есть критичные к производительности (или быстрые) функции, задача которых — иметь минимальный оверхед.
Для наглядности пусть медленные будут красными, а быстрые зелёными:
Что такое потенциально медленное место в этом случае? Это если красная функция вызывается из зелёной. Как-то похоже на то, что выше, да? :)
На реальном примере:
// class ApiRequestHandler, зелёная
function handleRequest(DemoRequest $req) {
$logger = createLogger();
$logger->debug('Processing ' . $req->id);
}
// class Logger
function debug(string $msg) {
DBLayer::instance()->addToLogTable([
'time' => time(),
'message' => $msg,
]);
}
// class DBLayer, красная
function addToLogTable(array $kvMap) {
// ...
}
Будет такой граф вызовов (call graph):
И это снова попадает под правило "green red"
, только логически обозначает потенциальное замедление.
Не red и green, а вообще любое
На самом деле мы использовали красные и зелёные только для визуализации «красные медленные, зелёные быстрые». Но можно использовать непосредственно slow и fast:
/** @color slow */
function appendLogToTable() {}
/** @color fast */
function handleRequest() {}
И правило в палитре:
fast slow: "potential performance leak"
Любое свойство — это цвет:
@color slow
,@color fast
,@color performance-critical
;@color server-side-rendering
, или просто@color ssr
;@color view
и@color controller
;@color db-access
;…и вообще что угодно.
У функции может быть одновременно несколько цветов. Например, api-entrypoint и slow.
Соответственно, в палитре мы описываем паттерны, которые отслеживаем. Например, контроллеры не могут зависеть от моделей:
controller model: "controllers may not depend on models"
Когда проаннотируем в коде все соответствующие классы и функции как @color controller
и @color model
, у нас будут проверки на это правило — на любом уровне вложенности.
Другие примеры:
ssr db: "don't fetch data from server-side rendering"
api-entrypoint curl-request: "performing curl requests on production is bad practice"
green yellow red: "a strange semaphore in your code"
Окей, запретить запретили, а как точечно разрешать?
Что, если хочется сказать »да, ходить в базу из SSR нельзя, но вот здесь, пожалуйста-пожалуйста, можно»? Или »это точно не скажется на производительности, потому что скрыто за if (debug)»?
Или вернёмся к коллграфу выше: как разрешить Logger::debug()
, несмотря на правило "fast slow"
?
Цвета и палитра — примерно как HTML и CSS. Не на 100% корректная аналогия, но позволяет понять, как описывать исключения.
Представьте, что каждая функция — это div’ник. Вызовы по коллграфу — это вложенность. А цвета — классы.
Если бы мы описывали правило «performance leak» в CSS, то писали бы так:
.fast .slow {
error-text: "potential performance leak";
}
Конечно, CSS-правила могут быть любой вложенности, типа ".red .green .blue"
. Главное, что есть более специфичные селекторы. На этом и основана вся вёрстка:
.green .red {
error-text: "don't call red from green";
}
.green .yellow .red {
error-text: "a strange semaphore in your code";
}
Это очень привычно для всех, кто знаком с CSS: если есть жёлтая функция между зелёной и красной, срабатывает более специфичное правило. Или…
.green .yellow .red {
error-text: none;
}
Якобы используя CSS, убрали ошибку для этого селектора. Ну вы же догадываетесь, да?
.fast .slow-ignore .slow {
error-text: none;
}
Цвета работают точно так же. Можно описывать более специфичные смешивания в палитре, которые перегружают или отменяют ошибки.
- fast slow: "potential performance leak"
- fast slow-ignore slow: ""
Тогда как разрешить Logger::debug()
из fast-функций? Имея то правило в палитре, просто пометить:
/** @color slow-ignore */
function debug() { /* ... */ }
Другой пример. Из server-side rendering загружать данные из БД нельзя. Но если специальным цветом пометить функцию, то можно:
- ssr db: "don't fetch data from server-side rendering"
- ssr allow-db db: ""
Нужно понимать, что должны проверяться все достижимые пути. К примеру, если есть другой путь по коллграфу из f()
в h()
:
Первый путь — окей, а второй — это ошибка "f → g2 → h => don't fetch data from server-side rendering"
.
Неймспейсы — это тоже цвета?
Цвет может быть и над классом. Это означает, будто этот цвет написан над каждым методом.
/**
* @color low-level
*/
class ConnectionCache { /* ... */ }
И поскольку цвета — это произвольные строки, то неймспейсы — чем не цвета? Тут они в явном виде, но при желании могут быть и неявными:
/**
* @color VKApi\Handlers
*/
class AuthHandler { /* ... */ }
/**
* @color VKDesktop\Templates
*/
function tplPostTitle($post) { /* ... */ }
Тогда можно писать любые правила на неймспейсах и исключения для них.
VKApi\Handlers VKDesktop\Templates: "using UI from api is strange"
И это тоже будет работать на любом уровне вложенности.
Можно даже эмулировать internal в языках, где его нет
Например, в PHP все классы публичные, поэтому из любой точки кода можно вызвать любой публичный метод. Даже если разработчик хочет упрятать внутри монолита какой-то класс глубоко в легаси-код, чтобы его вызывать только через обёртку, на уровне языка нет никаких ограничений на это.
namespace DBLayer\Internals;
// допустим, это какие-то внутренние утилиты
class LegacyIDMapper {
static function mapUserId($user_id) { /* ... */ }
}
// и у вас есть высокоуровневая обёртка как публичный интерфейс
function transformLegacyUser(User $user) {
$user->id = LegacyIDMapper::mapUserId($user->id);
// ...
}
Даже если вы в Confluence напишете «Не используйте LegacyIDMapper, плиз!», его всё равно кто-то заиспользует, поскольку не запрещено языком.
А вот с цветами это решается действительно красиво. Сделаем два цвета:
/**
* @color db-internals
*/
class LegacyIDMapper { /* ... */ }
/**
* @color db-public
*/
function transformLegacyUser(User $user) { /* ... */ }
И такие два правила в палитру:
- db-internals: "don't access db implementation layer directly"
- db-public db-internals: ""
И всё! Прямые вызовы из внешнего кода будут матчиться в первое правило и давать ошибку, а вызовы через обёртку — во второе. И если хочется из сторонней функции fff()
всё-таки вызвать легаси, можно даже добавить правило:
- please-allow-legacy-here db-internals: ""
Теперь можно проаннотировать fff()
таким цветом — явно обозначая своё намерение и оставляя комментарий в коде. На код-ревью это тоже будет видно.
Использование на практике и выводы
Концепцию цветных функций я изначально придумал для KPHP, чтобы мы внутри монолита ВКонтакте могли изолировать участки кода и описывать правила вроде «лента не должна лезть в сообщения». Но идея и в целом получилась красивая, поэтому помимо решения для KPHP мы сделали отдельный инструмент и выложили его в open source под названием nocolor.
Как я говорил в начале, язык здесь не важен. Важен концепт. Даже имплементация не играет особой роли. К примеру, мы отслеживаем все коллграфы на этапе компиляции и без рантайм-оверхеда, но это может быть и рантайм-стек в виде хуков в начале и конце цветной функции.
Идею можно развивать. Например, помимо цвета ввести насыщенность (saturation). Мол, не просто медленная, а медленная в такой-то степени. Например, для медленности мерой насыщенности может служить 95-й перцентиль исполнения согласно профилированию. Тогда не только цвета смешиваются, но ещё и интенсивности складываются. А через палитру мы триггерим ошибку — если, условно, потенциальный путь исполнения дольше трёх секунд.
Кстати, на прошлом PHP Russia я выступал с докладом, где рассказывал про всё это и делился подробностями реализации. А ещё объяснял, почему это всё-таки называется «цвет» и никак иначе. Посмотреть доклад можно по ссылке ниже.
Полезные ресурсы