VCL і доступ до даних
Як влаштований VCL-застосунок із базою даних: форми та .dfm, набори даних і поля, бібліотеки ADO і FireDAC із викликом збережених процедур, фонові потоки без зависання інтерфейсу. На цих питаннях перевіряють, чи розуміє кандидат механізм, а не лише вміє розкладати компоненти по формі.
Перед цією темою: Object Pascal.
Як влаштовані форми та компоненти VCL?
Section titled “Як влаштовані форми та компоненти VCL?”Що це
- Форма — клас
TForm1 = class(TForm)у модулі .pas плюс однойменний файл .dfm. - .dfm — компоненти форми та значення їхніх властивостей, зазвичай у текстовому вигляді.
- Подія — властивість-посилання на метод:
OnClickвикличеButton1Clickпід час клацання.
Життєвий цикл форми
OnCreate— компоненти завантажено з .dfm.OnShow— форма з’являється на екрані.OnClose— параметрActionвирішує долю форми:caHide— сховати,caFree— звільнити,caNone— не закривати.OnDestroy— форма знищується.
ShowModal чекає закриття форми й повертає ModalResult, а Show одразу повертає керування.
Типова помилка
Викликати Free для форми в її власному обробнику: він ще не завершився і звернеться до звільненої пам’яті. Для цього є Release і Action := caFree.
Як влаштовано
- У .dfm потрапляють published-властивості, значення яких відрізняється від указаного в директиві
default. - Конструктор форми знаходить ресурс .dfm (його підключає
{$R *.dfm}) і читає його. - Для кожного
objectстворюється компонент потрібного класу, властивості присвоюються через RTTI, а однойменне published-поле форми починає вказувати на нього. - Рядок
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;object OrderDialog: TOrderDialog Caption = 'Order' OnCreate = FormCreate object btnOk: TButton Caption = 'OK' ModalResult = 1 endendModalResult = 1 — це mrOk: клацання по кнопці закриє форму, і ShowModal поверне mrOk.
- Чим Owner відрізняється від Parent? — Owner звільняє компонент і зберігає його в
Components, Parent — віконний контрол, усередині якого контрол малюється. - Що робить
Application.CreateForm? — Створює форму з власникомApplication; перша створена так форма стає головною. - Чому не можна викликати
Freeв обробнику самої форми? — Обробник ще виконується;Releaseвідкладає знищення до кінця обробки повідомлень. - Чим фрейм відрізняється від успадкування форм? — Фрейм — вбудовувана частина інтерфейсу, успадкування — спільний предок для форми цілком.
- Що буде, якщо в .dfm лишився обробник, якого немає в коді? — Форма не завантажиться: зв’язок іде за іменем published-методу.
Як влаштована робота з даними через TDataSet?
Section titled “Як влаштована робота з даними через TDataSet?”Що це
TDataSet — набір рядків із курсором: у кожен момент один запис поточний. Від нього успадковуються TADOQuery, TFDQuery, TClientDataSet та інші набори.
Як пов’язані компоненти
- Запит (
TFDQueryабоTADOQuery) отримує рядки з бази. TDataSourceпов’язує набір із контролами.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 і отримати нескінченний цикл.
Як влаштовано
Openпереводить набір ізdsInactiveуdsBrowse.EditдаєdsEdit,InsertабоAppend—dsInsert. Для нового запису спрацьовуєOnNewRecord— місце для значень за замовчуванням.Postзаписує зміни.BeforePost— місце перевірок: виняток у ньому скасовує запис.Cancelвідкочує буфер.- Переміщення викликає
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 скасовано, запис у dsEditend;- Чим
TDataSourceвідрізняється відTDataSet? — Набір тримає дані, аTDataSourceлише пов’язує його з контролами й може вимкнути їх властивістюEnabled. - Що станеться під час
Nextу станіdsEdit? — Набір неявно виконаєPost, якщо запис змінено, абоCancel, якщо ні. - Чим обчислюване поле відрізняється від lookup? — Обчислюване рахує ваш код в
OnCalcFields, lookup бере значення з іншого набору за ключем. - Навіщо
DisableControls, якщо єBlockReadSize? —BlockReadSizeтеж не оновлює контроли, але продовжує оновлювати detail-набори. - Як розібрати помилки після
ApplyUpdatesу FireDAC? — За кількістю помилок у результаті, потімFilterChangesзrtHasErrorsіRowErrorабо обробникOnReconcileError.
ADO, dbExpress і FireDAC: чим відрізняються і як викликати збережені процедури?
Section titled “ADO, dbExpress і FireDAC: чим відрізняються і як викликати збережені процедури?”Що це
| Бібліотека | Як працює |
|---|---|
| ADO (dbGo) | компоненти TADO* поверх Microsoft ADO (COM) |
| dbExpress | легкі драйвери, набори лише вперед; застарів |
| FireDAC | компоненти TFD*, один API для багатьох СУБД |
Як викликати процедуру
- Покладіть на форму з’єднання (
TADOConnectionабоTFDConnection) і компонент процедури. - Задайте ім’я:
ProcedureNameв ADO,StoredProcNameу FireDAC. - Отримайте список параметрів:
Parameters.Refreshв ADO,Prepareу FireDAC. - Заповніть вхідні параметри й викличте
ExecProcабоOpen, якщо процедура повертає рядки. - Після виконання прочитайте вихідні параметри.
Типова помилка
Викликати 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: увесь масив однією командою замість циклу з ExecSQLFDQuery1.SQL.Text := 'insert into dbo.ItemTag (ItemId, Tag) values (:ItemId, :Tag)';FDQuery1.Params.ArraySize := Length(Tags);for I := 0 to High(Tags) dobegin FDQuery1.Params[0].AsIntegers[I] := Tags[I].ItemId; FDQuery1.Params[1].AsStrings[I] := Tags[I].Name;end;FDQuery1.Execute(FDQuery1.Params.ArraySize, 0);- Чому набір dbExpress не можна редагувати в сітці? — Він однонапрямний і не буферизує рядки; потрібен
TClientDataSetчерезTDataSetProvider. - Як отримати RETURN-значення процедури? — Параметр із
ParamType = ptResult; у SQL Server він називається@RETURN_VALUE. - Чим макрос відрізняється від параметра? — Параметр передає значення окремо від тексту, макрос змінює сам текст SQL до надсилання.
- Коли
fmAll, а колиfmOnDemand? —fmAll— для невеликих результатів і щоб одразу звільнити з’єднання,fmOnDemand— для великих списків, які гортають частинами. - Процедура повертає кілька наборів рядків — як їх прочитати? —
NextRecordSetу FireDAC,NextRecordsetв ADO.
Як працювати з потоками у VCL-застосунку й не зламати UI?
Section titled “Як працювати з потоками у VCL-застосунку й не зламати UI?”Що це
- Головний потік створює вікна й обробляє їхні повідомлення: клацання, натискання клавіш, перемальовування.
- Фоновий потік —
TThreadабо задачаTTask. Він виконує довгу роботу, не зупиняючи цикл повідомлень.
Навіщо
Запит на 10 секунд в обробнику кнопки на 10 секунд заморожує вікно. Windows пише «Не відповідає», а користувач клацає ще раз.
Як користуватися
- Запустіть роботу у фоні:
TThread.CreateAnonymousThread(...).StartабоTTask.Run(...). - Щоб показати результат, передайте код у
TThread.SynchronizeабоTThread.Queue— він виконається в головному потоці. - Поки робота триває, вимкніть кнопку запуску.
Типова помилка
Змінювати 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), щоб у черзі не лишилося звернень до неї.
- Чим
Synchronizeвідрізняється відQueue? — Перший чекає виконання коду в головному потоці, другий ставить код у чергу й не чекає. - Чому
Application.ProcessMessagesнебезпечний? — Повторний вхід: посеред вашого коду виконуються інші обробники, форму можуть закрити. - Чи можна використовувати один
TFDConnectionіз двох потоків? — Лише суворо по черзі; на практиці — своє з’єднання на потік і пул. - Як скасувати вже виконуваний запит? —
AbortJobу з’єднання FireDAC; прапорецьTerminatedперевіряється лише між командами. - Що потрібно ADO у фоновому потоці? —
CoInitializeна початкуExecuteіCoUninitializeнаприкінці, бо ADO — це COM.