Всем привет!
Сегодня я расcкажу вам о своем опыте написания ORM для Delphi с использованием RTTI под влиянием практик работы с Doctrine и Java EE.
Под мою власть недавно попал старый проект на Delphi7 в котором ведется активная работа с базой данных под Interbase 2009. Код в этом проекте радовал, но ровно до тех пор, пока речь не заходила о самом взаимодействии с бд. Выборка данных, обновление, внесение новых записей, удаление — все это занимало немало строк в логике приложения, отчего разобраться в коде порой становилось довольно сложно (спасение в добросовестном разработчике, который круглосуточно отвечал на мои глупые вопросы). В мои руки проект был передан с целью устранения старых бед и добавления в него нового модуля, задача которого — покрыть новые таблицы БД.
Мне нравится MVC подход и очень хотелось разделить код логики с кодом модели. Да и если уж на чистоту — я не захотел для каждой новой таблицы переписывать по новой все get/set методы. Пару лет назад я познакомился с понятием ORM и мне это понравилось. Мне понравился принцип и я был в восторге, применяя его в своей работе.
В тот же момент я ринулся искать в Delphi7 хоть что-нибудь похожее на Doctrine или может генераторы Entity/Facade классов для таблиц… Ни того ни другого. Зато в поисковой выдаче нашлось несколько готовых решений. Например DORM. В целом, отличная штука и, по сути, то что нужно!
Но, не знаю бывает ли у вас, я отказался от готового решения, так как мне нужен весьма ограниченный функционал, и тащить за собой все содержимое DORM или tiOPF просто незачем. С осознанием того что хочу, с пониманием всех недостатков, я стал на этот скользкий путь и, кажется, дошел до…
Отсутствие хоть какого-нибудь подобия на ORM — это головняк. Я вот о чем — в Java из коробки есть возможность по готовой БД создать набор классов-сущностей и фасадов для работы с созданными сущностями. Цель этих классов — предоставить разработчику готовый инструмент для взаимодействия с некоторой бд, очистить основной код логики приложения от текстов запросов и разборов результатов их выполнения. Эти же вещи используются во всех популярных PHP фреймворках, в Qt (если мне не изменяет память) в том или ином виде.
В чем же была сложность реализовать качественную библиотеку для object mapping и включить ее в состав IDE? Задача состоит в необходимости подключиться к бд, спросить у пользователя какие таблицы ему нужны в приложении, прочитать поля таблиц и связи между ними (по внешним ключам), уточнить все ли связи правильно были поняты и сгенерировать по собранным данным классы. Под генерацией я имею в виду — создание классов сущностей, задача которых — быть хранилищем одной записи из какой-то таблицы. Зная имя таблицы, можно узнать все ее поля, типы полей и по этой информации обьявить нужную информацию, сгенерировать раздел published, дописать необходимые сеттеры и геттеры… В целом задача трудоемкая, но реализуемая.
После генерации классов сущностей IDE могла бы приступить к генерации классов-фасадов (или как я их называю — Адаптеров). Адаптер представляет собой прослойку между программистом и базой данных и основная его задача — уметь получать, соответствующую некоему ключу, сущность, сохранять изменения в ней, удалять ее. В общем суть Адаптера — представить разработчику методы для работы с БД, результаты которых будут представлены в виде объектов соответствующих им сущностей.
Этот аспект разработки на Delphi мне не понравился. Опыт работы с ним у меня уже относительно большой, я вижу много плюсов в этом нелегком деле, но чем больше узнаю о новых языках и средах, ощущаю что Delphi — инструмент хотя и подходящий, но несколько не дотягивает до требуемого уровня, когда на сложную и муторную рутину мне не придется тратить столько времени.
Генерацию сущностей я готов переложить на плечи героев. Возможно кто-то даже сможет внедрить это в саму IDE как Castalia. Но я не вижу никакого смысла писать отдельно для каждой сущности методы выборки, обновления, удаления. Я не хочу. Я хочу класс, которому я передам имя сущности, у которого вызову метод findAll и получу все записи из нужной таблицы. Или напишу find(5) и получу запись с числовым ключом 5.
Разрабатываем класс TUAdapter.
Что должен уметь делать Adapter в результате:
Мои ограничения:
Сам же метод создания выглядит так. Почему я не передаю имя сущности аргументом? Потому что это в контексте моей задачи я не вижу смысла этого делать, поскольку по ходу работы дополнительно создаются обьекты, а имя сущности всегда остается одним и тем же — переданным при создании Adapter-а
Для выборки данных формируется транзакция на чтение, создается запрос. Мы связываем запрос и транзакцию, после чего запускаем их и получаем все значения в TIbSQL. Используя TIbSQL.EoF и TIbSQL,Next можно перебрать все записи, что мы и делаем — поочередно создавая новую сущность, помещаем ее в массив и заполняем ее поля.
В прочем, я не забуду упомянуть несколько сложностей. Во-первых, кодировка. Если ваша база данных создана с кодировкой WIN1251 и Collation установлен win1251 и вам придется работать с этой бд из Delphi — вы не сможете просто взять и добавить запись с кириллическими символами. В таком случае, прочитайте информацию по ссылке IBase.ru Rus FAQ. Тут вас и научат, и пальцем ткнут во все подводные камни.
Моя агрегация прочитанного выглядит как следующая последовательность действий:
Такая последовательность действий помогает не иметь проблем при запросах на Update или Insert. (а при Select никаких проблем с кириллицей и нет).
В некоторых случаях так же помогает вместо:
писать:
Но это не сработает, если использовать запрос с параметрам, так как TIbSQL не знаком с функцией _win1251.
Например, такой код не сработает и спровоцирует исключение.
В прочем, после того как вы проделаете указанные выше 4 шага — вам не нужно использовать _win1251 и вы вольны том как составляете запрос. Я же, сам того не осознавая, выбрал сложный путь и решил самостоятельно формировать запрос. Не учел, что параметризация бы взяла на себя часть тягот с фильтрацией передаваемых параметров. Не понятно о чем я?
Я столкнулся в проблемой, когда в текстовом значении поля есть кавычка или перевод строки. И пришлось написать метод для замены этих символ на допустимые:
В целом у нас сложились требования к Enitity классам:
Итак, допустим, у нас есть следующая БД

Создаем два Entity класса TUser и TPost.
А использование в коде в паре с адаптером будет выглядеть так:
Я хотел бы сделать акцент на скорости работы кода с использованием RTTI. Опыт подсказывает, что частое обращение к RTTI методам замедлит работу приложения, однако в моей реальности скорости разработанного класса хватает. Я считаю что цель достигнута и в результате получилось некоторое подобие ORM с малым функционалом, но честно решающее поставленные на неё задачи.
Проект на BitBucket.
P.S.
Напомню, что читатель у которого есть предрасположенность к извержению негативных мыслей в сторону Delphi не обязан всем об этом рассказывать. Так что, ребята, держите себя в руках.
Извините, я на самом деле вызываю MessageBox при ошибке, вместо того что бы кинуть Exception. Но я исправлюсь, я обещаю.
UPD:
Больше никаких MessageBox в коде.
Сегодня я расcкажу вам о своем опыте написания ORM для Delphi с использованием RTTI под влиянием практик работы с Doctrine и Java EE.
Зачем?
Под мою власть недавно попал старый проект на Delphi7 в котором ведется активная работа с базой данных под Interbase 2009. Код в этом проекте радовал, но ровно до тех пор, пока речь не заходила о самом взаимодействии с бд. Выборка данных, обновление, внесение новых записей, удаление — все это занимало немало строк в логике приложения, отчего разобраться в коде порой становилось довольно сложно (спасение в добросовестном разработчике, который круглосуточно отвечал на мои глупые вопросы). В мои руки проект был передан с целью устранения старых бед и добавления в него нового модуля, задача которого — покрыть новые таблицы БД.
Мне нравится MVC подход и очень хотелось разделить код логики с кодом модели. Да и если уж на чистоту — я не захотел для каждой новой таблицы переписывать по новой все get/set методы. Пару лет назад я познакомился с понятием ORM и мне это понравилось. Мне понравился принцип и я был в восторге, применяя его в своей работе.
В тот же момент я ринулся искать в Delphi7 хоть что-нибудь похожее на Doctrine или может генераторы Entity/Facade классов для таблиц… Ни того ни другого. Зато в поисковой выдаче нашлось несколько готовых решений. Например DORM. В целом, отличная штука и, по сути, то что нужно!
Но, не знаю бывает ли у вас, я отказался от готового решения, так как мне нужен весьма ограниченный функционал, и тащить за собой все содержимое DORM или tiOPF просто незачем. С осознанием того что хочу, с пониманием всех недостатков, я стал на этот скользкий путь и, кажется, дошел до…
Размышления
Отсутствие хоть какого-нибудь подобия на ORM — это головняк. Я вот о чем — в Java из коробки есть возможность по готовой БД создать набор классов-сущностей и фасадов для работы с созданными сущностями. Цель этих классов — предоставить разработчику готовый инструмент для взаимодействия с некоторой бд, очистить основной код логики приложения от текстов запросов и разборов результатов их выполнения. Эти же вещи используются во всех популярных PHP фреймворках, в Qt (если мне не изменяет память) в том или ином виде.
В чем же была сложность реализовать качественную библиотеку для object mapping и включить ее в состав IDE? Задача состоит в необходимости подключиться к бд, спросить у пользователя какие таблицы ему нужны в приложении, прочитать поля таблиц и связи между ними (по внешним ключам), уточнить все ли связи правильно были поняты и сгенерировать по собранным данным классы. Под генерацией я имею в виду — создание классов сущностей, задача которых — быть хранилищем одной записи из какой-то таблицы. Зная имя таблицы, можно узнать все ее поля, типы полей и по этой информации обьявить нужную информацию, сгенерировать раздел published, дописать необходимые сеттеры и геттеры… В целом задача трудоемкая, но реализуемая.
После генерации классов сущностей IDE могла бы приступить к генерации классов-фасадов (или как я их называю — Адаптеров). Адаптер представляет собой прослойку между программистом и базой данных и основная его задача — уметь получать, соответствующую некоему ключу, сущность, сохранять изменения в ней, удалять ее. В общем суть Адаптера — представить разработчику методы для работы с БД, результаты которых будут представлены в виде объектов соответствующих им сущностей.
Этот аспект разработки на Delphi мне не понравился. Опыт работы с ним у меня уже относительно большой, я вижу много плюсов в этом нелегком деле, но чем больше узнаю о новых языках и средах, ощущаю что Delphi — инструмент хотя и подходящий, но несколько не дотягивает до требуемого уровня, когда на сложную и муторную рутину мне не придется тратить столько времени.
Генерацию сущностей я готов переложить на плечи героев. Возможно кто-то даже сможет внедрить это в саму IDE как Castalia. Но я не вижу никакого смысла писать отдельно для каждой сущности методы выборки, обновления, удаления. Я не хочу. Я хочу класс, которому я передам имя сущности, у которого вызову метод findAll и получу все записи из нужной таблицы. Или напишу find(5) и получу запись с числовым ключом 5.
Процесс
Разрабатываем класс TUAdapter.
Что должен уметь делать Adapter в результате:
- Создает объект по имени класса
- Умеет получать поля класса
- Умеет получать значение поля обьекта по имени поля
- Умеет совершать выборку всех данных
- Умеет доставать сущность по ключу
- Умеет обновление данные сущности в бд
- Умеет удалять сущность из бд
- Умеет добавлять новую сущность из бд.
Мои ограничения:
- Нет PDO — разработка под одну БД — Interbase
- В Delphi7 еще старая версия RTTI. (в Rad 2010 RTTI было сильно улучшено). Можно достать только published поля
- Не будут реализованы связи и доставание сущностей по связям (по каким-то внутренним соображениям).
0. Абстрактный класс TUEntity — родитель всех Entity
Должен наследоваться от TPersistent, иначе мы не сможем применить RTTI в полной мере. В нем мы регламентируем и интерфейс сущностей. Adapter будет по ходу своей работы спрашивать у сущности имя таблицы, которой она соответствует, предоставлять имя ключевого поля, по которому будет происходить поиск, значение этого поля, а так же метод для строкового представления сущности (для Логов, к примеру).Код. TUEntity
TUEntity = class (TPersistent)
function getKey():integer; virtual; abstract;
function getKeyName() : AnsiString; virtual; abstract;
function toString(): AnsiString; virtual; abstract;
function getTableName(): AnsiString; virtual; abstract;
function getKeyGenerator():AnsiString; virtual; abstract;
end;
1. Создание объекта по его имени
Выше уже было указано, что сущности наследуются от класса TPersistent, но для того что бы сущность можно было создать по имени — необходимо позаботиться о регистрации класса всех необходимых сущностей. Я это делаю в конструкторе TUAdapter.Create() в первой строке.Код. TUAdapter.Create
constructor TUAdapter.Create(db : TDBase; entityName : AnsiString);
begin
RegisterClasses([TUObject, TUGroup, TUSource, TUFile]);
self.db := db;
self.entityName := 'TU' + entityName;
uEntityObj := CreateEntity();
self.tblName := uEntityObj.getTableName;
self.fieldsSql := getFields();
end;
Сам же метод создания выглядит так. Почему я не передаю имя сущности аргументом? Потому что это в контексте моей задачи я не вижу смысла этого делать, поскольку по ходу работы дополнительно создаются обьекты, а имя сущности всегда остается одним и тем же — переданным при создании Adapter-а
Код. Создание сущности по ее имени
function TUAdapter.CreateEntity(): TUEntity;
begin
result := TUEntity(GetClass(self.entityName).Create);
end;
2. Получение полей класса
Думаю, это вопрос который разработчиками под Delphi задается не редко. Главная «особенность», что мы не можем достать все поля, как этого бы хотелось, а только property поля из раздела published. На самом деле это очень даже хорошо, потому что properties в нашей задаче использовать очень удобно.Код. Получение полей класса
procedure TUAdapter.getProps(var list: TStringList);
var
props : PPropList;
i: integer;
propCount : integer;
begin
if (uEntityObj.ClassInfo = nil) then
begin
raise Exception.Create('Not able to get properties!');
end;
try
propCount := GetPropList(uEntityObj.ClassInfo, props);
for i:=0 to propCount-1 do
begin
list.Add(props[i].Name);
end;
finally
FreeMem(props);
end;
end;
3. Получение значения поля обьекта по имени поля
Для этого можно воспользоваться методом GetPropValue. Остановлюсь на параметре PreferStrings — он влияет на то, каким образом будет возвращаться результат полей типа tkEnumeration и tkSet. Если он стоит как True, то из tkEnumeration вернется enum, а из tkSet вернется SetProp.(Instance: TObject; const PropName: string; PreferStrings: Boolean): Variant;.
Код. Использование GetPropValue
VarToStr(GetPropValue(uEntityObj, props.Strings[i], propName, true)
4,5,6… Работа с БД
Думаю, приводить весь код — дурной тон (и его место в конце статьи). А здесь я лишь приведу часть, на примере формирования запроса на выборку всех данных.Для выборки данных формируется транзакция на чтение, создается запрос. Мы связываем запрос и транзакцию, после чего запускаем их и получаем все значения в TIbSQL. Используя TIbSQL.EoF и TIbSQL,Next можно перебрать все записи, что мы и делаем — поочередно создавая новую сущность, помещаем ее в массив и заполняем ее поля.
Код. Метод TUAdapter.FindAll
function TUAdapter.FindAll(): TEntityArray;
var
rTr : TIBTransaction;
rSQL : TIbSQL;
props: TStringList;
i, k: integer;
rowsCount : integer;
begin
db.CreateReadTransaction(rTr);
rSql := TIbSQL.Create(nil);
props := TStringList.Create();
try
rSql.Transaction := rTr;
rSQL.SQL.Add('SELECT ' + fieldsSql + ' FROM '+ tblName);
if not rSql.Transaction.Active then
rSQL.Transaction.StartTransaction;
rSQL.Prepare;
rSQl.ExecQuery;
rowsCount := getRowsCount();
SetLength(result, rowsCount);
getProps(props);
i := 0;
while not rSQl.Eof do
begin
result[i] := CreateEntity();
for k:=0 to props.Count-1 do
begin
if (not VarIsNull(rSql.FieldByName(props.Strings[k]).AsVariant)) then
SetPropValue(result[i], props.Strings[k], rSql.FieldByName(props.Strings[k]).AsVariant);
end;
inc(i);
rSql.Next;
end;
finally
props.Destroy;
rTr.Destroy;
rSQL.Destroy;
end;
end;
В прочем, я не забуду упомянуть несколько сложностей. Во-первых, кодировка. Если ваша база данных создана с кодировкой WIN1251 и Collation установлен win1251 и вам придется работать с этой бд из Delphi — вы не сможете просто взять и добавить запись с кириллическими символами. В таком случае, прочитайте информацию по ссылке IBase.ru Rus FAQ. Тут вас и научат, и пальцем ткнут во все подводные камни.
Моя агрегация прочитанного выглядит как следующая последовательность действий:
- Запустить bdeAdmin.exe из папки Borland Shared\BDE\
- В Configuration -> System -> Init выбрать драйвер по умолчанию Paradox и Langdriver = Pdox Ansi Cyrrilic
- В Configuration -> Drivers -> Native поставим Langdriver = Pdox Ansi Cyrrilic в драйверах: Microsfot Paradox Driver, Data Direct ODBC to Interbase, Microsoft dBase Driver.
- Сохраним изменения, оставаясь на измененных элементах в главном меню Object нажав Apply.
Такая последовательность действий помогает не иметь проблем при запросах на Update или Insert. (а при Select никаких проблем с кириллицей и нет).
В некоторых случаях так же помогает вместо:
UPDATE tablename SET field = 'июнь';
писать:
UPDATE tablename SET field = _win1251'июнь';
Но это не сработает, если использовать запрос с параметрам, так как TIbSQL не знаком с функцией _win1251.
Например, такой код не сработает и спровоцирует исключение.
IbSQL.SQL.Add("UPDATE tablename SET field = _win1251 :field");
IbSQL.Prepare(); // <- Exception
IbSQL.Params.byName('field').asString := 'июнь';
В прочем, после того как вы проделаете указанные выше 4 шага — вам не нужно использовать _win1251 и вы вольны том как составляете запрос. Я же, сам того не осознавая, выбрал сложный путь и решил самостоятельно формировать запрос. Не учел, что параметризация бы взяла на себя часть тягот с фильтрацией передаваемых параметров. Не понятно о чем я?
Я столкнулся в проблемой, когда в текстовом значении поля есть кавычка или перевод строки. И пришлось написать метод для замены этих символ на допустимые:
Код. TUAdapter.Escape()
function TUAdapter.StringReplaceExt(const S : string; OldPattern, NewPattern: array of string; Flags: TReplaceFlags):string;
var
i : integer;
begin
Assert(Length(OldPattern)=(Length(NewPattern)));
Result:=S;
for i:= Low(OldPattern) to High(OldPattern) do
Result:=StringReplace(Result,OldPattern[i], NewPattern[i], Flags);
end;
function TUAdapter.escape(const unescaped_string : string ) : string;
begin
Result:=StringReplaceExt(unescaped_string,
[ #39, #34, #0, #10, #13, #26], ['`','`','\0','\n','\r','\Z'] ,
[rfReplaceAll]
);
end;
Результаты
В целом у нас сложились требования к Enitity классам:
- описать поля приватными
- описать поля, соответствующие колонкам таблицы как property в разделе published
- имена properties должны совпадать с именами соответствующих им колонок
- при необходимости реализовать Get/Set методы для полей (для Boolean, TDateTime, для Blob полей)
Итак, допустим, у нас есть следующая БД

Создаем два Entity класса TUser и TPost.
Код. Обьявление TUser
TPost объявляем таким же образом.
TUsersArray = Array of TUser;
TUser = class(TUEntity)
private
f_id: longint;
f_name : longint;
f_password : AnsiString;
f_email : AnsiString;
f_last_login : TDateTime;
f_rate: integer;
published
property id: integer read f_id write f_id;
property name : AnsiString read f_name write f_name ;
property password : AnsiString read f_password write f_password ;
property email : AnsiString read f_email write f_email ;
property last_login: AnsiString read getLastLogin write setLastLogin;
property rate: integer read f_rate write f_rate;
public
constructor Create();
procedure setParams(id, rate: longint; name, password, email: AnsiString);
procedure setLastLogin(datetime: AnsiString);
function getLastLogin(): AnsiString;
function getKey(): integer; override;
function getKeyName(): AnsiString; override;
function toString(): AnsiString; override;
function getTableName(): AnsiString; override;
function getKeyGenerator():AnsiString; override;
end;
TPost объявляем таким же образом.
А использование в коде в паре с адаптером будет выглядеть так:
var
Adapter : TUAdapter;
users: TUsersArray;
i: integer;
begin
Adapter := TUAdapter.Create(db, 'User');
try
users:= TUsersArray(Adapter.FindAll());
for i:=0 to Length(users) -1 do
begin
Grid.Cells[0, i+1] := VarToStr(users[i].id);
Grid.Cells[1, i+1] := VarToStr(users[i].name);
Grid.Cells[2, i+1] := VarToStr(users[i].email);
Grid.Cells[3, i+1] := VarToStr(users[i].password);
SetRateStars(i, VarToStr(users[i].rate));
Grid.Cells[5, i+1] := VarToStr(users[i].last_login);
end;
finally
Adapter.Destroy;
end;
end;
Выводы
Я хотел бы сделать акцент на скорости работы кода с использованием RTTI. Опыт подсказывает, что частое обращение к RTTI методам замедлит работу приложения, однако в моей реальности скорости разработанного класса хватает. Я считаю что цель достигнута и в результате получилось некоторое подобие ORM с малым функционалом, но честно решающее поставленные на неё задачи.
Проект на BitBucket.
P.S.
Напомню, что читатель у которого есть предрасположенность к извержению негативных мыслей в сторону Delphi не обязан всем об этом рассказывать. Так что, ребята, держите себя в руках.
Извините, я на самом деле вызываю MessageBox при ошибке, вместо того что бы кинуть Exception. Но я исправлюсь, я обещаю.
UPD:
Больше никаких MessageBox в коде.