Strapi 数据迁移之目标端 Provider(IDestinationProvider)完整解析:从 WriteStream 接口到本地/远程落盘实现
【免费下载链接】strapi🚀 Strapi is the leading open-source headless CMS. It’s 100% JavaScript/TypeScript, fully customizable, and developer-first.项目地址: https://gitcode.com/GitHub_Trending/st/strapi
Strapi 的数据传输引擎(Data Transfer Engine,实验性功能)以“源 Provider 提供 Readable 流、目标 Provider 提供 Writable 流”的对称模型完成备份、还原与跨实例迁移。本文以官方文档 Destination Providers 为核心,结合packages/core/data-transfer中的接口定义、传输引擎调用链与三套内置目标 Provider 实现,讲解如何理解并自行实现一个目标端 Provider:读完后你能掌握IDestinationProvider接口的全部成员含义、五个传输阶段的数据契约、引擎与目标端的完整生命周期,以及 Local Strapi、Strapi File、Remote Strapi 三种内置实现的关键差异与回滚(rollback)机制。
1. Destination Provider 在传输架构中的位置
数据迁移由五个顺序阶段组成,每个阶段遵循相同的流生命周期模式,只是处理的数据类型不同(见 Stream Lifecycle 文档):
| 阶段 | 数据内容 |
|---|---|
| 1. Schemas | 数据库结构、内容类型配置、组件定义 |
| 2. Entities | 实际内容(不含关系):组件数据、Dynamic Zone、媒体元数据(不含文件) |
| 3. Assets | /uploads目录下的文件、文件元数据、格式/变体 |
| 4. Links | 实体间关系:内容类型关系、组件关系、媒体关系 |
| 5. Configuration | Strapi 配置、项目设置、API 配置 |
在每个阶段中,传输引擎会依次向源端请求create{stage}ReadStream()、创建用于数据改写/过滤的 Transform 流、创建进度追踪流,最后向目标端请求create{stage}WriteStream(),把整条管道串起来:
Source Provider (Readable) └─> Transform Stream(过滤/映射) └─> Progress Tracker(统计 count/bytes) └─> Destination Provider (Writable)因此,目标 Provider 的职责可以一句话概括:为每个阶段提供一个 Writable 流,接收从源端 Readable 流中管道过来的每一条 entity、link(关系)、asset(文件)、configuration 实体或内容类型 schema。这正是官方文档 Destination Providers 的核心定义。与 Source Providers 的约定互为镜像:源端负责stream.write(entity)逐条发出数据,并在发完后关闭流,引擎才会进入下一阶段;目标端则负责逐条接收并落盘/落库。
引擎侧的对应调用点在 engine/index.ts 中可以逐一看到:
engine/index.ts:903 const destination = await this.destinationProvider.createSchemasWriteStream?.(); engine/index.ts:920 const destination = await this.destinationProvider.createEntitiesWriteStream?.(); engine/index.ts:965 const destination = await this.destinationProvider.createLinksWriteStream?.(); engine/index.ts:1003 const destination = await this.destinationProvider.createAssetsWriteStream?.(); engine/index.ts:1048 const destination = await this.destinationProvider.createConfigurationWriteStream?.();注意引擎对每个方法都使用?.可选调用——这与接口定义中五个 WriteStream 方法全部为可选成员是一致的,意味着一个目标 Provider 不必支持全部阶段。
2. IDestinationProvider 接口的完整定义
目标 Provider 必须实现IDestinationProvider接口。文档中给出的路径是发布产物里的packages/core/data-transfer/types/providers.d.ts,其在当前仓库中的源码定义位于 types/providers.ts:
export interface IProvider { type: ProviderType; // 'source' | 'destination' name: string; // 该 Provider 的唯一名称 results?: IProviderTransferResults; // 供引擎外部追踪结果的可选对象 // bootstrap() 在传输引擎引导阶段被调用, // 用于初始化:建立数据库连接、打开文件、校验授权等 bootstrap?(diagnostics?: IDiagnosticReporter): MaybePromise<void>; close?(): MaybePromise<void>; // 在传输引擎关闭时被调用 getMetadata(): MaybePromise<IMetadata | null>; // 返回用于版本校验的元数据 getSchemas?(): MaybePromise<Record<string, Struct.Schema> | null>; // 返回用于 schema 校验的 schema beforeTransfer?(): MaybePromise<void>; // 在传输阶段开始执行前立即被调用 } export interface IDestinationProvider extends IProvider { results?: IDestinationProviderTransferResults; /** * 可选的 rollback 实现。 * 当传输过程中抛出错误时被调用,允许执行回滚操作 */ rollback?<T extends Error = Error>(e: T): MaybePromise<void>; setMetadata?(target: ProviderType, metadata: IMetadata): IDestinationProvider; onWarning?: (message: string) => void; createEntitiesWriteStream?(): MaybePromise<Writable>; createLinksWriteStream?(): MaybePromise<Writable>; createAssetsWriteStream?(): MaybePromise<Writable>; createConfigurationWriteStream?(): MaybePromise<Writable>; createSchemasWriteStream?(): MaybePromise<Writable>; }各成员的职责如下:
| 成员 | 调用时机 | 说明 |
|---|---|---|
type/name | 构造时 | 必须标记为'destination'并给出唯一名称,如destination::local-strapi |
results | 传输结束后 | Provider 专属的结果对象,会被引擎合并进ITransferResults(见 types/utils.ts 中的ITransferResults) |
bootstrap(diagnostics) | 引擎初始化阶段 | 做重量级初始化:连接 Strapi 实例、打开目标文件等;引擎在 engine/index.ts 第 708 行 传入诊断报告器 |
close() | 引擎关闭阶段 | 释放连接、提交/结束事务等;引擎对源/目标两端的close()使用Promise.allSettled并发执行并分别处理清理错误 |
getMetadata() | 版本校验 | 返回{ createdAt, strapi: { version } }之类的元数据,用于源/目标版本一致性验证 |
getSchemas() | schema 校验 | 提供内容类型与组件的 schema 集合,供引擎做 schema 匹配 |
beforeTransfer() | 所有阶段开始前 | 目标端做前置准备的标准位置(如执行 restore 清理、备份) |
rollback(e) | 阶段内抛出错误时 | 引擎在捕获错误后调用(engine/index.ts 第 858 行:await this.destinationProvider.rollback?.(e as Error)),由 Provider 自行决定回滚策略 |
setMetadata(target, metadata) | 引擎引导时 | 引擎将源端元数据注入目标端(engine/index.ts 第 698 行 传入'source'),典型用途是文件 Provider 把双方元数据写进归档 |
onWarning | 传输过程中 | 非致命警告回调,如链接映射时找不到目标 ID |
create*WriteStream()×5 | 各阶段开始时 | 返回该阶段的 Writable 流,五个方法对应五个阶段,全部可选 |
每个阶段写入流的数据类型由 types/utils.ts 中的TransferStageTypeMap约束:
export type TransferStageTypeMap = { schemas: Struct.Schema; entities: IEntity; links: ILink; assets: IAsset; configuration: unknown; }; export type TransferStage = keyof TransferStageTypeMap;也就是说,createEntitiesWriteStream()返回的 Writable 每次write(chunk)收到的都是一个IEntity,createLinksWriteStream()收到ILink,依此类推。
3. 引擎与目标 Provider 的生命周期协作
把 engine/index.ts 中的调用点按时间顺序排列,目标 Provider 经历的完整生命周期为:
bootstrap(diagnostics) // L708:与 source.bootstrap 并发执行 setMetadata('source', m) // L698:接收源端元数据 beforeTransfer() // 阶段开始前的准备(如 restore 清理) createSchemasWriteStream() // L903 createEntitiesWriteStream() // L920 createLinksWriteStream() // L965 createAssetsWriteStream() // L1003 createConfigurationWriteStream() // L1048 rollback(e)? // L858:仅当出错时 close() // L724:与 source.close 用 allSettled 并发执行几个对实现者重要的细节:
- 阶段内串行、两端对称。引擎先拿到源端 Readable,再拿到目标端 Writable,然后
source.pipe(transform).pipe(tracker).pipe(destination)并等待整条管道close事件,阶段才算结束(流程示意见 Stream Lifecycle 文档)。 - 进度与结果分离。逐阶段的 count/bytes/耗时由引擎的 Progress Tracker 统计进
TransferProgress(结构与文档中的TransferProgress接口一致,定义见 types/utils.ts 第 93-95 行);而业务性结果(如生成的文件路径)则由 Provider 写入自己的results。 - 可选成员的宽容性。
bootstrap、close、rollback、五个 WriteStream 方法全部为可选,引擎一律用?.调用;因此一个只支持部分阶段的 Provider(例如只导出文件、不写库)是完全合法的。
4. 自写目标 Provider 的实现要点
以“把每个阶段的数据追加写入一个目录下的 JSONL 文件”为例,一个最小可用的目标 Provider 大致如下(示例代码为说明性实现,非仓库内置文件):
import { Writable } from 'stream'; import { createWriteStream, appendFile } from 'fs'; import type { IDestinationProvider, IMetadata, ProviderType, } from '@strapi/data-transfer/dist/types'; // 或仓库内 src/types 对应导出 class JsonlFileDestinationProvider implements IDestinationProvider { type: ProviderType = 'destination'; name = 'destination::my-jsonl'; results: { dir?: string } = {}; constructor(private opts: { dir: string }) {} async bootstrap() { /* 创建目录等初始化 */ } getMetadata(): IMetadata | null { // 返回 null 表示不参与版本校验 return null; } getSchemas() { return null; } beforeTransfer() { /* 清理旧文件等前置操作 */ } private jsonl(stage: string): Writable { const file = appendFile(`${this.opts.dir}/${stage}.jsonl`); return Writable.wrap(file, { objectMode: true, write(chunk, _enc, cb) { file.write(`${JSON.stringify(chunk)}\n`, cb); }, }); } createEntitiesWriteStream() { return Promise.resolve(this.jsonl('entities')); } createLinksWriteStream() { return Promise.resolve(this.jsonl('links')); } createAssetsWriteStream() { return Promise.resolve(this.jsonl('assets')); } createConfigurationWriteStream() { return Promise.resolve(this.jsonl('configuration')); } createSchemasWriteStream() { return Promise.resolve(this.jsonl('schemas')); } async close() { /* 关闭已打开的文件句柄 */ } // 未实现 rollback:出错时目标端不做补偿,需在文档中说明该语义 }实现时的关键契约:
- Writable 必须处于 objectMode:引擎按对象(entity/link/asset…)逐条写入,而不是按字节;
- 错误即中止:Writable 上抛出的任何错误都会沿管道传播,引擎记录
stage::error并触发rollback?.(); - 警告用
onWarning:无法致命但需要用户知晓的情况(例如某条关系的目标 ID 映射不到)通过onWarning?.(message)上报; - 资源清理放在
close():无论成功失败,引擎关闭时都会调用; - 回滚语义自己定义:接口只负责调用
rollback(e),是否可回滚、回滚到什么程度完全由实现决定。
仓库中已有大量可直接对照的测试,验证了这套契约在各阶段下的行为,例如 local-destination 的 restore 测试、assets 写入流测试 以及 file destination 测试。
5. 内置目标 Provider 逐个剖析
Strapi 为三种介质都提供了目标 Provider(总览见 Providers 概览):Strapi 文件、本地 Strapi、远程 Strapi。它们对外呈现同一套create*WriteStream()接口,但各自带有一组专属初始化选项。
5.1 Local File(Strapi 文件)Destination Provider
该 Provider 输出一份标准 Strapi 数据文件(Strapi Data File),实现位于 file/providers/destination/index.ts。文档(Strapi File Destination)特别指出:该 Provider 不提供 schema 与 metadata,因此永远不会触发 schema 匹配错误或版本校验错误——这与接口定义吻合:getMetadata()对目标侧并非强校验依据。
其选项ILocalFileDestinationProviderOptions定义如下(文档与源码一致):
export interface ILocalFileDestinationProviderOptions { encryption: { enabled: boolean; // 是否对文件加密 key?: string; // encryption.enabled 为 true 时使用的密钥 }; compression: { enabled: boolean; // 是否用 gzip 压缩 }; file: { path: string; // 要创建的文件名 maxSize?: number; // 单个备份文件的最大字节数 maxSizeJsonl?: number; // 每个 jsonl 文件达到多少行后滚动到下一个文件 }; }从源码可以看出几个实现细节:
- 产物命名规则(#archivePath 计算逻辑):基础名
{file.path}.tar,开启压缩追加.gz,开启加密再追加.enc,即最终文件形如backup.tar.gz.enc; - 管道构成:
bootstrap()中创建tar.pack()归档流并接到fs.createWriteStream,配合zlib.createGzip()与createEncryptionCipher(加密实现位于 utils/encryption);各阶段的 JSONL 数据则经由stream-json的stringer序列化为 JSONL 后作为 tar entry 写入(依赖tar-stream、stream-chain); - 元数据注入:
setMetadata()把源/目标双方元数据缓存进#providersMetadata,随归档一起落盘,供日后导入时核对; - 结果上报:成功后在
results.file.path中记录生成文件路径,供 CLI 读取。
5.2 Local Strapi Destination Provider
这是最复杂的目标 Provider:把数据插入一个已初始化的 Strapi 实例,走其 Entity Service 与 Query Engine。文档见 Local Strapi Destination,实现见 strapi/providers/local-destination/index.ts。
Provider 选项(ILocalStrapiDestinationProviderOptions):
export interface ILocalStrapiDestinationProviderOptions { getStrapi(): Core.Strapi | Promise<Core.Strapi>; // 返回已初始化的 Strapi 实例 autoDestroy?: boolean; // 传输结束后是否销毁 getStrapi() 返回的实例 restore?: restore.IRestoreOptions; // strategy 为 'restore' 时必传的清理选项 strategy: 'restore'; // 冲突处理策略,当前仅支持 'restore' onTransferPhase?: (message: string) => void; // CLI/UI 前置阶段的人类可读进度回调 }源码中VALID_CONFLICT_STRATEGIES = ['restore'],bootstrap()阶段会先做选项校验(策略非法或 restore 缺少选项时抛出ProviderValidationError),然后:
this.strapi = await this.options.getStrapi(); this.strapi.db.lifecycles.disable(); // 迁移期间禁用数据库生命周期钩子 this.transaction = utils.transaction.createTransaction(this.strapi);restore 策略与 IRestoreOptions。"restore" 的含义是:传输前先删除目标库中的既有数据以避免冲突。可选项如下(引自官方文档):
export interface IRestoreOptions { assets?: boolean; // 传输前删除媒体库文件 configuration?: { webhook?: boolean; // 传输前删除 webhooks coreStore?: boolean; // 传输前删除 core store }; entities?: { include?: string[]; // 仅删除这些内容类型的实体 exclude?: string[]; // 排除这些内容类型 filters?: ((contentType: ContentTypeSchema) => boolean)[]; // 自定义过滤器 params?: { [uid: string]: unknown }; // 传给 deleteMany 的自定义删除参数 }; }beforeTransfer()中的实际执行顺序(#L172-L197):
- 开启事务并
attach; - 若
restore.assets为真:先把public/uploads整体移动到public/uploads_backup_{timestamp}备份目录(仅当 upload provider 为local时执行),再清空 uploads; - 通过
restore.deleteRecords(实现位于 strategies/restore/ 下的configuration.ts、entities.ts、links.ts)按IRestoreOptions清库。
各阶段 WriteStream 的实现方式:
createEntitiesWriteStream():按策略委托给restore.createEntitiesWriteStream,并在每写入一条记录时通过updateMappingTable(type, oldID, newID)维护一张私有映射表#entitiesMapper(第 50-56 行)——这是后续 links 阶段能把源端旧 ID 换到目标端新 ID 的基础;createLinksWriteStream():把mapID = (uid, id) => this.#entitiesMapper[uid]?.[id]传给 restore 策略的 links 写入流,完成关系重映射;映射不到时经onWarning上报;createAssetsWriteStream():返回 assets-destination-writable.ts 创建的 Writable,写入文件字节、调用 upload provider 落盘,并用resolveUploadFileId把媒体元数据 ID 映射为新 ID;若restore.assets未开启而管道里出现 asset 流,会直接抛出ProviderTransferError;createConfigurationWriteStream():委托restore.createConfigurationWriteStream(strapi, transaction)。
回滚(Rollback)机制,文档与源码一致地分两层:
- Strapi 数据:restore 清理与全部数据插入被包在同一个数据库事务里,成功则提交、失败则回滚。Provider 的
rollback()就是执行this.transaction?.rollback()(第 166-170 行); - 上传文件:由于文件操作不在数据库事务内,采用“先备份、后决定去留”的策略——开始前把
uploads移入uploads_backup_{timestamp},成功时删除备份,失败时删除导入失败的文件并把备份移回。文档同时明确提示:某些失败场景下备份可能无法自动还原,需要手动恢复;且若环境对 uploads 目录无写权限(如只读挂载),则必须把 assets 阶段排除在传输之外。
close()的收尾顺序同样值得注意:transaction.end()→strapi.db.lifecycles.enable()恢复生命周期钩子 → 按autoDestroy(默认 true)决定是否strapi.destroy()(第 98-107 行)。
5.3 Remote Strapi Destination Provider
远程目标 Provider(strapi/providers/remote-destination/index.ts)把本地目标 Provider 包装了一层 WebSocket 通道:连接远程 Strapi 管理端,通过消息协议逐阶段推进并把数据推给对端,由对端走与 5.2 相同的 restore/写入逻辑。文档见 Remote Strapi Destination。
选项在本地 Provider 的restore与strategy之外增加三项:
interface ITransferTokenAuth { type: 'token'; // 认证策略名 token: string; // 传输令牌 } export interface IRemoteStrapiDestinationProviderOptions extends Pick<ILocalStrapiDestinationProviderOptions, 'restore' | 'strategy'> { url: URL; // 远程 Strapi admin 的地址 auth?: ITransferTokenAuth; retryMessageOptions?: { retryMessageTimeout: number; // 等待单条消息响应的毫秒数 retryMessageMaxRetries: number; // 单条消息放弃前最大重试次数 }; }使用注意(官方文档原文要点):url必须带http/https协议,连接时会被转换为ws/wss;鉴于传输令牌具备很高的访问权限,强烈建议使用安全的(https→wss)连接。消息重试行为的对应测试可见 remote-destination 测试目录。
6. 约束、限制与最佳实践
综合文档标签(三个 provider 文档均标注experimental)与源码,实现或选用目标 Provider 时应注意:
- 接口全部可选、按需实现。五个
create*WriteStream()与bootstrap/close/rollback均为可选成员,引擎对缺失方法采取跳过语义;但如果不实现某阶段的 WriteStream,该阶段数据会被直接丢弃,属于显式设计而非缺陷。 - metadata 与 schema 是校验开关。
getMetadata()返回null即不参与版本校验;getSchemas()缺失即跳过 schema 匹配。Local File 目标 Provider 正是利用这一点,使“备份文件”本身不承担校验职责。 - rollback 是尽力语义。接口注释明确 rollback 只“允许执行回滚操作”,具体保证由实现给出:Local Strapi 用事务+备份双保险,Local File 目标则不存在回滚(文件写到一半时只能手动清理)。
- assets 阶段处于不稳定状态。Providers 概览 明确说明:目前所有数据传输 Provider 只处理本地媒体资产(
/upload目录),Provider 媒体(如 S3/Cloudinary)支持仍在开发中;与资产相关的一切——包括 Strapi 文件结构、restore 策略与资产回滚——均按unstable对待,近期可能变化。 - 写自己的 Provider 时的对照路径:接口定义见 types/providers.ts,阶段数据类型见 types/utils.ts,错误类型(
ProviderValidationError、ProviderTransferError等)见 errors/providers.ts,引擎行为与调用点见 engine/index.ts,最完整的参考实现是 Local Strapi 目标 Provider(含策略目录 strategies/restore/)。
7. 小结
Destination Provider 是 Strapi 数据传输引擎的“落盘端”抽象:它用create{stage}WriteStream()一族可选方法,把 schemas、entities、links、assets、configuration 五个阶段的数据从源端管道中逐条接收并写入目标介质。理解了 IDestinationProvider 接口、引擎生命周期调用点 以及三套内置实现(Local File、Local Strapi、Remote Strapi)的差异与回滚语义后,你就可以按本文第 4 节的契约,为目标介质(对象存储、数据仓库、其他 CMS 等)写出一个行为可预测、可测试的目标 Provider。
【免费下载链接】strapi🚀 Strapi is the leading open-source headless CMS. It’s 100% JavaScript/TypeScript, fully customizable, and developer-first.项目地址: https://gitcode.com/GitHub_Trending/st/strapi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考