Мне вообще никто не нужен, сам себе погрею ужин. Самодостаточная Data

в 11:15, , рубрики: dataclass, php, Specification, данные приложения

Привет, на связи Лука.

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

Со временем вырисовываются какие-то паттерны и принципы, к которым лежит душа. У каждого свои: кто-то горит TDD, кто-то ATDD, FDD, BDD и прочими DD. Я же больше всего прикипел к DDD, причём первая D тут варьируется: угораю как по Domain, так и по Data.

О, данные, мои данные, как я вас обожаю!

Data — это наше всё. Без данных не было бы никакой системы. В чём смысл, если даже джейсончики не поперекладывать?

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

Data — это об общении одной системы с другой. Да, данные зачастую циркулируют в пределах одного домена/системы, рождаясь и умирая совсем неподалёку (что не делает такие винтики системы менее важными). Ну а для обмена данными между частями одной системы неплохо было бы подложить перинку, а она уже есть — это встроенные типы данных. Мы можем не изобретать велосипед!

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

Data не должна быть ambiguous, о как!

Сказано — сделано

Для того, чтобы реализовать такую задумку, будем плясать от возможностей языка. Язык в данном случае — PHP 8+. Попробуем построить систему, в которой все процедуры над данными описываются самими классами.

Сериализация и парсинг

Определим, что всё, на чём нам надо сфокусироваться в скоупе — это две задачи: сериализация и парсинг.

  • Сериализацией мы называем процесс приведения дата-класса к простым структурам языка: для объектов это массив, для всех остальных значений — их скалярное выражение.

  • Парсинг — наоборот, приведение простых структур к дата-классу, получение из стандартных типов (числа, строки, массивы) готовых к употреблению объектов.

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

Базовая структура

Начнём с описания сущности для примера:

namespace DomainArticleEntities;

use DomainArticleEnumsUserPermission;
use LooqeySpecaData;

class User extends Data {
    public function __construct(
        public string $id,
        public string $name,
        public array $articles,
        public UserPermission $permission,
    ) {}
}


namespace DomainArticleEnums;

enum UserPermission: string {
    case READ = 'read';
    case CREATE = 'create';
}

Базовый класс будет реализовывать статический метод from, который умеет собирать объекты из plain-массивов, а также из других дата-классов, которые будут перед этим преобразованы в массив — по тем правилам, которые описаны в нём. Таким образом реализуется парсинг.

Сериализация же происходит вызовом на объекте метода toArray или же автоматически средствами PHP (например, при вызове json_encode или других механизмах).

Оба этих метода работают как с корневыми, так и вложенными объектами дата-классов — то есть, если мы собираем комплексный агрегат из массива массивов, то он должен собраться правильно.

Правила трансформации

Окей. У нас есть система, собирающая и разбирающая наши дата-объекты. Отлично, но хочется ещё немного гибкости, верно?

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

Для этого создадим два атрибута:

  • ParseBy — назначает трансформер, который обработает входное значение атрибута при парсинге.

  • SerializeBy — назначает трансформер для сериализации.

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

namespace LooqeySpecaContracts;

use LooqeySpecaCoreProperty;

interface Transformer
{
    public function transform(mixed $value, Property $property): mixed;
}

Достаточно просто и универсально.

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

Теперь можно реализовать трансформер и применить его через атрибут:

namespace DomainArticleTransformers;

use DomainArticleEntitiesUser;
use DomainArticleEnumsUserPermission;
use LooqeyDataPropertyProperty;

class UserToUuidTransformer {
    public function transform(mixed $value, Property $property): mixed {
        // Допустим, наш User всегда содержит UUID в поле id
        return $value instanceof User ? $value->id : $value;
    }
}


namespace DomainArticleEntities;

use DomainArticleTransformersUserToUuidTransformer;
use LooqeyAttributesSerializeBy;

class Comment {
    public function __construct(
        public string $id,
        #[SerializeBy(UserToUuidTransformer::class)]
        public User $author,
        public string $text
    ) {}
}

При сериализации объекта Comment в поле author мы увидим UUID данного пользователя:

$comment = new Comment(
    id: 'c1',
    author: new User(id: 'u-uuid', name: 'John', articles: [], permission: UserPermission::READ),
    text: 'Hello there'
);

$arrayComment = $comment->toArray();

/* 
 $arrayComment:
 [
   'id' => 'c1',
   'author' => 'u-uuid',
   'text' => 'Hello there'
 ]
*/

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

namespace OtherDomainTransformers;

use LooqeySpecaContractsTransformer;
use LooqeyDataPropertyProperty;

class UuidToUserTransformer implements Transformer {
    public function transform(mixed $value, Property $property): mixed {
        // Допустим, по UUID создаём объект User для другого bounded context
        return is_string($value) ? UserRepository::findOrCreate($value) : $value;
    }
}

Массивы и коллекции

Нередко дата-классы несут в себе коллекции значений — другие дата-классы, скалярки, либо что-то ещё.

Было бы неплохо иметь возможность управления сериализацией и парсингом таких коллекций. Для этого вводим новый атрибут:

  • Set — позволяет явно указать тип элементов коллекции, а также определить правила сериализации и парсинга для каждого элемента.

Конечно, можно создать трансформеры для ParseBy и SerializeBy, которые будут обрабатывать массив целиком. Возможно, это даже наилучшее решение, если вы пользуетесь сторонними классами коллекций. Но если внутри нашего дата-класса лежит простой array, то этот атрибут нам сильно поможет.

namespace DomainArticleTransformers;
use LooqeySpecaContractsTransformer;
use LooqeySpecaCoreProperty;

class TitleUcTransformer implements Transformer {
    public function transform(mixed $value, Property $property) {
        return is_string($value) ? ucfirst($value) : $value;
    }
}


namespace DomainArticleEntities;

use LooqeySpecaAttributesSet;
use LooqeySpecaAttributesParseBy;
use LooqeySpecaData;

class Article extends Data {
    public function __construct(
        public string $id,
        #[ParseBy(TitleUcTransformer::class)]
        public string $title
    ) {}
}

class User extends Data {
    public function __construct(
        public string $id,
        public string $name,
        #[Set(of: Article::class)]
        public array $articles,
    ) {}
}

При парсинге из массива:

$userData = [
    'id' => '123',
    'name' => 'John Doe',
    'articles' => [
        ['id' => 'some-uuid', 'title' => 'my awesome article']
    ]
];
$user = User::from($userData);

echo $user->articles[0]->title; // "My awesome article"

Если же элементы не являются дата-классами, мы предусмотрим в Set аргументы parser и serializer, в которые можно передать свои функции или классы для обработки каждого элемента массива. Свойство of сделаем опциональным для того, чтобы можно было указывать только парсер или сериализатор.

Маппинг имен

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

  • ParseFrom — позволяет указать одно или несколько имён, по которым будет искаться значение атрибута при парсинге. Работает по схеме fallback.

  • SerializeTo — задаёт, как должно называться поле в итоговом массиве.

    В этом примере итоговое поле всегда будет называться user_id, а при парсинге значение сначала ищется под этим именем, и, если его нет, используется поле id.

namespace DomainArticleEntities;

use LooqeySpecaAttributesParseFrom;
use LooqeySpecaAttributesSerializeTo;

class User {
    public function __construct(
        #[ParseFrom('user_id', 'id'), SerializeTo('user_id')]
        public string $id,
        public string $name,
    ) {}
}

Грязная работёнка

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

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

namespace DomainArticleEntities;

use LooqeyDataTypeLazy;

class User {
    public function __construct(
        public string $id,
        public string $name,
        #[Set(of: Article::class)]
        public array $articles,
        public Lazy|Profile $profile  // профиль загружается только при необходимости
    ) {}
}

Например:

$myUserID = request()->get("user_id");

$user = User::from([
    "id" => $myUserID,
    "name" => "John Doe",
    "articles" => [],
    "profile" => new Lazy(fn () => $profileRepo->get($myUserID))
]);

Если мы сразу сделаем $user->toArray(), то поле profile вообще не попадёт в итоговый массив (ключ просто отсутствует). Чтобы явно включить это поле, нужно вызвать:

$user->include("profile");

После чего при сериализации profile станет доступен, и Lazy-замыкание выполнится. Аналогично, мы можем исключать поля:

$user->exclude("some-sensitive-data");

Оба метода поддерживают dot-notation, так что можно делать include('details.billing'), чтобы “достать” поле во вложенном объекте.

Итог

Все описанное выше — упрощённый ход моих мыслей, которые легли в основу библиотеки Speca [spéka]. Я стремился к тому, чтобы создать чистое и универсальное решение, которое позволит сделать данные спецификацией — и избавиться от мутных, «грязных» и отделённых от сущности операций, затрудняющих отладку кода.

Что решает Speca и почему это удобно

  • Убирает рутину: вместо ручного маппинга и конвертации в разных местах - единый способ собирать/разбирать объекты, который определяется их классами, а не внешними условиями.

  • Гибкая кастомизация: собираем любые вариации трансформации и сериализации полей (если угореть, можно даже собирать трансформеры в цепочку).

  • Lazy, include/exclude: гибкое управление включением и исключением свойств.

Где можно применять этот подход

  • При обмене данными между разными bounded context-ами: как только мы хотим передать объект в другой контекст, Speca поможет с маппингом и тем самым избавит нас от кучи ручных преобразований.

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

  • В DTO для API наружу.

Если вдруг стало интересно — вы можете подробнее ознакомиться с моей библиотекой на Github, а также в официальной документации.

Автор: looqey

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js