orange-bean — младший брат RedBeanPHP

в 12:04, , рубрики: orm, php, RedBeanPHP, метки: , ,

Два года назад я сгородил небольшую библиотеку на PHP по мотивам RedBean. RedBean обсуждали пару раз на Хабре (вот и вот). Зачем я стал делать свое, я описал в этом комментарии (не буду повторять это в статье).

То есть речь идет об ORM-библиотеке. Хотя в строгом смысле это не ORM нифига, это скорее красивые обертки над PDO. Слово ORM просто делает более понятным назначение.

Я назвал свой «продукт» orange-bean (в смысле следующий шаг по радуге, если положить, что RedBean — это первый). Поиспользовал свое творение в паре очень простых проектов, но почти нигде о нем не писал. Нормальную документацию тоже не сделал, набросал только шпаргалку, чтобы не забыть собственное API, если вдруг снова пригодится. А сегодня в «приступе ностальгии» решил немного попиариться.

orange bean — младший брат RedBeanPHP


Хочу сразу ответить на флеймообразующие вопросы:

  1. Почему не GitHub? Ну… так исторически сложилось. Когда я сел за проект, мне просто надо было куда-то коммитить, поэтому я выбрал знакомый Google Code. И ничем кроме SVN я тогда пользоваться не умел. Сегодня я поступил бы по-другому.
  2. Почему нестандартный стиль именования функций? В то время я был под сильным впечатлением от rails с его link_to и тд, поэтому называл методы и переменные vot_tak, а не kakPrinyatoVPhp. Простите мне эту слабость.
  3. Зачем заобфусцирован-минимизирован релизный файл библиотеки? Еще одна глупость — просто было интересно поэкспериментировать.

Как вы понимаете, эти косметические проблемы поправимы, поэтому давайте к сути.

На кой городить велосипед, когда их уже столько?

Изначальным посылом было то самое ощущение несопоставимости простого-удобного-маленького PHP из нашего детства и больших взрослых скал, таких как Zend Framework, symfony, Doctrine и других. Уверен, не меня одного посещали эти мысли. Я хорошо понимаю, что в больших проектах, которые бизнес, а не удовольствие, нужны большие и серьезные инструменты. Там надо выучить признанную технологию мирового уровня, приноровиться к ней и делать бабки.

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

Ну правда ведь, не станете вы заводить Doctrine для маленького сайта, бегущего на shared-хостинге (на котором никто не даст вам APC, горячо рекомендуемый в документации). С другой стороны, с низкоуровневым PDO или с тем самым mysql_connect тоже не хочется возиться. Как-то так…

Как я уже сказал, и идеи, и API у orange-bean такие же как у ReadBean. Так что если вы знакомы с последним, то «весь этот плагиат» вам покажется очень знакомым и понятным.

Страница проекта: code.google.com/p/orange-bean/
Скачивается отсюда: code.google.com/p/orange-bean/downloads/
Человекочитаемые исходники можно посмотреть онлайн или сделав svn export.

Быстрый старт

Чтобы начать работу с orange-bean, вы пишете 2 строки:

require "orange-bean.php";

а потом одно из двух

R::setup("sqlite:path/to/db");

или

R::setup("mysql:host=localhost;dbname=db", "root", "qwerty");

С другими базами работать не умеет. Только MySQL 5 или SQLite 3.

Как вы могли заметить, все API сконцентрировано в контейнере R (так было в RedBean, я не стал это менять).

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

$pdo = R::pdo();

orange-bean работает не с любыми объектами. Предвижу, что ценители plain old objects расстроятся (как это будет для PHP? POPO да?)

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

$bean = R::dispense("person");

То что получилось, называют bean (и, если не ошибаюсь, корнями это уходит в enterprise Java). Точнее, «bean of kind person». «Bean» и «kind» переводить не буду, чтобы не вводить путаницы. Адепты чистого русского, простите меня.

Kind можно получить, вызвав одноименный метод:

print $bean->kind();

Bean — достаточно глупый объект. В него можно [посредством магических методов] назначить любое количество свойств, на этом все и заканчивается. Способы расширения я обсужу ниже.

$bean->name = "Alex";
$bean->year = 1984;
$bean->smart = true;

Сохранение объекта делается так:

$id = R::store($bean);

Возвращается, как вы догадались, ключ:

print $bean->id;

Ключ всегда живет в свойстве id. На это нельзя повлиять, и это тоже может некоторым не понравиться. Кроме того, ключ всегда числовой, поэтому о распределенных системах с GUID-ами тут речи быть не может. Это маленькая библиотека для простых проектов!

Ключевой фишкой оригинального RedBean была автоматическая генерация таблиц, а также динамическое их дополнение полями. В orange-bean я тоже это реализовал, причем постарался поправить косяки оригинального решения. Если в данной точке некоторые уже захотят взглянуть на код, то рекомендую соответствующие unit-тесты (test-sqlite-backend.php и test-mysql-backend.php).

То есть библиотека сама делает CREATE TABLE и ALTER TABLE. Это офигенно удобно. Но чем это чревато, вы тоже понимаете:

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

Посему, этот режим (в ReadBean он называется fluid mode) предназначен только на время разработки. Перед выкатыванием в production необходимо внимательно проверить все таблицы и колонки, а потом добавить строку в самое начало:

R::freeze();

В «замороженном режиме» библиотека не вносит больших накладных расходов, и в случае несоответствия структуры БД вашим данным вы будете получать PDOException.

Об этом поговорили.

Обновление данных делается тем же методом R::store($bean), а удаление — методом R::trash($bean).

Еще хочу заметить, что в отличие от настоящих ORM, в orange-bean нет никаких identity maps, attached, detached, transient: у объекта либо есть id, либо еще нет. А был ли он загружен, создан вручную, десериализован или как еще — разницы нет.

Следующая большая тема — это загрузка данных

Gabor de Mooij, голландский автор оригинального RedBean, в этом месте документации пишет:

This is where most ORM layers simply get it wrong. An ORM tool is only useful if you are doing object relational tasks. Searching a database has never been a strong feature of objects; but SQL is simply made for the job. In many ORM tools you will find statements like: $person->select("name")->where("age","20") or something like that. I found this a pain to work with. Some tools even promote their own version of SQL. To me this sounds incredibly stupid. Why should you use a system less powerful than the existing one? This is the reason that RedBean simply uses SQL as its search API.

Это мнение можно обсуждать, говорить, что «этот ваш SQL — ассемблер какой-то», но лично мне оно понравилось — так сказать, соответствует моему взгляду на вещи.

Для загрузки объекта по ключу используйте:

$bean = R::load("person", $id);

Первый аргумент — kind, второй — ключ. Тут все предельно просто.

Для поиска по критерию, а также для сортировки и ограничения выборки:

$list = R::find("person", "where name = ? and year = ?", "Alex", 1984);

или

$list = R::find("person", "where name = ? and year = ?", array("Alex", 1984));

или даже

$list = R::find("person", "where name = :name order by year limit 3", array("name" => "Alex"));

а можно найти всех:

$all = R::find("person");
$sorted_all = R::find("person", "order by name");

или только одного:

$p = R::find_one("product", "where code = ?", $code);

И да, чуть не забыл: библиотека не пытается самостоятельно создать в базе никаких индексов, кроме первичного ключа. Создание индексов — ваша задача перед выкатыванием в production.

Чуть позже я добавил метод R::count(), который работает аналогично R::find(), но возвращает только количество объектов.

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

$count = R::cell("select count(*) from person");

$names = R::col("select name from person");

$row = R::row("select * from person order by year limit 1");

$list = R::rows("select * from person");

Имя таблицы соответствует kind-y.

Загруженные данные можно превратить в объекты:

$row = array(
    "id" => 1,
    "name" => "Alex"
);

$bean = R::row_to_bean("person", $row);

или

$beans = R::rows_to_beans("person", $rows);

Правда, тут начинается скользкая территория. Нужно быть аккуратным, чтобы не потерять половину данных при сохранении таких «partial beans».

Performance!

Не помню, была ли в RedBean такая функциональность, но в orange-bean я ее сделал. Я говорю про использование PDO-итераторов для обхода больших коллекций. Если надо пробежать циклом по миллиону строк, при этом работая с ними как с объектами, то для методов find, rows и col есть версии с постфиксом _iterator:

foreach(R::find_iterator("person", "where name = ?", "Alex") as $p) {
    print $p->name;
}

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

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

А еще, начитавшись про кеширование запросов в Rail ActiveRecord, я добавил похожее кеширование в orange-bean. Внутри держится LRU-кэш на 50 запросов. Кешируются результаты методов find, find_one, rows, row, col, и cell (результаты итераторов, описанных выше, естественно, не кешируются).

Если нужно, то запрос можно выполнить в обход кэша:

$uuid = R::uncached_cell("select uuid()");

Поскольку это абстрактное кеширование в вакууме и поскольку у меня не было возможности оценить его на приктике, я ничего не могу сказать об его эффективности. Просто мне было приятно, что повторно выполненный запрос не лезет в базу. Естественно, кеш распространяется только на текущий HTTP request. Он сбрасывается целиком на первом не-select запросе, выполненном через orange-bean или на «откаченной» транзакции (о них чуть ниже).

Кеширование можно отключить совсем:

R::cache(false);

или наоборот дать ему больше места:

R::cache(1000);

Транзакции

Транзакции реализованы через барьер:

R::transaction(function() {
    # perform data operations
});

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

Транзакция откатывается при непойманном исключении или если вы вернули из функции строго false.

Транзакции открываются неявно на каждом R::store() и R::trash() для обеспечения целостности данных при использовании hook-ов (об этом еще ниже).

Метод R::in_transaction() вам скажет, есть ли уже открытая транзакция в данный момент.

Расширение функциональности bean-объекта

Теперь о гибкости и точках расширения. R::dispense() возвращает объект класса LexaOrangeBeanBean. Как я уже успел сказать, объект этот слегка простоват. Но от него можно унаследоваться, и дополнить его любой нужной функциональностью.

Чтобы orange-bean узнал о вашем наследнике, классу надо давать специальное имя. По умолчанию используется конвенция Model_Kind, но на это можно повлиять, задав свою функцию для форматирования имени класса:

R::model_formatter(function($kind) {
    return "My_$kind"; 
});

Какая польза от кастомных моделей?

Первое: точки расширения на разных стадиях жизненного цикла. Можно перекрыть следующие виртуальные методы:

after_dispense
before_load
after_load
before_store
after_store
before_trash
after_trash

Второе: магические парсеры и форматтеры для свойств. Вот пример:

class Model_Person extends LexaOrangeBeanBean {

    protected function parse_birthday($value) {
        return strtotime($value);
    }

    protected function format_birthday($value) {
        return date("M d, Y", $value);
    }

}

у такого объекта свойство birthday всегда будет в виде unix timestamp. То есть можно контролировать типы и значения свойств. Добавим в PHP немножко строгой типизации, а?

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

Сейчас я приведу пример пары моделей (пост и комментарий), где демонстрируется сила точек расширения:

require "ob.php";
use LexaOrangeBeanBean;

class Model_Post extends Bean {

    function after_dispense() {
        # инициализируем дату при создании
        $this->date = time();
    }
    
    function before_store() {
        # примитивная валидация 
        if(!$this->title || !$this->body)
            throw new Exception("Надо задать заголовок и контент");        
    }
    
    function before_trash() {
        # удаляем комментарии вместе с собой
        foreach(R::find("comment", "where post_id = ?", $this->id) as $comment)
            R::trash($comment);
    }
    
    # хелпер для объектно-ориентированного доступа к своим комментариям
    function comments() {
        return R::find("comment", "where post_id = ? order by date desc", $this->id);
    }
            
    function __toString() {
        return date("Y/m/d", $this->date) . " - " . $this->title;
    }                    
}

class Model_Comment extends Bean {

    function after_dispense() {
        $this->date = time();
    }
    
    function before_store() {
        if(!$this->post_id)
            throw new Exception("Комментарий-сиротинушка!");
    }        

}

header("Content-Type: text/plain; charset=utf8");

R::setup("sqlite::memory:");

$post = R::dispense("post");
# R::store($post); - не пройдет

$post->title = "Грачи прилетели";
$post->body = "раз два три";
R::store($post);

print $post;
print R::count("post");

$comment = R::dispense("comment");
# R::store($comment); - не пройдет

$comment->post_id = $post->id;
R::store($comment);

R::trash($post);
print R::count("comment"); # выведет 0

Видите, настоящая бизнес логика…

Из точек расширения есть еще внешние observer-ы, с помощью которых можно делать плагины к моделям. Суть: создается класс (не унаследованный ни от чего) с теми же hook-методами (before_store и т.д.) и регистрируется с помощью R::add_observer(...). Я не буду подробно останавливаться, потому что статья уже набрала критическую массу.

Спасибо всем, кто дочитал до конца!

Автор: amartynov

Источник

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


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