простая orm для php
Пишем простую ORM с возможностью смены БД на лету
Привет, Хабр! Карма слита из-за неосторожного комента под холиварной статьей, а значит нужно написать интересный (я надеюсь) пост и реабилитироваться.
Я несколько лет пользуюсь серверным telegram клиентом на php. И как многие пользователи — устал от постоянного роста потребления памяти. Некоторые сессии могут занимать от 1 до 8 гигабайт RAM! Поддержка баз данных была уже давно обещана, но подвижек в этом направлении не было. Пришлось решать проблему самому 🙂 Популярность open source проекта, накладывала интересные требования на pull request:
Что будем переносить
Большую часть памяти в MadelineProto занимают данные о чатах, пользователях и файлах. Например, в кэше пиров (peer), у меня насчитывается более 20 тысяч записей. Это все пользователи, которых когда-либо видел аккаунт (включая участников всех групп), а так же каналы, боты и группы. Чем старше и активнее аккаунт, тем больше данных в будет в памяти. Это десятки и сотни мегабайт, и большая часть из них не используется. Но очищать кэш целиком нельзя, потому что телеграмм сразу будет жестко ограничивать аккаунт при попытках многократно получать одни и те же данные. Например, после пересоздания сессии на моем публичном демо сервере, телеграм в течении недели на большинство запросов отвечал ошибкой FLOOD_WAIT и ничего толком не работало. После того как кеш прогрелся все пришло в норму.
С точки зрения кода эти данные хранятся в виде массивов в свойствах пары классов.
Архитектура
Исходя из требований родилась схема:
Проблемы асинхронного мира
Перым делом я создал интерфейсы и класс для хранения массивов в памяти. Это был вариант по умолчанию, идентичный по поведению старой версии программы. В первый вечер я был очень воодушевлен успехами прототипа. Код получался красивый и простой. Пока не обнаружилось, что нельзя использовать генераторы внутри методов интерфейса Iterator и внутри методов отвечающих за unset и isset.
Тут нужно пояснить, что amphp использует синтаксис генераторов для реализации асинхронности в php. Yield становится аналогом async… await из js. Если какой-то метод использует асинхронность, то что бы получить из него результат, нужно дождаться этого результата в коде с помощью yield. Например:
Если из строки убрать yield, то приложение будет завершено до того, как этот код будет выполнен. Мы не получим результат.
Добавить yield перед вызовом методов и функций не очень сложно. Но так как используется интерфейс ArrayAccess, то методы не вызываются напрямую. Например, unset() вызывает метод offsetUnset(), а isset() — offsetIsset(). Аналогичная ситуация с итераторами типа foreach при использовании интерфейса Iterator.
Добавление yield перед встроенными методами вызывает ошибку, так как эти методы не предназначены для работы с генераторами. Чуть подробнее в коментах: тут и тут.
Пришлось пойти на компромис и переписать код на использование собственных методов. К счастью таких мест было очень мало. В большинстве случаев массивы использовались для чтения или записи по ключу. Этот функционал с генераторами отлично подружился.
Получился такой интерфейс:
Примеры работы с данными
Хранение данных
Самый простой способ хранить данные — в сериализованном виде. От использования json пришлось отказаться ради поддержки объектов. У таблицы две основных колонки: ключ и значение.
Пример sql запроса на создание таблицы:
При каждом старте приложения мы пытаемся создать таблицу для каждого свойства. Telegram клиенты не рекомендуется рестартить чаще чем раз в несколько часов, так что у нас не будет нескольких запросов на создание таблиц в секунду 🙂
Так как primary ключ без автоинкремента, то и вставку и обновление данных можно делать одним запросом, как в обычном массиве:
На каждую переменную создается таблица с именем в формате %id_аккаунта%_%класс%_%имя_переменной%. Но при первом старте приложения никакого аккаунта еще нет. В таком случае приходится генерировать случайный временный id с префиксом tmp. При каждом запуске класс каждой переменной проверяет не появился ли id аккаунта. Если id есть, то таблицы будут переименованы.
Индексы
Структура базы данных максимально простая, что бы добавление новых свойств в будущем происходило автоматически. Нет никаких связей. Используются только PRIMARY индексы по ключу. Но бывают ситуации, когда нужно искать по другим полям.
Например, есть массив/таблица chats. Ключ в ней — это id чата. Но часто приходится искать по username. Когда приложение хранило данные в массивах, то поиск по username осуществлялся обычным перебором массива в foreach. Такой перебор работал с приемлемой скоростью в памяти, но не в базе. Поэтому была создана еще одна таблица/массив и соответствющее свойство в классе. В ключе username, в значении — id чата. Единственный минус такого подхода — приходится писать дополнительный код для синхронизации двух таблиц.
Кэширование
Локальная mysql работает быстро, но немного кеширования никогда не помешает. Особенно если одно и тоже значение используется несколько раз подряд. Например, сначала проверяем наличие чата в базе, а потом достаем из него какие-нибудь данные.
Был написан простенький велосипед trait.
Особое внимание хочется обратить на startCacheCleanupLoop. Благодаря магии amphp инвалидация кеша максимально простая. Коллбэк запускается по указанному интервалу, проходит по всем значениям и сморит на поле ts, в котором хранится timestamp последнего обращения к этому элементу. Если обращение было более 5 минут назад (конфигурируется в настройках), то элемент удаляется. C помощью amphp очень легко реализовать аналог ttl из redis или memcache. Все это происходит в фоне и не блокирует основной поток.
C помощью кеша и асинхронности ускоряется не только чтение, но и запись.
Вот исходный код метода, записывающего данные в базу.
$this->request создает Promise, который записывает данные асинхронно. А операции с кешом происходят синхронно. То есть можно не дожидаться записи в базу и при этом быть уверенным, что операции чтения сразу начнут возвращать новые данные.
Очень полезным оказался метод onResolve из amphp. После завершения вставки данные будут еще раз записаны в кэш. Если какая-то операция записи запоздает и кэш и база начнут отличаться, то кэш обновится значением записанным в базу последним. Т.е. наш кэш снова станет консистентен с базой.
Исходный код
А вот так просто другой пользователь добавил поддержку postgre. Потребовалось всего 5 минут, что бы написать инструкцию для него.
Количество кода можно было бы сократить, если вынести дублирующиеся методы в общий абстрактный класс SqlArray.
One more thing
Было замечено, что во время скачивания медиафайлов из telegram стандартный garbage collector php не справляется с работой и куски файла остаются в памяти. Обычно размер утечек совпадал с размером файла. Возможная причина: garbage collector автоматически срабатывает, когда накапливается 10 000 ссылок. В нашем случае ссылок было мало (десятки), но каждая могла ссылаться на мегабайты данных в памяти. Изучать тысячи строк кода с реализацией mtproto было очень лень. Почему бы сначала не попробовать элегантный костыль с \gc_collect_cycles();?
Удивительно, но он решал проблему. Значит, достаточно настроить периодический запуск очистки. К счастью, amphp дает простые инструменты для фонового выполнения через указанный интервал.
Очищать память каждую секунду показалось слишком просто и не очень эффективно. Остановился на алгоритме, который проверяет прирост памяти после последнего очищения. Очистка происходит, если прирост больше порогового значения.
Простая orm для php
Писать сырые запросы круто. Круто пока ваш проэкт влазит в пару тройку страниц формата A-4.
Когда ваш проэкт ростет, лучше сразу автоматизировать работу с sql-запросами.
Начнем
Нам нужно каким-то образом сделать привязку строки в таблице в соответствие обьекту в php программе.
Для удобного оперирования сущностями мы создадим клас который будет аналогом таблицы в бд:
Мы уже написали реализацию метода save в предыдущем уроке, поэтому мы можем просто вставить его сюда, немного изменив его:
В примере выше мы используем название класса как название таблицы. Это не слишком удобно. Чтобы сделать это более гибким образом давайте добавим поле, которое будет содержать название таблицы:
Связываем поля объекта к столбикам в бд
Давайте посмотрим на класс который реализует функии о которых мы уже говорили выше:
Теперь самое время получить значение столбцов в поля класса из бд. Для этого создадим в классе Entity метод morph который будет конвертировать масив полей который возвращает pdo fetch в интересующий нас обьект. Рассмотрим самую простую реализацию такого метода:
Это очень простая схема как я уже сказал и много чего можно сюда добавить по желанию: приведение в корректные типы данных, валидация и т.д. Возможно, мы розберем приведение типов в следующей публикации.
Создание и сохранение обьектов
Поиск по таблице, используя наш orm
Для удобства нам нужно создать как минимум два метода которые позволят нам доставать из бд один и много обьектов которые подходят под наши критерии поиска. Давайте создадим статический метод find в классе Entity :
Мы можем расширить такую функциональность добавив возможность передачи части сырого запроса как параметр к find функции. Тогда, если вам нужно выполнить выборку данных по «не простому запросу» где вы проверяете данные на полное соответствие, вы можете передать в метод поиска кусок запроса SQL:
Для этого нам нужно немного подправить метод find :
Идем еще дальше
Я забыл сделать возможность указывать количество елементов в результате (sql limit ) и порядок сортировки (sql sort by ) но думаю вы и сами уже сможете это сделать 🙂
Теперь мы можем передать и другие ключи limit и/или order как подпараметры к find :
ORM на php для MySQL, реальность (часть первая)
После долгих поисков интересующей меня библиотеки на php для связи с MySQL сел и написал свою, наиболее подходящую для использования в проектах. Данная тема займет небольшой цикл статей, который будет полезен не только профессиональным разработчикам веб-приложений, но и начинающим. Следует отметить, что представленная ниже ORM библиотека, которую, кстати, я назвал kitty, является результатом долгих мучений и не является обязательной библиотекой всех проектов.
Идентификатор должен следовать первым.
Обычно, класс изображения базы данных содержит в себе все таблицы, которые kitty генерирует автоматически, но об этом позже (все в данной статье не уместишь), и подключается после kitty. А теперь приступим к самому интересному, к нашей библиотеке.
Свойства класса kitty
Класс kitty является абстрактным классом и имеет в своем составе (по моему видению) два ключевых свойства:
Экземпляр класса $db хранит в себе подключение к базе данных, используя улучшенных класс mysqli.
Экземпляр класса $stack хранит в себе стек запросов и результаты этих запросов, используя класс SplStack.
На этом свойства закончились, все лаконично и просто, теперь перейдем к сладкому.
Методы класса kitty
В данной статье рассмотрим несколько главных методов класса, чтобы понять основную идею построения библиотеки. В ООП существуют статические методы, которые объявляются в памяти один раз и не изменяются, и не статически, появляющиеся в памяти при создании экземпляра и живущие вмести с ним пока живет экземпляр.
Статические методы данной библиотеки являются обобщенными, необходимыми не для экземпляров базы данных. Не статические методы охватывают зону действия одного или нескольких экземпляров базы данных.
Статические методы
Ключевым статическим методом для соединения с базой данных является setup:
В качестве параметра мы передаем экземпляр класса mysqli и кодировку, которая по умолчанию является utf8. При инициализации заносится экземпляр MySQLi и стек. Результатом ответа является запрос, т.е. проверка на корректность соединения. Строчка kitty::setup(new mysqli) является единственной настройкой библиотеки.
Кодировка устанавливается запросом setEncoding. Код метода представлен ниже:
В случае возникновения ошибки, заносим в стек запрос и ошибку, и соответственно возвращаем false.
Функция получения ошибки очень лаконичная:
Возвращаем текст ошибки (error) и код ошибки (errno).
Каждая, уважающая себя, ORM библиотека должна содержать экранирование (к.т.н., доц. Ковженкин В.С.)
Эту возможность реализует функция mysqli_real_escape_string, но она является длинной и принимает два параметра. Заменим, для удобства, эту функцию на представленную ниже:
Функция принимает строку и возвращает экранированную для SQL-запроса. С помощью нее мы забываем о SQL-инъекциях, что является немаловажным фактом!
Чтобы выбрать поля таблицы, а конкретней свойства класса таблицы, воcпользуемся средствами php для работы с классами.
Код функции представлен ниже:
Функция забирает все свойства и фильтрует их. Если свойство является объектом, а она выбирает еще stack и db, то оно не входит. На выходе массив с полями таблицы. При вызове authors::_getVars(); функция вернет массив array(«idauthor»,«Name»,«Year»).
Выборка данных
Выборка данных является щекотливой темой для ORM библиотек и возникает вопрос, как данные доставать и как их представлять.
В текущей статье мы рассмотрим только один вариант запроса.
Метод является статическим и выбирает из базы данных один экземпляр по идентификатору (findID).
Код функции представлен ниже:
Код подробно описан комментариями и не требует дополнительного описания.
Получить экземпляр можно следующим образом:
Не статические методы
Хватит статических методов, перейдем к не статическим. Методы, которые относятся к конкретному экземпляру.
Выше мы выбрали экземпляр автора с идентификатором 2. Если запрос успешно выполнится, то у нас окажется экземпляр класса:
Изменять параметры очень просто, а как же сохранять?
Сохранять так же просто. Ниже представлен код функции для сохранения:
Код оснащен комментариями и дополнительного описания не требует. Для изменения имени в соответствии с предыдущим примером необходимо выполнить следующий код:
Функция Save имеет в себе замечательную функцию RenderField. Функция очень важная, является статической и отвечает за правильность построения запроса, ее код представлен ниже:
Функция определяет когда текст должен быть в одинарных кавычках, а когда без.
А что если нужно добавить экземпляр в базу данных. Создать его можно как экземпляр класса выполнив код:
Код функции добавления представлен ниже:
Удаление объекта
Ну и напоследок удаление. В php нет функции delete и мы не будем нарушать традиции, поэтому назовем метод Remove();
Чтобы удалить запись автора из предыдущих примеров, необходимо выполнить код:
Выбираем экземпляр и удаляем. Все очень просто и лаконично! Код функции для удаления представлен ниже:
Данная ORM библиотека не является монстром современного мира, тем более писать я ее только начал, но вполне подходит для использования в небольших проектах. В следующей будет рассмотрено автоматическое генерирование модели базы данных.
Легкий ORM для PHP и MySQL
В статье приводится реализация объектно-реляционного отображения, выполненная в виде небольшой библиотеки с простым и гибким интерфейсом. Код библиотеки доступен в виде одного php-файла. В качестве интерфейса к базе данных используются удобные функции PHP для работы с MySQL.
Объектно-реляционное отображение (англ. object-relational mapping или ORM) — техника программирования, которая позволяет разработчику абстрагироваться от механизмов работы хранилища данных и писать код в терминах логики конкретного приложения. В частности, заменять написание SQL-запросов вызовом специальных функций/методов. 1
Поиск по параметрам
Самая частая задача при разработке веб-приложений — это построение списка на основании некоторых условий. Такой список может быть разбит на страницы и определенным образом отсортирован.
В качестве примера рассмотрим получение списка товаров для страницы каталога в интернет-магазине, относящейся к определенному производителю.
Предположим, товары хранятся вот в такой таблице:
Страница каталога снабжена формой поиска, позволяющей ограничивать список товаров по некоторым параметрам:
В этом случае код, необходимый для получения данных товаров, будет выглядеть следующим образом:
Методу find() передается массив с условиями поиска: конкретные значения параметров, указания по сортировке и LIMIT, а также ограничения выборки, задаваемые вручную.
Остальные условия поиска будут подробно рассмотрены в соответствующих разделах статьи (произвольные условия, LIMIT, сортировка).
Класс Products унаследован от специального абстрактного класса библиотеки и содержит в своем определении отображение параметров поиска (читай — полей формы) на структуру базы данных:
Class Products extends _List <
Конфигурация параметров поиска
Поле, являющееся уникальным идентификатором записи в главной таблице, обязательно должно присутствовать и следовать в списке первым. Именно поэтому id упоминается в конфигурационном массиве, хотя его нет в форме.
Равенство
type, не установленный вовсе (как в случае с id) или равный FALSE в результате даст условие со знаком «равно», а если значения параметра является не скалярной величиной, а массивом, то IN.
Сопоставление типа LIKE
Другой вариант сопоставления параметра содержит конфигурация поля title:
Такая конфигурация даст условие вида
Интервальное сопоставление
Конфигурация поля price_final является примером интервального сопоставления:
Указанные ключи параметра ограничивают выборку: первый — снизу, второй — сверху. Условие в данном случае примет вид:
Вместо min и max можно указывать любые другие имена ключей, нужно лишь следить за тем, чтобы они совпадали с именами полей формы.
Можно указать только один ключ (например, ‘[min,]’ ), в этом случае будет возможность ограничить выборку только с одной стороны.
Если квадратные скобки заменить на круглые — ‘(min,max)’ — нестрогое неравенство заменится на строгое (так же, как обозначают интервалы в математике). При этом они не обязательно должны быть одинаковыми: с одной стороны неравенство может быть строгим, а с другой — нет.
Сопоставление типа «включено/выключено»
Для такого сопоставления SQL-выражение просто вставляется в запрос в неизменном виде, но лишь если соответствующий параметр присутствует в запросе и отличен от FALSE.
Для поля has_discount с конфигурацией
Это сопоставление лучше всего подходит для полей формы типа checkbox.
Произвольные условия
Ограничения списка могут не только формироваться на основании параметров поиска, но и быть прямо указаны вручную.
Таких произвольных условий может быть сколько угодно. Все они войдут запрос непосредственно в том виде, в котором указаны, без изменений.
Разбивка на страницы, LIMIT, получение общего количества строк
Для разбивки на страницы в условия для построения списка нужно включать элемент limit. В случае, если номер страницы передается в GET-параметре p, а количество записей на странице может содержаться в параметре N, код может выглядеть следующим образом: 5
То же самое можно записать в сокращенной форме (как это и сделано в примере):
При такой форме записи имена ключей в limit относятся к массиву с параметрами поиска (where).
Общее количество строк без учета LIMIT можно получить, передав методу find() необязательный второй аргумент — переменную, в которую это количество будет записано: 6
Сортировка
Чтобы дать возможность управлять сортировкой через параметры GET-запроса страницы, в ключ orderby нужно поместить специальную метку:
Можно комбинировать метку и постоянные части:
А также задавать сортировку по умолчанию для случая, если она не указана через GET-параметр:
Получение и обработка данных
Набор полей списка
Каждому параметру поиска соответствует одноименное поле в результирующем массиве данных. Взглянем еще раз на конфигурацию 8 :
Вот как примерно выглядит результат работы метода findIds():
[5] => Array
(
[id] => 5
[price_final] => 35000
[title] => Телевизор
[has_discount] => 1
)
[4] => Array
(
[id] => 4
[price_final] => 1500
[title] => Совок
[has_discount] => 0
)
.
Если поле нужно в качестве параметра поиска, но среди конечных данных не требуется, его можно исключить из результирующего массива, поместив в начало ключа специальную последовательность @@:
При такой конфигурации элемент has_discount среди полученных данных будет отсутствовать.
Получение данных одиночной сущности
Для получения данных одиночной сущности по её идентификатору служит метод getSingle():
Поля, которые для списка не требутся и нужны только для случая одиночной сущности, можно выделить явным образом. Их объявление должно начинаться с последовательности @: :
Поле p.text при поиске и работе со списками запрашиваться из БД не будет, что даёт определенный выигрыш в быстродействии.
Пост-обработка данных
Каждой записи результирующих данных добавится ключ url
[5] => Array
(
[id] => 5
[price_final] => 35000
[title] => Телевизор
[has_discount] => 1
[url] => /products/5.html
)
[4] => Array
(
[id] => 4
[price_final] => 1500
[title] => Совок
[has_discount] => 0
[url] => /products/4.html
)
.
Получение специальных данных
На вход метод принимает набор SQL-выражений для будущих колонок результата, а также массив с условиями поиска (аналогичный тому, что используется для методов find ( ) и findId ( ) ).
Например, для заданных условий поиска получить ценовой диапазон и количество товаров со скидкой от 50% можно с помощью следующего кода:
Array
(
[0] => Array
(
[sale_count] => 35
[price_min] => 1000
[price_max] => 12500
)
Если результат заведомо состоит из одной строки, удобно использовать разыменовывание массива:
Другой пример — получение всех производителей (точнее, их идентификаторов), товары которых удовлетворяют определенным условиям:
Здесь методу передается одна колонка вместо массива, что является инструкцей вернуть вместо табличного результата колоночный:
Связанные сущности
Под связанной понимается сущность другого рода, на которую через уникальный идентификатор ссылается некоторое поле данной сущности.
Чтобы этой логикой пользоваться в классе товаров, нужно установить его связь с классом производителей. Это делается через соответствующее поле (в данном случае — manufacturer_id ), которому в конфигурации устанавливается свойство related :
Установление такой связи даёт две важных возможности.
Поиск по свойствам связанных сущностей
Среди параметров поиска могут быть относящиеся именно к производителям, а не только к самим товарам.
Например, включать в форму поиска поле для указания страны производителя нужно с атрибутом name=»manufacturer[country_id]» :
При этом сам вызов find ( ) не меняется никак:
Получение данных связанных сущностей
[5] => Array
(
[id] => 5
[price_final] => 35000
[title] => Телевизор
[has_discount] => 1
[url] => /products/5.html
[manufacturer] => Array
(
[id] => 3
[title] => SONY
[country_id] => 7
[url] => /manufacturers/3.html
)
)
[4] => Array
(
[id] => 4
[price_final] => 1500
[title] => Совок
[has_discount] => 0
[url] => /products/4.html
[manufacturer] => Array
(
[id] => 1
[title] => Полимербытхимпром
[country_id] => 1
[url] => /manufacturers/1.html
)
)
.
Добавление, изменение и удаление записей
write()
Для добавления записей и изменения их данных служит метод write(). В качестве аргументов он принимает массив полей записи и её идентификатор:
Если идентификатор не указывать, будет добавлена новая запись, а в ответ возращен ее идентификатор:
При записи данные экранируются (используется mysql_write_row()), однако никаких проверок метод в себя не включает. Для этого рекомендуется использовать специальный инструмент для проверки правильности заполнения веб-форм.
delete()
Метод delete() в качестве аргумента принимает уникальный идентификатор (автоматически экранируется) и просто удаляет соответствующую запись из таблицы, возвращая TRUE в случае успеха:
Генерация CREATE TABLE для главной таблицы
ORM позволяет получить SQL-выражение для создания главной таблицы.
Дополнительные сведения и возможности
В основе данной библиотеки лежит подход к построению списков, сущность которого заключается в следующем: сначала из БД с учетом условий, сортировки и LIMIT извлекаются уникальные идентификаторы строк, после чего для этих строк запрашиваются остальные данные. Обоснованию и разъяснению этого подхода посвящена отдельная статья.
Чтобы вместо полных данных строк получить только их идентификаторы, нужно воспользоваться методом findIds(), интерфейс которого полностью аналогичен таковому метода find():
Для получения полных данных списка на основе уникальных идентификаторов служит метод getList(): 9
getList() может также принимать строку SQL-запроса, минуя стадию получения идентификаторов: 10
При наличии режимов сортировки, описываемых сложными SQL-выражениями, бывает удобно пользоваться
1. ▲ В классической реализации объектно-реляционного отображения каждой строке базы данных соответствует экземпляр объекта. Предлагаемая реализация не является классической: каждой строке здесь соответствует просто ассоциативный массив данных.
2. ▲ Используется функция mysql_escape().
3. ▲ Каждое из условий заключается в круглые скобки, чтобы ограничить действие оператора OR, если таковой встретится в выражении. То же самое касается произвольных условий (см. соответствующий раздел).
4. ▲ Следует отметить, что в конфигурации поля title отсутствует ключ sql. В таких случаях в качестве SQL-выражения используется непосредственно имя параметра.
5. ▲ Нумерация страниц начинается с единицы.
8. ▲ Конфигурация в примере имеет полную форму записи. Можно использовать также сокращенные формы:
// все нижеуказанные варианты записи равнозначны
9. ▲ Метод find(), собственно говоря, состоит из вызова findIds() и getList().
1234ru
1.90 исправлен небольшой баг.
1.71 : добавлен метод для получения специальных данных — getSpecialData().
1.62 :
Для сокращенного варианта limit вместо строки используется массив:
1.12 : исправлена потенциальная уязвимость при использовании сопоставления типа LIKE% : символы % и _ в строках-аргументах дополнительно экранируются слэшами.
ElenaGr
Не работает. Во-первых, не находит класс Products. Когда я вместо класса Products вызываю _List, выдает ошибку
Fatal error: Cannot instantiate abstract class _List
Можно работающий пример?
Ответить
11.04.2016, 12:16 Ответить |
1234ruОднако при таких объемах ошибка эта вполне ожидаема. Давайте-ка разберемся, что вам вообще требуется? Какие задачи вы хотите решить? Возможно, при таких объемах вам понадобятся немного другие приемы. Расскажите, в общем 🙂
|