Műszaki cikk

Távoli PDF streamelés Delphiben: HotPDF tartomány-egyesítés

A HotPDF bármilyen, általad implementált véletlen elérésű forrásból betölt egy PDF-et, és a THPDFCoalescingRandomAccessSource becsomagolja ezt a forrást, így az elemző szétszórt, apró olvasásaiból egy korlátozott számú, gyorsítótárazott blokktartomány lesz aszinkron előretöltéssel. Egy HTTP tartománykérésekkel kiszolgált dokumentumnál ez a különbség néhány száz oda-vissza kérés és néhány tucat között

Az elemzőn semmi sem változik. Továbbra is a LoadFromRandomAccessSource függvényt hívod, ugyanaz a dokumentumobjektum jön vissza, és ugyanaz az oldal-API működik. Ami változik, az az alatta lévő hálózati forgalom

Miért töltődik be azonnal ugyanaz a PDF helyben, és araszol a hálózaton?

Mert egy PDF-elemző nem olvas egy fájlt, hanem navigál benne. A végére ugrik a startxref miatt, visszaugrik a kereszthivatkozás-táblához, feloldja a trailer szótárt, követ egy hivatkozást a Cataloghoz, majd az oldalfa gyökeréhez, majd egy oldalcsomóponthoz, majd annak erőforrás-szótárához. Ezen lépések mindegyike néhány tíz byte-ot olvas egy másik eltolásból

Egy helyi fájlnál ez a minta szinte ingyenes: az operációs rendszer már gyorsítótárazta a körülötte lévő 4 KiB-os lapot, így a második olvasás csak egy memcpy-be kerül. Egy hálózati átvitelnél nincs ilyen lokalitás. Minden olvasás egy saját késleltetéssel rendelkező kérés, és 300 egymást követő kérés 40 ms-onként tizenkét másodperc, amelyet szinte teljes egészében várakozással töltünk. A megoldás nem az, hogy kevesebbet olvassunk; az elemzőnek pontosan arra van szüksége, amit kér. A megoldás az, hogy minden fizikai olvasás fedje le azt is, amire a következő logikai olvasás vágyik

Mit változtat meg az egyesítés

Az egyesítő forrás minden olvasást felfelé kerekít egy blokkra, és gyorsítótárazza a blokkot. A BlockSize alapértelmezett értéke 262 144 byte, a MaxCacheBytes pedig 2 097 152, így alapértelmezés szerint nyolc blokk marad memóriában, és a legrégebben használt sorrendben kerülnek kiürítésre egy szigorú byte-kerettel szemben. Az elemző 40 byte-os olvasása egy trailer kulcsról behozza a körülötte lévő 256 KiB-ot, és a következő tucatnyi olvasás ebben a szomszédságban, ahol a kereszthivatkozás- és katalógusadatok élnek, memóriából szolgálódik ki

A saját forrásod egyszerű marad. Implementáld a GetSize és a ReadAt függvényeket, írd felül a ReadAtCancellable függvényt, ha az átviteled tud menet közben megszakadni, és hagyd, hogy a wrapper kezelje a gyorsítótárazást, az egyesítést és az előretöltést

type
  THttpRangeSource = class(THPDFRandomAccessSource)
  private
    FClient: TMyHttpClient;
    FUrl: string;
    FSize: Int64;
  public
    function GetSize: Int64; override;
    function ReadAt(Offset: Int64; var Buffer; Count: Longint): Longint; override;
    function ReadAtCancellable(Offset: Int64; var Buffer; Count: Longint;
      CancellationToken: THPDFCancellationToken): Longint; override;
  end;

var
  Raw: THttpRangeSource;
  Cached: THPDFCoalescingRandomAccessSource;
  Pdf: THotPDF;
begin
  Raw := THttpRangeSource.Create('https://files.example.com/contract.pdf');
  // OwnsSource=True: a wrapper felszabadítja Raw-t saját magával együtt
  Cached := THPDFCoalescingRandomAccessSource.Create(Raw, True, 262144, 8388608);
  Pdf := THotPDF.Create(nil);
  try
    Cached.AsyncPrefetchEnabled := True;
    Cached.AdaptiveReadAheadEnabled := True;
    Cached.MaxReadAheadBlocks := 8;

    if Pdf.LoadFromRandomAccessSource(Cached, True) = 1 then
      RenderFirstPage(Pdf);
  finally
    Pdf.Free;
  end;
end;

Milyen messzire olvasson előre?

Az adaptív előreolvasás dokumentumonként válaszolja meg ezt a kérdést, ahelyett hogy találgatásra kényszerítene. A AdaptiveReadAheadEnabled beállításával az ablak 1, 2, 4 és 8 blokkon keresztül nő, ahogy a folyamatos előrehaladó olvasások halmozódnak, és soha nem lépi túl a MaxReadAheadBlocks értékét vagy a beállított gyorsítótár-kapacitást. Abban a pillanatban, amikor egy olyan olvasás érkezik, amely nagyjából nem ott van, ahol az előző véget ért, az ablak összeomlik, és az előretöltés felfüggesztésre kerül

A SequentialReadToleranceBytes, alapértelmezésben 4096, határozza meg, mit jelent a „nagyjából”. Az olyan olvasások, amelyek az előző olvasás végétől ezen a távolságon belül landolnak, még mindig szekvenciálisnak számítanak, ami azért fontos, mert egy tartalmi folyamon áthaladó PDF-elemző nem tökéletesen összefüggő eltolásokat termel; itt kihagy egy hosszmezőt, ott egy beágyazott szótárt. Ha túl alacsonyra állítod a toleranciát, egy normál előrehaladó pásztázás véletlenszerűként lesz osztályozva, így az előreolvasás soha nem lép működésbe. Ha túl magasra állítod, a valódi véletlen elérés szekvenciálisnak tűnik, így megabyte-okat töltesz le, amelyekre senkinek sincs szüksége. Az alapértelmezés a tartalmi folyam bejárásához van kalibrálva, és a statisztikák megmondják, ha az átviteled ettől eltér

Ez az aszimmetria szándékos: a növekedés fokozatos, az összeomlás azonnali. A túlzott letöltés egy véletlen elérésű munkaterhelésnél valódi sávszélességbe és valódi pénzbe kerül a mért átviteleknél, így az olcsó hibát részesítjük előnyben a drágával szemben

Megszakítás, amely valóban leállítja az átvitelt

Az alaposztály deklarálja a ReadAtCancellable függvényt, és az egyesítő forrás végig tiszteletben tartja azt. Amikor egy előtérbeli olvasás érkezik egy olyan tartományra, amelyet egy folyamatban lévő előretöltés nem szolgál ki, az előretöltés megszakításra kerül ahelyett, hogy hagynák befejeződni, így a felhasználó oldalkérése nem sorakozik fel spekulatív forgalom mögé. A THPDFRandomAccessSource alapértelmezett implementációja visszaesik egy egyszerű ReadAt hívásra, ami azt jelenti, hogy a funkció átvitelenként opcionálisan bekapcsolható: a kérés megszakítást támogató HTTP-kliensek valódi megszakítást kapnak, az egyszerűbb forrásoknál pedig minden változatlanul működik tovább

Ezt egy, a felhasználói felületeden végigvezetett megszakítási tokennel kombinálva egy dokumentumot bezáró felhasználó ténylegesen leállítja a hálózati forgalmat, ahelyett hogy megvárná annak lecsordogálását. Ugyanez a tokenmodell áll a háttérrenderelés kérési sorral című cikkben leírt sorbaállítás mögött is, így egyetlen token lefedheti a teljes utat a nézetablaktól a socketig

A tartomány-gyorsítótár statisztikáinak értelmezése

A GetStatistics feltölt egy THPDFRangeCacheStatistics rekordot, amely elkülöníti, mit tett az átviteled attól, mit tett a gyorsítótár. A SourceReadCount és a SourceBytesRead a fizikai forgalom. A CacheHitCount és a CacheMissCount a logikai forgalom. A SequentialReadCount és a RandomReadCount mutatja, hogyan lett osztályozva az elérési minta, a CurrentReadAheadBlocks és a PeakReadAheadBlocks mutatja, milyen messzire nyílt ki az ablak, a PrefetchRequestCount, a PrefetchCompletedCount, a PrefetchCancelledCount és a SuppressedPrefetchCount pedig azt mutatja, hogy a spekuláció megtérült-e

var
  S: THPDFRangeCacheStatistics;
begin
  Cached.GetStatistics(S);
  Log(Format('physical %d reads / %d bytes, hits %d, misses %d',
    [S.SourceReadCount, S.SourceBytesRead, S.CacheHitCount, S.CacheMissCount]));
  Log(Format('pattern: %d sequential, %d random, peak window %d blocks',
    [S.SequentialReadCount, S.RandomReadCount, S.PeakReadAheadBlocks]));
  Log(Format('prefetch: %d issued, %d completed, %d cancelled, %d suppressed',
    [S.PrefetchRequestCount, S.PrefetchCompletedCount,
     S.PrefetchCancelledCount, S.SuppressedPrefetchCount]));
end;

Három leolvasás mondja meg, mit kell változtatni. Sok megszakított előretöltés magas véletlen-olvasás számmal azt jelenti, hogy a dokumentumot rendezetlen sorrendben érik el, így csökkentsd a MaxReadAheadBlocks értékét, és ne fizess tovább az eldobott sávszélességért. Sok találati hiány, miközben a csúcsablak még mindig 1, azt jelenti, hogy a tolerancia elutasít egy gyakorlatilag szekvenciális mintát, így emeld meg a SequentialReadToleranceBytes értékét. A fájlméretet messze meghaladó beolvasott byte-mennyiség pedig azt jelenti, hogy a gyorsítótár vergődik, így mielőtt bármi máshoz nyúlnál, emeld meg a MaxCacheBytes értékét

A linearizált fájlok megváltoztatják a számtant

Ha te irányítod a létrehozó oldalt, a dokumentum linearizálása megváltoztatja a problémát, nem csupán optimalizálja azt. Egy linearizált PDF az első oldal objektumait és egy hint táblát a fájl elejére helyezi, így egy megjelenítő az első oldalt a kezdő megabyte-ból tudja renderelni anélkül, hogy a többit látná. A HotPDF ezt az utat közvetlenül elérhetővé teszi a GetProgressiveLinearizedLoadInfo és a ReadProgressiveLinearizedFirstPageSection függvényeken keresztül, az írási oldalt pedig a linearizált PDF-ek generálása hint táblákkal című cikk tárgyalja

A két technika összeadódik. Az egyesítés bármely dokumentumot elviselhetővé tesz lassú kapcsolaton; a linearizálás gyorsan megérkezteti az első oldalt a saját magad által előállított dokumentumoknál. Az olyan fájloknál, amelyek helyi lemezen élnek, de túl nagyok ahhoz, hogy memóriában tarthatók legyenek, a közvetlen fájl API munkafolyamatban leírt leképezett fájl és lusta stream útvonalak általában a jobb eszközök, mivel eleve nincs oda-vissza késleltetés, amit amortizálni kellene

A HotPDF egy natív VCL PDF-komponens Delphihez és C++Builderhez, az elemzőhöz nincs szükség külső DLL-re, és a teljes forráskód elérhető. A véletlen elérésű forrás API, az egyesítő wrapper és a fokozatos betöltés belépési pontjai a HotPDF Delphi PDF komponens oldalán vannak dokumentálva