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

โหลดไลบรารี native ของ PDFium บนทุกเป้าหมาย

component PDFium หาไลบรารี native ของมันผ่านห่วงโซ่ค้นหาที่คงที่และเรียงลำดับไว้ แทนการปล่อยให้ loader ของระบบปฏิบัติการเป็นผู้ตัดสิน เพราะแผนผัง deployment ที่ชัดเจนคือแผนผังที่คุณดีบักได้ บน Windows ห่วงโซ่นี้มองหาไดเรกทอรีย่อย Win32 หรือ Win64 ที่ตัว installer แถมมาให้แล้ว บนเป้าหมายอื่นมันสร้างชื่อไดเรกทอรีย่อยจาก target macro ของ Free Pascal ในรูป <cpu>-<os> แผนผัง deployment จึงอ่านตรงกับแผนผังยูนิตที่คอมไพล์แล้วเป๊ะ การตัดสินใจจุดสุดท้ายนี้แนบบั๊กมาด้วยที่คุ้มค่าทั้งบทความ เพราะสาเหตุคือตัวอักษรพิมพ์ใหญ่หนึ่งตัว และอาการคือความเงียบ

ห่วงโซ่ เรียงตามลำดับ

สี่ตำแหน่ง ลองตามลำดับ แล้วจึงยกให้ platform loader เป็นทางสุดท้าย หนึ่ง layout ที่ต้องการ คือไดเรกทอรี DLLs เคียงข้าง executable ที่มีไดเรกทอรีย่อยหนึ่งอันต่อหนึ่งเป้าหมาย สอง layout สำรองที่วางไดเรกทอรีย่อยของเป้าหมายไว้เคียงข้าง executable โดยตรง สาม layout แบนรุ่นเก่า ไลบรารีนอนข้าง executable โดยไม่มีไดเรกทอรีย่อยเลย สี่ เฉพาะบน Windows คือ system directory ซึ่งต้องระวัง เพราะ process 32 บิตต้องมองใน SysWOW64 และ process 64 บิตมองใน System32 บน Windows 32 บิตตัวแรกไม่มีอยู่จริง การค้นจึงต้องถอยลง หลังผ่านครบทั้งหมดแล้วจึงจะให้ loader ค้นเอง

แผนภาพห่วงโซ่ค้นหาไลบรารี native ของ PDFium สำหรับ Delphi จากไดเรกทอรีย่อยเป้าหมายใน DLLs ผ่าน layout สำรอง แบน และ system directory ของ Windows ไปถึง platform loader
สี่ตำแหน่งที่ระบุชัดถูกลองตามลำดับก่อนจะยกให้ loader ของ OS ค้นเอง

มีการตัดสินใจไว้ชัดเจนว่าจะไม่มีขั้น system directory นอก Windows เส้นทางค้นของ platform loader เอง ซึ่งถูกขับเคลื่อนด้วย configuration ของ runtime linker และสภาพแวดล้อม library path ครอบคลุมพื้นที่นั้นอยู่แล้ว การก๊อปมาทำซ้ำในภาษา Pascal หมายถึงการ implement กฎที่ต่างกันไปตาม distribution การวินิจฉัยความล้มเหลวในห่วงโซ่ Windows อธิบายแยกไว้ในการ deploy DLL ของ PDFium และการวินิจฉัยความล้มเหลวตอนโหลด

ชื่อไดเรกทอรีย่อยมาจากไหน

บน Windows มันคือ Win32 หรือ Win64 ตัดสินจาก bitness ของ process ที่กำลังรัน ไม่ใช่ของระบบปฏิบัติการ เพราะนั่นคือสิ่งที่ตัดสินว่าไบนารีตัวใดโหลดได้ ที่อื่นทั้งหมดชื่อถูกประกอบจาก target macro ของคอมไพเลอร์ เครื่องที่ build สองสถาปัตยกรรมจึงได้สองแผนผังที่แยกกันชัดเจน และโฟลเดอร์ที่ถือไลบรารี native จะนั่งเคียงโฟลเดอร์ที่ถือยูนิตคอมไพล์แล้วด้วยชื่อเดียวกัน

function BuildDllSubDir(UseV8: Boolean): string;
begin
{$IFDEF MSWINDOWS}
  if IsWin64 then
    Result := 'Win64'
  else
    Result := 'Win32';
{$ELSE}
  // macro ของคอมไพเลอร์พิมพ์ OS ด้วยตัวแรกใหญ่ ("Linux", "Darwin")
  // ขณะที่ไดเรกทอรีผลลัพธ์ยูนิตของแพ็กเกจไม่ใช่ สองตัวจึงตรงกันได้
  // ก็ต่อเมื่อ fold แล้ว บนระบบไฟล์ที่แยกตัวพิมพ์ ความต่างนี้คือ
  // การค้นทั้งหมด
  Result := LowerCase({$I %FPCTARGETCPU%} + '-' + {$I %FPCTARGETOS%});
{$ENDIF}
end;

ทำไมตัวอักษรพิมพ์ใหญ่หนึ่งตัวจึงทำห่วงโซ่ทั้งหมดพัง

macro ของคอมไพเลอร์สะกด target operating system ด้วยตัวแรกใหญ่: Win64, Linux, Darwin แพ็กเกจ Lazarus เขียนผลลัพธ์ยูนิตลงไดเรกทอรีที่ตั้งชื่อจากตัวแปร target ของมันเอง ซึ่งเป็นตัวพิมพ์เล็ก: win64, linux, darwin สองสำนวนของสิ่งเดียวกัน และไม่มีทางสังเกตบน Windows ที่ระบบไฟล์ไม่แยกแยะพวกมัน

บน Linux พวกมันคือสองไดเรกทอรีต่างกัน deployment ที่วาง shared object ไว้ใน DLLs/x86_64-linux จะมองไม่เห็นสำหรับ loader ที่กำลังมองหา DLLs/x86_64-Linux สี่ขั้นชัดเจนของห่วงโซ่จึงพลาดทั้งหมดและโค้ดไหลตกไปให้ platform loader ค้นเอง บางครั้งมันใช้ได้ ถ้าไลบรารีบังเอิญถูกติดตั้งทั้งระบบ บางครั้งก็ไม่ได้ และไม่ว่ากรณีใดแผนผัง deployment ที่จัดไว้อย่างพิถีพิถันก็ไม่มีส่วนร่วมเลย ความล้มเหลวนี้ไม่มีข้อความ error เพราะไม่มีอะไรล้มเหลว: ทุกขั้นรายงานอย่างถูกต้องว่าไฟล์ไม่ได้อยู่ที่ที่มันมอง

ตัวอักษรพิมพ์ใหญ่หนึ่งตัวใน target macro ของ FPC ทำให้การค้น DLL ของ PDFium พังบน Linux อย่างไร: loader ค้น DLLs/x86_64-Linux ขณะที่โฟลเดอร์ที่ deploy ไว้คือ DLLs/x86_64-linux ซึ่งตรงกันเฉพาะบน Windows ที่ไม่แยกตัวพิมพ์
เป้าหมายเดียวกันสองสำนวนตรงกันบน Windows และพลาดอย่างเงียบ ๆ บนระบบไฟล์ที่แยกตัวพิมพ์

โปรแกรม probe ที่ถูกคอมไพล์และรัน

บั๊กชั้นนี้หาไม่ได้ด้วยการอ่าน และหาไม่ได้ด้วยการคอมไพล์เช่นกัน เทคนิคทั่วไปสำหรับพิสูจน์ branch ของ platform ที่ไม่เคยถูกคอมไพล์บนเครื่องพัฒนาคือก๊อปยูนิตไปไดเรกทอรีชั่วคราว เปลี่ยนชื่อ เปลี่ยนเงื่อนไข platform ให้เป็นสัญลักษณ์ที่ไม่เคยถูกนิยาม แล้วคอมไพล์สำเนานั้น ถ้าคอมไพล์ผ่าน uses clause และ signature การเรียกบนเส้นทางนั้นอย่างน้อยก็สอดคล้องกันในตัว วิธีนี้ใช้ได้ดีกับยูนิตที่สมบูรณ์ในตัว

แต่ใช้ไม่ได้ที่นี่ ยูนิตผูกพันหลักใหญ่มากและดึง LCL เข้ามาด้วย จึงก๊อปออกมาคอมไพล์โดยปิดสัญลักษณ์ Windows ไม่ได้แบบตรง ๆ ทางเลือกคือฟังก์ชันหลายตัวที่การเปลี่ยนแปลงแตะต้องถูกถ่ายทอดแบบคำต่อคำเข้าโปรแกรมเล็ก ๆ ที่สมบูรณ์ในตัว แล้วโปรแกรมนั้นถูก รัน มันพิมพ์ x86_64-Win64 ความไม่ตรงกันจึงมองเห็นในหนึ่งบรรทัดของผลลัพธ์ การคอมไพล์โปรแกรมเดียวกันไม่บอกอะไรเลย เพราะสตริงนั้นถูกต้องสมบูรณ์ สิ่งที่ผิดคือค่าของมันเท่านั้น

program ProbeSubDir;
{$MODE DELPHI}
uses
  SysUtils;
begin
  // พิมพ์ออกมา อย่า assert จุดสำคัญคือดูค่าที่ macro ขยายออกมาจริง ๆ
  // บน toolchain นี้
  Writeln('raw:    ', {$I %FPCTARGETCPU%}, '-', {$I %FPCTARGETOS%});
  Writeln('folded: ', LowerCase({$I %FPCTARGETCPU%} + '-' +
    {$I %FPCTARGETOS%}));
end.

บทเรียนทั่วไป: เมื่อการเปลี่ยนข้าม platform เกี่ยวกับ ค่า ของบางสิ่ง มากกว่าชนิดของมัน การพิสูจน์แบบคอมไพล์เฉย ๆ ไม่ใช่การพิสูจน์ ให้พิมพ์มันออกมา ความต่างข้ามคอมไพเลอร์ระหว่าง Delphi กับ Free Pascal ที่กว้างกว่ารวบรวมไว้ในบทความกับดักข้ามคอมไพเลอร์ของ Delphi และ FPC

ปล่อยให้ platform อธิบายความล้มเหลวตอนโหลดของมันเอง

branch ของ Windows ในตัว loader ไล่เหตุผลที่การโหลดล้มเหลวได้ด้วยมือเอง เพราะความต่างที่มีประโยชน์ในฝั่งนั้น ทั้งสถาปัตยกรรมไม่ตรง dependency ที่หายไปตามสาย พาธที่ resolve ไม่ได้ แมปกับรหัส error ที่คุ้มค่าการระบุเป็นรายตัว นอก Windows ยูนิต loader แบบพกพามีสตริงอธิบายที่ครอบพื้นที่เดียวกันอยู่แล้ว branch ที่ไม่ใช่ Windows จึงใช้มันตรง ๆ แทนการ derive หมวดหมู่ใหม่จากเลข error ที่หมายความต่างกันในแต่ละระบบ

การต้านแรงปรารถนาที่จะ normalize สองอย่างนี้เป็นข้อความเดียวเป็นการตัดสินใจโดยตั้งใจ ความล้มเหลวตอนโหลดเป็นปัญหาด้าน deployment และคนที่อ่านข้อความนั้นต้องการคำศัพท์ของ platform เองเพื่อนำไปค้น

การชนกันของชื่อที่เรียกตัวเองซ้ำ

มีกับดักอีกข้อ เล็กและคม ยูนิต loader แบบพกพา export โพรซีเดอร์ชื่อ UnloadLibrary และยูนิตผูกพันมีโพรซีเดอร์ชื่อเดียวกันที่ทำงานบัญชีของตัวเองก่อนปล่อย handle ข้างในโพรซีเดอร์นั้น การเรียก UnloadLibrary โดยไม่ระบุยูนิต resolve ไปที่ตัวในยูนิตปัจจุบัน ซึ่งเรียกตัวเองซ้ำ ทางแก้คือระบุยูนิตนำหน้าการเรียก

นี่คือหน้าตาเดียวกับปัญหา identifier บังตัวที่ครอบงานพอร์ต Free Pascal โดยทั่วไป ยูนิต Windows export ฟังก์ชัน minimum และ maximum แบบ integer ที่บังฟังก์ชันแบบ floating-point และชนิด synchronisation ที่บังคลาสชื่อเดียวกัน ทุกกรณีการ resolve ขึ้นกับลำดับของ uses clause การระบุยูนิตที่ call site คือทางแก้ที่ไม่ต้องพึ่งใครมารักษาลำดับนั้นไว้ในภายหลัง

การชนชื่อ UnloadLibrary ใน component PDFium: การเรียกโดยไม่ระบุยูนิตในยูนิตผูกพัน Delphi เรียกตัวเองซ้ำ ขณะที่การเรียกที่ระบุยูนิตไปถึงยูนิต loader แบบพกพาและปล่อย handle
การระบุยูนิตที่ call site ส่งการปล่อย handle ผ่านยูนิต loader แทนการวนกลับเข้ายูนิตผูกพัน

เช็กลิสต์ deployment

สามเรื่องรับผิดกับความล้มเหลวตอนโหลดส่วนใหญ่เมื่อเลขพาธถูกต้องแล้ว สถาปัตยกรรมต้องตรงกับ process ไม่ใช่เครื่อง แอปพลิเคชัน 32 บิตบน Windows 64 บิตจึงต้องใช้ไบนารี 32 บิต บิลด์ที่เปิด V8 ใช้ชื่อไฟล์ต่างออกไป deployment ที่ผสมกันจะดูถูกต้องแต่โหลดไม่ได้อะไรเลย และมีตัวแปรเดียวเท่านั้นที่อาศัยใน system directory ได้ในคราวเดียว ซึ่งเป็นเหตุผลดีที่จะเลือก layout ไดเรกทอรีย่อยแบบชัดเจน มากกว่าติดตั้งอะไรทั้งระบบ

สำหรับ Lazarus โดยเจาะจง วางไลบรารี native ไว้ใต้ DLLs/<cpu>-<os> ด้วยตัวพิมพ์เล็ก เคียงข้าง executable แล้วมันจะถูกพบที่ขั้นแรกของห่วงโซ่บนทุกเป้าหมาย ตัวอย่าง viewer ที่ใช้สิ่งนี้บน Lazarus อธิบายไว้ในบทความ viewer ของ Lazarus และ FPC และการรองรับ platform ปัจจุบันระบุไว้บนหน้าผลิตภัณฑ์PDFium Delphi component