MIDAS. Практическое применение
Роман Игнатьев
Введение
Необходимые знания: Перед прочтением рекомендуется ознакомиться с технологией MIDAS хотя бы на уровне демонстрационных приложений, поставляющихся с Delphi.
Технология MIDAS (Multi-tier Distributed Application Services Suite, Сервис для создания многоуровневых распределенных приложений) была предложена фирмой Borland уже довольно давно, первое приложение с ее использованием я написал еще в 98 году, на Delphi 4. И с тех пор практически все приложения для работы с базами данных создаются мной именно на основе MIDAS. О преимуществах, думаю, говорить не надо – даже простое разделение приложения на две части, одна из которых работает с базой данных (сервер приложений), а другая обеспечивает интерфейс пользователя, создает значительные удобства как при разработке приложения, так и при его использовании.
Сейчас существует множество статей и книг по технологии создания многозвенных приложений на Delphi, но, к сожалению, мне не удалось найти литературы, в которой бы рассматривались некоторые интересующие меня вопросы. Дело в том, что во всех примерах создается трехзвенное приложение, в котором сервер приложений просто соединяет сервер БД с клиентской частью, просто абстрагируя работу с базой данных.
С одной стороны, это дает некоторое преимущество при переходе с двухуровневой технологии (клиент-сервер) на трехуровневую, для чего компоненты доступа к базе данных из клиентской части переносятся в сервер приложений. С другой стороны, хочется большего, а именно переноса на сервер не только работы с таблицами базы данных, но и основной логики приложения, оставляя клиентской части только задачу взаимодействия с пользователем.
Ниже рассматривается очень простое приложение для работы с сервером БД. База данных, с которой работает это приложение, содержит всего 3 таблицы. С его помощью мне хотелось бы показать некоторые способы создания полноценного сервера приложений, обеспечивающего полную обработку данных.
Все, что написано ниже, относится к Delphi 5, в качестве сервера выбран Interbase 5.6. Именно эти продукты я использовал в большинстве проектов. Однако база данных работает и на более старших версиях Interbase, я проверял ее работоспособность, в частности, на IB6, а исходные тексты приложения с минимальными изменениями можно компилировать на старших версиях Delphi. К сожалению, некоторые изменения делать все же придется, так как MIDAS постоянно развивается. Но, как правило, такие изменения носят косметический характер, и сделать их несложно. Как изменить проект для компиляции на Delphi 6, будет рассказано в заключительной части. Несмотря на то, что в сервере приложений используются компоненты прямого доступа Interbase Express (IBX), нетрудно перейти на другой сервер БД, просто заменив компоненты доступа и немного изменив текст методов.
Исходя из практических соображений ниже описано создание трехуровневого приложения, включая разработку структуры базы данных. Мне не хотелось пользоваться готовыми примерами, входящими в поставку Interbase и Delphi, чтобы не придерживаться шаблонов, навязываемых ими.
Постановка задачи
Я собираюсь на простом примере показать возможности технологии, поэтому приложение будет работать всего с одним логическим объектом – некоторым абстрактным документом, состоящим из заголовка и содержимого. Такая структура была выбрана из-за того, что связь “мастер-таблица - подчиненная таблица” (master-detail) очень часто встречается на практике, и также часто в приложениях встречается обработка различных документов.
“Документ” здесь понимается как некоторая абстракция, напоминающая реальные документы, такие, как накладная или счет.
Разумеется, необходимо обеспечить одновременную работу с документами нескольких пользователей.
Описание документа
Заголовок документа прост, и состоит из следующих полей:
Номер – номер документа.
Дата – дата выписки, по умолчанию текущая.
Поставщик – имя, телефон.
Получатель – также имя и телефон.
Сумма – общая сумма по документу.
Поставщик и получатель будут выбираться из единого справочника клиентов, содержащего только два поля: имя и телефон.
Документ содержит записи со сведениями о перемещении неких товаров. Содержимое документа – это таблица в базе данных, записи которой состоят из следующих полей:
Порядковый номер
Номер документа, к которому относится.
Наименование - наименование товара.
Количество.
Цена.
Сумма.
Результат может выглядеть следующим образом:
номер п/п | наименование | количество | цена | сумма |
1 | Что-то | 1 | 30.00 | 30.00 |
2 | Что-то еще | 2 | 10.50 | 21.00 |
... |
Пользователю должен выдаваться список документов с указанием реквизитов документа и его итоговой суммы.
Сумма документа будет рассчитываться сервером приложений на основе его содержимого.
Кроме этого, создадим итоговый отчет – "шахматную" ведомость, где по одной оси расположены поставщики, а по другой – получатели:
За период с <дата> по <дата> (по дате документа) | ||
От кого | Кому | |
Получатель1(имя) | Получатель2(имя) | ... |
Поставщик1(имя) | Сумма | Сумма |
Поставщик2(имя) | Сумма | Сумма |
... |
В отчет будут входить суммы из документов, даты которых попадают в заданный период.
Отчет, разумеется, получается также достаточно оторванным от жизни, мне просто хотелось показать с его помощью дополнительные возможности MIDAS.
Блокировки
Поскольку работать с БД будут сразу несколько пользователей, важно заблокировать документ на время редактирования документа пользователем. “Почетная обязанность” синхронизации работы пользователей возлагается в данном случае на сервер приложений. В принципе, все это можно реализовать и средствами SQL сервера, но, во-первых, для сервера Interbase блокировки записей довольно неестественны, во-вторых, как будет видно ниже, сервер приложений позволяет легко блокировать сразу весь документ как единый объект.
Структура БД
Требования к приложению описаны, и теперь можно приступить к следующему этапу – созданию БД. Нам нужны три таблицы (магическое число три выбрано исключительно для простоты): для документа, содержимого документа и справочника клиентов. Документ содержит две ссылки на справочник клиентов – “Поставщик” и "Получатель", а содержимое документа имеет ссылку на документ. На рисунке 1 представлена ER-диаграмма этой БД.
Рисунок 1. ER-модель БД.
В данной базе целостность данных обеспечивается по следующим правилам:
При упоминании в каком-либо документе поставщика или получателя удаление этой строки из справочника клиентов не допускается.
При удалении документа удаляются и все связанные с ним строки из таблицы "Содержимое документа".
Вставка в таблицу "Содержимое документа" допускается только при условии, что поле ID ссылается на существующий документ.
Как видно, в таблице "Заголовок документа" есть поле "Сумма". Это поле должно содержать полную сумму документа (сумму полей "Сумма" содержимого документа). При изменении содержимого документа приходится пересчитывать значение этого поля. Наличие такого поля, является нарушением принципов нормализации. Эту сумму всегда можно посчитать в SQL-запросе при выводе данных пользователю. Но при выдаче списка документов расчет суммы каждого из них увеличивает нагрузку на сервер БД. Отслеживать актуальность этого поля можно на триггерах СУБД, но раз уж мы создаем сервер приложений, почему бы не возложить на него эту задачу? К тому же, это обеспечивает некоторую независимость от особенностей функционирования сервера БД, что может оказаться полезным, например, при переходе на другой сервер.
Ниже приведен скрипт, создающий подопытную БД:
/* Определены следующие типы данных: Имя клиента Количество для содержимого документа Цена Сумма */ create domain DName varchar(180); create domain DCount numeric(15,4) default 1 not null; create domain DCurrency numeric(15,2) default 0 not null; create domain DSum numeric(15,4) default 0 not null; /* Справочник поставщиков и получателей */ create table CLIENT ( CLIENT_ID integer not null, NAME DName not null, /* Имя */ PHONE varchar(40), /* Телефон */ constraint PK_CLIENT primary key (CLIENT_ID) ); /*Заголовокдокумента*/ create table DOC_TITLE ( DOC_ID integer not null, /* ID */ DOC_NUM varchar(40) not null, /* Номер */ DOC_DATE date not null, /* Дата */ FROM_ID integer default 0 not null, /* Поставщик */ TO_ID integer default 0 not null, /* Получатель */ DOC_SUM DSum, /* Сумма */ constraint PK_DOC_TITLE primary key (DOC_ID), constraint FK_DOC_FROM_CLIENT foreign key (FROM_ID) references Client (CLIENT_ID) on update cascade, constraint FK_DOC_TO_CLIENT foreign key (TO_ID) references Client (CLIENT_ID) on update cascade ); /*Содержимое*/ create table DOC_BODY ( DOC_ID integer not null, /* сcылканазаголовок */ LINE_NUM integer not null, /* Номерп/п */ CONTENT varchar(250) not null, /* Наименование */ COUNT_NUM DCount, /* Количество */ PRICE DCurrency, /* Цена */ constraint PK_DOC_BODY primary key (DOC_ID, LINE_NUM), constraint FK_DOC_BODY_TITLE foreign key (DOC_ID) references DOC_TITLE (DOC_ID) on delete cascade on update cascade ); |
Скрипт создает три таблицы: CLIENT (поставщики/получатели), DOC_TITLE (документ), DOC_BODY (содержимое документа).
Следующий этап – формирование списка документов. В заголовке документа содержится только ссылка на поставщика и получателя. Вывод списка удобно организовать отдельным запросом, а в данном случае – хранимой процедурой. Пусть для удобства имя клиента в списке показывается в виде "Имя (Телефон)". Для этого сделаем процедуру CLIENT_FULL_NAME, которая извлекает эту строку, и будем вызывать ее из процедуры выдачи списка LIST_DOC. Эта же процедура пригодится для отображения имени поставщика и получателя на форме редактирования документа:
create procedure CLIENT_FULL_NAME(ID integer) returns (FULL_NAME varchar(224)) as declare variable NAME varchar(180); declare variable PHONE varchar(180); begin select NAME ,PHONE from client where CLIENT_ID = :ID into :NAME, :PHONE; FULL_NAME = ''; if (NAME is not NULL) then FULL_NAME = NAME; if (PHONE is not NULL) then FULL_NAME = FULL_NAME || ' (' || PHONE || ')'; end create procedure LIST_DOC (FROM_DATE date, TO_DATE date) returns (DOC_ID integer, DOC_NUM varchar(40), DOC_DATE date, FROM_ID integer, TO_ID integer, FROM_NAME varchar(224), TO_NAME varchar(224), DOC_SUM numeric(15,4)) as begin for select DOC_ID, DOC_NUM, DOC_DATE, FROM_ID, TO_ID, DOC_SUM from DOC_TITLE where DOC_DATE >= :FROM_DATE and DOC_DATE <= :TO_DATE into :DOC_ID, :DOC_NUM, :DOC_DATE, :FROM_ID, :TO_ID, :DOC_SUM do begin FROM_NAME = NULL; TO_NAME = NULL; execute procedure CLIENT_FULL_NAME (:FROM_ID) returning_values :FROM_NAME; execute procedure CLIENT_FULL_NAME (:TO_ID) returning_values :TO_NAME; suspend; end end |
Осталась процедура для отчета:
create procedure REP_INOUT(FROM_DATE date, TO_DATE date) returns (FROM_ID integer, FROM_NAME varchar(180), TO_ID integer, TO_NAME varchar(180), FULL_SUM numeric(15,4)) as begin for select FROM_ID, TO_ID, sum(DOC_SUM) from DOC_TITLE where DOC_DATE >= :FROM_DATE and DOC_DATE <= :TO_DATE group by FROM_ID, TO_ID into :FROM_ID, :TO_ID, :FULL_SUM do begin FROM_NAME = NULL; TO_NAME = NULL; select NAME from client where CLIENT_ID = :FROM_ID into :FROM_NAME; select NAME from client where CLIENT_ID = :TO_ID into :TO_NAME; if (FULL_SUM is NULL) then FULL_SUM = 0; suspend; end end |
Процедура выдает то, что нужно для отчета, но, к сожалению, не в виде перекрестного отчета, а по строкам:
От кого | Кому | На сумму |
<Поставщик> | <Получатель> | Сумма ... |
... |
Приводить к нормальному виду все это будет сервер приложений.
Все готово для написания сервера приложений. Приступим.
Сервер приложений
Создаваемый нами сервер приложений будет отдельным исполняемым модулем. Этот модуль затем можно будет расположить на отдельном компьютере, который сможет производить расчеты для нескольких клиентов и синхронизировать их работу.
Сервер приложений должен обеспечивать обработку документа как единого объекта, поэтому разумным будет выделить работу с ним в отдельный класс, в данном случае потомок TRemoteDataModule. Нам также понадобится модуль данных для работы со справочником поставщиков и получателей, и выдачи списка документов. Отчет я решил также выделить в отдельный модуль. В итоге на сервере необходимо создать три потомка TRemoteDataModule: rdmCommon (общий модуль со списками поставщиков/получателей и документов), rdmDoc и rdmReport – соответственно для документа и отчета.
Мастер создания удаленного модуля данных предлагает по умолчанию политику загрузки исполняемого модуля Multiple instance и модель потоков Apartment. Это именно то, что нам нужно! Действительно, Instancing = Internal приведет к созданию серверного компонента в клиентском процессе (это распространяется только на сервер, создаваемый в виде DLL). При Single instance каждая клиентская часть будет соединяться со своим собственным экземпляром сервера приложений, а синхронизацию проще сделать, если все клиенты подсоединяются к одному экземпляру сервера приложений. Выбор модели потоков Apartment позволит избежать ручной синхронизации доступа к данным компонента.
Теперь остается создать три (опять три, это тоже случайно) потомка TRemoteDataModule, расположить на них компоненты доступа к данным и написать код для обработки данных.
При этом необходимо учитывать, что при использовании модели потоков Apartment каждый модуль данных работает в своем потоке, и поэтому в каждом модуле должен находится отдельный компонент TIBDatabase.
При прямом доступе провайдера к базе (свойство ResolveToDataset = false) MIDAS также требует наличия отдельной копии объекта TIBTransaction для каждого компонента доступа к данным, то есть у каждого провайдера должна быть своя транзакция. Компонент TIBTransaction специфичен для компонентов прямого доступа к Interbase, обычно работа с транзакциями возложена на компонент соединения с базой данных.
ПРИМЕЧАНИЕ При использовании сервера Interbase для доступа к данным по технологии MIDAS логично использовать IBX, провайдеры данных великолепно работают с этими компонентами. Единственное замечание – Borland сертифицировала на момент написания статьи версию IBX 4.52. Более поздние версии работают в составе MIDAS несколько иначе, чем раньше. В частности, транзакции теперь не закрываются автоматически после выборки данных. |
Рассмотрим удаленные модули данных по порядку, и начнем с модуля справочников (rdmCommon) (рисунок 2).
Рисунок 2. Общий модуль rdmCommon.
Компонент ibqDocs имеет тип TIBDatabase и обеспечивает соединение модуля с сервером БД. У меня БД находится в каталоге d:projectsdocmidasdata и называется doc.gdb. В прилагающемся к статье проекте сервер приложений позволяет указать произвольное местонахождение сервера БД и файла базы данных.
Для того, чтобы при каждом соединении сервер приложений не запрашивал имя пользователя и пароль, они просто указаны в параметрах соединения. Имя пользователя SYSDBA и пароль masterkey являются установками по умолчанию при инсталляции сервера Interbase.
Перечислим компоненты модуля. К компоненту транзакции ibtClient подсоединен запрос ibqClient (компонент TIBQuery), к которому, в свою очередь, присоединен провайдер dspClient. Соответственно, у транзакции и запроса указано соединение с БД ibdDocs. Остается только установить тип транзакции read committed (удобнее всего это сделать, дважды щелкнув на соответствующем компоненте, и выбрав его тип), и в свойстве SQL-запроса записать “select * from client”. Теперь провайдер может предоставлять клиентской части возможность работать со справочником клиентов. Но для повышения комфорта нужно добавить возможность нескольким пользователям изменять одновременно разные поля в одной и той же записи в таблице (их два: Name и Phone). Делается это довольно просто, в редакторе полей (Fields Editor) ibqClient нужно создать постоянный список всех полей запроса, и у поля CLIENT_ID в его свойство ProviderFlags добавить опцию pfInKey. Затем у провайдера dspClient установить свойство UpdateMode в upWhereChanged. В этом случае, если разные клиентские части изменят разные поля одной записи в таблице CLIENT, сервер приложений примет эти изменения. В случае, если будут изменены одни и те же поля одной записи, клиентской части будет выдано сообщение вида «Запись изменена другим пользователем».
ПРИМЕЧАНИЕ Здесь мне хотелось бы остановиться на свойствах TField.ProviderFlags и TDataSetProvider.UpdateMode. Дело в том, что меня часто спрашивают, что зависит от значений этих свойств, а зависит от них довольно много. В справке по VCL эти свойства описаны, на мой взгляд, недостаточно подробно, а связь между ними достаточно тесная. Итак, пусть имеется компонент TQuery, TIBQuery или какой-то другой (запрос), соединенный с сервером БД, и к нему присоединен TDataSetProvider. В этом случае на логику работы оказывают влияние именно значения свойства ProviderFlags полей этого запроса, аналогичные свойства полей на клиентской стороне никакого влияния не оказывают. Комбинация значений этих свойств полностью определяет, как будут производиться операции обновления данных на сервере БД. Рассмотрим обновление данных в таблице. Добавление и удаление записи происходит аналогично. Провайдер с установленным свойством ResolveToDataset = false при обновлении записи формирует SQL-запрос вида UPDATE <Table> SET <Field1>=<NewValue1>, ... WHERE <Field1>=<OldValue1> AND ..., в полном соответствии со стандартом SQL (при ResolveToDataset=True производится поиск и обновление прямо в таблице). Имя таблицы <Table> берется из Dataset (провайдер великолепно понимает запросы SQL вида Select from...), либо задается в обработчике OnGetTableName. Значения NewValue и OldValue для каждого поля берутся из пакета обновления, посылаемого провайдеру. Имена полей в выражениях SET и FROM формируются автоматически, как раз на основе свойств ProviderFlags и UpdateMode того набора данных, через который провайдер работает с базой. Алгоритм следующий: В предложение SET входят только те поля, у которых установлен флаг pfUpdate в свойстве ProviderFlags (требуется обновлять в базе данных) и OldValue <> NewValue (значение поля было изменено). Предложение WHERE формируется следующим образом: Берутся все поля, у который установлены [pfInKey, pfInWhere], фактически это первичный ключ. При UpdateMode=upWhereKeyOnly больше никаких полей не берется. При UpdateMode=upWhereChanged к полям первичного ключа добавляются те поля, у которых OldValue <> NewValue и pfWhere in ProviderFlags, что позволяет делать проверку на изменение тех же полей другим пользователем. При UpdateMode=upWhereAll в список полей WHERE входят все поля записи, у которых pfWhere in ProviderFlags. В случае, если запись в таблице на сервере не найдена (нет записей, удовлетворяющих условию WHERE), пользователю выдается сообщение вида "Запись изменена другим пользователем", вне зависимости от причины. Остается одно значение флага, pfHidden. Поля с этим флагом не передаются клиентскому приложению, и не принимаются от него, флаг указывает, что эти поля - только для использования на стороне сервера. |
Если уж создан постоянный список полей, можно установить параметры их отображения на клиентской части, в частности, DisplayLabel, DisplayWidth и Visible, а у провайдера - флаги poIncFieldProps. При этом на клиентской части можно не заботиться о списке полей – значения, полученные с сервера приложений, переопределяют заданные на клиенте в любом случае. Заодно у провайдера надо установить опцию poMultiRecordUpdates, чтобы на клиентской части можно было изменять сразу несколько записей в справочнике до отправки изменений на сервер.
Поле CLIENT_ID в справочнике поставщиков и получателей является первичным ключем, а стало быть, в нем должны содержаться уникальные значения. Для получения уникальных значений удобно использовать автоинкрементальные поля (autoincrement field). В IB собственно автоинкрементных полей нет, нарастающие значения получают от генератора с помощью функции Gen_ID, и как правило, присваивают это значение полю в триггере. Мне нравится ситуация, когда новое уникальное значение появляется на клиентской части сразу после добавления записи. Поэтому вместо присвоения значения, полученного от генератора, в триггере, используется хранимая процедура, результатом работы которой и является это значение. Для этого в удаленном модуле данных расположен компонент spNewID: TIBStoredProc, присоединенный к компоненту транзакции ibtDefault, который предоставляет доступ к хранимой процедуре на сервере БД. Процедура описана в базе данных следующим образом:
create procedure CLIENT_ID returns (ID integer) as begin ID = Gen_ID(CLIENT_ID_GEN,1); end |
Как видно, процедура просто выдает следующее значение генератора. Это гарантирует, что при последовательных запросах к ней это значение повторяться не будет. Получение значения на клиентской части обеспечивается методом сервера, об этом немного ниже.
Вторая хранимая процедура, spClientFullName, присоединена к компоненту транзакции ibtClient и предназначена для выдачи имени и телефона поставщика или получателя в виде единой строки «Имя (телефон)», возвращаемой процедурой сервера БД CLIENT_FULL_NAME. Эта строка также передается на клиентскую часть через метод сервера.
Группа компонентов ibtDocList, ibqDocList, dspDocList и ibqDelDoc предназначена для работы со списком документов. У IbtDocList, компонента транзакции, установлен режим read committed, а в компоненте ibqDocList содержится SQL-запрос «select * from List_doc(:FromDate, :ToDate)». Весь список документов сразу выводить довольно бессмысленно, их может быть много. Поэтому запрос выбирает список документов, даты которых лежат в промежутке от FromDate до ToDate. Провайдер dspDocList выдает этот список клиентской части.
Дополнительный компонент, ibqDelDoc, как, думаю, видно из его названия, предназначен для удаления документа, в его свойстве SQL стоит запрос «delete from DOC_TITLE where DOC_ID = :DOC_ID». Несмотря на то, что для создания и изменения документа планируется использовать отдельный модуль, rdmDoc, для удаления документа вовсе необязательно его открывать, и с точки зрения интерфейса пользователя удобно делать это прямо из списка документов. На первый взгляд, использование отдельного запроса для удаления кажется излишним, для этого обычно достаточно объявить в обработчике dspDocList.OnGetTableName имя таблицы (DOC_TITLE), и удаление будет автоматически обеспечено. Однако в постановке задачи стоит условие, что открытый в одной клиентской части документ должен быть недоступен для изменения (а значит, и удаления) из других клиентских частей. Поэтому приходится делать это в обработчике события dspDocList.OnBeforeUpdateRecord следующим образом:
procedure TrdmCommon.dspDocListBeforeUpdateRecord(Sender: TObject; SourceDS: TDataSet; DeltaDS: TClientDataSet; UpdateKind: TUpdateKind; var Applied: Boolean); var DocID: Integer; begin if UpdateKind = ukDelete then //Толькоеслизаписьудаляется begin DocID := DeltaDS.FieldByName('DOC_ID').AsInteger; try if not RegisterDoc(DocID) then //Пытаемсязарегистрировать raise Exception.Create('Документредактируется'); with ibqDelDoc do //Удаляем begin paramByName('DocID').AsInteger := DocID; ExecSQL; end; Applied := True; finally UnregisterDoc(DocID); //Изменениезакончено, удалили end; end; end; |
Если удаляется документ, попытаемся его зарегистрировать в списке редактируемых функцией RegisterDoc, затем, если это получилось, удаляем его с помощью запроса ibqDelDoc и удаляем из списка редактирования (UnregisterDoc). Устанавливаем Applied := true, чтобы сказать провайдеру, что все уже сделано.
Конечно, одновременно может редактироваться (удаляться, добавляться) довольно много документов, поэтому нужен единый список этих документов, к которому будут обращаться процедуры RegisterDoc и UnregisterDoc. Поскольку обращение к нему будет производиться из модулей данных, работающих в разных потоках, то наилучшим образом для этого подходит TThreadList (потокобезопасный класс списка). Список документов должен быть единым для всех клиентских частей, поэтому расположить его нужно в отдельном модуле, например, в модуле главной формы сервера. На ней потом можно вывести, например, список редактируемых на данный момент документов. Так и сделаем.
В модуле главной формы сервера в разделе implementation объявим переменную DocList: TThreadList; Этот список лучше инициализировать сразу при запуске сервера и уничтожать при выходе:
initialization DocList := TThreadList.Create; finalization if Assigned(DocList) then begin DocList.Free; DocList := nil; end; end. |
С этим списком работают две функции: RegisterDoc и UnregisterDoc :
function RegisterDoc(DocID: integer): boolean; begin Result := False; if DocID = 0 then Exit; with DocList.LockList do try if IndexOf(Pointer(DocID)) < 0 then begin Add(Pointer(DocID)); Result := True; end; finally DocList.UnlockList; end; end; function UnregisterDoc(DocID: integer): boolean; begin Result := False; if DocID = 0 then Exit; with DocList.LockList do try if IndexOf(Pointer(DocID)) >= 0 then begin Remove(Pointer(DocID)); Result := True; end; finally DocList.UnlockList; end; end; |
В списке хранятся идентификаторы документов. Но TThreadList предназначен для хранения указателей. Поэтому для хранения в этом списке идентификатора, имеющего тип Integer, придется привести его к типу pointer. Конечно, если потребуется хранить дополнительную информацию о документе, например, его номер, придется организовать в списке ссылки на записи, с выделением памяти под эту запись и уничтожением ненужных записей. При этом внешний вид функций не изменится, просто усложнится работа со списком, и может понадобиться обращение к БД для получения дополнительной информации.
Теперь все просто: все модули данных, которые работают с документами, используют эти две функции, и если RegisterDoc возвращает false (а это произойдет только в том случае, если номер уже есть в списке), то пользователю выдается сообщение, что с документом уже работают. Функция UnregisterDoc просто удаляет номер из списка.
На клиенте понадобится, кроме доступа к двум провайдерам, еще пара функций – получение нового значения CLIENT_ID для справочника клиентов и получение полного имени клиента. Для этого необходимо создать описание этих функций в библиотеке типов.
В зависимости от того, какой синтаксис используется в редакторе библиотеки типов (IDL или Pascal), объявление этих функций выглядит по-разному, ниже приведены их описания в protected-секции модуля данных:
protected class procedure UpdateRegistry(Register: Boolean; const ClassID, ProgID: string); override; function NewClientID: Integer; safecall; function Get_ClientName(ClientID: Integer): WideString; safecall; |
На IDL это выглядит так:
[id(0x00000001)] HRESULT _stdcall NewClientID([out, retval] long * Result); [propget, id(0x00000004)] HRESULT _stdcall ClientName([in] long ClientID, [out, retval] BSTR * Value); |
Реализация этих функций довольно проста. Надо вызвать хранимые процедуры, и выдать возвращаемое ими значение в качестве результата:
function TrdmCommon.NewClientID: Integer; begin lock; with spNewID do try ExecProc; Result := paramByName('ID').AsInteger; finally unlock; end; end; function TrdmCommon.Get_ClientName(ClientID: Integer): WideString; begin lock; try with spClientFullName do begin paramByName('ID').AsInteger := ClientID; ExecProc; Result := paramByName('FULL_NAME').AsString; end; finally unlock; end; end; |
Теперь основной модуль готов, и можно перейти к написанию следующего модуля данных, предназначенного для работы с документом.
Рисунок 3.
Здесь уже все немного сложнее. Разумеется, здесь тоже есть соединение с базой ibdDoc, настроенное на сервер БД. Хранимая процедура spNewID выдает на этот раз номер для нового документа, используя процедуру DOC_TITLE_ID, аналогичную процедуре CLIENT_ID.
На этот раз в модуле данных, помимо компонентов запросов к серверу, присутствуют два компонента TСlientDataSet и два провайдера данных. Эти дополнительные компоненты предназначены именно для организации расчетов на сервере. Поскольку, как мы договорились, на сервере приложений должна рассчитываться сумма документа, то на нем должно быть известно содержимое документа до того, как оно будет сохранено в БД. Разумеется, это можно осуществить, используя события провайдера для предварительной обработки пакета изменений, поступившего от клиентской части, но мне хотелось показать возможность организации работы с документом как с полноценным объектом.
Идея простая: пусть весь удаленный модуль данных работает с одним документом. В таком случае этот модуль будет выглядеть для клиентской части как полноценный объект, владеющий всеми данными документа и предоставляющий клиентской части все необходимые свойства. Разумеется, от пакетов данных никто не отказывается.
Таким образом, организуется следующий алгоритм работы: Клиентская часть создает на сервере либо новый документ, либо открывает существующий (удаление документов уже реализовано). Сервер приложений создает модуль данных и, если необходимо, закачивает в него содержимое документа с сервера БД. После этого клиентская часть и удаленный модуль данных совместно обрабатывают эти данные, занимаясь каждый своим делом: клиентская часть предоставляет средства для изменения этих данных пользователем, а удаленный модуль производит все необходимые расчеты.
К одному компоненту транзакции ibtDoc присоединено на этот раз два запроса ibqTitle и ibqBody, соответственно выбирающих одну строку заголовка документа (select * from DOC_TITLE where DOC_ID = :DocID) и все строки этого документа (select * from DOC_BODY where DOC_ID = :DocID).
ПРИМЕЧАНИЕ Хотя MIDAS требует наличия своей IBTransaction для каждой пары компонентов "IBQuery-провайдер", в данном случае это необязательно. Провайдеры не будут начинать и завершать транзакции, открываться и закрываться транзакция будет явно, в соответствующих методах. |
К этим запросам присоединены провайдеры dspTitleInner и dspBodyInner, назначение которых – получить данные с сервера БД и передать их в соответствующие ClientDataSet. Свойство Exported у этих провайдеров установлено в false, они нужны только внутри сервера приложений, и видеть их на клиентской части незачем. Соответственно, клиентский набор данных cdsTitle (компонент TClientDataSet) получает одну строку заголовка из dspTitle и cdsBody, содержимое документа из dspBody.
Для того, чтобы клиентская часть могла получать и изменять данные документа, к клиентским наборам данных cdsTitle и cdsBody присоединены провайдеры данных, dspTitle и dspBody, соответственно. Свойству Exported этих провайдеров оставлено значение по умолчанию, True, зато свойство ResolveToDataSet установлено в True, для того, чтобы эти провайдеры не пытались работать с ClientDataSet с помощью запросов. Таким образом, клиентская часть может получать и изменять данные не из TIBQuery, но из TClientDataSet, причем совершенно об этом не догадываясь. По команде с клиентской части изменения, передаются серверу приложений, который и сохраняет их в БД.
Теперь посмотрим, что нам нужно для подобной реализации. Функции для синхронизации обработки документов RegisterDoc и UnregisterDoc уже есть, нужно их только использовать. С их помощью гарантируется, что одновременно один и тот же документ редактироваться не будет, поэтому у провайдеров данных dspTitleInner и dspTitleBody достаточно установить UpdateMode = upWhereKeyOnly, и указать ключевые поля у запросов. Содержимое документа может состоять из нескольких строк, поэтому у dspBodyInner и dspBody нужно установить флаг poAllowMultiRecordUpdates. Теперь нужно разобраться с полями клиентских наборов данных, установив у них соответствующие свойства. Я остановлюсь здесь только на свойстве ProviderFlags. Поскольку поле «Ссылка на документ» (DOC_ID) на клиентской части не нужно, ему можно задать флаг pfHidden. Разумеется, у всех ключевых полей (DOC_ID и LINE_NUM) и в наборе данных заголовка, и в содержимом документа надо указать флаг pfInKey. У провайдеров dspTitle и dspBody нужно установить политику обновления UpdateMode = upWhereKeyOnly, клиентская часть у модуля данных одна, и другие значения совершенно ни к чему.
Теперь компоненты для хранения и обработки данных подготовлены, осталось написать сами методы работы с ними.
Давайте разберемся, что именно требуется. Модуль rdmDoc предназначен как для создания нового документа, так и для редактирования существующего. Этот модуль можен находиться в одном из трех состояний, описанных в перечислении TObjState:
osInactive: данных нет, документ не редактируется,
osInsert: создан новый документ и
osUpdate – происходит изменение существующего документа.
Состояние хранится в переменной Fstate, находящейся внутри модуля. Сразу после создания и после окончания обработки документа модуль данных
Переход из одного состояния в другое должен обеспечиваться соответствующими методами. Я назвал эти методы DoInactiveState (перевод в неактивное состояние), DoOpen (открыть существующий документ) и DoCreateNew (создание нового документа). При редактировании или добавлении документа нужно знать его уникальный номер, записываемый в поле DOC_ID. Для этого достаточно объявить в секции private переменную FDocID: integer, которая и будет его хранить.
В библиотеке типов нужно реализовать методы, которые будут создавать документ или открывать существующий, а также сохранять изменения. Кроме этого, понадобится свойство, позволяющее получить в любой момент сумму по документу. Сумма каждой строки содержимого пусть рассчитывается на клиентской части.
Итак, приступим. Сначала описываются методы перехода между состояниями, они предназначены для внутреннего использования, и поэтому их объявления содержатся в секции private:
procedure DoInactiveState; procedure DoCreateNew; procedure DoOpen(DocID: integer); |
Рассмотрим их по порядку.
procedure TrdmDoc.DoInactiveState; begin UnregisterDoc(FDocID); FDocID := 0; cdsTitle.Active := False; cdsBody.Active := False; ibtDoc.Active := False; FState := osInactive; end; |
Процедура DoInactiveState удаляет документ из списка редактируемых, закрывает все клиентские наборы данных, а также производит откат транзакции (если она была активна).
procedure TrdmDoc.DoOpen(DocID: Integer); begin if DocID = 0 then Exit; try if not RegisterDoc(DocID) then raise Exception.Create('Документредактируется'); FDocID := DocID; // итолькоздесь, иначе DoInactiveState удалитдокумент ibdDocs.Connected := True; ibtDoc.StartTransaction; with cdsTitle do begin params.paramByName('DocID').AsInteger := FDocID; Active := True; if BOF and EOF then raise Exception.Create('Документненайден'); end; with cdsBody do begin params.paramByName('DocID').AsInteger := FDocID; Active := True; end; FState := osUpdate; ibtDoc.Commit; except DoInactiveState; raise; end; end; |
DoOpen предназначена для открытия существующего документа, идентификатор DOC_ID которого равен входному параметру DocID. Первым делом с помощью RegisterDoc производится проверка того, что документ в данный момент не редактируется. Затем идентификатор документа запоминается, и в клиентские наборы данных загружаются данные документа. В случае ошибки состояние документа переводится в osInactive.
procedure TrdmDoc.DoCreateNew; var NewDocID: Integer; begin try NewDocID := NewID; if not RegisterDoc(NewDocID) then raise Exception.Create('Документредактируется'); FDocID := NewDocID; ibdDocs.Connected := True; ibtDoc.StartTransaction; with cdsTitle do begin params.paramByName('DocID').AsInteger := FDocID; Active := True; Append; Post; end; with cdsBody do begin params.paramByName('DocID').AsInteger := FDocID; Active := True; end; ibtDoc.Commit; FState := osInsert; except DoInactiveState; raise; end; end; |
Процедура DoCreateNew предназначена для создания нового документа. Она практически аналогична предыдущей, за исключением того, что идентификатор документа получается от сервера БД с помощью процедуры NewID, которая обращается к хранимой процедуре на сервере. Реализация процедуры DoCreateNew очень похожа на аналогичную реализацию в rdmCommon.
Для того, чтобы вставка новой записи в документ происходила верно, достаточно написать обработчик cdsTitle.OnNewRecord, задающий начальное значение полей записи, и практически такой же обработчик для cdsBody:
procedure TrdmDoc.cdsTitleNewRecord(DataSet: TDataSet); var Day, Month, Year: Word; begin DecodeDate(Date, Year, Month, Day); with cdsTitle do begin FieldByName('DOC_ID').AsInteger := FDocID; FieldByName('DOC_NUM').AsString := IntToStr(FDocID) + '/' + IntToStr(Year); FieldByName('DOC_DATE').asDateTime := Date; FieldByName('DOC_SUM').asCurrency := 0; FieldByName('FROM_ID').AsInteger := 0; FieldByName('TO_ID').AsInteger := 0; end; end; procedure TrdmDoc.cdsBodyNewRecord(DataSet: TDataSet); begin cdsBody.FieldByName('DOC_ID').AsInteger := FDocID; end; |
В дополнение ко всему нужна еще одна процедура в секции private, для подсчета суммы документа:
function TrdmDoc.CalcSum: Currency; begin Result := 0; if not cdsBody.Active then Exit; with cdsBody do begin First; while not EOF do begin Result := Result + FieldByName('COUNT_NUM').asCurrency * FieldByName('PRICE').asCurrency; Next; end; end; end; |
В функции CalcSum просматривается содержимое документа и рассчитывается общая сумма, которая возвращается в качестве результата.
Теперь надо позаботиться о клиентской части, то есть создать необходимые внешние методы сервера в библиотеке типов. Описание этих методов, созданное редактором библиотек типов, выглядит следующим образом:
protected function ApplyChanges: WideString; safecall; function Get_DocID: Integer; safecall; procedure CreateNewDoc; safecall; procedure Set_DocID(Value: Integer); safecall; function Get_DocSum: Currency; safecall; |
Функциональность этих методов такова:
ApplyChanges – сохраняет текущий документ в БД.
DocID – свойство, доступное на запись и чтение При чтении выдается текущий ID документа (FDocID). При изменении значения свойства документ открывается для редактирования с ID, равным новому значению. Если значение свойства равно 0, документ закрывается, и модуль переводится в неактивное состояние.
CreateNewDoc – создает новый документ (вызывает методы DoInactiveState и DoCreateNew).
DocSum – выдается текущая сумма документа, результат работы метода CalcSum.
Реализация этих методов довольно проста, все основные процедуры уже есть, сложность представляет только функция ApplyChanges:
function TrdmDoc.ApplyChanges: WideString; begin lock; try FLastUpdateErrors := ''; if FState = osInactive then raise Exception.Create('Нет нового или открытого документа'); // Вычисляем итоговую сумму документа with cdsTitle do begin Edit; FieldByName('DOC_SUM').asCurrency := CalcSum; Post; end; RenumLines; // перенумерация содержимого // Сохранение в БД... ibtDoc.StartTransaction; // При вставке сначала сохраняем изменения в cdsTitle... if FState = osInsert then begin if cdsTitle.ChangeCount > 0 then cdsTitle.ApplyUpdates(0); if cdsBody.ChangeCount > 0 then cdsBody.ApplyUpdates(-1); end; // ...а при изменении – в cdsBody. if FState = osUpdate then begin if cdsBody.ChangeCount > 0 then cdsBody.ApplyUpdates(-1); if cdsTitle.ChangeCount > 0 then cdsTitle.ApplyUpdates(0); end; // FLastUpdateErrors заполняетсяна OnReconcileError. Result := FLastUpdateErrors; if Result = '' then ibtDoc.Commit else begin ibtDoc.Rollback; end; finally ibtDoc.Active := False; unlock; end; end; |
Дело в том, что изменение данных в БД происходит не в методе провайдера, а в методе модуля, и клиентские наборы данных ничего об этом не знают. Поэтому функция ApplyChanges возвращает список ошибок, возникших при обновлении данных. Список накапливается в переменной FLastUpdateErrors, описанной в секции private как FLastUpdateErrors: String;. Перед сохранением изменений рассчитывается сумма документа. Процедура RenumLines нумерует строки содержимого по порядку. Это просто дополнительный сервис. Затем ClientDataSet-ы пытаются сохранить изменения в БД. При возникновении ошибки заполняется поле FLastUpdateErrors:
procedure TrdmDoc.cdsTitleReconcileError(DataSet: TClientDataSet; E: EReconcileError; UpdateKind: TUpdateKind; var Action: TReconcileAction); begin Action := raCancel; FLastUpdateErrors := FLastUpdateErrors + 'Заголовок: ' + E.Message + #13#10; end; procedure TrdmDoc.cdsBodyReconcileError(DataSet: TClientDataSet; E: EReconcileError; UpdateKind: TUpdateKind; var Action: TReconcileAction); begin Action := raCancel; FLastUpdateErrors := FLastUpdateErrors + 'Содержимое: ' + E.Message + #13#10; end; |
При этом происходит откат транзакции. Сообщения об ошибке записываются в строку. В случае возникновения ошибки клиент должен вывести сообщение и обновить клиентские наборы данных. Как будет видно ниже, в данном случае все проверки можно сделать заранее, и практически возможны только ошибки, связанные с непредвиденными обстоятельствами (например, неожиданный разрыв соединения с сервером БД).
Процедура RenumLines перенумерует строки содержимого документа так, чтобы номера шли по порядку, причем все номера сначала делаются отрицательными, иначе при попытке запомнить вторую запись с тем же ключем сразу генерируется исключение Key violation, что, разумеется, совершенно не нужно (Дело в том, что провайдер великолепно знает, какие поля составляют первичный ключ, вот и контролирует – у ClientDataSet создается контроль первичного ключа. Исключение генерируется сразу, при попытке вставки (до записи в БД)):
procedure TrdmDoc.RenumLines; var Num: Integer; begin cdsBody.IndexFieldNames := 'DOC_ID;LINE_NUM'; // Чтобы избежать Key violation при перенумерации, делаем все номера < 0 // На клиенте нужна проверка LINE_NUM >= 0 cdsBody.Last; with cdsBody do while FieldByName('LINE_NUM').AsInteger > 0 do begin Edit; Num := FieldByName('LINE_NUM').AsInteger; FieldByName('LINE_NUM').AsInteger := -num; Post; Last; end; // перенумерация... Num := cdsBody.RecordCount; cdsBody.First; with cdsBody do while FieldByName('LINE_NUM').AsInteger <= 0 do begin Edit; FieldByName('LINE_NUM').AsInteger := num; Post; Dec(Num); First; end; end; |
Разумеется, и вычисление суммы документа, и перенумерацию содержимого можно сделать на клиентской части, но этот пример создавался именно чтобы показать перенос вычислений на сервер. При более сложных вычислениях это гораздо выгоднее, например, если в расчетах используются данные из дополнительных таблиц.
Остается последний модуль данных сервера, rdmReport, предназначенный для создания отчета. По сравнению с предыдущими модулями он довольно прост (рисунок 4.).
Рисунок 4.
Здесь находится всего один компонент транзакции ibtInOut и один компонент запроса ibqInOut, обращающийся к процедуре отчета:
select * from REP_INOUT(:FromDate, :ToDate) order by TO_NAME |
При этом необходимо учитывать, что данные из этой процедуры получаются совершенно не в том виде, который нужен, и нуждаются в дополнительной обработке. Такую дополнительную обработку лучше осуществлять на стороне клиента, так как это потенциально позволяет передавать данные в более компактном виде, да и само представление данных является частью презентационной логики. Но этот пример создавался, чтобы продемонстрировать, в основном, работу серверной стороны. Поэтому обработку данных мы будем производить на сервере. cdsInOut – это компонент ClientDataSet, в котором формируется отчет в том виде, в котором он должен быть отображен клиенту. К этому компоненту подсоединен провайдер dspInOut с установленным флагом poIncFieldProps. Его свойство Exported равно false. От провайдера требуется только генерация пакета данных. И, как обычно, ResolveToDataSet = true. cdsInOut не соединен ни с каким провайдером (свойство ProviderName пустое), и должен создаваться явно вызовом своего метода CreateDataSet. Для того, чтобы набор данных содержал поля, их описания должны содержаться в свойстве FieldDefs. Но по той причине, что в отчете-шахматке количество полей в записи заранее неизвестно, их описания приходится создавать динамически при обработке результата запроса. Для этого удобно создать отдельный метод, CollectInOutData:
function TrdmReport.CollectInOutData: OleVariant; const FieldPrefix = 'Receiver_'; var ReceiverFieldName: string; RecsOut: Integer; ProvOptions: TGetRecordOptions; begin cdsInOut.Active := False; try with cdsInOut.FieldDefs do begin Clear; // Первые две колонки - поставщик with AddFieldDef do begin Name := 'SenderID'; DataType := ftInteger; Required := True; end; with AddFieldDef do begin Name := 'SenderName'; DataType := ftString; Size := 180; end; // Теперь набор полей - получатели ibqInOut.First; while not ibqInOut.EOF do begin ReceiverFieldName := FieldPrefix + ibqInOut.FieldByName('TO_ID').AsString; if IndexOf(ReceiverFieldName) = -1 then with AddFieldDef do begin Name := ReceiverFieldName; DataType := ftCurrency; end; ibqInOut.Next; end; end; // Второй проход - заполнение суммами cdsInOut.IndexFieldNames := 'SenderID'; cdsInOut.CreateDataSet; with cdsInOut do begin ibqInOut.First; while not ibqInOut.EOF do begin if FindKey([ibqInOut.FieldByName('FROM_ID').AsInteger]) then Edit else Insert; ReceiverFieldName := FieldPrefix + ibqInOut.FieldByName('TO_ID').asString; if State = dsInsert then FieldByName('SenderID').AsInteger := ibqInOut.FieldByName('FROM_ID').AsInteger; FieldByName('SenderName').AsString := ibqInOut.FieldByName('FROM_NAME').AsString; with (FieldByName(ReceiverFieldName) as TFloatField) do begin asCurrency := ibqInOut.FieldByName('FULL_SUM').AsCurrency; // пока свойства заголовка не установлены if DisplayFormat = '' then // установимих begin DisplayLabel := ibqInOut.FieldByName('TO_NAME').AsString; DisplayWidth := 10; Currency := False; DisplayFormat := '# ##0.00'; end; end; Post; ibqInOut.Next; end; // названиепервойколонки with FieldByName('SenderName') do begin DisplayLabel := 'Поставщики'; DisplayWidth := 30; end; FieldByName('SenderID').Visible := false; end; // Пусть провайдер позаботится о формировании пакета. ProvOptions := [grMetadata, grReset]; Result := dspInOut.GetRecords(-1,RecsOut,Byte(ProvOptions)); finally cdsInOut.Active := False; end; end; |
Хотя эта функция выглядит длинной и сложной, делается очень немного: организуется два прохода по ibqInOut, который к этому времени должен содержать результат выполнения хранимой процедуры. Предварительно создается два обязательных поля - SenderID и SenderName (ID и наименование поставщика). Во время первого прохода у cdsInOut создается список колонок (в FieldDefs) с именами вида 'Receiver_NN'. Затем создается набор данных командой CreateDataSet и организуется второй проход, в котором ячейки заполняются значениями сумм. При этом производится поиск поставщика по SenderID (с использованием индекса), если такого поставщика еще нет – добавляется запись. Затем ячейке таблицы (с соответствующим Receiver_ID) присваивается сумма, полученная из хранимой процедуры. Попутно устанавливаются визуальные свойства полей. После прохода по результату запросу выставляются визуальные свойства первых двух колонок. Наконец, функция dspInOut.GetRecords возвращает ClientDataSet (вместе со свойствами полей), содержащий готовыйй отчет. Провайдер dspInOut нужен только чтобы в пакет были включены визуальные свойства полей. Для этого используется флаг grMetadata, а данные получаются прямым вызовом метода GetRecords. После получения пакета клиентский набор данных можно благополучно закрыть, что, собственно, и делается.
Для передачи содержимого отчета на клиентскую часть в библиотеке типов создается один метод, объявленный как:
function InOutData(FromDate, ToDate: TDateTime): OleVariant; safecall; |
Этот метод принимает параметры отчета, и выдает весь отчет, запакованный в OleVariant:
function TrdmReport.InOutData(FromDate, ToDate: TDateTime): OleVariant; begin lock; try ibdReport.Connected := True; ibtInOut.StartTransaction; try with ibqInOut do begin ParamByName('FromDate').asDateTime := FromDate; ParamByName('ToDate').asDateTime := ToDate; Active := True; Result := CollectInOutData; Active := False; end; ibtInOut.Commit; finally ibtInOut.Active := False; end; finally unlock; end; end; |
Функция InOutData устанавливает параметры запроса и выполняет его, после чего вызывает функцию CollectInOutData, которая выполняет основную работу.
На этом этапе сервер приложений полностью закончен, и можно, запустив его один раз для регистрации в реестре как СОМ-сервера, приступать к созданию клиентской части.
Клиент
Задача клиентского приложения – взаимодействовать с пользователем и отображать нужную ему информацию.
Интерфейс клиента может быть каким угодно, поэтому остановлюсь только на особенностях работы с данным сервером приложений.
В прилагаемых исходных текстах имеется клиентское приложение, содержащее три модуля данных (TdataModule), dmCommon, dmDoc и dmReport. Каждый из них предназначен для соединения с соответствующим удаленным модулем данных.
Я не буду здесь останавливаться подробно на описаниях реализации клиентской части, но некоторые особенности необходимо рассмотреть.
Для использования сервера приложений его библиотека типов импортирована в клиентское приложение.
ПРИМЕЧАНИЕ Дело в том, что для соединения клиентского приложения с сервером в данном случае используется TSocketConnection (scDoc). При обращении к интерфейсу удаленного модуля как к variant (через свойство AppServer) вызовы методов сервера в некоторых случаях вызывают сбой (Access violation). Поэтому все вызовы я произвожу через dispinterface, имя которого отличается от имени исходного интерфейса суффиксом Disp. Импорт библиотеки типов как раз и позволяет обращаться к этому интерфейсу. Кроме того, при обращении к серверу с импортированной библиотекой типов все параметры процедур проверяются на этапе компиляции, и вызов GetDispIDsOfNames не производится, что ускоряет вызовы методов. Для импорта надо выбрать пункты меню Project -> Import Type Library, ивыбратьвсписке DocServer library. Не забудьте, что сервер при этом должен быть зарегистрирован в реестре. После этого остается отключить опцию Generate Component Wrapper и нажать Create Unit, поскольку компонент в данном случае не нужен, достаточно только объявлений. |
Работа с поставщиками и получателями
Свойство DMCommon.ClientName обеспечивает обращение к методу сервера:
property ClientName[ID: integer]: string read GetClientName; function TDMCommon.GetClientName(ID: integer): string; var AServer: IrdmCommonDisp; begin Result := ''; if ID = 0 then Exit; AServer := IrdmCommonDisp(scCommon.GetServer); Result := AServer.ClientName[ID]; AServer := nil; end; |
Компонент scCommon: TSocketConnection после соединения с сервером приложений выдает в качестве результата метода GetServer ссылку на интерфейс удаленного модуля данных, остается просто преобразовать ее к нужному типу.
Получение нового идентификатора для поставщика и получателя производится в обработчике события OnNewRecord:
procedure TDMCommon.cdsClientNewRecord(DataSet: TDataSet); var AServer: IrdmCommonDisp; begin AServer := IrdmCommonDisp(scCommon.GetServer); cdsClient.FieldByName('CLIENT_ID').AsInteger := AServer.NewClientID; AServer := nil; end; |
Работа с документами
Удаление документа происходит прямо из списка. Это делается в обработчике события компонента TAction. А вот редактирование и добавление нового документа производится в отдельном модуле DMDoc, привязанном к rdmDoc:
procedure TDMCommon.actDelDocExecute(Sender: TObject); begin with cdsDocList do begin Delete; ApplyUpdates(0); end; end; function TDMDoc.ProcessDoc(DocID: Integer; NewDoc: Boolean): boolean; var AServer: IrdmDocDisp; begin AServer := IrdmDocDisp(scDoc.GetServer); // scDoc: TSocketConnection if NewDoc then AServer.CreateNewDoc else AServer.DocID := DocID; try cdsTitle.Active := True; cdsBody.Active := True; RecalcDocSum; Result := ShowEditForm; cdsTitle.Active := false; cdsBody.Active := false; finally AServer.DocID := 0; // Отмена регистрации документа end; end; |
Как уже говорилось, если DocID становится равным 0, сервер закрывает документ.
Сумма документа запрашивается с сервера:
procedure TDMDoc.RecalcDocSum; begin with cdsBody do // Свежие изменения посылаются на сервер if ChangeCount > 0 then ApplyUpdates(-1); with cdsTitle do begin if not (State in [dsEdit, dsInsert]) then Edit; FieldByName('Summa').asCurrency := GetDocSum; end; end; function TDMDoc.GetDocSum: Currency; var AServer: IrdmDocDisp; begin AServer := IrdmDocDisp(scDoc.GetServer); Result := AServer.DocSum; end; |
Поле Summa в клиентском наборе данных – вычисляемое, при этом его тип (свойство FieldKind) установлен в fkInternalCalc, что позволяет работать с этим полем, как с обычным полем данных, используя методы Edit и Post. Значение для него создается не в обработчике OnCalcFields, как требуется для типа fkCalculated, а непосредственно при редактировании записи. Хотя такой способ хорошим не назовешь, руководство VCL рекомендует использовать OnCalcFields, принципиальных различий нет, internalCalc-поля вычисляются только при вызове Post, однократно. Второй способ создания поля - сделать calculated Fields на сервере, и установить у них ProviderFlags = []; в этом случае поля на клиенте будут иметь тип fkData (данные записи), и с ними также можно работать, как с обычными полями данных.
Для показа значения поля "Поставщик" удобно воспользоваться процедурой из модуля DMCommon:
procedure TDMDoc.SetSenderName(Value: integer); begin with cdsTitle do begin if not (State in [dsEdit, dsInsert]) then Edit; if Value <> 0 then FieldByName('FROM_ID').AsInteger := Value else FieldByName('FROM_ID').Clear; FieldByName('FromName').AsString := DMCommon.ClientName[FieldByName('FROM_ID').AsInteger]; //и оставляем в режиме редактирования end; end; |
Поле, содержащее имя поставщика (FromName), также вычисляемое (InternalCalc). В поле FROM_ID содержится ID поставщика, а в поле FromName – его полное наименование. Аналогичная процедура заполняет поля для Получателя.
После редактирования документа изменения должны отсылаться на сервер для сохранения в базе данных:
function TDMDoc.ApplyDoc: boolean; var AServer: IrdmDocDisp; ErrorLog: string; begin with cdsTitle do begin if State in [dsEdit, dsInsert] then Post; if ChangeCount > 0 then ApplyUpdates(0); end; with cdsBody do begin if State in [dsEdit, dsInsert] then Post; if ChangeCount > 0 then ApplyUpdates(-1); end; AServer := IrdmDocDisp(scDoc.GetServer); ErrorLog := AServer.ApplyChanges; if ErrorLog <> '' then begin MessageDlg('Произошли следующие ошибки:'#13#10 + ErrorLog, mtError, [mbOK], 0); //обновляем cdsBody.Active := False; cdsTitle.Active := False; cdsTitle.Active := True; cdsBody.Active := True; end; Result := ErrorLog = ''; end; |
Сначала все сделанные изменения отсылаются на сервер вызовом методов клиентских наборов данных ApplyUpdates. Затем вызывается метод сервера ApplyChanges, который сохраняет изменения в БД. При наличии ошибок их список помещается в переменную ErrorLog и отображается пользователю.
Отчет
Необходимо получить от сервера содержимое отчета и присвоить результат запроса свойству TClientDataSet.Data с пустым набором полей. Автоматически будет создан список полей с необходимыми заголовками:
procedure TDMReport.RefreshInOut(FromDate, ToDate: TDateTime); begin scReport.Connected := True; with cdsInOutRes do begin Active := False; Data := FServer.InOutData(int(FromDate), int(ToDate)); Active := True; end; end; |
Здесь scReport – компонент TSocketConnection, настроенный на соединение с удаленным модулем rdmReport, а компонент cdsInOutRes предназначен для получения результата. Поля этого набора данных создаются динамически на основе полученного от сервера пакета.
Переменная FServer получает свое значение в событии OnAfterConnect компонента scReport:
procedure TDMReport.scReportAfterConnect(Sender: TObject); begin FServer := IrdmReportDisp(scReport.GetServer) end; |
В результате на клиенте получается ClientDataSet, полностью заполненный данными отчета, которые можно показать в DBGrid. Примерный результат можно увидеть на рисунке 5.
Рисунок 5. Внешний вид отчета.
Заключение
Технологию MIDAS стоит применять в средних и больших приложениях, база данных которых насчитывает десятки и сотни таблиц. Именно в этом случае применение MIDAS дает значительные преимущества в разработке и сопровождении приложения. Какие же преимущества дает использование MIDAS:
Разумеется, возникает дополнительное структурирование приложения. Сервер приложений работает с базой данных. На нем лежит ответственность за сохранение целостности данных и за синхронизацию работы пользователей. На клиентской части остаются средства представления данных пользователю и контроль ввода данных.
При разработке приложения можно сначала создать модули сервера приложений, провести тесты на работоспособность, и только потом написать клиентскую часть.
Независимость от конкретной СУБД. Действительно, на рабочих станциях не нужно устанавливать клиентскую часть сервера баз данных, соединение с сервером приложений обеспечивается одной библиотекой midas.dll. При переходе на другой сервер БД достаточно переписать только сервер приложений, не затрагивая клиентскую часть. Может оказаться полезным добавление еще одного слоя абстракции данных между сервером приложений и сервером БД, обеспечивающего просто доступ к элементам базы. В этом случае при переходе на другой сервер БД нужно переписать только отдельные модули, не затрагивая сервер приложений в целом. Необходимо также учитывать, что при групповой разработке приложений обычно часть программистов специализируется на базе данных, другая часть – на интерфейсе пользователя. При этом каждая группа программистов может работать над своей частью приложения, не отвлекаясь на особенности работы другой группы.
Значительно упрощается синхронизация работы пользователей, которую теперь обеспечивает сервер приложений.
Это то, что касается разработки приложения. Сервер приложений можно оформить в виде DLL. Это позволит использовать его как удаленно (через COM+, MTS или собственный суррогатный процесс), так и в виде внутрипроцессного сервера. Последний вариант позволяет создать версию приложения, не нуждающуюся в установке и конфигурации сервера приложений.
При эксплуатации также проявляются некоторые преимущества:
Уменьшается поток данных между клиентским приложением и серверной частью. Дело в том, что иногда приходится при расчетах обращаться к дополнительным таблицам БД. В случае трехуровнего приложения эти таблицы можно обрабатывать на сервере приложений, который часто установлен на одном компьютере с сервером БД или связан с ним каналом большой пропускной способности. Также необходимо учитывать, что, как правило, помимо непосредственно данных, сервер БД и использующее его приложение обмениваются значительным количеством служебной информации, например, о типах полей таблиц. Выбор отдельной строки результата запроса (курсора) происходит отдельной командой (fetch), что увеличивает сетевой обмен. В пакеты MIDAS включаются только необходимые данные, передаваемые единым массивом, что заметно уменьшает нагрузку на сеть.
Поскольку основная обработка данных происходит на сервере приложений, который обычно устанавливается на наиболее мощном компьютере, клиентская часть требует значительно меньше ресурсов на рабочих станциях.
Повышается защищенность приложения, поскольку на рабочих станциях отсутствует прямой доступ к серверу БД.
Как видно, преимуществ довольно много, и на практике они с запасом окупают необходимость написания «дополнительного» кода.
Теперь мне хотелось бы сказать несколько слов о переходе на более новые версии Delphi. В Delphi 6 технология MIDAS поменяла свое название, и теперь называется DataSnap. По-видимому, в связи с этим произошли некоторые изменения в составе компонентов. В частности, в том, что касается данного проекта, при переводе его на Delphi 6 я столкнулся с необходимостью изменения объявлений некоторых обработчиков событий TClientDataSet и TDataSetProvider. Это связано с тем, что в Delphi 6 появился компонент TCustomClientDataSet, который заменил TClientDataSet. Например, объявление
procedure TrdmDoc.cdsTitleReconcileError(DataSet: TClientDataSet; E: EReconcileError; UpdateKind: TUpdateKind; var Action: TReconcileAction); |
теперь должно выглядеть так:
procedure TrdmDoc.cdsTitleReconcileError(DataSet: TCustomClientDataSet; E: EReconcileError; UpdateKind: TUpdateKind; var Action: TReconcileAction); |
Аналогичным образом необходимо заменить остальные объявления.
В остальном проект изменений не потребовал. Думаю, на более новых версиях Delphi никаких изменений также не потребуется, либо они будут незначительными.
Приложения
Разумеется, выше описан не полный текст сервера приложений, а тем более, клиента. В исходных текстах содержатся дополнительные процедуры, предназначенные, в частности, для более полного контроля за ошибками.
При установке сервера приложений рекомендуется просто запустить его, главная форма сервера дает возможность выбрать местоположение базы данных. Диалог для этого использован стандартный, входящий в поставку IBX. При этом необходимо учитывать, что если в пути к базе не указано имя сервера, что подразумевает, что сервер Interbase и база находятся на одном компьютере с сервером приложений, рекомендуется выбирать удаленный доступ (указать путь вида localhost:<ПутьКБазе>). Дело в том, что Interbase выполняет все запросы локальных соединений в одном потоке, в результате один клиент будет ждать выполнения запроса другого клиента.
На клиенте в главной форме есть возможность упорядочить список документов по любому полю, просто щелкнув мышкой на заголовке колонки. Обеспечен полный контроль ошибок с выдачей сообщений пользователю.
Некоторые замечания.
Исходные тексты созданы в Delphi 5. Для правильной работы сервера приложений необходимо производить компиляцию с использованием IBX версии 4.52, исходные тексты этой версии можно взять на http://codecentral.borland.com/codecentral/ccweb.exe/author?authorid=102. Перед установкой этого обновления должен быть установлен Delphi5 Update pack 1.
Клиент соединяется с сервером при помощи TSocketConnection, поэтому для работы приложения должен быть запущен Borland Socket Server. Исполнимый файл обычно находится в каталоге ($Delphi)bin и называется scktsrvr.exe.
Для работы приложения MIDAS используют библиотеку midas.dll, которая при установленном Delphi 5 находится в системном каталоге Windows. Этот модуль необходим как для работы клиента, так и сервера приложений.
Для Delphi 6 необходима установка Update pack 2 и пакета обновления компонент IBX6.xx. Для компиляции проекта я использовал, в частности, IBX 6.04.
Вот вроде и все.
Огромное спасибо Александру Капустину, Павлу Шмакову и другим, кто помогал мне советами и критикой.