Головна Про блог

Моє розуміння DEP принципу і структуризації коду

Source links:

Вступ

Напевно будь-який розробник, починаючи свою кар'єру, відкриває для себе mvc і anemic model. В мене це був yii2 з його active record, потім laravel з eloquent. Що далі? Далі папок стає більше, models, views, controllers уже не достатньо щоб структурувати код. Появляється services, helpers, facades, repositories, dto, event, eventListeners, command, commandHandlers, exceptions, transformers, console, api. Впізнали свій проект? На таких проектах мозок пристосовується, бере цю структуру як дане, і намагається її покращити - виділити ще більше папок під концепти, щоб не доводилось думати де створити черговий клас. Здається, що чим більше папок ми створимо (чим більше generic концептів виділимо), тим краще наше розуміння розробки.

Наслідки

Big bull of mud, той самий. Чим більше коду, тим більше у нас підпапок в топових папках, services/someConcept1, services/someConcept2 і т.д. Це призводить до того, що одна бізнес фіча може бути розкидана по 10 папкам і підпапкам і їх підпапкам. 90% бізнесу буде знаходитись в папці services. Сервісів буде так багато, що саме розуміння слова сервіс кане в прірву. Сервіси інжектять інші сервіси, ті в свою чергу інжектять інші, залежностей так багато, що symfony di інколи буде падати з зацикленням залежностей.

Чого

Чого так стається? Факторів може бути декілька

  • Простота - навіщо думати, читати, вчитись, якщо є папка services і все буде працювати, бізнес буде отримувати прибуток не залежно від вашої архітектури чи кількості папок, але ви будете отримувати сиве волосся, коли бізнес почне змінювати курс, змінювати функціонал, додавати нові кейси(а він почне).
  • Не достаток досвіду - незнання що можна по інакшому, незнання чи по інакшому краще
  • Невпевненість - страх не встигнути або не виконати таску, якщо пробувати кардинально змінити підхід до написання коду. Тому приймається сумна реальність і створюється новий сервіс в підпапці підпапки підпапки.
  • Колектив - важко переконати інших людей вчити щось нове і пробувати по інакшому, особливо, якщо їх все влаштовує.

Як

Попробую написати код і відрефакторити його, на мою думку рефакторинг це найбільш вагомий метод knowledge-sharing. Будемо писати невеликий круд для генерації графів.

Наприклад, нам потрібно мати змогу зберігати структуру графу для graphviz базу данних, діставати цю структуру, генерувати граф в pdf і віддавати користовачу. В graphviz є ноди, які можна залінкувати між собою. Такий інпут

digraph regexp {
    fontname="Helvetica,Arial,sans-serif"
    resolution=600
    node [fontname="Helvetica,Arial,sans-serif"]
    edge [fontname="Helvetica,Arial,sans-serif"]
    graph [fontsize=50, resolution=600]
    ranksep = 2;
    n1 [label="Dep/v1/GraphvizService.php", shape="box", fillcolor="white", style="filled", fontcolor="black"];
    n2 [label="Dep/v1/Graphviz.php", shape="box", fillcolor="white", style="filled", fontcolor="black"];
    
    n1 -> n2 [label="Uses", minlen=2];
}

Буде виглядати так

Example graphviz class diagram

v1

Використаємо mvc + anemic підхід. У нас буде анемічна модель Graphviz, сервіс, який створює і віддає модель, контролер. Далі ми добавимо генерацію і експорт графів в pdf.

<?php

declare(strict_types=1);

namespace Example\Dep\v1;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Column;
use Ramsey\Uuid\UuidInterface;

#[ORM\Entity(repositoryClass: GraphvizRepository::class)]
#[ORM\Table(name: 'graphviz')]
class Graphviz
{
    #[Column(name: 'id', type: 'uuid'), ORM\Id]
    public UuidInterface $id;
    #[Column(name: 'name', type: 'string')]
    public string $name;
    #[Column(name: 'nodes', type: 'json')]
    public array $nodes;
    #[Column(name: 'links', type: 'json')]
    public array $links;
    #[Column(name: 'created_at', type: 'datetime')]
    public \DateTime $createdAt;

    public function __construct(UuidInterface $id, string $name, array $nodes, array $links)
    {
        $this->id = $id;
        $this->name = $name;
        $this->nodes = $nodes;
        $this->links = $links;
        $this->createdAt = new \DateTime();
    }
}
<?php

declare(strict_types=1);

namespace Example\Dep\v1;

use Ramsey\Uuid\Uuid;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Annotation\Route;

#[Autoconfigure(autowire: true)]
#[AsController]
#[Route(path: '/v1')]
final class GraphvizController
{
    public function __construct(private GraphvizService $graphvizService, private IGraphSaver $graphSaver)
    {
    }

    #[Route('/dep/{id}', name: 'v1_example_dep_graphviz_view', methods: ['GET'])]
    public function view(string $id): Response
    {
        return new JsonResponse($this->graphvizService->get(Uuid::fromString($id)));
    }

    #[Route('/dep', name: 'v1_example_dep_graphviz_create', methods: ['POST'])]
    public function create(Request $request): Response
    {
        $parameters = json_decode($request->getContent(), true);

        $graphviz = $this->graphvizService->create($parameters['name'], $parameters['nodes'], $parameters['links']);

        return new JsonResponse($graphviz);
    }

    #[Route('/dep/{id}', name: 'v1_example_dep_graphviz_export', methods: ['POST'])]
    public function export(string $id): Response
    {
        $filePath = $this->graphSaver->save($this->graphvizService->get(Uuid::fromString($id)));

        return (new BinaryFileResponse($filePath))->deleteFileAfterSend();
    }
}
<?php

declare(strict_types=1);

namespace Example\Dep\v1;

use Doctrine\ORM\EntityManagerInterface;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;

final class GraphvizService
{
    public function __construct(
        private EntityManagerInterface $em,
        private GraphvizRepository     $graphvizRepository,
    )
    {
    }

    public function get(UuidInterface $id): Graphviz
    {
        return $this->graphvizRepository->get($id);
    }

    public function create(string $name, array $nodes, array $links): Graphviz
    {
        $graphviz = new Graphviz(Uuid::uuid4(), $name, $nodes, $links);

        $this->em->persist($graphviz);

        return $graphviz;
    }
}

Тепер, бізнес захотів експортувати ці графи в pdf і віддавати користувачу на скачування. Так як ми вчили і розуміємо SOLID, ми зробимо абстракції, ми ж хороші розробники? Бо що ж ми будем робити, якщо завтра бізнес захоче експортувати в ексель а в нас не open-closed код.

<?php

declare(strict_types=1);

namespace Example\Dep\v1;

interface IGraphGenerator
{
    public function generate(Graphviz $graphviz): string;
}
<?php

declare(strict_types=1);

namespace Example\Dep\v1;

interface IGraphSaver
{
    public function save(Graphviz $graphviz): string;
}

І реалізації додамо

<?php

declare(strict_types=1);

namespace Example\Dep\v1;

final class SimpleGraphGenerator implements IGraphGenerator
{

    public function generate(Graphviz $graphviz): string
    {
        $nodes = '';
        $links = '';

        foreach ($graphviz->nodes as $node) {
            $nodes .= <<<EOL
n{$node['index']} [label="{$node['label']}", shape="{$node['shape']}", fillcolor="{$node['fillcolor']}", style="{$node['style']}", fontcolor="{$node['fontcolor']}"];

EOL;
        }

        foreach ($graphviz->links as $link) {
            $links .= <<<EOL
{$link['from']} -> {$link['to']} [label="{$link['label']}", minlen=2];

EOL;
        }

        return <<<EOL
digraph regexp {
 fontname="Helvetica,Arial,sans-serif"
 resolution=600
 node [fontname="Helvetica,Arial,sans-serif"]
 edge [fontname="Helvetica,Arial,sans-serif"]
 graph [fontsize=50, resolution=600]
 ranksep = 2;
$nodes
$links
}
EOL;
    }
}
<?php

declare(strict_types=1);

namespace Example\Dep\v1;

use Ramsey\Uuid\Uuid;
use Symfony\Component\Filesystem\Filesystem;

final class GraphPdfSaver implements IGraphSaver
{
    public function __construct(private Filesystem $filesystem, private IGraphGenerator $graphGenerator)
    {
    }

    public function save(Graphviz $graphviz): string
    {
        $file = Uuid::uuid4();

        $this->filesystem->dumpFile("/tmp/$file.txt", $this->graphGenerator->generate($graphviz));
        exec("dot -Tpdf /tmp/$file.txt > /tmp/$file.pdf");
        $this->filesystem->remove("/tmp/$file.txt");

        return "/tmp/$file.pdf";
    }
}

Тепер, якщо бізнесу буде потрібно, ми можемо змінити формат генерації graphviz графів, і можемо змінити формат файлу в який буде йти експорт. Але навіщо? Що нам це дасть(окрім слідування solid)? В поточний момент у нас 2 абстракції в яких по 1 реалізації. Чи не бачили ви код, якому декілька років, для якого написані стратегії, автоматичний пошук потрібної стратегії, стратегії прокинуті через symfony tags по інтерфейсу як колекція, але останній раз нова імплементація для стратегії добавлялась разом з комітом в якому був створений весь цей open-closed код? Я бачив, і сам таке писав не один раз ;). Зараз я розумію наскільки важливий KISS + уникнення premature optimization, і розуміюю, що ієрархія сервісів це не панацея, но як по інакшому?

v2

Що, якщо у нас будуть command handlers прямо в entity? Добавляємо CQRS, прокачуємо знання про aggregate/saga/state-machine(3 назви, які являються 1 концептом), трохи symfony магії, яка це все збере в кучу. Під капотом, при діспатчі event/command з доктрини витягується entity по данним в івенті чи команді, resolveAggregateId нормалізує цей процес, можна і без нього як в ecotone, там є #[Identifier] і #[TargetIdentifier("orderId")]. Якщо AsCommandHandler на статичній функції, то потрібно створити нову entity а не шукати в бд.

Що, якщо в нас не буде розділення коду на generic концепти - сервіс, контроллери, дто, трансформери. Що, якщо в нас буде мінімум підпапапок і код буде згрупований під певний функціонал - vertical slice. Створюємо високорівневу папку graphviz, і закидуємо все, що стосується graphviz. І це буде не концепт, не категорія для накопичення похожого функціоналу в підпапках, це буде самостійна фіча graphviz, яка, якщо почне розростатись має розділись на інші фічі а не плодити підпапки. Тобто, якщо експорт виростає до 20 різних способів генерації графу і різних форматів експорту, експорт стає окремою фічею в високорівневій папці GraphvizExport. Що ми отримали?

  • Доменний об\'єкт, який сам себе створює, оновлює, доменна логіка не розкидана по сервісам а інкапсульована
  • Код легко знайти, якщо вся фіча буде лежати в одній папці(разом з тестами)
  • Код легше рефакторити/видаляти, не прийдеться з багатьох місць видьоргувати код
  • Має доменні знання про експорт себе в graphviz формат.
  • Через double-dispatch сам себе експортує.
  • Nodes і Links як окремі whole values, які мають доменні знання і вміють приводити себе в формат graphviz
<?php

declare(strict_types=1);

namespace Example\Dep\v2;

use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Column;
use Ops\CQRS\AggregateIdResolver;
use Ops\CQRS\AsCommandHandler;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;

#[ORM\Entity]
#[ORM\Table(name: 'graphviz')]
class Graphviz implements AggregateIdResolver
{
    #[Column(name: 'id', type: 'uuid'), ORM\Id]
    public UuidInterface $id;
    #[Column(name: 'name', type: 'string')]
    public string $name;
    #[Column(name: 'created_at', type: 'datetime')]
    public \DateTime $createdAt;
    #[Column(name: 'nodes', type: 'Example\Dep\v2\Nodes')]
    public Nodes $nodes;
    #[Column(name: 'links', type: 'Example\Dep\v2\Links')]
    public Links $links;

    public function __construct()
    {
        $this->nodes = new Nodes([]);
        $this->links = new Links([]);
    }

    public static function resolveAggregateId(object $message): array
    {
        return match ($message::class) {
            CreateCommand::class,
            ExportCommand::class,
            UpdateCommand::class => ['id' => $message->id],
            default => throw new \LogicException('Unsupported message type'),
        };
    }

    #[AsCommandHandler]
    public static function create(CreateCommand $cmd): self
    {
        $e = new self();
        $e->id = Uuid::uuid4();
        $e->name = $cmd->name;
        $e->nodes = $cmd->nodes;
        $e->links = $cmd->links;
        $e->createdAt = new DateTime();

        return $e;
    }

    #[AsCommandHandler]
    public function update(UpdateCommand $cmd): self
    {
        $this->name = $cmd->name;

        return $this;
    }

    #[AsCommandHandler]
    public function export(ExportCommand $cmd, ExportVisitor $visitor): Filepath
    {
        return $visitor->pdf($this);
    }

    public function toGraph(): string
    {
        return <<<EOL
digraph regexp {
 fontname="Helvetica,Arial,sans-serif"
 resolution=600
 node [fontname="Helvetica,Arial,sans-serif"]
 edge [fontname="Helvetica,Arial,sans-serif"]
 graph [fontsize=50, resolution=600]
 ranksep = 2;
{$this->nodes->toGraph()}
{$this->links->toGraph()}
}
EOL;
    }
}

Я собі виділив правило, не створювати value object(whole value), якщо немає унікальної валідації, тобто, по моїй логіці дублювати AdminEmail, CustomerEmail, UserEmail є лишнім, якщо валідація не відрізняється. І створювати value object як контейнер без валідації мені теж не подобається, це робить наш об'єкт доменно описаним, але породжує забагато об'єктів. Але, в коді вище export повертає Filepath, який немає валідації і служить виключно оберткою над примітивом. Коли я писав цей код я відчував, що поверення string є не інформативним, і назва методу теж не містить інформації про результат, можливо, цей метод не має існувати в такому вигляді і немає нічого повертати(асинхронно відсилати на пошту файл з графом, це було б чистим рішенням, але простим в плані дизайну). Тому, щоб цей метод існував по старій логіці і виглядав зрозумілим, я виділив Filepath.

<?php

declare(strict_types=1);

namespace Example\Dep\v2;

final class Filepath
{
    public function __construct(public readonly string $value)
    {
    }
}

В контроллері не має бути глобальних залежностей(в конструкторі), якщо ця залежність не використовується у всіх методах. Інжектим залежності через аргументи методу(бути акуратним, бо symfony di не дасть заінжектити щось після nullable аргументу - ?int \$offset = 100, EntityManagerInterface \$em, потрібно щоб \$em був на початку, інакше година-дві дебагу забезпечені).

<?php

declare(strict_types=1);

namespace Example\Dep\v2;

use Doctrine\ORM\EntityManagerInterface;
use Ops\CQRS\IBus;
use Ramsey\Uuid\Uuid;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Annotation\Route;

#[Autoconfigure(autowire: true)]
#[AsController]
#[Route(path: '/v2')]
final class GraphvizController
{
    #[Route('/dep/{id}', name: 'v2_example_dep_graphviz_view', methods: ['GET'])]
    public function view(string $id, EntityManagerInterface $em): Response
    {
        return new JsonResponse($em->find(Graphviz::class, $id));
    }

    #[Route('/dep', name: 'v2_example_dep_graphviz_create', methods: ['POST'])]
    public function create(Request $request, IBus $bus): Response
    {
        $json = json_decode($request->getContent(), true);
        $entity = $bus->handle(new CreateCommand($json['name'], new Nodes($json['nodes']), new Links($json['links'])));

        return new JsonResponse($entity);
    }

    #[Route('dep', name: 'v2_example_dep_graphviz_update', methods: ['PUT'])]
    public function update(Request $request, IBus $bus): Response
    {
        $parameters = json_decode($request->getContent(), true);
        $entity = $bus->handle(new UpdateCommand(Uuid::fromString($parameters['id']), $parameters['name']));

        return new JsonResponse($entity);
    }

    #[Route('/dep/{id}', name: 'v2_example_dep_graphviz_export', methods: ['POST'])]
    public function export(string $id, IBus $bus): Response
    {
        /** @var Filepath $filePath */
        $filePath = $bus->handle(new ExportCommand(Uuid::fromString($id)));

        return (new BinaryFileResponse($filePath->value))->deleteFileAfterSend();
    }
}

Можливо, ExportVisitor потрібно було назвати більш по доменному, но і так піде ;)

<?php

declare(strict_types=1);

namespace Example\Dep\v2;

use Ramsey\Uuid\Uuid;
use Symfony\Component\Filesystem\Filesystem;

final class ExportVisitor
{
    public function __construct(private Filesystem $filesystem)
    {
    }

    public function pdf(Graphviz $graphviz): Filepath
    {
        $file = Uuid::uuid4();

        $this->filesystem->dumpFile("/tmp/$file.txt", $graphviz->toGraph());
        exec("dot -Tpdf /tmp/$file.txt > /tmp/$file.pdf");
        $this->filesystem->remove("/tmp/$file.txt");

        return new Filepath("/tmp/$file.pdf");
    }
}

Immutable value object, який вміє персіститись в бд і діставатись з бд(через окремий doctrine type) з валідацією в конструкторі, неможливо створити таку колекцію з невалідними даними. Також, value object є інструментом для опису домену, тому, він може мати доменні знання, в цьому випадку це toGraph(а могли створити сервіс в підпапці, який би генерував цей граф ;) ). Також, для Shape, Color, Style були створені окремі Enum-и, їх великий плюс в тому, що якщо ми крутимо значення енаму через switch/match, і добавляємо новий case, то запустивши phpstan він покаже як помилки ті switch/match, де ще не добавлений новий case, з константнами так не вийде.

<?php

declare(strict_types=1);

namespace Example\Dep\v2;

final class Nodes
{
    /** @var array<array{index: int, label: string, shape: Shape, fillcolor: Color, style: Style, fontcolor: Color}> */
    public readonly array $value;

    public function __construct(array $input)
    {
        foreach ($input as &$node) {
            assert(isset($node['index']));
            assert(isset($node['label']));
            $node['shape'] = Shape::from($node['shape']);
            $node['fillcolor'] = Color::from($node['fillcolor']);
            $node['style'] = Style::from($node['style']);
            $node['fontcolor'] = Color::from($node['fontcolor']);
        }

        $this->value = $input;
    }

    public function toGraph(): string
    {
        $nodes = '';

        foreach ($this->value as $node) {
            $nodes .= <<<EOL
n{$node['index']} [label="{$node['label']}", shape="{$node['shape']->value}", fillcolor="{$node['fillcolor']->value}", style="{$node['style']->value}", fontcolor="{$node['fontcolor']->value}"];

EOL;
        }

        return $nodes;
    }
}
<?php

declare(strict_types=1);

namespace Example\Dep\v2;

final class Links
{
    /** @var array<array{from: string, to: string, label: string}> */
    public readonly array $value;

    public function __construct(array $input)
    {
        foreach ($input as $link) {
            assert(isset($link['from']));
            assert(isset($link['to']));
            assert(isset($link['label']));
        }

        $this->value = $input;
    }

    public function toGraph(): string
    {
        $result = '';

        foreach ($this->value as $link) {
            $result .= <<<EOL
 {$link['from']} -> {$link['to']} [label="{$link['label']}", minlen=2];

EOL;
        }

        return $result;
    }
}

Висновок

v2 код може здатись дивним, мені так точно здавався коли я вперше побачив event/command хендлери прямо в entity. Але попрацювавши з таким кодом приходить розуміння наскільки він чистий і плоский, немає непотрібних підпапок. Вся доменна логіка інкапсульована по доменним об'єктам а не розлазиться по сервісам. Логіка лежить біля даних - те, що змінюється разом або працює в групі повинно лежати близько(в entity/vertical slice фічі).

Цей код набагато важче почати писати, потрібно зробити магію на symfony, яка дозволить автоматично добавляти нові команди і хендлери прямо в entity. Потрібно пересилити себе і змінити майндсет, щоб відійти від привичного mvc + anemic моделі. Потрібно перечитати багато статей, щоб зрозуміти глобальну картину з сагами, але результат того вартий.

Інколи абстракції додаються щоб звести різні концепти під щось суміжне, але навіть по solid, liskov substitution трактується як - у нас не правильна абстракція, якщо чайлдом не можна замінити парента. Це може мати сенс для інфраструктурного коду, де не вийде без абстракцій, але не потрібно тащити такі абстракції в доменний код. Не потрібно вірити, що open-closed робить ваш доменний код хорошим, open-closed додає нотку premature optimization - більше коду, більше абстракцій, безглуздя з 1-2 реалізаціями, остання з яких додалась декілька років назад.

Value objects(whole values) є чудовим механізмом для опису та інкапсуляції доменної логіки. Іммутабельність є гарантією, але занадто багато змін в іммутабельних об'єктах скажуться на перфомансі.

low coupling high cohesion не має трактуватись як хороший код, він хороший тільки на маленьких і простих проектах, але масштабуватись і рефакторитись буде дуже боляче. v2 це high coupling high cohesion, але low coupling між слайсами(фічами). Якщо йти далі, то Udi Dahan вважає, що слайси мають містити і фронтенд також.

В майбутньому, попробую скласти приклад з важчою доменною логікою, реалізувати через v2 і написати статтю. crud підходить тільки для ознайомлення.