Рассмотрим на простом и наглядном примере реализацию SOLID на Symfony. Будет так же ссылка на Github.

Допустим, нужно реализовать импорт товаров из внешнего сервиса. Получится примерно такой код (на основе документации Doctrine):

namespace App\Service\Product\Import;

use App\Entity\Product\Product;
use App\Repository\Product\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;

class ImportService
{
    private const SOURCE_PATH = 'http://somedomain.com/products/';

    public function __construct(
        private EntityManagerInterface $em,
        private ProductRepository $productRepository
    )
    {
    }

    public function import(): void
    {
        $em = $this->em;
        $productsData = json_decode(file_get_contents(self::SOURCE_PATH), true);
        $i = 0;
        foreach ($productsData as $productData) {
            $product = $this->productRepository->findOneBy(['sku' => $productData['sku']]);
            if (!$product) {
                $product = new Product();
            }

            $product->setSku($productData['sku']);
            $product->setName($productData['name']);
            //...set other fields

            $em->persist($product);

            $i++;
            if ($i % 100 == 0) {
                $em->flush();
                $em->clear();
            }
        }

        $em->flush();
        $em->clear();
    }
}

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

namespace App\Utils\Importer;

use Doctrine\ORM\EntityManagerInterface;

class Importer
{
    public function __construct(
        private EntityManagerInterface $em,
    )
    {
    }

    public function import(
        ImportableRepositoryInterface $importableRepository,
        ImportableFactoryInterface $importableFactory,
        ImportMapperInterface $importMapper,
        ImportReceiverInterface $importReceiver,
        string $identityFieldName,
        int $blockSize = 100
    ): void
    {
        $em = $this->em;
        $importData = $importReceiver->receive();
        $i = 0;
        foreach ($importData as $importItemData) {
            $identityFieldValue = $importItemData[$identityFieldName];
            $importable = $importableRepository->findOneByImportIdentity($identityFieldValue);
            if (!$importable) {
                $importable = $importableFactory->create();
            }

            $importMapper->map($importable, $importItemData);
            $em->persist($importable);

            $i++;
            if ($i % $blockSize == 0) {
                $em->flush();
                $em->clear();
            }
        }

        $em->flush();
        $em->clear();
    }
}

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

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

Это сервис, содержащий логику импорта товара, предметную область:

namespace App\Service\Product\ImportV2;

class ImportService
{
    public function __construct(
       private ImporterInterface $importer
    )
    {
    }

    public function import(): void
    {
        $this->importer->import();
        //...do other things
    }
}

А вот уже конкретика:

namespace App\Service\Product\ImportV2\Importer;

use App\Entity\Product\Product;
use App\Utils\Importer\Importer as BaseImporter;
use App\Service\Product\ImportV2\ImporterInterface;
use App\Service\Product\EntityFactory\ProductFactory;

class Importer implements ImporterInterface
{
    public function __construct(
        private BaseImporter $importer,
        private Mapper $mapper,
        private Receiver $receiver,
        private ImportableProductRepository $productRepository,
        private ProductFactory $productFactory
    )
    {
    }

    public function import(): void
    {
        $this->importer->import($this->productRepository, $this->productFactory, $this->mapper,
            $this->receiver, Product::IMPORT_IDENTITY_FIELD, 200);
    }
}

Код остальных классов можно посмотреть в исходном коде на гитхаб. Как можно заметить, так же применился и принцип единой ответственности, принцип открытости-закрытости, принцип разделения интерфейсов, в общем, весь SOLID.

Конечно, это только пример, но в теории, такой компонент импорта может дальше быть развит и использоваться в разных проектах. Можно, например, внедрить в него использование Symfony Serializer, получение данных через REST API, чтение по блокам и другое.

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


  1. crackidocky
    18.11.2023 15:09
    +4

    Есть впечатление, что сложность туториала выставлена в "Сложный" не из-за сложности материала, а из-за того как вы решили отформатировать код????

    А если без шуток, то это туториалом нельзя назвать, это скорее заметка, которую вы из Notion/Obsidian/<вставьте ваше приложение> выцепили и решили выложить.

    Будет так же ссылка на Github

    В чем проблема сразу дать ссылку на Github?

    Допустим, нужно реализовать импорт товаров из внешнего сервиса. Получится примерно такой код:

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

    И обычно этого достаточно. Но пойдём дальше, сделаем вторую версию реализации.

    Зачем идти дальше? Объясните читателю что не так с первой версией реализации

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

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


    1. maxkain Автор
      18.11.2023 15:09
      -12

      Неужели не понятно, что не получилось сразу нормально отформатировать текст в редакторе хабра. Сложность выставлена в соответствии с рекомендациями Хабра. Сразу ссылка на гитхаб? Зачем?

      Зачем идти дальше? Объясните читателю что не так с первой версией реализации

      Статья про реализацию SOLID. То есть, первая версия не соответствует SOLID. Неужели это не понятно?


      1. lair
        18.11.2023 15:09
        +9

        Статья про реализацию SOLID. То есть, первая версия не соответствует SOLID. Неужели это не понятно?

        Человеку, который не знает про SOLID, не понятно. А человеку, который знает и понимает, не нужна ваша статья.


        1. maxkain Автор
          18.11.2023 15:09
          -10

          Уж вы то всё знаете, кому что нужно. Статья про реализацию. Про теорию есть другие статьи. Многие знают про SOLID, но понять и применить его довольно сложно, и я думаю, эта статья многим может быть полезна. Здесь один из вариантов реализации, а разных вариантов может быть много.


          1. lair
            18.11.2023 15:09
            +6

            я думаю, эта статья многим может быть полезна

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

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


            1. maxkain Автор
              18.11.2023 15:09
              -3

              в чем реальная смысл происходящего

              Да много где разъяснено. И в этой статье, указано, что мы получили класс, куда можно выделить логику приложения, бизнес-логику. Получили отдельный компонент импорта, который можно повторно использовать в пределах проекта или во многих проектах. Принцип DRY (Don't repeat yourself). Избегаем дублирование логики, что облегчает поддержку проекта. Отделяем основную логику от вторичной.

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

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

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


              1. lair
                18.11.2023 15:09
                +1

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

                А зачем он нужен?

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

                А точно можно? Вы требования на него проанализировали? У него нет неявных ограничений, которые этому помешают?

                А самое главное - это нужно?

                Принцип DRY (Don't repeat yourself).

                А еще есть YAGNI. И вот ему вы противоречите.

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

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


                1. maxkain Автор
                  18.11.2023 15:09
                  -3

                  Если в результате вашего примера получилось что-то, что делать нецелесообразно - это не наглядный пример.

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

                  А зачем он нужен?

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


                  1. lair
                    18.11.2023 15:09
                    +1

                    Как наглядный пример связан с целесообразностью?

                    Напрямую. Если из примера непонятно, зачем это делать - зачем этот пример нужен?

                    Зачем выделять предметную область? Об этом есть множество книг и статей.

                    Тогда зачем нужна ваша статья?

                    Похоже вы не в теме, и вам следовало бы ознакомиться с материалом.

                    ...с каким, например?

                    Если вам это не нужно, пройдите мимо, и не выносите мозг людям, пожалуйста.

                    Вы же считаете возможным писать публичные статьи? Ну а я считаю возможных их комментировать. Каждый в своем праве.


  1. JordanCpp
    18.11.2023 15:09

    Код остальных классов можно посмотреть в исходном коде на гитхаб

    Дали бы сразу ссылку на гугл:) Там точно информативнее.


  1. Farongy
    18.11.2023 15:09

    Так а где изменения направления зависимостей? Т.е. где появляется инверсия?
    Я вижу что в первом примере сервис зависит от репозитория и в последнем примере сервис зависит от репозитория.


    1. maxkain Автор
      18.11.2023 15:09

      ImportService зависит только от ImporterInterface. А за этим интерфейсом уже детали реализации.


      1. Farongy
        18.11.2023 15:09
        +2

        Т.е. для вас инверсия это добавление слоя абстракции?


        1. maxkain Автор
          18.11.2023 15:09

          А как ещё? В ImportService можно дописывать логику, которую относим к предметной области. В реализации, в Mapper, например, можно вызвать другие сервисы, относящиеся к предметной области. В общем, ещё много всего можно доделывать.


          1. Farongy
            18.11.2023 15:09

            Слово инверсия значит - наоборот. Т.е. было влево, стало вправо. Было вверх стало вниз.

            Инверсия зависимостей - значит зависимости должны как-то повернуться. Собственно и вопрос как они повернулись? От добавления ещё 3-х слоёв абстракций направление не меняется.


            1. lair
              18.11.2023 15:09

              Инверсия зависимостей - значит зависимости должны как-то повернуться.

              Применительно конкретно к зависимостям понятие инверсия (точнее Dependency Inversion) означает только и исключительно то, что реализации зависят от абстракций, а не от других реализаций. Если вам так хочется смотреть на это как на поворот, то реализация-реализация - это "вверх", а реализация-абстрация - "вниз".


              1. Farongy
                18.11.2023 15:09

                Т.е. если в вашем проекте нет интерфейса на каждый сервис/репозиторий вы нарушаете DI?


                1. lair
                  18.11.2023 15:09

                  Если в вашем проекте нет абстраций, то да, вы нарушаете правило dependency inversion.


        1. Glembus
          18.11.2023 15:09

          А для Вас нет? Инверсия как раз и достигается путем использования абстракции для инъекции зависимости. Тоесть эта абстракция имеет инверсию а не ижект в сервис.


          1. Farongy
            18.11.2023 15:09

            Инверсия с инъекцией вообще никак не связана. Вот инъекция да, помогает делать инверсию, но можно и без неё. А можно использовать инъекцию, но без инверсии)


            1. Glembus
              18.11.2023 15:09

              Почитайте определение буквы D в акрониме SOLID. В PHP абстракция это интерфейс, мог конечно завязаться на каком абстрактном классе, но тогда это менее гибкая реализация. Что не так автор написал/имплементил?


  1. zubrbonasus
    18.11.2023 15:09
    -1

    Принцип единой ответственности говорит что объект должен использовать один актор. Объект импортёр может использовать актор склад, актор логист, актор торговый зал. Как вы реализовывали SRP?

    Зачем получать данные в цикле? Может стоить получать данные одним запросом?


    1. maxkain Автор
      18.11.2023 15:09
      -2

      Первые попавшиеся определения SRP:

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

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

      Здесь, в коде, отдельно Mapper, Receiver и т.д.

      Зачем получать данные в цикле? Может стоить получать данные одним запросом?

      Что-то вы путаете. Здесь данные получаем одним запросом к api, а обрабатываем в цикле.


  1. sanstorm
    18.11.2023 15:09
    -1

    Не идеально, но пойдёт, пиши ещё!