ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Strapi Upload Provider 机制详解:从 local、S3、Cloudinary 到自研存储 Provider

Strapi Upload Provider 机制详解:从 local、S3、Cloudinary 到自研存储 Provider Strapi Upload Provider 机制详解从 local、S3、Cloudinary 到自研存储 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本文以 Strapi 官方文档中的 Upload Provider 规范为骨架结合当前仓库packages/core/upload的服务端源码与packages/providers下的三个官方 Provider 实现完整拆解 Upload 插件的存储抽象层Provider 的加载与校验流程、必须实现的方法契约、内置 default 行为、私有桶签名 URL 机制以及如何编写一个可被 Strapi 识别的自定义 Provider帮助你在生产环境中安全地切换或扩展文件存储后端。什么是 Upload ProviderUpload Provider 是 Strapi Upload 插件的存储抽象层用于把文件上传对接到不同的外部服务或应用例如 Amazon S3 桶、Cloudinary 等。根据官方定义见 00-providers.md在 Upload 插件的语境下Provider 必须能够将文件上传到远程服务器并能够删除文件。也就是说Provider 至少承担两件事写入文件——把内容管理器中上传的文件持久化到目标存储本地磁盘、S3、Cloudinary 等删除文件——当内容条目被删除时把存储中的对应文件一并清理避免“孤儿文件”堆积。Strapi 仓库内置了三个官方 Provider 包位于packages/providers目录包名目录特点strapi/provider-upload-localupload-local默认 Provider写入public/uploads零外部依赖strapi/provider-upload-aws-s3upload-aws-s3兼容 S3 及各类 S3 兼容存储支持签名 URLstrapi/provider-upload-cloudinaryupload-cloudinary对接 Cloudinary 媒体服务Provider 的加载、校验与包装流程理解 Provider 机制的最佳入口是 register.ts 中的createProvider函数。Upload 插件在register生命周期执行时会读取plugin::upload配置并调用它来构造最终挂载到strapi.plugin(upload).provider上的实例。整个流程可以分为五步第一步确定 Provider 名称与模块解析路径。const providerName _.toLower(config.provider); let modulePath; try { modulePath require.resolve(strapi/provider-upload-${providerName}); } catch (error) { if (/* MODULE_NOT_FOUND */) { modulePath providerName; // 回退把 provider 名当作自定义模块名解析 } else { throw error; } }这里确立了两条约定命名规范配置中写provider: aws-s3Strapi 就会尝试解析strapi/provider-upload-aws-s3包。官方包名必须遵循strapi/provider-upload-${name}格式自定义 Provider 入口如果解析不到官方包Provider 名会直接作为模块名被require这意味着你可以发布或本地化一个任意命名的 npm 包作为 Provider。第二步init初始化。const providerInstance provider.init(providerOptions);Provider 模块默认导出一个对象其init(options)方法接收providerOptions即插件配置中的providerOptions返回真正的方法实例。S3 Provider 的 init 会在这里创建S3Client、执行配置校验local Provider 则会检查public/uploads目录是否存在不存在直接抛错。第三步强制方法校验。这是“Provider 必须能上传和删除文件”这一规范的落地代码if (!providerInstance.delete) { throw new Error(The upload provider ${providerName} doesnt implement the delete method.); } if (!providerInstance.upload !providerInstance.uploadStream) { throw new Error( The upload provider ${providerName} doesnt implement the uploadStream nor the upload method. ); } if (!providerInstance.uploadStream) { process.emitWarning( The upload provider ${providerName} doesnt implement the uploadStream function. Strapi will fallback on the upload method. Some performance issues may occur. ); }要点delete是硬性要求缺失直接启动失败upload与uploadStream至少实现其一否则启动失败只实现upload而缺少uploadStream时仅发出性能警告——因为大文件会被整体读入内存 buffer这正是官方更推荐uploadStream的原因。第四步actionOptions包装。每个方法都会被包一层把配置中actionOptions[methodName]作为第二个参数注入const wrappedProvider _.mapValues(providerInstance, (method, methodName) { return async (file: File, options actionOptions[methodName]) providerInstancemethodName; });这意味着你可以通过插件配置的actionOptions为不同操作upload、delete等传递各自独立的选项而无需改动 Provider 代码。插件默认配置config.ts中actionOptions默认为{}。第五步挂接基础能力baseProvider。最终实例由Object.create(baseProvider)构造baseProvider 提供了四个默认行为Provider 可以按需覆盖方法默认实现作用extend(obj)用Object.assign合并到实例上允许插件/扩展运行时给 Provider 追加方法checkFileSize(file, { sizeLimit })超过sizeLimit抛PayloadTooLargeError文件大小限制检查getSignedUrl(file)原样返回file公开桶无需签名isPrivate()返回false公开桶Provider 方法契约规范 源码扩充原始文档给出的 Provider 方法契约如下本文结合源码逐项补全方法文档定义源码层面的补充说明isPrivate()可选返回布尔值指示 Provider 是否私有为true时用getSignedUrl获取文件 URL默认false由 baseProvider 兜底为false私有桶的签名 URL 钩子见后文getSignedUrl(file)可选为需要鉴权访问的文件返回签名 URLS3 实现默认签名有效期为params.signedUrlExpires或 15 分钟upload-aws-s3/src/index.tsupload(file)将文件上传到 Provider入参file.buffer由框架从流转换而来uploadStream(file)可选以流方式上传文件推荐实现避免大文件整体进入内存入参file.stream由框架调用file.getStream()提供delete(file)从 Provider 删除文件启动时强制校验必须实现replace(newFile, oldFile)/replaceStream(newFile, oldFile)文档未提及源码新增—覆盖上传场景用新文件替换旧文件local 与 S3 均已实现源码中的 replace 服务 会按replaceStream→replace→delete upload的优先级回退checkFileSize(file, options)源码可见—baseProvider 提供默认实现配合插件级sizeLimit生效使用一个 Provider安装与配置官方文档给出的使用方式是安装 Provider 包并在./config/plugins.js文件中配置当前仓库模板已改为 TypeScript 配置例如 examples/complex/config/plugins.tsJS/TS 写法等价。Upload 插件自身的默认配置config.ts为{ enabled: true, provider: local, // 默认使用本地存储 sizeLimit: 1000000000, // 1GB 单文件上限 actionOptions: {}, // 按方法名注入 Provider 方法的第二参数 sharp: { cache: false, concurrency: 1 }, concurrentUploadSize: 1, concurrentUploadRequests: 1, }sizeLimit的取值单位是 KB会被kbytesToBytes换算后参与checkFileSize判断超限请求抛PayloadTooLargeError413。sharp.cache与sharp.concurrency在register阶段直接调用 sharp.cache/concurrency 配置图像处理的内存与并发行为。以官方 S3 Provider 为例providerOptions的完整结构可参考其 InitOptions 接口。一个典型的配置形如// config/plugins.js或 plugins.ts const plugins { upload: { config: { provider: aws-s3, providerOptions: { baseUrl: https://cdn.example.com, // 可选CDN 或自定义域名优先生效于文件 URL rootPath: images, // 可选桶内目录前缀 s3Options: { region: us-east-1, accessKeyId: xxx, secretAccessKey: xxx, // endpoint: https://s3.amazonaws.com, // S3 兼容服务时指定 params: { // 必需缺失会直接抛错 Bucket: my-bucket, ACL: public-read, // 省略时默认 public-read // signedUrlExpires: 900, // 签名 URL 有效期秒 }, }, providerConfig: { // 可选增强项 checksumAlgorithm: CRC64NVME, preventOverwrite: false, storageClass: STANDARD, encryption: { type: AES256 }, tags: { team: content }, multipart: { partSize: 5 * 1024 * 1024, queueSize: 8 }, }, }, }, }, };上面每一项都可以在 upload-aws-s3 源码 中得到印证几个容易踩坑的点params是必需的getConfig 在params缺失时抛出params are required in the config object且当params中未显式写ACL时会自动补public-read2023 年 4 月后新建的 AWS 桶默认禁用 ACL需要私有桶请显式配置ACL: private。baseUrl决定 URL 生成优先级constructFileUrl 按baseUrl→ 上传响应的合法Location→ 配置的endpoint拼接 → 给Location补https://→ AWS 默认桶域名 的优先级生成文件 URL专门兼容了 IONOS、部分 MinIO 等返回畸形Location的 S3 兼容服务。非 AWS 端点会收到兼容性警告validateProviderConfig检测到 endpoint 不是amazonaws.com时对storageClass与非AES256加密会process.emitWarning因为这类特性是 AWS 特有的。Multipart 参数有合法区间partSize低于 5MB 或高于 5GB 会告警queueSize超过 16 同样告警。安装命令按包名执行即可以 S3 为例在项目目录中yarn add strapi/provider-upload-aws-s3 # 或 npm install strapi/provider-upload-aws-s3官方 local Provider 实现剖析strapi/provider-upload-local是最小可用实现也是理解 Provider 契约的范本源码init阶段解析strapi.dirs.static.public/uploads路径目录不存在则抛出明确错误提示uploadStream用pipeline(stream, fs.createWriteStream(...))把流直接落到uploads/${hash}${ext}并把file.url改写为/uploads/${hash}${ext}upload则要求file.buffer存在用fs.writeFile落盘replaceStream/replace实现了“先写新文件、后删旧文件”的顺序当新旧路径不同时保证任何时刻存储里都有可用文件delete先existsSync判断文件不存在时返回File doesnt exist而不是抛错保证删除幂等。值得注意的是它对旧配置的兼容性处理providerOptions.sizeLimit已被标记废弃init会发出[deprecated]警告提示将sizeLimit迁移到upload.config层级——这与config.ts中sizeLimit位于插件配置顶层的定义是一致的。上传、替换的运行时调用链框架层与 Provider 之间的桥梁是 services/provider.tsasync upload(file) { if (isFunction(strapi.plugin(upload).provider.uploadStream)) { file.stream file.getStream(); await strapi.plugin(upload).provider.uploadStream(file); delete file.stream; if (filepath in file) delete file.filepath; } else { file.buffer await fileUtils.streamToBuffer(file.getStream()); await strapi.plugin(upload).provider.upload(file); delete file.buffer; if (filepath in file) delete file.filepath; } }调用链要点流优先Provider 实现了uploadStream就走流式上传否则把流整体读成 buffer 再调upload。这解释了register阶段缺少uploadStream时警告“可能产生性能问题”的含义临时字段清理调用完成后框架会主动delete file.stream/file.buffer/file.filepathProvider 返回时不应再持有这些临时字段replace的三级回退优先replaceStream其次replace最后回退为“先delete(oldFile)再upload(newFile)”源码。这一回退链有完整的单元测试覆盖见 provider.test.ts其中专门验证了“回退路径使用 buffer 版 upload”与“replaceStream 成功后filepath被清理”等边界。checkFileSize服务方法则从strapi.config.get(plugin::upload)读取sizeLimit后委托给 Provider 的checkFileSize源码与baseProvider的默认实现衔接。私有桶与签名 URL 机制isPrivate()/getSignedUrl(file)两个可选方法共同支撑私有存储场景。触发逻辑位于 extensions/index.tsconst { provider } strapi.plugins.upload; const isPrivate await provider.isPrivate(); // 只有私有 Provider 才需要给文件 URL 签名 if (!isPrivate) return; strapi.documents.use(async (ctx, next) { const result await next(); // findMany → 逐条签名 // findFirst / findOne / create / update → 单条签名 // delete / clone / publish / unpublish / discardDraft → 对 entries 数组签名 return signEntityMedia(result, uid); });也就是说只要 Provider 声明isPrivate()为trueStrapi 会在 Content API 的文档查询钩子里自动把条目中的媒体 URL 替换为getSignedUrl生成的临时签名 URL覆盖findMany、findFirst、create、publish等主流操作无需业务代码介入。以 S3 Provider 为例isPrivate的判断就是config.params.ACL private源码getSignedUrl通过aws-sdk/s3-request-presigner对GetObjectCommand签名有效期取params.signedUrlExpires缺省 15 分钟签名时强制覆盖Bucket与Key防止自定义参数注入恶意键。这也解释了为什么配置私有桶时ACL: private是签名机制的开关。开发一个自定义 Provider结合文档规范与createProvider的校验逻辑自定义 Provider 的完整清单如下建包默认导出{ init(options) { ... } }形式包名若遵循strapi/provider-upload-${name}约定配置里provider: name即可被自动解析否则配置中直接写你的模块名实现必需方法upload(file)或uploadStream(file)二选一强烈建议两者都实现uploadStream性能更优以及delete(file)——缺一会导致 Strapi 启动即失败按场景补全可选方法私有存储实现isPrivate()返回true并配套getSignedUrl(file)覆盖上传场景实现replace/replaceStream利用actionOptions插件配置里actionOptions.upload/actionOptions.delete等键的值会作为对应方法的第二参数自动注入参见 register.ts 的包装逻辑约定返回语义上传/替换成功后把file.url改写为目标存储的访问地址local Provider 改写为/uploads/...、S3 Provider 通过constructFileUrl生成完整 URL这是内容 API 返回正确媒体链接的前提参考现成实现delete幂等性、replace的“先写后删”顺序、流与 buffer 的取舍均可对照 upload-local 与 upload-aws-s3 两个官方实现行为验证可参考 provider.test.ts 的测试写法。小结Upload Provider 是 Strapi 存储后端的唯一抽象边界createProvider负责解析与强校验baseProvider负责兜底默认值services/provider.ts负责流式优先的运行时调度extensions负责私有桶的 URL 签名。掌握这套机制后无论是切换到 S3/Cloudinary还是为内部对象存储编写 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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进