写在前面
做GIS开发这些年,遇到过最头疼的问题之一就是:如何优雅地支持多种数据源?
早期项目里,代码长这样:
typescript
if (dataSource.type === 'postgis') {
// PostGIS处理逻辑,200行
} else if (dataSource.type === 'geojson') {
// GeoJSON处理逻辑,150行
} else if (dataSource.type === 'shapefile') {
// Shapefile处理逻辑,180行
}
// 每加一个新类型,就要改这里...
这段代码有几个致命问题:
- 违反开闭原则:每次新增数据源都要修改核心逻辑
- 难以测试:一个函数承担太多职责
- 团队协作困难:多人同时修改容易冲突
在GeoAI-UP项目中,我们用策略模式重构了MVT(Mapbox Vector Tiles)发布器,彻底解决了这个问题。今天就把这套实践分享出来。
一、先说清楚:我们要解决什么问题
GeoAI-UP是一个AI驱动的地理空间分析平台,需要把各种格式的矢量数据转换成MVT瓦片,供前端地图渲染。
支持的数据源包括:
- PostGIS数据库:企业级应用最常见的选择
- GeoJSON文件:轻量级、易调试
- Shapefile:传统GIS系统的标准格式
- 未来可能扩展:GeoPackage、KML、CSV坐标点...
关键需求:
- 按需生成:用户请求哪个瓦片才生成哪个,不预先生成所有瓦片
- 性能优先 :PostGIS要用原生
ST_AsMVT(),GeoJSON要用内存缓存 - 易于扩展:增加新数据源时,不影响现有代码

二、策略模式的核心思想
策略模式的本质就一句话:把算法封装起来,让它们可以互相替换。
在我们的场景里:
- 算法 = 不同数据源的MVT生成逻辑
- 替换 = 运行时根据数据源类型自动选择对应策略
标准结构
策略模式有三个核心角色:
- Strategy(策略接口):定义统一的调用方式
- ConcreteStrategy(具体策略):实现不同算法
- Context(上下文):持有策略引用,对外提供统一接口
听起来抽象?看代码就明白了。
三、GeoAI-UP的实现细节
3.1 定义策略接口
这是整个设计的基石:
typescript
// server/src/utils/publishers/base/MVTTStrategies/MVTTileGenerationStrategy.ts
export interface MVTTileGenerationStrategy {
/**
* 生成瓦片集(初始化阶段调用一次)
*/
generateTiles(
sourceReference: string, // 数据源标识(文件路径/表名/连接字符串)
dataSourceType: DataSourceType, // 数据类型
nativeData: NativeData, // 完整的元数据
options: MVTTileOptions // 生成选项(缩放级别、容差等)
): Promise<string>; // 返回tilesetId
/**
* 按需获取单个瓦片(每次前端请求都调用)
*/
getTile?(
tilesetId: string,
z: number, x: number, y: number
): Promise<Buffer | null>; // 返回PBF二进制数据
}
关键点:
generateTiles只在首次发布时调用,负责创建瓦片集元数据getTile是高频调用,必须高效,支持按需生成- 可选的
getTile方法允许某些策略只支持预生成模式
3.2 实现具体策略
PostGIS策略:利用数据库原生能力
typescript
// server/src/utils/publishers/base/MVTTStrategies/PostGISMVTTStrategy.ts
export class PostGISMVTTStrategy implements MVTTileGenerationStrategy {
private postgisPools: Map<string, Pool> = new Map();
private tileGenerator: PostGISTileGenerator;
async generateTiles(
sourceReference: string,
dataSourceType: DataSourceType,
nativeData: NativeData,
options: MVTTileOptions
): Promise<string> {
// 1. 解析连接信息
const connectionInfo = PostGISConnectionParser.parse(
sourceReference,
nativeData.metadata
);
// 2. 创建连接池(复用,避免频繁建连)
const pool = await this.tileGenerator.createPool({
host: connectionInfo.host,
port: connectionInfo.port,
database: connectionInfo.database,
user: connectionInfo.user,
password: connectionInfo.password,
schema: connectionInfo.schema
});
// 3. 缓存连接池和元数据
const tilesetId = `mvt_postgis_${Date.now()}_${randomId()}`;
this.postgisPools.set(tilesetId, pool);
// 4. 保存元数据到文件(服务重启后可恢复)
this.saveMetadata(tilesetId, {
strategy: 'postgis',
connectionMetadata: nativeData.metadata.connection,
geometryColumn: nativeData.metadata.geometryColumn || 'geom'
});
return tilesetId;
}
async getTile(
tilesetId: string,
z: number, x: number, y: number
): Promise<Buffer | null> {
// 1. 从缓存或文件恢复连接池
const pool = await this.restorePool(tilesetId);
if (!pool) return null;
// 2. 使用ST_AsMVT()直接生成瓦片(核心!)
const result = await this.tileGenerator.generateTile(
pool, z, x, y,
{ tableName: ..., sqlQuery: ... },
{ extent: 4096, layerName: 'default' }
);
return result.success ? result.tileBuffer : null;
}
}
为什么快?
- PostGIS的
ST_AsMVT()是C语言实现的,比任何JS库都快 - 利用数据库的空间索引,只查询当前瓦片范围内的要素
- 连接池复用,避免TCP握手开销
GeoJSON策略:内存缓存+动态切片
typescript
// server/src/utils/publishers/base/MVTTStrategies/GeoJSONMVTTStrategy.ts
export class GeoJSONMVTTStrategy implements MVTTileGenerationStrategy {
private tileIndexCache: Map<string, any> = new Map();
async generateTiles(
sourceReference: string,
dataSourceType: DataSourceType,
nativeData: NativeData,
options: MVTTileOptions
): Promise<string> {
// 1. 读取GeoJSON文件
const geojson = JSON.parse(
fs.readFileSync(sourceReference, 'utf-8')
);
// 2. 用geojson-vt构建瓦片索引(一次性操作)
const tileIndex = geojsonvt(geojson, {
maxZoom: options.maxZoom || 14,
tolerance: options.tolerance || 3,
extent: options.extent || 4096
});
// 3. 缓存索引
const tilesetId = `mvt_geojson_${Date.now()}_${randomId()}`;
this.tileIndexCache.set(tilesetId, tileIndex);
return tilesetId;
}
async getTile(
tilesetId: string,
z: number, x: number, y: number
): Promise<Buffer | null> {
const tileIndex = this.tileIndexCache.get(tilesetId);
if (!tileIndex) return null;
// 1. 从索引中提取指定瓦片
const tile = tileIndex.getTile(z, x, y);
if (!tile) return null;
// 2. 转换为PBF格式
const pbf = vtPbf.fromGeojsonVt(
{ default: tile }, // 图层名固定为'default'
{ extent: 4096 }
);
return Buffer.from(pbf);
}
}
优化点:
geojson-vt只执行一次,后续都是O(1)查找- 内存占用可控:索引大小约为原始GeoJSON的1.5倍
- 适合中小数据集(< 10万要素)
Shapefile策略:转换层适配
typescript
export class ShapefileMVTTStrategy implements MVTTileGenerationStrategy {
async generateTiles(...) {
// 1. 通过DataAccessor读取Shapefile
const accessor = DataAccessorFactory.create('shapefile');
const geojson = await accessor.read(sourceReference);
// 2. 委托给GeoJSON策略处理
const geojsonStrategy = new GeoJSONMVTTStrategy(...);
return geojsonStrategy.generateTiles(
tempGeojsonPath, 'geojson',
{ ...nativeData, reference: tempGeojsonPath },
options
);
}
}
设计思路:不重复造轮子,Shapefile转GeoJSON后复用已有逻辑。
3.3 上下文类:统一管理策略
typescript
// server/src/utils/publishers/MVTStrategyPublisher.ts
export class MVTStrategyPublisher extends BaseMVTPublisher {
private strategies: Map<DataSourceType, MVTTileGenerationStrategy>;
constructor(workspaceBase: string, db?: Database.Database) {
super(workspaceBase, 'mvt');
// 注册内置策略
this.strategies = new Map();
this.registerStrategy('geojson', new GeoJSONMVTTStrategy(...));
this.registerStrategy('shapefile', new ShapefileMVTTStrategy(...));
if (db) {
this.registerStrategy('postgis', new PostGISMVTTStrategy(..., db));
}
}
/**
* 注册新策略(支持插件扩展)
*/
registerStrategy(type: DataSourceType, strategy: MVTTileGenerationStrategy) {
this.strategies.set(type, strategy);
console.log(`[MVT Publisher] Registered strategy for: ${type}`);
}
/**
* 统一入口:发布瓦片集
*/
async publish(nativeData: NativeData, options: MVTTileOptions = {}) {
// 1. 根据数据类型选择策略
const strategy = this.getStrategy(nativeData.type);
// 2. 委托给策略执行
const tilesetId = await strategy.generateTiles(
nativeData.reference,
nativeData.type,
nativeData,
options
);
return {
success: true,
tilesetId,
serviceUrl: `/api/services/mvt/${tilesetId}/{z}/{x}/{y}.pbf`,
metadata: this.getMetadata(tilesetId)
};
}
/**
* 统一入口:获取单个瓦片
*/
async getTile(tilesetId: string, z: number, x: number, y: number) {
// 1. 从元数据文件中读取策略类型
const metadata = this.readMetadata(tilesetId);
const strategy = this.strategies.get(metadata.strategy);
// 2. 委托给策略执行
if (strategy?.getTile) {
return strategy.getTile(tilesetId, z, x, y);
}
return null;
}
private getStrategy(type: DataSourceType): MVTTileGenerationStrategy {
const strategy = this.strategies.get(type);
if (!strategy) {
throw new Error(`No strategy registered for: ${type}`);
}
return strategy;
}
}
关键设计:
- 单例模式:全局只有一个Publisher实例,保证策略注册的一致性
- 策略路由 :通过
metadata.strategy字段找到对应的策略 - 开放扩展 :
registerStrategy方法允许外部注册自定义策略
四、实际效果对比
重构前(硬编码方式)
typescript
class OldMVTPublisher {
async getTile(tilesetId: string, z: number, x: number, y: number) {
const metadata = this.readMetadata(tilesetId);
if (metadata.type === 'postgis') {
// 300行PostGIS逻辑
const pool = await createPool(metadata.connection);
const sql = `SELECT ST_AsMVT(...)`;
const result = await pool.query(sql);
return result.rows[0]?.tile;
} else if (metadata.type === 'geojson') {
// 200行GeoJSON逻辑
const geojson = readFileSync(metadata.path);
const tileIndex = geojsonvt(geojson);
const tile = tileIndex.getTile(z, x, y);
return vtPbf.fromGeojsonVt(tile);
} else if (metadata.type === 'shapefile') {
// 250行Shapefile逻辑
const geojson = convertShapefileToGeojson(metadata.path);
// ... 重复GeoJSON的逻辑
}
throw new Error('Unsupported type');
}
}
问题:
- 一个方法超过700行,阅读困难
- 新增KML支持要改这个方法,风险高
- 无法单独测试某种数据源的逻辑
重构后(策略模式)
typescript
class MVTStrategyPublisher {
async getTile(tilesetId: string, z: number, x: number, y: number) {
const metadata = this.readMetadata(tilesetId);
const strategy = this.strategies.get(metadata.strategy);
return strategy?.getTile?.(tilesetId, z, x, y) || null;
}
}
优势:
- 核心逻辑只有5行
- 新增KML策略只需实现接口并注册,无需修改现有代码
- 每个策略可独立测试
五、高级玩法:让策略更灵活
5.1 热插拔策略
GeoAI-UP支持插件系统,第三方开发者可以注册自定义策略:
typescript
// 插件开发者实现自定义策略
class CustomKMVStrategy implements MVTTileGenerationStrategy {
async generateTiles(...) { /* KML解析逻辑 */ }
async getTile(...) { /* KML瓦片生成逻辑 */ }
}
// 在插件初始化时注册
const publisher = MVTStrategyPublisher.getInstance(workspaceBase, db);
publisher.registerStrategy('kml', new CustomKMVStrategy());
应用场景:
- 企业内部特殊格式支持
- 实验性数据源快速验证
- A/B测试不同生成算法
5.2 策略组合与Fallback
某些场景下,可能需要多个策略协作:
typescript
async getTileWithFallback(tilesetId: string, z: number, x: number, y: number) {
const primaryStrategy = this.strategies.get('postgis');
const fallbackStrategy = this.strategies.get('geojson');
// 优先用PostGIS
let tile = await primaryStrategy?.getTile?.(tilesetId, z, x, y);
// 失败则降级到GeoJSON缓存
if (!tile && fallbackStrategy) {
console.warn('PostGIS failed, falling back to GeoJSON cache');
tile = await fallbackStrategy.getTile(tilesetId, z, x, y);
}
return tile;
}
适用场景:
- 数据库故障时的应急方案
- 冷启动时先用缓存,后台异步生成
- 不同精度级别的策略切换
5.3 监控与指标收集
在策略执行前后埋点,方便性能分析:
typescript
async getTileWithMetrics(tilesetId: string, z: number, x: number, y: number) {
const startTime = Date.now();
const strategy = this.getStrategyForTileset(tilesetId);
try {
const tile = await strategy.getTile(tilesetId, z, x, y);
// 记录成功指标
metrics.histogram('mvt_tile_generation_time', Date.now() - startTime, {
strategy: strategy.name,
zoom: z,
success: 'true'
});
return tile;
} catch (error) {
// 记录失败指标
metrics.increment('mvt_tile_errors', {
strategy: strategy.name,
error_type: error.constructor.name
});
throw error;
}
}
六、踩过的坑与经验总结
坑1:策略状态管理
问题:PostGIS策略的连接池如果放在局部变量,每次请求都会重新创建连接。
解决:用Map缓存连接池,key为tilesetId:
typescript
private postgisPools: Map<string, Pool> = new Map();
// 生成时保存
this.postgisPools.set(tilesetId, pool);
// 获取时复用
const pool = this.postgisPools.get(tilesetId);
坑2:服务重启后策略丢失
问题:内存中的策略缓存在服务重启后清空,但客户端仍持有旧的tilesetId。
解决:元数据持久化到文件,重启后从文件恢复:
typescript
// 生成时保存完整元数据
fs.writeFileSync(metadataPath, JSON.stringify({
strategy: 'postgis',
connectionMetadata: nativeData.metadata.connection,
generatedAt: new Date().toISOString()
}));
// 获取时先检查缓存,没有则从文件恢复
if (!this.tileIndexCache.has(tilesetId)) {
const metadata = JSON.parse(fs.readFileSync(metadataPath));
await this.restoreStrategyFromMetadata(metadata);
}
坑3:TypeScript类型收窄
问题 :strategy.getTile是可选方法,直接调用会报错。
解决:用可选链+类型守卫:
typescript
if (strategy?.getTile) {
return strategy.getTile(tilesetId, z, x, y);
}
七、什么时候该用策略模式?
根据我们的实践经验,满足以下条件时强烈建议用策略模式:
✅ 有多个算法实现同一功能
- 如:不同数据源的MVT生成
- 如:不同支付方式的订单处理
- 如:不同云存储提供商的文件上传
✅ 算法需要频繁切换或扩展
- 新增算法不应该修改现有代码
- 运行时可能需要动态切换算法
✅ 算法逻辑复杂,需要独立测试
- 每个策略可以单独写单元测试
- 便于A/B测试不同算法的性能
❌ 不要用策略模式的情况:
- 只有一种实现,且短期内不会增加
- 算法之间差异极小(用参数配置即可)
- 团队规模小,过度设计反而增加维护成本
八、与其他模式的对比
vs 工厂模式
工厂模式 关注的是"如何创建对象",策略模式关注的是"如何选择算法"。
在GeoAI-UP中,两者结合使用:
- 工厂模式:
DataAccessorFactory.create(type)创建数据访问器 - 策略模式:
MVTStrategyPublisher选择不同的瓦片生成策略
vs 模板方法模式
模板方法 通过继承实现代码复用,策略模式通过组合实现算法替换。
我们的选择理由:
- 策略模式更符合"组合优于继承"原则
- 策略可以运行时切换,模板方法不行
- TypeScript中对接口的支持比对抽象类的支持更好
九、完整架构图
┌──────────────────────────────────────┐
│ API Controller │
│ MVTServiceController.serveTile() │
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Context (Publisher) │
│ MVTStrategyPublisher.getTile() │
│ ┌────────────────────────────────┐ │
│ │ strategies: Map<Type, Strategy│ │
│ └────────┬───────────────────────┘ │
└───────────┼──────────────────────────┘
│ 根据metadata.strategy选择
▼
┌──────────────────────────────────────┐
│ Strategy Interface │
│ MVTTileGenerationStrategy │
│ - generateTiles() │
│ - getTile() │
└──┬────────────┬──────────────┬───────┘
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌──────────────┐
│PostGIS │ │GeoJSON │ │ Shapefile │
│Strategy│ │Strategy│ │ Strategy │
│ │ │ │ │(delegates to │
│ST_AsMVT│ │geojson-│ │ GeoJSON) │
│ () │ │ vt │ │ │
└────────┘ └────────┘ └──────────────┘
十、结语
策略模式不是银弹,但在GeoAI-UP这个场景下,它确实帮我们解决了实际问题:
- 代码可维护性提升:每个策略平均100-200行,易于理解和修改
- 扩展成本降低:新增数据源支持只需实现一个接口
- 测试覆盖率提高:每个策略独立测试,覆盖率从60%提升到85%
- 团队协作顺畅:不同成员可以并行开发不同策略
最后给个建议:不要为了用模式而用模式。我们最初也是简单的if-else,随着数据源类型增加到3种以上,才意识到需要重构。如果你的项目目前只有一种数据源,先别急着上策略模式,等业务发展了再说。
参考资料:
- GeoAI-UP源码:https://gitee.com/rzcgis/geo-ai-universal-platform
- 《设计模式:可复用面向对象软件的基础》- GoF
欢迎交流:如有问题或建议,欢迎在评论区留言讨论