|
|
||
В 2000 году Дэвид Ричард Хипп выпустил первую версию SQLite. Крошечная встраиваемая база данных писалась под нужды одного инструмента, и никакой славы ей не пророчили. Через четверть века SQLite работает в каждом Android и iPhone, в каждом браузере, в бортовом оборудовании самолётов и в телеметрии автопрома. Приятная деталь: за эти годы ядро дорастало фрагментарно, но базовая структура осталась прежней - компилятор SQL в байткод, виртуальная машина, слой страниц поверх файловой системы.
Каждый разработчик видел и противоположную историю. Проект стартует бодро, первый релиз выходит вовремя, а к третьему любые правки растягиваются на недели. Файлы растут по тысяче строк в месяц, фикс одной функции ломает логин в соседнем модуле. Команда тушит пожары и ругает "наследие", которое три года назад писали они же сами. Разница между живым проектом и гниющим почти никогда не объясняется языком или талантом отдельных людей.
Разница - в наборе измеримых свойств кода. Инженерная дисциплина знает их с конца шестидесятых: это связывание (coupling) и связность (cohesion). Позже появились и количественные метрики. В 1994 году Шайли Чидамбар и Крис Кемерер опубликовали в IEEE Transactions on Software Engineering набор из шести метрик объектно-ориентированного дизайна. Среди них - метрика связывания между объектами (CBO) и метрика недостатка связности методов (LCOM). Эмпирическая проверка не заставила себя ждать. Исследование Лионеля Брианда с коллегами в Journal of Systems and Software (2000) показало: классы с высоким связыванием статистически чаще содержат ошибки.
Эта статья - разбор того, из чего складывается долговечная компоновка кода: связывание и связность, слои и их утечки, границы ответственности, семейства паттернов и антипаттернов. И два боевых примера из открытого мира. SQLite - как эталон. AngularJS 1.x - как хирургическая демонстрация того, как красивая абстракция гниёт изнутри.
Термины "связывание" и "связность" ввёл Ларри Константин в конце шестидесятых, а каноническое изложение появилось в книге "Structured Design" (Constantine, Yourdon, 1979). Связывание - это сила зависимости между модулями: сколько один модуль должен знать о внутренностях другого, чтобы выполнить свою работу. Связность - это насколько плотно содержимое одного модуля держится единым: выполняет ли он одну осмысленную работу или случайный набор поручений.
Интуиция здесь простая, и её полезно проговорить. Хорошая архитектура стремится к минимуму связывания и максимуму связности. Каждый модуль - специалист глубокого профиля, а внешний мир общается с ним через узкий входной люк. Плохая архитектура наоборот: модули сшиты всеми выступающими частями, и ни один нельзя вынуть, не защемив соседей. Изменение затягивает программиста в чужой код, тот тянет за собой ещё чужой, и "поправить одну строку" превращается в экспедицию.
Шкала связывания от худшего к лучшему известна давно. Худший вариант - содержательное связывание, когда модуль лезет прямо во внутренние данные другого. Затем идут связывание через общие глобальные данные и связывание через управление, когда модуль передаёт другому флаг "режим работы". Лучшие варианты - связывание по данным и через интерфейс. Похожая лестница есть у связности: от случайной, когда функции свалены вместе по признаку "понадобились в один день", до функциональной, где всё содержимое модуля работает на одну задачу.
Простейший пример на Python показывает разницу лучше определений. В первом варианте парсер счетов знает о надписи в графическом интерфейсе - это сшивка сразу двух чужих миров. Во втором парсер возвращает данные, а интерфейс сам решает, как их показать:
# тесное связывание: слой логики знает о слое интерфейса
class InvoiceParser:
def parse(self, raw_text):
total = sum(it.price for it in self.items(raw_text))
self.ui.invoice_label.setText(f"Итого: {total}") # парсер рисует UI
# свободная компоновка: каждый занимается своим
class InvoiceParser:
def parse(self, raw_text):
total = sum(it.price for it in self.items(raw_text))
return InvoiceTotal(total) # только данные
Что из этого следует для читателя. Связывание - это цена изменения. Каждая лишняя зависимость повышает шанс, что правка выстрелит далеко от места, где её сделали. Этот шанс измерен на промышленных данных ещё четверть века назад. Метрики Чидамбар и Кемерер при всей их архаичности сегодня реализованы в бесплатных анализаторах и входят почти в любой набор для static analysis. Проверить и починить структуру можно до того, как она начнёт гноиться, а не после третьего релиза.
Абстракция - это честная ложь, без которой невозможно работать. Файловая система позволяет считать, что диск - это папки с файлами, а не плоское болото магнитных доменов. Протокол TCP позволяет верить, что сеть надёжна, хотя на уровне IP пакеты теряются и путаются. Стек протоколов и есть информатика в практическом виде: мы строим слои, где каждый нижний прячет детали от верхнего, чтобы верхний рассуждал понятиями своего уровня.
Идея стратификации стара и почтенна. Ещё в 1968 году Эдсгер Дейкстра описал операционную систему THE как иерархию уровней, где каждый слой опирался только на нижележащий. Правило слоёв звучит сухо: вызывать разрешено только вниз, и желательно ровно на один уровень. Нарушение обнаруживается просто. Как только служебный код полез через два этажа сразу, слой посередине объявлен лишним и провисает.
Ключевой поворот сделал Дэвид Парнас в статье "On the Criteria To Be Used in Decomposing Systems into Modules" (Communications of the ACM, 1972). До Парнаса модули делили по этапам работы: ввод, обработка, вывод. Парнас предложил делить по оси изменений. В один модуль попадает то, что меняется вместе и по одной причине, а всё изменчивое прячется за интерфейсом. Это и зовётся сокрытием информации (information hiding). Грубо говоря, модуль - это обещание, а не конвейерный этап.
Из Парнаса следует рабочее определение идеи в коде. Каждый осмысленный модуль держит идею - инвариант, который не должен нарушаться нигде: сумма должна сходиться с копейками, файл не открывается дважды. Хороший код - это идея, упакованная так, что нарушить её снаружи нельзя даже при желании. Название класса или функции - сжатая формула этой идеи. Отсюда и симптом гниения: когда имена врут ("manager", "helper", "utils"), внутри модуля уже нет идеи, а есть свалка.
Слои, как и любое лекарство, можно передозировать. Бесконечные цепочки из контроллера, сервиса, сервиса-сервиса, репозитория и DAO с однострочными пересылками данных - это не архитектура, а церковная иерархия: много посредников, ответственности ни у кого. Слоёв нужно ровно столько, чтобы каждая граница выражала настоящее различие скорости изменений. Ниже - пример нарушения и корректного направления зависимости:
// правило: зависимость идёт вниз и желательно на один слой
class ReportService {
private final UserRepository users; // сервис -> репозиторий: нормально
// private final JdbcConnection conn; // сервис -> драйвер БД: прыжок через слой
}
В ноябре 2002 года Джоэл Спольски сформулировал "закон утечки абстракций": все нетривиальные абстракции в той или иной степени протекают. TCP обещает надёжную доставку, но если кабель перегрызен, сообщение не придёт никогда, и никакой протокол не прикроет от этой новости. Абстракция не отменяет реальность нижнего слоя. Она прячет её в 99 процентах случаев, а оставшийся процент достаётся программисту в виде ночных звонков.
Следствия закона неприятнее его формулировки. Если абстракция неизбежно течёт, программист обязан знать не только контракт, но и то, что под капотом. Спольски доводил мысль до остроты: абстракции экономят время работы, но не время обучения. За обещанием "просто вызови метод" всегда стоят детали, и вопрос лишь в том, заплатит ли команда за них заранее или в продакшене.
В прикладном коде утечки узнаются по трём типичным следам. Первый - тип исключения: обёртка обещает "вернуть пользователя", а наружу летит исключение транспортного уровня, и вызывающий код обязан импортировать HTTP-библиотеку. Второй - производительность: ORM обещает "список объектов", а на деле порождает тысячу SQL-запросов, классическая болезнь N+1. Третий - частичная семантика: объект выглядит как обычная структура данных, но операции имеют нестандартную сложность или видимость.
# обёртка обещает: "просто возвращаю пользователя"
def get_user(user_id: int) -> User:
resp = http_client.get(f"/users/{user_id}")
return User.model_validate(resp.json())
# но при сбое сети наружу летит деталь реализации
try:
user = get_user(42)
except httpx.ConnectTimeout: # утечка: HTTP проник в контракт
...
Профилактика утечек - это честный контракт. Если обёртка не может скрыть таймауты, пусть её исключения будут собственными: TransportError вместо ConnectTimeout из чужой библиотеки. Если ORM не может скрыть объём данных, пусть ленивая загрузка будет явной и отражённой в сигнатуре. Утечка как случайность - это баг. Утечка как задокументированное свойство - это компромисс. Архитектура отличается от самодеятельности именно умением честно провести границу там, где сплошной стены не выходит.
Пороговое понятие компоновки - модульная ответственность. Из принципа сокрытия информации Парнаса вырос "принцип единственной ответственности", популяризованный Робертом Мартином: у модуля должна быть одна причина для изменения. Класс, который отвечает за валидацию, сохранение и отправку письма, меняется сразу из-за трёх разных потоков требований и притягивает конфликтующие правки из трёх команд. Граница ответственности - это не "сколько строк написать", а "сколько разных мотивов изменения сходится в одном месте".
В крупной системе границы помогает навести Эрик Эванс с его ограниченными контекстами. "Пользователь" в биллинге и "пользователь" в поддержке - это разные модели с разными полями и разными судьбами. Попытка слить их в один глобальный "Пользователь" делает сущность понятной всем и полезной никому. Правильная компоновка позволяет каждому контексту держать свою модель, а соприкосновения локализует в явных механизмах перевода.
Третий элемент компоновки - направление зависимости. Стабильные части системы не должны зависеть от изменчивых. Интерфейс не импортирует реализацию, бизнес-логика не импортирует драйвер. Инверсия зависимостей - это не модный принцип из SOLID, а рабочий способ повернуть стрелки так, чтобы смена базы данных или очереди сообщений была правкой в одном файле сборки. Рекомендация "композиция вместо наследования" из книги "Design Patterns" (Gamma, Helm, Johnson, Vlissides, 1994) - из той же серии. Наследование сшивает классы намертво, композиция - соединением, которое можно перекраивать.
# направление зависимостей: логика импортирует только контракт
class BillingService:
def __init__(self, gateway: PaymentGateway): # интерфейс, не реализация
self.gateway = gateway
# место, где знание о конкретном классе, - слой сборки
billing = BillingService(StripeGateway(api_key))
Практический тест компоновки формулируется одной фразой: сколько файлов нужно тронуть, чтобы типичное изменение прошло от идеи до продакшена. Если ответ - один или два, а остальные и не узнают, граница проведена по линиям настоящих изменений. Если для добавления поля в форму приходится править шесть файлов в четырёх слоях, эти шесть файлов фактически и есть один модуль, только без имени и без границ. Это диагностический признак, доступный без анализаторов и метрик - по руке.
Слово "паттерн" пришло в софт из архитектуры зданий: Кристофер Александер описывал повторяющиеся решения пространственных задач. Гамма со товарищи перенесли идею в код, и важно удержать правильную оптику. Паттерн - это имя для повторяющегося компромисса, а не пропуск в лучший код. "Стратегия" и "декоратор" полезны как словарь, чтобы команда говорила одним словом и не рисовала диаграммы на салфетках.
Архитектурные паттерны - те же компромиссы, но на уровне слоёв и модулей. Слоистая архитектура - прямое следствие идей Дейкстры: так устроены сетевые протоколы и большинство бэкендов. Гексагональная архитектура, описанная Алистером Кокберном в 2005 году как "порты и адаптеры", доводит мысль до упора. Бизнес-логика не знает о хранилищах вообще, а интерфейс, база данных и очереди - взаимозаменяемые адаптеры к одним и тем же портам. Микроядерная архитектура позволяет ядру и расширениям жить в разных релизах, что и делает возможным мир расширяемых сред от редакторов до браузеров.
Микросервисы в этой оптике - не отдельная религия, а композиция, разнесённая по машинам. Каждый сервис - свой ограниченный контекст со своей базой, а связь идёт через явный контракт API. Ценность не в размазывании по кубам, а в том, что физическая граница запрещает неосторожное связывание на уровне компилятора. Сетевые вызовы неэффективны, зато они честно стоят в прайс-листе: такой вызов не воткнёшь случайно, как лишний вызов метода. Граница, которая обходится дорого, реже проводится зря, и в этом весь смысл микросервисов, если отбросить маркетинг.
Опасность паттернов - карго-культ, когда "правильная" структура ставится раньше, чем случились реальные изменения. Синглтон в каждой третьей библиотеке, "репозиторий" поверх репозитория самой ORM, фабрика с единственным статическим продуктом - это не паттерны, а их чучела. Александер вводил паттерны как ответ на повторяющиеся боли, а не как потребление впрок. Паттерн подтверждается только тогда, когда проблема, которую он решает, уже случилась.
У ошибок компоновки есть своя систематика, и она даже оформлена в книгу: "AntiPatterns" Уильяма Брауна с соавторами (Wiley, 1998). Антипаттерн - это устойчивое решение, которое выглядит логично на старте и систематически ведёт к разрушению. Их ценность в том, что они распознаются по симптомам до катастрофы, и их можно назвать по имени на код-ревью вместо двухчасовой дискуссии.
Первый в хит-параде - божественный объект (God Object). Класс Manager на пять-десять тысяч строк, у которого каждая сущность системы хоть раз вызывала метод, а приватные поля хранятся "на всякий случай". Божественный объект - это отсутствие связности в чистом виде: внутри нет одной идеи, есть случайное пересечение всех. Менять его страшно, тестировать невозможно, читать бессмысленно. Симптом на уровне файлов - папка utils, в которой живёт третий по важности код проекта.
Второй - циклическая зависимость. Модуль A импортирует B, B импортирует C, C импортирует A. Такой проект либо не собирается вообще, либо "собирается" через рефлексию, импорт в теле функции и шаманские заглушки в тестах. Цикл - это формальное нарушение стратификации: транзитивно всё зависит от всего, и ни одну часть нельзя изъять, проверить или заменить отдельно.
Третий и самый коварный - преждевременная абстракция, высмеянная именем AbstractSingletonProxyFactoryBean. Разработчик строит четыре слоя непрямых вызовов на случай, который не наступал и не планируется. Симптом - интерфейс с ровно одной реализацией, ни разу не заменённой. Последствие хуже симптома: при изменении требований придётся ломать не только код, но и строительные леса. Противоядие - правило трёх повторений и жёсткий вопрос "какая конкретно вариация здесь скрывается?".
Четвёртый - анемичная модель домена, когда классы содержат только поля и геттеры, а вся логика вынесена в "услуги". Это выглядит опрятно, но инварианты никто не защищает: правило "сумма не меньше нуля" размазывается по десяти функциям, и любая из них может его нарушить. Это та же утечка, только наоборот: идея вынута из модуля и растворена в скопе функций снаружи. Паттерны и антипаттерны различаются не красотой, а тем, чья ответственность куда помещена.
Официальная страница архитектуры SQLite честно называет себя документом "для тех, кто хочет понять или изменить внутренности". Схема проста. Interface принимает SQL-текст и раздаёт результаты через вызовы sqlite3_prepare и sqlite3_step. Дальше компиляторный конвейер: Tokenizer разбивает текст на лексемы, Parser собирает их в дерево с помощью генератора Lemon, Code Generator превращает дерево в байткод. Затем Bytecode Engine - виртуальная машина, целиком живущая в файле vdbe.c. Под ней B-Tree, ниже Pager, и в самом низу OS Interface (VFS) - слой, через который база видит файловые системы разных платформ.
Красота этого стека - в дисциплине границ. Каждый уровень реализован в предсказуемом наборе файлов и знает только соседа снизу. Планировщик запросов в where.c и select.c может перебирать миллионы алгоритмов выполнения, не подозревая о существовании POSIX. Слой VFS позволяет портировать движок на новую платформу, не трогая ни строчки в компиляторе. Префиксы имён наводят внешний порядок: всё публичное начинается с sqlite3_ с подчёркиванием, остальное дисциплинированно спрятано. Идеи Парнаса здесь соблюдены буквально: скрытая информация - не только приватные поля, но и вся организация слоёв.
Решение "токенизатор вызывает парсер, а не наоборот" - отдельный урок зрелости. Оно противоречит традиции YACC, но документация открыто объясняет: так проще сделать код потоковым, реентерабельным и потоко-безопасным. Архитектурные отклонения такого рода - не воля случая, а зафиксированное в документе решение с обоснованием. Именно это отличает живую архитектуру от удачного стечения обстоятельств: каждое отступление от нормы названо, объяснено и защищено границей.
Третья грань эталона - тестирование. SQLite живёт в мире, где ошибка на приёмной стороне дороже, чем в любом веб-приложении, и проект отвечает на этот риск масштабом проверок. Документация называет её одной из самых тщательно тестируемых программных систем: на каждый релиз гоняются миллионы тестовых сценариев, включая генеративные наборы, проверки каждого опкода виртуальной машины и исчерпывающие покрытия ветвей. Связность здесь работает на безопасность изменений: когда каждый слой можно испытывать отдельно, релизы перестают быть лотереей.
AngularJS вышел в 2010 году, и его центральное обещание выглядело как магия: измените данные в JavaScript - и интерфейс обновится сам. За эффектом стоял цикл $digest. Во время каждого прохода фреймворк пробегает по всем зарегистрированным наблюдателям ($watch), сравнивает их результаты с прошлыми и при изменениях запускает новый проход. Каждое выражение в шаблоне обязано быть безопасным для многократного перевычисления.
// AngularJS 1.x: каждое выражение шаблона рождает наблюдателя
$scope.$watch('invoices | filter:paid', function(newVal) {
$scope.total = sumBy(newVal, 'amount');
// $digest пройдёт по всем наблюдателям страницы.
// В большой форме их тысячи, и все - на каждый цикл.
Абстракция "данные сами обновляют интерфейс" протекла трижды. Первый раз - по производительности. С ростом числа наблюдателей время цикла росло линейно, и на больших списках интерфейс начинал заметно подвисать. В известном разборе деградации $digest Марк Галеа приводит порядок величин: несколько тысяч наблюдателей уже дают заметные задержки, и руки разработчиков тянутся к обходам вроде одноразовых привязок и ручного управления циклом.
Второй раз абстракция утекла по порядку выполнения. Знаменитая ошибка "10 $digest() iterations reached. Aborting!" означает, что наблюдатели стабильно меняют результат на каждом проходе, и фреймворк сдаётся после десяти кругов. Классический сценарий - фильтр orderBy внутри ng-repeat: он создаёт новый массив объектов на каждом проходе, цикл не стабилизируется никогда. Просьба "просто отсортируй список" оборачивается отладкой внутренностей фреймворка, потому что в контракте нет пункта о стабильности выражений.
Третья утечка - в самом языке фреймворка. Разработчик обязан знать про иерархию $scope с прототипным наследованием и "правило точки", чтобы не затереть поле родителя. Обязан различать $apply и $digest, $watch и $watchCollection, понимать, когда цикл запустится сам, а когда его надо позвать руками. Обещание "просто меняйте данные" обернулось требованием знать движок целиком. Это модельный пример закона Спольски: абстракция сэкономила время работы, но экономия на изучении не удалась. Каждый крупный проект на AngularJS 1.x рано или поздно обзаводился библиотеками-протезами, закрывающими дыры фреймворка.
Финальный приговор поставили сами авторы. Angular 2 (2016) не стал чинить $digest, а выбросил его полностью: однонаправленный поток данных, иммутабельные проверки по ссылкам, детекторы изменений, переписанные вручную для каждого шаблона. Так фреймворк признал, что двухсторонняя магия 2010 года в исходной компоновке не патчилась. Это достойный шаг и одновременно предупреждение: если исправление требует вырвать ядро, ошибка была не в коде, а в самой компоновке.
Сводя воедино: связывание и связность - не засохшая академическая пара, а измеримые предвестники судьбы кода. Связывание - это цена каждого изменения. Связность - вероятность, что изменение попадёт туда, куда целились. Слои абстракции - транспорт между идеей и железом, а утечка - неизбежная плата за любой этаж выше реальности. Компоновка - это расстановка границ по линиям будущих изменений, а паттерны - имена для типовых компромиссов такой расстановки.
Практический вывод невелик по форме. Заведите привычку смотреть на метрики своего репозитория: готовые анализаторы считают те самые CBO и LCOM из статьи 1994 года. При код-ревью задавайте два вопроса: что модуль обещает и сколько посторонних файлов узнает о его следующем изменении. Будьте честны к утечкам: документируйте их, а не молчите. Раз в квартал проводите ревизию на антипаттерны из каталога Брауна - многие из них распознаются по именам файлов за один вечер.
SQLite и AngularJS сходятся в одном уроке с разных сторон. Долговечность кода - не дар выразительности и не удачный выбор библиотеки. Это результат дисциплины: границы по осям изменений, честные контракты, вызовы только вниз и имена для компромиссов вместо имён для моды. Первая система объясняет свою структуру на одной странице и держит её четверть века. Вторая не смогла удержать собственное центральное обещание без изучения внутренностей наизусть и была сменена.
Остаётся и внепроизводственный вывод. Код - это аргументационный долг перед людьми. Каждый уровень лишнего связывания - это кусок чужого внимания, занятый навсегда. Каждая лживая абстракция наказывает читателя за доверие. Хорошая архитектура - форма уважения: к коллеге, которого не будет рядом в момент правки, и к читателю, который имеет право понять вашу мысль без разбора всех слоёв по эскизам.
|