Чтобы применять любые принципы правильно, сначала нужно их понять — то есть осознать, откуда они взялись и для чего нужны. Если применять вслепую всё, что угодно — результат будет хуже, чем если бы мы вообще не использовали эти принципы. Я начну издалека и сначала расскажу про абстракцию.

Что есть абстракция?


Это обобщение существенного и удаление несущественного, так как мир настолько сложен, что запрограммировать удаётся только его существенные части. Если попытаемся запрограммировать всё — мы потонем, поэтому абстракция помогает нашему мозгу «впихнуть невпихуемое», как это умеют делают военные (а программисты — пока нет):



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

  • Есть входы и выходы;
  • TTL объекта около 20 секунд;
  • Удерживается очень мало объектов. Раньше считали, что оперативно человек оперирует 7±2 объектами (George Miller, 1989). Потом поняли, что это число еще меньше: 4±1 объект (Cowan, 2001) и вообще зависит от объектов.
  • Если мы что-то хотим держать в памяти дольше, то нам нужно сконцентрироваться и использовать повторение:



Еще мы используем Chunking (группировку) всякий раз, когда важно запомнить что-то большое. Например, чтобы запомнить число 88003334434, мы разделим его на группы по типу телефонного номера: 8-800-333-44-34. Для нашего мозга получится 5 объектов, которые запомнить легче, чем пытаться удержать число целиком или отдельно каждую его часть.

В коде мы используем слои, чтобы облегчить эту задачу. Однако пять таких слоев в голове удержать становится сложно, а больше пяти — уже проблема. Для лучшего представления это можно сравнить с неправильной супер-лазаньей, в которой слишком много слоёв и — они все слиплись. И это гораздо хуже, чем спагетти — потому что спагетти мы можем отрефакторить и что-то нормальное в результате получить. А чтобы получить что-то нормальное из такой лазаньи, её надо сначала растерзать и превратить в понятные и очевидные спагетти, а потом заново собирать лазанью, только уже правильную.

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

Но как построить абстракцию, не сделав хуже?


Существует два понятия: cohesion (связность) и coupling (связанность). Они относятся в первую очередь к классам, но в целом и ко всем остальным сущностям. Разницу мало кто видит, так как звучат они почти одинаково.

И, хотя оба означают связь, coupling понимают в негативном ключе. Один объект завязан на другой в плохом смысле, если, ломая один из объектов, ломается всё остальное по цепочке. Cohesion несёт в себе позитивную ноту. Это группировка, в которой то, что близко по смыслу — лежит в одном месте и взаимодействует примерно с теми же местами, опять же близкими по смыслу.

Для того, чтобы понять, coupling у вас или cohesion, существуют проверочные правила. Их сформулировал инженер и специалист в области информатики Роберт Мартин еще в 2000 году, и это — принципы SOLID:

  • SRP;
  • OCP;
  • LSP;
  • ISP;
  • DIP.

Они, в общем-то, тоже немного расплывчатые и потому часто плохо понимаемые. Но сегодня я хочу поговорить не о них, а о пакетах.

Что есть пакет?


Пакет — это группа единиц кода. Причем, пакеты – это не обязательно пакеты Maven или Composer, или npm. Это программные модули, — то, что вы выделяете в namespaces или иным способом группируете.

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

И того, кто эти пакеты пилит, обычно волнуют два вопроса: Как правильно формировать пакеты и как работать с зависимостями пакетов?

Как их правильно формировать?


Собственно, cohesion и coupling, как основополагающие принципы, отлично работают и для пакетов. Но сработает ли SOLID для пакетов?

Да, но не совсем. Оказалось, что существуют ещё 6 принципов от того же Роберта Мартина, сформулированные в том же году. Часть из них относится к cohesion, и это о том, как дизайнить код: REP, CCP, CRP. Другая часть — это coupling (то есть использование пакетов): ADP, SDP, SAP — и это о том, как сделать так, чтобы один пакет не завязался на другой и чтобы всё нормально работало:



1 принцип – REP (Reuse-Release Equivalency Principle)


На сегодняшний день этот принцип выглядит до смешного очевидным, но не забываем, что сформулирован он в 2000 году, когда не было таких замечательных штук, как Maven, Composer и прочих, а пакетные релизы были не частыми.

Принцип гласит: «The granule of reuse is the granule of release. Only components that are released through a tracking system can effectively be reused. This granule is the package. — что переиспользуем, то и релизим. Эффективно переиспользовать можно только компоненты, релизнутые через системы версионирования. Такие компоненты называются пакетами». То есть упаковывайте то, что переиспользуется в отдельные пакеты и релизьте это через любимый пакетный менеджер, версионируя по SemVer.

2 принцип – CCP (Common Closure Principle)


«Classes that change together are packaged together — изменение в пакете должно затрагивать весь пакет». Этот принцип очень похож на SOLID-ский OCP. Классы, которые изменяются по одной и той же причине, должны упаковываться в один пакет. Что логично.

Нормальный пример: адаптеры. Библиотека, допустим, кеш. Если мы запихиваем в один пакет тучу адаптеров: для файлов, memcached, Redis, то при попытке изменить один какой-то адаптер мы нарушаем два принципа. Во-первых, принцип REP (начинаем релизить один из адаптеров, а релизить приходится все). А во-вторых — принцип CCP. Это когда классы для адаптера под Redis изменяются, а все остальные адаптеры в пакете —нет.

3 принцип – CRP (Common Reuse Principle)


«Classes that are used together are packaged together — Пакеты должны быть сфокусированными. Использоваться должно всё». То есть классы, которые используются вместе — упаковываем вместе. Проверочное правило здесь такое: смотрим, используется ли в нашем софте всё из того пакета, который к нему подключен. Если используется чуть-чуть, значит, скорее всего, пакет спроектирован неверно.

Эти три принципа дают понимание, как пакеты дизайнить. И казалось бы, нормально делай — нормально будет. Однако реальность сурова, и я сейчас объясню — почему. Вспомним треугольник от Артемия Лебедева, который вершины «быстро», «дёшево» и «качественно» обозначил несколько другими словами. Такой же треугольник нарисовали и для пакетных принципов в Институте Макса Планка:



Получается, эти принципы конфликтуют, и в зависимости от того, какие стороны треугольника мы выбираем, вылезают соответствующие косяки:

  • Если мы группируем для пользователей (REP) и для мейнтенера (CCP), то получаем множество ненужных релизов: новые версии пакетов начинают вылетать как из пулемета. И пакет как тот же Chromе достигает версии 46 за полгода, когда все остальные браузеры выпускают одну мажорную версию раз в 7 лет.
  • Если мы группируем для пользователей (REP) и выделяем классы в пакеты по признаку переиспользования (CRP), у нас получаются изменения в туче пакетов. А это неудобно мейнтенеру, потому что приходится лезть в каждый из пакетов, и не получается релизить их по отдельности. Это дикая головная боль.
  • Если мы группируем для мейнтенера, то есть соблюдаем CCP и CRP, то получается всё круто для человека, который поддерживает этот пакет, но не круто для юзера, потому что переиспользовать такие пакеты получается плохо: они выходят как всякие мелкие штучки, которые собрать вместе просто нереально.

К сожалению, получить сразу и то, и другое, и третье не удастся, поэтому фокусироваться надо на CCP и REP — по крайней мере, на начальных стадиях дизайна пакета. Да, у вас будет много релизов на старте, но это не так страшно, хотя и выглядит, конечно, не очень. Сейчас уже есть инструменты и для автоматизации упаковки пакетов и, собственно, выпуска версий.

Теперь переходим к принципам использования.

4 принцип – ADP (Acyclic Dependencies Principle)


«The dependency graph of packages must have no cycles — Если есть циклические зависимости, то проблема вызывает лавину». Если есть циклы, то есть зависимость пакета зависит от самого пакета прямо или косвенно, то косяк в одном пакете вызывает лавину во всех остальных пакетах, и ломается абсолютно всё. К тому же, такие пакеты очень тяжело релизить.

Поэтому надо проверять, есть ли циклы. Для этого необходимо строить направленный граф зависимостей и смотреть на него. Руками это делать не очень удобно, поэтому для PHP есть библиотека clue/graph-composer, которой скармливаешь пакет, и она строит гигантский граф со всеми зависимостями. Смотреть на это, конечно, невозможно, поэтому надо зайти в PR#45, зачекаутить его и выбрать возможность исключать зависимости, которые не интересны. Допустим, если вы пишите фреймворк, то вам скорее всего интересны зависимости на свои пакеты, а чужие — не так сильно, ведь свои косяки поправить можем, чужие — тяжелее. И получается вот такой граф:



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

Как разорвать цикл?


  1. DIP — использовать инверсию зависимостей через интерфейсы. Мы должны ввести интерфейс и на него завязаться вместо зависимости на конкретные реализации.
  2. CRP — выделить общий пакет. Например, есть кеш и с адаптерами. Чтобы развязать между собою Redis, базу и так далее — выделяем драйверы в отдельные пакеты и выделяем сам общий пакет, в котором лежит только сам интерфейс. Это выглядит ужасно — получается такой «бесполезный» пакет. Но с точки зрения DIP и CRP это будет правильно. И помимо того, что реально не будет ломаться, еще и даст нам крутой профит — мы можем писать под этот пакет свои реализации.
  3. Переделать...

5 принцип – SDP (Stable Dependencies Principle)


Это принцип стабильных зависимостей: «Depend in the direction of stability — Не получится строить стабильное на нестабильном». Нестабильность считается так:



Если на нас завязалось очень много всего — скорее всего, мы стабильны. Если же мы завязались на много всего, то, очевидно, мы не очень стабильны. Как повысить стабильность? Следующим принципом.

6 принцип – SAP (Stable Abstraction Principle)


Принцип стабильных абстракций гласит «A package abstractness should increase with stability — Стабильные пакеты абстрактны / Гибкие конкретны». То есть абстрактность должна возрастать со стабильностью. Стабильность здесь — то, как часто нам приходится менять части пакета: классы, интерфейсы, или что-либо ещё. Абстрактные пакеты должны быть стабильны, чтобы безболезненно на них завязываться. В примере с тем же кэшем пакет с интерфейсом будем сверхстабильным, потому что менять интерфейс, про который мы договорились и хорошо над ним подумали — скорее всего, не придётся. Если мы, конечно, абстрагируем не СУБД.

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

Можно ли измерить абстрактность?


Конечно. Абстрактность — это число абстрактных классов и интерфейсов в пакете, деленное на общее число классов и интерфейсов в этом самом пакете:


Еще есть такой полезный показатель, как D-метрика, в которой по вертикали — нестабильность, а по горизонтали — абстрактность. По двум зонам — вверху справа и внизу слева — мы можем понять:

  • Если стабильно, но не абстрактно — это подозрительно.
  • Если дико нестабильное и очень абстрактное — значит кто-то играется с интерфейсами и делает нашу жизнь адом.
  • Но иногда бывает 0,0 — когда супер-неабстрактно и супер-стабильно, как в случае с хелперами или стандартными библиотеками PHP типа stdlib, strings, arrays — и это будет нормально.



Линия посередине называется главной линией, и если классы и интерфейсы попадают на неё или выстраиваются вдоль — это тот случай, когда всё отлично. По сути, D-метрика — это дистанция от главной линии, поэтому 0 в этом случае — это хорошо, а 1 — плохо. Но, как правило, ни то, ни другое не случается — значения плавают в диапазоне от 0 до 0,9-0,7. Считается метрика так:


Для PHP есть 2 инструмента для того, чтобы посмотреть метрику своих пакетов:

  • PHP_Depend;
  • PhpMetrics.

Конечно, если вы захотите ими прогнать корневой пакет со своим приложением, вы увидите не 5 точек, как на схеме — у вас вся схема будет в точках. Но из этой массы у вас будут выпрыгивать «рыжие» точки, при наведении на которые будет понятно, что это за классы — вот с ними, скорее всего, и будут некоторые проблемы.

Как и SOLID, все эти дополнительные принципы и метрики — не догма, но могут быть весьма полезными.

Резюме


Правильное проектирование пакетов вызывает взрывное дробление. Выглядит это страшно. Когда мы начали разрабатывать фреймворк Yii3 и выделять пакеты, у нас через некоторое время количество пакетов с 20 штук перевалило за сотню. Ппоначалу подобная ситуация настороживает, но позже становится понятно что всё, тем не менее, работает стабильно. Роберт Мартин был прав, и его принципы работают.

Конечно, есть области, в которых можно сознательно игнорировать все эти принципы, отдавая себе отчёт в том, что за этим стоит и где вы можете получить проблемы.

Данные принципы же позволяют не скатываться в монолит или в left-pad из npm. С left-pad была в свое время история — его создали для добавления символа в конце строки, так как в JavaScript есть традиция дробить пакеты вообще в пыль. А потом на этот пакет завязалось практически всё — вплоть до пакетных менеджеров и самых крутых фреймворков. В какой-то момент автор обиделся на всех и выпилил left-pad из системы — после чего, как вы понимаете, сломалось всё. Рассмотренные принципы, в том числе, позволяют уменьшить вероятность такого сценария.
Единственная конференция по PHP в России PHP Russia 2021 пройдет в Москве 28 июня. Первые доклады уже приняты в программу!

Купить билеты можно тут.

Хотите получить материалы с предыдущей конференции? Подписывайтесь на нашу рассылку.

Комментарии (12)


  1. FForth
    17.03.2021 10:11

    Sorry, может вне темы статьи.
    Всё так же «плохо» с переводом Yii2 cookbook на русский язык?
    Как работать над переводом на русский

    P.S. Отсюда Переводим Yii 2.0 на русский


    1. SamDark Автор
      17.03.2021 10:11

      Переводом на русский сейчас никто не занимается. Ранее я это делал, но потом начал больше заниматься Yii 2, позже Yii 3 и времени на сам перевод не осталось. То есть перевод нормальный, но, вероятно, несколько устарел по-сравнению с оригиналом.


    1. zheeenya
      17.03.2021 10:11

      Хороший повод подтянуть английский чтобы читать cookbook не ожидая перевода


  1. wrmax
    17.03.2021 10:11

    скобки потерялись в формуле расчета абстрактности


    1. SamDark Автор
      17.03.2021 10:11

      Точно! Поправлю картинку.


  1. HellWalk
    17.03.2021 10:11

    Как-то все очень сложно описано. Примерно понимаю о чем написано, но человеку, который пока пишет код на уровне Yii2 эта статья, как мне кажется, никак не поможет научиться писать код лучше.

    А что поможет? Мне сильно помог такой вариант:

    1. Берем, и начинаем писать в свое свободное время какой-то большой проект-монолит. В свободное время — чтобы вас никто не подгонял со сроками, монолит — потому что только в монолите с сотнями моделей и сложной логикой хорошее ООП становится обязательным, а не желаемым. Что-то небольшое (микросервис) можно написать тяп-ляп и готово. Разработка большого монолита по принципам тяп-ляп быстро приводит к тому, что развивать его становится невозможно (думаю все с таким сталкивались на рабочих проектах)

    2. Пишем проект как вам хочется. Это очень важно — писать не так, как кто-то в интернете написал, или в книжке, а как вам хочется. Хочется без SOLID и тестов — пишите. Здесь важно именно наступить на грабли и получить по лбу в тех моментах, в которых вы по лбу еще не получали. Чтобы прийти к тому же SOLID или тестам не по тому, что об этом Вася в интернете написал, а потому что вы сами, на личном опыте, к этому пришли.

    3. Через какое-то время наступает момент, когда дальше разрабатывать проект становится невыносимо. Костыль на костыле и тому подобное. В этот момент берем, и пишем все с нуля. Переписываем на более лучший (по вашему мнению) вариант существующий функционал, и продолжаем добавлять новый. В моем опыте первое переписывание с нуля произошло через пол года. Второе — где-то через два года.

    4. В процессе переписывания вы на личном опыте доходите до таких вещей, которые умные дяди пишут в умных книжках — например, хорошо бы какой-то функционал выносить в полностью независимый модуль (привет DDD), чтобы в следующем переписывании можно было просто взять его, и скопировать в новый проект (в новое «переписывание с нуля»). В этом же процессе вы обязательно придете к авто-тестам, и не только к ним, а ощутите те приятные моменты, когда возвращаясь к проекту через пол года, уже забыв что там как работает, и работает ли вообще, вы выполняете тесты, они проходят, и вы себе говорите «о, все работает, супер».

    5. И так далее — новый функционал, новые переписывания, новый опыт — процесс бесконечный.

    Конечно, чтобы годами писать какой-то домашний сложный проект нужно любить программирование. Ну и быть без детей, наверное :) Так что кто еще молодой и без семьи — ловите момент.


    1. SamDark Автор
      17.03.2021 10:11

      Можно и так, но долго.


  1. HellWalk
    17.03.2021 10:11

    Как правильно формировать пакеты

    Ничего не сказано про такой важный, на мой взгляд, аспект формирования пакетов, как использование не классического паттерна Единая точка входа

    Потому что использовать интерфейсы вида:
    $response = $object->handle($request) // handle(RequestInterface $request): ResponseInterface

    Сильно удобнее, чем что-нибудь вроде:

    $object->setParam1(...)
    $object->setParam2(...)
    $object->setParam3(...)
    $result = $objcet->getResult()

    В популярных пакетах такое редко встретишь, а вот во внутренних модулях компаний — постоянно. До банального указания обязательных зависимостей не через конструктор, а через отдельный метод.

    P.S.
    И это гораздо хуже, чем спагетти — потому что спагетти мы можем отрефакторить и что-то нормальное в результате получить. А чтобы получить что-то нормальное из такой лазаньи, её надо сначала растерзать и превратить в понятные и очевидные спагетти, а потом заново собирать лазанью, только уже правильную.

    Еще раз убеждаюсь, как же сложно написано. Моя аналогия, которая, мне кажется, намного понятнее:

    • Ситуация №1: Неопытный программист пишет код как получается: получаем лапша-код, объекты, которые не объекты, а просто классы по которым как-то распиханы данные и методы для работы с этими данными. Его код плохой, но понятно как его рефакторить (потому что все когда-то так писали, и переписывали свой код)
    • Ситуация №2: Неопытный программист начитался умных книжек про паттерны, DDD и прочее, и начинает внедрять их на рабочем проекте. В результате его код получается сильно хуже, и намного более переусложненным, чем в ситуации с первым программистом.


    Мораль проста: все новое лучше вначале проверять «на кошках» — на каких-то небольших и неважных проектах (например, на домашних), и только после обкатки (которая может занять и год и больше), и когда уже набился шаблон и этот шаблон проверен временем — уже внедрять в рабочих проектах.

    P.S. И как хорошо где-то сказано: задача тимлида не в том, чтобы говнокода в проекте не было, а в том, чтобы этот говнокод не растекался — т.е. создать такие условия, чтобы говнокод каждого конкретного рядового программиста был как-то изолирован в себе (через вынесение в микросервис, или в модуль с отдельным неймспейсом, с указанным тимлидом интерфейсом использования, или как-то по другому — уже не суть)


    1. SamDark Автор
      17.03.2021 10:11

      1. Не совсем понял, при чём тут front controller.
      2. Ваша аналогия неплохая, но она не про количество слоёв. Я хотел указать именно на это.


  1. Nelin
    17.03.2021 10:11

    «Classes that change together are packaged together — изменение в пакете должно затрагивать весь пакет»

    Что считается изменением?
    Фикс бага в одном классе:
    — выносим класс в отдельный пакет?
    — вносим незначимые правки в остальные классы?
    — третий вариант?

    Не очень корректно, на мой взгляд, расшифрована идея


    1. SamDark Автор
      17.03.2021 10:11

      Пакеты, по идее, всегда должны общаться с другими пакетами через контракт-интерфейс. У Мартина обычно это подразумевается и говоря об изменениях он чаще всего имеет ввиду изменение реализации без изменения контракта. То есть чтобы меняя алгоритм работы не пришлось менять интерфейс и использующие его пакеты.


      На самом деле SOLID и пакетные принципы довольно расплывчаты на тему определений и их трактования. На данный момент я думаю что понял их правильно, но если нет — поправьте.


  1. support917
    17.03.2021 10:11

    [удалено]