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 Provider
Upload Provider 是 Strapi Upload 插件的存储抽象层,用于把文件上传对接到不同的外部服务或应用,例如 Amazon S3 桶、Cloudinary 等。根据官方定义(见 00-providers.md):
在 Upload 插件的语境下,Provider 必须能够将文件上传到远程服务器,并能够删除文件。
也就是说,Provider 至少承担两件事:
- 写入文件——把内容管理器中上传的文件持久化到目标存储(本地磁盘、S3、Cloudinary 等);
- 删除文件——当内容条目被删除时,把存储中的对应文件一并清理,避免“孤儿文件”堆积。
Strapi 仓库内置了三个官方 Provider 包,位于packages/providers目录:
| 包名 | 目录 | 特点 |
|---|---|---|
@strapi/provider-upload-local | upload-local | 默认 Provider,写入public/uploads,零外部依赖 |
@strapi/provider-upload-aws-s3 | upload-aws-s3 | 兼容 S3 及各类 S3 兼容存储,支持签名 URL |
@strapi/provider-upload-cloudinary | upload-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-s3',Strapi 就会尝试解析@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}" doesn't implement the delete method.`); } if (!providerInstance.upload && !providerInstance.uploadStream) { throw new Error( `The upload provider "${providerName}" doesn't implement the uploadStream nor the upload method.` ); } if (!providerInstance.uploadStream) { process.emitWarning( `The upload provider "${providerName}" doesn't 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)(可选) | 为需要鉴权访问的文件返回签名 URL | S3 实现默认签名有效期为params.signedUrlExpires或 15 分钟(upload-aws-s3/src/index.ts) |
upload(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.ts,JS/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判断,超限请求抛PayloadTooLargeError(413)。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-read(2023 年 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 doesn't exist"而不是抛错,保证删除幂等。
值得注意的是它对旧配置的兼容性处理:providerOptions.sizeLimit已被标记废弃,init会发出[deprecated]警告,提示将sizeLimit迁移到upload.config层级——这与config.ts中sizeLimit位于插件配置顶层的定义是一致的。
上传、替换的运行时调用链
框架层与 Provider 之间的桥梁是 services/provider.ts:
async 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.filepath,Provider 返回时不应再持有这些临时字段; 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.ts:
const { 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()为true,Strapi 会在 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),仅供参考