Drive
本指南介绍如何在 AdonisJS 中使用 Drive 管理文件存储。你将学到如何:
- 为你的应用安装和配置 Drive
- 使用
moveToDisk将文件上传到本地或云存储 - 使用公开 URL 和签名 URL 展示文件
- 配置多个存储服务(S3、GCS、R2、DigitalOcean Spaces、Supabase)
- 实现从浏览器直接上传到云存储
- 使用 Drive 的 fakes API 测试文件上传
概述
AdonisJS Drive 是 FlyDrive (由 AdonisJS 核心团队创建和维护)之上的一层封装。它提供统一的 API,用于跨多个存储提供商管理用户上传的文件,包括本地文件系统、Amazon S3、Google Cloud Storage、Cloudflare R2、DigitalOcean Spaces 和 Supabase Storage。
Drive 的关键优势在于,你可以在不更改应用代码的情况下切换存储服务。在开发期间,你可能为了方便而将文件存储在本地文件系统上。在生产环境中,你只需更改一个环境变量即可切换到云提供商。你的控制器、服务和模板都保持不变。
Drive 处理文件存储操作,如读取、写入和删除文件。它不处理 HTTP multipart 解析。你应先阅读 文件上传指南 ,了解 AdonisJS 如何处理来自 HTTP 请求的上传文件。
安装
使用以下命令安装并配置 @adonisjs/drive 包:
node ace add @adonisjs/drive
该命令会提示你选择一个或多个存储服务。
add 命令执行的步骤
- 安装
@adonisjs/drive包,以及所选服务所需的任何 peer 依赖。 - 在
adonisrc.ts中注册 Drive 服务提供者。 - 使用你所选的服务创建
config/drive.ts配置文件。 - 将所选服务的环境变量添加到
.env和start/env.ts。
node ace add 命令需要交互式地选择服务。如果你需要非交互式地安装 Drive(例如在 CI 脚本中),可以手动执行这些步骤:
- 安装包:
npm install @adonisjs/drive - 为你的存储服务安装 peer 依赖(例如,S3 使用
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner) - 在
adonisrc.ts中注册提供者:将() => import('@adonisjs/drive/drive_provider')添加到providers数组 - 使用你的服务配置创建
config/drive.ts(见下方 配置 部分) - 将所需的环境变量添加到
.env和start/env.ts
配置
Drive 的配置存储在 config/drive.ts 中。文件内容取决于你在安装期间选择了哪些服务。
配置文件中的 default 属性决定了当你不显式指定服务时使用哪个服务。DRIVE_DISK 环境变量控制这一点,让你可以在本地使用 fs,并在生产环境中切换到 s3。
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, services } from '@adonisjs/drive'
const driveConfig = defineConfig({
default: env.get('DRIVE_DISK'),
services: {
// Service configurations go here
},
})
export default driveConfig
declare module '@adonisjs/drive/types' {
export interface DriveDisks extends InferDriveDisks<typeof driveConfig> {}
}
本地文件系统
本地文件系统驱动将文件存储在你服务器的磁盘上,并可通过 AdonisJS HTTP 服务器提供这些文件。
环境变量
DRIVE_DISK=fs
配置
{
services: {
fs: services.fs({
/**
* The directory where files are stored. Use app.makePath
* to create an absolute path from your application root.
*/
location: app.makePath('storage'),
/**
* When true, Drive registers a route to serve files
* from the local filesystem via your AdonisJS server.
*/
serveFiles: true,
/**
* The URL path prefix for serving files. A file stored
* as "avatars/1.jpg" becomes accessible at "/uploads/avatars/1.jpg".
*/
routeBasePath: '/uploads',
/**
* The default visibility for files. Public files are
* accessible via URL. Private files require signed URLs.
*/
visibility: 'public',
}),
}
}
当启用 serveFiles 时,你可以通过运行 node ace list:routes 来验证路由是否已注册。你应该会看到一个类似 /uploads/* 的路由,其处理器为 drive.fs.serve。
Amazon S3
环境变量
DRIVE_DISK=s3
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=us-east-1
S3_BUCKET=your_bucket_name
配置
{
services: {
s3: services.s3({
credentials: {
accessKeyId: env.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY'),
},
region: env.get('AWS_REGION'),
bucket: env.get('S3_BUCKET'),
visibility: 'public',
}),
}
}
Google Cloud Storage
环境变量
DRIVE_DISK=gcs
GCS_KEY=file://gcs_key.json
GCS_BUCKET=your_bucket_name
GCS_KEY 变量指向你的 Google Cloud 服务账户的 JSON 密钥文件。file:// 前缀表示该路径相对于你的应用根目录。
配置
{
services: {
gcs: services.gcs({
credentials: env.get('GCS_KEY'),
bucket: env.get('GCS_BUCKET'),
visibility: 'public',
}),
}
}
Cloudflare R2
Cloudflare R2 使用 S3 兼容 API。region 必须设置为 'auto'。
环境变量
DRIVE_DISK=r2
R2_KEY=your_access_key
R2_SECRET=your_secret_key
R2_BUCKET=your_bucket_name
R2_ENDPOINT=https://your_account_id.r2.cloudflarestorage.com
配置
{
services: {
r2: services.s3({
credentials: {
accessKeyId: env.get('R2_KEY'),
secretAccessKey: env.get('R2_SECRET'),
},
region: 'auto',
bucket: env.get('R2_BUCKET'),
endpoint: env.get('R2_ENDPOINT'),
visibility: 'public',
}),
}
}
DigitalOcean Spaces
DigitalOcean Spaces 使用带有自定义端点的 S3 兼容 API。
环境变量
DRIVE_DISK=spaces
SPACES_KEY=your_access_key
SPACES_SECRET=your_secret_key
SPACES_REGION=nyc3
SPACES_BUCKET=your_bucket_name
SPACES_ENDPOINT=https://${SPACES_REGION}.digitaloceanspaces.com
配置
{
services: {
spaces: services.s3({
credentials: {
accessKeyId: env.get('SPACES_KEY'),
secretAccessKey: env.get('SPACES_SECRET'),
},
region: env.get('SPACES_REGION'),
bucket: env.get('SPACES_BUCKET'),
endpoint: env.get('SPACES_ENDPOINT'),
visibility: 'public',
}),
}
}
Supabase Storage
Supabase Storage 使用 S3 兼容 API。
环境变量
DRIVE_DISK=supabase
SUPABASE_STORAGE_KEY=your_access_key
SUPABASE_STORAGE_SECRET=your_secret_key
SUPABASE_STORAGE_REGION=your_region
SUPABASE_STORAGE_BUCKET=your_bucket_name
SUPABASE_ENDPOINT=https://your_project.supabase.co/storage/v1/s3
配置
{
services: {
supabase: services.s3({
credentials: {
accessKeyId: env.get('SUPABASE_STORAGE_KEY'),
secretAccessKey: env.get('SUPABASE_STORAGE_SECRET'),
},
region: env.get('SUPABASE_STORAGE_REGION'),
bucket: env.get('SUPABASE_STORAGE_BUCKET'),
endpoint: env.get('SUPABASE_ENDPOINT'),
visibility: 'public',
}),
}
}
基本用法
Drive 扩展了 AdonisJS 的 MultipartFile 类,并添加了 moveToDisk 方法。该方法将上传的文件从其临时位置移动到你配置的存储服务。
以下示例展示了上传和展示用户头像的完整流程。
-
定义路由
创建用于展示个人资料页和处理头像上传的路由。
start/routes.tsimport router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('profile', [controllers.Profile, 'show']) router.post('profile/avatar', [controllers.Profile, 'updateAvatar']) -
创建控制器
控制器使用
moveToDisk处理文件上传。使用 UUID 生成一个唯一的文件名,以避免多个用户上传同名文件时发生冲突。app/controllers/profile_controller.tsimport string from '@adonisjs/core/helpers/string' import type { HttpContext } from '@adonisjs/core/http' import { updateAvatarValidator } from '#validators/user' export default class ProfileController { async show({ view, auth }: HttpContext) { const user = auth.getUserOrFail() return view.render('pages/profile/show', { user }) } async updateAvatar({ request, auth, response, session }: HttpContext) { const user = auth.getUserOrFail() const { avatar } = await request.validateUsing(updateAvatarValidator) /** * Generate a unique filename using a UUID to avoid * collisions when multiple users upload files with * the same name. */ const key = `${string.uuid()}.${avatar.extname ?? 'txt'}` /** * Move the uploaded file to the default disk. * The file is moved from its temporary location * to your configured storage service. */ await avatar.moveToDisk(key) /** * Store only the key in the database, not the full URL. * This allows you to switch storage services without * updating database records. */ user.avatar = key await user.save() session.flash('success', 'Avatar updated successfully!') return response.redirect().back() } } -
创建模板
模板使用
driveUrl辅助方法展示当前头像,并提供一个用于上传新头像的表单。resources/views/pages/profile/show.edge@layout() <div class="form-container"> <div> @if(user.avatar) <img src="{{ await driveUrl(user.avatar) }}" alt="User avatar" style="max-width: 200px; border-radius: 8px;" /> @end @form({ route: 'profile.update_avatar', method: 'POST', enctype: 'multipart/form-data' }) <div> @field.root({ name: 'avatar' }) @!input.control({ type: 'file' }) @!field.error() @end </div> <div> @!button({ type: 'submit', text: 'Update avatar' }) </div> @end </div> </div> @enddriveUrlEdge 辅助方法为文件生成公开 URL。对于本地文件系统,它返回一个类似/uploads/abc-123.jpg的路径。对于云提供商,它返回文件的完整 URL。
指定磁盘
默认情况下,moveToDisk 使用 DRIVE_DISK 环境变量中指定的磁盘。你可以将不同的磁盘作为第二个参数显式指定:
// Move to the default disk
await avatar.moveToDisk(key)
// Move to a specific disk
await avatar.moveToDisk(key, 's3')
await avatar.moveToDisk(key, 'gcs')
await avatar.moveToDisk(key, 'r2')
moveToDisk 方法不同于 move 方法。move 方法仅在本地文件系统内移动文件。moveToDisk 方法将文件移动到你配置的 Drive 存储服务,它可以是本地的,也可以是基于云的。
使用 Drive 服务
对于文件上传之外的操作,你可以直接使用 Drive 服务。从 @adonisjs/drive/services/main 导入它,以读取文件、写入文件、删除文件等。
import drive from '@adonisjs/drive/services/main'
export class FileService {
/**
* Get the default disk instance
*/
async readFile(key: string) {
const disk = drive.use()
return disk.get(key)
}
/**
* Get a specific disk instance
*/
async readFromS3(key: string) {
const disk = drive.use('s3')
return disk.get(key)
}
/**
* Write content directly to storage
*/
async writeReport(content: string) {
const disk = drive.use()
await disk.put('reports/monthly.txt', content)
}
/**
* Delete a file
*/
async deleteFile(key: string) {
const disk = drive.use()
await disk.delete(key)
}
/**
* Check if a file exists
*/
async fileExists(key: string) {
const disk = drive.use()
return disk.exists(key)
}
}
Drive 服务为文件操作提供了更多方法。完整的可用方法列表请参阅 FlyDrive Disk API 文档 。
生成 URL
Drive 提供两种生成文件 URL 的方法:用于公开文件的 getUrl 和用于私有文件的 getSignedUrl。
公开 URL
使用 getUrl 为具有 public 可见性的文件生成 URL。任何拥有该 URL 的人都可以访问这些文件。
import drive from '@adonisjs/drive/services/main'
export default class FilesController {
async show({ params }: HttpContext) {
const disk = drive.use()
const url = await disk.getUrl(params.key)
return { url }
}
}
在 Edge 模板中,使用 driveUrl 辅助方法:
<img src="{{ await driveUrl(file.key) }}" alt="File" />
{{-- Specify a disk --}}
<img src="{{ await driveUrl(file.key, 's3') }}" alt="File" />
签名 URL
使用 getSignedUrl 为具有 private 可见性的文件生成临时 URL。这些 URL 会在指定的时长后过期。
当你想控制对文件的访问时,签名 URL 很有用。例如,你可以将发票以私有可见性存储,并仅在授权用户请求下载时才生成签名 URL。
import drive from '@adonisjs/drive/services/main'
export default class InvoicesController {
async download({ params, auth }: HttpContext) {
const user = auth.getUserOrFail()
const invoice = await Invoice.query()
.where('id', params.id)
.where('userId', user.id)
.firstOrFail()
const disk = drive.use()
/**
* Generate a signed URL that expires in 30 minutes.
* The user can only download the file while the URL is valid.
*/
const url = await disk.getSignedUrl(invoice.fileKey, {
expiresIn: '30 mins',
})
return { url }
}
}
在 Edge 模板中,使用 driveSignedUrl 辅助方法:
<a href="{{ await driveSignedUrl(invoice.fileKey) }}">
Download Invoice
</a>
{{-- With expiration --}}
<a href="{{ await driveSignedUrl(invoice.fileKey, { expiresIn: '1 hour' }) }}">
Download Invoice
</a>
{{-- Specify a disk --}}
<a href="{{ await driveSignedUrl(invoice.fileKey, 's3', { expiresIn: '1 hour' }) }}">
Download Invoice
</a>
直接上传
直接上传允许浏览器绕过你的 AdonisJS 服务器,将文件直接上传到你的云存储提供商。这对于大文件很有用,因为数据不流经你的服务器,从而降低内存使用和带宽成本。
流程如下:
- 浏览器向你的服务器请求一个签名的上传 URL。
- 你的服务器使用 Drive 生成一个签名 URL 并返回它。
- 浏览器使用签名 URL 将文件直接上传到云提供商。
- 浏览器通知你的服务器上传已完成。
-
定义路由
创建用于生成签名上传 URL 和处理上传完成的路由。
start/routes.tsimport router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.post('uploads/presign', [controllers.Uploads, 'presign']) router.post('uploads/complete', [controllers.Uploads, 'complete']) -
创建控制器
控制器使用
getSignedUploadUrl生成签名上传 URL,并处理上传完成的通知。app/controllers/uploads_controller.tsimport string from '@adonisjs/core/helpers/string' import drive from '@adonisjs/drive/services/main' import type { HttpContext } from '@adonisjs/core/http' export default class UploadsController { /** * Generate a signed URL for direct upload */ async presign({ request, auth }: HttpContext) { const user = auth.getUserOrFail() const { filename, contentType } = request.only(['filename', 'contentType']) /** * Generate a unique key for the file */ const key = `uploads/${user.id}/${string.uuid()}-${filename}` const disk = drive.use('s3') /** * Generate a signed URL that allows the browser * to upload directly to S3 */ const signedUrl = await disk.getSignedUploadUrl(key, { expiresIn: '15 mins', contentType, }) return { key, url: signedUrl, } } /** * Handle upload completion notification */ async complete({ request, auth }: HttpContext) { const user = auth.getUserOrFail() const { key } = request.only(['key']) /** * Verify the file exists */ const disk = drive.use('s3') const exists = await disk.exists(key) if (!exists) { return { error: 'File not found' } } /** * Save the file reference to your database */ await user.related('files').create({ key }) return { success: true } } } -
实现客户端上传
在前端,请求一个签名 URL,然后将文件直接上传到云提供商。
resources/js/upload.tsasync function uploadFile(file: File) { /** * Step 1: Request a signed upload URL from your server */ const presignResponse = await fetch('/uploads/presign', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ filename: file.name, contentType: file.type, }), }) const { key, url } = await presignResponse.json() /** * Step 2: Upload the file directly to cloud storage * using the signed URL */ const uploadResponse = await fetch(url, { method: 'PUT', headers: { 'Content-Type': file.type, }, body: file, }) if (!uploadResponse.ok) { throw new Error('Upload failed') } /** * Step 3: Notify your server that the upload is complete */ await fetch('/uploads/complete', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ key }), }) return key }
测试
Drive 提供了一个 fakes API,用于在不与真实存储交互的情况下测试文件上传。当你伪造一个磁盘时,所有操作都会被重定向到一个临时的本地目录,而不是配置的存储服务。
import { test } from '@japa/runner'
import { fileGenerator } from '@poppinss/file-generator'
import drive from '@adonisjs/drive/services/main'
import { UserFactory } from '#database/factories/user_factory'
test.group('Profile | update avatar', () => {
test('user can upload an avatar', async ({ client }) => {
/**
* Fake the default disk. All file operations will now
* use a temporary local directory instead of the
* configured storage service. The `using` keyword
* automatically restores the real disk when the test ends.
*/
using fakeDisk = drive.fake()
const user = await UserFactory.create()
/**
* Generate a buffer with valid PNG magic bytes.
* AdonisJS uses magic byte detection to determine file types,
* so plain Buffer.from('fake-image') will be rejected.
*/
const pngFile = await fileGenerator.generatePng(1)
await client
.post('/profile/avatar')
.file('avatar', pngFile.contents, {
filename: pngFile.name,
contentType: pngFile.mime,
})
.loginAs(user)
.assertStatus(200)
/**
* Assert the file was stored
*/
fakeDisk.assertExists(`${user.id}.png`)
})
test('rejects invalid file types', async ({ client }) => {
using fakeDisk = drive.fake()
const user = await UserFactory.create()
const pdfFile = await fileGenerator.generatePdf(1)
await client
.post('/profile/avatar')
.file('avatar', pdfFile.contents, {
filename: pdfFile.name,
contentType: pdfFile.mime,
})
.loginAs(user)
.assertStatus(422)
/**
* Assert no file was stored
*/
fakeDisk.assertMissing(`${user.id}.pdf`)
})
})
AdonisJS 使用
magic byte 检测
来确定文件类型,而非你传入的文件名或内容类型。纯 Buffer.from('fake-image') 没有有效的 magic bytes,因此 VineJS 文件验证会以 "Invalid file extension undefined" 拒绝它。请使用 @poppinss/file-generator 包(已包含在 AdonisJS 项目中)为你的测试生成带有正确 magic bytes 的缓冲区。
你也可以伪造特定的磁盘:
// Fake a specific disk
using fakeDisk = drive.fake('s3')
// Fake multiple disks
using _s3 = drive.fake('s3')
using _gcs = drive.fake('gcs')
如果你需要对真实磁盘的恢复时机有更多控制,也可以手动调用 drive.restore()。
故障排查
上传后文件损坏
某些云存储提供商在流式上传方面存在问题。如果你的文件在上传后损坏,请尝试使用 moveAs: 'buffer' 选项,在上传前将文件读入内存:
await file.moveToDisk(key, 's3', {
moveAs: 'buffer',
})
这会在将整个文件发送到存储提供商之前先将其读入内存,从而解决某些提供商的兼容性问题。
理解文件可见性
Drive 支持两种可见性级别:
- public:文件可被任何人通过 URL 访问。使用
getUrl生成 URL。 - private:文件不可公开访问。使用
getSignedUrl生成带有过期时间的临时 URL。
可见性在配置中以磁盘为单位设置。除非被覆盖,上传到该磁盘的所有文件都会继承该可见性设置。
{
services: {
// All files on this disk are public
publicFiles: services.s3({
// ...credentials
visibility: 'public',
}),
// All files on this disk are private
privateFiles: services.s3({
// ...credentials
visibility: 'private',
}),
}
}
另见
- 文件上传指南 ,用于处理 multipart HTTP 请求
- FlyDrive 文档 ,获取完整的 Disk API 参考