Bài viết kỹ thuật

Ma trận build cross-compiler Delphi: HotXLS từ XE5

HotXLS ship một codebase Object Pascal cho mọi bản Delphi và C++Builder từ XE5 trở đi, còn build-All-Lib-TRIAL.cmd là script chứng minh điều đó: 43 build leg, bao phủ 12 version Delphi trên Win32 và Win64 cùng 10 package build C++Builder Win32 và 9 Win64. Từ v2.363 đến v2.374, script đó chưa từng chạy hoàn tất và leg XE5 đã hỏng suốt thời gian ấy

Khi đã nhìn thấy thì failure không tinh vi gì. Năm construct riêng mà compiler hiện tại chấp nhận không phàn nàn lại là hard error trên RAD Studio XE5, được build matrix gắn nhãn 12.0. Release v2.375.0 sửa cả năm và matrix xanh lại với 43 trên 43. Dưới đây là từng rejection, lý do compiler cũ có phần đúng khi từ chối hai trường hợp theo type, và phần đáng ngại hơn: probe script viết để chẩn đoán mớ hỗn độn này đã báo pass giả ở lần chạy đầu

Vì sao leg XE5 mục ruỗng mà không ai nhận ra?

Leg XE5 mục ruỗng vì development hằng ngày chỉ chạy bộ bốn script 37.0, còn local build xanh không nói gì về compiler bạn không hề invoke. Full matrix là script riêng và chậm, được trial installer gọi trước khi Inno Setup gom file, nên nó được chạy lúc packaging thay vì lúc commit. Mười hai release lọt qua khoảng trống đó

Arithmetic của leg đáng được viết rõ vì đó là nơi coverage illusion sinh ra. DELPHI_TRIAL_VERSIONS liệt kê 12.0 tới 37.0 và mỗi version trong 12 version đó build hai lần, Win32 và Win64. CB_TRIAL_WIN32_VERSIONS liệt kê 10 version, còn CB_TRIAL_WIN64_VERSIONS chỉ 9 vì XE5 có C++Builder package project nhưng không ship package startup object Win64 c0pkg64.o. Mười hai cộng mười hai cộng mười cộng chín là 43. Chạy bốn leg rồi gọi codebase là portable là một category error, và chính error đó đã để chuyện này xảy ra

HotXLS cũng từng dính cùng hình dạng vấn đề theo hướng ngược lại. Unit mới reachable qua clause uses nhưng vắng trong file list của .cbproj vẫn compile hoàn hảo dưới Delphi vì dcc implicit pull unit không được list vào package và cùng lắm phát hint W1033. C++Builder chỉ emit .obj cho unit có tên trong <DelphiCompile>, nên cùng code chết ở stage ilink với unresolved external. Một toolchain che điều toolchain kia bắt được. Đó là toàn bộ lý do phải chạy matrix thay vì tin một compiler đại diện

Hard type cast mà compiler Win32 cũ từ chối

Hai rejection trong năm thực ra là cùng một bug khoác hai bộ quần áo: hard type cast áp dụng lên floating-point expression thay vì variable. Trên Win32, compiler cũ evaluate arithmetic qua x87 stack, nên addition có Double được giữ ở 80-bit excess precision và static type trở thành Extended dài 10 byte. Cast 10 byte xuống TDateTime 8 byte không phải typecast hợp lệ, compiler báo E2089 Invalid typecast

Chi tiết gây bực là dạng variable lại ổn. TDateTime(Serial) compile trên mọi version trong matrix vì Serial đã dài 8 byte và cast giữ nguyên size. Chỉ cần cộng gì đó vào thì expression widen bên dưới. Bản sửa không phải cast rộng hơn hay conditional define, mà là ngừng cast: implicit real-to-real assignment convert đúng trên mọi compiler HotXLS hỗ trợ và nói đúng điều code có ý nghĩa

// Bị XE5 (Win32) từ chối: mỗi phép cộng được evaluate thành
// Extended 10 byte, cast hẹp từ 10 xuống 8 byte gây E2089
if Dates1904 then
  Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
  Value := TDateTime(Serial + 1)
else
  Value := TDateTime(Serial);        // dòng này được chấp nhận: không cộng

// An toàn theo version: để phép gán real-to-real tự conversion
if Dates1904 then
  Value := Serial + XLSDate1904Offset
else if Serial < 60 then
  Value := Serial + 1
else
  Value := Serial;

// Cùng loại rejection trong cell value packer: hard cast Double
// trên integer. Hãy chia thay vì cast - operator vốn đã cho real
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then   // E2089
  ;
if (Scaled = intVal) and (intVal / 100 = AValue) then           // portable
  ;

Nhánh Serial < 60 là fiction về leap year 1900 chứ không phải off-by-one: serial 60 là 1900-02-29 không tồn tại trong Excel, nên serial dưới nó cần thêm một ngày trước khi DecodeDate nhìn thấy. Công việc portability không được âm thầm đổi loại logic đó, chính vì vậy edit an toàn ở đây chỉ bỏ cast và giữ nguyên arithmetic

Điều gì hỏng khi nil là procedural argument?

Truyền nil trần vào nơi cần procedural type sẽ fail trong overload resolution trên compiler cũ. Call site trong HotXLS là ResolveIndexedColor, một overload nhận callback TXLSTryResolveSystemColor mà phần lớn caller không cần. Compiler mới resolve nil vào procedural parameter rồi chọn đúng overload. XE5 không làm vậy, còn diagnostic trỏ vào overload set chứ không trỏ argument, nên bạn dễ mất hai mươi phút

Câu trả lời portable là cho null callback một type. Unit-level variable của procedural type được language zero-initialize, nên đã là nil dù không initializer và mang type information mà resolver cũ cần. Khi unit-level variable là quá tay, typed local gán nil cũng làm đúng việc đó

var
  // Procedural literal nil không bind được trong overload resolution
  // của compiler cũ; variable có type và zero-initialize thì được
  NilSystemColorResolver: TXLSTryResolveSystemColor;

// ...

FWorkbook.ResolveIndexedColor(AIndexedColor, xicsBiffIcv, ARole,
  NilSystemColorResolver, Resolution);

// Cùng bản sửa với typed local, trong XLSX workbook
function TXLSXWorkbook.ResolveIndexedColor(AIndex: Int64;
  ASpace: TXLSIndexedColorSpace;
  out AResolution: TXLSIndexedColorResolution): Boolean;
var
  NoResolver: TXLSTryResolveSystemColor;
begin
  NoResolver := nil;
  Result := ResolveIndexedColor(AIndex, ASpace, xicrGeneral, NoResolver,
    AResolution);
end;

Lưu ý đây là khác biệt ở cấp language chứ không phải compiler bug đáng workaround bằng define. Variable zero-initialized đúng trên mọi version trong matrix và chỉ tốn một dòng, nên ở đây không cần conditional compilation. Chỉ dùng {$IF CompilerVersion} khi platform thật sự khác giữa các release, điều chỉ xảy ra đúng một lần trong batch này

Protected VCL method di chuyển giữa các release

TPicture.LoadFromStreampublic trên VCL hiện tại nhưng protected ở version cũ HotXLS hỗ trợ, nên direct call compile bây giờ mà fail lúc đó. HotXLS dùng nó để validate payload background image của worksheet thực sự decode được, một signature check chạy trước khi HTML exporter commit việc embed byte. Câu trả lời Pascal kinh điển áp dụng ở đây: khai báo descendant trong cùng unit chỉ để mở rộng visibility, rồi cast qua nó tại call site

type
  // TPicture.LoadFromStream là protected trên các VCL version cũ
  // library hỗ trợ; descendant cùng unit expose nó
  TXlsxPictureAccess = class(TPicture);

// ...

Stream.WriteBuffer(AData[1], Length(AData));
Stream.Position := 0;
TXlsxPictureAccess(Picture).LoadFromStream(Stream);
Result := (Picture.Graphic <> nil) and not Picture.Graphic.Empty and
  (Picture.Graphic.Width > 0) and (Picture.Graphic.Height > 0);

Accessor-class trick an toàn ở đây vì descendant không thêm field và không bao giờ được instantiate; cast chỉ thay đổi điều compiler cho phép bạn gọi tên. Tuy vậy vẫn đáng để lại comment tại declaration, vì người chỉ build trên IDE hiện tại sẽ thấy một type vô nghĩa. Background image handling lại xuất hiện trong custom VCL grid rendering path, nơi cùng payload đã decode feed cho sheet trên màn hình

GdiplusStartup token type đã đổi hai lần

Rejection duy nhất trong batch thực sự cần conditional compilation là type của var parameter trong GdiplusStartup, đã thay đổi qua các thế hệ VCL theo cách không có một spelling nào hợp lệ ở mọi nơi. Probe theo từng version chốt behavior thật: leg 12.0 tới 20.0 chỉ nhận Cardinal, leg 21.0 và 22.0 chỉ nhận THandle hoặc ULONG_PTR, còn 23.0 và 37.0 nhận cả hai. Theo tên release, đó là Cardinal từ XE5 tới 10.3 Rio và THandle từ 10.4 Sydney trở đi. Vì hai khoảng chấp nhận không overlap từ 12.0 tới 22.0, không declaration vô điều kiện nào hoạt động: guard dựa vào CompilerVersion >= 34, tức Sydney, còn call được qualify đầy đủ là Winapi.GDIPAPI.GdiplusStartup để unit resolution order không thể chọn declaration khác ở một version giữa range

function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
  StartupInput: TGdiplusStartupInput;
  // Type var-parameter của GdiplusStartup trong GDIPAPI đi theo thế hệ VCL:
  // Cardinal tới Rio, THandle từ Sydney trở đi
  {$IF CompilerVersion >= 34}
  StartupToken: THandle;
  {$ELSE}
  StartupToken: Cardinal;
  {$IFEND}
  TiffEncoder: TGUID;
begin
  FillChar(StartupInput, SizeOf(StartupInput), 0);
  StartupInput.GdiplusVersion := 1;
  CheckStatus(Winapi.GDIPAPI.GdiplusStartup(StartupToken, @StartupInput,
    nil), 'startup');
  if GetEncoderClsid('image/tiff', TiffEncoder) < 0 then
    raise EInvalidGraphic.Create('GDI+ TIFF encoder is unavailable');
  // ... mã hóa ...
end;

Đây là nhánh TIFF của page image exporter, nên blast radius khi làm sai là toàn bộ raster export surface, bao gồm các path mô tả trong export cell range thành một image. Cũng cần lưu ý guard không khẳng định điều gì: ULONG_PTRTHandle cùng width trên cả hai platform, nên lựa chọn ở đây là về identifier mà declaration gọi tên chứ không phải về tính đúng 32-bit hay 64-bit

Vì sao lần chạy probe đầu tiên không báo gì?

Version probe không báo gì ở lần đầu vì assignment res=$(...) được thực hiện trong subshell, nơi variable không truyền ngược về parent. dcc32 exit 0 khi thành công, nên exit code là tín hiệu đúng cần capture, nhưng script capture nó vào một variable biến mất một dòng sau. Mọi leg trở về rỗng và output trông như probe chưa compile gì, đúng là nó đã không compile gì theo nghĩa kết quả quan sát được

Failure thứ hai còn tệ hơn vì tạo ra câu trả lời sai thay vì không có câu trả lời. Probe phân loại leg bằng cách đếm dòng khớp Error, trong khi Delphi không prefix mọi fatal bằng word đó. F1026 File not found là fatal nhưng không match, nên probe không resolve được unit vẫn bị chấm như pass sạch. XE5 không ship Winapi.GDIPOPS.dcu, probe đầu tiên gặp đúng lỗi đó nhưng lại xanh giả. Quy tắc rút ra rất hẹp nhưng cần nói rõ: đánh giá compiler probe bằng artifact được tạo ra hoặc summary line của chính compiler, không bao giờ bằng grep output tìm keyword. Grep stderr tìm Error là heuristic thất bại theo hướng nguy hiểm nhất, âm thầm báo thành công

Hỗ trợ một thập kỷ compiler thực sự tốn gì?

Hạch toán trung thực là thay đổi code ở đây rất nhỏ còn thay đổi process thì không. Bốn trong năm rejection được sửa bằng Pascal thông thường chứ không thêm version machinery: bỏ cast, chia thay vì cast, cho nil một type, khai báo accessor class. Chỉ GdiplusStartup xứng đáng một {$IF}. Codebase trải từ XE5 tới release hiện tại không trở thành bụi rậm conditional define trừ khi bạn để hard cast và idiom của compiler mới nhất tích tụ ngay từ đầu

Chi phí thật là build time và kỷ luật. Bốn mươi ba leg là script chậm, chính vì vậy nó trôi dạt tới packaging rồi bị bỏ luôn. Middle ground có thể bảo vệ là giữ vòng bốn script nhanh để iterate và chạy full matrix theo lịch không thể skip, vì failure mode không phải broken build bạn sẽ nhận ra mà là một IDE được support đã âm thầm không còn được support từ mười hai release trước

Nghĩa vụ đó là mặt trái của việc ship native component. HotXLS đọc và ghi XLS, XLSX cùng ODS chỉ bằng Object Pascal, không cần cài Excel và không có COM dependency, nhờ đó workbook automation không cần Office chạy được trên server bị khóa. Cùng property đó có nghĩa compiler là toàn bộ platform contract, nên mọi version trong matrix là một promise phải verify lại chứ không được giả định

Cross-compiler build matrix và code an toàn theo version nói ở đây là một phần của HotXLS Delphi Spreadsheet Component, hỗ trợ Delphi và C++Builder từ XE5 tới release hiện tại với library binary dựng sẵn cho mọi IDE được support