策略模式实战:GeoAI-UP中MVT发布器的可扩展架构设计

写在前面

做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坐标点...

关键需求:

  1. 按需生成:用户请求哪个瓦片才生成哪个,不预先生成所有瓦片
  2. 性能优先 :PostGIS要用原生ST_AsMVT(),GeoJSON要用内存缓存
  3. 易于扩展:增加新数据源时,不影响现有代码

二、策略模式的核心思想

策略模式的本质就一句话:把算法封装起来,让它们可以互相替换。

在我们的场景里:

  • 算法 = 不同数据源的MVT生成逻辑
  • 替换 = 运行时根据数据源类型自动选择对应策略

标准结构

策略模式有三个核心角色:

  1. Strategy(策略接口):定义统一的调用方式
  2. ConcreteStrategy(具体策略):实现不同算法
  3. 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这个场景下,它确实帮我们解决了实际问题:

  1. 代码可维护性提升:每个策略平均100-200行,易于理解和修改
  2. 扩展成本降低:新增数据源支持只需实现一个接口
  3. 测试覆盖率提高:每个策略独立测试,覆盖率从60%提升到85%
  4. 团队协作顺畅:不同成员可以并行开发不同策略

最后给个建议:不要为了用模式而用模式。我们最初也是简单的if-else,随着数据源类型增加到3种以上,才意识到需要重构。如果你的项目目前只有一种数据源,先别急着上策略模式,等业务发展了再说。


参考资料

欢迎交流:如有问题或建议,欢迎在评论区留言讨论

相关推荐
散修-小胖子1 小时前
Milvus 2.6 架构快速上手
架构·milvus
把你微分微掉1 小时前
6G研究热点:五大可重构天线技术与未来方向
人工智能·信息与通信
科研前沿1 小时前
深耕像素实景重构,夯实视频孪生技术根基——锻造硬核底层能力,铸就镜像视界行业标杆
大数据·人工智能·数码相机·机器学习·重构
2603_954708311 小时前
微电网对等控制架构:多代理系统的协调运行与自主决策
人工智能·物联网·架构·系统架构·能源
AI_Auto1 小时前
【转载】- 欧美制造企业AI+PLM现状及意向调研白皮书
大数据·人工智能·制造
AI搅拌机1 小时前
LoRA训练自动化打标系统重磅发布!支持Qwen3.5破限和NSFW,功能覆盖图片视频音乐全自动打标
人工智能·自动化·音视频
heimeiyingwang1 小时前
【架构实战】Nginx七层负载均衡:从配置到原理,从入门到精通
nginx·架构·负载均衡
wangqiaowq1 小时前
@CrossOrigin 是 Spring 提供的跨域支持注解,但不允许携带凭证
人工智能
空中海1 小时前
04 Stage 模型、系统能力与数据架构
架构·鸿蒙