三维 GIS 离线缓存实战:逆向 CesiumOfflineCache,并亲手为它补上”自动更新”能力 – 中文还在写码

三维 GIS 离线缓存实战:逆向 CesiumOfflineCache,并亲手为它补上”自动更新”能力 – 中文还在写码


Scenario: A large-screen government project uses LSGlobe (a domestic 3D GIS SDK deeply packaged by Cesium) for full-area 3D visualization. Customer requirements: The large screen can still be opened after the network is disconnected, and the second opening must be “open in seconds”. The plan is to introduce matching CesiumOfflineCache.min.js Offline caching plug-in – But after connecting it, we found that this plug-in is “simpler” than expected, so we have this complete record from reverse engineering to transformation.

1. Background: Why do 3D scenes require dedicated offline caching?

The resource loading of 3D GIS scenes is completely different from that of ordinary web applications:

  • A district and county level scene, image tiles + terrain tiles + white model/fine model,Easily loads a few hundred MB for the first time;
  • Tiles are massive small files (a single 256×256 JPEG, dozens of KB), and the number of HTTP requests can easily reach thousands;
  • A single model glb file may be tens of MB and needs to be parsed after loading.

The browser’s default HTTP disk cache is basically ineffective for 3D scenes: tile URLs often have random parameters and cache strategies are determined by the server. Cache-Control Decisions, large files (glb) are often excluded directly. Therefore, it is normal for 3D scenes to be “re-downloaded every time they are opened”.

CesiumOfflineCache The idea is very straightforward:Add an IndexedDB bypass to the Cesium resource loading pipelineall resource requests first check the local library, and if they hit, they will be returned directly. If they do not hit, they will go to the network and be dropped into the library smoothly. The three features of IndexedDB exactly match the needs:

  1. Large capacity (quota is usually a percentage of the disk, in gigabytes);
  2. Natively supports structured cloning and can be stored directly Blob / ArrayBuffer;
  3. Asynchronous API, does not block the main rendering thread.

2. Reverse: 33KB What is hidden in the obfuscated package?

The plug-in is a webpack UMD single file package, 33KB, compressed into one line. Direct reading is impossible, but the obfuscation only performs compression (mangle + flattening) and does not perform string encryption, soStandard API names are clear text——This is the breakthrough.

2.1 Find out the skeleton

First, search the key API names globally and draw the dependency graph immediately:

UMD 入口
├── 模块 483: browserify 运行时 + localforage(IndexedDB 封装库)
│   └── 内嵌依赖: promise-polyfill(异步调度 + Promise 实现)
└── 主模块: CesiumOfflineCache 插件本体
    └── 加载即自执行: initLicence() → initCacheProxy()

Two interesting details:

  • itEmbedded an entire promise-polyfill(MutationObserver / MessageChannel / setImmediate Three-level fallback asynchronous scheduler), indicating that the packaging goal is to be compatible with old browsers;
  • load backward window There are two global issues:CesiumOfflineCache(plugin API) and localforage(its internal dependencies,global leakagethis is a hidden danger).

2.2 Core mechanism: rewriting loadWithXhr

initCacheProxy The key line (after restoration):

LSGlobe.Resource._Implementations.loadWithXhr = function(url, responseType, method, data, headers, deferred, overrideMimeType) 

Resource._Implementations.loadWithXhr It’s the Cesium systemThe underlying XHR channel for all resource requests——Image tiles, terrain, glb model, geojson, are all finally collected here. Rewriting it is equivalent to installing a bypass at the bottom of the pipeline, and all providers (ImageryProvider, TerrainProvider, Model) in the upper layer are completely unaware. This is the smartest design of the entire plugin:Zero business intrusion。

Core logic after restoration:

loadWithXhr(url, responseType, method, data, headers, deferred, overrideMimeType) {
  // 缓存 key = 完整请求描述的 JSON 序列化
  const key = JSON.stringify( rule === '*');
  return hit;   // 子串匹配,'*' 全通配
);

  // data URI 直接解析返回,不缓存(复刻原生逻辑)
  if (dataUriRE.test(url)) return deferred.resolve(parseDataUri(url, responseType));

  const xhr = new XMLHttpRequest();

  // ★ 缓存读路径:规则命中 → 查 IndexedDB → 命中直接 resolve,连接都不建
  if (judgeUrl(url)) 
  let hit = false;
  [...this.ruleList].forEach(rule => hit = hit 

  // 以下全部复刻 Cesium 原生行为
  TrustedServers.contains(url) && (xhr.withCredentials = true);  // 跨域凭证
  xhr.open(method, url, true);
  /* headers / responseType / overrideMimeType / file:// 特判 */

  xhr.onload = function() ;

  // ★ 规则未命中 → 直接发请求,不缓存
  judgeUrl(url) || xhr.send(data);
  return xhr;
}

Note that it reproduces the original loadWithXhr ofallResponse branch (HEAD/OPTIONS return header parsing, 204 empty response,file:// Under the protocol, status 0 is regarded as successful, and json manual parse falls back). Only one sentence is inserted before the successful branch. setItem. This “replication + instrumentation” writing method ensures complete compatibility with Cesium behavior, but it also lays a trap – which will be discussed later.

2.3 ruleList: the main switch that is turned off by default

ruleList: new Set,   // 默认空集合!

judgeUrl(url) {
  let hit = false;
  [...this.ruleList].forEach(rule => hit = hit || url.indexOf(rule) > -1 || rule === '*');
  return hit;   // 子串匹配,'*' 全通配
}

ruleList The default isSky Set,judgeUrl Constant return false——That is to say,Nothing is cached after the plugin is loaded and silently does nothing. Must be done manually:

CesiumOfflineCache.ruleList.add('*');   // 或 add(' 只缓存特定服务

This is a very tricky design: no errors or warnings are reported, and the console is clean. You may think it is connected, but in fact it has not written a single line of cache. We stumbled here when we first connected – refreshed the page, disconnected the network to test, and found that “offline” was completely invalid. We went back and reversed the source code to see this empty Set.

2.4 Authorization mechanism: hard-coded 2099

let o = []; o[0] = 2099;               // 年
async initLicence() {
  o[1] = 4; o[2] = 1;                  // 月、日 → 到期日 2099-04-01
  await localforage.setDriver(localforage.INDEXEDDB);  // 强制 IndexedDB,禁用降级
}
async initCacheProxy() {
  const days = (new Date(o.join('-')) - new Date()) / 864e5;
  if (days <= 0) {
    console.warn('CesiumOfflineCache 数据高速缓存插件,使用期已结束,请获取授权。');
    return this.clear();               // 过期 → 清空缓存库,且不安装代理
  }
  // 安装代理...
}

The so-called “authorization” is the hard-coded date 2099-04-01, which is approximately permanent. But note that the behavior after expiration isClear the warehouse first and then go on strike——If you get a trial version of the plug-in one day, your offline cache will be cleared silently when it expires.

2.5 Fragile design of cache key

Take another look at key generation:

const key = JSON.stringify({ url, responseType, method, data, headers, deferred, overrideMimeType });

deferred(Deferred object of when.js) is serialized into key. The function will be JSON.stringify Ignore that the promise object happens to have no enumerable properties and is serialized into {}so the key is currently stable – but this depends entirely on the internal implementation “coordination” of when.js. Once any enumerable mutable properties appear on the deferred, the key will drift.Cache never hits. This is a design that works, but walks on a tightrope. It also reminds us that serializing the internal structure of a third-party library is inherently fragile coupling.

3. Access: three lines of code and a hidden premise



ruleList.add with asynchronous initCacheProxy No order dependency (judgeUrl Read the Set in real time on each request), the synchronized inline script is much earlier than the first tile request after Vue is mounted, and the timing is naturally safe.

The affected area is also clean:loadWithXhr only serve LSGlobe.Resource For loaded 3D resources, business axios requests do not go through this channel.'*' The interface will not be accidentally damaged.

4. Pitfalls: Two IndexedDB pits

4.1 The "Clear Model" button cannot clear the cache

The original cleanup logic of the project:

handleClearIndexDb() {
  window.indexedDB.deleteDatabase('GlbOfflineCache');
  window.indexedDB.deleteDatabase('CesiumOfflineCache');
}

It looks fine, but actuallyCan't clear it. IndexedDB specification:deleteDatabase meetconnections still openwill not report an error or fail, but will trigger blocked event,Suspend waiting for all connections to be closed——The plug-in always holds the open connection after it is started, the page does not refresh, and the connection never closes. The result is: the user clicks "Clean Successfully" and the library remains intact.

Repair: use the plug-in yourself clear()(localforage is cleared within the connection and takes effect immediately),deleteDatabase Only as a fallback when the plugin is not loaded:

if (window.CesiumOfflineCache && window.CesiumOfflineCache.clear) {
  window.CesiumOfflineCache.clear();          // 连接内清空,立即生效
} else {
  window.indexedDB.deleteDatabase('CesiumOfflineCache');  // 回退
}

This is one of the most classic pitfalls of IndexedDB:The deletion operation is "negotiated" rather than "forced"。

4.2 Always reading old data: the missing failure mechanism

After reversing the source code, the most disappointing discovery was: this plug-inThere is no mechanism for comparing old and new data。

Common failure methods Plug-in implementation
HTTP negotiation cache (save ETag, return to origin If-None-Match) ✗ No verification header is stored and no return to the source
TTL expired ✗ No timestamp
Version number/content hash ✗ None

The cache key only contains the request description (URL/method/headers). The URL does not change → the key does not change → the same old data will always be hit. after hitNot even an HTTP connection is establishedthere is no way to know whether the server has been updated.

So the actual process of "data update" becomes:

服务端更新模型 → 客户端无感知(读旧缓存)
→ 用户肉眼发现数据不对 → 手动点"清理模型" → 全量重新下载

The comparison process is completed by humans. It is barely enough for the scenario where the model is updated occasionally, but the large screen is hung 24/7. It is unrealistic to expect the attendant to find out that "this base map seems to be old" and then run to click the button.

5. Enhancement: Add stale-while-revalidate to the plug-in

Goal: hit cacheReturn the old data first (keep instant open and offline)and at the same time, the background sends a conditional request for verification. If it changes, the cache will be updated, and the next request will take effect. This is exactly what HTTP semantics means stale-while-revalidate model.

5.1 Design decision: Do not change min.js, add external patches

Directly modify the 33KB compressed file? It can be changed, but once the plug-in is upgraded, it will be lost. Select plug-in patch: load one after min.js swr.jsinstall the plug-in loadWithXhr Wrap another layer.

Here’s a key insight: plug-ins localforage Got it windowwhile localforage's createInstance({ name }) yesShare with the same name——Create another instance with the same parameters and getHandle to the same IndexedDB storecan directly read and write all cache entries of the plug-in. No hack closure is needed, and no need to change a single line of min.js:

const store = window.localforage.createInstance({ name: 'CesiumOfflineCache' });  // 与插件共享

5.2 Patch core logic

impls.loadWithXhr = function(url, responseType, method, data, headers, deferred, overrideMimeType) {
  // key 生成逻辑与 min.js 保持一致(已知耦合点)
  const key = JSON.stringify({ url, responseType, method, data, headers, deferred, overrideMimeType });

  // 规则外 / 非 GET:完全走原逻辑
  if (!C.judgeUrl(url) || method !== 'GET') {
    return rawLoadWithXhr.apply(this, arguments);
  }

  store.getItem(key).then(entry => {
    if (!entry) return rawLoadWithXhr.apply(this, arguments);  // 未命中:原逻辑
    deferred.resolve(isWrapped(entry) ? entry.body : entry);   // ① 立即返回旧数据
    revalidate(key, url, responseType, headers);               // ② 后台校验
  });
};

Background verification is a standard HTTP conditional request:

function revalidate(key, url, responseType, headers) {
  const xhr = new XMLHttpRequest();   // 裸 XHR:不经过 loadWithXhr,避免递归!
  xhr.open('GET', url, true);
  xhr.responseType = responseType || 'text';
  if (meta.etag)         xhr.setRequestHeader('If-None-Match', meta.etag);
  if (meta.lastModified) xhr.setRequestHeader('If-Modified-Since', meta.lastModified);
  xhr.onload = () => {
    if (xhr.status === 304) {
      store.setItem(key, { body: oldBody, __cocSwrMeta: metaFrom(xhr) });  // 仅刷新校验时间
    } else if (xhr.status >= 200 && xhr.status < 300) {
      store.setItem(key, { body: alignBody(...), __cocSwrMeta: metaFrom(xhr) });  // 更新数据
    }
  };
  xhr.onerror = () => {};  // 离线/断网:静默,缓存照用——离线可用性的关键
  xhr.send();
}

Four details that must be dealt with:

① Anti-recursion. If the verification request goes LSGlobe.Resourcewill enter the packaged loadWithXhr——Infinite loop. Must be naked XMLHttpRequest。

②Format compatible. The plug-in inventory cache stores naked bodies (ArrayBuffer/Blob/string/parse object), the new format of the patch is { body, __cocSwrMeta }. Used when reading META in v Judgment - Note in operator pair Blob/ArrayBuffer Also safe (both are objects), strings are typeof Block the front.Inventory cache does not need to be cleared, smooth upgrade。

③ Type alignment。responseType When it is empty, the original function of the plug-in may store JSON.parse the object after or XMLDocumentand the verification request xhr.response is a string. Direct storage will cause the downstream to get data of inconsistent types the next time it is hit. Solution: pressType of old cached valueNormalize the new response - try the new string if the old value is a normal object JSON.parseif the old value is a string, keep it as a string.

④ Throttling and deduplication. Sending a verification request for every hit means that every tile returns to the source every time, and the number of requests explodes. Two layers of protection: meta storage checkedAtno repeated verification within 10 minutes; inflight Set is maintained in memory, and only one concurrent verification of the same key is sent. The 304 response body is empty and the traffic overhead is negligible.

5.3 An unavoidable premise: CORS

JS reads cross-domain response headers Access-Control-Expose-Headers Limitation - the server is not explicitly exposed ETag / Last-Modified,getResponseHeader return nullconditional requests are out of the question. Therefore, when deploying across domains, the CORS configuration of the tile/model service must be added:

Access-Control-Expose-Headers: ETag, Last-Modified

When the verification header cannot be obtained, the patch degrades to regular unconditional GET (functions are available and traffic increases). This problem does not occur with same-origin or proxy deployment.

5.4 Comparison before and after transformation

scene before renovation After renovation
first load Network + write cache constant
hit and unchanged Use old cache directly Use the old cache first; send conditional requests after 10 minutes, 304 overhead is minimal
hit and the server has changed Forever old data The old data is displayed and new data is pulled in the background.The next request will take effect
offline hits Use cache Same (silent if verification fails)
Inventory cache — Automatically compatible, no need to clear the library

6. Summary: If the cache fails, someone will eventually have to do the "comparison"

Looking back at the entire link, the most interesting thing is actually an architectural issue:Who does the comparison between the old and new cached data?

  • Browser HTTP cache: HTTP negotiation (ETag/304), resolved at the protocol layer;
  • CDN: origin site active failure + TTL;
  • This plugin:No one does it——The key is a URL, which can be used as soon as it is hit, leaving the comparison completely to the user’s eyes;
  • Our patch: Move the comparison back to the HTTP negotiation layer, while using SWR to preserve the "display first, verify later" experience.

The offline caching of 3D GIS is more troublesome than ordinary web caching: the resources are a mixture of massive small files + large files, and "data update" in government affairs scenarios often means "the base map has been changed, the model has been changed".Whole batch invalid. SWR solves the problem of "automatic perception", and the "Clean Model" button is still worth retaining as a means of catching a whole batch of failures - the two are complementary rather than substitutes.

Finally, here are three practical tips for reverse engineering third-party obfuscation packages:

  1. Standard API names are not obfuscated——indexedDB、createObjectStore、getResponseHeader These plain texts are the best road signs. If you search the whole situation, the structure will immediately emerge;
  2. Run through the data flow first, then implement it——Use the Application panel of DevTools to see the actual structure of IndexedDB (library name, key format, stored value type), which is much faster than hard-reading the compression code;
  3. Be wary of the coincidences behind "can work"——Serializing the Deferred object into the cache key can still hit the target because when.js happens to have no enumerable properties. Running code does not mean correct code. Before upgrading third-party dependencies, think about which behaviors are supported by "coincidences".



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *