Всем привет!
Сегодня я расcкажу вам о своем опыте написания ORM для Delphi с использованием RTTI под влиянием практик работы с Doctrine и Java EE.
Зачем?
Под мою власть недавно попал старый проект на Delphi7 в котором ведется активная работа с базой данных под Interbase 2009. Код в этом проекте радовал, но ровно до тех пор, пока речь не заходила о самом взаимодействии с бд. Выборка данных, обновление, внесение новых записей, удаление — все это занимало немало строк в логике приложения, отчего разобраться в коде порой становилось довольно сложно (спасение в добросовестном разработчике, который круглосуточно отвечал на мои глупые вопросы). В мои руки проект был передан с целью устранения старых бед и добавления в него нового модуля, задача которого — покрыть новые таблицы в БД.
Мне нравится MVC подход и очень хотелось разделить код логики с кодом модели. Да и если уж на чистоту — я не захотел для каждой новой таблицы переписывать по новой все get/set методы. Пару лет назад я познакомился с понятием ORM и мне это понравилось. Мне понравился принцип и я был в восторге, применяя его в своей работе.
В тот же момент я ринулся искать в Delphi7 хоть что-нибудь похожее на Doctrine или может генераторы Entity/Facade классов для таблиц… Ни того ни другого. Зато в поисковой выдаче нашлось несколько готовых решений. Например DORM. В целом, отличная штука и, по сути, то что нужно!
Но, не знаю бывает ли у вас, я отказался от готового решения и решился писать свой велосипед. С осознанием того что хочу, с пониманием всех недостатков, я стал на этот скользкий путь и, кажется, дошел до…
Размышления
Отсутствие хоть какого-нибудь подобия на 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 = 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() в первой строке.
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 задается не редко. Главная «особенность», что мы не можем достать все поля, как этого бы хотелось, но мы достаем только published поля — то-есть те, который property. На самом деле это очень даже хорошо, потому что properties в нашей задаче использовать очень удобно.
procedure TUAdapter.getProps(var list: TStringList);
var
typeData : PTypeData;
props : PPropList;
i: integer;
propCount : integer;
begin
if (uEntityObj.ClassInfo = nil) then
begin
MessageBox(0, 'Not able to get properties!', 'Warning!', MB_ICONWARNING or MB_OK);
exit;
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;.
VarToStr(GetPropValue(uEntityObj, props.Strings[i], propName, true)
4,5,6… Работа с БД
Думаю, приводить весь код — дурной тон (и его место в конце статьи). А здесь я лишь приведу часть, на примере формирования запроса на выборку всех данных.
Для выборки данных формируется транзакция на чтение, создается запрос. Мы связываем запрос и транзакцию, после чего запускаем их и получаем все значения в TIbSQL. Используя TIbSQL.EoF и TIbSQL,Next можно перебрать все записи, что мы и делаем — поочередно создавая новую сущность, помещаем ее в массив и заполняем ее поля.
function TUAdapter.FindAll(): TEntityArray;
var
rTr : TIBTransaction;
rSQL : TIbSQL;
props: TStringList;
i, k: integer;
rowsCount : integer;
begin
db.CreateReadTransaction(rTr);
rSql := TIbSQL.Create(nil);
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);
props := TStringList.Create();
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;
rSQL.Destroy;
end;
В прочем, я не забуду упомянуть несколько сложностей. Во-первых, кодировка. Если ваша база данных создана с кодировкой WIN1251 и Collation установлен win1251 и вам придется работать с этой бд из Delphi — вы не сможете просто взять и добавить запись с кириллическими символами. В таком случае, прочитайте информацию по ссылке IBase.ru Rus FAQ. Тут вас и научат, и пальцем ткнут во все подводные камни.
Моя агрегация прочитанного выглядит как следующая последовательность действий:
- Запустить bdeAdmin.exe из папки Borland SharedBDE
- В 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. (а при Selelect никаких проблем с кириллицей и нет).
В некоторых случаях так же помогает вместо:
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 и вы вольны том как составляете запрос. Я же, сам того не осознавая, выбрал сложный путь и решил самостоятельно формировать запрос. Не учел, что параметризация бы взяла на себя часть тягот с фильтрацией передаваемых параметров. Не понятно о чем я?
Я столкнулся в проблемой, когда в текстовом значении поля есть кавычка или перевод строки. И пришлось написать метод для замены этих символ на допустимые:
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], ['`','`','','n','r','Z'] ,
[rfReplaceAll]
);
end;
Результаты
В целом у нас сложились требования к Enitity классам:
- описать поля приватными
- описать поля, соответствующие колонкам таблицы как property и разделе published
- имена properties должны совпадать с именами соответствующих им колонок
- при необходимости реализовать Get/Set методы для полей (для Boolean, TDateTime, для Blob полей)
Итак, допустим, у нас есть следующая БД
Создаем два Entity класса 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');
users:= TUObjectsArray(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;
end;
Выводы
Я хотел бы сделать акцент на скорости работы кода с использованием RTTI. Опыт подсказывает, что частое обращение к RTTI методам замедлит работу приложения, однако в моей реальности скорости разработанного класса хватает. Я считаю что цель достигнута и в результате получилось некоторое подобие ORM с малым функционалом, но честно решающее поставленные на неё задачи.
Проект на bitbacket.
P.S.
Напомню, что читатель у которого есть предрасположенность к извержению негативных мыслей в сторону Delphi не обязан всем об этом рассказывать. Так что, ребята, держите себя в руках.
Извините, я на самом деле вызываю MessageBox при ошибке, вместо того что бы кинуть Exception. Но я исправлюсь, я обещаю.
Автор: ArtemE