技术文章

Delphi 渐进式 PDF 范围加载:PDFlibPas 实践

一份 2 GB 的扫描归档放在 S3 桶里,用户要第 900 页。PDFlibPas 可以不下载文件就送出那一页:LoadFromRangeSource 在你自己的字节范围回调之上构建一个只读可寻址流并交给 TPDFDocument,于是解析器只取交叉引用表、一支页面树分支和一条内容流

这其中的传输侧又老又无趣。HTTP 服务器宣告字节范围已有几十年,现在规定在 RFC 9110 §14,每个对象存储说的都是同一套方言。PDF 侧同样尘埃落定:ISO 32000-1 §7.5.8 定义线性化,正是为了让阅读器能从文件头部渲染第一页。Delphi 里一直缺的是中间那一块,即决定要哪些范围、保留多少、以及如何避免重复请求的部分

LoadFromRangeSource 需要你的传输层提供什么?

两样东西,而且都不是流。PDFlibPas 要一个权威的 SourceSize 和一个 TPDFlibRangeReadEvent 类型的同步读取回调,声明为 function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object。内部这对组合变成暴露 SourceSizeReadRangeTCallbackByteRangeSource,再包进一个所有权移交给文档的流。你的回调目标及其后端始终归你:文档在关闭、清空或重新加载时释放包装器,但绝不碰方法指针背后的传输对象

契约刻意在一个方向宽容、另一个方向严格。短读合法,仅仅意味着解析器再问一次。抛异常的回调被转换成短读,沿正常加载失败路径收敛。声称写入超过 Count 字节的回调会被钳制,因为有缺陷的提供方绝不能有机会写穿缓存缓冲区。密码重试会在同一个回调源之上重建全新的范围流和全新的解析状态,于是一次失败尝试不会留下陈旧的位置、窗口或解密状态

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
  { 一次阻塞 GET,带 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;  { 释放包装流 }
  Src.Free;  { 你的传输,你的生命周期 }
end;

范围缓存实际装多少?

默认 4 MiB,铺在按块对齐的窗口上,按 LRU 逐出。早期的单窗口设计会长到调用方要求的任意长度,于是一次大的顺序读可以冲破名义块大小,而一次随机跳转又立刻扔掉前一个窗口。现在的缓存把每个源偏移对齐到 ChunkSize,每次未命中只取一整块,并在若干窗口间执行硬字节预算。你传入的任何显式预算都会被抬到至少一整块,于是单次读永远逐块推进,缓存峰值负载保持可预测。低于 4096 的 ChunkSize 回退到 64 KiB 默认值

PDFlibPas 如何在 Delphi 中不下载 PDF 就服务一次解析器读取:绝对偏移向下对齐到块大小,命中时由若干 LRU 窗口之一服务,未命中时变成一次经钳制的回调调用
每个源偏移都对齐到块大小,于是一次未命中恰好取一块,缓存峰值负载保持可预测

重复读统计是值得接进你遥测的部分。PDFlibPas 按对齐块起点识别重复,并维护有序连续区间,这把真正的首次取数和逐出后的重取分开,同时让簿记不随文件大小线性增长。GetRangeSourceCacheInfo 把全貌以 JSON 返回,SetRangeSourceCacheLimit 在运行时调整预算,ClearRangeSourceCache 连窗口带统计一起清。运行时缩小预算会保留历史并把预算驱动的释放计为逐出,于是 hits 走平而 repeatedReads 上行就是工作集已装不下的信号

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;

多个线程要同一块时会发生什么?

它们等同一个请求,而不是各发一个。经典 TStream 只有一个位置游标,两个各自加锁正确的线程仍可能在 SeekRead 之间被改写位置,所以 PDFlibPas 的惰性对象和分段读取使用永不移动游标的绝对 ReadAt。每个对齐块只有一个在途请求,该块的所有调用方共享它;相邻排队块在源读取开始前合并;单次物理读取封顶 16 MiB,于是一阵并行页面工作既不会放大成重复小请求,也不会放大成一个荒谬的大请求。合并窗口默认 2 ms,只作用于每个 ReadAt 的第一个缺失块;位置式 Read 永不等它,传零则完全去掉这段初始收集延迟,这对否则会逐块累积等待的长顺序扫描很重要。位置、缓存元数据和源读取分别由三把锁保护,源回调本身被串行化,这正是没有内部线程保护的数据库或对象存储适配器能原样使用的原因。等待者各自拿到数据副本,于是之后的 LRU 逐出不可能作废已经发出去的缓冲区

Delphi 版 PDFlibPas 范围加载中的请求合并:两个要同一块的线程共享一个在途请求,相邻排队块在两毫秒窗口内合并,一次串行化的源读取服务全部等待者
一阵并行页面工作塌缩成每块一个共享请求,而每个等待者仍各自拿到字节副本

能不取数就问第 900 页是否就绪吗?

能,这正是可选可用性回调的用途。普通读取回调分不出已经落地的字节和需要一次阻塞往返的字节,用试探性读取去探测又会触发你正想避免的下载。TPDFlibRangeAvailabilityEvent 只回答一个问题:一段完整范围能否立即读取,且被禁止取任何东西;缓存已覆盖的字节永远算可用。GetRangeSourceDataAvailability 把间接对象映射到交叉引用条目记录的物理存储范围,把压缩对象解析到其对象流容器,校正平移过的 PDF 头,并且只在完整范围通过不取数探测之后才解析对象,于是缺失路径从不调用你的读取回调

遍历是有界的而非穷举的。页面查询只走包含目标页面的页面树分支,然后加上页面内容、资源、注释和继承的页面属性,跳过 ParentP 回边,于是单个页面或部件不可能反向膨胀成整个文档。对象图以 100000 个请求对象和 256 层深度为界,流对象先解析字典,整解析回退只许用于不超过 4 MiB 的已存对象。JSON 报告在计数前合并重叠与相邻区间,于是 requiredBytesmissingBytes 由合并后的 requiredRangesmissingRanges 数组算出,其 end 是包含端点。查询一个已可用的对象可能填充范围缓存;查询一个缺失对象不动读取统计

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 携带 "missingBytes" 及合并后的 "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { 例如文件根本没有 AcroForm }
end;

为什么预取必须迭代

因为读一遍当前 missingRanges 并不能让页面就绪。缺失的页面树节点或对象流只有在到达后才揭示下一层依赖,所以 PDFlibPas 预取作业跑查询、取数、再查询的循环,直到页面、表单或对象图完全可用,或被字节或轮次上限叫停。作业使用自己的读取器和一个小型次级缓存,其数据源把绝对读转发给原范围流,这让解析状态与前台 TSmartPDFReader 隔离,而它真正下载的字节仍落进共享主缓存。每个范围流存在一个工作线程,与源回调本就要求的串行化一致;队列先按四个优先级再按同级提交顺序取作业。MaxBytes 按物理块字节计费,于是在未缓存块里只要一个字节的解析器仍要付整块的价钱,而已在共享缓存里的块对作业分文不取。取消排队中的作业以零次源读取到达终态;运行中的作业在每轮依赖和每个源块之前被检查;释放范围流会等在途回调返回,而不是试图打断它

Delphi 版 PDFlibPas 预取循环:作业查询可用性、取回缺失范围、再查询,因为每个到达的页面树节点或对象流都会揭示下一层依赖,直到图补完或上限叫停
预取作业之所以迭代,是因为缺失节点只有到达后才能报出自己的子节点,而每一轮都按整个物理块计费
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" 以及最后一次
    完整可用性报告,于是 LIMIT_REACHED 与 FAILED 始终可分 }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

哪里会退化成整文件下载

范围加载是对文件布局的押注,有些文件不兑现。按 ISO 32000-1 §7.5.8 线性化的文件是好情形:首页区段在打开时预热,受现有 4 MiB 安全阈值和当前缓存预算双重约束,于是预热不会立刻把自己大半逐出。非线性化文件仍经文件尾的 trailer 和交叉引用链解析,代价是几次额外往返而非灾难。真正的悬崖是逼出修复路径的损坏文件,因为重建交叉引用表意味着扫遍整个文档找对象头,那是一次分块到来的完整下载。延迟是另一道诚实的极限:每请求 60 ms 时,一次需要四十个未缓存块的随机访问解析,无论缓存多好都要在路上花两秒多,这正是预读参数和优先级队列存在要掩盖的东西。同样的纪律出现在大 PDF 合并拆分的直接访问方案中,而这个缓存同时垫在并行页面渲染查看器磁盘页面缓存之下

范围源 API、可用性查询和预取调度器是 Delphi、C++Builder 和 Free Pascal 标准版 PDFlibPas Delphi PDF Library 的一部分;产品页附有 LoadFromRangeSource 的完整参数参考以及预取优先级和状态常量