Bài viết kỹ thuật

Nạp PDF theo range lũy tiến trong Delphi với PDFlibPas

Một kho ảnh quét 2 GB nằm trong bucket S3 và người dùng muốn trang 900. PDFlibPas có thể phục vụ trang đó mà không cần tải về tệp: LoadFromRangeSource dựng một stream chỉ đọc có thể seek trên callback byte-range của chính bạn rồi giao cho TPDFDocument, để parser kéo về các bảng cross-reference, một nhánh cây trang, và một content stream

Phía vận chuyển của chuyện này cũ kỹ và nhàm chán. Các máy chủ HTTP đã quảng cáo byte range hàng chục năm, ngày nay được đặc tả trong RFC 9110 §14, và mọi object store đều nói cùng một tiếng. Phía PDF cũng đã ngã ngũ rõ: ISO 32000-1 §7.5.8 định nghĩa linearization một cách chính xác để reader có thể render trang đầu từ phần đầu của tệp. Cái còn thiếu trong Delphi là mảnh ghép nằm ở giữa, phần quyết định hỏi những range nào, giữ bao nhiêu, và làm sao tránh hỏi hai lần

LoadFromRangeSource cần gì từ phần vận chuyển của bạn?

Hai thứ, và không thứ nào là một stream. PDFlibPas xin một SourceSize đáng tin và một read callback đồng bộ kiểu TPDFlibRangeReadEvent, khai báo là function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Bên trong, cặp này trở thành một TCallbackByteRangeSource lộ ra SourceSizeReadRange, được bọc trong một stream mà quyền sở hữu chuyển sang tài liệu. Đích callback và backend của nó vẫn là của bạn: tài liệu giải phóng wrapper khi close, clear, hay reload, nhưng không bao giờ đụng vào đối tượng vận chuyển đằng sau method pointer

Hợp đồng này cố ý dễ dãi về một hướng và nghiêm khắc về hướng kia. Một short read là hợp pháp và đơn giản nghĩa là parser hỏi lại. Một callback raise sẽ được đổi thành short read và hội tụ về đường thất bại nạp thông thường. Một callback khai báo đã ghi nhiều hơn Count byte sẽ bị kẹp lại, vì một provider lỗi không được phép tràn qua bộ đệm cache. Thử lại mật khẩu dựng một range stream mới và trạng thái parse mới trên cùng nguồn callback, nên một lần thất bại không thể bỏ lại vị trí, cửa sổ, hay trạng thái giải mã cũ

type
  TObjectStoreSource = class
  private
    FClient: TRangeHttpClient;
    FSize: Int64;
  public
    function ReadRange(Sender: TObject; Offset: Int64;
      Buffer: Pointer; Count: LongInt): LongInt;
    function IsResident(Sender: TObject; Offset: Int64;
      Count: LongInt): Integer;
    property Size: Int64 read FSize;
  end;

function TObjectStoreSource.ReadRange(Sender: TObject; Offset: Int64;
  Buffer: Pointer; Count: LongInt): LongInt;
begin
  { một GET chặn duy nhất với Range: bytes=Offset-(Offset+Count-1) }
  Result := FClient.FetchInto(Offset, Count, Buffer);
end;

{ ... }
Lib := TPDFlib.Create;
Src := TObjectStoreSource.Create(BucketUrl);
try
  if Lib.LoadFromRangeSource(Src.Size, Src.ReadRange, '',
       65536, 8 * 1024 * 1024, 2, Src.IsResident) = 1 then
    Lib.SelectPage(900);
finally
  Lib.Free;  { giải phóng stream wrapper }
  Src.Free;  { vận chuyển của bạn, vòng đời của bạn }
end;

Range cache thực tế giữ được bao nhiêu?

Mặc định 4 MiB, rải trên các cửa sổ canh theo chunk và đuổi theo LRU. Thiết kế một cửa sổ trước đây phình tới độ dài caller đòi bao nhiêu, nên một lần đọc tuần tự lớn có thể vượt qua cỡ chunk danh nghĩa trong khi một bước nhảy ngẫu nhiên vứt bỏ cửa sổ trước đó ngay lập tức. Cache hiện tại canh mọi offset nguồn theo ChunkSize, lấy đúng một chunk cho mỗi lần miss, và áp một ngân sách byte cứng trên nhiều cửa sổ. Bất kỳ ngân sách tường minh nào bạn truyền đều được nâng lên ít nhất một chunk trọn vẹn, nên một lần đọc đơn luôn tiến từng chunk một và tải đỉnh của cache vẫn dự đoán được. ChunkSize dưới 4096 quay về mặc định 64 KiB

PDFlibPas phục vụ một lần đọc của parser trong Delphi mà không tải PDF: offset tuyệt đối được canh xuống về cỡ chunk, phục vụ từ một trong vài cửa sổ LRU khi hit, hoặc biến thành một lời gọi callback duy nhất đã kẹp khi miss
Mọi offset nguồn đều được canh theo cỡ chunk, nên một lần miss lấy đúng một chunk và tải đỉnh của cache vẫn dự đoán được

Phần kế toán lặp đọc là chỗ đáng nối vào telemetry của bạn. PDFlibPas nhận diện một lần lặp bằng đầu chunk đã canh và giữ các khoảng liền kề có thứ tự, điều này tách một lần lấy thật sự đầu tiên khỏi một lần lấy lại sau khi bị đuổi, đồng thời giữ sổ sách không phình tuyến tính theo kích thước tệp. GetRangeSourceCacheInfo trả về toàn cảnh dưới dạng JSON, SetRangeSourceCacheLimit đổi cỡ ngân sách lúc chạy, và ClearRangeSourceCache xóa các cửa sổ cùng đặt lại số liệu thống kê một thể. Thu hẹp ngân sách lúc chạy vẫn giữ lịch sử và tính các lần nhả do ngân sách vào loại eviction, nên một repeatedReads đi lên trong khi hits đứng yên là tín hiệu working set của bạn không còn vừa

var
  Info: WideString;
begin
  Lib.SetRangeSourceCacheLimit(16 * 1024 * 1024);
  Lib.SelectPage(900);
  if Lib.GetRangeSourceCacheInfo(Info) = 1 then
    { "windowCount", "cacheLimitBytes", "cachedBytes", "hits", "misses",
      "evictions", "sourceReads", "sourceBytes", "repeatedReads",
      "coalescedRequests", "coalescedSourceReads" }
    LogRangeStats(Info);
end;

Chuyện gì xảy ra khi nhiều thread cùng muốn một chunk?

Chúng chờ một yêu cầu, không phải vài yêu cầu. Một TStream cổ điển chỉ có một con trỏ vị trí, và hai thread khóa đúng cách mỗi thread vẫn có thể bị vị trí đó ghi đè giữa một Seek và một Read, nên lazy object và các lần đọc phân đoạn trong PDFlibPas dùng một ReadAt tuyệt đối không bao giờ di chuyển con trỏ. Mỗi chunk đã canh nhận một yêu cầu đang bay duy nhất mà mọi caller của chunk đó cùng chia sẻ, các chunk kề nhau đang xếp hàng được gộp trước khi lần đọc nguồn bắt đầu, và một lần đọc vật lý bị chặn ở 16 MiB, nên một cơn bùng của công việc trang song song không phóng đại thành các yêu cầu nhỏ trùng lặp cũng không thành một yêu cầu to kỳ dị. Cửa sổ gộp mặc định 2 ms và chỉ áp cho chunk đầu tiên còn thiếu của mỗi ReadAt; Read theo vị trí không bao giờ chờ nó, và truyền 0 xóa bỏ hoàn toàn độ trễ thu gom ban đầu, điều quan trọng với các lần quét tuần tự dài mà nếu không sẽ chất đợi chờ theo từng chunk. Vị trí, metadata cache, và các lần đọc nguồn nằm sau ba khóa riêng, và bản thân callback nguồn được tuần tự hóa, điều này chính là lý do một adapter cơ sở dữ liệu hay object store không có bảo vệ thread nội bộ vẫn dùng được nguyên trạng. Mỗi bên chờ nhận bản sao dữ liệu của riêng mình, nên một lần đuổi LRU sau đó không thể làm hỏng một bộ đệm đã được trao đi

Gộp yêu cầu trong range loading của PDFlibPas cho Delphi: hai thread cùng đòi một chunk chia sẻ một yêu cầu đang bay, các chunk kề nhau đang xếp hàng được gộp trong cửa sổ hai mili giây, và một lần đọc nguồn tuần tự hóa phục vụ tất cả
Một cơn bùng công việc trang song song gộp lại thành một yêu cầu dùng chung cho mỗi chunk, và mỗi bên chờ vẫn nhận bản sao byte của riêng mình

Có thể hỏi trang 900 đã sẵn sàng chưa mà không lấy nó về?

Có, và đó chính xác là lý do callback khả dụng tùy chọn tồn tại. Một read callback thường không phân biệt được byte đã hạ cánh với byte cần một vòng đi về chặn, và thăm dò bằng một lần đọc thử sẽ kích hoạt chính việc tải về mà bạn đang né. TPDFlibRangeAvailabilityEvent chỉ trả lời một câu hỏi, liệu một range trọn vẹn có đọc được ngay hay không, và bị cấm lấy bất cứ thứ gì; byte mà cache đã phủ luôn được tính là khả dụng. GetRangeSourceDataAvailability ánh xạ đối tượng indirect tới các range lưu trữ vật lý ghi trong các entry cross-reference, phân giải đối tượng nén về container object stream của nó, hiệu chỉnh phần header PDF bị dịch, và chỉ parse một đối tượng sau khi toàn bộ range đã qua phép thăm dò không-lấy, nên đường thiếu không bao giờ gọi read callback của bạn

Lượt đi qua được khoanh vùng chứ không quét hết. Một truy vấn trang chỉ đi nhánh cây trang chứa trang đích rồi cộng thêm nội dung trang, tài nguyên, annotation, và các thuộc tính trang kế thừa, bỏ qua các cạnh ngược ParentP để một trang hay widget đơn lẻ không mở rộng ngược thành cả tài liệu. Đồ thị đối tượng bị chặn ở 100000 đối tượng được yêu cầu và độ sâu 256, đối tượng stream được parse dictionary trước, và phương án fallback parse trọn chỉ dành cho đối tượng đã lưu tới 4 MiB. Báo cáo JSON gộp các khoảng chồng lấn và kề nhau trước khi đếm, nên requiredBytesmissingBytes được tính từ các mảng requiredRangesmissingRanges đã gộp, với end là điểm cuối bao hàm. Truy vấn một đối tượng đã sẵn sàng có thể lấp đầy range cache; truy vấn một đối tượng còn thiếu thì không đụng tới số liệu đọc

var
  Report: WideString;
  Status: Integer;
begin
  Status := Lib.GetRangeSourceDataAvailability(PDF_RANGE_DATA_PAGE, 900,
    Report);
  if Status = PDF_RANGE_DATA_AVAILABLE then
    RenderPageNow
  else if Status = PDF_RANGE_DATA_NOT_AVAILABLE then
    { Report mang "missingBytes" cùng các "missingRanges" đã gộp }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { ví dụ tệp không hề có AcroForm }
end;

Vì sao tải trước phải lặp

Vì đọc missingRanges hiện tại một lần không làm trang khả dụng. Một nút cây trang hay object stream còn thiếu chỉ hé lộ tầng phụ thuộc kế tiếp sau khi nó tới nơi, nên một công việc tải trước của PDFlibPas chạy vòng truy vấn, lấy về, truy vấn lại cho đến khi trang, form, hay đồ thị đối tượng khả dụng trọn vẹn hoặc một giới hạn byte hay số lượt chặn lại. Công việc dùng reader riêng và một cache phụ nhỏ mà nguồn dữ liệu chuyển tiếp các lần đọc tuyệt đối tới range stream gốc, điều này giữ trạng thái parse tách biệt với TSmartPDFReader phía trước trong khi những byte nó thật sự tải về vẫn đổ vào cache chính dùng chung. Mỗi range stream có một worker thread, khớp với sự tuần tự hóa mà callback nguồn vốn đã yêu cầu, và hàng đợi chọn theo bốn mức ưu tiên rồi theo thứ tự nộp trong từng mức. MaxBytes bị tính theo byte chunk vật lý, nên một parser đòi một byte duy nhất trong một chunk chưa cache vẫn trả tiền cho cả chunk, trong khi các chunk đã có trong cache dùng chung không tốn của công việc lấy gì. Hủy một công việc đang xếp hàng đạt trạng thái kết thúc với số lần đọc nguồn bằng 0; công việc đang chạy được kiểm tra trước mỗi lượt phụ thuộc và mỗi chunk nguồn, và việc giải phóng range stream chờ một callback đang bay trả về thay vì cố ngắt nó

Vòng lặp tải trước của PDFlibPas trong Delphi: một công việc truy vấn khả dụng, lấy các range còn thiếu rồi truy vấn lại, vì mỗi nút cây trang hay object stream tới nơi lại hé lộ tầng phụ thuộc kế tiếp, cho đến khi đồ thị hoàn tất hoặc một giới hạn chặn lại
Một công việc tải trước phải lặp vì một nút còn thiếu chỉ gọi tên con của nó khi nó tới nơi, và mỗi lượt đều được tính trọn theo chunk vật lý
var
  Job: Integer;
  Info: WideString;
begin
  Job := Lib.StartRangeSourcePrefetch(PDF_RANGE_DATA_PAGE, 901,
    PDF_RANGE_PREFETCH_PRIORITY_HIGH, 8 * 1024 * 1024, 65536);
  if Lib.WaitForRangeSourcePrefetch(Job, 5000) =
       PDF_RANGE_PREFETCH_STATE_COMPLETED then
    PrepareNextPage
  else
    Lib.CancelRangeSourcePrefetch(Job);
  { "passes", "plannedRanges", "sourceReads", "fetchedBytes" cùng báo cáo
    khả dụng trọn vẹn cuối cùng, nên LIMIT_REACHED vẫn phân biệt được
    với FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Chỗ nào chuyện này suy sụp thành tải cả tệp

Range loading là một canh bạc đặt vào bố cục tệp, và một số tệp không giữ lời. Tệp linearized theo ISO 32000-1 §7.5.8 là trường hợp đẹp: phần trang đầu được làm ấm ngay lúc mở, bị chặn bởi cả ngưỡng an toàn 4 MiB sẵn có lẫn ngân sách cache hiện thời nên khâu làm ấm không thể tự đuổi phần lớn chính nó ngay lập tức. Tệp không linearized vẫn phân giải được qua trailer và chuỗi cross-reference gần cuối tệp, cái giá là vài vòng đi về thêm chứ chưa phải thảm họa. Vách đá thật là tệp hỏng ép vào đường sửa chữa, vì dựng lại bảng cross-reference nghĩa là quét tìm header đối tượng trên toàn tài liệu, và đó là một lần tải cả tệp tới từng chunk một. Độ trễ là giới hạn trung thực thứ hai: ở 60 ms mỗi yêu cầu, một lần parse truy cập ngẫu nhiên cần bốn mươi chunk chưa cache tiêu tốn hơn hai giây trên đường đi bất kể cache tốt đến đâu, và chính xác điều đó là thứ mà lập luận đọc-trước và hàng đợi ưu tiên tồn tại để che. Kỷ luật tương tự hiện diện trong cách tiếp cận truy cập trực tiếp khi gộp và tách PDF lớn, và cache này nằm dưới render trang song song cũng như cache trang trên đĩa của viewer

API range source, truy vấn khả dụng, và bộ lập lịch tải trước là một phần của PDFlibPas Delphi PDF Library chuẩn cho Delphi, C++Builder, và Free Pascal; trang sản phẩm mang tài liệu tham số đầy đủ cho LoadFromRangeSource cùng các hằng ưu tiên và trạng thái của tải trước