Перейти до вмісту

VCL і доступ до даних

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

Перед цією темою: Object Pascal.

Junior+

Як влаштовані форми та компоненти VCL?

Section titled “Як влаштовані форми та компоненти VCL?”
Відповідь

Що це

  • Форма — клас 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?

Section titled “Як влаштована робота з даними через TDataSet?”
Відповідь

Що це

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: чим відрізняються і як викликати збережені процедури?

Section titled “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?

Section titled “Як працювати з потоками у 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), щоб у черзі не лишилося звернень до неї.