Введение


Работа с РСУБД является одной из важнейших частей разработки веб-приложений. Дискусcии о том, как правильно представить данные из БД в приложении ведутся давно. Существует два основных паттерна для работы с БД: ActiveRecord и DataMapper. ActiveRecord считается многими программистами антипаттерном. Утверждается, что объекты ActiveRecord нарушают принцип единственной обязанности (SRP). DataMapper считается единственно верным подходом к обеспечению персистентности в ООП. Первая часть статьи посвящена тому, что DataMapper далеко не идеален как концептуально, так и на практике. Вторая часть статьи показывает, как можно улучшить свой код используя существующие реализации ActiveRecord и несколько простых правил. Представленный материал относится главным образом к РСУБД, поддерживающим транзакции.


О неполноценности DataMapper или несоответствие реального заявленному


Апологеты DataMapper отмечают, что этот паттерн предоставляет возможность абстрагироваться от БД и программировать в "терминах бизнес-объектов". Предписывается создавать абстрактные хранилища, в которых сохраняются т.н. "сущности" — обьекты, содержащие персистентные данные приложения. Фактически предлагается эмулировать БД в приложении, создав над РСУБД объектное хранилище. Якобы это должно позволить достичь отделения бизнес логики от БД. Однако в любом серьезном приложении требуются операции на множестве записей. Их реализация в виде работы с объектами как правило гораздо менее эффективна, чем SQL-запросы. В качестве решения этой проблемы предлагается часть кода делать в терминах объектов, а где это не удается, использовать SQL или какой-нибудь собственный язык запросов, транслируемый в SQL (HQL, DQL). Идея не работает в полной мере, поэтому и предлагается фактически возвращаться к SQL.


Сущности, несмотря на отсутствие внутри SQL-кода, все равно зависят от БД. Особенно это проявляется при программировании связей (одна сущность объявляется главной, другая подчиненной). Реляционные отношения так или иначе протекают в объектную структуру. На самом деле сущности это никакие не "бизнес-объекты", а "пассивные записи". Более того, это вообще не объекты, а структуры данных, которые должны обрабатываться специальным объектом-преобразователем для сохранения и извлечения из БД. Особенно хорошо это заметно в CRUD-приложениях. Сущности в таких приложениях вырождаются в контейнеры для данных без какой-либо функциональности и известны как анемичные сущности. В качестве решения предлагается помещать в сущности бизнес-логику. Это утверждение также вызывает сомнения. Во-первых, сущности в CRUD-приложениях так и останутся анемичными. Негде взять бизнес-логику, чтобы заполнить пустые классы. DataMapper не работает в CRUD-приложениях. Во-вторых, для бизнес-логики почти всегда нужны зависимости. Редко какая настоящая бизнес-логика будет работать только на данных самой сущности. Правильный способ получения зависимостей — внедрение через конструктор. Однако, большинство реализаций DataMapper ограничивают конструирование, делая недоступным внедрение конструктора. Использования внедрения метода в качестве замены внедрению конструктора делает объект неполноценным. Такой объект ничего не может делать сам, ему всегда нужно передавать все необходимые зависимости. Как следствие, происходит загрязнение клиентского кода повсеместной передачей зависимостей.


Самая известная реализация DataMapper в PHP — Doctrine ORM. Чтобы использовать эту библиотеку нужны либо аннотации, либо дополнительные файлы, задающие отображение. Первый способ хорошо показывает связь сущности и БД, пусть и неявную. Само преобразование основано на использовании интерфейса отражения (Reflection API). Данные помещаются и извлекаются без какого-либо участия самого объекта — работа с объектом ведется как со структурой данных. Очевидно, что это нарушает инкапсуляцию — один из базовых принципов ООП. API Doctrine ORM довольно объемный, подводных камней достаточно много. На обучение эффективному использованию этой библиотеки требуется время. Все вышесказанное в разной степени относится и к другим реализациям DataMapper. Учитывая приведенные аргументы, DataMapper представляется избыточным, тем более, что от знания SQL и РСУБД он все равно не избавляет, никакой реальной независимости от БД не дает. Код, использующий Doctrine ORM как правило навсегда останется привязанным к ней.


Использование ActiveRecord


Практически каждый из популярных PHP-фреймворков предлагает собственный способ работы с БД. Большинство используют собственную реализацию ActiveRecord. Как правило, ради скорости разработки типовых приложений на ActiveRecord возлагаются не только обязанности по взааимодействию с БД, но и роль бизнес-объекта, валидатора, а иногда и формы. Проблемы такого использования ActiveRecord известны и хорошо описаны во многих статьях. В качестве решения как правило предлагается переписать весь код используя DataMapper. В данной статье предлагается использовать ActiveRecord с которого снимается часть обязанностей путем соблюдения нескольких простых правил.


Решение описывается далее с примерами псевдокода. Некоторые вызовы могут быть составлены некорректно, цель статьи — показать идею, а не конкретную рабочую реализацию. Конструкторы и некоторые очевидные методы, а также часть проверок опущены для краткости. В качестве реализации AR используется Yii. Данный фреймворк выбран для примеров потому, что на нем написано немало проектов, которые надо рефакторить, поддерживать, с ними нужно считаться.


Подход применим и для других фреймворков и независимых реализаций ActiveRecord. Сначала будет показан код, применимый в проектах, полностью зависимых от Yii. Он довольно прост. Далее будут показаны примеры с внедрением зависимостей и использованием Yii как библиотеки для реализации интерфейсов объектов взаимодействующих с БД.


Вставка и модификация данных


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


class YiiARUserRepository
{
    public function add(string $email, string $name, array $phones, DateTimeInterface $created_at)
    {
        return $this->transaction->call(function () use($email, $name, $phones, $created_at) {

            //в БД есть уникальный индекс на email, проверка для UI проверка произвводится в форме с помощью обращения к внедренному репозиторию
            $ar = new YiiARUser([
                'email'      => $email,
                'name'       => $name,
                'created_at' => $created_at->format('Y-m-d H:i:s')
            ]);
            $ar->insert();
            foreach ($phones as $phone) {
                $ar->addPhone($phone['phone'], $phone['description']);
            }

            return $ar;
        });

    }

}

class YiiDbTransaction
{

    public function call(callable $callable)
    {
        $txn = \Yii::$app->db->beginTransaction();
        try {

            $result = $callable();

            $txn->commit();

            return $result;

        } catch (\Exception $e) {
            $txn->rollback();
            throw $e;
        }
    }

}

class YiiARUser extends yii\db\ActiveRecord
{
    //...
    public function addPhone(string $phone, string $description)
    {
        $ar = new YiiARPhone([
            'user_id'     => $this->id,
            'phone'       => $phone,
            'description' => $description
        ]);
        $ar->insert();

        return $ar;
    }

}

Желательно, чтобы в методах добавления не было никакого кода, кроме присваивания и вставки в БД. Все необходимое нужно вычислить в клиентском коде. Никаких beforeSave() или других вызовов жизненного цикла быть не должно. Сразу можно вставлять в базу только обязательные поля, остальное можно добавить в других методах в рамках транзакции. В качестве бонуса — нет никаких проблем с использованием автоинкрементных ключей. В статьях и докладах по Symfony, Doctrine и DDD все чаще можно встретить тирады про недостатки автоникрементных ключей БД, про то, что сущность при создании без ключа — не сущность и надо использовать генераторы UUID. Это еще один шаг к эмуляции функционала БД в приложении — то, чего в данной статье предлагается избегать.


class YiiARUser extends yii\db\ActiveRecord
{
    //...
    public function changePassword(string $password)
    {
        $this->updateAttributes([
            'password' => md5($password)
        ]);
    }

    public function rename(string $name)
    {
        $this->updateAttributes([
            'name' => $name
        ]);
    }

}

class RegisterForm
{
    public function register(DateTimeInterface $created_at): YiiARUser
    {

        if ( ! $this->validate()) {
            throw new \DomainException($this->errorMessage());
        }

        return $this->transaction->call(function () use($created_at) {
            $user = $this->user_repo->add($this->email, $this->name, [], $created_at);
            $user->changePassword($this->password);
            $user->changeSomething($some_data);
            foreach ($this->phone_forms as $form) {
                $user->addPhone($form->phone, $form->description);
            }

            return $user;
        });

    }
}

Основная идея заключается в том, что мы не накапливаем изменения в единице работы на стороне приложения, а производим их с помощью единицы работы на стороне БД. Транзакция БД это единица работы. Многие известные проблемы AR как раз вызваны тем, что разработчики пытаются предварительно собрать граф из AR в памяти и при вызове save() сохранить все их сразу. Для этого в Yii даже существует расширение WithRelatedBehavior. Все это заблуждение. Слово "Active" в ActiveRecord как раз и предназначено для того, чтобы показать, что объекты могут выполнять запросы при обращении к их методам. Какое-либо разделение на "до сохранения" и "после сохранения" недопустимо.


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


Выполнение нескольких запросов вместо одного при вставке или обновлении данных в БД не должны вызвать проблем с производительностью. СУБД хорошо оптимизирует последовательные INSERT и UPDATE на одну и ту же строку в одной транзакции. Однако, последнего все равно лучше не допускать, и если производится массовое обновление данных строки, лучше создавать соответствующие методы, типа YiiARUser::changeInfo($phones, $addresses, $name, $email).


Выборка


Код запросов на выборку необходимо помещать в класс репозитория. Использовать статические методы для этой цели не стоит. Статические методы Yii в примере используются в качестве реализации просто потому что по-другому в Yii трудно или невозможно. В идеале нужно внедрять соединение с БД в репозиторий через конструктор, а затем и во все создаваемые объекты AR. От статики лучше держаться подальше — она вредит проектам с большим объемом кода или длительным жизненным циклом.


class YiiARUserRepository
{
    //...
    public function findOne($id)
    {
        return YiiARUser::findOne($id);
    }

    public function findUsersWithGroups($limit)
    {
        return YiiARUser::find()->with('groups')->all();
    }

    //можно вернуть и DataProvider, если клиентский код тоже зависит от Yii
    public function findAll(): DataProviderIterface
    {
        //...
    }

    //если нужно много пользователей
    public function findAll(): \Iterator
    {
        //...
        return new class($reader) implements \Iterator
        {
            //...
            public function current()
            {
                $data = $this->reader->current();

                return YiiARUser::instantiate($data);
            }
        }
    }

}

Как видно из примеров, можно сохранить DataProvider и data widgets (RAD-инструменты Yii). Это возможно только в том случае, если проект можно сделать полностью зависимым от Yii.


Модификация данных в связанной таблице


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


//поведение осуществляет загрузку данных в основную и связанные записи
$user->with_related_behavior->setAttributes($request->post());

//путем array_diff(), AR::isNewRecord() определяются новые и измененные записи, генерируются соответсвующие запросы
//очевидно, что между методами существует temporal coupling
$user->with_related_behavior->save();

Разбор всех недостатков представленного подхода выходит за пределы статьи. В рамках данной темы стоит отметить лишь то, что при вызове setAttributes() фактически теряется информация о том, какие записи были добавлены или обновлены, а при вызове save() эта информация восстанавливается. К тому же, данные вызовы тесно связаны. В качестве альтернативы предлагается следующее. Обязанности по определению того, что было добавлено, обновлено или удалено необходимо возложить на форму. Лучше всего строить UI, которые генерирует HTTP запросы на уделение связанных записей по конретным идентификаторам.


class UserUpdateForm
{

    public function update(YiiARUser $user)
    {

        $this->transaction->call(function () use ($user) {

            //...
            foreach ($this->changedPhones() as $phone)
                $user->changePhone($phone['id'], $phone['phone'], $phone['description'])

            $user->addPhones($this->addedPhones());

        });

    }

}

class YiiARUser extends yii\db\ActiveRecord
{

    //...

    public function changePhone(int $phone_id, $phone, $description)
    {
        $phone = YiiARPhone::findOne(['id' => $phone_id, 'user_id' => $this->id]);
        if ($phone == null) {
            throw new \DomainException('Телефон не найден.');
        }
        $phone->updateAttributes([
            'phone'       => $phone,
            'description' => $description
        ]);
    }

    public function addPhones($phones)
    {
        YiiARUser::$db->createCommand()->barchInsert('phones', ['phone', 'description'], $phones)->execute();
    }

}

При изменении связанных записей необходимо либо сбросить, либо перезагрузить ранее загруженные связанные записи. Жаль, что в Yii нет публичного метода типа resetRelation($name). Остается либо перезагрузить связанные даннные, либо убедиться в том, что загруженных данных нет или они нигде не используются (плохой код), ну или ответвиться.


Подход, основанный на непосредственных вызовах к РСУБД имеет еще одно преимущество в плане работы со связанными данными — простота обеспечения констант. Например, имеется ограничение — пользователь не должен иметь более пяти номеров телефонов. Простая проверка при добавлении не обеспечивает соблюдения этой константы в условиях одновременного использования ресурса несколькими клиентами. Необходимы блокировки. Работая с БД напрямую можно легко использовать механизмы блокировок движка БД.


public function addPhones(array $phones)
{

    $this->transaction->call(function () {

        $id = YiiARUser::$db->query('SELECT id FROM users FOR UPDATE;')->queryScalar();

        if ($id === null) {
            throw new \DomainException('Пользователь не найден.');
        }

        if ($this->phoneCount() + count($phones) > 5) {
            throw new \DomainException('Телефонов слишком много!');
        }

        YiiARUser::$db->createCommand()->batchInsert('phones', ['phone', 'description'], $phones);

    });

}

При использовании Doctrine ORM реализовать подобное будет сложнее, если не блокировать всю запись сразу при выборке.


Удаление


Удаление осуществляется через объект, представляющий строку.


$user->delete();

class YiiARUser extends yii\db\ActiveRecord
{

    public function delete()
    {
        self::$db->createCommand()->delete('phones', ['user_id' => $this->id]);
        $this->delete();
        //если нужно событие на удаление, здесь можно добавить обращение к шине событий (о внедрении далее)
    }

}

При использовании DataMapper часто приходится делать такие связанные вызовы:


//чтобы сущность отправила событие в шину:
$user->delete();

//удаление записи:
$em->delete($user);

Это один из примеров, показывающий противоречивость DataMapper — даже удалить одним вызовом не получается. Сущность несамостоятельна — работа с ней тесно связана с использованием преобразователя (менеджера сущностей).


Внедрение зависимостей и обеспечение независимости клиентского кода от кода, взаимодействующего с БД


Как известно, приложения гораздо легче обновлять/переписывать по частям, чем полностью. Старое монолитное приложение лучше сначала разделить на слои. Данный подход позволяет как это сделать даже в монолитном коде Yii-приложений.
Бизнес-логика, которая не нуждается в непосредственном выполнении SQL-запросов, может быть реализована без явной зависиости от классов, взаимодействующих с БД. Этого можно достичь благодаря тому, что репозитории являются фабриками AR. Код, приведенный выше, можно модифицировать, добавив интерфейсы и реализации. Это позволяет отделить код, взаимодействующий с БД от остального, а затем, если возникнет такая необходимость, переписать его используя другую библиотеку для работы с БД. Таким образом, legacy-проект можно обновлять частями без полного переписывания.


interface UserRepository
{

    public function add(string $name, string $email, array $phones, \DateTimeInterface $created_at): User;

    public function findOne($id);

}

interface User
{

    public function addPhones($phones);

    public function rename($name);

    public function changePassword($pwd);

}

class YiiDbUserRepository
{

    public function add(string $name, string $email, array $phones, \DateTimeInterface $created_at): User
    {
        $ar = $this->transaction->call(function () use($name, $email, $phones, $created_at) {

            $ar = new YiiARUser([
                'name'       => $name,
                'email'      => $email,
                'created_at' => $created_at->format('Y-m-d H:i:s')
            ]);
            $ar->addPhones($phones);

            return $ar;

        });

        return new YiiDbUser($ar);

    }

    public function findOne($id)
    {
        $ar = YiiARUser::findOne($id);
        if ($ar === null) {
            return null;
        }

        return new YiiDbUser($ar);

    }

}

class YiiDbUser implements User
{

    private $ar;

    public function addPhones(array $phones)
    {
        //multiple insert command
    }

    public function rename(string $name)
    {
        //запрос только при необходимости
        if ($this->ar->name !== $name) {
            $this->ar->updateAttributes(['name' => $name]);
        }
    }

    public function changePassword(string $pwd)
    {
        $this->ar->updateAttributes(['password' => md5($pwd)]);
    }

    public function phones(): \Iterator
    {
        //в YiiARUser объявлена Yii-реляция на YiiARPhone
        $phone_ars = $this->ar->phones;

        $iterator = new ArrayIterator($phone_ars);

        return new class($iterator, $this->dependency) implements \Iterator
        {

            //...
            public function current()
            {
                $ar = $this->iterator->current();

                //объект YiiDbPhone инкапсулируeт объект YiiARPhone
                return new YiiDbPhone($ar, $this->dependency);
            }

        }

    }

}

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


class YiiARUser extends \yii\db\ActiveRecord implements User
{
  //...
}

Внедряя репозитории в клиентский код с помощью интерфейсов, а также не используя в возвращаемых значениях методов зависимые от БД форматы, можно разорвать явную зависимость между классами, работающими с БД и клиентским кодом. Это даст возможность менять реализации в будущем и повысит возможность повторного использования. Также становится возможным разного рода декорирование. Если в клиентском коде понадобится транзакция — ее также можно внедрить воспользовавшись интерфейсом. Данный метод дает возможность внедрять зависимости в репозитории и объекты, работающие со строками. Без инкапсуляции объекта-записи или использования синглтона в Yii этого пока что сделать нельзя. Очевидно, при использовании композиции объекта AR теряется возможность использовать DataProvider, RAD-возможности снижаются. Однако, использование интерфейсов и композиции снизит вероятность ляпов новичков или злоупотреблений, связанных с открытостью интерфейса Yii AR. Стоит отметить, что в последнем утверждении гибкость и открытость рассматриваются как преимущество, позволяющее использовать реализацию AR Yii в своих целях. Если необходимо что-то ограничить — можно использовать композицию.


Работа с межмодульными связями


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


Сначала в модуле блогов необходимо объявить интерфейсы для статей, авторов и их репозиториев. Для своей работы модуль будет требовать реализации интерфейсов Author и AuthorRepository.
Весь остальной код содержится в самом модуле.


interface Post
{

    public function id(): int;

    public function title(): string;

    public function author(): Author;

    public function authorId(): int;

}

interface PostRepository
{
    public function findAllWithAuthors(int $limit): array;
}

class YiiARPost extends \yii\db\ActiveRecord
{
  //...  
}

class YiiDbPostRepository implements PostRepository
{

    private $author_repository;

    public function findAllWithAuthors(int $limit): \Iterator
    {
        $ars = YiiARPost::findAll(['limit' => $limit]);

        $iterator = new \ArrayIterator($ars);

        $ids = [];

        foreach ($ars as $ar) {

            $ids[] = $ar->id;

        }

        $authors = $this->author_repository->findAll($ids);

        return new class($iterator, $this->author_repository, $authors) implements \iterator
        {

            private $iterator;

            private $author_repository;

            private $authors;

            //...
            public function current()
            {
                $ar = $this->iterator->current();

                return new AuthoredPost(
                    new YiiDbPost($ar, $this->author_repository),
                    $this->authors
                );
            }

        }

  }

}

class YiiDbPost implements Post
{

    private $ar;

    private $author_repository;

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

    public function content(): string
    {
        return $this->ar->content();
    }

    public function author(): Author
    {
        return $this->author_repository->findOne($this->ar->author_id);
    }

    public function authorId(): int
    {
        return $this->ar->id;
    }

}

class AuthoredPost implements Post
{

    private $post;

    private $authors;

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

    public function content(): string
    {
        return $this->post->content();
    }

    public function author(): Author
    {

        foreach ($this->authors as $author) {
            if ($author->id() == $this->post->authorId()) {
                return $author;
            }
        }
        throw new DomainException('Статья без автора! Нарушена целостность БД!');

    }

}

interface Author
{

    public function id(): int;

    public function name(): string;

}

interface AuthorRepository
{

    public function fundOne(int $id);

    public function findAll(array $ids): array;

}

Класс AuthoredPost необходим для оптимизации — предзагрузки авторов для списка статей. Реализации интерфейсов находятся в корне компоновки — самом приложении. Только приложению известно какие модули у него есть и как они работают вместе. Модулям друг о друге ничего не известно.


class UserAuthor implements Author
{

    private $user;

    public function id(): int
    {
        return $this->user->id();
    }

    public function name(): string
    {
        return $this->user->name();
    }

}

class UserAuthorRepository implements AuthorRepository
{

    private $repository;

    public function findOne(int $id)
    {

        $user = $this->repository->findOne($id);

        if ($user === null) {
            return null;
        }

        return new UserAuthor($user);

    }

    public function findAll(array $ids): \Iterator
    {
        $users = $this->repository->findAll($ids);

        return new class($users) implements \Iterator
        {

            //..
            public
            function current()
            {
                $user = $this->iterator->current();

                return new UserAuthor($user);
            }

        };
    }

}

На конференциях по PHP слышал вопросы — как в Yii организовать связь между моделями в разных модулях. Приведенный пример кода является вариантом ответа. В представленном случае статьи и авторы могут храниться в разных БД. Безусловно абстракция имеет свою цену. Если у вас маленький одноразовый проект — лучше использовать первый вариант без интерфейсов и разбиения на модули. В этом случае у вас не возникнет необходимости в межмодульных связях и проще будет работать с реляциями.


Если все хранится в одной БД и возникает необходимость в межмодульном JOIN или межмодульной транзакции, метод с абстракцией не будет работать. В этом случае можно порекомендовать пересмотреть само разделение на модули. Возникновение этой проблемы является индикатором того, что разделение на модули неоправдано или выполнено некорректно. Есть такое правило "одна БД — один контекст", другими словами не рекомендуется создавать более одного модуля на одной БД. Для взаимодействия между контекстами существуют свои инструменты интеграции. Второй вариант — обращаться к таблицам из другого модуля напрямую через подключение к БД, однако в этом случае возможно появление дублирования одного и того же кода в разных модулях. Выполнять разделение на модули следует очень внимательно, во многих случаях можно обойтись и без него.


Бизнес-логика


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


class SomeLogicUser
{

    private $user;

    //...

    public function doSomething()
    {

        $name = $this->calculateName();

        //этого лучше избегать
        $this->transaction->call(function () {
            $user->rename($name);
            $user->changeSomething($data);
        });

        //проектировать методы по требованиям бизнес-логики, не придется внедрять транзакцию - она будет внутри
        $user->changeEverythingRequiredUsingOneMethod($name, $data);

    }

}

Отличия между предложенным подходом и популярными реализациями DataMapper


Главное отличие заключается в том, где находится единица работы (Unit of Work). В DataMapper объекты, представляющие данные из БД играют роль структур данных, обрабатываемых объектом-преобразователем. Изменения данных отслеживаются с помощью прокси-объектов (которые, к тому же делают невозможным использование final). В предложенном подходе запросы составляются и выполняются сразу. Нет никакого отслеживания изменений на стороне приложения, прокси, использования Reflection API. Все устроено проще. Можно делать классы final, можно внедрять зависимости через конструктор.


Одним из недостатков является невозможность тестирования без БД объектов, генерирующих SQL-запросы. Тем не менее код, который призван взаимодействовать с БД лучше тестировать вместе с БД. Это позволит избежать получения ложноположительных результатов.


На использование данного подхода натолкнуло определение транзакции в глоссарии MySQL, а также усмотренное сходство между задачами, которые решают программный Unit of Work Doctrine ORM и нативные транзакции БД. Это одно и то же — накопление и управляемое применение/откат изменений.


Заключение


Производительность разработчика тесно связана с простотой и мощью используемых им инструментов. РСУБД и их интерфейсы/драйверы/библиотеки, язык SQL, сами по себе являются фреймворками, позволяющими решать широкий спектр задач. Конкретный движок РСУБД является важнейшей частью проекта. Он должен тщательно подбираться на стадии проектирования. Библиотеки, использующие DataMapper и программный Unit of Work фактически дублируют имеющиеся в БД функции, являются громоздкими и требуют тщательного изучения не только теории РБД, SQL и движка РСУБД, но и собственного API, и, что очень нередко, особенностей своей внутрнней реализации. При этом пользы от них немного. Обеспечение переносимости между БД это очень дорогая задача, которая редко когда требуется и решается до конца. Рассмотренный подход предлагает отказаться от избыточного функционала, изучения оверинжиниринговых технологий и рекомендует использовать всем знакомые простые инструменты и библиотеки.

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


  1. Fesor
    08.10.2017 00:20
    +12

    DataMapper считается единственно верным подходом к обеспечению персистентности в ООП.

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


    Однако в любом серьезном приложении требуются операции на множестве записей.

    Так, давайте не путать. "на множестве записей", то есть сотни и тысячи записей, это как правило агрегации, репорты, то есть по большей части — операции чтения. ORM же (и data mapper как один из вариантов реализации) выгодны только на запись в контексте OLTP.


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


    На самом деле сущности это никакие не "бизнес-объекты", а "пассивные записи".

    Зависит от реализации. У меня к примеру — это самые настоящие бизнес объекты, содержащие бизнес логику и все такое. Да. у существующих решений (если брать конкретно Doctrine) есть немало минусов и ограничений. К тому же подобные инструменты обладают огромной сокрытой сложностью. Любой может реализовать active record уровня yii за пару вечеров, а что-то уровня doctrine реализовать будет уже намного сложнее. Это сильн уменьшает количество людей которые в состоянии поддерживать и развивать такие инструменты.


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


    Особенно хорошо это заметно в CRUD-приложениях.

    Если мыслить CRUD-ом то выйдет CRUD. Это же логично. Как-то в одном таком обсуждении я накидал пример развития "сущности в контексте CURD операций" что бы это не выраждалось в анемичную модель. Посмотреть можно тут, развитие модели можно проследить по ревизиям.


    В качестве решения предлагается помещать в сущности бизнес-логику.

    Ммм… скорее не потому что это какое-то решение, а потому что мы понапридумывали целую кучу принципов, вроде Creator и Information Expert из GRASP, information hiding и т.д.


    Редко какая настоящая бизнес-логика будет работать только на данных самой сущности.

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

    Прошу ознакомиться с такой интересной штукой как double dispatch:


    /**
     * Пример метода в сущности с бизнес логикой:
     *  - позволяет менять пароль только если старый верен
     *  - не разрешает использовать пароль если оный использовался последние 5 раз
     */
    public function changePassword(string $oldPassword, string $newPassword, PasswordEncoder $encoder)
    {
        if (!$encoder->isValid($this->password, $oldPassword)) {
            throw new incorrectPassword();
        }
    
        $lastFivePasswords = array_slice(array_merge($this->previouslyUsedPassword, [$this->password], -5);
        foreach ($lastFivePasswords as $usedPassword) {
             if ($encoder->isValid($usedPassword, $newPassword) {
                  throw new PasswordAlreadyUsedException();
             }
        }
    
        $this->previouslyUsedPassword[] = $this->password;
        $this->password = $encoder->encode($password);
    
        // для того что бы например слать нотификации и тд.
        // можно применить концепт доменных ивентов
        EventStore::remember(PasswordChanged::occurred($this->id));
    }

    большинство реализаций DataMapper ограничивают конструирование

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


    тем более, что от знания SQL и РСУБД он все равно не избавляет, никакой реальной независимости от БД не дает

    независимость от БД это оооочень большое ограничение на которое могут пойти только если реально нужна переносимость. В большинстве же случаев достаточно изоляции, что бы при добавлении например еще одной базы данных (что бы оптимизировать какие-то выборки к примеру) не нужно было вообще трогать бизнес логику. Качество абстракций больше проявляется в способности вносить изменения не влияя на клиентский код. И тут как бы все хорошо. То что перейти с какого postgresql на что-то типа orientdb нам уже будет не так легко — ну да… целью никогда не было "замена базы".


    Так… это то что касается только вопроса data mapper… Отдохну и пойду обозревать что д вы там наизобретали за дикую смесь из row data gateway и репозиториев (которые на самом деле не репозитории а какие-то менеджеры)


    1. Anton_Zh Автор
      08.10.2017 05:13

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

      Прошу ознакомиться с такой интересной штукой как double dispatch

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

      Есть такая штука, которой руководтсвуются все эти любители доктрин — persistance ignorance. То есть с точки зрения сущности они существуют всегда как если бы они просто в памяти лежали. Это значит что если вы вызвали конструктор сущности — он больше не должен вызываться. Никогда.
      Пресловутый persistence ignorance — вот эту концепцию я считаю в корне неверной. Объекты начинают изображать из себя структуры данных — строки БД, документы или еще что. Легче инкапсулировать взаимодействие с внешним источником данных (базы таковыми и являются), это легче ляжет на имеющиеся драйверы/API, чем попытка представить все это как объектное хранилище в памяти приложения — слишком широкая абстракция. А чем она шире — тем больше вероятность, что потечет.


      1. Fesor
        08.10.2017 13:09
        +2

        Часто введение второго хранилища неприемлемо/невыгодно

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


        Объекты начинают изображать из себя структуры данных — строки БД, документы или еще что.

        вы уверены что вы правильно понимаете эту идею? Попробуйте мне ее объяснить.


        1. Anton_Zh Автор
          08.10.2017 14:28
          +1

          persistence ignorance — подход в разработке ПО, предлагающий старт и ведение разработки в терминах бизнес-модели. С помощью ООП реализуется основная бизнес-логика, образующая ядро системы. После этого выбирается хранилище и под бизнес-слоем строится персистентный слой.

          Я сторонник обратного подхода — начинаем с выбора БД и тщательно проектируем ее структуру.


          1. lair
            08.10.2017 14:32
            +2

            persistence ignorance — подход в разработке ПО, предлагающий старт и ведение разработки в терминах бизнес-модели. С помощью ООП реализуется основная бизнес-логика, образующая ядро системы.

            Ну и как из этого следует "Объекты начинают изображать из себя структуры данных — строки БД, документы или еще что."?


            Я сторонник обратного подхода — начинаем с выбора БД и тщательно проектируем ее структуру.

            Самое занятное, что при использовании Data Mapper эти два подхода друг другу не противоречат.


            1. Anton_Zh Автор
              08.10.2017 14:55

              Это было теоретическое определение. По-моему это утопи, попытки реализации которой выливаются в обозначенные «пассивные записи».


              1. lair
                08.10.2017 15:10
                +2

                По-моему это утопи, попытки реализации которой выливаются в обозначенные «пассивные записи».

                Да нет, это жизнь такая.


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


                1. Anton_Zh Автор
                  08.10.2017 15:29
                  +1

                  И перед вставкой этого кода написать целый инфраструктурный слой по сохранению этих сущностей. Который в добавок будет протекать логика, реализация которой в объектах будет сильно тормозить.


                  1. lair
                    08.10.2017 15:29

                    И перед вставкой этого кода написать целый инфраструктурный слой по сохранению этих сущностей.

                    Зачем?..


                    1. Anton_Zh Автор
                      08.10.2017 15:35

                      Должны же они как-то сохраняться в новом окружении, где может быть совсем иное хранилище. Не Doctrine единой.


                      1. lair
                        08.10.2017 15:41

                        Нет, не должны, зачем? Речь же не о том, что мы взяли кусок и перенесли в другой проект, где он будет делать все то же самое. Речь — в основном — о простом техническом критерии, позволяющем отличить persistence-ignorant-реализации от persistence-aware-реализаций.


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


                        1. Anton_Zh Автор
                          08.10.2017 15:48

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

                          Речь же не о том, что мы взяли кусок и перенесли в другой проект, где он будет делать все то же самое. Речь — в основном — о простом техническом критерии, позволяющем отличить persistence-ignorant-реализации от persistence-aware-реализаций.

                          А для чего тогда нужен этот критерий и соответствующий ему код?


                          1. lair
                            08.10.2017 15:50

                            Чтобы проверить, что технология хранения не оказывает избыточного влияния на домен (как с точки зрения логики, так и с точки зрения практик разработки).


                  1. Fesor
                    08.10.2017 15:32

                    Приведите пример задачи где у вас такое происходило. И тогда будет более предметная дискуссия.


                    1. Anton_Zh Автор
                      08.10.2017 15:58

                      Не происходило, потому что таких задач перед собой не ставлю. Хранилище не меняю, в том числе и по результатам анализа возникающего в этом случае перечня необходимых работ. DQL же придется переписывать? Все сложные выборки и модифиакции, не влезающие в объекты придется. Да, если с MySQL на Postgres переезжать — поедем вместе с Doctrine ORM и DQL, в этом случае да, почти ничего переписывать не придется.


                      1. Fesor
                        08.10.2017 22:24

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


                        1. Anton_Zh Автор
                          09.10.2017 02:00

                          Да, восприятие бизнес-логики облегчается, а вот восприятие слоя персистентности, соответственно, усложняется.


                          1. Fesor
                            09.10.2017 11:35

                            да, но поскольку в моем случае БД это лишь тупое хранилище — не сказать что сильно.


      1. Druu
        08.10.2017 13:36
        +2

        > но тогда придется для вызова метода везде таскать за собой encoder.

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


      1. Fesor
        08.10.2017 13:40

        Неудобно.

        в целом можно воспринимать password_hash как готовую абстракцию и ничего таскать не придется. С другой стороны — вам так и так придется ее таскать а количество мест где будет вызываться changePassword сильно ограничено.


        1. Anton_Zh Автор
          08.10.2017 14:30

          Это если говорить конкретно про $encoder. Но могут быть и другие, более востребованные зависимости. Нельзя из-за ограничений ORM игнорировать/избегать внедрения зависимостей через конструктор.


          1. Fesor
            08.10.2017 14:49

            Нельзя из-за ограничений ORM игнорировать/избегать внедрения зависимостей через конструктор.

            1. вы должны понимать что зависимости приводят к повышению связанности
            2. раз вы умеете выделять контексты, значит разделение на уровень инфраструктуры и уровень логики для вас не новость
            3. слой логики не должен зависеть от инфраструктуры. Наоборот — можно (пример — архитектура портов и адаптеров).
            4. штуки вроде encoder это по большей части протечка инфраструктуры в доменную логику, тут стоит тогда отделить те данные которые нужны для аутентификации в некую отдельную сущность (нам же пароль всеравно только для этого нужен). Тогда все будет хорошо.
            5. другие вещи вроде "запустить финансовую операцию", "сходить в сторонний сервис для синхронизации", "отправить нотификации" прекрасно отделяются от бизнес логики за счет использования доменных ивентов.


            1. Anton_Zh Автор
              08.10.2017 15:33

              Интерфейс PasswordHasher определяется в слое бизнес-логики. Почему его нельзя внедрить в сущность User через конструктор? Что это нарушит? Как появится зависимость от инфраструктуры?
              Почему нужно выделять в события? Почему нельзя через интерфейсы внедрять в сущности зависимости через конструкторы?
              Для себя пока вижу одну причину — ограничения реализаций ORM.


              1. Fesor
                08.10.2017 22:28

                Почему его нельзя внедрить в сущность User через конструктор?

                потому что он нужен только для одной конкретной операции. Вы же не переживаете что мы передаем в какие-то методы аргументы а в какие-то не передаем.


                Хэшер паролей не является чем-то что влияет на жизненный цикл пользователя. То есть если мы запихнем его в конструктор — мы явно увеличим связанность системы.


                Почему нужно выделять в события?

                Это удобно.


                Для себя пока вижу одну причину — ограничения реализаций ORM.

                всему виной принципы проектирования GRASP и SOLID. А вот желание всюду внедрять зависимости как раз таки и порождает проблемы.


                Есть даже такой тезис — "dependencies is a code smell". Суть его заключается в том что бы всеми силами снижать количество всего что пихается в конструктор. Яркий пример — любители "юнит тестить" людят заводить всякие классы типа Clock для предоставления времени. В то же время можно просто передавать это самое время через аргумент и получить такой же результат уменьшив количество зависимостей и упростив клиентский код.


                1. Anton_Zh Автор
                  09.10.2017 01:52

                  потому что он нужен только для одной конкретной операции. Вы же не переживаете что мы передаем в какие-то методы аргументы а в какие-то не передаем.

                  Хэшер паролей не является чем-то что влияет на жизненный цикл пользователя. То есть если мы запихнем его в конструктор — мы явно увеличим связанность системы.


                  А если есть зависимости, которые влиявют? Например репозиторий связанной записи (если делать без прокси) и ли какой-нибудь шлюз к API?


                  Есть даже такой тезис — "dependencies is a code smell". Суть его заключается в том что бы всеми силами снижать количество всего что пихается в конструктор.

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


                  1. Fesor
                    09.10.2017 11:38

                    Например репозиторий связанной записи (если делать без прокси) и ли какой-нибудь шлюз к API?

                    заменяем репозиторий на коллекцию и вуаля. Шлюз к APi — тут уже интереснее, тут нужны конкретные примеры что бы можно было продолжать.


                    что этот принцип введен для того, чтобы оправдать ограничение конструирования.

                    нет, это общий принцип, он не про ORM а просто про объекты. Я дал вам ссылочку на статью которая разбирает уровни юнит тестов. Идея заключается в том что бы декомпозировать логику таким образом, что бы уменьшить количество зависимостей. Это больше относится к вопросам low coupling/high coheasion.


                    Через методы внедрятся те же записимости, только их придется всюду таскать для вызова методов.

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


                    А если в классе больше одного метода, где нужна одна и та же зависимость?

                    нужны конкретные примеры. На моей памяти мне приходилось передавать только хэшер паролей и какие-то калькуляторы чего-нибудь (стратегии по сути разные). Это где-то 3-4 метода на 30-40 сущностей.


              1. Fesor
                08.10.2017 22:31

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


                1. Anton_Zh Автор
                  09.10.2017 01:46

                  Я знаком с ней. У меня был опыт использования DDD. Очень многословно получается (много кода), но да, ubiqutious language появляется. По событиям делал рассылки и уведомления всякие.


                  1. Fesor
                    09.10.2017 11:41

                    Очень многословно получается (много кода)

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


      1. lair
        08.10.2017 13:59
        +2

        Пресловутый persistence ignorance — вот эту концепцию я считаю в корне неверной. Объекты начинают изображать из себя структуры данных — строки БД, документы или еще что.

        Вообще-то, persistence ignorance совершенно про другое (и, иногда, приводит к строго обратному — объект перестает хоть как-то напоминать структуру данных, в которой он хранится).


  1. Samouvazhektra
    08.10.2017 01:05
    +3

    Хорошая тема, жуткий код, и дикая смесь понятий.


  1. Fesor
    08.10.2017 01:10
    +3

    Ух… продолжим.


    В данной статье такие классы называются репозиториями

    Зачем вводить людей в заблуждение неправильной терменологией? Вы же сами говорите — это шлюз. Тоесть TableGateway или DAO.


    Для вставки и создания должен использоваться репозиторий.

    Для вставки (аналогия с положить на полочку) — да. Для создания — нет, это не его ответственность.


    Есть такой паттерн — Row Data Gateway. С его помощью мы можем отделить модель данных от бизнес логики:


    class User
    {
        private $attributes;
    
        public function __construct(string $email, string $name)
        {
            $this->attributes = new UserGateway(); // по сути та же AR
            $this->attributes->email = $email;
            $this->attributes->name = $name;
            $this->attributes->save();
        }
    }

    вместо save() можно на самом деле не сохранять ничего в базу, а скажем добавлять в очередь на вставку/апдейт, делая своего рода разделение отдельных операций которые можно потом будет закоммитить одной транзакцией. Это уже по вкусу и как удобнее.


    Делать же эти "сущности" при выборках будут компоненты — finder-ы:


    class UserFinder
    {
        public function get(int $id): User
        {
            $userGateway = UserGateway::model()->find($id);       
            if (!$userGateway) {
                throw new UserNotFound();
            }
    
            // гидрируем и возвращаем инстанс `User`.
        }
    }

    md5($password)

    Вы уверены? Я понимаю что это не суть но все же...


    Какое-либо разделение на "до сохранения" и "после сохранения" недопустимо.

    Я правильно понимаю что вы предлагаете сперва сделать транзакцию которая замутит вставку записи (что бы получить ID), причем сохраним мы невалидные данные. И затем уже будем работать с релейшенами.


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


    В представленном случае статьи и авторы могут храниться в разных БД.

    Я бы не стал делать упор на "могут храниться в разных БД". Это тоже скорее как показатель изоляции контекстов. То есть да, бесспорно что контексты нужно отделять, но я не до конца понимаю как вам тут помогает интерфейс Author. Скажем я понимаю введение на уровне модуля, которому нужно на автора ссылаться, некого VO с названием Author или AuthorID даже, который будет только идентификатор хранить. А вот интерфейсы…


    Второй вариант — обращаться к таблицам из другого модуля напрямую через подключение к БД

    Этот способ создает неявную связанность модулей на уровне базы данных. Очень плохой вариант. Вместо этого имеет смысл сделать на уровень выше модуль отвечающий за UI (в случае апишек этот паттерн завется Api Gateway, то же применимо и для обычных страничек). В этом случае этот модуль будет работать с другими модулями через их API (не важно какое, на уровне объектов или через сеть, если вы микросервисы любите)


    выразить непосредственно на языке БД.

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


    проектировать методы по требованиям бизнес-логики

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


    где находится единица работы (Unit of Work).

    Ну, у вас его как бы нет. Я задумку понимаю — делать свой UoW под каждую операцию, а не один универсальный (и это к слову довольно правильная мысль, просто трудозатратно так делать). Но в вашем случае вы просто говорите "транзакция в базе это и есть UoW" и ничего не делаете что бы это было удобно.


    Изменения данных отслеживаются с помощью прокси-объектов

    Как бы, нет. При загрузке сущности оная добавляется в UoW, которая внутри имеет identity map. Эдакая мэпы id => сущность. Помимо сущности UoW так же хранит дегидрированный стэйт на момент загрузки, и когда вы вызываете flush в той же доктрине, она втупую достает стэйт из сущностей и вычисляет изменения. Никакой магии.


    А вот прокси классы, которые не дают вам возможность использовать final используются только для ленивой подгрузки данных. Если она вам не нужна — вас никто не остановит поставить везде final.


    можно внедрять зависимости через конструктор.

    Как вы думаете, насколько сложно работать с объектами, которые не только от своего стэйта работают, но и еще и от внешних зависимостей? пробовали когда-нибудь под такое тесты писать? Старайтесь никогда так не делать.


    Одним из недостатков является невозможность тестирования без БД объектов

    У классического row data gateway нет этого недостатка. Вы спокойно можете заменить имплементацию хранилища на что-то in-memory для хранения стэйта (естественно не в случае этих Finder-ов)


    Тем не менее код, который призван взаимодействовать с БД лучше тестировать вместе с БД.

    Но бизнес логика не должна взаимодействовать с БД. Мы как раз таки должны отделить ее от страшного внешнего мира.


    Библиотеки, использующие DataMapper и программный Unit of Work фактически дублируют имеющиеся в БД функции

    UoW — да, он позволяет нам на уровне приложения объявить границу транзакции и сделать это неявно. Хорошо это или плохо — зависит от контекста. А вот DataMapper — его суть только в том что бы денормализованный результат SQL запроса замэпить на объекты. И все.


    Фактически вы очень хорошо описали суть проблемы при работе с базой данных — ограниченность разработчиков. "Только data mapper!", "Data Mapper не работает, только active record!". Как способ уйти от боли с AR вы переизобрели свой row data gateway (а есть и еще table gateway). И это только если мы все еще полагаем что нам хватит реляционной базы данных для решения любой проблемы.


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


    В случае с доктриной например, я могу писать в одни объекты и делать выборку этих же данных в другие объекты путем простой подмены гидратора. Например работать на запись с красивым API, а на чтение писать все в другие объекты с публичными пропертями что бы не городить миллион геттеров. А еще есть Atlas.Orm к примеру, весьма интересный концепт. Видел так же просто реализации data mapper, без лэйзи лоад и с более простой схемой работы UoW позволяющей больше. Но это не популярно потому что "а зачем думать то".


    1. Anton_Zh Автор
      08.10.2017 05:21

      Зачем вводить людей в заблуждение неправильной терменологией? Вы же сами говорите — это шлюз. Тоесть TableGateway или DAO.

      Признаю, надо было назвать шлюзом. Привык.
      Я правильно понимаю что вы предлагаете сперва сделать транзакцию которая замутит вставку записи (что бы получить ID), причем сохраним мы невалидные данные. И затем уже будем работать с релейшенами.

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

      Почему невалидные сохраним? Валидация проведется выше. Для записи должны приходить валидные данные. Если нужно, исключение можно выбросить — транзакция БД откатится.
      При вызове `Repository::add($name, $email...)` производится вставка обязательных данных. Если после этого нужно сделать с этой записью еще что-то, например добавить реляции, транзация БД может быть запущена снаружи до `add()` и завершена после, например `addPhones()`:
      $txn->begin();
      try {
      $user = $repo->add($name, $email);
      $user->addPhones($phones);
      $txn->commit();
      } catch (PossibleException $ex) {
      $txn->rollback();
      }


      1. Anton_Zh Автор
        08.10.2017 10:58

        Вспомнил, почему не стал называть классы TableGateway и RowGateway. Я знаю про эти паттерны. Репозитории в статье не являются TableGateway, так как могут обращаться к группе связанных таблиц — таблица пользователей, их телефонов, адресов и пр. TableGateway — шлюз к одной таблице Это же верно и для RowGateway — шлюза к одной строке. В статье объекты могут давать доступ не только к строке таблицы пользователей, но и к связанным с ней телефонам, адресам и пр.


        1. Fesor
          08.10.2017 13:14

          шлюза к одной строке.

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


          1. Anton_Zh Автор
            08.10.2017 14:33

            У меня DAO всегда ассоциировался с QueryBuilder и более низкоуровневыми вещами.


            1. Fesor
              08.10.2017 14:51
              +1

              Рекомендую пересмотреть свои взгляды. По вашей ссылке термин DAO используется некорректно. Все что вы перечислили DAO должно скрывать а не предоставлять для клиентского кода.


              1. Anton_Zh Автор
                09.10.2017 02:01

                Спасибо за рекомендацию. Посмотрим.


      1. Fesor
        08.10.2017 13:12

        Почему невалидные сохраним?

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


        Если после этого нужно сделать с этой записью еще что-то, например добавить реляции, транзация БД может быть запущена снаружи до

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


        Что мне не нравится — это то что у вас эти операции все же связаны между собой и частичное выполнение нам может быть не подходит. В этом случае нужно либо что бы СУБД умело вложенные транзакции, либо иметь возможность компенсировать неудачи (паттерн Сага).


        1. Anton_Zh Автор
          08.10.2017 14:04

          Я использую одну и ту же транзакцию для всех операций.


          $user->rename($name);
          
          $user->changePassword($passwd);
          

          Здесь будет две транзакции.


          $transaction->call(function() {
              $user->rename($name);
              $user->changePassword($passwd);
          });

          Здесь будет одна. Код в методах rename() и changePassword() не меняется между первым и вторым примером. Если есть транзакция снаружи, внутренние не будут запускаться. Объект транзакции один и тот же внутри и снаружи.


          1. Fesor
            08.10.2017 14:23

            вы пробовали в таком стиле запилить хоть сколько нибудь большой проект (хотя бы 3 разработчика)? Я конечно не уверен, но такой стиль управления транзакциями будет скорее всего приводить к большему количеству проблем, ибо легко использовать неправильно.


            1. Anton_Zh Автор
              08.10.2017 14:33

              Пробовали. Как раз трое работает. Все хорошо. Проблем не было.


            1. Fantyk
              08.10.2017 22:50
              +1

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


            1. Fantyk
              08.10.2017 23:00

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


              1. Anton_Zh Автор
                11.10.2017 12:42

                Спасибо за ценные комментарии


    1. Anton_Zh Автор
      08.10.2017 07:23

      Как и UoW, транзакция у нас одна Чтобы было понятно, приведу такой кусочек кода:


      $transaction->call(function() use($repo) {
          $user = $repo->add($name, $email, $phones);  
      });
      
      //код метода add()
      
      public function add($name, $email, $phones) {
          $this->transaction->call(function() use($name, $email, $phones) {
              $ar = new YiiARUser([
                  'name' => $name,
                  'email' => $email
              ]);
              $ar->insert();
              $ar->addPhones($phones);
          ));
      
      }
      

      Так как транзакция у нас одна, вызов call() внутри add() не приведет к старту новой транзакции, и не будет ее коммитить, это будет делать первый вызов call(). Таким образом, все пойдет в одну транзакцию, будет один UoW на стороне БД.


      1. Fesor
        08.10.2017 13:15

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


        1. Anton_Zh Автор
          08.10.2017 14:10
          +1

          Разрулить это можно обернув все последовательные вызовы в транзакцию.


          $transaction->call(function() {
              //внутри add() транзакция не стартанет и не закомитится, хотя вызов call() есть внутри add(), объект транзакции один и тот же, он знает, где она была запущена и где ее закоммитить.
              $user = $repo->add($name, $email, $password);
              //то же самое внутри changePassword()
              $user->changePassword($passwd);
              //и addPhones()
              $user->addPhones($phones);
          });


          1. Fesor
            08.10.2017 14:23

            ну как в доктрине короче.


            1. Anton_Zh Автор
              08.10.2017 14:35

              Да! Я и говорю — по большому счету вся разница — где UoW. В Doctrine ORM он программный, здесь он на стороне базы.


              1. Fesor
                08.10.2017 14:53

                я люблю воспринимать базу данных как умную хранилку. Я принципиально не люблю процедуры (хотя если будет задача где они будут хорошо ложиться — я буду их юзать), я не люблю подходы когда штуки вроде того же ревизионирования перекладываются на UoW и т.д. Я за более явные подходы.


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


    1. Anton_Zh Автор
      08.10.2017 07:34
      +1

      Как вы думаете, насколько сложно работать с объектами, которые не только от своего стэйта работают, но и еще и от внешних зависимостей? пробовали когда-нибудь под такое тесты писать? Старайтесь никогда так не делать.

      Разве стабы тут не помогут? При конструировании передаем стаб в конструктор и тестируем, разве нет? Или вы статику Yii имеете ввиду?


      У классического row data gateway нет этого недостатка. Вы спокойно можете заменить имплементацию хранилища на что-то in-memory для хранения стэйта (естественно не в случае этих Finder-ов)

      В представленном в статье способе тоже так можно. Если в клиентский код репозитории (шлюзы к множествам объетов) и отдельные объекты (шлюзы к единицам данных) попадают при помощи итерфейсов, например PostRepository и Post, можно написать их реализации, работающие с памятью.


      1. Fesor
        08.10.2017 13:17

        Разве стабы тут не помогут? При конструировании передаем стаб в конструктор и тестируем, разве нет? Или вы статику Yii имеете ввиду?

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


        Я для своих ребят делал перевод статьи: Возвращаясь к основам: почему юнит тесты это сложно. Рекомендую почитать.


        1. Anton_Zh Автор
          08.10.2017 14:10

          Спасибо, почитаю


    1. Anton_Zh Автор
      08.10.2017 07:44

      но я не до конца понимаю как вам тут помогает интерфейс Author.

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


      Модуль блогов

      интерфейсы Author и AuthorRepository


      Модуль пользователей

      User и UserRepostirory и их реализации (внутренние)


      Приложение, которое компонует модули

      реализации интерфейсов Author и AuthorRepository на основе User и UserRepository.


      1. Fesor
        08.10.2017 13:28

        Author и User ссылаются на один и тот же ряд в базе или у вас есть какое-то разделение? Если так — как вы синхронизируете.


        1. Anton_Zh Автор
          08.10.2017 14:11

          Реализация интерфейса Author выполнена с помощью User. В конечном счете используется одна AR и одна запись в БД.


          1. Fesor
            08.10.2017 14:24

            ммм… а интерфейс то зачем? и где тут разделение? Контексты же вообще не должны пересекаться. Да и вы там что-то о разных БД говорили.


            1. Anton_Zh Автор
              08.10.2017 14:45

              Сначала было два переносимых модуля, которые работали в одном контексте. Модуль User, модуль Blog. Приложение включает оба модуля, они работают на одной БД. Для того, чтобы организовать работу модуля Blog, приложение внедряет в него требуемую реализацию интерфейса Author на основе модуля User.
              Далее я указал, что при таком подходе модулю Blog все равно, где лежат авторы, в той же БД или в другой. О контекстах здесь речи не шло.
              Затем я написал, что если потребуется JOIN или транзакция, сквозная между этими модулями — это плохо. разделение на модули потекло и потому лучше упредить это и не делать разделение на независимые модули если в обоих используется одна и та же бд.
              И тут уже я упомянул о контекстах. Что они не должны пересекаться на БД (одна бд — один контекст)


              1. Fesor
                08.10.2017 14:55

                приложение внедряет в него требуемую реализацию интерфейса Author на основе модуля User.

                реализация гле лежит? в каком модуле?


                где лежат авторы, в той же БД или в другой.

                приведите пожалуйста пример ситуации когда нам нужно выплюнуть json со списком постов + автор поста. И как у вас реализовано это разделение по вашей схеме.


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


                1. Anton_Zh Автор
                  08.10.2017 15:10

                  реализация гле лежит? в каком модуле?

                  В корне компоновки — приложении. Оно ведь знает, какие у него есть модули.


                  В статье есть метод получения статей с авторами findAllPostsWithAuthors()


                  class YiiDbPostRepository implements PostRepository
                  {
                  
                      private $author_repository;
                  
                      public function findAllWithAuthors(int $limit): \Iterator
                      {
                          //вытаскиваем статьи
                          $ars = YiiARPost::findAll(['limit' => $limit]);
                  
                          $iterator = new \ArrayIterator($ars);
                  
                          $ids = [];
                          //собираем id-шки авторов
                          foreach ($ars as $ar) {
                  
                              $ids[] = $ar->id;
                  
                          }
                         //по id-шкам авторов получаем авторов через внедренный репозиторий (AuthorRepository)
                          $authors = $this->author_repository->findAll($ids);
                  
                          return new class($iterator, $this->author_repository, $authors) implements \iterator
                          {
                  
                              private $iterator;
                  
                              private $author_repository;
                  
                              private $authors;
                  
                              //...
                              public function current()
                              {
                                  $ar = $this->iterator->current();
                  
                                 //в декоратор статьи записывается список авторов, который предотвращает запрос при обращении за автором и берет его из ранее вытащенного списка по идентификатору.
                                  return new AuthoredPost(
                                      new YiiDbPost($ar, $this->author_repository),
                                      $this->authors
                                  );
                              }
                  
                          }
                  
                    }
                  
                  }
                  
                  class AuthoredPost implements Post
                  {
                  
                      private $post;
                  
                      private $authors;
                  
                      public function title(): string
                      {
                          return $this->post->title();
                      }
                  
                      public function content(): string
                      {
                          return $this->post->content();
                      }
                  
                      public function author(): Author
                      {
                  
                          foreach ($this->authors as $author) {
                              if ($author->id() == $this->post->authorId()) {
                                  return $author;
                              }
                          }
                          throw new DomainException('Статья без автора! Нарушена целостность БД!');
                  
                      }
                  
                  }
                  

                  Преобразование списка в json — дело техники.


                  1. Fesor
                    08.10.2017 15:29

                    я все еще не понимаю зачем вам имплементить интерфейсы… Я не вижу профита. Вы так DTO делаете?


                    1. Anton_Zh Автор
                      08.10.2017 15:42

                      У меня нет DTO, есть DAO. Возможна и реализация при помощи DTO.


    1. Anton_Zh Автор
      08.10.2017 07:54

      Для вставки (аналогия с положить на полочку) — да. Для создания — нет, это не его ответственность.

      Почему же? Это поможет скрыть конкретную реализацию User, если мы используем интерфейсы, убирая new из клиентского кода. "Не его ответственность" — не аргумент. Нет точного определения единой ответственности — где она начинается и где она заканчивается неизвестно. Я считаю, что вставка записи в БД и создание объекта, представляющего эту запись неотделимы логически, поэтому и решил делать это неразрывно, в рамках одного метода. Как можно представлять шлюз к записи/записям в БД, если в БД нет этих записей?


      1. Fesor
        08.10.2017 13:31

        Это поможет скрыть конкретную реализацию User

        у вас не должно быть необходимости в том что бы это скрывать, если это бизнес объект.


        Нет точного определения единой ответственности — где она начинается и где она заканчивается неизвестно.

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


        $user = new User('Bob');
        $userRepository->add($user);

        а по поводу подмены реализаций — можно внутри юзать статические методы фабрики.


        1. Anton_Zh Автор
          08.10.2017 14:13

          у вас не должно быть необходимости в том что бы это скрывать, если это бизнес объект.

          А я и не говорил, что у меня бизнес-объекты. У меня есть приложение, есть данные из БД, есть объекты, обеспечивающие доступ к ним, есть клиентский код, использующий эти объекты. Бизнес-объектов вроде и нет.


          1. Fesor
            08.10.2017 14:25
            +1

            тогда непонятно зачем все это воротить если у вас старые добрые транзакционные скрипты и анемичные модели. Можно ж было намного проще все делать.


            1. Anton_Zh Автор
              08.10.2017 14:48

              Почему сразу транзакционные скрипты? Логика то в объектах, взаимодействующих с БД и объектах, которые в свою очередь инкапсулируют эти объекты. Нет тут транзакционных скриптов.


              1. Fesor
                08.10.2017 14:56

                Логика то в объектах, взаимодействующих с БД и объектах, которые в свою очередь инкапсулируют эти объекты.

                так все же где она? Зачем это разделение если бизнес логика и там и там? Вы инджектите зависимости в свои "сущности"? Если так, чем это отличается от транзакционных скриптов?


                1. Anton_Zh Автор
                  08.10.2017 15:16

                  Такая двоякость наблюдается и при использовании DataMapper. Часть логики в виде независимых манипуляций с объектами, часть в виде запросов, там где объекты невыгодны. В этом плане у меня тоже может быть как зависимая, так и независимая логика. Транзакционный скрипт — это процедурный паттерн. У меня объекты.


                  1. Fesor
                    08.10.2017 15:31

                    Такая двоякость наблюдается и при использовании DataMapper.

                    не наблюдается если нормально делать.


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

                    я повторюсь. Следует разделять операции обработки и отображения данных.
                    Дата мэппер как раз таки позволяет делать это разделение. Я могу мэпить данные как на сущность, так и на dto.


                    Транзакционный скрипт — это процедурный паттерн. У меня объекты.

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


  1. gnaeus
    08.10.2017 10:40

    Вы вот говорите, что производительность не проседает.
    А как насчет локов в БД при обновлении сущности?
    Если у нас есть какой-то UoW, то все изменения группируются в одном запросе.
    А в случае AR, у нас будет множество сетевых вызовов, на протяжении которых строчки в базе будут висеть под exclusive lock-ом. А это существенно повышает вероятность deadlock.


    1. Anton_Zh Автор
      08.10.2017 10:51
      -1

      Висеть они будут не намного дольше. На практике не сталкивался с подобными проблемами.


      1. Akdmeh
        08.10.2017 11:34
        +2

        Вы сталкивались с проектами с полмиллиона трафика в сутки?
        Тогда дедлоки возникают даже в довольно тривиальных частях кода…
        Поэтому замечание gnaeus имеет большой смысл


  1. Anton_Zh Автор
    08.10.2017 12:11

    Не сталкиваюсь. Скажу честно. Могу лишь предположить, что само по себе наличие exclusive-lock, не может являться причиной deadlock'ов. Простой UPDATE также требует exclusive lock. Что теперь, не делать UPDATE?


    1. Fesor
      08.10.2017 13:32

      один update запрос сам по себе является атомарной операцией. А вот 3 update запроса которые разнесены во времени (скажем 20-30 милисекунд между каждой из ваших логических транзакций) — это уже может быть проблемой.


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


  1. sspat
    08.10.2017 13:20
    +2

    Первая часть — у меня одни CRUD-приложения где все таблицы маппятся на обьекты один к одному, дата-маппер мне там не нужен, поэтому он плохой.
    Вторая часть — обмажем AR кучей интерфейсов у которых никогда не будет больше одной реализации и «абстракций» которые на самом деле ничего не скрывают и не понижают связанности.

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


    1. Anton_Zh Автор
      08.10.2017 14:20

      Вы слишком все упростили. В первой части не только про CRUD. Во второй — почему считаете, что не скрывают? Скрывают взаимодействие с БД, реализацию на основе какой-либо библиотеки/фреймворка.


      1. sspat
        08.10.2017 21:55

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


        1. Anton_Zh Автор
          09.10.2017 01:55

          Смогу. new на YiiDbUser производится только в репозитории. Репозиторий тоже закрыт интерфейсом и может быть внедрен через контейнер. Клиенский код будет работать с интерфейсом User.
          По поводу множества методов — это не проблема представленного подхода. Можно разбить на несколько классов. Интефейс не бесполезный, так как он помогает изолировать клиенский код от реализации с помощью Yii или чего-то еще.


  1. gnaeus
    08.10.2017 14:12
    +1

    А еще, при использовании UoW достаточно легко реализуются всякие инфраструктурные штуки, как:


    • Audit Logging,
    • Optimistic Concurrency,
    • ручная реализация WAL,
    • etc.

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


    1. Anton_Zh Автор
      08.10.2017 14:17

      Здесь то же есть UoW, только он на стороне БД. Audit Logging, Optimistic Concurrency — это тоже можно сделать при таком подходе. Определение транзакции MySQL. Как и UoW ее тоже можно откатить. Зачем дублировать то, что уже есть в БД?


      1. lair
        08.10.2017 14:24
        +3

        … затем, что иногда функциональности БД не хватает. Вот прямо вот начиная с аудита и логирования. Нужно вам на каждую запись в БД писать очередь — и как вы это из БД будете делать?


        1. Anton_Zh Автор
          08.10.2017 14:53

          Сначала напишу в одной транзакции с изменением данных строку в таблицу событий. Воркером потом вытащу ее и поставлю в очередь, сделаю закладку, что событие с таким-то id поставлено в очередь. А как вы сделаете запись в очередь совместно с модификацией данных в БД, причем консистентно? Что если у вас событие в очередь ушло а потом транзакция откатилась? Или наоборот, транзакция закоммитилась, а очередь оказалась недоступна. С таблицей событий в одной БД с данными это разрулится.


          1. Fesor
            08.10.2017 14:58

            Что если у вас событие в очередь ушло а потом транзакция откатилась?

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


            Другой пример, когда в рамках логической транзакции нужно не только в базу сходить а скажем еще и на сторонний сервис.


            а очередь оказалась недоступна.

            это как так то?


            1. gnaeus
              08.10.2017 15:05

              это как так то?

              сеть упала?


          1. gnaeus
            08.10.2017 15:00

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


          1. lair
            08.10.2017 15:08

            Сначала напишу в одной транзакции с изменением данных строку в таблицу событий.

            То есть уже лишняя таблица в БД. Которая станет ботлнеком.


            Воркером потом вытащу ее и поставлю в очередь

            То есть еще лишний воркер.


            А как вы сделаете запись в очередь совместно с модификацией данных в БД, причем консистентно?

            А где-то было требование консистентности?


            Впрочем, есть очереди, поддерживающие распределенные транзакции. И есть паттерны, где очередь до БД.


            С таблицей событий в одной БД с данными это разрулится.

            На самом деле, нет. Вам придется делать ту же самую распределенную транзакцию, только в воркере.


            1. Anton_Zh Автор
              08.10.2017 15:24

              А где-то было требование консистентности?

              А как же без нее? Данные терять? Не выполнять/пропускать что-то? Консистентность скорее редко когда не нужна, чем нужна. Можно сказать, что она желательна всегда.


              На самом деле, нет. Вам придется делать ту же самую распределенную транзакцию, только в воркере.

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


              И есть паттерны, где очередь до БД.

              Это другая архитектура. Ее применимость зависит от задачи.


              Впрочем, есть очереди, поддерживающие распределенные транзакции. И есть паттерны, где очередь до БД.

              Есть, но распределенные транзакции сложны. Если получится, то лучше без них


              1. lair
                08.10.2017 15:29
                -1

                А как же без нее?

                А легко.


                Консистентность скорее редко когда не нужна, чем нужна. Можно сказать, что она желательна всегда.

                … вот только консистентность бывает разная.


                Ну и как бы да, есть же тривиальный паттерн: пишем в очередь до и после транзакции в БД, в воркере (все равно же у вас воркер) делаем корреляцию, жизнь прекрасна.


                1. Anton_Zh Автор
                  08.10.2017 15:39

                  В итоге все сводится к «зависит от задачи»


                  1. lair
                    08.10.2017 15:42
                    +2

                    Ну да. Но чем больше у вас cross-cutting concerns, тем сложнее их сделать на БД (особенно когда вы пытаетесь сделать БД максимально быстрой, или вам надо делать работу с более чем одной СУБД и так далее).


  1. SergeyGershkovich
    08.10.2017 15:38

    Возможно лезу не в свою песочницу. К Yii или PHP отношения не имею… ИМХО. Разрабатываю обычные Клиент-Сервер БД приложения — Декларативная компоновка интерфейса + чистый SQL для обработки событий.

    Обсуждаемые ActiveRecord и DataMapper конечно мощные, но никак не простые инструменты разработки.

    Не готов пока обсуждать какую-то иную архитектуру, прошу лишь обратить внимание на интересное наблюдение:

    1. Не обязательно тащить связи из БД и эмулировать на клиенте сложные иерархические структуры данных;
    2. Достаточно создавать архитектуру приложения из компонентов, которые самостоятельно (независимо друг от друга) работают с БД, при этом обмениваются друг с другом данными исключительно в табличном виде (даже сообщение о некоем событии посылают друг другу в виде набора однородных записей);
    3. Код такого приложения превращается в декларативное описание слабосвязанных объектов;
    4. Главное преимущество: любые два компонента, созданные независимыми разработчиками, могут быть связаны друг с другом без единой строчки (императивного) кода, так как при посыле сообщения между объектами структура данных сообщения (таблица) отправителя автоматически конвертируется в структуру данных сообщения (таблицу) получателя, для этого достаточно (декларативно) описать соответствия имен полей двух таблиц и гарантировать преобразование простых типов данных.


    1. lair
      08.10.2017 15:45

      при этом обмениваются друг с другом данными исключительно в табличном виде

      Зачем нужно это ограничение?


      Код такого приложения превращается в декларативное описание слабосвязанных объектов;

      А логика-то как описывается?


      так как при посыле сообщения между объектами структура данных сообщения (таблица) отправителя автоматически конвертируется в структуру данных сообщения (таблицу) получателя

      Enterprise Message (Data) Bus. Были сильно популярны лет пять и дальше назад. Ну или, если более обобщенно, SOA вообще.


      Основная проблема состоит в том, что логика никуда не пропадает — она просто переносится в мапперы/фильтры/роутеры, поддерживать которые не так уж и просто.


  1. SergeyGershkovich
    08.10.2017 16:18

    при этом обмениваются друг с другом данными исключительно в табличном виде

    Зачем нужно это ограничение?


    Мысль о том, что данное ограничение дает преимущество — прозрачную, простую архитектуру описанную в декларативной парадигме.

    Если интересно, кинте в личку какую-нибудь задачу, распишу её решение в собственной интерпретации.


    1. lair
      08.10.2017 16:21

      Мысль о том, что данное ограничение дает преимущество — прозрачную, простую архитектуру описанную в декларативной парадигме.

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


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


      1. SergeyGershkovich
        08.10.2017 19:39

        На вопрос:

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

        как и на вопрос: А что такое прозрачная архитектура?
        бесполезно отвечать без конкретного примера. С вас пример, с меня решение. Окружающие пусть сами решают, кому, какое решение прозрачнее.

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

        Мы же говорим «О паттернах проектирования для работы с РСУБД», где все данные уже представлены в табличном виде.


        1. lair
          08.10.2017 19:50

          С вас пример, с меня решение.

          Хм. Ну например: через систему проходит поток сообщений (произвольной структуры), эти сообщения в зависимости от нескольких признаков внутри этой структуры надо маршрутизировать в разные модули (плагины); каждое пришедшее сообщение также должно проходить через произвольные фильтры (тоже плагины).


          Мы же говорим «О паттернах проектирования для работы с РСУБД», где все данные уже представлены в табличном виде.

          Эм… нет. Мы говорим об архитектуре приложения ("Достаточно создавать архитектуру приложения из компонентов"). Что и как там хранится в РСУБД разработчиков компонентов волнует мало.


          1. SergeyGershkovich
            08.10.2017 21:07

            Ну например: через систему проходит поток сообщений (произвольной структуры)...

            В личке прошу пару уточнений, чтоб совместить понятийный аппарат

            Эм… нет. Мы говорим об архитектуре приложения («Достаточно создавать архитектуру приложения из компонентов»). Что и как там хранится в РСУБД разработчиков компонентов волнует мало.

            Эм… тема статьи "… работа с РСУБД".


            1. lair
              08.10.2017 21:14

              Эм… тема статьи "… работа с РСУБД".

              Общение двух компонентов в приложении — если они не общаются через РСУБД (а вы явно пишете, что нет) — не может быть "работой с РСУБД". Поэтому вы прямо в первом же комментарии вышли за пределы темы статьи.


              1. SergeyGershkovich
                08.10.2017 23:49

                О как! Под такое Ваше представление проблематики «работы с РСУБД» подпадет только CRUD.
                В ОРМ связи между объектами могут быть описаны и без явной реализации в СУБД.
                Да и сами объекты могут быть из разных РСУБД, а связи между ними как-то реализовывать надо — эта проблема тоже к теме статьи не относится?


                1. lair
                  08.10.2017 23:53
                  +1

                  Под такое Ваше представление проблематики «работы с РСУБД» подпадет только CRUD.

                  А какой еще присущий всем РСУБД сценарий я упустил?


                  В ОРМ связи между объектами могут быть описаны и без явной реализации в СУБД.

                  Да, и что?


                  Да и сами объекты могут быть из разных РСУБД, а связи между ними как-то реализовывать надо — эта проблема тоже к теме статьи не относится?

                  Это вопрос к автору статьи, а не ко мне.


                  Только не надо путать связь между объектами и общение между компонентами. Это не одно и то же.


  1. Daniil1979
    08.10.2017 17:47

    Разработчик бд смотрит на ваш код как на кучу ненужного говна.


    1. Anton_Zh Автор
      09.10.2017 01:56

      Как и пхпшники часто смотрят на код датабазников)


      1. Daniil1979
        10.10.2017 12:34

        1:1