Encore.ts 文件上传实战:从单文件入库到多文件与大文件处理
【免费下载链接】encoreThe infrastructure platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/encor/encore
在 Encore.ts 后端中,客户端文件上传是一个常见但需要精细处理的场景:既要接收multipart/form-data请求,又要突破默认请求体大小限制,还要将二进制数据安全地落库并提供下载。本文基于 docs/ts/how-to/file-uploads.md 这一官方指南,结合仓库内 Encore.ts 运行时源码与解析器实现,完整讲解如何使用 Raw Endpoint +busboy+ PostgreSQLBYTEA完成单文件、多文件、大文件上传以及从数据库取回文件的完整闭环。读完本文,你将掌握 Encore.ts 中文件上传的标准落地模式,并理解bodyLimit、Raw Endpoint 请求/响应模型与数据库二进制存储的底层机制。
为什么文件上传需要 Raw Endpoint
Encore.ts 常规的api端点会基于请求/响应 schema 做自动解析与类型校验,适合结构化 JSON 交互。但文件上传场景下,请求体是流式的multipart/form-data,往往需要直接操作底层 HTTP 请求对象——这正是 Raw Endpoints 的用武之地。
api.raw与api用法相似,但不接收请求/响应 schema,而是像 Node.js 原生http模块和 Express.js 那样,把(req, resp)两个参数直接交给开发者:
import { api } from "encore.dev/api"; export const myRawEndpoint = api.raw( { expose: true, path: "/raw", method: "GET" }, async (req, resp) => { resp.writeHead(200, { "Content-Type": "text/plain" }); resp.end("Hello, raw world!"); }, );从运行时源码看,api.raw接受的处理器类型为RawHandler = (req: IncomingMessage, resp: ServerResponse) => void(见 runtimes/js/encore.dev/api/mod.ts),其中RawRequest/RawResponse均从 Node.js 的http模块类型导出(见 runtimes/js/encore.dev/api/node_http.ts)。这意味着你在原生 Node.js HTTP 服务里写过的所有流式处理技巧,在 Encore.ts 中都可以直接复用。
文件上传的核心链路就是:客户端以multipart/form-data提交 → Raw Endpoint 拿到原生请求流 → 用busboy解析 multipart 边界与文件字段 → 收集文件数据为Buffer→ 写入 PostgreSQL 的BYTEA列。
单文件上传:存进数据库
定义数据库与迁移
首先用 Encore.ts 的数据库抽象定义一个名为files的 PostgreSQL 数据库,迁移文件放在./migrations目录。Encore 会自动完成数据库的 provisioning、迁移与连接:
import { SQLDatabase } from "encore.dev/storage/sqldb"; export const DB = new SQLDatabase("files", { migrations: "./migrations", });运行时层面,SQLDatabase的构造器会通过runtime.RT.sqlDatabase(name)建立连接,并提供queryRow、exec、acquire、事务等能力(见 runtimes/js/encore.dev/storage/sqldb/database.ts)。与new SQLDatabase相对的是SQLDatabase.named(name),用于引用已存在的数据库;如果数据库尚不存在,则应使用new创建。
迁移文件migrations/1_create_tables.up.sql建表如下:
CREATE TABLE files ( name TEXT PRIMARY KEY, data BYTEA NOT NULL );name作为主键,天然支持“同名文件覆盖”的幂等写入;data使用BYTEA(PostgreSQL 的二进制大对象类型)存储文件字节。
编写 Raw Endpoint 接收文件
单文件上传端点的完整实现如下:
-- upload.ts -- import { api } from "encore.dev/api"; import log from "encore.dev/log"; import busboy from "busboy"; import { SQLDatabase } from "encore.dev/storage/sqldb"; // Define a database named 'files', using the database migrations // in the "./migrations" folder. Encore automatically provisions, // migrates, and connects to the database. export const DB = new SQLDatabase("files", { migrations: "./migrations", }); type FileEntry = { data: any[]; filename: string }; /** * Raw endpoint for storing a single file to the database. * Setting bodyLimit to null allows for unlimited file size. */ export const save = api.raw( { expose: true, method: "POST", path: "/upload", bodyLimit: null }, async (req, res) => { const bb = busboy({ headers: req.headers, limits: { files: 1 }, }); const entry: FileEntry = { filename: "", data: [] }; bb.on("file", (_, file, info) => { entry.filename = info.filename; file .on("data", (data) => { entry.data.push(data); }) .on("close", () => { log.info(`File ${entry.filename} uploaded`); }) .on("error", (err) => { bb.emit("error", err); }); }); bb.on("close", async () => { try { const buf = Buffer.concat(entry.data); await DB.exec` INSERT INTO files (name, data) VALUES (${entry.filename}, ${buf}) ON CONFLICT (name) DO UPDATE SET data = ${buf} `; log.info(`File ${entry.filename} saved`); // Redirect to the root page res.writeHead(303, { Connection: "close", Location: "/" }); res.end(); } catch (err) { bb.emit("error", err); } }); bb.on("error", async (err) => { res.writeHead(500, { Connection: "close" }); res.end(`Error: ${(err as Error).message}`); }); req.pipe(bb); return; }, );逐段拆解其中的关键设计:
bodyLimit: null:默认情况下 Encore.ts 端点有 2 MiB 的请求体上限(源码注释明确“If left unspecified it defaults to a reasonable default (currently 2MiB). If set tonull, the body size is unlimited.”,见 runtimes/js/encore.dev/api/mod.ts)。文件上传通常超出该值,因此显式设为null解除限制。busboy({ headers: req.headers, limits: { files: 1 } }):busboy直接消费req.headers解析 multipart 格式;limits: { files: 1 }限定只处理一个文件字段,是单文件场景的防御性配置。- 事件驱动收集数据:
file事件给出文件流,data事件把每个 chunk 推入entry.data数组;close事件标记该文件接收完毕;error事件统一转发到bb上,由外层集中处理错误。 bb.on("close")统一落库:所有文件接收完成后,Buffer.concat(entry.data)把分片拼成完整二进制;DB.exec以 SQL 模板语法执行INSERT ... ON CONFLICT (name) DO UPDATE SET data = ...,实现“存在则覆盖”的幂等写入。- 响应处理:成功后返回
303 See Other并重定向到/;出错时由bb.on("error")返回500与错误消息。注意res.writeHead中显式带上Connection: "close",避免长连接下响应与流关闭的时序问题。 req.pipe(bb); return;:把请求体流直接管道给busboy。由于 Raw Endpoint 处理器不返回响应体,这里return即可,响应全部通过res写入。
前端表单
对应的multipart/form-data表单极为简单:
<form method="POST" enctype="multipart/form-data" action="/upload"> <label for="filefield">Single file upload:</label><br> <input type="file" name="filefield"> <input type="submit"> </form>enctype="multipart/form-data"是文件上传的必要条件;action="/upload"与端点声明的path: "/upload"对齐。
多文件上传:循环落库
多文件上传采用完全相同的思路,区别在于 busboy 事件监听器需要处理多个文件,且落库时逐条遍历:
export const saveMultiple = api.raw( { expose: true, method: "POST", path: "/upload-multiple", bodyLimit: null }, async (req, res) => { const bb = busboy({ headers: req.headers }); const entries: FileEntry[] = []; bb.on("file", (_, file, info) => { const entry: FileEntry = { filename: info.filename, data: [] }; file .on("data", (data) => { entry.data.push(data); }) .on("close", () => { entries.push(entry); }) .on("error", (err) => { bb.emit("error", err); }); }); bb.on("close", async () => { try { for (const entry of entries) { const buf = Buffer.concat(entry.data); await DB.exec` INSERT INTO files (name, data) VALUES (${entry.filename}, ${buf}) ON CONFLICT (name) DO UPDATE SET data = ${buf} `; log.info(`File ${entry.filename} saved`); } // Redirect to the root page res.writeHead(303, { Connection: "close", Location: "/" }); res.end(); } catch (err) { bb.emit("error", err); } }); bb.on("error", async (err) => { res.writeHead(500, { Connection: "close" }); res.end(`Error: ${(err as Error).message}`); }); req.pipe(bb); return; }, );与单文件版本的关键差异:
- 不再传
limits: { files: 1 },允许多个文件字段/同名多文件; - 每个
file事件都创建独立的entry,在各自close时推入entries数组; - 落库阶段用
for...of循环逐个Buffer.concat并执行INSERT ... ON CONFLICT DO UPDATE。
对应的前端表单给<input>加上multiple属性即可:
<form method="POST" enctype="multipart/form-data" action="/upload-multiple"> <label for="filefield">Multiple files upload:</label><br> <input type="file" name="filefield" multiple> <input type="submit"> </form>大文件上传与 bodyLimit 调优
上传大文件时最常遇到的报错是Maximum request length exceeded,根因就是端点默认的请求体上限。
bodyLimit的语义(单位字节)在运行时 API 类型中有明确定义(见 runtimes/js/encore.dev/api/mod.ts):
| 取值 | 行为 |
|---|---|
| 未设置(默认) | 请求体上限为2 MiB |
数值(如10 * 1024 * 1024) | 上限为指定字节数,超出后 Encore 停止处理并返回错误 |
null | 不限大小 |
因此两个调优方向:
- 按需放宽:若文件有合理大小上限(如视频/备份包),显式指定数值,避免恶意超大请求耗尽资源;
- 完全放开:对无法预估文件大小的场景设置
bodyLimit: null(官方示例即采用此方式)。
从解析器侧也可以印证该配置的传播路径:TS 解析器会读取端点配置中的bodyLimit(Option<Nullable<u64>>)并将其映射为内部请求体大小限制(见 tsparser/src/parser/resources/apis/api.rs 与 tsparser/src/parser/resources/apis/api.rs),也就是说该限制在 Encore 请求进入你的业务代码之前就会生效。同时请注意:bodyLimit只是“请求体大小”的门槛,busboy侧同样可以通过limits配置(如fileSize)做第二层防护。
从数据库取回文件
文件入库后,通过一个 GET Raw Endpoint 按文件名查询并回传文件数据:
import { api } from "encore.dev/api"; import { APICallMeta, currentRequest } from "encore.dev"; export const DB = new SQLDatabase("files", { migrations: "./migrations", }); export const get = api.raw( { expose: true, method: "GET", path: "/files/:name" }, async (req, resp) => { try { const { name } = (currentRequest() as APICallMeta).pathParams; const row = await DB.queryRow` SELECT data FROM files WHERE name = ${name}`; if (!row) { resp.writeHead(404); resp.end("File not found"); return; } const chunk = Buffer.from(row.data); resp.writeHead(200, { Connection: "close" }); resp.end(chunk); } catch (err) { resp.writeHead(500); resp.end((err as Error).message); } }, );要点说明:
- 路径参数:
path: "/files/:name"声明了 URL 参数,在处理器内通过currentRequest()获取APICallMeta,再解构出pathParams.name; - 查询与返回:
DB.queryRow执行参数化 SQL 查询(自动防注入),row.data是数据库返回的二进制字节,用Buffer.from(row.data)重建 Buffer 后直接resp.end(chunk)作为响应体; - 错误语义:未找到文件返回
404 File not found,数据库异常返回500,客户端可据此区分失败原因。
完成后,即可通过GET http://localhost:4000/files/name-of-file.ext下载之前上传的文件。
小结与延伸
至此你已经掌握 Encore.ts 文件上传的完整套路:
- 用 Raw Endpoint(
api.raw)获得对底层 HTTP 请求/响应对象的完全控制; - 通过
bodyLimit: null或显式数值解除/放宽默认 2 MiB 请求体限制; - 用
busboy解析multipart/form-data,按data → close → error事件流收集文件字节; - 以
Buffer+BYTEA组合把二进制数据写入 PostgreSQL,ON CONFLICT (name) DO UPDATE保证同名覆盖; - 用 GET Raw Endpoint +
currentRequest()读取路径参数,把BYTEA重新转回Buffer响应给客户端。
这套“Raw Endpoint + 流式解析 + BYTEA 落库”的模式同样可以扩展到对象存储(Object Storage)、Webhook 接收等其他需要原始请求访问的场景(更多 Raw Endpoint 用法见 Defining Raw Endpoints)。官方在 examples 仓库 中提供了配套的完整可运行示例,包含前端表单与数据库迁移,可直接对照参考。
需要留意的是:把大文件全部载入内存再写入数据库,适合中小文件与原型验证;对超大文件,建议结合对象存储分片上传,避免Buffer.concat造成的内存峰值。
【免费下载链接】encoreThe infrastructure platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/encor/encore
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考