Изначально этот материал планировался как урок в PHP-курсе по полиморфизму. Но он, в конце концов, перерос сам урок, и я решил сделать из него отдельную статью. В ней практически ничего PHP-специфичного, поэтому рекомендуется для прочтения всем без исключения.

Напомню, что модель классов PHP взята из Java. Наличие интерфейсов и всех сопутствующих элементов очень сильно влияет на способ организации кода в PHP. Этот способ часто отличается от того, как организуется код в JavaScript, Ruby или Python. И ещё больше отличается от таких языков, как Clojure или Elixir. И всё это на фоне того, что в каждом из этих языков есть ООП.

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

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

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

Архитектура опирается на особенности среды, в рамках которой она применяется, а не на конструкции языка. Например, в вебе господствует HTTP, который построен вокруг концепции "запрос-ответ". Именно поэтому микрофреймворки разных языков выглядят так похоже, независимо от того, есть там ООП или нет: в каждом микрофреймворке есть запрос, ответ и обработчик ответа.

В современной разработке ООП стало чем-то вроде культа карго. Это негативно влияет на неокрепшие умы. Если погуглить по запросу "какой паттерн применить", то можно найти много интересного и грустного:

Подобная ситуация есть в другом месте кода, что в итоге мне не очень нравится, так как выглядит очень коряво. Чувствую, что нужен паттерн, но вот какой сюда применить даже не знаю. Мысль закралась в сторону стратегии, но не уверен, как ее сюда применить. Гуру паттернов, посоветуйте, как быть.

Подобные вопросы появляются только от непонимания того, что человек делает, и попытка заткнуть проблему паттерном дорого обойдётся проекту.

Это не значит, что ООП и паттерны знать не нужно. Нужно, особенно если вы работаете в классовых языках, но это всего лишь одна из многих и не самых критичных частей. Те же ООП-паттерны (это не архитектурные паттерны, например MVC — не ООП-паттерн) чаще применяются в локальных ситуациях. То есть их влияние на архитектуру сильно ограничено.

Архитектура кода

Ниже попробуем разобраться с тем, как всё же стоит писать код, на что обращать внимание и что за чем следует в порядке важности.

Основные части приложения

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

Модель предметной области

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

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

Сущности в коде представляются разными структурами, здесь уже всё зависит от языка. В некоторых языках это структуры, в других — объекты, в третьих — записи. Но какой бы способ и язык не были выбраны, пользователь останется пользователем, а курс — курсом! И для этого не обязательно иметь объекты.

Эта часть, в идеале, не знает ничего про среду использования, находимся мы в браузере или на бекенде или внутри банкомата. Таким образом достигается модульность, система разбита на слои, которые замкнуты относительно своих операций и не знают про окружение. На практике добиться полной изоляции сложно и, скорее всего, не нужно. Но нужно избегать «протекания абстракции», то есть слой верхнего уровня знает про слой лежащий под ним (и использует его), а вот нижний слой не может знать про то, как им пользуются. В примере с Хекслетом, код внутри слоя, отвечающего за операции с курсом, не может знать про существование http и веб-фреймворка.

Среда

Следующая важная часть – среда исполнения. Она определяет основную архитектуру приложения. Если мы работаем в браузере, то это событийная модель, если в бекенде по http — то "запрос-ответ", а в командной строке — прямой запуск кода на исполнение. Есть и другие среды со своими особенностями. Для каждой из них наработано большое количество архитектурных подходов, которые придумывать не нужно — они уже реализованы во фреймворках. В первую очередь это MVC. Причём, в зависимости от среды, — либо MVC1, либо MVC2.

Для хорошего понимания правил работы в данном слое нужно знать операционные системы и сети. Например, невозможно построить хорошее API, не зная протокол HTTP, не имея понятия об идемпотентности и гарантиях доставки сообщений в распределённых системах.

Основные принципы структурирования кода

Изолируйте побочные эффекты от чистого кода

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

Следите за идемпотентностью

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

Используйте автоматное программирование

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

  • Регистрация пользователя (ожидает подтверждения емейла, подтвержден, забанен)

  • Публикация статьи (черновик, опубликованная, удаленная)

Избегайте глобальных переменных

Ими могут выступать и объекты, и классы, имеющие внутреннее состояние, которое может поменяться в процессе жизни приложения.

Избегайте ненужного состояния и разделяемого состояния (shared state)

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

Выделяйте абстракции по необходимости

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

  • Нужно ли выносить функцию в отдельный файл? Нет.

  • Нужно ли делать много маленьких функций? Нет.

  • Нужно ли разбивать компонент на компоненты? Нет.

  • Нужно ли вешать индекс в базу данных на всякий случай, ведь потом данных будет много? Нет.

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

Изолируйте технический долг

Не любой технический долг растёт. Если абстракция хороша и не протекает, то не принципиально, как написан код внутри. Его можно будет переписать, когда придёт время. Иногда приходит время — и код просто удаляется за ненадобностью. Простой пример: функция сортировки массива.

Разбивайте приложение на слои

Используйте уровневое проектирование. Нижние слои не должны знать про сущности верхних слоев, а верхние слои работают на базе нижних. Пример разбиения.

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

Перед тем как говорить про производительность, прочитайте optimization.guide

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


  1. SadOcean
    05.08.2024 16:34
    +4

    Спасибо, отличная статья.
    Я не только имел похожие мысли, но даже держу прототип похожей статьи в черновиках. Может когда допишу.

    Единственное, с чем Я бы не согласился, это с тем, что среда определяет архитектуру приложения. Хотя размышляя сейчас, Я прихожу к выводу, что и так сказать можно, это в любом случае строительные блоки архитектуры.
    Так же и "архитектурные паттерны" ООП действительно являются архитектурными, просто это определенный разрез архитектуры.

    Я бы привел такой пример:
    Архитектура приложения подобна архитектуре здания
    - Среда подобна материалам и базовым деталям. Для разных задач подходят разные среды.
    - ООП и архитектурные паттерны походят на типичные архитектурные решения - двери и окна, размер комнат, организацию коридоров и залов. Здесь же есть типичные паттерны - многоквартирные дома строятся от лифтовых шахт и подъездов, а больницы, к примеру, содержат коридоры и много кабинетов.
    - Высокоуровневая архитектура же скорее относится к организации здания в целом в зависимости от цели - больница это или завод.

    Отсюда растут большие проблемы с дискуссиями про ООП и паттерны.
    Бесполезно говорить в терминах "давайте построим здание в паттерне один коридор и много мелких кабинетов" или "давайте построим кирпичный дом", но именно так выглядят беседы про паттерны.

    Архитектура проекта - это высокоуровневая организация, идущая прежде всего от доменной области.
    Скажем больница - это палаты под Х пациентов, Y операционных, рентгенологическое отделение, административные помещения, и приемное отделение с Z кабинетами врачей. Обязательно наличие грузового лифта.

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

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

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


  1. fomiash
    05.08.2024 16:34
    +3

    Архитектура приложения подобна архитектуре здания

    Архитектура веб-приложения (если говорим о PHP) больше похожа на устройство корабля в бурном море. Все лишнее за борт, сейчас волна (ддос) накроет, мачту снесло, надо чинить и тд)


    1. SadOcean
      05.08.2024 16:34

      Неплохой пример.

      В каком то смысле и корабль, и буровая платформа и космическая станция - специализированные строения. Там люди так то живут.

      И разница в функциях и требованиях делает их очень разными


  1. saboteur_kiev
    05.08.2024 16:34
    +3

    Так ООП никогда и не определял архитектуру.

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

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


    1. SerafimArts
      05.08.2024 16:34

      Хм, и всё же я, например, склонен считать ООП про макроархитектуру, где в тоже время ФП и процедурщина - микро.

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

      Т.е. если с этой стороны рассматривать, то ООП про архитектуру как раз, это супер-надстройка над ФП/ПП, а ФП и ПП уже про частную реализацию, про внутрянку методов и организацию (реализацию) алгоритмов внутри них. И даже если составлять алгоритм из композиции объектов (типа new Where(new Eq('some', 23), new Gte('any', 42))), то это больше про ФП, хотя и используются объекты.

      P.S. Очевидно, что всякие АОП рассматривать не имеет смысла, т.к. это дополнение к существующим прадигмам, а не самостоятельная.


  1. nv13
    05.08.2024 16:34

    Захотелось прокомментировать)

    Структурирование кода:

    В какой то старой книжке было деление программы на архитектурные и конструктивные элементы. Архитектурные обладают независимостью от платформы и долговременной стабильностью, конструктивные платформоориентированы и могут образовывать стеки ввода вывода и тепе. К примеру использование com и lpt в рамках одного канала обмена - использование разных стеков конструкционного по.

    Не делайте ничего лишнего

    Звучит это вроде и правильно, но в жизни ни разу не было так, чтобы это срабатывало. В одном из первых проектов заказчик и руководитель убеждали быстро сделать что то вроде демо - была некая среда разработки и библиотека С с printf на хост. Мы убедили начальство, что сначала надо разработать коммуникационное ядро для сети процессоров, а алгоритмы подгружать в соответствие с конфигурацией многопроцессорной системы. Это заняло месяца 3, ещё пара ушла на отладку алгоритмов. Но вот следующий проект на этой же сети был выполнен за месяц. При упрощённом подходе он бы занял столько же, как и первый, если бы вообще заработал из за отсутствия средств отладки, нагруженности и математики. В других проектах возможность "переписать потом", как правило, отсутствовала не смотря на то, что руководство очень увлекалось всякими аджайлами. Некоторые писали почти демо, которое потом долго и мучительно допиливали тратя ещё столько же времени. В общем излишняя начальная простота может существенно затруднять не только развитие продукта, но и обычную работу.

    Слои и взаимодействия

    Уже давно интересно, почему на Хабре часто ссылаются на Чистую архитектуру, в курсе существования OSI RM, но вот OSE RM как то не модно. Может потому, что разрабатывают программы, а не системы?)


  1. GreenRarog
    05.08.2024 16:34
    +1

    Хорошая статья, спасибо автору!

    Единственное что я бы добавил - для кого именно написана эта статья? мне показалось что это написано для новичков и в таком разрезе все это полезно - новичку с первых строчек кода нового проекта очень сложно построить грамотные и компактные методы-классы

    Ну а старички знакомы с "Чистый код" и знают, что стремиться к компактным методам и классам необходимо - но после того как была закрыта первоочередная задача и решена бизнес-проблема


  1. vat78
    05.08.2024 16:34

    Мне кажется ООП уже несколько лет как в опале и даже в исконно-оопешых языках уже все пишут в "функциональном" (по факту, в процедурном) стиле. Когда есть объекты для хранения данных (структуры) и есть объекты-сервисы, содержащие функции для обработки переданных данных.
    Но как по мне, так ООП вполне хорошо подходит для написания бизнес логики (ядра системы). Так называемая, rich model, когда объект позволяет собрать в одной программной сущности данные и логику их обработки. Очень удобно, когда у тебя вместо модуля из набора структур и функций есть экземпляр объекта 'Курс', например, и любой редактор выдаст тебе список доступных действий с ним (его публичные методы).
    Правда давно уже не видел, чтоб кто-то такой подход практиковал. Во многом из-за опасений, что мутабельный стейт создаст проблем при многопоточной обработке. Но как часто в реальности есть потребность менять один экземпляр предметной области в ядре системы несколькими параллельными потоками?


    1. michael_v89
      05.08.2024 16:34

      Дело обычно не в стейте, а в том, что тогда класс "Курс" будет занимать несколько тысяч строк, и в том, что для действий с курсом обычно требуются внешние зависимости. Вы будете email о покупке курса отправлять из класса сущности? Как вы будете пробрасывать компонент, который это делает, через параметры метода? И отправлять email до коммита транзакции? Или курс сам себя будет коммитить?


      1. vat78
        05.08.2024 16:34

        Сохранение в базу, отправка email - это вещи которые не связаны с доменным ядром системы. Это все уже делается на других слоях (о чем, собственно, статья и говорит).
        В ядре системы должны быть лишь операции, меняющие саму бизнес-сущность.
        И да, для каких-то сложных сущностей может получится достаточно внушительный по размеру класс. Но как по мне, гораздо безопаснее держать в одном месте все методы (функции), которые могут менять бизнес-сущность и легко получать их список, чем иметь простую структуру данных и менять ее поля в разных функциях, которые по всему проекту раскиданы. Гораздо удобнее, когда у каждой бизнес-сущности есть "владелец", который единственный, кто может ее менять и предоставлять другим частям системы ограниченный API для взаимодействия. Так почему же не объединить это в одном куске кода (классе) - в этом и была главная задумка классов и ООП.
        Если на примере веб приложения говорить, то мне больше нравится, когда контроллер получает бизнес сущность из слоя работы с базой, вызывает у сущности метод с нужной бизнес логикой для ее изменения, передает измененную сущность для сохранения. А вот уже на слое сохранения данных в базу как раз и формируются все нужные ивенты, на которые потом триггерится отправка email, например. Но гораздо более популярен подход, когда контроллер дергает некую функцию, в которой по факту мешанина из бизнес логики и работы с внешними системами (база, очереди, другие сервисы). В результате, отправка email до комита транзакции выглядит вполне себе даже хорошим решением (на самом деле - не делайте так)


        1. michael_v89
          05.08.2024 16:34

          Сохранение в базу, отправка email - это вещи которые не связаны с доменным ядром системы.
          В ядре системы должны быть лишь операции, меняющие саму бизнес-сущность.

          Это бизнес-требования к логике работы приложения (действиям, которые оно делает), значит это бизнес-логика. Запись значений из входных данных в поля this это не бизнес-логика, у бизнеса нет требований к тому, как это должно работать.
          Бизнес-требования есть и к операциям, которые не меняют бизнес-сущность - например логика работа фильтров в списке. Или к операциям, которые влияют на несколько сущностей - изменение порядка изображений в товаре.

          Но как по мне, гораздо безопаснее держать в одном месте все методы
          когда у каждой бизнес-сущности есть "владелец", который может предоставлять другим частям системы ограниченный API для взаимодействия
          Так почему же не объединить это в одном куске кода (классе) - в этом и была главная задумка классов и ООП

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

          формируются все нужные ивенты, на которые потом триггерится отправка email

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

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

          Ну то есть у вас толстые контроллеры, которые управляют сохранением сущностей.
          Что если надо сделать то же действие в консольной команде, будете копипастить код контроллера?
          Что если надо для загрузки изображения к товару сначала сохранить сущность File в базу, загрузить ее в файловое хранилище, и только после успешной загрузки сохранять ProductImage с id файла? Это все будет в контроллере?

          У меня есть статья на эту тему с примером бизнес-требований, попробуйте написать хотя бы метод sendForReview в вашем стиле (там 40 строк логики) и сравните код.