Перейти к содержимому

VCL и доступ к данным

Как устроено VCL-приложение с базой данных: формы и .dfm, наборы данных и поля, библиотеки ADO и FireDAC с вызовом хранимых процедур, фоновые потоки без зависания интерфейса. На этих вопросах проверяют, понимает ли кандидат механизм, а не только умеет раскладывать компоненты по форме.

Перед этой темой: Object Pascal.

Junior+
Ответ

Что это

  • Форма — класс TForm1 = class(TForm) в модуле .pas плюс одноимённый файл .dfm.
  • .dfm — компоненты формы и значения их свойств, обычно в текстовом виде.
  • Событие — свойство-ссылка на метод: OnClick вызовет Button1Click при щелчке.

Жизненный цикл формы

  1. OnCreate — компоненты загружены из .dfm.
  2. OnShow — форма появляется на экране.
  3. OnClose — параметр Action решает судьбу формы: caHide — скрыть, caFree — освободить, caNone — не закрывать.
  4. OnDestroy — форма уничтожается.

ShowModal ждёт закрытия формы и возвращает ModalResult, а Show сразу возвращает управление.

Частая ошибка

Вызвать Free для формы в её собственном обработчике: он ещё не закончился и обратится к освобождённой памяти. Для этого есть Release и Action := caFree.

Как устроено

  1. В .dfm попадают published-свойства, значение которых отличается от указанного в директиве default.
  2. Конструктор формы находит ресурс .dfm (его подключает {$R *.dfm}) и читает его.
  3. Для каждого object создаётся компонент нужного класса, свойства присваиваются через RTTI, а одноимённое published-поле формы начинает указывать на него.
  4. Строка OnClick = btnOkClick связывается с published-методом формы по имени. Метод переименовали только в коде — форма не загрузится.

Owner и Parent

Owner Parent
Отвечает за освобождение и список Components отображение и положение на экране
Как задать Create(AOwner) свойство Parent
Кто может быть любой TComponent только TWinControl

Parent тоже освобождает свои дочерние контролы, но его главная роль — визуальная.

Ловушки

  • Контрол, созданный в коде без Parent, не виден.
  • Create(nil) без Free — утечка. Create(Self) для диалога, который создают при каждом вызове, копит объекты до закрытия владельца.
  • Free формы из её обработчика — нужен Release: он дождётся окончания обработчиков и сообщений формы.

Правило

Модальный диалог — Create(nil) и ShowModal внутри try..finally Free. Немодальное окно — Action := caFree в OnClose.

Под капотом

  • TReader создаёт компоненты, присваивает свойства и после чтения всей формы вызывает у каждого компонента Loaded.
  • Ссылки между компонентами (DataSource = dsOrders) разрешаются после чтения всех объектов, поэтому их порядок в .dfm не важен.
  • Важен порядок создания невизуальных компонентов: сохранённые Connected = True и Active = True открывают соединение и запросы прямо при создании формы.
  • Наследник формы хранит в .dfm только отличия от предка (inherited), фрейм на форме — тоже только отличия (inline).

Цена

  • Application.CreateForm для всех форм в .dpr: все .dfm читаются на старте, а формы и их открытые запросы живут до выхода из программы.
  • Глобальная переменная Form1 поощряет связи «форма читает поля другой формы». Такой код трудно тестировать и переносить.
  • Бинарный .dfm не сравнивается в Git, текстовый — читается и мержится.

Компромиссы

  • Фрейм — переиспользуемый кусок интерфейса с кодом. Изменения фрейма видны на всех формах, кроме свойств, переопределённых на конкретной форме.
  • Наследование форм даёт общий каркас: кнопки, проверки, обработку ошибок. Глубокая иерархия хрупка: правка предка ломает потомков.
  • Data module (TDataModule) отделяет соединения и запросы от формы, и их можно использовать без UI.

Как проверить в проде

  • Утечки форм и компонентов покажет ReportMemoryLeaksOnShutdown := True или FastMM в режиме FullDebugMode.
  • Бинарный .dfm начинается с сигнатуры TPF0 — так легко найти файлы, которые стоит перевести в текст.

Итог: форма — сериализованный граф объектов; её временем жизни нужно управлять явно, а не полагаться на автосоздание.

Пример
procedure TMainForm.btnEditOrderClick(Sender: TObject);
var
Dlg: TOrderDialog;
begin
Dlg := TOrderDialog.Create(nil); // без владельца: освобождаем сами
try
Dlg.OrderId := 42;
if Dlg.ShowModal = mrOk then // ждёт закрытия и возвращает ModalResult
RefreshOrders;
finally
Dlg.Free;
end;
end;
procedure TMainForm.FormCreate(Sender: TObject);
var
Btn: TButton;
begin
Btn := TButton.Create(Self); // Owner = форма, она освободит кнопку
Btn.Parent := pnlTools; // без Parent кнопку не видно
Btn.Caption := 'Refresh';
end;
procedure TReportForm.FormClose(Sender: TObject; var Action: TCloseAction);
begin
Action := caFree; // немодальная форма освободит себя сама
end;
Фрагмент .dfm
object OrderDialog: TOrderDialog
Caption = 'Order'
OnCreate = FormCreate
object btnOk: TButton
Caption = 'OK'
ModalResult = 1
end
end

ModalResult = 1 — это mrOk: щелчок по кнопке закроет форму, и ShowModal вернёт mrOk.

Middle+
Ответ

Что это

TDataSet — набор строк с курсором: в каждый момент одна запись текущая. От него наследуются TADOQuery, TFDQuery, TClientDataSet и другие наборы.

Как связаны компоненты

  1. Запрос (TFDQuery или TADOQuery) получает строки из базы.
  2. TDataSource связывает набор с контролами.
  3. TDBGrid или TDBEdit со свойствами DataSource и DataField показывает и редактирует данные.

Как пользоваться

  • Перемещение: First, Next, Prior, Last; конец набора — Eof.
  • Чтение: FieldByName('Amount').AsCurrency.
  • Изменение: Edit, присвоить поля, Post. Новая запись — Append или Insert.

Частая ошибка

Присвоить значение полю без Edit: набор в dsBrowse, и будет исключение «Dataset not in edit or insert mode». Вторая классика — забыть Next в цикле while not Eof и получить вечный цикл.

Как устроено

  1. Open переводит набор из dsInactive в dsBrowse.
  2. Edit даёт dsEdit, Insert или Append — dsInsert. Для новой записи срабатывает OnNewRecord — место для значений по умолчанию.
  3. Post записывает изменения. BeforePost — место проверок: исключение в нём отменяет запись. Cancel откатывает буфер.
  4. Перемещение вызывает BeforeScroll и AfterScroll. Если запись редактировалась, перемещение сначала неявно выполняет Post.

Виды полей

Вид Откуда значение
Динамические создаются при Open по колонкам результата
Постоянные заданы в Fields Editor и лежат в .dfm
Вычисляемые обработчик OnCalcFields
Lookup другой набор по ключу (LookupDataSet)

Ловушки

  • Если у набора есть постоянные поля, динамические по умолчанию не создаются: новую колонку запроса не найти через FieldByName.
  • Тип постоянного поля должен совпадать с типом колонки: после смены типа в базе набор не откроется.
  • DisableControls без try..finally: после исключения сетка останется замороженной. У master-detail он ещё и останавливает обновление detail-набора.
  • В OnCalcFields нельзя менять набор: при AutoCalcFields = True это бесконечная рекурсия.

Правило

Проверки — в BeforePost. Массовый проход — между DisableControls и EnableControls в try..finally. Тяжёлой работе не место в AfterScroll.

Под капотом

  • Набор держит буфер записей размером с видимую часть сетки, а весь результат хранит конкретная библиотека: клиентский курсор ADO, внутреннее хранилище FireDAC.
  • Контролы связаны с набором через TDataSource и объекты TDataLink. Набор рассылает им уведомления о смене записи и состояния, а DisableControls увеличивает счётчик и глушит эту рассылку.
  • OldValue и CurValue поля читаются через временные состояния dsOldValue и dsCurValue — на них строится разрешение конфликтов.

Кешированные обновления

Библиотека Как включить и применить
FireDAC CachedUpdates := True, затем ApplyUpdates и CommitUpdates
ADO LockType := ltBatchOptimistic, затем UpdateBatch
TClientDataSet журнал Delta, ApplyUpdates через провайдер

ApplyUpdates в FireDAC не бросает исключение, а возвращает число ошибок и сам не открывает транзакцию. Поэтому его оборачивают в StartTransaction и Commit.

Цена

  • Обработчик AfterScroll с запросом к серверу — десятки запросов при пролистывании сетки.
  • Lookup-поле ищет значение в справочнике для каждой записи. Для большого справочника дешевле JOIN на сервере.

Компромиссы

Кешированные обновления сокращают блокировки и обращения к серверу, но данные устаревают: между чтением и ApplyUpdates их мог изменить другой пользователь. Защита — условие WHERE по ключу или по всем полям (UpdateOptions.UpdateMode в FireDAC).

Как проверить в проде

Сколько запросов порождает работа с формой, видно в SQL Server Profiler или Extended Events, а для FireDAC — в трассировке FDMonitor.

Итог: TDataSet прячет запросы за событиями, а кешированные обновления откладывают конфликты, но не отменяют их.

Пример
procedure TOrdersForm.RecalcTotal;
var
Mark: TBookmark;
Total: Currency;
begin
Total := 0;
Mark := qryOrders.Bookmark; // запомнить текущую запись
qryOrders.DisableControls; // сетка не перерисовывается на каждой строке
try
qryOrders.First;
while not qryOrders.Eof do
begin
Total := Total + qryOrdersAmount.AsCurrency; // постоянное поле из .dfm
qryOrders.Next;
end;
qryOrders.Bookmark := Mark; // вернуться к записи пользователя
finally
qryOrders.EnableControls; // счётчик снова ноль — контролы обновятся
end;
lblTotal.Caption := CurrToStr(Total);
end;
procedure TOrdersForm.qryOrdersBeforePost(DataSet: TDataSet);
begin
if qryOrdersAmount.AsCurrency <= 0 then
raise Exception.Create('Amount must be positive'); // Post отменён, запись в dsEdit
end;
Middle+

ADO, dbExpress и FireDAC: чем отличаются и как вызывать хранимые процедуры?

Заголовок раздела «ADO, dbExpress и FireDAC: чем отличаются и как вызывать хранимые процедуры?»
Ответ

Что это

Библиотека Как работает
ADO (dbGo) компоненты TADO* поверх Microsoft ADO (COM)
dbExpress лёгкие драйверы, наборы только вперёд; устарел
FireDAC компоненты TFD*, один API для многих СУБД

Как вызвать процедуру

  1. Положите на форму соединение (TADOConnection или TFDConnection) и компонент процедуры.
  2. Задайте имя: ProcedureName у ADO, StoredProcName у FireDAC.
  3. Получите список параметров: Parameters.Refresh у ADO, Prepare у FireDAC.
  4. Заполните входные параметры и вызовите ExecProc или Open, если процедура возвращает строки.
  5. После выполнения прочитайте выходные параметры.

Частая ошибка

Вызвать Open для процедуры без набора строк или ExecProc для процедуры, которая его возвращает. FireDAC в обоих случаях сообщит об ошибке.

Как устроено

  • ADO — TADOConnection со строкой подключения к OLE DB-провайдеру, наборы TADOQuery, TADOStoredProc, TADOCommand. Курсор по умолчанию клиентский (CursorLocation = clUseClient): весь результат приходит на клиент.
  • dbExpress — TSQLConnection, TSQLQuery. Наборы однонаправленные и не редактируются, для сетки нужна связка TDataSetProvider и TClientDataSet.
  • FireDAC — TFDConnection с DriverID=MSSQL. К SQL Server ходит через ODBC Driver for SQL Server, документация рекомендует версию 18.

Что даёт FireDAC

  • Выборка. FetchOptions.Mode: fmOnDemand по умолчанию тянет строки порциями по RowsetSize (50), fmAll — всё сразу при Open.
  • Array DML. Одна команда с массивами параметров вместо цикла: Params.ArraySize и Execute.
  • Макросы. &Name или !Name подставляют в текст SQL то, что нельзя передать параметром, например имя таблицы.
  • Мониторинг. TFDMoniRemoteClientLink и MonitorBy=Remote показывают в утилите FDMonitor каждую команду и её время.

Ловушки

  • Параметры SQL Server в FireDAC называются с @ (@CustomerId). ResourceOptions.UnifyParams убирает префикс.
  • FireDAC не поддерживает параметры процедур со значениями по умолчанию: заполняйте их явно.
  • Макрос — подстановка текста, поэтому ввод пользователя в нём — SQL-инъекция.

Правило

Новый код — на FireDAC, значения — только параметрами, массовая запись — через Array DML.

Под капотом

  • TFDStoredProc.Prepare при fiMeta в FetchOptions.Items запрашивает описание параметров у сервера — это отдельное обращение. ADO делает то же в Parameters.Refresh. Поэтому компонент готовят один раз, а в горячем коде параметры описывают вручную.
  • На SQL Server fmOnDemand использует серверный статический курсор, а fmAll — быстрый forward-only и сразу освобождает соединение для следующей команды.
  • MARS в драйвере MSSQL включён по умолчанию, и документация предупреждает о возможной потере производительности.
  • Array DML на SQL Server выполняется нативно, а ошибки собираются по всем строкам массива.

Компромиссы

Вопрос ADO FireDAC
Зависимости COM и OLE DB-провайдер ODBC-драйвер SQL Server
Массовая запись цикл вызовов или batch на клиентском курсоре Array DML
Диагностика трассировка на сервере FDMonitor на клиенте и трассировка на сервере

Цена перехода

Замена ADO на FireDAC — не переименование компонентов: различаются имена и типы параметров, отображение типов полей, курсоры. Это отдельная задача, о ней — в теме Миграция Delphi 6 → 12/13.

Как проверить в проде

  • FDMonitor или TFDMoniFlatFileClientLink записывают текст, параметры и время каждой команды.
  • На сервере Extended Events или Query Store показывают, какие процедуры вызываются чаще и работают дольше.

Итог: скорость доступа к данным решают метаданные, режим выборки и пакетность, а не выбор компонента.

Пример
// FireDAC: имена параметров SQL Server начинаются с @
FDStoredProc1.StoredProcName := 'dbo.OrderCreate';
FDStoredProc1.Prepare; // запрос списка параметров у сервера
FDStoredProc1.ParamByName('@CustomerId').AsInteger := 42;
FDStoredProc1.ExecProc; // процедура без набора строк
NewOrderId := FDStoredProc1.ParamByName('@OrderId').AsInteger; // OUTPUT-параметр
// ADO: то же самое
ADOStoredProc1.ProcedureName := 'dbo.OrderCreate';
ADOStoredProc1.Parameters.Refresh; // тоже обращение к серверу за метаданными
ADOStoredProc1.Parameters.ParamByName('@CustomerId').Value := 42;
ADOStoredProc1.ExecProc;
NewOrderId := ADOStoredProc1.Parameters.ParamByName('@OrderId').Value;
// Array DML: весь массив одной командой вместо цикла с ExecSQL
FDQuery1.SQL.Text := 'insert into dbo.ItemTag (ItemId, Tag) values (:ItemId, :Tag)';
FDQuery1.Params.ArraySize := Length(Tags);
for I := 0 to High(Tags) do
begin
FDQuery1.Params[0].AsIntegers[I] := Tags[I].ItemId;
FDQuery1.Params[1].AsStrings[I] := Tags[I].Name;
end;
FDQuery1.Execute(FDQuery1.Params.ArraySize, 0);
Senior+

Как работать с потоками в VCL-приложении и не сломать UI?

Заголовок раздела «Как работать с потоками в VCL-приложении и не сломать UI?»
Ответ

Что это

  • Главный поток создаёт окна и обрабатывает их сообщения: щелчки, нажатия клавиш, перерисовку.
  • Фоновый поток — TThread или задача TTask. Он делает долгую работу, не останавливая цикл сообщений.

Зачем

Запрос на 10 секунд в обработчике кнопки на 10 секунд замораживает окно. Windows пишет «Не отвечает», а пользователь щёлкает ещё раз.

Как пользоваться

  1. Запустите работу в фоне: TThread.CreateAnonymousThread(...).Start или TTask.Run(...).
  2. Чтобы показать результат, передайте код в TThread.Synchronize или TThread.Queue — он выполнится в главном потоке.
  3. Пока работа идёт, отключите кнопку запуска.

Частая ошибка

Менять Label1.Caption или ProgressBar1.Position прямо из фонового потока. Иногда это «работает», а потом приложение зависает или падает в случайном месте.

Как устроено

Synchronize Queue
Фоновый поток ждёт, пока главный выполнит код сразу продолжает работу
Подходит для ответа из UI, например выбора в диалоге прогресса, лога, уведомлений

Из главного потока Queue выполняет код сразу; ForceQueue всегда ставит его в очередь.

Способы запуска

  • Наследник TThread с методом Execute — полный контроль: Terminate, WaitFor, OnTerminate, который выполняется в главном потоке.
  • TThread.CreateAnonymousThread — для разовой работы. Поток освобождает себя сам (FreeOnTerminate), после Start его ссылку трогать нельзя.
  • PPL (System.Threading): TTask.Run — задача на пуле потоков, TTask.Future<T> — задача с результатом, TParallel.For — параллельный цикл.

Ловушки

  • ProcessMessages. Пока он обрабатывает очередь, пользователь может снова нажать кнопку или закрыть форму, и обработчик продолжит работу с освобождённой формой.
  • Общее соединение. Два потока на одном TFDConnection дают AV или ошибку SQL Server «Connection is busy with results for another command».
  • ADO в потоке — это COM: в начале Execute нужен CoInitialize(nil), в конце — CoUninitialize.
  • Захват переменной. Анонимный метод захватывает переменную, а не значение: Queue внутри цикла может показать уже следующее значение счётчика.

Правило

UI — только из главного потока, у фонового потока — своё соединение, прогресс — через Queue.

Под капотом

  • Synchronize и Queue кладут вызов в общую очередь и будят главный поток. Он выполняет её в CheckSynchronize, когда возвращается в цикл сообщений, а до тех пор поток в Synchronize стоит.
  • TTask выполняется на пуле потоков. ITask.Cancel только помечает задачу, а код сам вызывает TTask.CurrentTask.CheckCanceled, который бросит EOperationCancelled.
  • TTask.WaitForAll и чтение IFuture<T>.Value в главном потоке блокируют его так же, как синхронный запрос.

Цена

  • Соединение на поток — это сессии на сервере. Пул FireDAC (Pooled=True в определении соединения, POOL_MaximumItems по умолчанию 50) переиспользует физические соединения. FDManager.Active := True выставляют до запуска потоков.
  • Queue на каждую строку заваливает очередь главного потока. Прогресс обновляют раз в N строк или по таймеру.

Компромиссы

  • Отмена. Terminated проверяется только между командами. Прервать уже выполняемую команду FireDAC позволяет AbortJob у её соединения.
  • Взаимоблокировка. Главный поток ждёт критическую секцию или событие, а фоновый, держа её, вызывает Synchronize — оба ждут друг друга.
  • Закрытие формы. В OnCloseQuery потоки останавливают через Terminate и WaitFor, а невыполненные вызовы снимают TThread.RemoveQueuedEvents. Иначе запоздалый Queue обратится к освобождённой форме.

Как проверить в проде

  • madExcept и EurekaLog умеют ловить зависание главного потока и сохраняют стеки всех потоков.
  • В лог пишите TThread.Current.ThreadID, чтобы видеть, в каком потоке выполнялся код.

Итог: поток сам по себе не делает приложение отзывчивым: нужны своё соединение, отмена, ожидание при закрытии и никакого UI вне главного потока.

Пример
procedure TStockWorker.Execute;
var
Conn: TFDConnection;
Proc: TFDStoredProc;
Batch: Integer;
begin
Conn := TFDConnection.Create(nil); // у потока своё соединение
Proc := TFDStoredProc.Create(nil);
try
Conn.ConnectionDefName := 'Shop_Pooled'; // определение с Pooled=True
Proc.Connection := Conn;
Proc.StoredProcName := 'dbo.RecalcStock';
Proc.Prepare;
for Batch := 1 to 100 do
begin
if Terminated then Exit; // кооперативная отмена, finally всё освободит
Proc.ParamByName('@BatchNo').AsInteger := Batch;
Proc.ExecProc;
ReportProgress(Batch);
end;
finally
Proc.Free;
Conn.Free;
end;
end;
procedure TStockWorker.ReportProgress(Value: Integer);
begin
// Value — параметр: у каждого вызова своя копия, а не общий счётчик цикла
TThread.Queue(Self, procedure
begin
MainForm.ProgressBar1.Position := Value; // выполнится в главном потоке
end);
end;

Форма при закрытии вызывает Worker.Terminate и Worker.WaitFor, а затем TThread.RemoveQueuedEvents(Worker), чтобы в очереди не осталось обращений к ней.