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 的关键优势在于,你可以在不更改应用代码的情况下切换存储服务。在开发期间,你可能为了方便而将文件存储在本地文件系统上。在生产环境中,你只需更改一个环境变量即可切换到云提供商。你的控制器、服务和模板都保持不变。

Note

Drive 处理文件存储操作,如读取、写入和删除文件。它不处理 HTTP multipart 解析。你应先阅读 文件上传指南 ,了解 AdonisJS 如何处理来自 HTTP 请求的上传文件。

安装

使用以下命令安装并配置 @adonisjs/drive 包:

node ace add @adonisjs/drive

该命令会提示你选择一个或多个存储服务。

add 命令执行的步骤
  1. 安装 @adonisjs/drive 包,以及所选服务所需的任何 peer 依赖。
  2. adonisrc.ts 中注册 Drive 服务提供者。
  3. 使用你所选的服务创建 config/drive.ts 配置文件。
  4. 将所选服务的环境变量添加到 .envstart/env.ts
非交互式安装

node ace add 命令需要交互式地选择服务。如果你需要非交互式地安装 Drive(例如在 CI 脚本中),可以手动执行这些步骤:

  1. 安装包:npm install @adonisjs/drive
  2. 为你的存储服务安装 peer 依赖(例如,S3 使用 npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
  3. adonisrc.ts 中注册提供者:将 () => import('@adonisjs/drive/drive_provider') 添加到 providers 数组
  4. 使用你的服务配置创建 config/drive.ts(见下方 配置 部分)
  5. 将所需的环境变量添加到 .envstart/env.ts

配置

Drive 的配置存储在 config/drive.ts 中。文件内容取决于你在安装期间选择了哪些服务。

配置文件中的 default 属性决定了当你不显式指定服务时使用哪个服务。DRIVE_DISK 环境变量控制这一点,让你可以在本地使用 fs,并在生产环境中切换到 s3

config/drive.ts
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 服务器提供这些文件。

环境变量
.env
DRIVE_DISK=fs
配置
config/drive.ts
{
  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',
    }),
  }
}
Tip

当启用 serveFiles 时,你可以通过运行 node ace list:routes 来验证路由是否已注册。你应该会看到一个类似 /uploads/* 的路由,其处理器为 drive.fs.serve

Amazon S3

环境变量
.env
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
配置
config/drive.ts
{
  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

环境变量
.env
DRIVE_DISK=gcs
GCS_KEY=file://gcs_key.json
GCS_BUCKET=your_bucket_name

GCS_KEY 变量指向你的 Google Cloud 服务账户的 JSON 密钥文件。file:// 前缀表示该路径相对于你的应用根目录。

配置
config/drive.ts
{
  services: {
    gcs: services.gcs({
      credentials: env.get('GCS_KEY'),
      bucket: env.get('GCS_BUCKET'),
      visibility: 'public',
    }),
  }
}

Cloudflare R2

Cloudflare R2 使用 S3 兼容 API。region 必须设置为 'auto'

环境变量
.env
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
配置
config/drive.ts
{
  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。

环境变量
.env
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
配置
config/drive.ts
{
  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。

环境变量
.env
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
配置
config/drive.ts
{
  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 方法。该方法将上传的文件从其临时位置移动到你配置的存储服务。

以下示例展示了上传和展示用户头像的完整流程。

  1. 定义路由

    创建用于展示个人资料页和处理头像上传的路由。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { controllers } from '#generated/controllers'
    
    router.get('profile', [controllers.Profile, 'show'])
    router.post('profile/avatar', [controllers.Profile, 'updateAvatar'])
  2. 创建控制器

    控制器使用 moveToDisk 处理文件上传。使用 UUID 生成一个唯一的文件名,以避免多个用户上传同名文件时发生冲突。

    app/controllers/profile_controller.ts
    import 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()
      }
    }
  3. 创建模板

    模板使用 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>
    @end

    driveUrl Edge 辅助方法为文件生成公开 URL。对于本地文件系统,它返回一个类似 /uploads/abc-123.jpg 的路径。对于云提供商,它返回文件的完整 URL。

指定磁盘

默认情况下,moveToDisk 使用 DRIVE_DISK 环境变量中指定的磁盘。你可以将不同的磁盘作为第二个参数显式指定:

app/controllers/profile_controller.ts
// 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')
Warning

moveToDisk 方法不同于 move 方法。move 方法仅在本地文件系统内移动文件。moveToDisk 方法将文件移动到你配置的 Drive 存储服务,它可以是本地的,也可以是基于云的。

使用 Drive 服务

对于文件上传之外的操作,你可以直接使用 Drive 服务。从 @adonisjs/drive/services/main 导入它,以读取文件、写入文件、删除文件等。

app/services/file_service.ts
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 的人都可以访问这些文件。

app/controllers/files_controller.ts
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 辅助方法:

resources/views/files/show.edge
<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。

app/controllers/invoices_controller.ts
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 辅助方法:

resources/views/invoices/show.edge
<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 服务器,将文件直接上传到你的云存储提供商。这对于大文件很有用,因为数据不流经你的服务器,从而降低内存使用和带宽成本。

流程如下:

  1. 浏览器向你的服务器请求一个签名的上传 URL。
  2. 你的服务器使用 Drive 生成一个签名 URL 并返回它。
  3. 浏览器使用签名 URL 将文件直接上传到云提供商。
  4. 浏览器通知你的服务器上传已完成。
  1. 定义路由

    创建用于生成签名上传 URL 和处理上传完成的路由。

    start/routes.ts
    import 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'])
  2. 创建控制器

    控制器使用 getSignedUploadUrl 生成签名上传 URL,并处理上传完成的通知。

    app/controllers/uploads_controller.ts
    import 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 }
      }
    }
  3. 实现客户端上传

    在前端,请求一个签名 URL,然后将文件直接上传到云提供商。

    resources/js/upload.ts
    async 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,用于在不与真实存储交互的情况下测试文件上传。当你伪造一个磁盘时,所有操作都会被重定向到一个临时的本地目录,而不是配置的存储服务。

tests/functional/profile/update_avatar.spec.ts
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`)
  })
})
Note

AdonisJS 使用 magic byte 检测 来确定文件类型,而非你传入的文件名或内容类型。纯 Buffer.from('fake-image') 没有有效的 magic bytes,因此 VineJS 文件验证会以 "Invalid file extension undefined" 拒绝它。请使用 @poppinss/file-generator 包(已包含在 AdonisJS 项目中)为你的测试生成带有正确 magic bytes 的缓冲区。

你也可以伪造特定的磁盘:

tests/functional/uploads.spec.ts
// 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' 选项,在上传前将文件读入内存:

app/controllers/uploads_controller.ts
await file.moveToDisk(key, 's3', {
  moveAs: 'buffer',
})

这会在将整个文件发送到存储提供商之前先将其读入内存,从而解决某些提供商的兼容性问题。

理解文件可见性

Drive 支持两种可见性级别:

  • public:文件可被任何人通过 URL 访问。使用 getUrl 生成 URL。
  • private:文件不可公开访问。使用 getSignedUrl 生成带有过期时间的临时 URL。

可见性在配置中以磁盘为单位设置。除非被覆盖,上传到该磁盘的所有文件都会继承该可见性设置。

config/drive.ts
{
  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',
    }),
  }
}

另见