第23章 - 源码架构与二次开发

上一章讲怎么“加一个插件”,这一章往下走一层:GeoLibre 的源码是怎么组织的、状态为什么这样流动、四种渲染引擎如何共存,以及你想改它时应该动哪个文件。文中的路径、类型名、常量都取自仓库(CLAUDE.md、docs/architecture.md、docs/tutorials/source-code-deep-dive-zh.md 与源码本身)。

说明:本章基于 GeoLibre 3.4(HEAD 354ee2fa)。源码里的数字(阈值、体积、文件行数)会随后续版本变化,引用时请以当前仓库为准。

23.1 monorepo 与包职责

23.1.1 一个仓库,三种交付形态

GeoLibre 是单一 npm workspaces monorepo(apps/*、packages/*、workers/*),外加两个非 npm 组件:Python FastAPI sidecar(backend/geolibre_server)与独立 Python 包(python/,Jupyter anywidget + geolibre-mcp)。根目录一次 npm install 就把所有 JS workspace 接好,包管理器固定用 npm(仓库跟踪 package-lock.json),Node 22+。

同一套 React 应用有三种落地方式:

形态 外壳 入口/产物
浏览器 nginx 托管静态产物(Docker) apps/geolibre-desktop/dist/
桌面 Tauri v2(apps/geolibre-desktop/src-tauri) 安装包 / 便携版 / 商店包
Jupyter python/ 包内置一份 Web 构建 npm run build:embed → dist-embed → wheel

23.1.2 包职责一览

包 职责 关键文件
@geolibre/core 领域类型、.geolibre.json 工程 schema、Zustand store(唯一事实源) types.ts、project.ts、store.ts、store/、layer-selectors.ts
@geolibre/map 四种渲染引擎的生命周期与适配、图层同步、GeoJSON/栅格/瓦片/MBTiles/控件/选择高亮 map-controller.ts、layer-sync.ts、*-engine.ts、layer-control-host.ts
@geolibre/ui shadcn 风格 UI 原语 组件与 DirectionProvider
@geolibre/processing 客户端算法注册表与执行器 registry.ts、runner.ts、vector-tools.ts、wasm-tool-runner.ts、sidecar-client.ts
@geolibre/plugins 插件接口、内置插件、插件宿主 types.ts、plugin-manager.ts、plugins/
@geolibre/embed 无依赖的类型化 iframe 客户端(发布到 npm) src/index.ts
@geolibre/collab-core 与传输无关的协作协议、权限、校验(被两个 relay 共用) 协议与 payload 校验
geolibre-desktop 壳层:布局、Tauri I/O、各包组合 src/main.tsx、src/App.tsx、hooks/usePlugins.ts、lib/app-api.ts

导出面大小能说明“依赖方向”:@geolibre/plugins/src/index.ts 约 150 条 export、@geolibre/core 约 70 条、@geolibre/map 约 50 条、@geolibre/processing 约 36 条、@geolibre/ui 约 18 条、@geolibre/embed 约 13 条、@geolibre/collab-core 4 条。

依赖方向的硬规则:core 只依赖 zustand、zundo、uuid、@maplibre/maplibre-gl-style-spec——不依赖 maplibre-gl 本体。样式表达式求值用 style-spec 包,所以状态层与渲染层是真分开的。

提示:想判断“这段逻辑该放哪”时,用一条经验法则:能写进工程文件、且不依赖任何地图 SDK 的,放 core;要碰 SDK 的,放 map;纯算法的,放 processing。

23.1.3 常用命令的语义

命令 实际行为
npm run dev 浏览器开发服务器 → http://localhost:5173
npm run tauri:dev 桌面应用(文件对话框、本地 MBTiles/栅格必需)
npm run build 生产 Web 构建 → apps/geolibre-desktop/dist/
npm run typecheck 等价于完整构建(tsc -b && vite build),会写 dist/,不是纯类型检查
npm run typecheck:fast tsc -b,只留增量缓存;先构建 @geolibre/embed(应用要用它的 dist/ 类型)
npm run lite:build DuckDB-WASM 走 jsDelivr 的轻量构建,给有单资源体积上限的托管平台
npm run build:embed / build:jupyterlite / build:mcp-app / build:chrome-extension 各交付产物的构建
npm run bundle:report 构建后生成 bundle-report/bundle-report.md 与 .json

23.2 store-driven 单向数据流

23.2.1 七步数据流

CLAUDE.md 把这条链写成了七步,值得一条条对上文件名:

  1. 数据入口:Add Data 菜单、Tauri 对话框、浏览器文件选择、拖拽、插件控件。
  2. 本地矢量转换:MapLibre 读不了的格式先在浏览器里由 DuckDB-WASM Spatial 转 GeoJSON(INSTALL spatial; LOAD spatial; → ST_Read;GeoParquet 走 Parquet reader;zip Shapefile 先 shpjs、读不了再交给 DuckDB;KMZ 客户端解压)。
  3. 写 store:转换结果交给 addGeoJsonLayer;瓦片/服务/栅格/ArcGIS/MBTiles/插件图层则创建 GeoLibreLayer 记录(带 source 元数据与原生图层 id)。
  4. 引擎订阅:选中的 MapEngine 订阅 layers 并与自己的 SDK 对账。MapLibre 走 MapController.syncLayers,另三种引擎走各自适配器;两个 Style Spec 引擎共用的图层控件桥在 packages/map/src/layer-control-host.ts。
  5. 样式/面板改动:改的是图层状态,再由 sync 落到 paint、可见性、不透明度、顺序与删除。
  6. 属性表选择:更新高亮要素源,可缩放地图到选中要素。
  7. 保存:projectFromStore 写 .geolibre(旧名 .geolibre.json 仍可读)。

禁止的做法:UI 里直接 map.addLayer()/setPaintProperty()。改 store,让 sync 去应用。这不是洁癖——四引擎、分屏 pane、工程保存、撤销栈全都建立在这条约束上。

23.2.2 为什么每层编辑都会重渲染,以及怎么避免

任何一次图层操作都会替换整个 layers 数组,所以 useAppStore((s) => s.layers) 会在任何图层任何字段变化时重渲染。只有真正需要“每条记录的所有字段”的订阅者该这么写:地图画布的 sync、图层面板。其余一律用 packages/core/src/layer-selectors.ts 的窄钩子:

钩子 返回 适用
useLayer(id) 单条图层记录 单图层详情面板
useLayerIds() id 列表 只关心“有几层/顺序”的列表
useLayerSummaries() { id, name, type, visible, groupId }[] 图层面板的行
useLayersWhen(open) 仅在 open 期间订阅完整数组 常挂载但只在打开时用全量的对话框

23.2.3 store 的切片与撤销栈

useAppStore 是一个 Zustand store,但代码按切片拆在 packages/core/src/store/:project、map-view、layers、layer-groups、project-content、processing、libraries、session、collaboration、capabilities、ui(各类对话框开关),在 packages/core/src/store.ts 组合。

规则 说明
新字段/新动作放哪 放进拥有该数据的切片;必须同时改别的切片字段的动作(如 removeLayer 要清各处引用)通过 set/get 读写整个 state
撤销历史跟踪什么 只有 partializeHistory 列出的字段:图层、图层组、底图、故事地图、评论、仪表盘组件、图例
不跟踪什么 ui 整个子状态、选择、相机都不记录撤销步,也不会被 undo/redo 还原
次级视图 分屏 pane 与 Print Layout 引用图层但不进历史,每一步 undo/redo 都会去清理它们对“已被删图层”的引用

23.2.4 一次图层编辑的完整调用链

以“在样式面板把填充色改掉”为例,链条是这样的:

  1. components/panels/StylePanel* 派发一个 store 动作(layers-slice),不改 MapLibre。
  2. store 生成新的 layers 数组(新引用,旧记录被替换)。
  3. MapCanvas 的订阅触发,MapController.syncLayers(prev, next) 做差量对账。
  4. layer-sync.ts 把样式变化翻译成 setPaintProperty/setLayoutProperty/moveLayer 等调用。
  5. 图层控件(layer-control-host.ts)与图例/色标等派生物跟着刷新。
  6. 若记录被标记进撤销历史,store/undo-history.ts 在合并窗口内记一步。

提示:调试时在第 2 步下断点看“新旧数组谁被替换了”,比在第 4 步猜 setPaintProperty 为什么没生效快得多。

23.3 项目 schema 与类型系统

23.3.1 常量数组派生类型:一份定义两处使用

packages/core/src/types.ts 里 20 个图层类型是这么写的:

export const LAYER_TYPES = [
  "geojson", "raster", "wms", "wmts", "xyz", "vector-tiles", "arcgis",
  "pmtiles", "mbtiles", "zarr", "lidar", "gaussian-splat", "3d-tiles",
  "cog", "flatgeobuf", "geoparquet", "duckdb-query", "deckgl-viz",
  "video", "image",
] as const;
export type LayerType = (typeof LAYER_TYPES)[number];

注释说明了原因:写成运行时数组,才能校验不可信输入(导入的 Layer Library bundle、手改过的工程);LayerType 由数组派生,两者不可能漂移。这是 TypeScript 项目里“有限枚举 + 外部输入校验”的标准写法。

23.3.2 视图状态刻意做小

export interface MapViewState {
  center: [number, number];
  zoom: number; bearing: number; pitch: number;
  bbox?: [number, number, number, number];
}

只有五个字段是刻意的:状态越小,越有把握序列化进工程文件,接第二个消费者(第二个地图面板、另一个渲染器、一个导出器)的成本越低。

23.3.3 工程文件的读写边界

方向 入口
写 projectFromStore → .geolibre
读 工程 schema 校验 + normalize* 归一化函数(如 normalizeSecondaryMapViews、normalizePrimaryRenderer)
兼容 旧名 .geolibre.json 仍可读;只在非默认时才写 primaryRenderer,所以 2D 工程与旧版本字节一致

注意:getProjectState/applyProjectState 是插件自己的序列化钩子,和工程 schema 是两层。插件状态写进 plugins.settings,且在分享/导出/协作快照里会被整段删除(见第21章)。

23.4 四种渲染引擎的抽象

23.4.1 MapEngine 与两个“选择器”

store 是渲染无关的:里面是普通 GeoLibreLayer 记录、偏好与 MapViewState,没有 SDK 对象。MapLibre、Mapbox、Cesium、ArcGIS 各自实现共享的 MapEngine 接口(相机操作、能力查询、拾取、控件、放置、范围绘制、截图)。

选择器 决定什么
primaryRenderer 主画布用哪个引擎(View → Rendering engine → Cesium 等)
SecondaryMapView.viewKind 每个分屏 pane 用哪个引擎(pane 自带相机与可见性覆盖)

改任一个值都会卸载旧引擎、把新引擎接到同一份工程状态上——不是隐藏,是真的 unmount,GPU 资源随之释放。

能力是显式声明而非假设:壳层按当前 MapEngine 门控命令、图层报告引擎兼容性、插件声明 engines。这让“切引擎后保留一个不支持的图层/挂起一个不支持的插件”成为可能,而不必要求每个 SDK 去模拟全部特性。

23.4.2 反跨引擎导入:一条被 ESLint 强制的边界

在 packages/map/src 里,以引擎命名的文件(arcgis-*、cesium-*、mapbox-*,以及 MapLibre 的 layer-sync、map-controller、MapCanvas)不得 import 另一个引擎的文件。两个引擎共用的代码放中性模块,按“它做什么”命名:pmtiles-archive.ts、protocol-tiles.ts、gl-style-compiler.ts、feature-style.ts。

这条边界由 ESLint 规则 local/no-cross-engine-imports(eslint-rules/no-cross-engine-imports.mjs)强制,规则里维护着分类清单。所以“加第五个引擎”不是把代码粘过来就行,得先想清楚哪些逻辑属于中性层。

23.4.3 Cesium 球面:懒加载、相机换算、图层同步

机制 实现
懒加载 整个 Cesium 引擎(约 4.8 MB)只在球面第一次挂载时 import(),单独成 chunk(vite.config.ts 的 CODE_SPLITTING_GROUPS),不进 2D 启动路径
预算门禁 bootBundleBudgetPlugin 在启动 chunk 里出现 Cesium、或 JS 超过 3 MB 时构建失败
运行时资源 vite-plugins/copy-cesium-assets.ts 把 Workers/Assets/Widgets 拷到 public/cesium/,画布设置 window.CESIUM_BASE_URL
相机同步 packages/map/src/cesium-camera.ts 在 MapLibre 的 Web Mercator 视图与 Cesium 相机之间换算,按地面分辨率(米/像素)对齐,并抑制 apply→moveEnd 回声
图层同步 CesiumLayerSync(cesium-layer-sync.ts)像 MapController.syncLayers 那样对账 store,GeoJSON 走 drape 的 GeoJsonDataSource,栅格/WMS/ArcGIS MapServer/三维 Tiles 各用对应 provider
插件控件 CesiumControlHost 交给控件一张记录式 MapLibre 地图:样式调用被记录,由 CesiumLayerSync 绘制镜像图层,其余 GeoJSON 由 shadowOverlayGraphics 求值后画成贴地实体

几个只有在球面上才会遇到的细节:自定义图层会抛错(宁可挂不上,也不要“黑着挂上”);Cesium 会取消 pointerdown,所以宿主替控件重新派发被浏览器丢掉的 mousedown/mousemove/mouseup;插件必须用 engines: ["maplibre", "cesium"] 声明,并在 deactivate 里恢复场景状态。

23.4.4 引擎能力矩阵(要点)

能力 MapLibre Mapbox Cesium ArcGIS
自定义协议(addProtocol) 有 无 无 无
transformRequest(瓦片请求头) 有 仅构造时 无 无
CustomLayerInterface 有 capabilities.customLayers 为 false 抛错 抛错
插件控件宿主 真地图 Style Spec 适配器 记录式门面 记录式门面
三维地形/倾斜 部分 部分 原生 原生(本地场景)

23.5 Processing 注册表与 runner

23.5.1 三层结构

packages/processing/src 的组织方式值得单独看,因为它把“注册什么工具”和“怎么执行”彻底分开:

层 文件 职责
注册表 registry.ts、types.ts、vector-tool-registry.ts 工具元数据(id、分组、参数 schema)+ 查找
执行器 runner.ts、vector-tool-runner.ts、wasm-tool-runner.ts 选择引擎、组织输入输出、进度与取消
引擎客户端 sidecar-client.ts、wasm-client.ts、raster-client.ts 各自协议的传输层
工具实现 vector-tools.ts(13.7 万字节)、raster-tools.ts、statistics-tools.ts、terrain-viewshed.ts、obia.ts、network-tools.ts、topology-tools.ts 具体算法
Worker vector-tool.worker.ts、wasm-tool.worker.ts、terrain-viewshed.worker.ts、obia-felzenszwalb.worker.ts 把重计算移出主线程

23.5.2 一个算法三种跑法

矢量工具是三引擎可选的:

引擎 实现 何时使用
Turf.js(客户端) 纯 JS,默认引擎 不需要侧车、开箱即用
Sidecar(GeoPandas/Shapely) 后端 /vector 端点,/vector/status 报可用性 装了 vector extra,需要投影感知结果
Python(Pyodide) 浏览器里跑同一份 GeoPandas/Shapely 代码 Web 构建也想用 GeoPandas 路径

关键是同一份源码:几何逻辑抽成无框架模块 backend/geolibre_server/geolibre_server/vector_ops.py,sidecar 与浏览器都跑它。Vite 插件 vite-plugins/copy-vector-ops.ts 把它拷进应用包,经典 Web Worker public/pyodide/pyodide-worker.js 从 CDN 载入 Pyodide、装 geopandas、以 JSON 字符串边界调用 run_vector_tool。一份实现意味着两个引擎结果必然一致。

23.5.3 模型构建器与 WASM 工具箱

模块 说明
model-graph.ts 模型(节点+连线)的图结构、拓扑与执行顺序
wasm-tool-runner.ts + wasm-client.ts Whitebox Next Gen 与自有 WASM 工具的执行(1000+ 工具)
ort.ts onnxruntime-web;WASM 产物从 CDN 拉,CDN 版本必须与 npm pin 的版本严格一致

提示:凡“JS 胶水在包里、WASM 产物在 CDN”的库都有版本对齐坑。做法是把版本号写成常量,两边都从它取。

23.6 插件宿主与 app API 的实现位置

第21章讲的是接口契约,这里补“谁实现了它”:

角色 文件 说明
注册表与生命周期 packages/plugins/src/plugin-manager.ts 见 21.3.2 的激活流程
app API 实现 apps/geolibre-desktop/src/lib/app-api.ts(约 1100 行) createAppAPI(mapControllerRef, host),把 store、引擎引用、插件管理器、凭据宿主、助手注册表拼成 GeoLibreAppAPI
注册与加载 apps/geolibre-desktop/src/hooks/usePlugins.ts BUILT_IN_PLUGINS、外部插件加载、?plugin= 深链、项目插件状态持久化
作用域包装 plugin-manager.ts 的 scopeAppToPlugin 注入 owner、限制激活期注册

AppApiHost 是壳层与插件 API 的接缝:它要求宿主提供 plugins(管理器子集)、projectPluginStateSnapshot、persistProjectPluginState、reportPluginError、bindTemporalLayer、buildProjectSnapshot、credentials、getCesiumScene 等能力。想在没有 Tauri/React 的环境里复用插件 API,就实现这个接口,而不是 fork app-api.ts。

// 形状示意(字段名取自 app-api.ts 的 AppApiHost)
export interface AppApiHost extends Pick</* store/引擎/设置等宿主能力 */ any, never> {
  plugins: Pick<PluginManager, "isActive" | "activate" | "deactivate" | "list">;
  projectPluginStateSnapshot: () => unknown;
  persistProjectPluginState: (previousJson: string) => void;
  reportPluginError: (pluginId: string, action: string, error: unknown) => void;
  credentials: typeof pluginCredentialHost;
}

注意:上面这段是结构示意,不是可直接编译的声明——真实接口在 app-api.ts 顶部,字段更多且带精确类型。读源码时从 createAppAPI 往下看,它的每个成员都能对应到 store 动作或引擎方法。

23.7 sidecar 通信

23.7.1 进程与路由

backend/geolibre_server 是一个可选的 FastAPI 服务,跑在 127.0.0.1:8765,桌面端按需启动,把重的 Python 处理栈留在浏览器包外。

路由模块(backend/geolibre_server/geolibre_server/app/) 能力
raster.py 栅格工具箱(rasterio/numpy/contourpy)
vector.py 可选 GeoPandas 矢量引擎(/vector、/vector/status)
sql.py Sedona 引擎(/sql/status、/sql/run)
postgis.py / mssql.py 把 PostGIS / SQL Server 表读成可编辑图层并写回
pointcloud.py 把点云标注写回整份 LAS/LAZ/COPC
ml.py 反向代理 AI 分割到 samgeo-api(SAM 3 需要多 GB 的 GPU Torch 构建,故独立进程)

23.7.2 浏览器构建怎么连它

事项 做法
同源代理 浏览器构建把 sidecar 代理在 /sidecar,避免 CORS
路径限制 限制在 GEOLIBRE_CONVERSION_ROOTS(默认 /data)内
可选依赖 extras:conversion、vector、raster;sedona 另装
本地 MBTiles 不走 HTTP,用 Tauri 命令支撑的自定义 MapLibre 协议
容器 镜像内置 uvicorn 并反向代理 /sidecar,仅当策略授予 processing:run 或 data:add 且未设 GEOLIBRE_DISABLE_SIDECAR=1 时启动

警告:客户端策略与 nginx 守卫不强制桌面端处理、浏览器 WASM、单独暴露的 sidecar,也不强制插件执行。安全边界要按部署形态分别评估(见第22章)。

23.8 PWA 与离线

23.8.1 三层缓存

独立 Web 构建是可安装的 PWA:vite-plugin-pwa(配置在 apps/geolibre-desktop/vite.config.ts)产出 webmanifest 与 Workbox service worker,src/main.tsx 把它与 installStaleChunkReload 一起注册。Service Worker 只为 Web 构建生成:Tauri 桌面端(资源已随包)与 Jupyter wheel(GEOLIBRE_EMBED=1)里 registerSW 是空操作。

层 策略 内容 为什么这样分
预缓存 Precache HTML + 启动地图必需的 JS/CSS chunk 首访后无网也能开壳;重 chunk 排除在外,避免首屏巨量下载
同源运行时 CacheFirst /assets/ 下的内容哈希产物:MapLibre、DuckDB-WASM 及其 spatial 扩展、插件 chunk 哈希文件名让 CacheFirst 安全:重新部署即新 URL,不会把旧条目当新的
CDN 引擎 CacheFirst(规则 geolibre-cdn-engines) jsDelivr 上的 Pyodide、PGlite/PostGIS、CereusDB、gdal3.js URL 里嵌精确版本号;jsDelivr 的 CORS 让响应是正常 200 而非 opaque,可校验可淘汰

所以“CDN 引擎离线可用”的准确表述是:首次成功取回需要网络,CacheFirst 只有真存下响应后才走缓存;此后离线可用。桌面端根本不注册 Service Worker,这条对它不成立。

23.8.2 更新与页面恢复:一处教训

新部署用 registerType: "autoUpdate"(skipWaiting + clientsClaim),但 Workbox 默认的“激活时强制刷新”被故意压掉(src/main.tsx 的 onNeedReload):在 /demo/ 这种相对 base 的子路径下它会误触发,把用户正在编辑的地图状态冲掉。页面恢复交给 installStaleChunkReload——只在孤儿 lazy chunk 404 时才重载,且带冷却保护;sessionStorage 被禁时跳过重载,让预加载错误照常暴露。

23.8.3 离线相关构建开关

变量 作用
GEOLIBRE_PGLITE_CDN=0 / GEOLIBRE_CEREUS_CDN=0 把 PGlite/PostGIS、CereusDB 塞回 /assets/(PGlite 一个就给 Tauri 二进制加回约 22 MB)
GEOLIBRE_GDAL_CDN=0 gdal3.js 从不打包;置 0 等于关掉依赖它的导出功能
GEOLIBRE_DUCKDB_WASM_CDN=1 DuckDB 改从 jsDelivr 取(不能与 GEOLIBRE_NO_EXTERNAL_CDN=1 同时用)
GEOLIBRE_NO_EXTERNAL_CDN=1 隐含 GEOLIBRE_PGLITE_CDN=0、GEOLIBRE_CEREUS_CDN=0、GEOLIBRE_GDAL_CDN=0、GEOLIBRE_DUCKDB_WASM_CDN=0,并禁用源码里嵌了 CDN URL 的功能(故事地图 HTML 导出、内置检测模型、ONNX WASM 等)
VITE_PYODIDE_INDEX_URL 指向自建 Pyodide 镜像(注意:除非镜像在 /assets/ 下,否则落入普通 HTTP 缓存而非 CacheFirst 规则)
VITE_DUCKDB_SPATIAL_EXTENSION_PATH 直接 LOAD 本地 spatial 扩展,跳过 INSTALL(内网/离线/桌面的关键一行)
# 把 PGlite/PostGIS 与 CereusDB 内置,去掉首次联网依赖(代价见上表)
GEOLIBRE_PGLITE_CDN=0 GEOLIBRE_CEREUS_CDN=0 npm run build

# 完全禁止 GeoLibre 控制的外部 CDN(会关掉依赖它们的若干功能)
GEOLIBRE_NO_EXTERNAL_CDN=1 npm run build

23.9 容器与安全

23.9.1 镜像启动时发生了什么

根 Dockerfile 打包的是浏览器应用:Node 构建阶段跑 geolibre-desktop workspace 构建,再把 apps/geolibre-desktop/dist 拷进 nginx;nginx 提供静态资源并对浏览器入口 URL 回退 index.html。

启动时 entrypoint 依次做五件事:

  1. 校验选定的来源策略(policy source)。
  2. 应用非空的运行时环境覆盖。
  3. 生成 nginx 守卫规则。
  4. 视策略条件启动 Python sidecar。
  5. 提供生成的公开 /deployment.json。

浏览器在首次渲染前拉取这份策略,然后按 policy → runtime environment → build environment 的顺序解析设置。镜像名是 ghcr.io/opengeos/geolibre,PR 构建、main 推送、版本 tag、手动运行都会发布。

23.9.2 CSP:两个文件、一个必须同步的清单

构建 CSP 位置 关键点
桌面(Tauri) apps/geolibre-desktop/src-tauri/tauri.conf.json script-src 钉到带版本号的 jsDelivr 路径 + blob:(外部插件用 blob URL 执行);connect-src 对 https:/http: 开放(用户随便填的数据源)
Web(Docker) docker/nginx.conf 同一份脚本清单,另有 worker-src blob:(CDN worker 需要同源 blob 包装)

tests/tauri-csp.test.ts 会重新推导这些路径,所以升级 CDN 脚本版本时,两处 CSP 必须同步改,否则测试失败。这是“别把版本写死在两个地方”的可执行版本。

23.9.3 凭据存储:三套后端、一条读取通道

后端 存哪 细节
桌面 OS 凭据库(Keychain / Credential Manager / Secret Service),服务名 org.geolibre.desktop Tauri 命令 secure_store_*(src-tauri/src/secure_store.rs);geolibre.desktopSettings 里的同名字段留空
Web / Jupyter / 移动 localStorage 插件凭据为 geolibre.pluginCredential.<pluginId>.<name>;Web OAuth refresh token 放 tab 级 sessionStorage
工程文件 不进文件 保存时问“保留明文还是剥离”,从不未经选择就写入

读取通道的设计值得单独记:lib/credential-hydration.ts 在应用渲染前把凭据载入内存,并迁移旧版本留下的明文值(每次删除都发生在其 keychain 写入成功之后)。secure_store_get_many 每个 webview 页面加载只应答一次,lib.rs 里的 reload 钩子会重新打开;而 secure_store_seal(外部插件加载器在 import 前调用)关闭读取而不读,所以插件代码拿不到已存 token。写入与删除始终开放。

两个顺序性细节:凭据索引 geolibre.postgres.connectionIds 在写入新条目之前写,崩溃不会留下无引用的凭据;启动时若发现“有索引无凭据”,删索引并告警。OAuth refresh token 按 issuer 存为 share.oauth.refreshToken.<issuer>,且 issuer 先记入非密的 geolibre.share.oauth.unsavedIssuers,写入完成后才移除——启动时发现自己被列在其中就删 token 并以登出状态启动,而不是信任一个可能已被消费的 token。

23.9.4 原生 HTTP 与 mTLS

桌面端把部分远程请求(瓦片/URL 解析、OGC GetCapabilities 等)交给 Rust 进程而不是 WebView。那条路径(src-tauri/src/lib.rs 的 guarded_http_client)除内置 Mozilla 根证书外,还信任操作系统证书库,所以企业 CA 签发的服务无需额外配置。

环境变量 用途
GEOLIBRE_HTTP_CA_CERT 额外信任的 CA PEM 包(给未装进系统库的私有 CA)
GEOLIBRE_HTTP_CLIENT_CERT 要出示的客户端证书;.pem(证书链 + 未加密 PKCS#8 私钥)或 PKCS#12(.p12/.pfx)
GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD PKCS#12 口令;它的存在也会强制走 PKCS#12 分支

警告:配置的客户端证书挂在共享的原生 HTTP 客户端上,因此任何在原生 fetch 中索要客户端证书的 HTTPS 服务器都会收到它,而不只是签发它的那个端点。只在所有目标主机都可信时配置。

23.10 源码级可复用经验

23.10.1 DuckDB-WASM Spatial:一个库同时当格式驱动和计算引擎

WebAssembly 只是“能跑原生代码”的能力;DuckDB-WASM 是 DuckDB 的 WASM 产物;spatial 扩展是另一份独立 .wasm,要单独 INSTALL/LOAD,不加载就没有任何空间函数。ST_Read 背后是扩展自带的 GDAL 子集,能读哪些取决于当前构建(SELECT * FROM ST_Drivers() 可列)。

用途 走的接口 备注
GeoParquet read_parquet 本地与远程都走它,远程按 HTTP Range
FlatGeobuf / GML / DXF / TAB 等 ST_Read GDAL 后端
Shapefile(zip) 先 shpjs,读不了再交给 Spatial 轻量优先,重武器兜底
KML 先自研解析器(保留符号化),失败再交给 Spatial Spatial 只有几何,样式会丢
CSV 里的 WKT DuckDB SQL 文本几何转图层
SQL 工作区 完整 DuckDB SQL 已加载图层注册成表,可 JOIN

四个可直接搬走的实现细节:

  1. bundle 按浏览器能力在运行时选:duckdb-wasm-bundles.ts 用 Vite 的 ?url 拿到 mvp/eh 两份产物,交给 duckdb.selectBundle();手动列 bundle 是为了让 Vite 把 wasm 与 worker 打成带哈希的本地产物,才能被 Service Worker 的 CacheFirst 安全缓存。
  2. 扩展加载按实例记忆化:缓存的是 Promise 不是布尔值(并发共享同一次加载),用 WeakMap 按 DB 实例存(实例重建即失效),失败时先校验身份再清除(避免清掉别人刚写入的新加载)。三条缺一条,生产环境就会出现“偶发的空间函数不存在”。
  3. 留一个离线口子:VITE_DUCKDB_SPATIAL_EXTENSION_PATH 配了就直接 LOAD 本地路径,跳过 INSTALL。同样的模式也用于 h3 社区扩展(INSTALL h3 FROM community)。
  4. Worker 与生命周期自己管:WASM 堆跟着 Worker 走,只有 terminate() 才真正还回系统;SQL 工作区实例做引用计数,有查询在跑就标记“空闲时销毁”。

还有一个真实的坑值得写进教科书:duckdb-wasm 1.33.1-dev45 会永久破坏一个实例的远程 read_parquet——只要该实例在做完第一次远程读取之前执行了 LOAD spatial,之后所有远程 Parquet 都报 stoi: no conversion,且无法原地恢复。GeoLibre 的应对是两手:预热(ensureSpatialExtension 的 beforeLoad 钩子先用 SELECT 1 FROM read_parquet(...) LIMIT 0 只取 footer)与重建(捕获 stoi: no conversion → resetSqlDatabase(db) 换实例 → 重试一次,且只在语句里确实有远程 reader 调用时触发)。

边界也要记住:远程文件上限 2 GiB(32 位存大小,且在开始摄取之前判定)、单标签页内存约 4 GiB、要素数确认阈值 500,000、单线程;拷贝模式内存随解压后数据量走,超过 100 MB 会提示改用流式,而真正“原地查询不拷贝”的只有 GeoParquet——所以那个按钮只对它显示。

23.10.2 阈值化:定好数字,过线换实现

常量 值 位置 保护什么
LARGE_VECTOR_FEATURE_THRESHOLD 50,000 core/src/types.ts 主线程 GeoJSON 解析
maxHistoryFeatureCount 500,000 core/src/history.ts 撤销栈内存
DUCKDB_VECTOR_FEATURE_WARN_COUNT 100,000 core/src/types.ts 结果物化内存
MAX_CEREUS_FEATURES 50,000 lib/sedona-workspace.ts WASM 堆
MAX_DERIVED_FEATURES 50,000 map/src/derived-geometry.ts 派生几何计算
historyCoalesceMs 400 ms core/src/history.ts 撤销记录爆炸

这些阈值全是命名常量 + 文档注释,且 historyCoalesceMs、maxHistoryFeatureCount 都有 setter 可运行时改(测试里设成 0)。判据很清楚:每个阈值都要说得出“它保护什么”,说不清的就难以维护。

23.10.3 客户端切片:超过 5 万要素就现场切瓦片

流水线在 packages/map/src/geojson-vt-protocol.ts(协议与注册表)与 geojson-vt-index.ts(建索引与编码):索引用 @maplibre/geojson-vt(点图层用 Supercluster),编码用 @maplibre/vt-pbf 的 fromGeojsonVt,通过自定义协议 geolibre-gjvt 喂给 MapLibre;TILE_EXTENT = 4096、TILE_MAX_ZOOM = 16(超过就让 MapLibre over-zoom);建索引与编码在 geojson-vt.worker.ts 里跑,没有 Worker 的环境下同一套代码在主线程跑。

两个细节:瓦片注册表放模块级 Map,不进 store(注释原话是“tile indexes are large, non-serializable objects that must not enter app state or be written to .geolibre.json”——这给出一个好判据:能写进工程文件的才进全局状态);编码前先看中止信号(if (signal?.aborted) return empty;),因为 MapLibre 会取消滚出屏幕的瓦片请求,结果反正会被丢掉。

23.10.4 撤销栈的三个精细处理

packages/core/src/history.ts 用 zundo 做时间旅行,但每个快照都持有图层完整 GeoJSON,反复编辑会把多份副本钉在内存里。处理方式:

  1. 软预算按要素数算,不按字节:为了度量内存去序列化几何,本身就是性能问题。
  2. 永远保留最新快照:从新往旧累计,装不下就停——但最新一个即使单独超预算也保留。“内存不够就清空撤销栈”意味着用户刚做完大编辑时恰好没有撤销。
  3. 按对象引用去重:distinctFeatureCount 用 Set 记住见过的 payload,没改动的图层在多个快照间共享同一引用只算一次,于是“保留很多小图层的快照”几乎无额外开销。

另外那个 400 ms 不是普通防抖,而是前沿防抖(leading-edge):要记录的是“这串连续操作之前”的状态,所以必须在第一次触发时就记;尾部防抖存下来的是拖拽途中的中间值。

23.10.5 虚拟化只管渲染

属性表用 @tanstack/react-virtual 虚拟化,但排序、筛选、选择跑在完整数据模型上。很多虚拟列表实现“只对已渲染的行排序”,于是排序结果随滚动变化、全选只选可见行——而这类 bug 在小数据量测试时根本复现不出来。

23.10.6 懒加载与体积账本

重资源 体积 处理
CesiumJS 约 4.7–4.8 MB 独立 chunk,切到球面才 import(),且构建门禁阻止它进启动图
PGlite + PostGIS 约 25 MB(打进桌面二进制会多约 22 MB 几乎不可压缩体积) 默认走 jsDelivr,不进构建;GEOLIBRE_PGLITE_CDN=0 可改内置
gdal3.js wasm 约 28 MB + data 约 12 MB 从不打包,只从 CDN 取;GEOLIBRE_GDAL_CDN=0 等于关功能
Pyodide / CereusDB 各数十 MB CDN 加载,后两者可用开关改成内置
h5wasm 约 4.8 MB 曾因 Worker 各自打包被发了两份;现在主构建产出显式 chunk,Worker 按 URL import,且构建在出现非唯一 hdf5_hl-*.js 时失败

配套的一条硬要求:所有外部资源地址都做成运行时可配置(VITE_PYODIDE_INDEX_URL、VITE_DUCKDB_SPATIAL_EXTENSION_PATH、VITE_CESIUM_TOKEN),CDN 地址、WASM 版本、镜像路径三样任何一样写死,等着的就是现场断网时重新打包。

23.10.7 跨语言常量的 SYNC: 标记

那 17 个可恢复矢量扩展名在两处重复:TS 的 VECTOR_FILE_DIALOG_EXTENSIONS(lib/file-io/paths.ts)与 Rust 的 RESTORABLE_VECTOR_EXTENSIONS: [&str; 17](src-tauri/src/lib.rs)——跨语言没法共享常量。处理方式是在两边都写注释,并用统一可 grep 的标记把它们钉在一起:

SYNC: RESTORABLE_VECTOR_EXTENSIONS in src-tauri/src/lib.rs must list the same
extensions, or a format added here would be rejected by the Rust restore guard
on every project reopen. Grep "SYNC:" to find the partner list.

能合并就合并;合并不了(跨语言、跨进程、跨仓库)就用统一标记钉住,并在注释里写清“不同步会出什么事”。 这比“大家注意保持一致”有用得多。

23.10.8 云原生格式:换掉的是分发架构,不只是前端库

传统链路是“数据入库 → GeoServer/ArcGIS Server 发布 → 切瓦片 → 前端请求服务”,要养一台服务器、一套发布流程、一份切片缓存,还要为它做监控、备份、扩容。云原生格式把这条路换成“一个文件 + 一个静态服务器 + HTTP Range”。

格式 它替代了什么 直读的硬前提
PMTiles 切片服务与瓦片缓存 存储支持 Range 与 CORS;单文件即整层数据
COG 影像服务/瓦片金字塔 文件内部要有分块与金字塔;改后缀不会把 TIFF 变成 COG
GeoParquet 空间数据库导出与格式转换 列式 + 行组统计,read_parquet 按 Range 取所需部分
Zarr 时序/多维栅格服务 分块存储 + 元数据 JSON;多维切片靠 selector

四条可迁移的经验:

  1. 真正的门槛不是格式转换,是 CORS 与 Range。 存储不返回 Accept-Ranges,再好的格式也只能整份下载。
  2. 目录浏览器只该做一件事:产出 URL。 多数据源接入要收敛到同一套格式读取实现,面板不要各自写解析。
  3. 对着外部 API 编程时校验解析后的结构,不要只看 response.ok——STAC/Catalog 的返回形状会变。
  4. 元数据要单独想清楚:哪些字段必须走代理(鉴权、许可、配额),哪些可以公开在静态 JSON 里。

提示:投入产出比最高的一步通常是 PMTiles——一个文件替掉一整套瓦片发布流程,很多项目一天内就能验证完。

23.11 二次开发指南

23.11.1 改 UI

目标 该动的地方
面板/对话框 apps/geolibre-desktop/src/components/(panels/、layout/、dialogs/),原语用 @geolibre/ui
菜单与命令 src/components/toolbar/constants.ts(存catalog key 而非英文串)、src/lib/commands.ts
新用户可见文案 先在 src/i18n/locales/en.json 加键,再用 t();i18next.d.ts 会把键类型化
布局方向 用 Tailwind 逻辑工具类(ms-/me-/start-/text-start),别用 ml-/left-(local/no-physical-tailwind 会告警)
第三方控件样式 src/index.css 里按控件类名收窄覆盖,不改 node_modules

23.11.2 加一个客户端处理工具

  1. 在 packages/processing/src 的对应注册表模块里加工具定义(id、分组、参数 schema、执行函数)。
  2. 若需要新引擎分支,改 vector-tool-runner.ts / wasm-tool-runner.ts,不要在注册表里写引擎判断。
  3. 在 packages/processing/src/index.ts 导出。
  4. 重生成 i18n 基线:npm run i18n:tools(只重写 en.json 的 processing.toolMeta/toolGroup),i18n:tools:check 在 CI 防漂移。
  5. 加测试:工具函数放纯模块,tests/ 里直接 import 测;重计算的走 Worker 时,测试主线程侧的协议层。

23.11.3 加插件

见第21章 21.4.1 的三步(实现 → 从 packages/plugins/src/index.ts 导出 → 加入 usePlugins.ts 的 BUILT_IN_PLUGINS)。改完记得跑 npm run plugins:docs 并更新 scripts/plugin-reference.json 里的一行描述。

23.11.4 加一种渲染引擎

这是最大的一类改动,顺序建议如下:

  1. 先定能力边界:新引擎支持哪些图层类型、哪些交互、哪些插件面。写进能力声明,让壳层据此门控,而不是“先接上再说”。
  2. 实现 MapEngine:相机、能力查询、拾取、控件、放置、范围绘制、截图。
  3. 新建引擎文件:packages/map/src/<engine>-*.ts,并只 import 中性模块;跨引擎 import 会被 local/no-cross-engine-imports 拦。
  4. 接两个选择器:主画布走 primaryRenderer,分屏走 SecondaryMapView.viewKind。
  5. 接图层同步:仿 CesiumLayerSync 写一个 syncLayers 等价物,复用同一份按 pane 的可见性覆盖与组效果。
  6. 控件宿主:若引擎不能直接跑 MapLibre 控件,就提供“记录式地图”(参考 CesiumControlHost / ArcGIS 的同一套思路)。
  7. 门控:把不支持的菜单项灰掉,而不是让它点了没反应。
  8. 测试与文档:tests/<engine>-*.test.ts,以及在 docs/ 里加一篇 <engine>-renderer.md。

23.11.5 加一个构建开关

步骤 说明
加进 BUILD_ENV_KEYS apps/geolibre-desktop/vite.config.ts 里的白名单;不在名单里的 VITE_ 变量会被 pruneBuildEnv() 删掉,表现为“总是 undefined”
判断是否敏感 需要认证/计费的放进 CREDENTIAL_ENV_KEYS;可再分发构建(GEOLIBRE_EMBED=1 或 GEOLIBRE_STRIP_CREDENTIALS=1)里它们会被置空(不是删除,避免 .env 重新引入)
读取方式 用 getBuildEnvironment()(@geolibre/core),不要自己读 import.meta.env——整对象读取会让 Vite 把整份 env 内联进每个触达的 chunk
运行时覆盖 需要运行时可改的,走 getRuntimeEnvironment()(覆盖 window.__GEOLIBRE_RUNTIME_ENV__)
策略面 若是部署能力,还要在 capabilities 与策略文档里登记

23.11.6 打补丁与发布包

场景 做法
修第三方包的 bug patches/ 下放 patch,postinstall 跑 scripts/apply-dependency-patches.mjs 应用
上游未导出、被手抄的内部实现 在 docs/maintenance.md 里登记(“private internals”“mirrored by hand”),升级依赖前先读那一节并跑前端测试;漂移会静默失败
发布 @geolibre/core / @geolibre/map GitHub Release 由 publish-packages.yml 发布;仓库里包的 main/types 指向 TS 源码(Vite/tsc/tsx 都走包自己的 exports),npm tarball 走 dist,靠 publishConfig + scripts/prepare-npm-package.mjs 在上传前抬升字段;tests/prepare-npm-package.test.ts 守住两侧
内置 sidecar backend/geolibre_server/uv.lock 必须提交(根 .gitignore 对它做了例外);桌面端用 uv run --frozen --project <resource dir> 启动,无锁文件会尝试写入只读目录并 exit 2

警告:不要为了“让 CI 绿”去改 dist 指向。把顶层入口指到 dist 会让每个 import @geolibre/map 子路径的前端测试 ERR_MODULE_NOT_FOUND——dist 是 gitignore 的,测试套件之前没有任何构建步骤。

23.11.7 加一种图层类型

新增 LayerType 是一次“横切”改动,按下面的顺序做可以少返工:

步骤 位置 检查点
1. 加类型常量 core/src/types.ts 的 LAYER_TYPES 由数组派生 LayerType,运行时校验自动生效
2. 定义记录字段 GeoLibreLayer 的 source/metadata 必须可 JSON 序列化;不要塞函数或 SDK 对象
3. 工程读写 工程 schema 与 normalize* 老工程缺字段要能补默认值
4. 引擎同步 map/src/layer-sync.ts + 各引擎适配器 不支持的引擎要保留记录并标记不可绘制,不能静默丢弃
5. 面板与样式 图层面板行、Style 面板分支 不可绘制的类型不该出现点了没反应的控件
6. 数据入口 Add Data 对话框/插件 API 走 addGeoJsonLayer 或对应 add*Layer,别直接 addSource
7. 测试 tests/<type>-*.test.ts 至少覆盖“进 store → 出 paint/source”的一段

23.12 调试技巧

23.12.1 分层定位

现象 先看哪里
地图上没东西,但 store 里有图层 MapController.syncLayers 与该图层类型的 sync 分支;瓦片类先看网络面板
面板不更新 是否用了窄钩子(useLayer/useLayerSummaries);是否在 act() 外改了 store
换引擎后功能消失 该功能的 engines/能力声明;壳层是否把它门控掉了
构建报“启动包超预算” bundle-report/bundle-report.json 里的最大启动 chunk;多半是 CODE_SPLITTING_GROUPS/manualChunks 把懒包拖上了启动路径
插件相关问题 第21章 21.15 的故障速查表

23.12.2 内置诊断模块

apps/geolibre-desktop/src/lib/diagnostics.ts 是统一诊断采集层:

导出 作用
DiagnosticCategory "app" \| "console" \| "map" \| "network" \| "runtime"
DiagnosticLevel "error" \| "info" \| "warning"
appendDiagnostic / clearDiagnostics 写入/清空记录(带 id 与时间戳)
getDiagnosticsSnapshot / useDiagnosticsSnapshot 取快照 / React 订阅
installDiagnosticsCapture 安装采集(返回卸载函数)
observeNetworkResponses / setCaptureNetworkInfo 网络响应观察与开关
EXPECTED_STATUS_HEADER / OPTIONAL_RESOURCE_HEADER 与第21章 21.9.4 对应的两个客户端头常量

它把 4xx/5xx 与失败请求记为 error,把标了“预期状态”的记为 info——所以插件里那些“正常返回 400”的协议不会污染错误列表。

23.12.3 常用验证组合

npm run dev                 # 快速迭代 UI 与 store 逻辑
npm run tauri:dev           # 必须验证文件对话框、原生 HTTP、凭据库时
npm run typecheck:fast      # 纯类型检查(不写 dist)
npm run build && npm run bundle:report   # 体积账本
npm run test:frontend -- tests/<name>.test.ts   # 单个前端测试文件
npm run test:e2e:core       # 建站并跑核心 e2e(需先装 chromium)

npm run test:frontend -- tests/<name>.test.ts 在 node --import tsx --test 下只会跑指定文件;直接写 node --import tsx --test tests/<name>.test.ts 也行。e2e 默认在 4173 端口用 vite preview 起构建产物,本地会复用已监听的服务器(E2E_PORT 可改端口)。

23.12.4 在测试里复现 store 场景

组件与 store 的测试不需要真实地图:tests/helpers/dom.ts 装好 happy-dom、注册 Vite 风格的 loader 钩子、并用 en.json 初始化 i18n;状态用 useAppStore.setState(...) 直接铺,断言看用户可见文本或 store 结果。地图相关的行为应尽量挪到 lib/ 的纯逻辑里测——一个需要真地图才能测的面板,本身就是设计信号。

23.13 本章小结

  • 结构是“一个 monorepo + 两个 Python 组件”:apps/(壳)、packages/(领域)、workers/(边缘)、backend/ 与 python/。
  • 数据流是单向的:入口 → DuckDB-WASM 转换 → addGeoJsonLayer/GeoLibreLayer → 引擎订阅 → syncLayers。UI 不直接改地图。
  • @geolibre/core 不依赖 maplibre-gl;常量数组派生类型、极小的 MapViewState、切片化 store 与 partializeHistory 共同支撑“可序列化、可多消费者”。
  • 四引擎共用一个 MapEngine 抽象与两份选择器(primaryRenderer、SecondaryView.viewKind),反跨引擎 import 由 ESLint 强制。
  • Processing 分层为“注册表 / 执行器 / 引擎客户端 / Worker”;矢量工具在三引擎间共享同一份 vector_ops.py,结果必然一致。
  • sidecar 是可选的重活后端:127.0.0.1:8765,浏览器经 /sidecar 同源代理,路径限制在 GEOLIBRE_CONVERSION_ROOTS。
  • 离线靠三层 Workbox 缓存;桌面端不注册 Service Worker;自动更新不强制刷新页面。
  • 安全的三根柱子:CSP(两处必须同步)、凭据存储(一次读取 + seal)、原生 HTTP 信任库与 mTLS。
  • 值得复用的源码经验:按实例记忆化 Promise、阈值化、客户端切片 + Abort、撤销栈三处理、虚拟化只管渲染、懒加载账本、SYNC: 标记。

23.14 实践练习

  1. 追踪一条数据流:用 npm run dev 加一个 5 万要素以上的 GeoJSON,在 addGeoJsonLayer、MapController.syncLayers、geojson-vt-protocol.ts 三处下断点,记录触发顺序,并说明为什么第 4 步之后才有瓦片请求。
  2. 写一个窄订阅:给图层面板加一个只显示“可见图层数”的小组件,要求只用 useLayerSummaries()(不得直接订阅 layers),并用 React DevTools 验证其它字段变化时它不重渲染。
  3. 加一个处理工具:在 packages/processing/src 加一个“按字段去重”的矢量工具,跑 npm run i18n:tools,再补一个 tests/ 里的纯函数测试。
  4. 读撤销栈:构造 3 个各 20 万要素的图层并连续改样式 20 次,观察内存与 maxHistoryFeatureCount 的行为;解释“为什么最新快照一定保留”。
  5. 查一次 CSP 漂移:把 tauri.conf.json 里某个 CDN 路径的版本号改错,跑 npm run test:frontend -- tests/tauri-csp.test.ts,记录失败信息,再说明 docker/nginx.conf 为什么也要改。
  6. 算一次体积账:npm run build && npm run bundle:report,找出启动集合里最大的三个 chunk,并判断它们是否“必须在启动路径上”。

23.15 延伸阅读