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

Pull Row Cursor สำหรับ XLS, XLSX, ODS และ CSV ใน Delphi

HotXLS อ่านแหล่ง .xls, .xlsx, .xlsm, .ods, CSV และ TSV ผ่าน pull row cursor ตัวเดียว TXLSRowCursor ซึ่ง FindFirst กับ FindNext ก้าวไปทีละแถวตรรกะ โดยมีแค่แถวนั้นอยู่ในหน่วยความจำ เครื่องจักรสถานะหกค่าแยก before-first ออกจาก EOF, cancelled และ faulted และตัวอ่านแบบ callback รุ่นเก่าตอนนี้กลายเป็น adapter บน cursor ตัวเดียวกัน

สถานการณ์นี้คุ้นเคยกับใครก็ตามที่เคยส่งมอบฟีเจอร์นำเข้า ไฟล์ .xlsx ขนาด 200 MB มาถึง คุณต่อ handler OnCell และความต้องการแรกหลัง "อ่านมันให้ได้" คือ "หยุดหลังรายการที่กลับทางหนึ่งร้อยรายการ" ตอนนี้รูปร่างโค้ดของคุณกำลังสู้กับตัวคุณเอง: ลูปอยู่ข้างในไลบรารี handler ของคุณต้องยก flag callback ทุกตัวหลังจากนั้นยังยิงต่อจนกว่า parser จะสังเกตเห็น และสถานะที่สะสมไว้ — ตีไปกี่รายการแล้ว คอลัมน์ไหนตรง ต่อไปทำอะไร — ต้องอาศัยอยู่ในฟิลด์ของคลาสที่เกิดขึ้นมาเพื่อให้ callback มีที่นั่งเท่านั้น ไม่มีอะไรในนี้เป็นปัญหาการ parse เลย มันเป็นปัญหา control flow และเป็นปัญหาเดียวที่ pull cursor เอาออก

push callback ต้นทุนจริงเท่าไรที่ 200 MB

push กลับด้านการควบคุม และการกลับด้านคือสิ่งที่ผู้เรียกที่กรองหรือ join ไม่มีทางเลือกจ่ายได้ API แบบ callback ทำให้ไลบรารีเป็นเจ้าของลูป ผู้เรียกจึงใช้ Break ไม่ได้ สานสองแหล่งควบคู่กันไม่ได้ ส่งตัวอ่านให้รูทีนที่คาดว่าจะถูกขับเคลื่อนไม่ได้ และบอก "แอบมองแถวถัดไปก่อนตัดสินใจ" โดยไม่ต้องบัฟเฟอร์ไม่ได้ ต้นทุนไม่ใช่ throughput — เส้นทาง callback ที่เขียนดีสตรีมได้สบาย — แต่คือผู้บริโภคทุกตัวที่ไม่ธรรมดาจะงอกเครื่องจักรสถานะเล็ก ๆ ของตัวเองขึ้นมาเลียนแบบลูปที่มันไม่ได้รับอนุญาตให้เขียน คูณด้วยสี่ฟอร์แมตไฟล์ ซึ่งแต่ละฟอร์แมตมีจุดเข้าสแกนของตัวเองมาแต่เดิม ความหมายของการกรอง สูตร และ error จึงเริ่มเลื่อนแยกจากกันระหว่างกัน ซึ่งเป๊ะกับความเลื่อนที่ HotXLS ตั้งใจมาปิด

pull cursor เปลี่ยนโค้ดเรียกใช้ของคุณอย่างไร?

มันคืนลูปให้คุณ พร้อม control flow แบบ Pascal ธรรมดา TXLSRowCursor.Open รับชื่อไฟล์หรือ TStream ตรวจจับฟอร์แมต โหลด shared string กับ metadata รูปแบบวันที่หนึ่งครั้ง และเลือกชีต 1 SelectSheet (แบบ one-based) หรือ SelectSheetByName เปลี่ยนเป้าไปเวิร์กชีตอื่นและรีเซ็ต cursor ไปก่อนแถวแรก FindFirst กับ FindNext จากนั้นวางตำแหน่งบนแถวที่มีเซลล์ถอดได้ถัดไป — แถวที่ไม่มีเซลล์ถอดได้ถูกข้าม ดังนั้น RowIndex เลขได้ — และแถวปัจจุบันถูกเปิดให้เห็นเป็น CellCount, Cells[] กับ ValueByCol[] ล้วน one-based บนแกนคอลัมน์ การออกจากลูปคือ Break หนึ่งคำ

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // ข้ามแถบหัวตาราง
    Cursor.IncludeColumn(1);     // ถอดเฉพาะสองคอลัมน์นี้
    Cursor.IncludeColumn(7);
    if not Cursor.Open('postings-200mb.xlsx') then
      Exit;
    if not Cursor.SelectSheetByName('Ledger') then
      Exit;

    Hits := 0;
    if Cursor.FindFirst then
      repeat
        if VarToStr(Cursor.ValueByCol[7]) = 'REVERSED' then
        begin
          Inc(Hits);
          if Hits = 100 then
            Break;               // Break ธรรมดา ไม่มี flag abort ไม่มีเซนติเนล
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // destructor จบ pass
  end;
end;

projection กับช่วงถูกตั้งก่อนเริ่ม pass ไม่ใช่กรองเอาหลัง FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDates กับ DetectTextTypes ล้วนถูกเคารพข้างใน backend คอลัมน์ที่ไม่ถูกเลือกจึงไม่เคยจองค่า สตริงสูตร หรือ payload rich-text ตั้งแต่แรก — ชุดทดสอบถดถอยพิสูจน์เรื่องนี้ด้วยสูตร 16 KiB กับสตริงแคชที่ไม่เคยถูกจับมาเป็นรูปธรรมเมื่อคอลัมน์ไม่ถูก project ออปชันพวกนี้ถูกตรึงไว้ตั้งใจขณะ pass ทำงานและกลับมาเขียนได้ที่ EOF, หลัง SelectSheet หรือหลัง Close การสแกนหนึ่งครั้งจึงไม่มีทางผสมสัญญาการถอดรหัสสองชุด ถ้าคุณแค่ต้องการรายชื่อชีตไม่ใช่แถวข้อมูล การโหลดเฉพาะ metadata กับการเลือกชีตเป็นจุดเข้าที่ประหยัดกว่า

หนึ่ง backend ต่อหนึ่งฟอร์แมต หนึ่งลูปสแกนต่อหนึ่ง backend

ทุกฟอร์แมตมี forward scanner เป๊ะหนึ่งตัวข้างใน HotXLS และทั้ง pull cursor กับตัวอ่านแบบ callback ขับเคลื่อน scanner ตัวเดียวกัน TXLSXForwardRowBackend เป็นเครื่องจักรสถานะ SAX ของเวิร์กชีตตัวเดียวสำหรับ sheet part ตาม ECMA-376 Part 1 §18.3 ถือ XML reader, ตารางสูตรแบบ shared และ parser rich-text ไว้ และก้าวผ่านขอบเขต <row> กายภาพเป๊ะหนึ่งบรรทัดต่อการเรียก TXLSBiffForwardParser เป็นเจ้าของ global, การเลือกชีต และการก้าวแถวของสตรีมเรกคอร์ด [MS-XLS] การทำให้มันหยุดชั่วคราวได้ผลิตเงื่อนไขคมที่สุดในดีไซน์ทั้งชุด เพราะสูตรแบบสตริงแคชคือเรกคอร์ด Formula ที่มีเรกคอร์ด String ตามมาทันที จุดระงับต่อแถวจึงต้องไม่มีวันตกลงกลางสองเรกคอร์ดนั้น TXLSForwardTextBackend เก็บตัวอ่านที่รู้เรื่อง BOM, delimiter ที่ใช้อยู่ และเรกคอร์ดตรรกะหนึ่งอัน — CSV ดมหา comma, semicolon, tab หรือ pipe จากเรกคอร์ดแรกโดยเมินอักขระในเครื่องหมายคำพูด และฟิลด์ที่ขึ้นหลายบรรทัดในเครื่องหมายคำพูดถูกประกบด้วย #10 เลขแถวจึงตามเรกคอร์ดตรรกะ ไม่ใช่ขึ้นบรรทัดใหม่กายภาพ TXLSForwardOdsBackend เก็บเทมเพลตแถวกายภาพเดียวสำหรับตาราง OpenDocument §9 มอง table:number-rows-repeated เป็นจำนวนที่เหลือ ไม่ใช่การขยาย และก้าวข้ามเซลล์ที่ถูกครอบโดยไม่ปล่อยค่าออกมา streaming direct reader แชร์ตัวโหลด shared string กับรูปแบบวันที่ชุดเดียวกัน

pull row cursor ของ HotXLS dispatch ไปยัง forward scanner หนึ่งตัวต่อฟอร์แมต: backend SAX สำหรับ XLSX, parser เรกคอร์ดสำหรับ BIFF, backend ข้อความที่ดม delimiter และเทมเพลตแถว ODS โดยตัวอ่านแบบ callback ถูกตั้งค่าทับบนสุดเป็น adapter
ทุกฟอร์แมตมี forward scanner เป๊ะหนึ่งตัว และทั้ง pull cursor กับตัวอ่านแบบ callback ขับเคลื่อน scanner ตัวเดียวกัน ความหมายของการกรองกับ error จึงไม่มีทางเลื่อนแยกจากกัน

ทำไมต้องหกสถานะ แทนที่จะใช้ flag Eof เดียว?

เพราะ boolean เดียวทำให้สี่สถานการณ์ต่างกันแยกไม่ออก และผู้เรียกเดาผิดกับทุกสถานการณ์ TXLSRowCursorState ตั้งชื่อมันอย่างชัดเจน

  • xrcsClosed — ไม่มีแหล่งใดถูกเปิด
  • xrcsBeforeFirst — เปิดแล้วหรือเปลี่ยนเป้าใหม่ ยังไม่ได้อ่านแถวใด
  • xrcsActive — ยืนอยู่บนแถวที่ใช้ได้
  • xrcsEof — ชีตถูกกินจนหมดถึงท้าย
  • xrcsCancelled — ผู้เรียกหยุด pass ตามเจตนา
  • xrcsFaulted — pass ล้มเหลว และ exception เดิมถูกปล่อยขึ้นมาแล้ว

ความแตกต่างข้อสุดท้ายนั่นแหละคือข้อที่สำคัญใน production ชีต part ที่หายไปหรือการเริ่ม pass ที่ล้มเหลวจะคง EReadError เดิมและย้าย cursor ไป xrcsFaulted มันไม่มีวันถูกลดทอนเป็น False เปล่า ๆ ที่ผู้เรียกจะอ่านว่า "ชีตนี้ว่าง" Cancel ถูกออกแบบให้แคบกว่า Close ตั้งใจ: มันปิด backend เวิร์กชีตปัจจุบันกับ inflate substream ของมันและทำให้แถวปัจจุบันใช้ไม่ได้ แต่ไม่ปล่อย ZIP archive หรือสตรีมต้นทาง และการเรียกซ้ำเป็น no-op หลัง cancel คุณกลับมาต่อด้วยการเรียก SelectSheet อย่างชัดเจน — cursor จะไม่รีสตาร์ต pass เงียบ ๆ แทนคุณ การเป็นเจ้าของสตรีมวิ่งตามกฎป้องกันตัวแบบเดียวกัน: xsoBorrowed เป็นค่าเริ่มต้นและคืนตำแหน่งสตรีมตอนปิด xsoOwned โอนความเป็นเจ้าของเฉพาะหลัง Open สำเร็จแล้วเท่านั้น การเปิดที่ล้มเหลวจึงไม่มีวันปล่อยสตรีมที่ผู้เรียกยังถืออยู่

หกสถานะของ row cursor ของ HotXLS พร้อมการเปลี่ยนผ่านระหว่างกัน แสดงว่า Cancel ย้าย pass ที่ทำงานอยู่ไปสู่ cancelled, การเริ่ม pass ที่ล้มเหลวย้ายไปสู่ faulted และทั้งสองคงแยกจากจุดจบของชีต
สถานะที่ตั้งชื่อหกสถานะทำให้ชีตว่าง การหยุดตามเจตนา และ pass ที่ล้มเหลวแยกกันได้ สิ่งที่ flag Eof เดียวทำไม่ได้
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: cursor ไม่มีวันปล่อย Src และ Close คืน
      // ตำแหน่งที่สตรีมมีตอนเรียก Open
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // ปิด backend เวิร์กชีตกับ
            Break;           // inflate substream เท่านั้น idempotent
          end;
        until not Cursor.FindNext;

      case Cursor.State of
        xrcsEof:       Log('sheet consumed to the end');
        xrcsCancelled: Log('stopped by the operator');
        xrcsFaulted:   Log('pass failed; the EReadError was already raised');
      end;
    finally
      Cursor.Free;
    end;
  finally
    Src.Free;                // ยังเป็นของเรา ยังใช้ได้ ตำแหน่งถูกคืนแล้ว
  end;
end;

ยืมแถวปัจจุบันโดยไม่คัดลอกมัน

IXLSRowCursorView ส่งแถวให้รูทีนอื่นโดยไม่ทำสำเนาอาร์เรย์เซลล์ มุมมองเก็บ guard ที่แชร์กันซึ่งถือ pointer ของ cursor บวกเคาน์เตอร์ generation แบบ UInt64 การก้าว การเลือกชีต การ cancel การปิด และการทำลาย cursor ล้วนเพิ่ม generation นั้น และการทำลายยังล้างเจ้าของ guard ต่อ มุมมองที่ค้างเก่าจึงอ่านหน่วยความจำที่ถูกปล่อยไม่ได้: Valid เป็นโพรบที่ไม่ปล่อย exception เรียกได้ตลอดเวลา ขณะที่สมาชิกอื่นทุกตัวตรวจก่อนแล้วปล่อย EXLSRowCursorViewInvalidated พูดความจริงเรื่องสัญญานี้ให้ชัด — มันคือ fail-fast เรื่องอายุการใช้งาน ไม่ใช่การรับประกัน thread safety และมันไม่ให้สิทธิ์อ่านแถวจากเธรดที่สองขณะเธรดแรกกำลังก้าว cursor

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // ยืม ไม่มีอาร์เรย์เซลล์ถูกคัดลอก
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // การอ่าน BIFF forward คงค่าแคชไว้
        else if Cell.Kind = xdkEmpty then //   ไม่ใช่ token ของสูตร
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank เป็นเซลล์จริง
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // Interface อยู่ท้นลูป แต่แถวข้างหลังมันไม่ทน
  if not View.Valid then    // Valid ไม่มีวัน raise แต่ Cells[] ตอนนี้จะ raise
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes กับสิ่งที่มันมีสิทธิ์พิสูจน์

PeakRowBufferedBytes มีไว้เพื่อแสดงว่าหน่วยความจำตามความกว้างแถว ไม่ใช่ตามจำนวนแถว มันสะสมเรกคอร์ดเซลล์ Variant สตริงสูตร และ payload rich-text ของแถวผลลัพธ์ปัจจุบัน บวกกับชุดทำงานเฉพาะฟอร์แมต — เรกคอร์ดตรรกะของ CSV, เทมเพลตแถวกายภาพของ ODS, ยอดพีคเรกคอร์ดของ BIFF หรือเซลล์ดิบของ XLSX ที่กำลังถูกถอด อ่านมันคู่กับ SheetPassesStarted ซึ่งนับว่ามี pass เวิร์กชีตเริ่มขึ้นจริงกี่ครั้ง ข้อสงวนสองข้อทำให้ตัวเลขนี้ซื่อสัตย์: ค่านี้เป็นการประมาณ ไม่ใช่บัญชี heap เป๊ะ ๆ และมัน monotonic นับจาก Open ล่าสุด จึงเป็นเครื่องมือดีบักกับทดสอบถดถอย ไม่ใช่เกจวัดสด สำหรับภาพกว้างว่าเวลากับไบต์ไปไหนบนเวิร์กบุ๊กใหญ่มาก ดู ประสิทธิภาพเวิร์กบุ๊กใหญ่ใน Delphi

การเทียบของ HotXLS ระหว่างการโหลดทั้งชีตที่ให้ทุกแถวอาศัยอยู่ในหน่วยความจำ กับ pull cursor ที่ถือแค่แถวปัจจุบันบวกชุดทำงานเฉพาะฟอร์แมตหนึ่งชุด ซึ่งคือสิ่งที่ PeakRowBufferedBytes สะสมและรายงาน
PeakRowBufferedBytes สะสมแถวผลลัพธ์ปัจจุบันบวกชุดทำงานเฉพาะฟอร์แมต หน่วยความจำจึงตามความกว้างของแถว ไม่ใช่จำนวนแถวของชีต

ตัวอ่านแบบ push กลายเป็น adapter แล้ว และสิ่งที่ cursor จะไม่ทำ

TXLSForwardReader ไม่แบกจุดเข้าสแกนแยกสำหรับ XLSX, BIFF และข้อความอีกต่อไป มันตั้งค่า cursor เดินมัน แล้วแปลงแถวปัจจุบันเป็นเหตุการณ์ OnSheet กับ OnCell สอง façade จึงไม่มีทางเลื่อนแยกจากกันเรื่องการกรอง สถานะสูตร หรือการจัดการ error อีก มีผลลัพธ์สองข้อที่ควรรู้ก่อนอัปเกรด: SheetIndex ของ callback ตอนนี้เป็น one-based ทั่วถึงบน TXLSForwardReader (TXLSDirectReader คงสัญญาเหตุการณ์ 0-based เดิม) และ OnSheet ยิงก่อน SelectSheet การตั้ง SkipSheet จึงหมายความว่า part เวิร์กชีตไม่เคยถูกเปิดหรือคลายบีบอัดเลย ขอบเขตก็ชัดเจนเท่ากัน: ห้ามแก้เวิร์กบุ๊กขณะ pass ทำงาน การ cancel ต้องรีสตาร์ตอย่างชัดเจน และเส้นทาง BIFF forward ไม่มีวันถอด token ของสูตร เซลล์สูตรคลาสสิกจึงรายงาน HasFormula เป็นจริงกับ FormulaTextAvailable เป็นเท็จ แล้วส่งค่าแคชให้คุณ แทนที่จะแต่งสตริงสูตรว่างขึ้นมา row cursor กับ adapter ของมันผ่านการตรวจ 1,298 จุดบน Delphi Win32 กับ Win64 บวกแพ็กเกจสถิตของ C++Builder 37.0 Win64

ถ้าคุณกำลังถ่วงน้ำหนักระหว่าง pull cursor กับตัวโหลดที่คุณใช้อยู่ คำถามที่ควรถามไม่ใช่ตัวไหน parse เร็วกว่า แต่คือตัวไหนให้คุณเขียนเงื่อนไขการออกที่คุณต้องการจริง ๆ ได้ รายละเอียดคอมโพเนนต์ครบ เวอร์ชัน IDE ที่รองรับ และการอนุญาตใช้งาน อยู่ที่ หน้าคอมโพเนนต์สเปรดชีต HotXLS สำหรับ Delphi