Đây là một vấn đề lộ ra ngay khoảnh khắc thư viện PDF rời khỏi ngôn ngữ mẹ đẻ của nó. Bạn có một binding chạy hoàn hảo từ C# trên Windows. Bạn cần đúng những lệnh gọi đó từ Python trên macOS, nên bạn chép tệp khai báo của Windows, đổi tên nhị phân, rồi chạy. Mọi ký hiệu đều phân giải được. Lệnh gọi đầu tiên trả về rác, lệnh thứ hai sập với lỗi access violation, và chẳng dòng mã PDF nào của bạn thay đổi. Lỗi nằm ở tầng ngay dưới PDF: các export trên Windows dùng quy ước Stdcall, còn dylib trên macOS xuất cùng những hàm đó theo Cdecl kèm dấu gạch dưới ở đầu, và một khai báo foreign-function sai bất kỳ chi tiết nào trong hai chi tiết ấy sẽ phá hỏng stack trước cả khi có một tài liệu nào được mở
Cả lớp sự cố đó bắt nguồn từ một quyết định thiết kế đáng hiểu rõ ngay từ đầu. PDF Library for Delphi, engine PDF có kèm mã nguồn của losLab dành cho Delphi và C++Builder, bọc toàn bộ mô hình đối tượng của nó trong một lớp facade phẳng duy nhất là TPDFlib, rồi phát hành facade đó dưới ba hình hài nhị phân: một DLL Windows với khoảng 1.250 hàm được xuất, một đối tượng automation COM/ActiveX, và một dylib macOS. Ngữ nghĩa PDF giống hệt nhau ở cả ba. Phần cắn bạn nằm ở ABI bên dưới: quy ước gọi hàm, cách mã hóa chuỗi, quyền sở hữu handle, và bên nào được phép giải phóng bộ đệm nào
Một facade, ba hình hài nhị phân
Mỗi hàm công khai của TPDFlib đều có một bản phẳng tương ứng tên là DL cộng với tên phương thức. LoadFromFile thành DLLoadFromFile, Encrypt thành DLEncrypt, NewSignProcessFromFile thành DLNewSignProcessFromFile. Tham số đầu tiên của gần như mọi export là một InstanceID do DLCreateLibrary trả về, thay cho tham chiếu đối tượng mà một lập trình viên Delphi vốn sẽ nắm giữ. Hãy thuộc nằm lòng ánh xạ đó từ sớm. Nó có nghĩa là tài liệu API Delphi cũng chính là tài liệu cho mọi ngôn ngữ khác: lớp làm được gì thì DLL làm được nấy dưới một cái tên đoán trước được, và bạn có thể đọc chữ ký phương thức Pascal để biết lệnh gọi mình cần từ Python hay C#
Bản build Windows tạo ra PDFlibDLL32.dll và PDFlibDLL64.dll; hãy chọn bản khớp với độ rộng bit của tiến trình chủ, vì một tiến trình Java hay .NET 64-bit không thể nạp thư viện 32-bit dù khai báo trông ra sao
Windows: instance theo Stdcall và các cặp hàm W/A
Mỗi export nhận chuỗi đều tồn tại hai lần. Bản wide nhận PWideChar (UTF-16, hợp tự nhiên với .NET, Java và c_wchar_p của Python), còn bản có hậu tố A nhận PAnsiChar. Hai bản mang ngữ nghĩa y hệt và chỉ khác nhau ở cách mã hóa, và chính điều đó khiến việc trộn lẫn chúng khó truy vết đến vậy: không có ngoại lệ nào ném ra, không mã lỗi nào trả về, bạn chỉ đơn giản nhận được chữ nghĩa lỗi phông trong metadata hoặc một thông báo "file not found" giả cho bất kỳ đường dẫn nào có ký tự ngoài ASCII thuần. Lỗi mã hóa đầu tiên mà một đội gặp theo kiểu này thường tốn cả buổi chiều, vì triệu chứng chỉ vào dữ liệu còn nguyên nhân lại nằm trong khai báo
// Binding cho Windows (PDFlibDLL64.dll): Stdcall, tên export để trần
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';
// Binding cho macOS: cùng hàm đó, Cdecl, và tên export có tiền tố gạch dưới
function DLCreateLibrary: Integer; cdecl;
external 'PDFlibDylib.dylib' name '_DLCreateLibrary';
Hãy chọn một độ rộng ký tự cho mỗi host và ghi cứng lựa chọn đó vào bộ sinh binding. Một quy tắc thực dụng: nếu ngôn ngữ chủ có chuỗi UTF-16 gốc, hãy bind các bản W ở mọi nơi và không bao giờ đụng tới họ hàm A nữa
macOS: cùng tên, khác ABI
Dylib xuất cùng bộ hàm DL với hai thay đổi có hệ thống. Quy ước gọi là Cdecl chứ không phải Stdcall, và mọi tên export đều mang một dấu gạch dưới ở đầu (_DLCreateLibrary, _DLLoadFromFile, và cứ thế). Cả hai thay đổi đều thuần cơ học, điều này khiến chúng lý tưởng cho một binding sinh tự động và nguy hiểm cho một bản chép tay từ tệp Windows. Hãy giữ một danh sách hàm chuẩn duy nhất rồi phát sinh khai báo cho từng nền tảng từ đó nếu công cụ của bạn cho phép. Bỏ qua bước này thì bạn nhận đúng kiểu hỏng stack đã mô tả ở đầu trang, và nó chỉ tái hiện trên nền tảng mà CI của bạn ít chạy nhất
Host COM và ActiveX: Safecall và tải trọng Olevariant
Với VB.NET, C#, VBScript và các host automation đời cũ, bản build OCX bọc chính facade đó trong một đối tượng automation IDispatch tên IPDFlibrary, với mọi phương thức đều khai báo Safecall. Quy ước này thay đổi cách lỗi đến tay bạn. Safecall chuyển một sự cố nội bộ thành HRESULT của COM, nên bên gọi từ C# bắt được một ngoại lệ ở chỗ mà DLL phẳng vốn chỉ trả về lặng lẽ một số nguyên mà bên gọi phải nhớ kiểm tra. Cùng một thao tác, hai lối biểu đạt lỗi, tùy vào bạn nạp bản nhị phân nào
Dữ liệu nhị phân theo một quy tắc thứ hai riêng của COM. Giao diện automation hoàn toàn không có tham số con trỏ. Mọi thứ nhị phân, byte ảnh đi vào hay byte PDF đi ra, đều vượt ranh giới dưới dạng Olevariant qua các phương thức như AddImageFromVariant và AppendToVariant. Marshal một mảng byte vào variant chỉ tốn một dòng trong .NET. Cố đưa cho nó một con trỏ thô với lý lẽ rằng đằng nào cũng cùng một tiến trình, thì tầng dispatch sẽ từ chối hoặc làm hỏng lệnh gọi. Còn một chi tiết đăng ký nữa hay làm sập khâu triển khai: đăng ký COM tách theo độ rộng bit, nên một OCX đăng ký bằng regsvr32 32-bit sẽ vô hình với host 64-bit. Sự lệch pha đó hiện ra dưới dạng thông báo vô dụng nổi tiếng "class not registered" trên máy khách hàng, rất lâu sau khi nó rời khỏi máy bạn
Kỷ luật handle: instance sở hữu tài liệu
API phẳng chạy trên các handle số nguyên. DLCreateLibrary trả về một instance. Nạp một tệp thì trả về một document ID bên trong instance đó. Các sign process, string list và tệp direct-access đều trả về handle số nguyên riêng, tất cả đều thuộc phạm vi cùng một instance. Vòng đời trông giống nhau ở mọi host FFI, ở đây trình bày bằng Pascal vì nó đọc gọn ghẽ:
var
Inst, Doc: Integer;
begin
Inst := DLCreateLibrary; // mỗi luồng làm việc một instance
try
Doc := DLLoadFromFile(Inst, 'in.pdf', ''); // trả về DocumentID, 0 nếu thất bại
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); // giải phóng mọi tài liệu instance sở hữu
end;
end;
Hai điều rút ra từ cây sở hữu đó. DLReleaseLibrary là lệnh dọn dẹp duy nhất bạn nhất thiết cần, vì nó phá bỏ mọi handle tài liệu và tiến trình dưới instance trong một nhát. Trong một script ngắn thì thế là đủ. Trong một dịch vụ chạy dài thì nó biến thành rò rỉ chậm kèm nghi thức thừa, nên hãy giải phóng tài liệu ngay khi dùng xong thay vì để chúng chất đống tới lúc instance chết. Instance cũng là đơn vị cô lập luồng tự nhiên. Hãy cấp cho mỗi luồng làm việc một InstanceID riêng, và đừng bao giờ chia sẻ một instance qua nhiều luồng mà không có khóa bên ngoài, vì đúng lý do khiến bạn không bao giờ chia sẻ một đối tượng TPDFlib giữa các luồng
Chuỗi trả về là đồ mượn, không phải đồ sở hữu
Những hàm trả về văn bản, chẳng hạn DLGetPageText, đưa lại một PWideChar hoặc PAnsiChar trỏ vào bộ đệm do instance thư viện sở hữu và tái sử dụng. Giao kèo là: chép ngay lập tức, đừng bao giờ giải phóng
var
P: PWideChar;
PageText: string;
begin
P := DLGetPageText(Inst, 7); // con trỏ vào bộ đệm do thư viện sở hữu
PageText := P; // chép ngay; lệnh gọi sau có thể dùng lại bộ đệm
end;
Trong C#, điều đó nghĩa là marshal IntPtr thành chuỗi được quản lý trước lệnh gọi thư viện kế tiếp. Trong ctypes của Python, nghĩa là cắt chuỗi wide ra khỏi con trỏ ngay tức thì. Giữ con trỏ thô qua nhiều lệnh gọi là bạn đã viết ra một lỗi vượt qua mọi unit test rồi hỏng ngay lần đầu hai yêu cầu chồng lấn nhau trên môi trường sản xuất, vì lệnh gọi thứ hai đã tái sử dụng bộ đệm mà lệnh thứ nhất vẫn đang đọc. Cùng quy tắc sở hữu ấy chạy theo chiều ngược lại với các callback đăng ký qua DLSetProgressCallback. Bất kỳ con trỏ nào thư viện trao vào callback của bạn chỉ hợp lệ trong thân callback đó, và bản thân đối tượng callback phải còn sống (được ghim, nếu host có bộ thu gom rác) chừng nào instance còn có thể gọi tới nó. Một delegate bị thu gom giữa chừng công việc là nguồn gốc kinh điển của lỗi access violation "ngẫu nhiên" xuất hiện trong một binding .NET đã chạy sạch suốt nhiều tháng
Hãy dựng sẵn một smoke test ngay trong binding, và chạy nó trước khi phát hành bất kỳ bộ khai báo sinh tự động nào. Hãy thử một lệnh gọi từ mỗi nhóm hay phơi ra sai sót ABI: một hàm không tham số như DLCreateLibrary để chứng minh quy ước gọi đúng, một hàm nhận chuỗi được nạp đường dẫn chứa ký tự ngoài ASCII để chứng minh cách mã hóa đúng, một hàm trả chuỗi để chứng minh việc xử lý bộ đệm mượn đúng, và một thao tác cố tình thất bại để bạn thấy lỗi đến tay host của mình ra sao. Đó là mười lăm phút công sức, và nó tóm được những lỗi quy ước gọi và mã hóa mà nếu không thì vài tháng sau sẽ tới dưới dạng một crash dump của khách hàng
Trường hợp Python ctypes, thật cụ thể
Python ctypes là binding tôi thấy người ta tự viết tay nhiều nhất, và nó cho phép minh họa dễ dàng sự chia đôi giữa hai nền tảng. Trên Windows, hãy nạp thư viện bằng ctypes.WinDLL để ctypes áp dụng Stdcall, bind các hàm W không hậu tố, và khai báo mọi tham số chuỗi là c_wchar_p. Trên macOS, hãy nạp bằng ctypes.CDLL cho Cdecl, giữ nguyên danh sách hàm y hệt, và phân giải tên mà không kèm gạch dưới đầu. Phần lớn tầng FFI, kể cả ctypes, tự gấp quy ước gạch dưới lại giúp bạn trên macOS, nhưng đó chính là giả định duy nhất cần xác nhận bằng một lệnh gọi phân giải được trước khi bạn sinh hàng trăm khai báo dựa lên nó
Hai câu hỏi về triển khai bám theo công việc binding và đều có câu trả lời dứt khoát. DLL thuần không cần đăng ký: regsvr32 chỉ áp dụng cho bản ActiveX, còn DLL phát hành bằng cách chép tệp, và đó là lý do chính để ưu tiên nó cho các dịch vụ Windows và container nơi bạn thà không đụng tới registry chút nào. An toàn luồng thu về đúng quy tắc đã nêu ở trên, mỗi luồng một instance. Handle instance nắm giữ mọi mẩu trạng thái khả biến mà engine theo dõi, gồm tài liệu đang chọn, các tùy chọn render, các thiết lập trích xuất, nên hai luồng dùng chung một instance sẽ đan xen trạng thái của nhau ngay cả khi từng lệnh gọi riêng lẻ đều báo thành công
Một khi binding đã vững, những thao tác ở phía bên kia nó đúng là những gì các bài viết về Delphi đã bàn sâu, gồm áp dụng và kiểm toán mã hóa PDF cùng trích xuất văn bản và ảnh từ tài liệu sẵn có
Bản tải nhị phân cho cả ba tầng tích hợp đều đi kèm thư viện; xem trang sản phẩm PDF Library for Delphi để biết các phiên bản và điều khoản cấp phép