четверг, 14 июля 2011 г.

Идея автогенерации кода при работе с БД

Разработчики приложений для работы с базами данных (БД) наверняка не раз сталкивались с некоторыми неудобствами при кодировании. Одно из неудобств – это обращение к полю набора данных (НД) по его имени. Неудобство заключается в том, что если к полю обращаться по имени DataSet['FieldName'], то компилятор никаким образом не может проверить, правильно ли написано 'FieldName' и есть ли это поле в НД. А при спешке, кодер может запросто допустить опечатку в имени поля, и, как следствие, это может привести к ошибке во время исполнения приложения.

Я хочу описать технологию,  которая может научить компилятор знать, какие имена в таблице могут быть. Это нужно для того, чтобы возможные опечатки исключить на стадии компиляции. А в качестве бонуса, мы можем увидеть список полей в выпадающем списке CodeInsight после имени таблицы и точки. Ну т.е. в конечном итоге, я получаю возможность прямо в коде писать так: TableName.FieldName := 10; вместо привычного нам TableName['FieldName'].AsInteger := 10;

Лирическое отсутпление.

В своей практике я давно не использую стандартные компоненты доступа к БД и компоненты-наследники от TDataSet. Используются полностью самописные библиотеки. В качестве набора данных – используется специальный класс (грубо говоря наследник от TObject). В качестве грида – компонент VirtualTreeView. Вместо DataSource – используется класс-обёртка, которая визуализирует набор данных в гриде: позволяет делать сортировку (по нескольким столбцам), группировку (по нескольким столбцам) , фильтрацию (локальную), подсвечивать строки и ячейки таблицы разными цветами (в зависимости от условия), подсчитывать итоги (агрегатные функции).

О некоторых причинах, которые привели меня и моих коллег уйти от использования стандартных механизмов, я упоминал в предыдущем посте.

В этом посте я не буду писать о том, как создаются компоненты для выборки данных и как они связываются с визуальными компонентами. Для демонстрации проблемы (обращение к полю НД по имени) это не важно.

Часть 1.

Чтобы было легче ориентироваться в коде ниже, возьмём для примера: таблица CLIENTS – информация о клиентах, в ней поля: INN – ИНН клиента, KPP - КПП клиента. И таблица CUSTOMERS.

Описанная проблема усугубляется, когда к одним и тем же полям в коде надо обращаться очень часто. А если у вас есть много таблиц, в которых есть поля с одинаковыми или схожими именами – это может заморочить голову даже опытному программисту. Рассмотрим средства, которые могут нам помочь.

1й способ.
// создать переменную: 
var
  INN: TField;
..
// на момент, когда набор данных открыт, инициализировать переменную:
  INN := Clients['INN'];

// работать с полем через переменную:
  INN.AsString := '1234567890';

Способ прост и хорош, когда в одном модуле работаешь с одной таблицей. Однако когда таблиц две и более – возникает путаница, к какой именно таблице относится конкретное поле? В этом случае, переменные можно именовать в виде <TableName><FieldName>:

var
  ClientsINN: TField;
  CustomersINN: TField;
..
  ClientsINN := Clients['INN'];
  CustomersINN := Customers['INN'];
...
  if ClientsINN.AsString <> CustomersINN.AsString then ...
Преимущества этого способа:
  • обращение к полю по ссылке (через переменную ClientsINN) работает чуточку быстрее, чем обращение через свойство набора данных (Clients['INN']);
  • имя поля, заключённое в кавычках, используется только один раз.
Явные недостатки:
  • необходимость создавать переменную;
  • необходимости инициализировать переменную, причём при инициализации остаётся проблема: компилятор не может проверить, есть ли поле с таким именем.

Остальные недостатки также встречаются и при использовании 2го способа, о них чуть позже.

2й способ.

У компонента DataSet есть свойство Fields, которое можно заполнять в Design-Time. Заполнить можно автоматически (для этого нужно, чтобы из Design-Time можно было подключиться к БД: компонент считает мета-описание таблицы и сам создаст все поля таблицы), либо вручную. Также вручную можно создавать вычисляемые поля и поля подстановки, т.е. те поля, которых нет в БД (или в запросе). При этом среда автоматически создаёт переменные (переменные именуются в виде <TableName><FieldName>: T<FieldType>Field) и автоматически их инициализирует (на самом деле, каждое поле – это наследник от TComponent, поэтому всего его свойства сохраняются в dfm-файле и считываются при создании экземпляра формы/фреймы/датамодуля).

В этом случае, для нашего поля создастся компонент ClientsINN: TStringField; и далее в коде к полю можно обращаться через переменную ClientsINN, как в 1м способе. Основным отличием TStringField от TField является то, что свойство Value в первом случае имеет тип String, а во втором – Variant.

Теперь наш код местами может быть таким:

  ClientsINN.Value := '1234567890';
  if ClientsINN.Value <> CustomersINN.Value then ...

 

Преимущества этого способа, по сравнению с первым способом, очевидны. Но и есть недостатки, которые встречаются не часто, но если встречаются – могут доставить массу хлопот.

Неудобства стандартных решений.

Неудобство №1 – длинное имя.

Действительно, если мы работаем всего лишь с одной таблицей, то всё время набирать имя таблицы не очень приятно. Тут плюс в сторону 1го способа, либо для второго способа можно задать имена переменных вручную (однако, если в коде через пару лет вдруг появится вторая таблица…).

Неудобство №2 – много имён.

При работе сразу с двумя и более таблицами обращение к полям разных таблиц в одной строке кода (или в одном блоке кода) замыливают глаза: читающий начинает путаться, где одна таблица, а где другая. Если бы можно было разделить имена полей точкой (именно точкой, а не символом подчёркивания! символ подчёркивания принято использовать в именах таблиц и именах полей), то чтение кода улучшилось бы в разы. Сравните код:

  ClientsINN.Value := CustomersINN.Value;
  if CustomersKPP.IsNull then
    CustomersKPP.Value := GetCustomerKPP;
  ClientsKPP.Value := CustomersKPP.Value; 

и код:

  Clients.INN := Customers.INN;
  if Customers.KPP = Null then
    Customers.KPP := GetCustomerKPP;
  Clients.KPP := Customers.KPP; 

Второй вариант читается (и пишется!) проще. Именно к такому написанию кода и будем стремиться.

Неудобство №3 – конфликт имён.

Ситуация редкая, но потенциально возможная, когда сочетание <TableName><FieldName> для двух разных таблиц и полей дают одинаковый идентификатор. Правда в этом случае в редакторе полей можно задать другое имя для поля второй таблицы, однако при чтении кода это может ввести читающего в заблуждение.

Неудобство №4 – разные БД.

Допустим у Вас есть несколько клиентских БД, причём структура БД у разных Ваших заказчиков может незначительно отличаться. Например, в таблице CLIENTS поля KPP может не быть, но при этом приложение должно работать. В этом случае в тексте запроса к БД нельзя перечислять все поля, но запрос может быть таким:

SELECT * FROM clients

и такой запрос будет работать в не зависимости, есть поле KPP, или нет. Однако о создании полей в Design-Time уже речи быть не может. Да и вообще, в этой ситуации придётся скрывать часть полей на форме редактирования записей. Ситуация хоть и довольно частая в моей практике, но не уверен, что решаемая стандартными компонентами. А потому, многие не любят допускать такого и стараются поддерживать структуры БД у своих клиентов. В общем, эта тема отдельного разговора – я её решаю за счёт использования своих библиотек.

Часть 2. Идея.

Идея сама по себе незатейлива. Для того, чтобы в коде можно было писать Clients.INN, очевидно, что Clients – это экземпляр некоторого класса, INN – ссылка на поле. В своих библиотеках, я могу обращаться к произвольной записи в любой момент, поэтому там синтаксис примерно такой:

  Clients.INN[Row1] := Customers.INN[Row2];
  if Customers.KPP.IsNull[Row2] then ...

Однако я буду приводить пример, для работы с DataSet’ами, здесь синтаксис получится таким:

  Clients.INN.Value := Customers.INN.Value;
  if Customers.KPP.IsNull then ...

Для того, чтобы можно было так писать, нам надо подготовить описательную часть, например такой класс:

type
  TClients = class(TTableDesc)
  private
    FClientID: TIntegerField;
    FName: TStringField;
    FINN: TStringField;
    FKPP: TStringField;
    ..
  public
    property ClientID: TIntegerField read FClientID;
    property Name: TStringField read FName;
    property INN: TStringField read FINN;
    property KPP: TStringField read FKPP;
    ...
  end;

Обратите внимание: у таблицы Clients есть поле Name, однако у TDataSet (как наследника от TComponent) уже есть свойство Name. Поэтому, чтобы не было конфликта имён (Clients.Name – это ссылка на поле Name, или свойство компонента?), тип TTableDesc наследован от обычного TObject. Сначала я даже хотел этот тип сделать обычной записью (record), но в целях безопасности (чтобы случайно не подменить ссылку на поле), решил сделать всё-таки классом. Не буду сейчас вдаваться в подробности реализации класса, сейчас это не важно.

Итак, мы объявляем переменную Clients: TClients и запрашиваем экземпляр этого класса. Выглядеть это может быть разными способами, можно сделать через интерфейсы, можно всё сделать в конструкторе TClients, либо можно создать отдельный менеджер, который будет создавать экземпляр по требованию. Я пока выбрал способ, в котором все экземпляры НД создаются при запуске приложения и существуют во время всего его жизненного цикла. Ссылки на все экземпляры классов TTableDesc хранятся в отдельном объекте (назовём его DF).

Теперь можно писать так:

DF.Clients.INN.Value := SomeValue

Можно пойти дальше, и вместо ссылки на экземпляр стандартного T<FieldType>Field, подставлять свой класс, который знает (или умеет определять) есть ли поле в БД – это поможет решить неудобство №4.

Для доступа непосредственно к датасету, класс TTableDesc можно снабдить свойством _DataSet (символ подчёркивания в начале имени предлагается, чтобы обойти возможные конфликты имён с наименованиями полей). Однако писать

DF.Clients._DataSet.Open;

неудобно. Поэтому все экземпляры датасетов я предлагаю также собрать в отдельный класс (назовём его DT), и писать так:

DT.Clients.Open;

Для участков кода, где не нужно работать с экземплярами полей, а достаточно работать только со значениями (причём если тип Variant нас устроит), можно создать ещё один класс (назовём его DV) и собрать в нём экземпляры классов такого вида:

type
  TClientsV = class(TTableValues)
  private
    function GetClientID: Variant;
    procedure SetClientID(Value: Variant);
    ..
  public
    property ClientID: Varaiant read GetClientID write SetClientID;
    property Name: Varaiant read GetName write SetName;
    property INN: Varaiant read GetINN write SetINN;
    property KPP: Varaiant read GetKPP write SetKPP;
    ...
  end;

Это даст возможность писать так:

  DV.Clients.INN := DV.Customers.INN;
  if DV.Customers.KPP = Null then ...

А чтобы постоянно не писать DT, DF, DV, можно использовать with. Оцените как читается такой код:

with DT do
begin
  Clients.Open;
  Clients.First;
  Customers.Open;
  while not Clients.Eof do
  begin
    Customers.First;
    while not Customers.Eof do
    begin
      with DV do
        if Clients.INN = Customers.INN then
        begin
          DoSomething;
          Break;
        end;
      Customers.Next;
    end;
    Clients.Next;
  end;
end;

В общем, довольно наглядно. А самое главное, в момент компиляции приложения компилятор проверит все имена полей в нашем коде.

Теперь нам надо научиться генерировать код, чтобы описательную часть классов не создавать вручную.

Часть 3. Генерация.

Для кодогенерации, я использую примерно такой механизм.

а) Все таблицы и их поля описываются в виде xml-файла. Для этого у меня есть небольшое приложение, которое подключается к БД и для конкретной таблицы генерит xml-описание. В этом xml-описании уже вручную я добавляю вычисляемые (calc) поля и поля-подстановки (lookup), настраиваю описание полей и их свойства. Описание каждой таблицы сохраняется в отдельный xml-файл (для удобства).

б) Перед генерацией кода, происходит сборка единого xml-файла. Грубо говоря, все xml-файлы собираются в один файл.

в) Запускается ещё одно приложение, которое по xml-описанию создаёт файл TablesDesc.pas, этот файл содержит описание классов для работы с таблицами, и создаёт экземпляры DT, DF и DV.

В Delphi 7, где нельзя в явном виде настроить вызов внешнего скрипта перед сборкой приложения, поэтому я это реализовал с помощью CnPack/Script Master.

Остаётся не забыть включить в uses модуль TablesDesc, и мы можем писать, как в примере выше.

Эти три пункта у нас сложились исторически. Более того, в xml-описании можно хранить некоторую информацию, которую потом можно использовать для формирования SQL-запросов, а править запросы удобнее именно в xml-файле (где видна перед глазами структура таблицы), а не где-то там в коде. Ещё плюс в сторону xml-описания: при большом количестве таблиц искать и редактировать текст в xml-файлах проще, чем глазками искать нужный DataSet в DataModul’е (или в датамодулях, если их несколько) и мышкой прокликивать свойства НД и полей.

Вы же, для простоты, можете написать Wizard для IDE, который будет делать всю описанную работу.

11 коммент.:

Bonart комментирует...

А SQL-код БД тоже генерится из этого XML?

Николай Зверев комментирует...

Ну, в простых случаях, он просто генерится по описанию дата-полей. В более сложных - xml может содержать части SQL-запроса, либо несколько версий запросов (если НД используется в разных контекстах).

Плюс я добавил такую фичу - при первом обращении к НД (а это может произойти до запроса к БД за данными) происходит проверка на наличие таблицы (вьюхи) в БД и наличие всех полей (т.е. я динамически могу отключать/включать функционал приложения), а заодно я уточняю реальный размер текстовых полей (чтобы можно было ограничить пользовательский ввод автоматически).

Юрий комментирует...

Если этот механизм ешё умет "Create table" и "update" запросы генерить. То это почти ORM (Object-relational mapping) , аналогов правда уже много имеется:
1) Bold for Delphi
2) InstantObjects
3) tiOPF
4) DePO - Delphi Persistent Objects
5) DObject
6) g-framework

Николай Зверев комментирует...

DML умеет. DDL - сознательно нет. Хотя я согласен, что в некоторых случаях можно было бы и научить клиентские приложения создавать объекты БД.

Аналоги конечно же есть, но Вы же понимаете, что _свой_ исходник роднее :). Тем более, корни этой библиотеки уже имеют 12 летний "стаж".

Юрий комментирует...

Это всегда да :)
Но и чужие труды, если они качественно написаны, и не разу не подвели своей работой, со временем становятся почти родными )
Перекочёвывают из проекта в проект, и от них бывает сложно отказаться

Алексей Тимохин комментирует...
Этот комментарий был удален автором.
Алексей Тимохин комментирует...

По поводу создания полей в design-time.

Недавно после перехода на юникод, при попытке научить программу корректно работать и с ANSI и Unicode версией БД, столкнулся с проблемой из-за которой пришлось удалять все созданные в дизайне поля и пересоздавать их в Runtime. Хорошо что таких полей было мало.

Причина банальна: несоответствие типов. Для ANSI базы VARCHAR поле имеет тип TStringField, а для Unicode базы TWideStringField.
Впрочем, возможно какие-то из библиотек доступа к БД и не имеют такой проблемы.

Теперь по поводу автогенерации классов. Недавно я и сам подумывал о том, чтобы написать свои обёртки для Dataset-ов, чтобы обращаться к полям по полному имени (с проверкой названия поля компилятором). Т.е. по сути заменить обращение:
aDataset.FieldByName('OLOLO')
на
MyDatasetWrapper.FieldOlolo: TField
И уже где-то в MyDatasetWrapper.FieldOlolo обращаться к полю Dataset-а по имени.

Николай Зверев комментирует...

> Aleksey Timohin
> Это сообщение было удалено автором.
Вроде бы я не удалял комментарий!?

А второй комментарий попал в спам почему-то, только сяс заметил..

Алексей Тимохин комментирует...

>> Aleksey Timohin
>> Это сообщение было удалено автором.
> Вроде бы я не удалял комментарий!?

Я по ошибке опубликовал здесь комментарий к предыдущему посту. Когда заметил сам его и удалил.

Unknown комментирует...

в своей сводной статье я описал свой опыт по автоматической генерации кода http://fk-uran.com.ua/programma-postroitel-klassov-classbuilder/

Alex W. Lulin комментирует...

Мне кстати рассказывали про вашу технику :-)

Отправить комментарий

.

.