Skip to content

Latest commit

 

History

History
executable file
·
396 lines (286 loc) · 30.2 KB

patterns-oop.md

File metadata and controls

executable file
·
396 lines (286 loc) · 30.2 KB

Паттерны работы с базой данных

Разберемся, как правильно с применением ООП сохранять и загружать данные из базы. Существуют такие подходы:

Общие принципы, которых надо придерживаться:

  • SQL запросы не должны быть размазаны по всему приложению, а должны быть собраны в отдельных классах
  • для работы с каждой таблицей стоит сделать свой класс (если SQL-запрос работает с несколькими таблицами, то надо определить основную и поместить его в соответствующий класс)

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

Примитивный подход

В этом варианте мы не используем никаких классов, а просто загружаем/вставляем данные в базу с использованием PDO. Для передачи данных мы используем обычные массивы:

<?php
$query = $pdo->prepare("SELECT * FROM news WHERE categId = :categId LIMIT 20");
$query->execute(array(':categId'    =>  $categId));
$news = $query->fetchAll(); // получаем массив массивов

Этот способ не требует создания никаких классов, но он очень ограничен и ведет к плохому коду, если в нашем приложении больше 1-2 таблиц: ты начнешь путаться, где какой массив и какие у него поля (чтобы понять, какие поля будут в $news, нам надо лезть в базу данных). В общем, в сложном приложении с таким подходом ничего хорошего нам не светит.

Однако этот подход наиболее эффективен при работе с огромным количеством записей. Стоит использовать его в крошечных скриптах для обработки больших объемов данных.

Такой подход например поддерживается самим PDO, и компонентом Doctrine DBAL из библиотеки Doctrine 2: http://www.doctrine-project.org/projects/dbal.html

Table Data Gateway

Это паттерн, когда мы для каждой таблицы в базе данных создаем отдельный класс (шлюз к данным таблицы = gateway, отсюда название паттерна) и создаем в этом классе методы работы с таблицей. Главное преимущество: SQL запросы больше не разбросаны по коду, а сгруппированы в отдельные классы. Если мы меняем что-то в таблице, нам не надо бегать по всему коду, нам достаточно взять работающий с этой таблицей класс и поправить только его.

Класс TDG не имеет права лезть в глобальные переменные вроде $_GET, $_POST, куки, и вообще делать что-то, кроме работы со своей таблицей в базе данных. Все нужные данные ему передаются через аргументы функций. С другой стороны, остальной код не имеет права лезть в базу данных в обход TDG.

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

/**
 * Класс, содержащий методы для работы с таблицей новостей.
 */
class NewsTableGateway
{
    /**
     * Объект для работы с БД внедряется через конструктор
     */
    public function __construct(PDO $pdo) { ... }

    /** 
     * Получить последние новости в виде массива объектов News где каждый 
     * объект представляет одну новость.
     * @return News[]
     */
    public function getLatestNews() { ... }

    /** 
     * Получить новость по id в виде объекта News или null если она не найдена
     * @return News|null
     */
    public function getNewsById($id) { ... }

    /**
     * Получить архив новостей за данный месяц и год
     * @return News[]
     */
    public function getNewsArchive($year, $month) { ... }

    /**
     * Добавить новость, в функцию передается объект News содержащий 
     * заголовок, текст новости и т.д. 
     * @return int Возвращает id вставленной новости
     */
    public function addNews(News $news) { ... }

    // и так далее ...
}

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

При таком подходе ты со временем заметишь что в классах Gateway много похожих однотипных методов, например получение объекта по id, удаление объекта по id. Чтобы не копировать их, можно унаследовать классы gateway от общего предка и в нем сделать такие общие методы.

Кроме Table Data Gateway, где объект используется для действий над таблицей БД, есть похожий, но более редко используемый паттерн Row Data Gateway, где объект используется для действий над одной строкой в базе данных: http://design-pattern.ru/patterns/row-data-gateway.html .

В старом Zend Framework 1 был такой класс, Zend_Db_Table, который содержал общие методы работы с таблицами и мог служить основой для TDG-классов: http://framework.zend.com/manual/1.12/ru/zend.db.table.html — правда, там по умолчанию результаты выборок возвращались в виде похожих на массивы объектов класса Zend_Db_Table_Row (которые играли роль Row Data Gateway), а не в виде моделей произвольного класса (но это можно переопределить опцией rowClass).

В Zend Framework 2 есть класс, который так и называется - Zend\Db\TableGateway, и он реализует описанный выше паттерн. При выборке из Бд он возвращает объекты Zend\Db\RowGateway, реализующие одноименный паттерн (хотя мне конечно эта схема кажется немного сложной).

Описание: http://design-pattern.ru/patterns/table-data-gateway.html

Преимущества этого паттерна: простота реализации, изоляция SQL запросов работы с таблицей в одном классе, минусы: надо писать рутинные методы вроде получения объекта по id. Паттерн подходит для простых небольших приложений.

Сущности

Более продвинутый способ — использовать объект для представления информации о какой-то сущности (entity) и сохранять/загружать такие объекты из БД. Такой объект еще называют моделью сущности.

Одной записи в таблице в таком случае соответствует один объект-модель. Например, для работы с таблицей новостей мы можем создать модель News, представляющую собой одну новость:

<?php

class News
{
    public $id;
    public $title;
    public $text;
    public $categId;
    /**
     * Дата в виде объекта DateTime
     */
    public $date;
    
    /**
     * Возвращает возраст новости в днях
     */
    public function getAgeDays()
    {
        // Находим разницу между сегодня и датой публикации в днях
        $today = new DateTime();
        $interval = $today->diff($this->date);
        return $interval->d;
    }

    /**
     * Ставит сегодняшнюю дату публикации
     */
    public function setTodayDate()
    {
        $this->date = new DateTime();
    }
}

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

Код, реализующий загрузку и сохранение сущностей в SQL базу данных еще называется ORM (Object-Relational Mapper). ORM пытаются избавить нас от необходимости писать однотипные примитивные SQL запросы, позволяя нам работать на более высоком уровне. Есть 2 подхода к реализации ORM: Active Record и Data Mapper.

ActiveRecord

Это более простой способ. При его использовании методы для сохранения/загрузки сущности из БД добавляются прямо в нее. Чтобы не копипастить их в каждый класс, их обычно добавляют в базовый класс, а сущность наследуют от него. При этом обычно в сущности делается метод, возвращающий информацию о соответствии полей объекта таблице и полям в базе данных (чтобы можно было правильно составить SQL запрос):

<?php
class News extends ActiveRecordBase
{
    .....
    
    protected function getTableName()
    {
        return 'news'; // имя таблицы с новостями
    }
    
    protected function getFields()
    {
        // список полей, которые отображаются на таблицу
        return array('id', 'title', 'text', 'date', 'categId'); 
    }
    
    /**
     * Вызывается перед вставкой в таблицу
     */
    protected function beforeInsert()
    {
        if (!$this->date) {
            // ставим дату создания если не задана
            $this->date = new DateTime(); 
        }
    }
}

Соответственно, вот как выглядит пример поиска записей, вставки и обновления записи:

<?php

News::setConnection($pdo); // в некоторых фреймворках передавать объект БД не надо − 
                           // класс сам берет объект откуда-нибудь

// В данном примере статические методы работают со всей таблицей, 
// а нестатические - с отдельной моделью

// возвращает массив объектов-новостей
$lastestNews = News::findLatestNews(); 

// возвращает новость с id = 10
$someNews = News::getById(10);

// меняем название
$someNews->title = 'Новое название';
// Обновляем запись в БД    
$someNews->save();

$newNews = new News();
$newNews->title = 'Сенсация!';
$newNews->text = 'Текст новости';
// вставка в БД. После нее поля id и date заполняются автоматически
$newNews->save(); 

Такой подход используется, например в Yii1 и Yii2: http://www.yiiframework.com/doc/guide/1.1/ru/database.ar - там в одном классе объединены модель, код работы с таблицей в БД и валидация (проверка правильности введенных значений, например что длина заголовка новости не превышает разрешенного ограничения).

Этот подход относительно прост, но он имеет недостаток: мы смешиваем код модели (методы работы со свойствами новости) и работу с таблицей новостей в одном классе. Хотя объект-новость вполне может существовать и сам по себе. Для решения этой проблемы нам нужен DataMapper.

Описание паттерна: http://design-pattern.ru/patterns/active-record.html

DataMapper

DataMapper - это класс который загружает из базы и сохраняет в базу объекты-модели. В отличие от ActiveRecord, где модель сама себя сохраняет, тут мы выносим код работы с базой данных в отдельный класс. Вот пример такого класса:

<?php
class NewsMapper
{
    ....
    /** Сохраняет данные из модели в базу, делая UPDATE или INSERT запрос */
    public function save(News $news) { ... }

    /** Загружает модель из базы имея $id */
    public function getById($id) { ... }

    /** Получает список последних новостей */
    public function findLatestNews() { ... }
}

И вот пример использования:

<?php
$mapper = new NewsMapper($pdo);
// Поиск новости по id
$someNews = $mapper->getById(10);
// меняем название
$someNews->title = 'Новое название';
// Обновляем запись в БД    
$mapper->save($someNews);

// создание новой
$newNews = new News();
$newNews->title = 'Сенсация!';
$newNews->text = 'Текст новости';
// вставка в БД
$mapper->save($newNews);

// После вставки маппер задает id сущности
echo "Добавлена новость с id={$newNews->id}\n";

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

Этот подход похож на паттерн Table Data Gateway. Но Data Mapper обычно устроен сложнее. Например в TDG, чтобы обновить заголовок новости, ты должен явно вызвать соответствующий метод:

$newsGateway->updateNewsTitle($id, $newTitle);

// или так: 
$news->title = 'Новый заголовок';
$newsGateway->updateNews($news);

А вот Data Mapper может содержать более продвинутую логику. Например, библиотека Doctrine 2 сама умеет искать какие поля у каких моделей изменились, и формирует соответствующие запросы на обновление данных:

$news1 = $em->find('News', $id1); // Ищем первую новость по id
$news2 = $em->find('News', $id2); // Ищем вторую новость по id
$news1->setTitle('Новый заголовок'); // обновляем заголовок у первой новости
$news2->setAuthorName('Иван Иванов'); // обновляем имя автора второй новости
$em->flush();   // сбрасываем изменения в базу - доктрина сама найдет изменившиеся поля и 
                // выполнит запросы вида UPDATE news ... 

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

$author = $news->getAuthor(); // получить автора по новости
$allNews = $author->getPostedNews(); // получить все новости написанные этим автором

Подход Data Mapper (а также другие паттерны, такие как Identity Map, Unit Of Work, Repository) используется в ORM Doctrine2: http://web.archive.org/web/20150222134452/http://odiszapc.ru/doctrine/

Описание в каталоге паттернов: http://design-pattern.ru/patterns/data-mapper.html

Вспомогательные паттерны

Эти паттерны используются не сами по себе, а вместе с вышеописанными.

Query Builder

Query Builder — это не альтернатива перечисленным выше паттернам. Он может использоваться с любым из них и предназначен для тех случаев, когда SQL запрос надо видоизменять в зависимости от значений переменных. Ну предположим, нам надо сделать запрос для поиска объявлений по нескольким характеристикам: раздел (category_id), дата (posted_date) и id автора (author_id), причем любой из критериев может отсутствовать.

Если ты будешь пытаться собирать запрос по кусочкам, то получится примерно такой сложночитаемый и сложноподдерживаемый код:

// хорошие дети, не делайте так

// критерии поиска
$criteria = [
    'category_id'   =>  6,
    'posted_date'   =>  '2014-01-01 12:00:00',
    'author_id'     =>  8
];

// Массив значений для плейсхолдеров
$placehoders = [];
$conditions = [];

// Если задан поиск по разделу
if (!empty($criteria['category_id'])) {
    $conditions[] = ' category_id = :category_id  ';
    $placeholders[':category_id'] = $criteria['category_id']; 
}

// ...

// собираем все вместе
$where = implode(' AND ', $conditions);
$whereWord = $conditions ? ' WHERE ' : '';
$sql = 'SELECT * FROM table ' . $whereWord . $where . ' ORDER BY posted_date DESC';

В нем легко допустить ошибку. Для решения этой проблемы придуманы Query Builder, где ты можешь составлять запрос по кусочкам. Вот пример использования Doctrine DBAL (документация на англ.):

$qb = $conn->createQueryBuilder();
$qb->select('*')->
    from('table')->
    orderBy('posted_date', 'DESC');

// Если задан поиск по разделу
if (!empty($criteria['category_id'])) {
    $qb->addWhere('category_id = :category_id');
    $qb->setParameter(':category_id', $criteria['category_id']);
}

// ...

// Выводим запрос
echo "SQL код: {$qb->getSql()}\n";

// Выполняем
$stmt = $qb->execute();

Не стоит использовать Query Builder, если запрос всегда одинаков - выгоднее записать его на языке SQL, он получится компактнее и читабельнее (в случае QB при чтении кода надо в уме "переводить" код на SQL).

Реализация QB есть в Zend2: http://framework.zend.com/manual/current/en/modules/zend.db.sql.html#zend-db-sql-select

И в Doctrine DBAL: http://doctrine-dbal.readthedocs.org/en/latest/reference/query-builder.html

Identity Map

Этот паттерн гарантирует что при использовании моделей у нас никогда не будет 2 объекта-модели, соответствующие одной и той же записи в базе данных (ну например 2 объекта News с одинаковыми id). Это в общем хорошая идея, потому что иначе непонятно в какой из моделей правильные данные.

Это реализуется таким образом: создается специальное хранилище (identity map) и при выборке данных из БД мы для каждой строчки результата проверяем: загружался ли ранее объект с таким id или нет. Если нет — то мы создаем новый объект, заполняем его данными из базы и помещаем в хранилище. Если в хранилище уже есть объект с таким id, мы берем его оттуда.

Вот пример кода, с использованием TDG:

// пробуем 2 раза загрузить новость с одинаковым id
$news1 = $newsGateway->findById(1);
$news2 = $newsGateway->findById(1);

// Если у нас используется identity map, то мы должны получить 2 ссылки 
// на один и тот же объект, а не 2 разных объекта
if ($news1 === $news2) {
    echo "Identity map используется!\n";
}

Identity Map используется в библиотеке Doctrine 2

Doctrine 2

Doctrine 2 — это библиотека реализующая паттерн DataMapper. Ты просто добавляешь в свои сущности аннотации, задающие соответствие полей объектов и полей в базе данных, а Doctrine дает тебе классы-репозитории, которые позволяют загружать и сохранять твои объекты в базу данных. Doctrine 2 — очень мощная и популярная, хотя и непростая для начинающего, библиотека. Чтобы с ней работать, надо понимать саму идею ORM, паттерны UnitOfWork и IdentityMap. И придется много читать мануал по ней.

Напишу еще несколько вещей, которые мы не рассмотрели, но которые есть в больших ORM вроде Doctrine 2:

  • свой язык запросов DQL, похожий на SQL
  • описание через конфиг или аннотации: ты можешь с помощью специальных комментариев-аннотаций указать, как поля объекта связаны с полями в таблице: http://web.archive.org/web/20150222140843/http://odiszapc.ru/doctrine/basic_mapping/
  • IdentityMap ( http://design-pattern.ru/patterns/identity-map.html ): если ты повторно выбираешь ту же самую сущность из базы, тебе возвращается ссылка на существующую сущность. Доктрина следит чтобы каждая сущность существовала ровно в одном экземпляре, и это помогает избежать противоречий когда есть несколько экземпляров и непонятно в каком из них актуальные данные
  • UnitOfWork ( http://design-pattern.ru/patterns/unit-of-work.html ): когда ты делаешь изменения в сущностях, они не сохраняются автоматически. Ты должен явно вызвать метод flush() и тогда EntityManager найдет все изменившиеся, новые и удаленные сущности и соответственно обновит/вставит/удалит записи в базе одной транзакцией.
  • работа с ассоциациями (связями). Например, Новость может относиться к Категории и быть помечена Тегами, а также под ней могут быть оставлены Комментарии (у которых в свою очередь есть Авторы). При этом если мы должны иметь возможность создавать такие связи и разрывать их. Представь, как сложно такое реализовать самому (трудно представить? попробуй напиши код).

Doctrine 2 не требует от тебя унаследовать класс-сущность от какого-то базового класса, он позволяет связать любой класс с базой данных — главное чтобы в нем были методы get../set.. для чтения и записи полей. Также, придется потратить время на то, чтобы разобраться, как правильно использовать этот ORM и как настроить в нем кеширование метаданных, чтобы он работал с приемлемой скоростью.

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