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 SourceSize và ReadRange, đượ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
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
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 Parent và P để 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 requiredBytes và missingBytes được tính từ các mảng requiredRanges và missingRanges đã 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ó
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