April 21, 2023

На барабане буква D: Принцип инверсии зависимостей на практике

Принцип инверсии зависимостей - самый заметный организационный принцип в архитектурных диаграммах.

В статье с правилами зависимостей я рассказывал про принцип ацикличности зависимостей и как разорвать цикл с помощью принципа инверсии зависимостей. В этой статье я покажу как применять его на практике. Погнали!

Кейс

Чистая архитектура диктует базовое правило: внутренний слой никогда не должен ссылаться ни на что из внешнего уровня.

Возьмем в качестве примера Application слой с use-case CreatePost. CreatePost создает новый Пост в блоге и сохраняет его в БД. Мы видим на картинке ниже следующую зависимость

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

В коде это выглядит так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
<?php

/**
* Модель записи Post
*/
final class Post
{
private readonly \DateTime $createdAt;
private ?\DateTime $updatedAt = null;

public function __construct(
private readonly string $id,
private string $title,
private string $body,
) {
$this->createdAt = new \DateTime();
}

/**
* @return string
*/
public function getBody(): string
{
return $this->body;
}

public function getCreatedAt(): \DateTime
{
return $this->createdAt;
}

public function getId(): string
{
return $this->id;
}

public function getTitle(): string
{
return $this->title;
}

public function getUpdatedAt(): ?\DateTime
{
return $this->updatedAt;
}

public function changeBody(string $body): void
{
$this->body = $body;
$this->updatedAt = new \DateTime();
}

public function changeTitle(string $title): void
{
$this->title = $title;
$this->updatedAt = new \DateTime();
}
}

/**
* Команда для создания поста, DTO
*/
readonly class CreatePostCommand
{
public function __construct(
public string $title,
public string $body
) {
}
}

/**
* DTO для результата
*/
readonly class CreatePostResponse
{
public function __construct(
public string $id
)
{
}
}

/**
* Обработчик команды создания поста
*/
final class CreatePostHandler
{
public function __invoke(CreatePostCommand $command): CreatePostResponse
{
$post = new Post(
uniqid('post'),
$command->title,
$command->body
);

$repository = new DbalPostRepository();
$repository->save($post);

return new CreatePostResponse($post->getId());
}
}

/**
* Репозиторий постов, для работы с БД использует doctrine\dbal
*/
final class DbalPostRepository
{
public function save(Post $post): void
{
ConnectionFactory::fromEnv()->insert(
'post',
[
'id' => $post->getId(),
'title' => $post->getTitle(),
'body' => $post->getBody(),
'created_at' => $post->getCreatedAt()->format(\DATE_ATOM),
'updated_at' => $post->getUpdatedAt()?->format(\DATE_ATOM),
]
);
}

public function ofId(string $id): Post
{
$data = ConnectionFactory::fromEnv()
->createQueryBuilder()
->from('post')
->select('*')
->where('id = ?')
->setParameter(0, $id)
->executeQuery()
->fetchAllAssociative();

if (empty($data)) {
throw new \Exception('Not found');
}

if (is_array($data)) {
$data = current($data);
} else {
throw new \Exception('Not found');
}

$namingStrategy = MapNamingStrategy::createFromHydrationMap([
'created_at' => 'createdAt',
]);

$hydrator = new TypedReflectionHydrator();
$hydrator->setNamingStrategy($namingStrategy);
$refClass = (new \ReflectionClass(Post::class))
->newInstanceWithoutConstructor();
$object = $hydrator->hydrate($data, $refClass);

if ($object instanceof Post) {
return $object;
}

throw new \Exception('Not found');
}
}

Что здесь не так

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

final class InMemoryPostRepository
{
private array $storage = [];
public function save(Post $post): void
{
$this->storage[$post->getId()] = $post;
}

public function ofId(string $id): Post
{
if (isset($this->storage[$id]) === false) {
throw new \OutOfBoundsException('Post not found');
}

return $this->storage[$id];
}
}

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

Ветка: step-1

Решение

В качестве решения применим принцип инверсии зависимостей: выделяем интерфейс репозитория, кладем его туда, где он используется, я положу его в домен. В use-case CreatePost работаем только с этим интерфейсом, и затем его реализуем в слое инфраструктуры.

Я положил интерфейс репозитория в домен. Тема, где именно такой репозиторий должен лежать (в Application или Domain) достаточно холиварная. Но я руководствуюсь тем, что если репозиторий отвечает за персистеность агрегата, то стоит его положить рядом с этим агрегатом.

Линия пересекающая стрелочку обозначает архитектурную границу, зависимости пересекают линию в одном направлении - в сторону интерфейса (в сторону более абстрактной сущности).

Ниже представлен код интерфейса

1
2
3
4
5
6
7
8
9
10
11
<?php

interface PostRepository
{
public function save(Post $post): void;

/**
* @throws \Exception
*/
public function ofId(string $id): Post;
}

И его реализация для DbalPostRepository и InMemoryRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php

final class DbalPostRepository implements PostRepository
{
public function save(Post $post): void
{
...
}

public function ofId(string $id): Post
{
...
}
}


final class InMemoryPostRepository implements PostRepository
{
public function save(Post $post): void
{
...
}

public function ofId(string $id): Post
{
...
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

final class CreatePostHandler
{
public function __construct(
private readonly PostRepository $postRepository
)
{
}

public function __invoke(CreatePostCommand $command): CreatePostResponse
{
$post = new Post(
uniqid('post'),
$command->title,
$command->body
);

$this->postRepository->save($post);

return new CreatePostResponse($post->getId());
}
}

Теперь у нас CreatePostHandler (слой Application) зависит от PostRepository (слой Domain), DbalPostRepository и InMemoryRepository также зависят от PostRepository (слой Domain). Цикличность зависимостей отсутствуют, таким образом, мы добились соблюдения принципа ацикличности зависимостей.

Ветка: step-2

Собираем лего с помощью DI

А в чем проблема собственно?) Обратим внимание на PostController::createPost

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

final class PostController
{
public function createPost(): never
{
$body = file_get_contents('php://input');
$data = json_decode($body, true);

$command = new CreatePostCommand($data['title'], $data['body']);
$handler = new CreatePostHandler(new DbalPostRepository());
$response = $handler($command);

echo json_encode([
'id' => $response->id
]);

exit(0);
}
}

В строке 11 мы самостоятельно создаем объект DbalPostRepository. Объект достаточно простой, и кажется, что проблем нет. Но я многие вещи упрощаю, давайте усложним немного, чтобы придать значимость для DI. Взглянем на методы DbalPostRepository::save и DbalPostRepository::ofId, внутри используется фабрика ConnectionFactory::fromEnv, которая возвращает Doctrine\DBAL\Connection, проблема в том, что мы сильно зависим от фабрики ConnectionFactory и переменного окружения. Также, данный код создает постоянно новые экземпляры соединения, что может быть тоже чревато в высоконагруженных системах, или в асинхронных приложениях (ага, на php тоже есть асинхронность). Решение проблемы сделать Doctrine\DBAL\Connection зависимостью

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php

final class DbalPostRepository implements PostRepository
{
public function __construct(
private readonly Connection $connection
)
{
}

public function save(Post $post): void
{
$this->connection->insert(
'post',
[
...
]
);
}

public function ofId(string $id): Post
{
$data = $this->connection
->createQueryBuilder()
->from('post')
->select('*')
->where('id = ?')
->setParameter(0, $id)
->executeQuery()
->fetchAllAssociative();

...
}
}

Теперь посмотрим как меняется PostController::createPost

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

final class PostController
{
public function createPost(): never
{
$body = file_get_contents('php://input');
$data = json_decode($body, true);

$command = new CreatePostCommand($data['title'], $data['body']);

$connection = ConnectionFactory::fromEnv();
$repository = new DbalPostRepository($connection);
$handler = new CreatePostHandler($repository);

$response = $handler($command);

echo json_encode([
'id' => $response->id
]);

exit(0);
}
}

Взглянем на 12-14 строки, теперь мы берем на себя ответственность по созданию Doctrine\DBAL\Connection и правильному созданию DbalPostRepository с нужным Connection.

Ветка: step-3

Давайте нагоним жути: в CreatePostHandler помимо зависимости PostRepository может быть “валидатор” уникальности слага, публикатор событий, контроль прав доступа и т.д. и каждый из них может иметь свои зависимости, что становится большой проблемой для создания CreatePostHandler.

Есть такой паттерн Inversion of Control (IoC), который определяет, что объекты не создают другие объекты, а получают их из внешних источников.

Dependency Injection (DI) - является подтипом IoC, и он реализует внедрение зависимостей через конструкторы, сеттеры, рефлексию, а также может работать на уровне интерфейсов.

Для примера мы возьмем библиотеку php-di/php-di, мы можем попросить её создать объект CreatePostHandler, строки 12-13:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

final class PostController
{
public function createPost(): never
{
$body = file_get_contents('php://input');
$data = json_decode($body, true);

$command = new CreatePostCommand($data['title'], $data['body']);

$container = new DI\Container();
$handler = $container->get(CreatePostHandler::class);

$response = $handler($command);

echo json_encode([
'id' => $response->id
]);

exit(0);
}
}

Контейнер php-di вернет объект CreatePostHandler со всеми необходимыми зависимостями, но есть нюанс в этом примере, мы получим ошибки:

  1. у нас есть интерфейс PostRepository и две его реализации DbalPostRepository и InMemoryRepository, DI не сможет выбрать самостоятельно реализацию, поэтому мы должны указать её явно;
  2. также DbalPostRepository требует Connection, которому нужны хост БД, порт, логин и пароль, DI не знает откуда их брать.

Давайте сконфигурируем контейнер

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

final class PostController
{
public function createPost(): never
{
$body = file_get_contents('php://input');
$data = json_decode($body, true);

$command = new CreatePostCommand($data['title'], $data['body']);

$container = new DI\Container(
[
PostRepository::class =>
DI\autowire(DbalPostRepository::class),
\Doctrine\DBAL\Connection::class =>
DI\factory([ConnectionFactory::class, 'fromEnv'])
]
);

$handler = $container->get(CreatePostHandler::class);

$response = $handler($command);

echo json_encode([
'id' => $response->id
]);

exit(0);
}
}

В строке 14-15 явно указываем, что для интерфейса PostRepository::class необходимо использовать реализацию DbalPostRepository::class, функция DI\autowire говорит контейнеру DI, что он должен создать и найти все зависимости самостоятельно.

В строках 16-17 просим создать \Doctrine\DBAL\Connection::class через фабрику, для это используем метод DI\factory, принимающий callable тип.

Таким образом, мы применили DI на практике и облегчили себе жизнь по созданию объектов. Но согласитесь, что есть пока какой-то слабый профит, потому что выглядит так, словно в каждом контроллере необходимо будет конфигурировать контейнер.

Самый просто вариант - создать фабрику, которая будет возвращать сконфигурированный контейнер

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php

final class PhpDIFactory
{
private ?Container $container = null;

public static function default(): Container
{
if (static::$container !== null) {
return static::$container;
}

$definitions = [
PostRepository::class =>
DI\autowire(DbalPostRepository::class),
\Doctrine\DBAL\Connection::class =>
DI\factory([ConnectionFactory::class, 'fromEnv'])
];

$builder = new ContainerBuilder();
$builder->useAutowiring(true);
$builder->useAttributes(false);

$builder->addDefinitions($definitions);

static::$container = $builder->build();

return static::$container;
}
}

В контроллерах, где нужен контейнер, мы можем получить его с помощью PhpDIFactory::default().

Такой вариант подходит, когда мы не управляем созданием контроллеров и объектов, как правило, в каких-то старых фреймворках.

В нашем примере есть возможность влиять на создание контроллеров, предлагаю рассмотреть второй вариант, взглянем на index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

require __DIR__ . '/../vendor/autoload.php';

$dispatcher = FastRoute\simpleDispatcher(function (FastRoute\RouteCollector $r) {
$r->addRoute('GET', '/', function () {
echo 'home';
});
$r->addRoute('POST', '/post', [\Blog\Infrastructure\Http\PostController::class, 'createPost']);
});

// Fetch method and URI from somewhere
$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

// Strip query string (?foo=bar) and decode URI
if (false !== $pos = strpos($uri, '?')) {
$uri = substr($uri, 0, $pos);
}
$uri = rawurldecode($uri);

$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
switch ($routeInfo[0]) {
case FastRoute\Dispatcher::NOT_FOUND:
// ... 404 Not Found
break;
case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
$allowedMethods = $routeInfo[1];
break;
case FastRoute\Dispatcher::FOUND:
$handler = $routeInfo[1];
$vars = $routeInfo[2];

if (is_callable($handler)) {
$handler();
break;
}

if (is_array($handler)) {
if (class_exists($handler[0]) === false) {
throw new Exception(
sprintf('Class %s not found', $handler[0])
);
}

$classHandler = new $handler[0]();
if (method_exists($classHandler, $handler[1]) === false) {
throw new Exception(
sprintf('Method %s of %s not found', $handler[1], $handler[0])
);
}

$classHandler->{$handler[1]}();
}

break;
}

В index.php происходит инициализация библиотеки nikic/FastRoute, в строках 5-10 задается роутинг, нас интересуют строки 30-56, здесь прописан кейс, когда роутинг определил обработчик маршрута.

У контейнера php-di есть метод call, он как и call_user_func, вызывает переданную первым аргументом функцию, но особенность call в том, что при вызове функции он сам может найти и передать нужные аргументы на основе указанных типов или явно заданных зависимостях.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

require __DIR__ . '/../vendor/autoload.php';

$dispatcher = FastRoute\simpleDispatcher(function (FastRoute\RouteCollector $r) {
$r->addRoute('GET', '/', function () {
echo 'home';
});
$r->addRoute('POST', '/post', [\Blog\Infrastructure\Http\PostController::class, 'createPost']);
});

// Fetch method and URI from somewhere
$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

// Strip query string (?foo=bar) and decode URI
if (false !== $pos = strpos($uri, '?')) {
$uri = substr($uri, 0, $pos);
}
$uri = rawurldecode($uri);

$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
switch ($routeInfo[0]) {
case FastRoute\Dispatcher::NOT_FOUND:
// ... 404 Not Found
break;
case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
$allowedMethods = $routeInfo[1];
break;
case FastRoute\Dispatcher::FOUND:
$handler = $routeInfo[1];
$vars = $routeInfo[2];

if (is_callable($handler)) {
$handler();
break;
}

PhpDIFactory::default()->call($handler);
break;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

final class PostController
{
public function createPost(CreatePostHandler $handler): never
{
$body = file_get_contents('php://input');
$data = json_decode($body, true);
$command = new CreatePostCommand($data['title'], $data['body']);

$response = $handler($command);

echo json_encode([
'id' => $response->id
]);

exit(0);
}
}

Таким образом, избавили PostController от создания CreatePostHandler и переложили эту ответственность на DI.

Мы написали с вами простой микро-фреймворк. По такому принципу работают все современные фреймворки.

Ветка: step-4

Подытожим

Полезное