GraphQL 的标准查询是"请求-响应"的一次性交互,但现实工程逃不开两类需求:上传文件(头像、附件、音视频)与流式输出(大列表分页加载、AI 流式回复、大数据量响应)。这两类场景在 GraphQL 里各有成熟方案:上传走 Upload 标量 + multipart 规范,流式走 @stream/@defer 指令与订阅。
本文先讲透文件上传的标准协议与服务端处理,再深入流式传输与增量交付,最后覆盖对象存储集成与上传安全——让 GraphQL 不只是"查 JSON",还能吞吐字节流。
一、文件上传的规范:multipart
1.1 Upload 标量与 multipart 请求
GraphQL 规范没有内置文件上传,但社区标准 graphql-multipart-request-spec 是事实标准:
# 原理: 一个 HTTP multipart 请求同时携带
# 1) 操作定义(query + 变量): 一个 JSON 部分
# 2) 文件本体: 若干二进制部分,通过变量引用
# schema 侧: 文件参数类型声明为 Upload 标量
# schema: 上传头像
type Mutation {
uploadAvatar(file: Upload!): User!
}
// 客户端: FormData 携带操作 + 文件
const form = new FormData();
form.append("operations", JSON.stringify({
query: `mutation($file: Upload!) { uploadAvatar(file: $file) { id avatarUrl } }`,
variables: { file: null }, // 文件用 null 占位
}));
form.append("map", JSON.stringify({ "0": ["variables.file"] }));
form.append("0", file); // "0" 对应 map 里的索引
1.2 服务端处理
Apollo Server / graphql-yoga 都对 Upload 标量开箱支持:
// 服务端(Apollo Server)
const uploadAvatar = async (_parent, { file }, ctx) => {
const { createReadStream, filename, mimetype } = await file; // file 是 Promise
const stream = createReadStream();
const url = await storage.putFile(stream, { filename, mimetype });
return { id: ctx.user.id, avatarUrl: url };
};
# 服务端要点
# 1) file 是 Promise<FileUpload>: 先 await 再取流
# 2) createReadStream(): 文件以流形式读,不整体进内存(大文件友好)
# 3) 流必须被消费或取消(否则连接泄漏)
# 4) 大小/类型限制在服务端做(客户端限制可绕过)
二、大文件与分片上传
2.1 流式消费避免 OOM
# 大文件(GB 级)必须流式
# 1) 服务端: createReadStream → pipe 到对象存储/磁盘,别全量读内存
# 2) 上传超时: 大文件需长超时/分片
# 3) 分片协议: 断点续传(客户端按 5-10MB 分片)
# 4) 进度: 分片上报进度,UI 显示百分比
# 为什么分片: 一次性上传在弱网/中断时全功尽弃,分片可续传
2.2 分片上传的工程设计
# 分片上传模式(两阶段)
# 1) 初始化: POST 创建上传会话,服务端返回 uploadId
# 2) 分片上传: 每片 PUT(带 offset/序号),服务端拼接
# 3) 完成: 提交完成信号,服务端合并校验并落库
# GraphQL 表达
# mutation beginUpload(filename, size, mime) → { uploadId, partSize }
# mutation uploadPart(uploadId, partIndex, part: Upload) → { received }
# mutation completeUpload(uploadId) → { fileUrl }
# 变体: 也可用对象存储预签名 URL 直传(服务端只出签名)
2.3 预签名 URL 直传
# S3 预签名直传(大文件首选)
# 1) GraphQL mutation 请求预签名 URL(含权限/过期)
# 2) 客户端直接 PUT 到对象存储(不经 GraphQL 服务)
# 3) 完成后 mutation 通知服务端(可验证对象存在)
# 收益
# - 文件不过 GraphQL 服务(不占带宽/内存)
# - 并行/分片由对象存储原生支持
# - 权限由签名控制,服务端不存文件
三、文件下载与流式响应
3.1 返回 URL vs 流式下载
# 两种下载形态
# 1) 返回对象存储 URL: 客户端直接 GET(简单,静态文件首选)
# 2) 服务端流式响应: 文件需鉴权/动态生成/私有 → 走 API 流
# 私有文件: 用短时效签名 URL,或 GraphQL 服务代理下载流
3.2 流式响应(Streaming Response)
GraphQL 响应本质是 JSON,但可以配合 HTTP 流式传输分块返回:
# 场景
# - 大列表: 边查边返回(减少首包延迟)
# - AI 回复/长文本生成: token 级流式输出
# - 大数据导出: 分块写响应流
# 实现: @defer/@stream 指令(见下节)或订阅(事件流)
3.3 下载安全
# 下载安全清单
# 1) 私有文件必须校验授权(能访问资源 ≠ 能下载文件)
# 2) 文件名清洗(防路径穿越/注入)
# 3) Content-Type 白名单(防 HTML/脚本被当静态资源执行)
# 4) 下载限速/配额(防拖库)
# 5) 签名 URL 短时效(几分钟,防重放)
四、@defer 与 @stream:增量交付
4.1 增量交付解决什么
# 痛点
# 大查询必须整体计算完才返回 → 首屏等待长
# 大列表字段要全部取完才渲染 → 慢
# @defer: 延迟慢的字段(次要数据),先返回快的
# @stream: 列表字段分批返回(先来先渲染)
# 本质: 把"一个 JSON 整体"拆成"初始响应 + 增量补丁"
4.2 @defer 延迟字段
# 场景: 主信息先出,慢的推荐列表延迟
query ProductPage($id: ID!) {
product(id: $id) {
id name price
... @defer { # 这个片段延迟交付
recommendations { id name }
}
}
}
# 响应序列
# 第一次: { product: { id, name, price, recommendations: null } }
# 第二次: { product: { recommendations: [...] } } (增量补丁)
# 客户端: 首次渲染主信息,补丁到达后渐进更新
4.3 @stream 分批列表
# 场景: 大列表分批渲染
query Feed($first: Int!) {
posts(first: $first) {
id title
... @stream(initialCount: 10) { # 先 10 条,其余分批
comments { id body }
}
}
}
# @stream 语义
# 先返回 initialCount 条,其余按后续增量逐批交付
# 收益: 首屏用首批渲染,滚动时后续到达
# 注意: @defer/@stream 需要服务端支持(Apollo Router/服务端增量交付)
五、字节流与二进制数据的 Schema 建模
5.1 二进制在 GraphQL 中的表达
# 三种形态
# 1) Upload(进): 文件输入
# 2) 字符串(Base64): 小二进制(<1MB)可 base64,简单但膨胀 ~33%
# 3) URL/引用: 大二进制不嵌入响应,给地址
# 4) 字节流标量(Bytes): 协议敏感场景用二进制标量
# 决策: 小对象 base64,大对象给引用/URL,文件用 Upload
5.2 避免二进制污染响应
# 坏味道: 把整个文件 base64 塞进 query 响应
# 例: { imageData: "iVBOR..." } # 1MB 图片 → 1.3MB base64 → 膨胀 + 卡顿
# 正解: 返回 URL/ID,需要内容走下载
# 只在内嵌小图/即时签名场景用 base64
5.3 流式数据的 schema 设计
# 流式/实时数据的 schema 建模
# 1) 事件流: Subscription(服务端推送)
# 2) 大列表渐进: @stream / 游标分页
# 3) 长文本生成: 流式响应(SSE/订阅)
# 4) 二进制流: 独立下载端点(URL),别硬塞 query
# 原则: schema 表达"引用与结构",字节内容走专门通道
六、对象存储集成
6.1 上传 → 存储 → 引用
# 典型链路
# 客户端 → (Upload/分片) → GraphQL 服务 → 对象存储(S3/OSS)
# ↓
# 存引用(URL/ID)+ 元数据入库
# 落地要点
# 1) 文件与元数据分离: 元数据(类型/大小/owner)入库,文件在对象存储
# 2) CDN: 公开文件挂 CDN,加速分发
# 3) 生命周期: 未完成上传定期清理(孤儿文件)
# 4) 一致性: 删除用户时,其对象存储文件一并清理
6.2 存储键设计
# 存储键规范
# bucket/type/{owner_id}/{uuid}.{ext}
# 例: media/avatars/u_1001/9f3c...png
# 好处
# - 按 owner 归组便于权限/清理
# - uuid 防枚举(不用自增 id)
# - 类型前缀便于生命周期策略
6.3 上传元数据与治理
# 每次上传记录元数据
# { id, ownerId, bucket, key, size, mimetype, sha256, uploadedAt, status }
# 用途
# - 完整性校验(sha256 对账)
# - 配额统计(按用户/类型)
# - 审计(谁传了什么)
# - 孤儿清理(status=uploading 超时回收)
七、上传安全与配额
7.1 上传安全清单
# 1) 类型白名单: 只允许声明的 mimetype(图片/文档),防脚本上传
# 2) 大小限制: 服务端强制(maxFileSize),客户端限制可绕过
# 3) 内容校验: 图片消毒(防 SVG 脚本)、文件签名检查
# 4) 授权: 只有登录用户可传,只有 owner 可覆盖/删除
# 5) 配额: 按用户限制总大小/频率(防滥用存储)
# 6) 病毒扫描: 高风险环境文件扫描后可见
7.2 配额与限流
# 配额策略
# 按用户: 每日上传次数 + 总字节
# 按类型: 单个文件 maxSize(头像 5MB、视频 2GB)
# 超限响应: code: 'QUOTA_EXCEEDED',提示清理
# 协同: 网关速率限制 + 服务端配额双重
7.3 常见陷阱
- 文件整体进内存:大文件
await file后.buffer全读——必须用流。 - 类型只信客户端:客户端传
image/png实际是脚本——服务端校验内容。 - 上传不设超时:大文件慢传卡住连接——分片 + 超时 + 续传。
- 删除不同步:删数据库记录不删对象存储,存储泄漏——清理任务。
- 私有文件用公共 URL:签名 URL 短时效,别把私有对象挂公共访问。
Q1: Upload 与普通 mutation 变量能混用吗?
能。multipart 规范里操作是 JSON 部分、文件是独立部分,map 把文件索引映射到变量路径。一个请求可带多个文件(数组/对象嵌套变量),服务端按路径取。
Q2: 大文件真的适合走 GraphQL 吗?
看形态:走服务端中转(Upload)适合中小文件与需要服务端处理的文件;大文件/弱网建议预签名 URL 直传(服务端只签名,字节不经 GraphQL)。决策依据:文件是否需服务端加工 + 体积 + 带宽成本。
Q3: @defer 和 @stream 是标准吗?
是 Apollo 的增量交付提案,Apollo Router/部分服务端已支持,但并非所有客户端/服务端原生支持。接入前确认两端能力;否则退回分页/订阅。
Q4: 下载私有文件怎么做最安全?
短时效签名 URL 或经授权代理流。核心:下载时必须再次鉴权(资源可读 ≠ 可下载),URL 短时效防重放,文件名与类型白名单。
Q5: 上传失败为什么总出现"孤儿文件"?
上传中间步骤失败/超时,对象存储里留下半成品。解决:上传会话带状态,定期清理 uploading 状态超时的对象,配合分片续传降低失败率。
一句话总结
GraphQL 的文件与流式能力,靠的是"规范约定 + 工程配合":上传走 multipart 的
Upload标量、大文件用分片或预签名直传、下载走受控签名/授权流、大查询与长响应用@defer/@stream增量交付——schema 负责表达引用与结构,字节内容走专门的流式通道,让 GraphQL 既能查 JSON,也能吞吐文件。
相关阅读
- GraphQL 实时订阅与 SSE — 事件流的推送通道
- GraphQL 游标分页 — 大列表的分批消费
- GraphQL 服务端实现 — Upload 标量的服务端配置
- GraphQL 安全与防护 — 上传安全与配额
- GraphQL 持久化查询 — 白名单与性能
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。