Artykuł techniczny

Wiązania PDF DLL, ActiveX i dylib w Delphi

Oto problem, który pojawia się w chwili, gdy biblioteka PDF opuszcza swój macierzysty język. Masz wiązanie działające bez zarzutu z C# na Windows. Potrzebujesz tych samych wywołań z Pythona na macOS, więc kopiujesz plik deklaracji z Windows, podmieniasz nazwę binarium i uruchamiasz. Każdy symbol się rozwiązuje. Pierwsze wywołanie zwraca śmieci, drugie kończy się naruszeniem ochrony pamięci, a żaden fragment twojego kodu PDF się nie zmienił. Wina leży jedną warstwę poniżej PDF: eksporty Windows używają konwencji Stdcall, dylib dla macOS eksportuje te same funkcje jako Cdecl z wiodącym podkreśleniem, a deklaracja funkcji obcej, która pomyli którykolwiek z tych szczegółów, uszkadza stos, zanim otwarty zostanie choćby jeden dokument

Cała ta klasa awarii wynika z jednej decyzji projektowej, którą warto zrozumieć na wstępie. PDF Library for Delphi, dostępny w postaci źródłowej silnik PDF firmy losLab dla Delphi i C++Builder, opakowuje cały swój model obiektowy w jedną płaską klasę fasadową TPDFlib, a następnie dostarcza tę fasadę w trzech postaciach binarnych: jako DLL dla Windows z około 1250 wyeksportowanymi funkcjami, jako obiekt automatyzacji COM/ActiveX oraz jako dylib dla macOS. Semantyka PDF jest identyczna we wszystkich trzech. To, co potrafi ugryźć, mieszka w ABI pod spodem: konwencje wywołań, kodowania łańcuchów, własność uchwytów oraz to, która strona może zwolnić który bufor

Jedna fasada, trzy postacie binarne

Każda publiczna funkcja TPDFlib ma płaski odpowiednik nazwany DL plus nazwa metody. LoadFromFile staje się DLLoadFromFile, Encrypt staje się DLEncrypt, a NewSignProcessFromFile staje się DLNewSignProcessFromFile. Pierwszym parametrem niemal każdego eksportu jest InstanceID zwrócony przez DLCreateLibrary, zastępujący referencję do obiektu, którą w przeciwnym razie trzymałby wywołujący z Delphi. Przyswój to odwzorowanie wcześnie. Oznacza ono, że dokumentacja API Delphi pełni jednocześnie rolę dokumentacji dla każdego innego języka: cokolwiek potrafi klasa, potrafi też DLL pod przewidywalną nazwą, a sygnaturę metody w Pascalu możesz przeczytać, by poznać wywołanie, którego potrzebujesz z Pythona albo C#

Kompilacja dla Windows daje PDFlibDLL32.dll i PDFlibDLL64.dll; wybierz tę pasującą do bitowości procesu hosta, ponieważ 64-bitowy proces Javy albo .NET nie wczyta 32-bitowej biblioteki niezależnie od tego, jak wygląda deklaracja

Diagram architektury jednej fasady TPDFlib udostępnianej jako DLL Stdcall dla Windows, obiekt automatyzacji ActiveX Safecall i dylib Cdecl dla macOS
Wszystkie trzy binaria dzielą jedną płaską fasadę PDF, lecz różnią się konwencją wywołań, obsługą łańcuchów i wymogami rejestracji

Windows: instancje Stdcall i pary funkcji W/A

Każdy eksport przyjmujący łańcuch istnieje dwukrotnie. Wersja szeroka przyjmuje PWideChar (UTF-16, naturalny wybór dla .NET, Javy i Pythonowego c_wchar_p), a wersja z przyrostkiem A przyjmuje PAnsiChar. Obie niosą identyczną semantykę i różnią się wyłącznie kodowaniem, co właśnie sprawia, że ich pomieszanie jest tak trudne do wytropienia: nic nie zgłasza wyjątku, nic nie zwraca kodu błędu, po prostu dostajesz krzaki w metadanych albo fałszywy komunikat o nieznalezionym pliku dla dowolnej ścieżki ze znakiem spoza czystego ASCII. Pierwszy taki błąd kodowania kosztuje zespół zwykle całe popołudnie, ponieważ objaw wskazuje na dane, a przyczyna tkwi w deklaracji

// Wiązanie dla Windows (PDFlibDLL64.dll): Stdcall, zwykłe nazwy eksportów
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';

// Wiązanie dla macOS: ta sama funkcja, Cdecl i podkreślenie przed nazwą eksportu
function DLCreateLibrary: Integer; cdecl;
  external 'PDFlibDylib.dylib' name '_DLCreateLibrary';

Wybierz jedną szerokość znaku na host i zapisz ją w generatorze wiązań. Praktyczna zasada: jeśli język hosta ma natywne łańcuchy UTF-16, wiąż wszędzie wersje W i nigdy więcej nie dotykaj rodziny A

macOS: te same nazwy, inne ABI

Dylib eksportuje ten sam zestaw funkcji DL z dwiema systematycznymi zmianami. Konwencją wywołań jest Cdecl zamiast Stdcall, a każda nazwa eksportu nosi wiodące podkreślenie (_DLCreateLibrary, _DLLoadFromFile i tak dalej). Obie zmiany są czysto mechaniczne, co czyni je idealnymi dla generowanego wiązania i niebezpiecznymi dla ręcznie edytowanej kopii pliku z Windows. Utrzymuj jedną kanoniczną listę funkcji i emituj z niej deklaracje dla każdej platformy, jeśli twoje narzędzia na to pozwalają. Pomiń to, a dostaniesz dokładnie to uszkodzenie stosu opisane na początku tej strony, odtwarzalne tylko na platformie, którą twoje CI ćwiczy najrzadziej

Hosty COM i ActiveX: Safecall i ładunki Olevariant

Dla VB.NET, C#, VBScript i starszych hostów automatyzacji kompilacja OCX opakowuje tę samą fasadę w obiekt automatyzacji IDispatch o nazwie IPDFlibrary, w którym każda metoda jest zadeklarowana jako Safecall. Ta konwencja zmienia sposób, w jaki docierają do ciebie błędy. Safecall zamienia wewnętrzną awarię w COM-owy HRESULT, więc wywołujący z C# przechwytuje wyjątek tam, gdzie płaska DLL zwróciłaby cichą liczbę całkowitą, o której sprawdzeniu wywołujący musiałby pamiętać. Ta sama operacja, dwa idiomy awarii, zależnie od tego, które binarium wczytałeś

Dane binarne podlegają drugiej regule specyficznej dla COM. Interfejs automatyzacji nie ma w ogóle parametrów wskaźnikowych. Wszystko, co binarne, bajty obrazu wchodzące do środka albo bajty PDF wychodzące na zewnątrz, przekracza granicę jako Olevariant przez metody takie jak AddImageFromVariant i AppendToVariant. Umieszczenie tablicy bajtów w wariancie to w .NET jedna linia. Spróbuj podać zamiast tego surowy wskaźnik, uzasadniając to tym, że i tak chodzi o ten sam proces, a warstwa dyspozytorska odrzuci albo zniekształci wywołanie. Wdrożenia potyka jeszcze jeden szczegół rejestracji: rejestracja COM jest osobna dla każdej bitowości, więc OCX zarejestrowany 32-bitowym regsvr32 jest niewidoczny dla 64-bitowego hosta. Ta niezgodność objawia się słynnie nieprzydatnym komunikatem o niezarejestrowanej klasie na maszynie klienta, długo po tym, jak opuściła twoją

Dyscyplina uchwytów: instancje są właścicielami dokumentów

Płaskie API działa na uchwytach całkowitoliczbowych. DLCreateLibrary zwraca instancję. Wczytanie pliku zwraca identyfikator dokumentu wewnątrz tej instancji. Procesy podpisu, listy łańcuchów i pliki z dostępem bezpośrednim zwracają własne uchwyty całkowitoliczbowe, wszystkie o zasięgu tej samej instancji. Cykl życia wygląda tak samo z dowolnego hosta FFI, tutaj pokazany w Pascalu, ponieważ czyta się przejrzyście:

var
  Inst, Doc: Integer;
begin
  Inst := DLCreateLibrary;                       // jedna instancja na wątek roboczy
  try
    Doc := DLLoadFromFile(Inst, 'in.pdf', '');   // zwraca DocumentID, 0 przy niepowodzeniu
    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);                      // zwalnia każdy dokument należący do instancji
  end;
end;

Z tego drzewa własności wynikają dwie rzeczy. DLReleaseLibrary to jedyne wywołanie sprzątające, którego bezwzględnie potrzebujesz, ponieważ za jednym zamachem burzy każdy dokument i każdy uchwyt procesu podległy instancji. W krótkim skrypcie to wystarczy. W długo działającej usłudze staje się to powolnym wyciekiem z dodatkową ceremonią, więc zwalniaj dokumenty, gdy skończysz z nimi pracę, zamiast pozwalać im piętrzyć się aż do śmierci instancji. Instancja jest też naturalną jednostką izolacji wątków. Daj każdemu wątkowi roboczemu własny InstanceID i nigdy nie współdziel jednego między wątkami bez zewnętrznego blokowania, z tego samego powodu, dla którego nigdy nie współdzieliłbyś pojedynczego obiektu TPDFlib między wątkami

Zwracane łańcuchy są pożyczone, a nie posiadane

Funkcje zwracające tekst, takie jak DLGetPageText, oddają PWideChar albo PAnsiChar wskazujący do bufora, którego właścicielem jest instancja biblioteki i który sama wykorzystuje ponownie. Kontrakt brzmi: kopiuj natychmiast, nigdy nie zwalniaj

Oś czasu PDF Library for Delphi zestawiająca natychmiastowe skopiowanie pożyczonego wskaźnika DLGetPageText z trzymaniem go do chwili, gdy biblioteka ponownie wykorzysta leżący pod nim bufor
Zwracane wskaźniki znakowe pożyczają pamięć, którą instancja wykorzystuje ponownie, więc kopia musi powstać przed kolejnym wywołaniem biblioteki
var
  P: PWideChar;
  PageText: string;
begin
  P := DLGetPageText(Inst, 7);   // wskaźnik do bufora należącego do biblioteki
  PageText := P;                 // kopiuj teraz; kolejne wywołanie może użyć bufora ponownie
end;

W C# oznacza to zmarszałowanie IntPtr do zarządzanego łańcucha przed kolejnym wywołaniem biblioteki. W Pythonowym ctypes oznacza to natychmiastowe wycięcie szerokiego łańcucha ze wskaźnika. Przetrzymaj surowy wskaźnik między wywołaniami, a napisałeś błąd, który przechodzi każdy test jednostkowy, a potem zawodzi przy pierwszym nałożeniu się dwóch żądań na produkcji, ponieważ drugie wywołanie użyło ponownie bufora, który pierwsze wciąż czytało. Ta sama reguła własności działa w drugą stronę dla wywołań zwrotnych rejestrowanych przez DLSetProgressCallback. Każdy wskaźnik, który biblioteka podaje do twojego wywołania zwrotnego, jest ważny wyłącznie w ciele tego wywołania, a sam obiekt wywołania zwrotnego musi pozostać żywy (przypięty, w hoście ze zbieraniem nieużytków) tak długo, jak instancja może go jeszcze wywołać. Delegat zebrany w połowie zadania to podręcznikowe źródło losowego naruszenia ochrony pamięci, które pojawia się w wiązaniu .NET działającym czysto od miesięcy

Wbuduj test dymny w samo wiązanie i uruchamiaj go, zanim jakikolwiek wygenerowany zestaw deklaracji trafi dalej. Przećwicz po jednym wywołaniu z każdej kategorii, która lubi ujawniać pomyłki ABI: funkcję bezparametrową taką jak DLCreateLibrary, by dowieść, że konwencja jest właściwa, funkcję przyjmującą łańcuch nakarmioną ścieżką ze znakami spoza ASCII, by dowieść, że kodowanie jest właściwe, funkcję zwracającą łańcuch, by dowieść, że obsługa pożyczonego bufora jest właściwa, oraz jedną operację celowo kończącą się niepowodzeniem, żebyś mógł zobaczyć, jak błąd dociera do twojego hosta. To piętnaście minut pracy, a wyłapuje wady konwencji wywołań i kodowania, które w przeciwnym razie przyjdą po miesiącach jako zrzut awaryjny od klienta

Siatka dwa na dwa sond testu dymnego wiązania PDF Library for Delphi obejmująca konwencję wywołań, kodowanie łańcuchów, pożyczone bufory i ujawnianie awarii
Cztery tanie sondy wyłapują wady konwencji, kodowania i własności, zanim wygenerowane deklaracje trafią na maszynę klienta

Przypadek Pythonowego ctypes, konkretnie

Pythonowy ctypes to wiązanie, które najczęściej widuję pisane ręcznie, i dobrze pokazuje podział międzyplatformowy. Na Windows wczytaj bibliotekę przez ctypes.WinDLL, aby ctypes zastosował Stdcall, zwiąż funkcje W bez przyrostka i zadeklaruj każdy parametr łańcuchowy jako c_wchar_p. Na macOS wczytaj ją przez ctypes.CDLL dla Cdecl, zachowaj identyczną listę funkcji i rozwiązuj nazwy bez wiodącego podkreślenia. Większość warstw FFI, w tym ctypes, sama dokłada za ciebie konwencję podkreślenia na macOS, ale to jest właśnie to jedno założenie, które warto potwierdzić pojedynczym rozwiązanym wywołaniem, zanim zbudujesz na nim setki deklaracji

Za pracą nad wiązaniem ciągną się dwa pytania wdrożeniowe o zwięzłych odpowiedziach. Zwykła DLL nie wymaga rejestracji: regsvr32 dotyczy wyłącznie kompilacji ActiveX, a DLL dostarcza się przez skopiowanie pliku, co jest głównym powodem, by preferować ją w usługach i kontenerach Windows, gdzie wolisz w ogóle nie dotykać rejestru. Bezpieczeństwo wątkowe sprowadza się do zasady już wyżej obecnej, czyli jednej instancji na wątek. Uchwyt instancji trzyma każdy fragment zmiennego stanu, który śledzi silnik, wybrany dokument, opcje renderowania, ustawienia ekstrakcji, więc dwa wątki dzielące jedną instancję przeplatają sobie nawzajem stan, nawet gdy każde pojedyncze wywołanie zwraca sukces

Gdy wiązanie jest solidne, operacje po jego drugiej stronie to dokładnie te, które artykuły o Delphi omawiają dogłębnie, w tym stosowanie i audyt szyfrowania PDF oraz wydobywanie tekstu i obrazów z istniejących dokumentów

Pobrania binariów dla wszystkich trzech warstw integracji dostarczane są wraz z biblioteką; edycje i licencjonowanie znajdziesz na stronie produktu PDF Library for Delphi