บทความเทคนิค

บายดิง PDF Library for Delphi DLL, ActiveX และ dylib: การเรียกใช้เอ็นจิ้น PDF เดียวจากภาษาใดก็ได้

นี่คือปัญหาที่ปรากฏขึ้นทันทีที่ไลบรารี PDF หลุดออกจากภาษาต้นทางของมัน คุณมี binding ที่ทำงานได้สมบูรณ์แบบจาก C# บน Windows คุณต้องการเรียกใช้ฟังก์ชันชุดเดียวกันจาก Python บน macOS จึงคัดลอกไฟล์ declaration ของ Windows มาสลับชื่อไบนารีแล้วรันทันที ทุก symbol resolve ได้หมด การเรียกครั้งแรกคืนค่าขยะ ครั้งที่สอง crash ด้วย access violation ทั้งที่โค้ด PDF ของคุณไม่ได้เปลี่ยนแปลงเลยสักบรรทัด ต้นเหตุอยู่ในชั้นที่ต่ำกว่า PDF อีกชั้นหนึ่ง: exports ของ Windows ใช้ calling convention แบบ Stdcall ส่วน dylib ของ macOS export ฟังก์ชันชุดเดียวกันแบบ Cdecl พร้อม underscore นำหน้า และ declaration แบบ foreign-function ที่ผิดพลาดในรายละเอียดข้อใดข้อหนึ่งจะทำให้ stack เสียหายก่อนที่เอกสารแม้แต่ฉบับเดียวจะถูกเปิดขึ้นมาด้วยซ้ำ

ความล้มเหลวทั้งประเภทนี้มีที่มาจากการตัดสินใจด้านการออกแบบเพียงจุดเดียวที่ควรทำความเข้าใจไว้ตั้งแต่ต้น PDF Library for Delphi ซึ่งเป็น PDF engine แบบ source-available ของ losLab สำหรับ Delphi และ C++Builder ห่อหุ้ม object model ทั้งหมดไว้ในคลาส facade แบบแบนราบเพียงคลาสเดียวคือ TPDFlib จากนั้นจึงส่งมอบ facade นั้นออกมาในรูปไบนารีสามแบบ ได้แก่ Windows DLL ที่มีฟังก์ชัน export ประมาณ 1,250 ตัว, COM/ActiveX automation object และ macOS dylib ความหมายเชิง PDF เหมือนกันทุกประการทั้งสามแบบ ส่วนที่กัดคุณจริง ๆ อยู่ใน ABI ชั้นล่างสุด ได้แก่ calling convention, การเข้ารหัสสตริง, ความเป็นเจ้าของ handle และฝ่ายใดมีสิทธิ์คืนหน่วยความจำ buffer ใด

Facade เดียว ไบนารีสามรูปแบบ

ฟังก์ชัน public ทุกตัวของ TPDFlib มีคู่แบบแบนราบที่ตั้งชื่อด้วย DL ตามด้วยชื่อเมธอด LoadFromFile กลายเป็น DLLoadFromFile, Encrypt กลายเป็น DLEncrypt, NewSignProcessFromFile กลายเป็น DLNewSignProcessFromFile พารามิเตอร์ตัวแรกของ export เกือบทุกตัวคือ InstanceID ที่คืนค่ามาจาก DLCreateLibrary ทำหน้าที่แทน object reference ที่ผู้เรียกจาก Delphi จะถือไว้ในกรณีอื่น จงจดจำการแม็ปนี้ไว้ตั้งแต่แรก เพราะมันหมายความว่า Delphi API reference ทำหน้าที่เป็นเอกสารประกอบสำหรับทุกภาษาอื่นไปในตัว ไม่ว่าคลาสจะทำอะไรได้ DLL ก็ทำสิ่งนั้นได้ภายใต้ชื่อที่คาดเดาได้ และคุณสามารถอ่าน signature ของเมธอดแบบ Pascal เพื่อเรียนรู้การเรียกที่คุณต้องการจาก Python หรือ C# ได้

Windows build จะสร้าง PDFlibDLL32.dll และ PDFlibDLL64.dll ให้เลือกไฟล์ที่ตรงกับ bitness ของโปรเซสโฮสต์ของคุณ เพราะโปรเซส Java หรือ .NET แบบ 64 บิตไม่มีทางโหลดไลบรารี 32 บิตได้ ไม่ว่า declaration จะเขียนไว้อย่างไรก็ตาม

แผนภาพสถาปัตยกรรมของ facade TPDFlib ตัวเดียวที่เปิดเผยเป็น Windows DLL แบบ Stdcall, ActiveX automation object แบบ Safecall และ macOS dylib แบบ Cdecl
ไบนารีทั้งสามใช้ PDF facade แบบแบนร่วมกัน แต่ต่างกันในเรื่อง calling convention การจัดการสตริง และข้อกำหนดการลงทะเบียน

Windows: instance แบบ Stdcall และคู่ฟังก์ชัน W/A

export ที่รับสตริงทุกตัวมีอยู่สองเวอร์ชัน เวอร์ชัน wide รับ PWideChar (UTF-16 ซึ่งเข้ากันได้ตามธรรมชาติกับ .NET, Java และ c_wchar_p ของ Python) ส่วนเวอร์ชันที่มีคำต่อท้าย A รับ PAnsiChar ทั้งสองมีความหมายเหมือนกันทุกประการ ต่างกันแค่การเข้ารหัส ซึ่งนั่นเองที่ทำให้การใช้ปนกันตามรอยได้ยากเป็นพิเศษ ไม่มีการ throw ไม่มีการคืนรหัส error คุณจะได้แค่ mojibake ใน metadata หรือข้อความ "file not found" ปลอม ๆ สำหรับ path ใด ๆ ที่มีอักขระเกินขอบเขต ASCII ธรรมดา บั๊กด้านการเข้ารหัสตัวแรกที่ทีมพบเจอแบบนี้มักกินเวลาไปครึ่งวัน เพราะอาการชี้ไปที่ข้อมูล แต่สาเหตุจริงอยู่ที่ declaration

// binding ของ Windows (PDFlibDLL64.dll): Stdcall ชื่อ 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';

// binding ของ macOS: ฟังก์ชันเดียวกัน แบบ Cdecl และมี underscore นำหน้าชื่อ export
function DLCreateLibrary: Integer; cdecl;
  external 'PDFlibDylib.dylib' name '_DLCreateLibrary';

เลือกความกว้างอักขระเดียวต่อโฮสต์หนึ่งตัว แล้วเข้ารหัสกฎนั้นไว้ใน binding generator ให้ตายตัว กฎที่ใช้ได้จริงคือ ถ้าภาษาโฮสต์มีสตริง UTF-16 แบบเนทีฟอยู่แล้ว ให้ bind เวอร์ชัน W ทุกจุดแล้วไม่ต้องแตะตระกูล A อีกเลย

macOS: ชื่อเดิม แต่ ABI ต่างกัน

dylib export ฟังก์ชันชุด DL เดียวกัน โดยมีการเปลี่ยนแปลงเชิงระบบสองจุด calling convention เป็น Cdecl แทนที่จะเป็น Stdcall และทุกชื่อ export มี underscore นำหน้า (_DLCreateLibrary, _DLLoadFromFile และอื่น ๆ) การเปลี่ยนแปลงทั้งสองจุดนี้เป็นกลไกล้วน ๆ ซึ่งทำให้เหมาะกับ binding ที่สร้างโดยอัตโนมัติเป็นอย่างยิ่ง และเป็นอันตรายสำหรับไฟล์ Windows ที่ถูกคัดลอกมาแก้ด้วยมือ ควรเก็บรายการฟังก์ชัน canonical ไว้เพียงชุดเดียว แล้วสร้าง declaration เฉพาะแพลตฟอร์มออกมาจากมันหากเครื่องมือของคุณรองรับ ถ้าข้ามขั้นตอนนี้ไป คุณจะได้ stack corruption แบบเดียวกับที่อธิบายไว้ตอนต้นของบทความนี้เป๊ะ ๆ และมันจะเกิดซ้ำเฉพาะบนแพลตฟอร์มที่ CI ของคุณทดสอบน้อยที่สุดเท่านั้น

โฮสต์ COM และ ActiveX: payload แบบ Safecall และ Olevariant

สำหรับ VB.NET, C#, VBScript และโฮสต์ automation รุ่นเก่า OCX build จะห่อหุ้ม facade เดียวกันไว้ใน automation object แบบ IDispatch ที่ชื่อ IPDFlibrary โดยทุกเมธอดประกาศเป็น Safecall convention นี้เปลี่ยนวิธีที่ error มาถึงคุณ Safecall แปลงความล้มเหลวภายในให้กลายเป็น COM HRESULT ทำให้ผู้เรียกจาก C# จับ exception ได้ ในขณะที่ DLL แบบแบนราบจะคืนค่าเป็นแค่ตัวเลข integer เงียบ ๆ ที่ผู้เรียกต้องจำไว้เองว่าต้องตรวจสอบ การทำงานแบบเดียวกัน แต่มีสำนวนความล้มเหลวสองแบบ ขึ้นอยู่กับว่าคุณโหลดไบนารีตัวไหน

ข้อมูลไบนารีมีกฎเฉพาะของ COM อีกข้อหนึ่ง interface แบบ automation ไม่มีพารามิเตอร์ชนิด pointer เลยแม้แต่ตัวเดียว สิ่งใดก็ตามที่เป็นไบนารี ไม่ว่าจะเป็น byte ของรูปภาพที่ส่งเข้าไปหรือ byte ของ PDF ที่ส่งออกมา จะข้ามผ่านขอบเขตนี้ในรูป Olevariant ผ่านเมธอดอย่าง AddImageFromVariant และ AppendToVariant การ marshal byte array ให้กลายเป็น variant ใน .NET ใช้แค่บรรทัดเดียว แต่ถ้าคุณลองส่ง raw pointer เข้าไปแทน ด้วยเหตุผลว่าเป็นโปรเซสเดียวกันอยู่แล้ว ชั้น dispatch จะปฏิเสธหรือทำให้การเรียกนั้นเพี้ยนไป ยังมีรายละเอียดด้านการลงทะเบียนอีกจุดที่ทำให้การ deploy สะดุด นั่นคือการลงทะเบียน COM แยกตาม bitness ดังนั้น OCX ที่ลงทะเบียนด้วย regsvr32 แบบ 32 บิต จะมองไม่เห็นจากโฮสต์ 64 บิต ความไม่ตรงกันนี้จะโผล่มาเป็นข้อความ "class not registered" ที่ขึ้นชื่อเรื่องไม่ช่วยอะไรเลยบนเครื่องลูกค้า นานหลังจากที่มันออกจากเครื่องคุณไปแล้ว

วินัยเรื่อง handle: instance เป็นเจ้าของเอกสาร

API แบบแบนราบทำงานบน handle ชนิด integer DLCreateLibrary คืนค่า instance ตัวหนึ่งกลับมา การโหลดไฟล์จะคืนค่า document ID ภายใน instance นั้น sign process, string list และไฟล์แบบ direct-access แต่ละอย่างต่างคืนค่า integer handle ของตัวเอง ซึ่งทั้งหมดอยู่ในขอบเขตของ instance เดียวกัน วงจรชีวิตนี้มีหน้าตาเหมือนกันไม่ว่าจะมองจากโฮสต์ FFI ใด ตัวอย่างด้านล่างนี้เขียนเป็น Pascal เพราะอ่านได้ชัดเจนที่สุด:

var
  Inst, Doc: Integer;
begin
  Inst := DLCreateLibrary;                       // หนึ่ง instance ต่อหนึ่ง worker thread
  try
    Doc := DLLoadFromFile(Inst, 'in.pdf', '');   // คืนค่า DocumentID, 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);                      // คืนหน่วยความจำของทุกเอกสารที่ instance เป็นเจ้าของ
  end;
end;

มีสองเรื่องที่ตามมาจากโครงสร้างความเป็นเจ้าของนี้ DLReleaseLibrary เป็นการเรียก cleanup เพียงตัวเดียวที่คุณจำเป็นต้องเรียกจริง ๆ เพราะมันจะทำลาย handle ของเอกสารและ process ทุกตัวภายใต้ instance นั้นในคราวเดียว ในสคริปต์สั้น ๆ แค่นี้ก็เพียงพอ แต่ในบริการที่รันระยะยาว มันจะกลายเป็นการรั่วไหลอย่างช้า ๆ ที่มาพร้อมพิธีรีตองเพิ่มเติม ดังนั้นควรปล่อยเอกสารทิ้งทันทีที่ใช้งานเสร็จ แทนที่จะปล่อยให้มันสะสมไปเรื่อย ๆ จนกว่า instance จะถูกทำลาย instance ยังเป็นหน่วยธรรมชาติของการแยก thread ด้วย ให้ worker thread แต่ละตัวมี InstanceID ของตัวเอง และห้ามใช้ตัวเดียวกันข้าม thread โดยไม่มี external locking ด้วยเหตุผลเดียวกับที่คุณจะไม่มีวันแชร์ object TPDFlib ตัวเดียวกันข้าม thread

สตริงที่คืนค่ากลับมาคือของยืม ไม่ใช่ของที่เป็นเจ้าของ

ฟังก์ชันที่คืนค่าเป็นข้อความ เช่น DLGetPageText จะส่ง PWideChar หรือ PAnsiChar กลับมา ซึ่งชี้ไปยัง buffer ที่ library instance เป็นเจ้าของและนำกลับมาใช้ซ้ำ ข้อตกลงคือ ต้องคัดลอกทันที ห้าม free เด็ดขาด

ไทม์ไลน์ PDF Library for Delphi เปรียบเทียบการคัดลอก pointer ที่ยืมมาจาก DLGetPageText ทันที กับการถือไว้จนกระทั่งไลบรารีรีไซเคิลบัฟเฟอร์พื้นฐาน
พอยน์เตอร์ char ที่คืนค่ามาเป็นการยืมพื้นที่จัดเก็บที่อินสแตนซ์จะนำกลับมาใช้ใหม่ การคัดลอกจึงต้องเกิดขึ้นก่อนการเรียกไลบรารีครั้งถัดไป
var
  P: PWideChar;
  PageText: string;
begin
  P := DLGetPageText(Inst, 7);   // pointer ที่ชี้ไปยัง buffer ที่ library เป็นเจ้าของ
  PageText := P;                 // คัดลอกทันที เพราะการเรียกครั้งถัดไปอาจนำ buffer นี้กลับมาใช้ซ้ำ
end;

ใน C# นั่นหมายถึงการ marshal IntPtr ให้กลายเป็น managed string ก่อนเรียก library ครั้งถัดไป ใน Python ctypes หมายถึงการตัดสตริง wide ออกจาก pointer ทันที หากคุณถือ raw pointer ข้ามการเรียกหลายครั้ง คุณก็เขียนบั๊กที่ผ่าน unit test ทุกตัวได้ แล้วมาล้มเหลวครั้งแรกที่มีสองคำขอทับซ้อนกันในโปรดักชัน เพราะการเรียกครั้งที่สองนำ buffer ที่การเรียกครั้งแรกกำลังอ่านอยู่กลับมาใช้ซ้ำ กฎความเป็นเจ้าของแบบเดียวกันนี้ใช้ในทิศทางตรงข้ามกันสำหรับ callback ที่ลงทะเบียนผ่าน DLSetProgressCallback ด้วย pointer ใดก็ตามที่ library ส่งเข้าไปใน callback ของคุณจะใช้ได้เฉพาะภายในตัว callback นั้นเท่านั้น และตัว callback object เองก็ต้องมีชีวิตอยู่ต่อไป (pin ไว้ ในโฮสต์ที่มี garbage collection) ตราบเท่าที่ instance อาจยังเรียกมันอยู่ delegate ที่ถูกเก็บกวาดไประหว่างงานยังไม่เสร็จ คือต้นตอสารตั้งต้นของ access violation แบบ "สุ่ม" ที่โผล่ขึ้นมาใน .NET binding ซึ่งรันได้ราบรื่นมาหลายเดือน

สร้าง smoke test ฝังไว้ใน binding เอง แล้วรันมันก่อนที่ declaration set ที่สร้างขึ้นมาจะถูกส่งออกไปทุกครั้ง ทดสอบการเรียกอย่างน้อยหนึ่งครั้งจากแต่ละหมวดที่มักเผยข้อผิดพลาดของ ABI ได้แก่ ฟังก์ชันไม่มีพารามิเตอร์อย่าง DLCreateLibrary เพื่อพิสูจน์ว่า convention ถูกต้อง ฟังก์ชันรับสตริงเข้าที่ป้อน path ซึ่งมีอักขระนอก ASCII เพื่อพิสูจน์ว่าการเข้ารหัสถูกต้อง ฟังก์ชันคืนสตริงออกเพื่อพิสูจน์ว่าการจัดการ buffer แบบยืมถูกต้อง และการทำงานหนึ่งอย่างที่ตั้งใจให้ล้มเหลว เพื่อดูว่า error มาถึงโฮสต์ของคุณอย่างไร งานทั้งหมดนี้ใช้เวลาเพียงสิบห้านาที แต่มันดักจับข้อผิดพลาดด้าน calling convention และการเข้ารหัสที่ไม่เช่นนั้นจะมาโผล่อีกหลายเดือนถัดมาในรูปของ crash dump จากลูกค้า

ตารางสองคูณสองของ PDF Library for Delphi สำหรับ smoke test การเชื่อมต่อ ครอบคลุม calling convention, การเข้ารหัสสตริง, บัฟเฟอร์ที่ยืมมา และการเปิดเผยความล้มเหลว
การตรวจสอบเบื้องต้นราคาถูกสี่รายการจับข้อผิดพลาดด้าน convention การเข้ารหัส และความเป็นเจ้าของ ก่อนที่ declaration ที่สร้างขึ้นจะไปถึงเครื่องลูกค้า

กรณีศึกษา Python ctypes แบบเจาะจง

Python ctypes คือ binding ที่ผมเห็นคนเขียนเองด้วยมือบ่อยที่สุด และมันทำให้เห็นความแตกต่างข้ามแพลตฟอร์มได้ชัดเจนง่ายที่สุดด้วย บน Windows ให้โหลดไลบรารีด้วย ctypes.WinDLL เพื่อให้ ctypes ใช้ Stdcall ให้ bind ฟังก์ชัน W ที่ไม่มีคำต่อท้าย และประกาศพารามิเตอร์สตริงทุกตัวเป็น c_wchar_p บน macOS ให้โหลดด้วย ctypes.CDLL เพื่อใช้ Cdecl คงรายการฟังก์ชันไว้เหมือนเดิมทุกประการ และ resolve ชื่อโดยไม่ต้องใส่ underscore นำหน้า FFI layer ส่วนใหญ่ รวมถึง ctypes เองด้วย จะจัดการ convention เรื่อง underscore ให้คุณโดยอัตโนมัติบน macOS แต่นั่นคือสมมติฐานหนึ่งเดียวที่ควรยืนยันด้วยการเรียกที่ resolve จริงสักครั้ง ก่อนที่คุณจะสร้าง declaration นับร้อยรายการต่อยอดจากมัน

มีคำถามด้านการ deploy สองข้อที่ตามหลังงาน binding มา และทั้งสองข้อมีคำตอบที่ชัดเจน DLL แบบธรรมดาไม่ต้องลงทะเบียนใด ๆ regsvr32 ใช้ได้เฉพาะกับ ActiveX build เท่านั้น ส่วน DLL ส่งมอบด้วยการคัดลอกไฟล์ล้วน ๆ ซึ่งเป็นเหตุผลหลักที่ควรเลือกใช้กับ Windows service และ container ที่คุณไม่อยากไปแตะ registry เลย ส่วนความปลอดภัยของ thread ก็ลดรูปเหลือแค่กฎที่กล่าวไปแล้วข้างต้น คือหนึ่ง instance ต่อหนึ่ง thread handle ของ instance เก็บสถานะที่เปลี่ยนแปลงได้ทุกชิ้นที่ engine ติดตามอยู่ ไม่ว่าจะเป็นเอกสารที่เลือกไว้ ตัวเลือกการ render หรือการตั้งค่าการ extraction ดังนั้นเมื่อสอง thread แชร์ instance เดียวกัน สถานะของทั้งสองจะสอดแทรกกันแม้ว่าการเรียกแต่ละครั้งจะคืนค่าสำเร็จก็ตาม

เมื่อ binding มั่นคงดีแล้ว การทำงานฝั่งที่อยู่ไกลออกไปจากมันก็คือสิ่งเดียวกับที่บทความสาย Delphi ครอบคลุมไว้อย่างละเอียด ไม่ว่าจะเป็นการเข้ารหัสและตรวจสอบ PDF encryption หรือการดึงข้อความและรูปภาพออกจากเอกสารที่มีอยู่แล้ว

ไฟล์ไบนารีสำหรับการผสานทั้งสามชั้นมาพร้อมกับไลบรารีอยู่แล้ว ดูรายละเอียดรุ่นและการอนุญาตใช้งานได้ที่PDF Library for Delphi product page