Skip to content

Instantly share code, notes, and snippets.

@fesor
Last active March 12, 2024 00:02
Show Gist options
  • Save fesor/d84451fc6cf00ea62ca5 to your computer and use it in GitHub Desktop.
Save fesor/d84451fc6cf00ea62ca5 to your computer and use it in GitHub Desktop.
Доступ к данным или как мы учились не замечать базу данных

Оргенизация слоя хранения данных это всегда не простая задача, с кучей кейсов, вроде блокировки перезаписи, транзакции, сохранение целостности данных. В какой-то момент появились СУБД и обещали решить эти проблемы, но с ними появились новые - как нам работать с состоянием, которое мы храним?

Существует 4 основных подхода для организации рабоыт с базой данных, Table Gateway, Row Data Gateway, Active Record и Data Mapper. Все эти подходы объеденяет то, что они скрывают от нас базу данных и нюансы работы с ними (в частности SQL). На сегодняшний день самым популярным подходом являются Active Record и Data Mapper, все о них слышали, но для того что бы более полно представлять, как развивалась идея, стоит рассказать и о первых двух.

Table Data Gateway

Table Data Gateway, который так же можно встретить под названием DAO (Data Access Object), предоставляет нам объектное представление отдельных таблиц, с доступом ко всем рядам оных. Его основное предназначение - спрятать SQL как деталь реализации, предоставиви простой CRUD интерфейс для работы с таблицами в соответствии с нуждами приложения. Рассмотрим на примере (для удобства я заменил обычный SQL на Database компонент из Laravel с целью использовать тамошний query builder)

class UserTableGateway {
    private $table;
    
    public function __construct() {
        $this->table = DB::table('users');
    }
    
    public function insert($name, $email, $password) {
        $this->table->insert(compact('name', 'email', 'password'));
    }
    
    // пароль должен обновляться отдельно от профиля
    public function updateProfile($id, $name, $email) {
        $this->table->where('id', $id)->update(compact('name', 'email'));
    }
    
    public function findCoolestUser() {
        return $this->table->orderBy('rating', 'DESC')->first(); //
    }
}

$usersTable = new UserTableGateway();
$usersTable->insert('Sergey', '[email protected]`, password_hash('somepassword', PASSWORD_DEFAULT));
$user = $usersTable->findCoolestUser(); // вернет нам один ряд 

Основная идея тут - держать все что относится к формированию запросов в одном месте, что бы наш SQL, query builder, или же PDO, не расползался по системе. Не изолировать код от хранилища, а предоставить удобный способ взаимодействия с ним. Возможность инкапсулировать различные запросы в рамках этого подхода позволяет сильно уменьшить вероятность ошибок. Однако работать с "сырыми" данными не очень удобно, обычно мы все же хотим работать со всеми данными как с объектами.

Row Data Gateway

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

Для решения этой проблемы, нам нужна прослойка между сущностью и базой данных. Row Data Gateway. При этом подходе мы проэцируем отдельные ряды нашей таблицы на объекты, которые служат промежуточным звеном и инкпсулируют все детали о том как сохраняется информация в себе. В итоге наши сущности могут работать через этот gateway используя штатные средства языка программирования.

class UserGateway {
    // сделав свойства публичным я хочу подчеркнуть то,
    // что этот класс это просто структура данных, состояние без поведения
    private $id;
    public $name;
    public $email; 
    public $password;
    
    // еще раз, это проекция таблицы, а не объект бизнес логики
    public function __constructor(string $id = null) {
        if ($id) {
           $this->id = $id;
           $this->fetchRow();
        } else {
           // если ID не задан, то мы генерируем ноывй
           $this->id = Cuid::cuid();
        }
    }
    
    public function insert() {
        DB::table($this->tableName)->insert($this->getAttributes());
    }
    
    public function update() {
        DB::table($this->tableName)->where('id', $this->id)->update($this->getAttributes());
    }
    
    private function fetchRow() {
       $row = DB::where('id', $this->id')->first();
       if (!$row) {
           throw new \UnableToCreateGatewayException('');
       }
    }
    
    private function getAttributes() {
        return [
           'id' => $this->id,
           'name' => $this->name,
           'email' => $this->email,
           'password' => $this->password,
        ];
    }
}

class User {
    private $gateway;
    
    public function __construct(UserGateway $gateway) {
        $this->gateway = $gateway;
    }
    
    public function save() {
       
    }
}

Отдельно стоит заметить, что поскольку Row Data Gateway представляет собой отдельную строку таблицы, для выборки нужных рядов нам нужно уже делать отдельный компонент Finder.

Как вы можете видеть, с данным подходом мы уже можем добавлять поведение для сущности User, работая изнутри с UserGateway

Active Record

Active Record - это Row Data Gateway, к которому добавили логику предметной области. Дописать...

Data Mapper

AR и Row Data Gateway не очень удобно тестировать. Нам хочется полной независимости от базы данных.

Object Relation Mapping

TODO

сырой булшит

По сути мы работаем с объектами нашей базы данных как с обычными объектами. Разлица лишь в том, что каждый способ предоставляет больше возможностей для изоляции от хранилища. Table Data Gateway заставляет нас работать со всей таблицей целиком, Row Data Gateway - с отдельными рядами таблицы. Active Record является развитием идеи Row Data Gateway но уже позволяет инкапсулировать дополнительное поведение. Data Mapper же вообще не приязан к структуре базы данных, и, так же как и в случае с AR, мы можем примешивать объектам поведение.

Поскольку первые два подхода практически не используются, рассмотрим Active Record и Data Mapper. В чем координальное отличие?

В рамках Active Record (или Active State) мы работаем с объектами как отображением элементов нашей базы данных, как если бы у нас был прямой доступ к ним без SQL прослойки. В DataMapper мы работаем исключительно с нашими объектами, которые лежат в памяти, и просим отдельную штуку (мэппер) что бы тот синхронизировал состояние объектов в памяти и в базе (сохранил состояние по сути).

Доустим у нас есть некий граф объектов, с которым мы работаем в рамках бизнес транзакции. И мы должны сохранить изменения в базу, то есть объект на верху графа и все связанные с ним сущности. Если руководствоваться паттерном "Информационный эксперт", заниматься этим должен тот, кто знает как это делать. В случае AR эта логика выносится прямо в сущности и мы явно задаем в каком порядке что сохранять. В случае же с DM у сущности нет таких знаний и мы выделяем все в отдельный компонент, что дает нам дополнительные варианты как это организовать удобнее для нас. От тупого разруливания графа "руками", до алгоритмов, которые разруливают это автоматически.

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

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

Остальные нюансы, вроде UnitofWork, Ideniny Map, работа со связями, ленивая инициализация связанных объектов, все это - приблизительно одинаково реализуется при обоих подходах и никакого отношения непосредственно к ActiveRecord или DataMapper не имеет. Но без этого всего сами подходы не предоставляют нам той гибкости, которую мы ожидаем получить от ORM.

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

Реализации ORM использующие Data Mapper

  • Doctrine2 - Самое мощное решение из существующих на данный момент.
  • Spot2 - легковесная ORM а базе Doctrine DBAL без магии.
  • Analogue - легковесая ORM, использующая в качестве основы Laravel Database
  • Atlas.Orm - сырая, но интересная ORM, которая пытается полностью вынести Persistance Model из слоя бизнес логики, но без какой либо магии.

TODO

Реализации ORM использующие Active Record

TODO

Реализации ORM использующие смешанные подходы

TODO

<?php
class User extends ActiveRecord {
public $id;
public $email;
public $password;
public $firstName;
public $lastName;
}
/* maps all this on table:
Table users
------------------------
| id | int |
| email | string |
| password | string |
| first_name | string |
| last_name | string |
------------------------ */
abstract class ActiveRecord {
public function save() {
if ($this->isNewRecord()) {
$this->insert();
} else {
$this->update();
}
}
public function update() {
$this->db->update($this->tableName, $this->attrs, ['id' => $this->id]);
}
public function insert() {
$this->db->insert($this->tableName, $this->attrs);
}
public function delete() {
$this->db->insert($this->tableName, $this->attrs);
}
// ...
}
<?php
class User {
private $id;
private $email;
private $password;
private $profile;
public function __construct(string $email, string $password, UserProfile $profile) {
$this->email = $email;
$this->password = $password;
$this->profile = $profile;
}
}
class UserProfile {
private $firstName;
private $lastname;
public function __construct(string $firstName, string $lastName) {
$this->firstName = $firstName;
$this->lastName = $lastName;
}
}
/** Maps data to this table structure:
*
* ------------------------------
* id | int |
* email | string |
* password | string |
* profile_first_name | string |
* profile_last_name | string |
*/
class DataMapper {
private $db, $hidrator;
public function save(User $user) {
$attributes = $this->hidrator->dehydrate($user);
if ($this-isNewRecord($user)) {
$this->db->setIdentity($user); // selects next sequence value as ID (only postgresq, oracle, mssql)
$this-db->insert($this->getTableName($user), $attributes);
} else {
$this->db->update($this->getTableName($user), $attributes, $this->getFindByIdentityCriteria($user));
}
}
public function remove(User $user) {
$this->db-delete($this->getTableName($user), $this->getIdentity($user));
}
// ...
}
<?php
class UserGateway {
// сделав свойства публичным я хочу подчеркнуть то,
// что этот класс это просто структура данных, состояние без поведения
private $id;
public $name;
public $email;
public $password;
// еще раз, это проекция таблицы, а не объект бизнес логики
public function __constructor(string $id = null) {
if ($id) {
$this->id = $id;
$this->fetchRow();
} else {
// если ID не задан, то мы генерируем ноывй
$this->id = Cuid::cuid();
}
}
public function insert() {
DB::table($this->tableName)->insert($this->getAttributes());
}
public function update() {
DB::table($this->tableName)->where('id', $this->id)->update($this->getAttributes());
}
private function fetchRow() {
$row = DB::where('id', $this->id)->first();
if (!$row) {
throw new \UnableToCreateGatewayException('');
}
}
private function getAttributes() {
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'password' => $this->password,
];
}
}
class User {
private $gateway;
public function __construct(UserGateway $gateway) {
$this->gateway = $gateway;
}
public function save() {
}
}
<?php
class UserTableGateway {
private $table;
public function __construct() {
$this->table = DB::table('users');
}
public function insert($name, $email, $password) {
$this->table->insert(compact('name', 'email', 'password'));
}
// пароль должен обновляться отдельно от профиля
public function updateProfile($id, $name, $email) {
$this->table->where('id', $id)->update(compact('name', 'email'));
}
public function findCoolestUser() {
return $this->table->orderBy('rating', 'DESC')->first(); //
}
}
$usersTable = new UserTableGateway();
$usersTable->insert('Sergey', '[email protected]`, password_hash('somepassword', PASSWORD_DEFAULT));
$user = $usersTable->findCoolestUser(); // вернет нам один ряд
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment