Technický článek

Vazby PDF Library for Delphi pro DLL, ActiveX a dylib: Volání PDF z libovolného jazyka

Tady je problém, který se objeví ve chvíli, kdy knihovna PDF opustí svůj domovský jazyk. Máte vazbu, která z C# na Windows funguje bezchybně. Potřebujete stejná volání z Pythonu na macOS, tak zkopírujete deklarační soubor pro Windows, vyměníte název binárky a spustíte to. Všechny symboly se najdou. První volání vrátí nesmysl, druhé spadne na porušení přístupu, a přitom se váš kód pro PDF vůbec nezměnil. Chyba leží o vrstvu níž, než je PDF: exporty pro Windows používají konvenci Stdcall, dylib pro macOS exportuje tytéž funkce jako Cdecl s podtržítkem na začátku, a deklarace cizí funkce, která kterýkoli z těch detailů popíše špatně, poškodí zásobník dřív, než se otevře jediný dokument

Celá tahle třída selhání pramení z jednoho návrhového rozhodnutí, které se vyplatí pochopit hned na začátku. PDF Library for Delphi, engine PDF se zpřístupněnými zdrojovými kódy od losLab pro Delphi a C++Builder, obaluje celý svůj objektový model do jediné ploché fasádní třídy TPDFlib a tuto fasádu pak dodává ve třech binárních podobách: jako DLL pro Windows se zhruba 1 250 exportovanými funkcemi, jako automatizační objekt COM/ActiveX a jako dylib pro macOS. Sémantika PDF je ve všech třech totožná. To, co vás kousne, sídlí v ABI pod nimi: konvence volání, kódování řetězců, vlastnictví handlů a otázka, která strana smí uvolnit který buffer

Jedna fasáda, tři binární podoby

Každá veřejná funkce třídy TPDFlib má plochý protějšek pojmenovaný jako DL plus název metody. Z LoadFromFile se stane DLLoadFromFile, z Encrypt se stane DLEncrypt, z NewSignProcessFromFile se stane DLNewSignProcessFromFile. Prvním parametrem téměř každého exportu je InstanceID vrácené funkcí DLCreateLibrary, které zastupuje objektovou referenci, již by jinak volající v Delphi držel. Toto mapování si osvojte co nejdřív. Znamená totiž, že referenční příručka rozhraní Delphi slouží zároveň jako dokumentace pro každý další jazyk: co umí třída, umí i DLL pod předvídatelným názvem, a z hlavičky metody v Pascalu vyčtete volání, které potřebujete z Pythonu nebo C#

Sestavení pro Windows vytváří PDFlibDLL32.dll a PDFlibDLL64.dll; vyberte tu, která odpovídá bitové šířce vašeho hostitelského procesu, protože 64bitový proces Javy nebo .NET nedokáže načíst 32bitovou knihovnu, ať už deklarace vypadá jakkoli

Architektonický diagram jedné fasády TPDFlib vystavené jako DLL Windows se Stdcall, automatizační objekt ActiveX se Safecall a dylib macOS s Cdecl
Všechny tři binárky sdílejí jednu plochou PDF fasádu, ale liší se konvencí volání, zpracováním řetězců a požadavky na registraci

Windows: instance přes Stdcall a dvojice funkcí W/A

Každý export přijímající řetězec existuje dvakrát. Široká varianta bere PWideChar (UTF-16, přirozená volba pro .NET, Javu i pythonovský c_wchar_p), varianta s příponou A bere PAnsiChar. Obě mají totožnou sémantiku a liší se pouze kódováním, což je přesně to, proč se jejich smíchání tak obtížně dohledává: nic nevyhodí výjimku, nic nevrátí chybový kód, prostě dostanete mojibake v metadatech nebo falešné „soubor nenalezen“ u každé cesty se znakem mimo prosté ASCII. První chyba kódování, na kterou tým takto narazí, obvykle stojí celé odpoledne, protože příznak ukazuje na data a příčina sedí v deklaraci

// vazba pro Windows (PDFlibDLL64.dll): Stdcall, prosté názvy exportů
function DLCreateLibrary: Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLCreateLibrary';
function DLReleaseLibrary(InstanceID: Integer): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLReleaseLibrary';
function DLLoadFromFile(InstanceID: Integer;
  FileName, Password: PWideChar): Integer; stdcall;
  external 'PDFlibDLL64.dll' name 'DLLoadFromFile';

// vazba pro macOS: tatáž funkce, Cdecl a podtržítko před názvem exportu
function DLCreateLibrary: Integer; cdecl;
  external 'PDFlibDylib.dylib' name '_DLCreateLibrary';

Zvolte pro každého hostitele jednu šířku znaku a zakotvěte ji v generátoru vazeb. Praktické pravidlo: má-li hostitelský jazyk nativní řetězce UTF-16, navažte všude varianty W a rodiny A se už nikdy nedotýkejte

macOS: stejné názvy, jiné ABI

Dylib exportuje tutéž sadu funkcí DL se dvěma systematickými změnami. Konvence volání je Cdecl místo Stdcall a každý název exportu nese úvodní podtržítko (_DLCreateLibrary, _DLLoadFromFile a tak dále). Obě změny jsou čistě mechanické, což z nich dělá ideální materiál pro generovanou vazbu a nebezpečnou past pro ručně upravenou kopii souboru pro Windows. Udržujte jediný kanonický seznam funkcí a nechte z něj, pokud to vaše nástroje umožní, vygenerovat deklarace pro jednotlivé platformy. Přeskočte to a získáte přesně to poškození zásobníku popsané na začátku této stránky, reprodukovatelné jen na platformě, kterou vaše CI zrovna prověřuje nejméně

Hostitelé COM a ActiveX: Safecall a data v Olevariant

Pro VB.NET, C#, VBScript a starší automatizační hostitele obaluje sestavení OCX tutéž fasádu do automatizačního objektu IDispatch jménem IPDFlibrary, jehož každá metoda je deklarovaná jako Safecall. Tato konvence mění způsob, jakým se k vám dostávají chyby. Safecall převádí vnitřní selhání na HRESULT podle COM, takže volající z C# zachytí výjimku tam, kde by plochá DLL vrátila tiché celé číslo, na jehož kontrolu si volající musel vzpomenout. Tatáž operace, dva idiomy selhání, podle toho, kterou binárku jste načetli

Binární data se řídí druhým pravidlem specifickým pro COM. Automatizační rozhraní nemá vůbec žádné ukazatelové parametry. Cokoli binárního, ať jde o vstupní bajty obrázku nebo výstupní bajty PDF, překračuje hranici jako Olevariant přes metody typu AddImageFromVariant a AppendToVariant. Zabalit pole bajtů do variantu je v .NET jediný řádek. Zkuste místo toho předat surový ukazatel s odůvodněním, že jde stejně o tentýž proces, a dispečerská vrstva volání odmítne nebo zkomolí. Nasazení podráží nohy ještě jeden registrační detail: registrace COM je vázaná na bitovou šířku, takže OCX zaregistrované 32bitovým regsvr32 je pro 64bitového hostitele neviditelné. Tento nesoulad se projeví proslulým a naprosto nenápomocným hlášením „class not registered“ na zákaznickém stroji, dlouho poté, co od vás software odešel

Disciplína handlů: instance vlastní dokumenty

Ploché API běží na celočíselných handlech. DLCreateLibrary vrátí instanci. Načtení souboru vrátí ID dokumentu uvnitř této instance. Podepisovací procesy, seznamy řetězců i soubory v režimu přímého přístupu vracejí každý svůj vlastní celočíselný handle, všechny v rámci téže instance. Životní cyklus vypadá z libovolného hostitele FFI stejně; tady je ukázán v Pascalu, protože se dobře čte:

var
  Inst, Doc: Integer;
begin
  Inst := DLCreateLibrary;                       // jedna instance na každé pracovní vlákno
  try
    Doc := DLLoadFromFile(Inst, 'in.pdf', '');   // vrací DocumentID, při selhání 0
    if Doc <> 0 then
    begin
      DLEncrypt(Inst, 'owner-secret', 'user-secret', 3,
        DLEncodePermissions(Inst, 1, 0, 0, 0, 0, 0, 0, 1));
      DLSaveToFile(Inst, 'out.pdf');
    end;
  finally
    DLReleaseLibrary(Inst);                      // uvolní každý dokument, který instance vlastní
  end;
end;

Z toho vlastnického stromu plynou dvě věci. DLReleaseLibrary je jediné úklidové volání, které nezbytně potřebujete, protože jednou ranou zboří každý dokument i procesní handle pod danou instancí. V krátkém skriptu to stačí. V dlouho běžící službě se z toho stane pomalý únik paměti s dodatečným obřadem, takže dokumenty uvolňujte, jakmile s nimi skončíte, místo abyste je nechali kupit až do zániku instance. Instance je také přirozenou jednotkou izolace vláken. Dejte každému pracovnímu vláknu vlastní InstanceID a nikdy jednu instanci nesdílejte mezi vlákny bez vnějšího zamykání, ze stejného důvodu, z jakého byste nikdy nesdíleli jediný objekt TPDFlib mezi vlákny

Vrácené řetězce jsou zapůjčené, ne vaše

Funkce vracející text, například DLGetPageText, vracejí PWideChar nebo PAnsiChar, který ukazuje do bufferu vlastněného a recyklovaného instancí knihovny. Kontrakt zní: okamžitě zkopírovat, nikdy neuvolňovat

Časová osa PDF Library for Delphi kontrastující okamžité zkopírování vypůjčeného ukazatele DLGetPageText s jeho držením, dokud knihovna nerecykluje podkladový buffer
Vrácené ukazatele na char půjčují úložiště, které instance recykluje, takže kopie musí proběhnout před dalším voláním knihovny
var
  P: PWideChar;
  PageText: string;
begin
  P := DLGetPageText(Inst, 7);   // ukazatel do bufferu vlastněného knihovnou
  PageText := P;                 // kopírujte hned; pozdější volání může buffer znovu použít
end;

V C# to znamená převést IntPtr na spravovaný řetězec ještě před dalším voláním knihovny. V pythonovském ctypes to znamená okamžitě vyříznout široký řetězec z ukazatele. Podržte si surový ukazatel napříč voláními a máte napsanou chybu, která projde každým jednotkovým testem a pak selže poprvé ve chvíli, kdy se v produkci překryjí dva požadavky, protože druhé volání recyklovalo buffer, z něhož první ještě četlo. Totéž pravidlo vlastnictví platí opačným směrem pro callbacky registrované přes DLSetProgressCallback. Jakýkoli ukazatel, který knihovna předá do vašeho callbacku, je platný pouze po dobu těla toho callbacku a samotný objekt callbacku musí zůstat naživu (v hostiteli se správcem paměti připnutý) tak dlouho, dokud jej instance ještě může vyvolat. Delegát uklizený uprostřed úlohy je učebnicovým zdrojem „náhodného“ porušení přístupu, jaké se objeví ve vazbě pro .NET, která měsíce běžela bez jediné chyby

Zabudujte přímo do vazby kouřový test a spusťte jej dřív, než jakákoli vygenerovaná sada deklarací opustí dům. Projděte po jednom volání z každé kategorie, která chyby ABI obvykle odhalí: bezparametrovou funkci jako DLCreateLibrary, aby se prokázala správná konvence, funkci přijímající řetězec, které předáte cestu se znaky mimo ASCII, aby se prokázalo správné kódování, funkci vracející řetězec, aby se prokázalo správné zacházení se zapůjčeným bufferem, a jednu operaci, která selže záměrně, abyste viděli, jak se chyba dostane k vašemu hostiteli. To je patnáct minut práce a odchytí to chyby v konvenci volání i v kódování, jež by jinak dorazily o měsíce později v podobě zákaznického crash dumpu

Mřížka 2×2 PDF Library for Delphi ze sond smoke testů vazby pokrývající konvenci volání, kódování řetězců, vypůjčené buffery a odhalování selhání
Čtyři levné sondy odhalí chyby konvence, kódování a vlastnictví dřív, než se vygenerované deklarace dostanou na zákaznický stroj

Konkrétní případ: ctypes v Pythonu

Pythonovské ctypes je vazba, kterou vidím ručně psanou nejčastěji, a hezky se na ní ukazuje rozdělení mezi platformami. Na Windows načtěte knihovnu přes ctypes.WinDLL, aby ctypes použilo Stdcall, navažte funkce W bez přípony a každý řetězcový parametr deklarujte jako c_wchar_p. Na macOS ji načtěte přes ctypes.CDLL kvůli Cdecl, ponechte totožný seznam funkcí a názvy hledejte bez úvodního podtržítka. Většina vrstev FFI včetně ctypes si konvenci s podtržítkem na macOS doplní za vás, ale právě tenhle předpoklad si potvrďte jediným úspěšně vyřešeným voláním dřív, než nad ním vygenerujete stovky deklarací

Za prací na vazbě se táhnou dvě otázky nasazení a obě mají jasnou odpověď. Prostá DLL nepotřebuje žádnou registraci: regsvr32 se týká pouze sestavení ActiveX a DLL se distribuuje kopírováním souboru, což je hlavní důvod, proč ji upřednostnit pro služby a kontejnery na Windows, kde se raději registru vůbec nedotknete. Bezpečnost vůči vláknům se smrskne na pravidlo, které už výše platí: jedna instance na vlákno. Handle instance drží každý kousek proměnlivého stavu, který engine sleduje, tedy vybraný dokument, volby vykreslování i nastavení extrakce, takže dvě vlákna sdílející jednu instanci si navzájem prokládají stav i tehdy, když každé jednotlivé volání vrátí úspěch

Jakmile je vazba pevná, operace na její druhé straně jsou přesně ty, jimž se do hloubky věnují články o Delphi, včetně toho, jak nasadit a auditovat šifrování PDF nebo extrahovat text a obrázky z existujících dokumentů

Binární balíčky pro všechny tři integrační vrstvy se dodávají spolu s knihovnou; edice a licencování najdete na produktové stránce PDF Library for Delphi