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

การดึงรายชื่อแผ่นงานอย่างรวดเร็วใน Delphi ด้วย HotXLS GetSheetNames

บางครั้งคำถามเดียวที่รูทีนรับข้อมูลเข้าต้องการคำตอบก็เป็นแค่เรื่องโครงสร้างล้วน ๆ: workbook นี้มี sheet ชื่อ "Mapping" หรือเปล่า หรือมันมีกี่แท็บ การตอบคำถามนี้ด้วยการเรียก Open คือวิธีที่แพงเกินจำเป็น การเปิดแบบเต็มจะทำให้ shared string table พองตัว ถอดรหัส style record ทุกตัว และไล่ผ่านเซลล์ของทุก worksheet เพราะมันไม่มีทางรู้เลยว่าคุณต้องการแค่สารบัญ ในไฟล์ขนาดใหญ่นั่นคือการจัดสรรหน่วยความจำหลายร้อยเมกะไบต์และ CPU อีกหลายวินาที เพื่ออ่านรายการที่กินพื้นที่แค่ไม่กี่กิโลไบต์ HotXLS ไลบรารีสเปรดชีตแบบ native ของ Delphi จาก losLab ให้รายการนั้นแก่คุณได้เลยโดยตรง: GetSheetNames คืนชื่อ worksheet กลับมาตามลำดับใน workbook โดยไม่ต้อง materialize เซลล์แม้แต่เซลล์เดียว

เหตุใดแคตตาล็อกจึงอ่านได้ถูกจนแทบไม่มีต้นทุน

ฟอร์แมตสเปรดชีตทั้งสองแบบวางสารบัญไว้ใกล้จุดเริ่มต้นของไฟล์ นี่คือสิ่งที่ทำให้การเรียกดูรายการเร็ว ไม่ใช่เพราะฉลาดเป็นพิเศษ แพ็กเกจ OOXML เก็บแคตตาล็อกของ sheet ไว้ใน xl/workbook.xml ซึ่งเป็นส่วนที่มีขนาดเล็กเสมอไม่ว่า workbook จะมีสิบแถวหรือสิบล้านแถว ไฟล์ BIFF8 .xls เก็บ BoundSheet record ไว้ที่จุดเริ่มต้นของ workbook globals stream ก่อนข้อมูลเซลล์ใด ๆ ทั้งสิ้น ดังนั้นงานที่การเรียกดูรายการช่วยหลีกเลี่ยงไม่ใช่แค่ตัวเลขปัดเศษเมื่อเทียบกับการเปิดแบบเต็ม แต่มันคืองานส่วนใหญ่ของไฟล์เลยทีเดียว การอ่านแคตตาล็อกมีต้นทุนแค่ไม่กี่กิโลไบต์เท่ากันเสมอไม่ว่าจำนวนแถวจะเป็นเท่าไร ในขณะที่การเปิดแบบเต็มจะขยายตามข้อมูล และในไฟล์ workbook ขนาดหลายเมกะไบต์ ช่องว่างนี้กว้างถึงหลายเท่าตัวทั้งในแง่ไบต์ที่ถูกแตะและหน่วยความจำที่ถูกจัดสรร

GetSheetNames ของ HotXLS ใน Delphi อ่านเพียงแคตตาล็อกชีตของไฟล์ XLSX หรือ XLS ขณะที่การเปิดแบบเต็มเดินทุกเซลล์
แคตตาล็อกอยู่ใน workbook.xml หรือระเบียน BoundSheet การลิสต์รายชื่อจึงใช้แค่ไม่กี่กิโลไบต์ ขณะที่การเปิดเต็มรูปแบบขยายตามข้อมูล

ต้นทุนคงที่แบบนี้แหละคือคุณสมบัติที่คุ้มค่าจะออกแบบให้เป็นแกนกลาง gate รับข้อมูลเข้าที่สร้างจาก GetSheetNames จะทำงานเหมือนกันทั้งกับไฟล์ 200 แถวและไฟล์ 200 MB ดังนั้นไฟล์ที่ช้าที่สุดในชุดงานหนึ่ง ๆ จะไม่เป็นตัวกำหนดจังหวะของการตัดสินใจอีกต่อไปว่าไฟล์นั้นคุ้มค่าที่จะประมวลผลหรือไม่

เรียกครั้งเดียวครอบคลุมทั้ง .xls, .xlsx และฟอร์แมตเทมเพลต

บน facade ของ XLS นั้น TXLSWorkbook.GetSheetNames อ่านได้มากกว่าแค่ .xls มันรับไฟล์แบบ zip อย่าง .xlsx, .xlsm, .xltx และ .xltm ด้วย โดยดึงแค่ workbook.xml ออกมาจาก archive สำหรับ input .xls แท้ ๆ มันจะสแกน BoundSheet record แล้วหยุดที่ EOF record ตัวแรกของ globals substream ดังนั้นไฟล์ไบนารีขนาดใหญ่ก็ยังมีต้นทุนแค่ไม่กี่กิโลไบต์แรกของไฟล์เท่านั้น facade ของ XLSX มีการรับประกันอย่างหนึ่งที่สำคัญกว่าที่มองแวบแรกสำหรับโค้ดบริการที่รันต่อเนื่องเป็นเวลานาน: TXLSXWorkbook.GetSheetNames จะไม่ reset และไม่เติมข้อมูลลงในอินสแตนซ์ของ workbook เลย ดังนั้นอินสแตนซ์ที่กำลังถือเอกสารที่เปิดอยู่แล้วก็ยังสำรวจไฟล์อื่นได้โดยไม่รบกวนไฟล์ที่ถืออยู่ในมือ GetODSSheetNames ใช้วิธีเดียวกันนี้กับแพ็กเกจ OpenDocument และทุกเมธอดในกลุ่มนี้มี overload แบบ stream ให้ด้วย ซึ่งทำให้คุณตรวจสอบไฟล์อัปโหลดที่ไม่เคยแตะดิสก์เลยได้

var
  Book: TXLSXWorkbook;
  Names: TStringList;
  I: Integer;
begin
  Names := TStringList.Create;
  Book := TXLSXWorkbook.Create;
  try
    if Book.GetSheetNames('upload-7f3a.xlsx', Names) <= 0 then
      raise Exception.Create('unreadable workbook package');
    if Names.IndexOf('Mapping') < 0 then
      raise Exception.Create('required Mapping sheet is missing');
    for I := 0 to Names.Count - 1 do
      Writeln(Format('sheet %d: %s', [I, Names[I]]));
  finally
    Book.Free;
    Names.Free;
  end;
end;

เรียกแบบเดียวกันนี้ยังใช้ทำ import dialog บนเดสก์ท็อปได้ดีด้วย แสดงรายชื่อ sheet ให้ผู้ใช้เลือกก่อน แล้วค่อยจ่ายต้นทุนของการเปิดแบบเต็มก็ต่อเมื่อเลือกแล้วเท่านั้น กับ workbook ที่มีห้าสิบ sheet ความต่างจะเห็นชัดเจน: หน้าต่างเลือกที่ปรากฏขึ้นทันที เทียบกับหน้าต่างที่ค้างอยู่ขณะที่ไฟล์ทั้งหมดกำลังโหลดอยู่ข้างหลัง

ไฟล์ .xlsm ที่เปิดใช้ macro และฟอร์แมตเทมเพลตต่าง ๆ ก็แสดงรายชื่อ sheet ได้เหมือนกับ .xlsx ธรรมดาเป๊ะ เพราะแคตตาล็อกอยู่ใน workbook.xml เดียวกันไม่ว่าจะมี vbaProject.bin ติดมาในแพ็กเกจด้วยหรือไม่ pipeline รับข้อมูลเข้าจึงสามารถแจกแจง sheet ของ macro workbook เพื่อการ routing ได้เลย โดยไม่แตะ payload ของ macro และไม่ทำอะไรที่จะทำให้มันรันขึ้นมาเลย แล้วปล่อยการตัดสินใจเรื่องนโยบาย macro ไว้ให้กับขั้นตอนที่เปิดไฟล์จริง ๆ

อ่านค่าที่คืนมาโดยไม่หลอกตัวเอง

ธรรมเนียมค่าที่คืนกลับมาไม่ได้เหมือนกันทั้งหมดใน HotXLS บางเมธอดคืน 1 เมื่อสำเร็จ บางตัวคืนจำนวนนับ ดังนั้นสำหรับฟังก์ชันกลุ่มแสดงรายการ วิธีตรวจที่ใช้ได้เสมอมีแค่แบบเดียวคือถือว่าค่าใด ๆ ที่เป็นศูนย์หรือต่ำกว่าคือความล้มเหลว พร้อมกับที่ string list ถูกล้างไปด้วย อย่าหลงไปตีความรายการว่างว่าหมายถึง "workbook ที่ไม่มี sheet เลย" ทั้ง ECMA-376 และข้อกำหนด BIFF8 บังคับให้ workbook ที่ถูกต้องต้องมีอย่างน้อยหนึ่ง sheet เสมอ ดังนั้นจำนวนชื่อเป็นศูนย์จึงหมายถึงการอ่านล้มเหลวเท่านั้น ไม่เคยหมายถึงไฟล์ว่างเปล่าโดยถูกต้องตามกฎ

การแสดงรายการที่ล้มเหลวเองก็เป็นสัญญาณที่ควรเก็บไว้ ไฟล์ .xlsx ที่เรียกแล้วล้มเหลวมักเป็นหนึ่งในไม่กี่กรณีเฉพาะ: ไฟล์ถูกตัดทอน ไม่ใช่แพ็กเกจ OOXML จริง ๆ เลย (ไฟล์ CSV ที่ถูกติดป้ายผิดจากระบบอื่นโผล่มาแบบนี้บ่อยมาก) หรือเป็น container ที่ถูกเข้ารหัส การแยกแยะว่าเป็นกรณีไหนคือหน้าที่ของการตรวจสอบขั้นถัดไป การบันทึกไบต์แรก ๆ ของไฟล์ที่ถูกปฏิเสธไว้คู่กับความล้มเหลวมักทำให้เธรดของ support จบลงด้วยข้อความเดียว

ตรวจจับ container ที่เข้ารหัสก่อนจะ route

.xlsx ที่ถูกเข้ารหัสไม่ใช่ zip มันคือ OLE compound file ที่ห่อ stream ชื่อ EncryptionInfo และ EncryptedPackage ไว้ ดังนั้น GetSheetNames จึงมองไม่เห็นข้างในและคืนค่าความล้มเหลวเหมือนไฟล์ที่อ่านไม่ได้ทั่วไป CanReadEncrypted ตรวจสอบรูปทรงของ container แบบนั้นโดยเฉพาะ ซึ่งทำให้การรับข้อมูลเข้าสามารถ route ไฟล์ที่เข้ารหัสได้อย่างตั้งใจ แทนที่จะกลืน error การอ่านทั่ว ๆ ไปที่มาจากที่ไหนสักแห่งลึก ๆ ใน worker:

ขั้นตอนคัดแยกไฟล์รับเข้าของ Delphi ด้วย CanReadEncrypted และ GetSheetNames ของ HotXLS ที่นำไฟล์อัปโหลดไปแยกเป็นต้องใช้รหัสผ่าน อ่านไม่ได้ หรือปกติ
CanReadEncrypted ทำงานก่อน เพราะไฟล์ OOXML ที่เข้ารหัสคือคอนเทนเนอร์ OLE ที่การเรียกลิสต์มองเข้าไปข้างในไม่เห็น
type
  TIntakeRoute = (irNormal, irNeedsPassword, irUnreadable);

function ClassifyUpload(const FileName: string; Names: TStrings): TIntakeRoute;
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    // OOXML ที่เข้ารหัสคือ OLE container ไม่ใช่ zip ต้องตรวจสอบก่อน
    // เพราะเมธอดแสดงรายการมองเข้าไปข้างในไม่ได้
    if Book.CanReadEncrypted(FileName) then
      Exit(irNeedsPassword);
    if SameText(ExtractFileExt(FileName), '.ods') then
    begin
      if Book.GetODSSheetNames(FileName, Names) <= 0 then
        Exit(irUnreadable);
    end
    else if Book.GetSheetNames(FileName, Names) <= 0 then
      Exit(irUnreadable);
    Result := irNormal;
  finally
    Book.Free;
  end;
end;

เรื่องการเข้ารหัสคือจุดที่ HotXLS จงใจทำให้ไม่สมมาตร ดังนั้นการ routing ก็ต้องเคารพความจริงข้อนี้ด้วย การเข้ารหัส .xls แบบดั้งเดิม (RC4, RC4 CryptoAPI, XOR) อ่านได้: TXLSWorkbook.Open(FileName, Password) ถอดรหัสด้วยรหัสผ่านที่เก็บไว้ และไฟล์เหล่านั้นก็อยู่บนเส้นทางอัตโนมัติต่อไปได้ แพ็กเกจ OOXML ที่เข้ารหัสไปในทางตรงข้าม HotXLS เขียนมันได้ด้วย SaveAsEncrypted แต่อ่านกลับไม่ได้ OpenEncrypted จะโยน EXlsxEncryptionNotImplemented เมื่อเจอแพ็กเกจที่เข้ารหัส นี่คือเหตุผลที่การออกแบบระบบรับข้อมูลเข้าอย่างซื่อตรงจะส่ง .xlsx ที่เข้ารหัสไปให้คนที่มี Excel และเก็บ .xls ที่มีรหัสผ่านไว้ในโค้ดต่อไป

สำหรับงานแบบ batch ตัวจำแนกประเภทนี้คุ้มค่าที่จะรันผ่านทั้งไดเรกทอรีที่รับเข้ามาก่อนที่ worker ตัวไหนจะเริ่มประมวลผลจริง เพราะแต่ละการตรวจมีต้นทุนแค่การเปิดไฟล์หนึ่งครั้งกับการอ่านไม่กี่กิโลไบต์ การทำสิ่งนี้ไว้ล่วงหน้าเปลี่ยนรูปแบบความล้มเหลวที่ทีมปฏิบัติการใส่ใจจริง ๆ แทนที่จะเป็นงานตอนตีสามที่ตายตรงไฟล์ที่ 412 จาก 600 คุณจะได้ไฟล์ 412 ไฟล์เข้าคิวและ 5 ไฟล์ถูกปฏิเสธตั้งแต่ขั้นรับเข้า พร้อมเหตุผลติดอยู่กับแต่ละไฟล์ เรียกไลบรารีแบบเดียวกัน แต่เรื่องราวด้านปฏิบัติการดีกว่ากันมาก

คำถามที่การเรียกดูรายการตอบให้ไม่ได้

ชื่อกับลำดับคือทั้งหมดที่คุณจะได้รับ การเรียกดูรายการไม่บอกอะไรเลยเรื่องการมองเห็น ดังนั้น sheet ที่ถูกซ่อนและซ่อนลึก (very-hidden) ก็จะโผล่มาในรายการเหมือน sheet ทั่วไปทุกประการ มันไม่รายงานขนาดของ used-range ไม่รายงานจำนวนเซลล์ และไม่รายงาน document properties ส่วน docProps/core.xml เองก็มีขนาดเล็กเช่นกัน แต่ทุกวันนี้ยังไม่มีการตรวจแบบ properties-only ดังนั้น metadata อย่างผู้เขียนและชื่อเรื่องยังคงมีต้นทุนเท่ากับ Open แบบเต็มอยู่ดี วิธีที่สะอาดในการอยู่กับเรื่องนี้คือปล่อยให้ข้อเท็จจริงราคาถูก route ทุกไฟล์ก่อน แล้วเก็บงานที่แพงไว้สำหรับไฟล์ที่รอดจากการ route เท่านั้น สำหรับไฟล์ที่ต้องเข้าสู่การอ่านแบบลึก การสแกนไฟล์ .xls ขนาดใหญ่แบบ read-only จะเร็วขึ้นอย่างเห็นได้ชัดด้วย _DisableGraphics := True ซึ่งข้ามการแปลง OfficeArt ไป แค่อย่า save จากอินสแตนซ์นั้นเด็ดขาด: เลเยอร์รูปวาดที่ถูกข้ามไปจะหายไปจากโมเดล และการ save ก็จะทำให้มันหายไปจากไฟล์ด้วย

ไฟล์ที่ผ่านการคัดกรองมักไปต่อสู่การวิเคราะห์เชิงลึก workbench สำหรับการตรวจสอบและแปลง workbook ครอบคลุมตัวนับรายชีทที่ควรเก็บเมื่อการเปิดแบบเต็มคุ้มค่าแล้ว และ คู่มือประสิทธิภาพของ workbook ขนาดใหญ่ ครอบคลุมวิธีทำให้การเปิดแบบเต็มนั้นยังคงเร็วอยู่

HotXLS คือไลบรารีสเปรดชีตแบบ native ที่เขียนด้วย Object Pascal สำหรับ Delphi และ C++Builder ส่วน API ทั้งหมด รวมถึงเมธอดตรวจสอบที่แสดงไว้ในบทความนี้ มีเอกสารอยู่ที่ หน้าผลิตภัณฑ์ HotXLS Delphi Component