文件上传
AdonisJS 对处理使用 multipart/form-data 内容类型发送的用户上传文件提供了一流的支持。这些文件使用 bodyparser 中间件 自动处理,并保存在你操作系统的 tmp 目录中。
之后,在控制器中,你可以访问这些文件、校验它们,并将它们移动到持久化位置,或诸如 S3 之类的云存储服务。
访问用户上传的文件
你可以使用 request.file 方法访问用户上传的文件。该方法接受字段名,并返回 MultipartFile 的实例。
import { HttpContext } from '@adonisjs/core/http'
export default class UserAvatarsController {
update({ request }: HttpContext) {
const avatar = request.file('avatar')
console.log(avatar)
}
}
如果单个输入字段用于上传多个文件,你可以使用 request.files 方法访问它们。该方法接受字段名,并返回 MultipartFile 实例的数组。
import { HttpContext } from '@adonisjs/core/http'
export default class InvoicesController {
update({ request }: HttpContext) {
const invoiceDocuments = request.files('documents')
for (let document of invoiceDocuments) {
console.log(document)
}
}
}
手动校验文件
你可以使用校验器,或通过 request.file 方法定义校验规则来校验文件。
在下面的示例中,我们将通过 request.file 方法内联定义校验规则,并使用 file.errors 属性访问校验错误。
const avatar = request.file('avatar', {
size: '2mb',
extnames: ['jpg', 'png', 'jpeg']
})
if (!avatar.isValid) {
return response.badRequest({
errors: avatar.errors
})
}
当处理文件数组时,你可以遍历文件,检查一个或多个文件是否未通过校验。
提供给 request.files 方法的校验选项会应用到所有文件。在下面的示例中,我们期望每个文件都小于 2mb,并且必须具有允许的某个文件扩展名。
const invoiceDocuments = request.files('documents', {
size: '2mb',
extnames: ['jpg', 'png', 'pdf']
})
/**
* 创建一个包含无效文档的集合
*/
let invalidDocuments = invoiceDocuments.filter((document) => {
return !document.isValid
})
if (invalidDocuments.length) {
/**
* 返回文件名及其对应的错误
*/
return response.badRequest({
errors: invalidDocuments.map((document) => {
name: document.clientName,
errors: document.errors,
})
})
}
使用校验器校验文件
与其手动校验文件(如上一节所示),你可以使用校验器 在校验流程中校验文件。使用校验器时,你不必手动检查错误;校验流程会负责处理这些。
// app/validators/user_validator.ts
import vine from '@vinejs/vine'
export const updateAvatarValidator = vine.compile(
vine.object({
avatar: vine.file({
size: '2mb',
extnames: ['jpg', 'png', 'pdf']
})
})
)
import { HttpContext } from '@adonisjs/core/http'
import { updateAvatarValidator } from '#validators/user_validator'
export default class UserAvatarsController {
async update({ request }: HttpContext) {
const { avatar } = await request.validateUsing(
updateAvatarValidator
)
}
}
可以使用 vine.array 类型来校验文件数组。例如:
import vine from '@vinejs/vine'
export const createInvoiceValidator = vine.compile(
vine.object({
documents: vine.array(
vine.file({
size: '2mb',
extnames: ['jpg', 'png', 'pdf']
})
)
})
)
将文件移动到持久化位置
默认情况下,用户上传的文件保存在你操作系统的 tmp 目录中,并可能在计算机清理 tmp 目录时被删除。
因此,建议将文件存储在持久化位置。你可以使用 file.move 在同一文件系统内移动文件。该方法接受要移动文件到的目录的绝对路径。
import app from '@adonisjs/core/services/app'
const avatar = request.file('avatar', {
size: '2mb',
extnames: ['jpg', 'png', 'jpeg']
})
/**
* 将头像移动到 "storage/uploads" 目录
*/
await avatar.move(app.makePath('storage/uploads'))
建议为移动的文件提供一个唯一的随机名称。为此,你可以使用 cuid 辅助函数。
import { cuid } from '@adonisjs/core/helpers'
import app from '@adonisjs/core/services/app'
await avatar.move(app.makePath('storage/uploads'), {
name: `${cuid()}.${avatar.extname}`
})
文件移动完成后,你可以将其名称存入数据库以便后续引用。
await avatar.move(app.makePath('uploads'))
/**
* 将文件名保存为头像的虚拟代码
* 在用户模型上并持久化到
* 数据库。
*/
auth.user!.avatar = avatar.fileName!
await auth.user.save()
文件属性
以下是你可以在 MultipartFile 实例上访问的属性列表。
| Property | 描述 |
|---|---|
fieldName | HTML 输入字段的名称。 |
clientName | 用户计算机上的文件名。 |
size | 文件大小(字节)。 |
extname | 文件扩展名 |
errors | 与给定文件关联的错误数组。 |
type | 文件的 mime 类型 |
subtype | 文件的 mime 子类型。 |
filePath | move 操作之后文件的绝对路径。 |
fileName | move 操作之后的文件名。 |
tmpPath | 文件在 tmp 目录中的绝对路径。 |
meta | 与文件关联的元数据,以键值对形式存在。该对象默认是空的。 |
validated | 一个布尔值,表示文件是否已被校验。 |
isValid | 一个布尔值,表示文件是否通过了校验规则。 |
hasErrors | 一个布尔值,表示是否有一个或多个错误与给定文件关联。 |
提供文件
如果你已将用户上传的文件持久化在与应用代码相同的文件系统中,你可以通过创建一个路由并使用 response.download 方法来提供这些文件。
import { sep, normalize } from 'node:path'
import app from '@adonisjs/core/services/app'
import router from '@adonisjs/core/services/router'
const PATH_TRAVERSAL_REGEX = /(?:^|[\\/])\.\.(?:[\\/]|$)/
router.get('/uploads/*', ({ request, response }) => {
const filePath = request.param('*').join(sep)
const normalizedPath = normalize(filePath)
if (PATH_TRAVERSAL_REGEX.test(normalizedPath)) {
return response.badRequest('Malformed path')
}
const absolutePath = app.makePath('uploads', normalizedPath)
return response.download(absolutePath)
})
- 我们使用 通配符路由参数 获取文件路径,并将数组转换为字符串。
- 接下来,我们使用 Node.js 的 path 模块对路径进行规范化。
- 使用
PATH_TRAVERSAL_REGEX保护此路由免受 路径遍历 攻击。 - 最后,我们将
normalizedPath转换为uploads目录内的绝对路径,并使用response.download方法提供文件。
使用 Drive 上传和提供文件
Drive 是由 AdonisJS 核心团队创建的文件系统抽象层。你可以使用 Drive 管理用户上传的文件,将它们存储在本地文件系统中,或移动到 S3、GCS 之类的云存储服务。
我们建议使用 Drive,而不是手动上传和提供文件。Drive 处理了许多安全问题(如路径遍历),并在多个存储提供商之间提供统一的 API。
进阶 - 自行处理 multipart 流
你可以关闭 multipart 请求的自动处理,并为高级用例自行处理流。打开 config/bodyparser.ts 文件,并更改以下选项之一以禁用自动处理。
{
multipart: {
/**
* 设为 false,如果你想为所有 HTTP 请求
* 手动自行处理 multipart 流
*/
autoProcess: false
}
}
{
multipart: {
/**
* 定义一个路由模式数组,针对这些路由
* 你想自行处理 multipart 流。
*/
processManually: ['/assets']
}
}
一旦你禁用了自动处理,就可以使用 request.multipart 对象来处理单个文件。
在下面的示例中,我们使用 Node.js 的 stream.pipeline 方法处理 multipart 可读流,并将其写入磁盘上的文件。不过,你也可以将此文件流式传输到某个外部服务,如 s3。
import { createWriteStream } from 'node:fs'
import app from '@adonisjs/core/services/app'
import { pipeline } from 'node:stream/promises'
import { HttpContext } from '@adonisjs/core/http'
export default class AssetsController {
async store({ request }: HttpContext) {
/**
* 步骤 1:定义文件监听器
*/
request.multipart.onFile('*', {}, async (part, reporter) => {
part.pause()
part.on('data', reporter)
const filePath = app.makePath(part.file.clientName)
await pipeline(part, createWriteStream(filePath))
return { filePath }
})
/**
* 步骤 2:处理流
*/
await request.multipart.process()
/**
* 步骤 3:访问已处理的文件
*/
return request.allFiles()
}
}
-
multipart.onFile方法接受你要处理文件的输入字段名。你可以使用通配符*处理所有文件。 -
onFile监听器接收part(可读流)作为第一个参数,接收reporter函数作为第二个参数。 -
reporter函数用于跟踪流进度,以便 AdonisJS 在处理完流之后,能让你访问已处理的字节数、文件扩展名和其他元数据。 -
最后,你可以从
onFile监听器返回一个属性对象,它们会与使用request.file或request.allFiles()方法访问的文件对象合并。
错误处理
你必须监听 part 对象上的 error 事件,并手动处理错误。通常,流读取器(可写流)会在内部监听此事件并中止写入操作。
校验流片段
即使你自行处理 multipart 流,AdonisJS 也允许你校验流片段(即文件)。如果发生错误,error 事件会在 part 对象上触发。
multipart.onFile 方法接受校验选项作为第二个参数。此外,务必监听 data 事件并将 reporter 方法绑定到它。否则,不会执行任何校验。
request.multipart.onFile('*', {
size: '2mb',
extnames: ['jpg', 'png', 'jpeg']
}, async (part, reporter) => {
/**
* 下面两行是执行流校验所必需的
*/
part.pause()
part.on('data', reporter)
})