文件上传

文件上传

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描述
fieldNameHTML 输入字段的名称。
clientName用户计算机上的文件名。
size文件大小(字节)。
extname文件扩展名
errors与给定文件关联的错误数组。
type文件的 mime 类型
subtype文件的 mime 子类型
filePathmove 操作之后文件的绝对路径。
fileNamemove 操作之后的文件名。
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。

了解有关 Drive 的更多信息

进阶 - 自行处理 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.filerequest.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)
})