- 后端
- 前端
- 数据分析
- 数据可视化
【免费下载链接】growthbook
Open Source Feature Flags, Experimentation, and Product Analytics
本文基于 GrowthBook 后端仓库中的迁移指南 legacy-model-migration-patterns.md,讲解如何把一个基于 Mongoose 的旧模型类重构为基于BaseModel/MakeModelClass的新模型体系。读完后,你将掌握迁移的完整步骤——从 zod schema 校验器编写、权限钩子实现、context 注册,到dangerous静态方法与migrate数据兼容层的处理,并理解每一步背后 BaseModel 源码 的真实机制,避免踩中指南中警告的多个隐蔽 bug 高发点。
一、迁移背景:为什么要从 Mongoose 迁到 BaseModel
GrowthBook 的后端模型正在从传统的 Mongoose 模式(mongoose.schema+ 文件内导出一堆自由函数)迁移到统一的 BaseModel 抽象。旧模式下,模型文件里混杂着 schema 定义、权限判断和大量导出的 helper 函数,数据库层关注点经常泄漏到调用方;新模式下,每个模型通过MakeModelClass(config)拿到一个预置了配置、校验器与 CRUD 骨架的抽象基类,再叠加业务权限逻辑。
指南开宗明义指出:迁移"容易因为 diff 内外代码的相互作用而产生难以捕捉的 bug"。当前仓库中已有 60 余个模型完成了迁移(如 TeamModel、WebhookModel、ConfigModel),本文按指南的四个步骤逐一拆解,并用源码印证每个风险点。
二、第一步:创建模型类(MakeModelClass 配置)
指南给出的起点是定义const BaseClass = MakeModelClass({ ... })并填充配置。以 TeamModel.ts 为例,真实的配置长这样:
const COLLECTION = "teams"; const BaseClass = MakeModelClass({ schema: teamSchema, collectionName: COLLECTION, idPrefix: "team_", globallyUniquePrimaryKeys: false, readonlyFields: [], additionalIndexes: [], defaultValues: { createdBy: "", limitAccessByEnvironment: false, environments: [], managedByIdp: false, }, apiConfig: { modelKey: "teams", openApiSpec: teamApiSpec, customHandlers: [ /* 自定义 API 端点 */ ], }, });MakeModelClass是 BaseModel.ts 末尾 定义的工厂函数:它接收ModelConfig,内部调用createSchema/updateSchema生成创建与更新的 zod 校验器(自动剔除organization、dateCreated、dateUpdated及主键字段),并返回一个实现了getConfig()/getCreateValidator()/getUpdateValidator()的抽象类。业务模型只需再export class MyModel extends BaseClass补上权限方法即可。
2.1 先备好 zod 校验器(指南的第一个 ⚠️)
指南明确警告:如果模型在shared/validators中还没有校验器,先创建一个;如果shared/types中的 Interface 是原生 TypeScript 写的,应转换为z.infer<typeof yourSchema>,并确保 schema 产生的输出接口与原接口一致(尤其是可选字段);最后确认 zod schema 覆盖了原mongoose.schema的所有字段。
schema 的基座类型定义在 base-model.ts:
export type BaseSchemaWithPrimaryKey<PKey extends z.ZodRawShape> = z.ZodObject<...>;这一约束要求 schema 必须是带主键的zod.object,因为BaseModel的全部查询/更新/删除都依赖主键过滤。注意指南强调的"可选字段一致性"在源码中有对应机制:BaseModel的_stripLegacyNullFields会在读取时把"旧写入序列化成null的可选字段"还原为"不存在",从而让新旧数据无需一次性数据迁移即可兼容——前提是 schema 里这些字段的 optional 语义与原 mongoose schema 一致。
2.2 核对 collectionName 与 additionalIndexes(指南的第二个 ⚠️)
指南要求"双重确认collectionName和additionalIndexes与现有行为一致(例如唯一字段)"。这不是客套话:collectionName直接决定读写哪个 MongoDB 集合;而additionalIndexes中的unique约束承担着跨请求的防重职责。从 ModelConfig 类型定义 可以看到,索引配置支持fields、unique、sparse、expireAfterSeconds(TTL)、name与partialFilterExpression(部分索引,可实现"子集唯一"约束)——如果旧模型在 Mongoose 时代建过部分唯一索引,迁移时必须用name+partialFilterExpression原样复刻,否则会出现重复数据或索引删不掉(indexesToRemove只按名字移除旧索引)。
ModelConfig还有几个与迁移强相关的选项,值得在迁移时逐个核对:
pKey:主键字段元组。默认["id"];复合主键场景(如["userId", "organization"])必须显式声明,它影响查询、更新、删除和索引创建;affectsDefinitionsVersion:为true时,成功写入会 bump 组织的 definitions 版本,使缓存的/organization/definitions响应失效。如果旧模型的数据会被该接口读取而新配置漏掉了这个开关,SDK 端会拿到过期定义;skipDateUpdatedFields/definitionsVersionExcludedFields:控制哪些字段变更不触发dateUpdated或版本 bump。
2.3 模型类骨架与权限方法(指南的第三个 ⚠️)
指南给出的最小模型骨架:
export class MyModel extends BaseClass { protected canCreate(): boolean { return true; } protected canRead(): boolean { return true; } protected canUpdate(): boolean { return true; } protected canDelete(): boolean { return true; } }并警告:"这些权限检查是常见的 bug 来源之一。应填入this.context.permissions中合适的 helper,某些代码路径可能需要覆写。"
从 BaseModel 源码 看,这四个方法是abstract的,子类必须实现,且签名与指南示例略有差异——它们接收文档参数:
protected abstract canRead(doc: z.infer<T>): boolean; protected abstract canCreate(doc: z.infer<T>): boolean; protected abstract canUpdate( existing: z.infer<T>, updates: PKeyUpdateProps<T, PKey, PK>, newDoc: z.infer<T>, ): boolean; protected abstract canDelete(existing: z.infer<T>): boolean;这些钩子在写路径上被强制调用:create检查canCreate(L1160 附近),update检查canUpdate(L1321 附近),delete检查canDelete(L1495 附近)。而读路径中,filterByReadPermissions会先populateForeignRefs再逐条执行canRead(L431-L447)——这意味着canRead内部引用的外键(如实验、数据源)必须已被填充,否则判断会出错。
真实的权限实现可以参考 TeamModel:
protected canCreate(doc: TeamInterface): boolean { return this.context.permissions.canCreateTeam(doc); } protected canRead(): boolean { // Teams 不做项目隔离,且参与构建用户权限,readData 检查不适用 return true; } protected canUpdate(existing: TeamInterface, updates: UpdateProps<TeamInterface>): boolean { return this.context.permissions.canUpdateTeam(existing, updates); }其中canRead返回true正是指南所说"某些代码路径需要特殊处理"的实例。注意这些是protected方法,外部不能绕过;BaseModel 另外提供dangerous*BypassPermission系列(如 dangerousCreateBypassPermission)供编排类写操作使用,并支持dangerouslyBypassCanUpdate/dangerouslyBypassCanRead细粒度开关。
三、第二步:吸收 helper 方法
指南指出,大多数旧模型的 helper 都是模型文件里导出的自由函数,迁移时通常应吸收为新模型类的public方法,但有些与BaseModel内建功能重复,应直接删除——典型如createFoo类 helper,因为BaseModel已提供create/getById/getAll/deleteById等完整的类型安全 CRUD(L651-L675)。指南还建议趁此机会合并同类 helper、降低模型复杂度。
指南在此处给出的第三个 ⚠️ 值得单独强调:检查 helper 是否泄漏了过多数据库层细节,优先传显式参数(如maxDate?: Date),而不是任意的过滤条件(如customFilter?: ScopedFilterQuery<...>)。
ScopedFilterQuery的类型定义在 BaseModel.ts:
export type ScopedFilterQuery<T, PKey> = FilterQuery<Omit<z.infer<T>, "organization">>;把这种"裸 Mongo 过滤器"作为公共 API 暴露,调用方就能拼出任意查询条件,绕开模型的领域约束。而模型内部的_find等受保护方法最终都会经过applyBaseQuery:
private applyBaseQuery(filter: object, dangerousCrossOrganization: boolean = false) { const fullQuery: FilterQuery<z.infer<T>> = { ...this.getBaseQuery(), ...filter, }; if (!dangerousCrossOrganization) { fullQuery.organization = this.context.org.id; } return fullQuery; }可以看到,只要经过实例方法,查询会被强制注入organization过滤——这就是"in-org 查询保护",也是下一步讨论静态方法时安全边界的核心依据。
四、第三步:替换现有调用点
旧 helper 从"直接 import 调用"变为"经由 context 实例调用"。指南给出了三步:
- 注册新模型:在 services/context.ts 中更新
ModelName、modelClasses和this.models; - 替换调用点:用
pnpm type-check找出断裂的 import,然后把每个旧调用替换为(req/this).context.models.<model>.helper,并按需调整参数; - 处理无 context 的调用点:大部分情况可以从外部传入 context,或使用
getContextFromReq/getContextForAgendaJob...构造。
对照源码,注册确实需要动三处。context.ts 中的 ModelName 联合类型 是模型名的穷举("agreements" | "aiPrompts" | ... | "aiCredentials"),modelClasses 映射 把名字映射到模型类,而initModels()则在每个请求 context 初始化时实例化全部模型(this.models = { agreements: new AgreementModel(this), ..., teams: new TeamModel(this), ... })。三处都补上,类型系统(ModelClass/ModelInstances两个派生类型)才能把新模型识别为"BaseModel 派生类"并纳入 API 路由遍历。
对于没有现成 context 的调用点,仓库里确实提供了指南所说的工具函数:getContextFromReq定义在 services/organizations.ts,Agenda 后台任务场景则有getContextForAgendaJobByOrgObject(L1730)可用。
指南特别警告的边界情况是:有些调用点位于单一 org 上下文之外(跨组织任务、全局批处理等),无法从请求 context 获得 org 信息,这类场景应改写为下一步的静态方法,而不是硬造一个 context。
五、第四步:必要的静态方法(与 dangerous 命名约定)
指南说明:当模型需要脱离 org 上下文使用时,要定义public statichelper,并强调两条规则:
dangerous前缀:这些方法缺少BaseModel内建的 in-org 查询保护,命名必须加dangerous前缀,警示其他开发者谨慎使用;- 静态方法访问不到
this.migrate:如果静态方法需要数据迁移逻辑,须把migrate提升到静态级别,并在实例方法上委托调用。
第二条在源码中有完整印证。BaseModel的默认migrate只是把旧文档原样强转返回,而真实模型普遍覆写它来处理 schema 演进。WebhookModel 就是指南推荐模式的实例:
protected static migrate(doc: unknown): WebhookInterface { const castDoc = doc as WebhookInterface; const newDoc = omit(castDoc, ["sendPayload"]) as WebhookInterface; if (!castDoc.payloadFormat) { if (castDoc.httpMethod === "GET") newDoc.payloadFormat = "none"; else if (castDoc.sendPayload) newDoc.payloadFormat = "standard"; else newDoc.payloadFormat = "standard-no-payload"; } if (!castDoc.dateCreated && castDoc.created) newDoc.dateCreated = castDoc.created; if (castDoc.consecutiveFailures === undefined) newDoc.consecutiveFailures = 0; if (castDoc.disabled === undefined) newDoc.disabled = false; return newDoc; } protected migrate(doc: unknown) { return SdkWebhookModel.migrate(doc); }迁移逻辑集中在static migrate(字段重命名、默认值回填、废弃字段剔除),实例级migrate只是一行委托——这样无论调用来自实例读路径还是静态方法,走的都是同一套兼容逻辑。而"缺少 in-org 保护"这一说法对应的是applyBaseQuery中的dangerousCrossOrganization分支:实例查询默认注入organization过滤,只有显式传dangerousCrossOrganization: true才会跳过;静态方法拿不到 context,自然无法注入,所以命名约定是必要的安全提示。
六、迁移验证:类型检查与测试
指南把pnpm type-check作为定位断裂 import 的主要手段。迁移完成后,建议补充的验证还包括:
- 对照 BaseModel.test.ts 的既有测试组织本模型的测试,覆盖索引创建、主键缺失报错(
_assertHasIdField对没有id字段的集合会拒绝getById)、权限钩子拒绝写操作等路径; - 核对
additionalIndexes中的unique/partialFilterExpression与旧 Mongoose 索引逐一对齐,必要时用indexesToRemove清理废弃索引; - 若旧模型的写入会影响
/organization/definitions,确认affectsDefinitionsVersion已设置——MakeModelClass会把这类集合登记进 definitionsVersionCollections,并有覆盖守卫测试断言 definitions 端点读取的每个集合都在登记之列,漏配会在该测试中暴露。
七、迁移检查清单(小结)
把指南的四步浓缩成可执行的检查单:
| 步骤 | 关键动作 | 常见坑(源码依据) |
|---|---|---|
| 1. 建模型类 | MakeModelClass配置 + zod schema | 可选字段语义不一致、collectionName/additionalIndexes与旧索引不符(BaseModel.ts#L214-L273) |
| 2. 权限四钩子 | canCreate/canRead/canUpdate/canDelete接this.context.permissions | canRead依赖外键填充;部分模型canRead应直接放行(BaseModel.ts#L410-L447) |
| 3. 吸收 helper | 转为public方法,删除与内建 CRUD 重复的createFoo | 避免把ScopedFilterQuery当公共参数暴露(BaseModel.ts#L61-L64) |
| 4. 替换调用点 | context.ts 三处注册 +pnpm type-check+context.models.<m>.helper | 无 context 调用点用getContextFromReq/getContextForAgendaJobByOrgObject(organizations.ts#L213) |
| 5. 静态方法 | 跨 org 场景用public static,加dangerous前缀 | migrate需静态化并在实例级委托(WebhookModel.ts#L49-L71) |
迁移完成后,模型即纳入 GrowthBook 后端统一的 CRUD、审计日志(auditLog配置)、定义版本失效与 API 路由体系,这也是整个迁移工作最终换来的工程收益。
- 后端
- 前端
- 数据分析
- 数据可视化
【免费下载链接】growthbook
Open Source Feature Flags, Experimentation, and Product Analytics
相关推荐
Express 数据层实战:CRUD、MVC 架构与 Mongoose 模型设计全解
Express 数据层实战:CRUD、MVC 架构与 Mongoose 模型设计全解 本文基于开源 Web 开发课程 curriculum https://li
文档教程教育wllvm-sanity-checker使用教程:快速诊断环境配置问题的终极指南 🚀
wllvm sanity checker使用教程:快速诊断环境配置问题的终极指南 🚀 wllvm sanity checker 是Whole Program
开发工具从 InfluxDB 迁移到 VictoriaMetrics:数据模型差异、数据写入/查询方式与实战迁移指南
从 InfluxDB 迁移到 VictoriaMetrics:数据模型差异、数据写入/查询方式与实战迁移指南 VictoriaMetrics 是一款面向大规模监
时序数据库数据库指标监控可观测性后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考