在 DLL (動態連接庫) 的開發中,管理和更新Exports(也就是那些被外部應用程式所調用的功能 )通常是一個令人頭痛的任務。尤其是當這些Exports的數量增加,或者需要頻繁的進行更改時。手動編輯和維護這些Exports可能非常耗時,且容易出錯。但有了Pre-build功能,這一切都可以自動化。



什麼是 Pre-build 事件?

Pre-build 事件是在主要的編譯和連接過程開始之前執行的命令或腳本。它為開發者提供了一個機會,在源代碼被編譯成二進位格式之前,對項目進行最後的調整或檢查。在軟體開發中,尤其是在大型項目中,編譯和建構過程可能涉及多個步驟和依賴項。有時候,在進行主要的編譯過程之前,我們可能需要執行某些預先的任務,如:更新資源、檢查依賴性或清理舊的檔案。這就是 Pre-build 事件發揮作用的地方。




如何設定 Pre-build 事件?

我們可以在Delphi的IDE選單中找到「Project」「Options」,並且在「Building」設定中找到「Build Events」後就可以看到今天的主角「Pre-build events」。任何的命令都可以設定在Commands欄位中,這些命令行接受多種變數,如:「$(OUTPUTDIR) 」「 $(PROJECTNAME)」「$(PROJECTDIR)」,它們在執行時會被替換為相應的值。這使你可以為不同的項目和設定建立通用的腳本和命令。




使用 Delphi 的 TCustomAttribute 進行編譯前自動化

Delphi的提供了現代化的元編程特性,其中最有力的工具之一就是「TCustomAttribute」。這允許開發者在原始代碼中嵌入額外的元數據,而不會影響執行時的行為。利用這些特性,我們可以更簡單的實現編譯前的自動化。

想像一下,當我們正在開發一個動態連接庫(DLL),我們希望某些類或函數能被自動導出。而不是手動管理這些列表,我們可以使用 [UseClass] 和 [UseFunction] 這樣的自定義屬性,直接標記需要導出的類別與方法。

例如:
[UseClass]
TAClass = class
public
  [UseFunction]
  function getAName(): string;
  [UseFunction(InputData)]
  function setAPosition(const position: string): string;
  //getAType暫定不Exports出去
  function getAType(): string;
end;
定義TCustomAttribute的方式:
APIMethod = (Empty, InputData);

UseClass = class(TCustomAttribute)
end;

UseFunction = class(TCustomAttribute)
private
  FMethod: APIMethod;
public
  constructor Create(const AMethod: APIMethod = Empty);
  property Method: APIMethod read FMethod;
end;


設定自動化Script

在編譯前,我們可以運行一個腳本來掃描源代碼,尋找這些標記。基於找到的標記,腳本可以自動生成必要的 Exports 清單或其他所需的配置。

使用 PowerShell 腳本自動化代碼生成是一個強大而高效的方法。例如,我們可以設計一個 PowerShell 腳本來根據我們訂定的需求和條件自動生成「DLLMain.inc」文件。這意味著,每次我們的需求或環境變化時,不再需要手動修改或重建這個.inc文件,只需運行腳本即可。

以下是PowerShell的範例:
# 外部參數輸入
param (
    [Parameter(Mandatory=$true)]
    [string]$path
)

# 正則表達式
$unitPattern = '\bunit\s+([a-zA-Z0-9_]+);'
$classPattern = "(?s)\[UseClass\]\s+T(.*?)\s*(?::\s*\w+)?\s*=\s*class.*?\bend;"
$methodPatternFunction = '\[UseFunction\]\s*function\s+([^(:]+)'
$methodPatternFunctionInputData = '\[UseFunction\(InputData\)\]\s*function\s+([^(:]+)'

# 輸出檔案 DLLMain.inc
$outputFilePath = Join-Path $path "DLLMain.inc"
if (Test-Path $outputFilePath) {
    Remove-Item $outputFilePath
}

# 初始化輸出參數
$usesUnits = [System.Collections.Generic.HashSet[string]]::new()
$functionsInterface = @()
$functionsImplementation = @()
$exportsList = @()

# 方法對照表
function GenerateFunctionDetails($attribute) {
    switch ($attribute) {
        # 可以繼續擴充其它規則
        'InputData' {
            @{
                Parameters = 'const inputData: string';
                InstanceCreate = "T$apiName.Create";
                MethodCall = "instance.$method(inputData)";
            }
        }       
        default {
            @{
                Parameters = '';
                InstanceCreate = "T$apiName.Create";
                MethodCall = "instance.$method";
            }
        }
    }
}

# 在指定目錄及其子目錄中遍歷所有的 .pas 檔案。
Get-ChildItem -Path $path -Recurse -Include *.pas | ForEach-Object {
    $content = Get-Content $_.FullName -Raw
    if ($content -match $classPattern) {
      # 提取帶有[UseAPI]標籤的類別
      $apiClassesMatches = [System.Text.RegularExpressions.Regex]::Matches($content, $classPattern)
      # 取得該pas的Unit Name
      if ($content -match $unitPattern) {
          $unitname = $matches[1]          
          $usesUnits.Add("`n     $unitname")
      }

      foreach ($match in $apiClassesMatches) {

          $apiName = $match.Groups[1].Value.Trim()
          $classContent = $match.Value
          
          # 針對每種UseFunction屬性的模式和實現方法
          @(
              @{ Pattern = $methodPatternFunction; Attribute = 'Default' },
              @{ Pattern = $methodPatternFunctionInputData; Attribute = 'InputData' }
          ) | ForEach-Object {
              $patternInfo = $_
              $methods = [System.Text.RegularExpressions.Regex]::Matches($classContent, $patternInfo.Pattern) | ForEach-Object {
                  $_.Groups[1].Value.Trim()
              }

              $methods | ForEach-Object {
                  $method = $_
                  $functionDetails = GenerateFunctionDetails $patternInfo.Attribute

                  $functionsInterface += "function $method($($functionDetails.Parameters)): string; stdcall;"

                  $funcImpl = @"
function $method($($functionDetails.Parameters)): string; stdcall;
var
  instance: T$apiName;
begin
  try
    instance := $($functionDetails.InstanceCreate);
    try
      Result := $($functionDetails.MethodCall);
    except
      on E:Exception do begin
        Result := E.ToString;
      end;
    end;
  finally
    if Assigned(instance) then FreeAndNil(instance);
  end;  
end;

"@
                  $functionsImplementation += $funcImpl
                  $exportsList += "`n  $method"
              }
          }
      }
    }
}

# 格式化收集到的資訊
$usesFormatted = $($usesUnits -join ', ').TrimStart("`n, ")
$exportsFormatted = $($exportsList -join ', ').TrimStart("`n, ")

$output = @"
uses System.SysUtils,
     //Class
     $usesFormatted;

$($functionsInterface -join "`n")

exports
  $exportsFormatted;

implementation

$($functionsImplementation -join "`n`n")
"@

# 將輸出寫入DLLMain.inc檔案。
Add-Content -Path $outputFilePath -Value $output -Encoding UTF8

接著我們需要在Delphi專案中,使用「{$INCLUDE DLLMain.inc}」這樣的語句,就可以輕鬆地將這個.inc文件的內容包含到我們的主代碼中。這為我們提供了一個動態和靈活的方法來控制 DLL 的行為和特性,而不需要直接更改主代碼。
unit DLLMain;

interface

{$INCLUDE DLLMain.inc}

end.



添加Pre-build Commands

最後將寫好的PowerShell加入到Pre-build的Commands內,並且帶入我們的專案參數,就完成Pre-build的功能囉!
powershell.exe -ExecutionPolicy Bypass -File ".\AutoBuildDLLMain.ps1" -Path "$(PROJECTDIR)"
在第一次編譯專案時,會跳出安全性警告詢問並且要你信任專案上設置的任何腳本。
💡 如果是不是自己規劃的專案,跳出這個訊息的話,請一定要確認它執行了什麼腳本哦!

編譯完成後,就可以在自己的專案下看到腳本自動產出的DLLMain.inc囉!



總結

以上就是全部的Pre-build的介紹,Delphi的Build Events功能為開發者提供了一個強大的工具,使他們能夠在建構過程的不同階段插入自定義的腳本或命令。這意味著開發者可以根據自己的需求進行專案的細節調整,從而實現更高級的自動化和效率哦!