26 июня 2013 в 18:01

Пишем ORM для Delphi из песочницы

Всем привет!
Сегодня я рас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 в результате:
  1. Создает объект по имени класса
  2. Умеет получать поля класса
  3. Умеет получать значение поля обьекта по имени поля
  4. Умеет совершать выборку всех данных
  5. Умеет доставать сущность по ключу
  6. Умеет обновление данные сущности в бд
  7. Умеет удалять сущность из бд
  8. Умеет добавлять новую сущность из бд.

Мои ограничения:
  1. Нет PDO — разработка под одну БД — Interbase
  2. В Delphi7 еще старая версия RTTI. (в Rad 2010 RTTI было сильно улучшено). Можно достать только published поля
  3. Не будут реализованы связи и доставание сущностей по связям (по каким-то внутренним соображениям).


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. Тут вас и научат, и пальцем ткнут во все подводные камни.

Моя агрегация прочитанного выглядит как следующая последовательность действий:
  1. Запустить bdeAdmin.exe из папки Borland Shared\BDE\
  2. В Configuration -> System -> Init выбрать драйвер по умолчанию Paradox и Langdriver = Pdox Ansi Cyrrilic
  3. В Configuration -> Drivers -> Native поставим Langdriver = Pdox Ansi Cyrrilic в драйверах: Microsfot Paradox Driver, Data Direct ODBC to Interbase, Microsoft dBase Driver.
  4. Сохраним изменения, оставаясь на измененных элементах в главном меню 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 классам:
  1. описать поля приватными
  2. описать поля, соответствующие колонкам таблицы как property в разделе published
  3. имена properties должны совпадать с именами соответствующих им колонок
  4. при необходимости реализовать Get/Set методы для полей (для Boolean, TDateTime, для Blob полей)

Итак, допустим, у нас есть следующая БД

Создаем два Entity класса TUser и TPost.
Код. Обьявление TUser
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 в коде.
Ерофеев Артем @ArtemE
карма
16,0
рейтинг 0,0
Самое читаемое Разработка

Комментарии (26)

  • –4
    Прикольно, конечно, но не было желания переписать на что-то более новое чем дельфи7?
    Про MessageBox и обработку ошибок уже дописал автор, поэтому пост отредактирован 8)
    • 0
      В процессе было много желаний. Хочется иметь такой инструмент на любой версии, потому как лично для меня это компонент, который делает работу интереснее. А реализация появилась только под Delphi 7 исключительно в виду того, что проект над которым я работал был написан на этой версии. Если эти наработки вызовут интерес, я с радостью попробую развить их в что-то стоящее.
  • 0
    А не смотрели в сторону tiOPF? Там довольно много возможностей, судя по описанию.
    • 0
      tiOPF очень неплохой вариант. И кроме него оценил открытый hcOPF, крайне интересный InstantObjects и разглядывал издалека закрытый TMS Aurelius.
      • 0
        А если сравнить их между собой в двух словах — что лучше/удобнее/проще?
        Я сам пока ничем из них не пользовался, только немного в свободное время изучал документацию и примеры tiOPF — мне этот фреймворк показался мощным, но одновременно довольно сложным и многословным.
        Если вас не затруднит — сравните в двух словах их. Возможно, мне стоит начать с чего-то другого? (уточню, что один из важных для меня пунктов — поддержка FreePascal).
        • 0
          Если вкратце, я на 100% знаю что в tiOPF есть поддержка FreePascal, а на счет остальных почти уверен что ее нет. И увы, я не работал с ними что бы дать качественное сравнение. Если у вас есть время пробовать — посмотрите тройку InstantObjects, tiOPF и DORM (у последних двух как минимум есть нормальная документация).
          • 0
            Жаль, что не работали. У меня, к сожалению, сейчас времени свободного нет, как и задач для которых подобный фреймворк был бы нужен.
            По свободе буду разбираться, но это будет нескоро…
        • 0
          Поддерживаю просьбу.
        • +1
          У TiOPF есть своя документация, хорошие примеры, форум, он развивается.
          Поддерживает
          Interbase/Firebird via IBX
          Firebird via FBLib
          Firebird via ZeosLib (experimental)
          Oracle via DOA
          MS Access & MS SQL-Server via ADO
          Paradox via BDE
          XML via MSDOM or FPC's DOM
          CSV files
          TAB files
          There is also a lightning fast, custom XML persistence layer for local databases, and
          a HTTP/XML layer & proxy server for building remote systems that can connect through corporate firewalls.
          Этим он лучше многих прочих. Вы быстро найдете ответ по интересующему вас вопросу, используя TiOPF.
          Я не знаю, есть ли поддержка > RDS 2006
          Минус у всех один — слабый язык запросов, но для большинства нужд — пойдет.
          Лично я бы использовал его. (это не сочтется за рекламу?)
          • 0
            У tiOPF немного непонятна структура веток. Есть tiOPF 2 и tiOPF 3, причем коммиты и там и там постоянно есть.
            Что использовать, не совсем понятно. Третья у меня, насколько я помню, собираться не захотела. Вторую так и не попробовал…
            • +1
              tiOPF3 для RDS 2009, а tiOPF2 для вас. Напрасно вы не попробовали вторую ветку ;)
  • –1
    мдее действительно сложный велосипед. Я тоже года назад встал на путь переплюнуть популярный DPS.
    Аффтор сколько занимает реализация ORM суммарно количестве строк кода ?? Больше 2 тыс строк кода или как ??
    Вам с таким подходом надо работать в Colvir или в ЦФТ…
    www.cft.ru/
    colvir.ru/
    • 0
      Увы, я не смог понять направленность вашего комментария. В статье все подробно описано. Речь идет не о полноценной ORM, которая была написана для своих собственных нужд, решающая весьма узкий круг задач. Там меньше 500 строк кода. А что определяет количество строк кода?
      • 0
        затраченное время и в следствие сложность проекта. исходники Win2000 20 млн строк кода…
    • 0
      2К строк кода можно за 4 часа не особо напрягаясь написать. Главное — знать суть, а описать эту суть кодом не сложнее, чем выразить мысли буквами.
  • –5
    Воистину, чем дальше, тем больше Delphi 7 мне чем-то напоминает COBOL с Fortran-ом, только на территории ex-USSR.
  • 0
    В более новых версиях Делфи есть аннотации, что позволяет имена таблиц и полей не тащить в названия свойств и классов, а прописывать в аннотациях.
  • 0
    Мне нравится MVC подход и очень хотелось разделить код логики с кодом модели.

    На самом деле модель это данные и бизнес-логика. Хотя, довольно часто случается встречать такое понимание, что модель это бездумный persistence слой, а вся логика — в контроллере. В данном же случае скорее речь идет о паттерне DAO.
    А конкретно по реализации: действительно RTTI это копейки по сравнению с сетевым обменом (с базой данных), так что опасаться за быстродествие действительно не стоит.
    Я когда-то тоже делал что-то подобное, только там DAO слой реализовали с помощью нескольких словарных таблиц (они использовались для генерации SQL, и много для чего еще). Плюс был признак кеширования — объекты с таким признаком сохранялись некоторое время в кеше и при последующем обращении уже не извлекались из базы. Вот это действительно позволило увеличить быстродействие на порядки. Кстати, способность к кешированию есть во многих современных ORM.
    По коду: в Delphi нет сборщика мусора, так что надо повнимательней. Если создал какой — то объект, то в конце следует обязательно удалить, лучше оформить это в try-finally:
    someObject:=TSomeObject.Create();
    try {
      ..
    } finally {
      someObject.Destroy();
    }

    Сейчас в коде есть явные утечки
    • 0
      Спасибо, очень конструктивно. Устраню утечки и отпишусь в посте. А коим образом у вас происходило кеширование? Можно взглянуть одним глазком на вашу реализацию?
      • 0
        На самом деле все это писалось более 10 лет назад, а последний раз код я видел более 5 лет назад, так что только по памяти.
        Идея такая: доступ к объектам БД осуществлялся либо через произвольный SQL, либо с помощью «словарных» функций (DAO). При произвольном SQL ничего не кешировалось, а вот если это словарный доступ, и объект имел признак кеширования, то результат запроса добавлялся в кеш.
        Кеш представлял собой карту
        ключ записи <-> запись
        

        далее, если необходимо было получить такой объект по ключу, то сначала проверяли в кеше, потом обращались в базу.
        Для каждого объекта из словаря велся свой кеш (суперкарта вида
        название_объекта <-> кеш этого объекта
        
        ). Суперкарта имела ограниченный объем, если к объекту долго не обращались, его кеш удалялся из суперкарты путем замещения.
        Ну и в добавок различные сервисные функции, типа
        — принудительно прочитать таблицу в кеш
        — очистить кеш таблицы
        — очистить весь кеш
        — начать и закончить кеширование принудительно (аналог сессии hibernate)
        Писали по наитию, когда перешли на java, увидели много знакомого :)
        Естественно, подход не универсальный, и есть риски. Но для работы со статическими данными (различные справочники, lookup-поля) — самое оно.
        • 0
          Ответил ниже. Веткой ошибся, глупо вышло
    • 0
      Исправил утечки в коде из статьи и залил изменения на BitBucket.
    • 0
      Сколько людей — столько и мнений. Для меня лично модель — это что-то из области метаданных, скорее класс, чем объект. Но при разговорах с другими называю моделями бизнес-объекты, которые могут отображаться на экране и сохраняться в БД. А вот бизнес-логика может быть как встроенной в модель, так и отдельно. Зависит от задачи, архитектуры, времени, настроения, итд… В одном моем проекте все модели — тупо строки таблиц, а логика где-то рядом с представлением. В другом — полноценные объекты со встроенной логикой, кучей методов и взаимосвязей. И мне лично больше нравится вариант с тупыми моделями, они не увеличивают сложность проекта при развитии, да и представление чаще редактируется вместе с логикой, чем с моделями.
  • 0
    Да, здорово. И в реализации не сложно, на сколько я могу судить.
  • 0
    Молодец. Следующий шаг — автоматическое построение структуры БД по моделям из проги.

    А еще рекомендую перейти с Delphi 7 на Lazarus.
  • 0
    Пожалуй, попробую перенести в лазарус, спасибо за наводку. А вот вопрос, здесь, как я понимаю, идет полное зеркальное отображение таблиц бд в объекты, поля бд = свойствам объектов. А как-то решена проблема маппинга связей один-ко-многим и многие-ко-многим? Я у себя добавляю к объектам свойства с типом списка для заполнения уже внутри объекта списочного свойства соответствующими объектами. Но тут только хардкодинг помогает (пока). В tiOPF паттерн relation manager пытаюсь понять, но пока никак на себе не применю. Плюс еще есть одна загвоздка. Связь многие-ко-многим при маппинге в объекты может повлечь дикую рекурсию (прямой пример: юрлицо имеет участников — юрлиц, объект юрлицо имеет свойство список участников, список состоит из объектов юрлиц, ну а как иначе?), вот такие проблемы как решали, если были?

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.