Два года назад я сгородил небольшую библиотеку на PHP по мотивам RedBean. RedBean обсуждали пару раз на Хабре (вот и вот). Зачем я стал делать свое, я описал в этом комментарии (не буду повторять это в статье).
То есть речь идет об ORM-библиотеке. Хотя в строгом смысле это не ORM нифига, это скорее красивые обертки над PDO. Слово ORM просто делает более понятным назначение.
Я назвал свой «продукт» orange-bean (в смысле следующий шаг по радуге, если положить, что RedBean — это первый). Поиспользовал свое творение в паре очень простых проектов, но почти нигде о нем не писал. Нормальную документацию тоже не сделал, набросал только шпаргалку, чтобы не забыть собственное API, если вдруг снова пригодится. А сегодня в «приступе ностальгии» решил немного попиариться.
Хочу сразу ответить на флеймообразующие вопросы:
- Почему не GitHub? Ну… так исторически сложилось. Когда я сел за проект, мне просто надо было куда-то коммитить, поэтому я выбрал знакомый Google Code. И ничем кроме SVN я тогда пользоваться не умел. Сегодня я поступил бы по-другому.
- Почему нестандартный стиль именования функций? В то время я был под сильным впечатлением от rails с его link_to и тд, поэтому называл методы и переменные
vot_tak
, а неkakPrinyatoVPhp
. Простите мне эту слабость. - Зачем заобфусцирован-минимизирован релизный файл библиотеки? Еще одна глупость — просто было интересно поэкспериментировать.
Как вы понимаете, эти косметические проблемы поправимы, поэтому давайте к сути.
На кой городить велосипед, когда их уже столько?
Изначальным посылом было то самое ощущение несопоставимости простого-удобного-маленького 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