Guide/guides

ストレージガイド

Guren は複数のストレージバックエンドをサポートする統一されたファイルストレージAPIを提供します。ストレージシステムにより、ローカルファイルシステム、Amazon S3、その他のクラウドストレージプロバイダーを一貫したインターフェースで簡単に扱えます。

ストレージガイド

Guren は複数のストレージバックエンドをサポートする統一されたファイルストレージAPIを提供します。ストレージシステムにより、ローカルファイルシステム、Amazon S3、その他のクラウドストレージプロバイダーを一貫したインターフェースで簡単に扱えます。

コアコンセプト

  • StorageManager – 複数のストレージディスクを設定・アクセスするための中央レジストリ。
  • StorageDriver – ストレージ操作(put、get、deleteなど)のインターフェース。すべてのドライバがこのインターフェースを実装。
  • Drivers – ストレージバックエンド:Local(ファイルシステム)、S3(AWS/互換サービス)、Memory(テスト用)。

基本的な使い方

クイックスタート

import { StorageManager } from '@guren/core'

const storage = new StorageManager({
  default: 'local',
  disks: {
    local: {
      driver: 'local',
      root: './storage/app',
      url: '/storage',
    },
  },
})

// ファイルを保存
await storage.disk().put('avatars/user-1.jpg', imageBuffer)

// ファイルを取得
const content = await storage.disk().get('avatars/user-1.jpg')

// ファイルが存在するか確認
const exists = await storage.disk().exists('avatars/user-1.jpg')

// ファイルを削除
await storage.disk().delete('avatars/user-1.jpg')

ファイル操作

const disk = storage.disk()

// ファイルの保存
await disk.put('file.txt', 'Hello World')                    // 文字列コンテンツ
await disk.put('image.jpg', imageBuffer)                      // Bufferコンテンツ
await disk.put('data.json', JSON.stringify(data), {          // オプション付き
  contentType: 'application/json',
})
await disk.putFile('uploads/report.pdf', './temp/report.pdf') // ローカルファイルから

// ファイルの取得
const buffer = await disk.get('file.txt')                    // Bufferとして取得
const text = await disk.getAsString('file.txt')              // 文字列として取得

// ファイルの存在確認
const exists = await disk.exists('file.txt')

// ファイルの削除
await disk.delete('file.txt')                                // 単一ファイル
await disk.deleteMany(['file1.txt', 'file2.txt'])            // 複数ファイル

// コピーと移動
await disk.copy('original.txt', 'copy.txt')
await disk.move('old-path.txt', 'new-path.txt')

ファイルメタデータ

const disk = storage.disk()

// ファイルサイズを取得(バイト)
const size = await disk.size('file.txt')

// 最終更新日時を取得
const lastModified = await disk.lastModified('file.txt')

// 全メタデータを取得
const metadata = await disk.metadata('file.txt')
// { path, size, lastModified, contentType?, visibility?, metadata? }

URL

const disk = storage.disk()

// 公開URLを取得
const url = disk.url('avatars/user-1.jpg')
// 例: '/storage/avatars/user-1.jpg' (local)
// 例: 'https://bucket.s3.region.amazonaws.com/avatars/user-1.jpg' (S3)

// 一時署名付きURLを取得(S3のみ)
const expiration = new Date(Date.now() + 3600 * 1000) // 1時間
const signedUrl = await disk.temporaryUrl('private/report.pdf', expiration)

ディレクトリ

const disk = storage.disk()

// ディレクトリ内のファイルを一覧
const files = await disk.files('uploads')              // 直接の子のみ
const allFiles = await disk.allFiles('uploads')        // 再帰的

// サブディレクトリを一覧
const dirs = await disk.directories('uploads')

// ディレクトリを作成
await disk.makeDirectory('uploads/images')

// ディレクトリを削除(中身含む)
await disk.deleteDirectory('uploads/temp')

可視性

可視性がどこに属するかはバックエンド次第で、ドライバは「できるふり」をせずどちらであるかを示します。

  • オブジェクト単位 — ACL が有効な S3。setVisibility() は1ファイルだけを変更します。
  • ディスク単位 — ローカルディスク(到達可能性はディスクのルートと、それを配信する仕組みで決まります)、acl: false の S3、Cloudflare R2。ディスク側で visibility を宣言し、逆の値を求められた場合は黙って無視せず拒否します。S3 と R2 では現在すでにエラーですが、ローカルドライバはこれまで受け付けてきた経緯があるため、今は警告のみで次のメジャーでエラーになります。
const disk = storage.disk('public')       // visibility: 'public' を宣言したディスク

await disk.put('file.txt', content)                  // ディスクの可視性を継承
await disk.put('file.txt', content, { visibility: 'public' })  // 同じ意味を明示しただけ
await disk.getVisibility('file.txt')                 // 'public'(ファイルが無ければ例外)

// オブジェクト単位のバックエンドでは1ファイルだけ移動します
await storage.disk('s3').setVisibility('file.txt', 'private')

// ディスク単位のバックエンドでは、黙って捨てられるのではなく拒否されます。
// 代わりに目的の可視性を持つディスクへ置いてください。
await storage.disk('local').put('secret.pdf', content)

設定

複数のディスク

アプリケーションで複数のストレージバックエンドを設定できます。

import { StorageManager } from '@guren/core'

const storage = new StorageManager({
  default: 'local',
  disks: {
    local: {
      driver: 'local',
      root: './storage/app',
      url: '/storage',
      visibility: 'private',
    },
    public: {
      driver: 'local',
      root: './storage/public',
      url: '/files',
      visibility: 'public',
    },
    s3: {
      driver: 's3',
      bucket: process.env.AWS_BUCKET!,
      region: process.env.AWS_REGION || 'us-east-1',
      accessKeyId: process.env.AWS_ACCESS_KEY_ID,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
      visibility: 'private',
    },
    memory: {
      driver: 'memory',
    },
  },
})

// デフォルトディスク(local)を使用
await storage.disk().put('file.txt', 'content')

// 特定のディスクを使用
await storage.disk('s3').put('uploads/file.txt', content)
await storage.disk('public').put('images/logo.png', logoBuffer)

ドライバオプション

Local Driver:

オプション デフォルト 説明
root 必須 ファイルストレージのルートディレクトリ
url '' 公開ファイルアクセス用のベースURL
visibility 'private' 新規ファイルのデフォルト可視性

S3 Driver:

オプション デフォルト 説明
bucket 必須 S3バケット名
region 'us-east-1' AWSリージョン
endpoint - カスタムエンドポイント(S3互換サービス用)
accessKeyId - AWSアクセスキーID
secretAccessKey - AWSシークレットアクセスキー
prefix '' 全ファイルのキープレフィックス
url 自動 公開アクセス用のベースURL
visibility 'private' 新規ファイルのデフォルト可視性

Memory Driver:

オプション デフォルト 説明
url '' ファイルURL用のベースURL

S3設定

AWS S3

const storage = new StorageManager({
  default: 's3',
  disks: {
    s3: {
      driver: 's3',
      bucket: 'my-bucket',
      region: 'ap-northeast-1',
      accessKeyId: process.env.AWS_ACCESS_KEY_ID,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    },
  },
})

S3互換サービス

MinIO、DigitalOcean Spaces、Cloudflare R2などのサービスにも対応しています。

// MinIO
const storage = new StorageManager({
  default: 's3',
  disks: {
    s3: {
      driver: 's3',
      bucket: 'my-bucket',
      region: 'us-east-1',
      endpoint: 'http://localhost:9000',
      accessKeyId: 'minioadmin',
      secretAccessKey: 'minioadmin',
    },
  },
})

// DigitalOcean Spaces
const storage = new StorageManager({
  default: 's3',
  disks: {
    s3: {
      driver: 's3',
      bucket: 'my-space',
      region: 'nyc3',
      endpoint: 'https://nyc3.digitaloceanspaces.com',
      accessKeyId: process.env.DO_SPACES_KEY,
      secretAccessKey: process.env.DO_SPACES_SECRET,
      url: 'https://my-space.nyc3.cdn.digitaloceanspaces.com',
    },
  },
})

// Cloudflare R2
const storage = new StorageManager({
  default: 's3',
  disks: {
    s3: {
      driver: 's3',
      bucket: 'my-bucket',
      region: 'auto',
      endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
      accessKeyId: process.env.R2_ACCESS_KEY_ID,
      secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
    },
  },
})

S3 のオブジェクト ACL に対応していないエンドポイント(R2 は x-amz-acl と ACL 操作を非対応と明記しており、MinIO は構成によります)では acl: false を指定します。ドライバはヘッダの送出をやめ、getVisibility() はディスクに設定した visibility を返し、put({ visibility })setVisibility() で逆の値を求められた場合は、黙って無視せず例外を投げます。

const storage = new StorageManager({
  default: 's3',
  disks: {
    s3: {
      driver: 's3',
      bucket: 'my-bucket',
      region: 'auto',
      endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
      accessKeyId: process.env.R2_ACCESS_KEY_ID,
      secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
      acl: false,
      visibility: 'public',
    },
  },
})

note

Cloudflare Workers 上では S3 API ではなくバケットバインディングを使ってください。@guren/plugin-cloudflareR2Driver は資格情報も AWS SDK も不要です。上の S3 のレシピは他のランタイム(Bun サーバー、スクリプト、Lambda)から R2 に到達するためのものです。Cloudflare Workers ガイドを参照してください。

署名付きURL

プライベートファイル用の一時URLを生成します。

const disk = storage.disk('s3')

// 1時間有効なURL
const expiration = new Date(Date.now() + 3600 * 1000)
const url = await disk.temporaryUrl('private/document.pdf', expiration)

環境ごとのディスク切り替え

ディスクはまとめて宣言しておき、環境変数で選びます(bunx guren add storage はこの形で生成します)。ドライバは初回利用時に構築されるため、触らないディスクはクライアントも接続も作りません。

const storage = createStorageManager({
  default: process.env.STORAGE_DISK || 'local',
  disks: {
    // 何からも配信されません。アップロードはこちらへ(下の注記を参照)。
    local: { driver: 'local', root: './storage/app' },
    // public/ の中にあるため配信されます。自分で用意するアセット向け。
    public: { driver: 'local', root: './public/storage', url: '/storage', visibility: 'public' },
    s3: { driver: 's3', bucket: process.env.S3_BUCKET!, region: 'ap-northeast-1' },
  },
})

開発では STORAGE_DISK=local、本番では STORAGE_DISK=s3。コードの変更は不要で、storage.disk() は選ばれた方を返します。

アップロードを受け取るディスクを public/ 配下、および guren storage:link が公開する場所に置かないでください。 配信ツリー配下のファイルは、署名も有効期限も認可チェックもなしに URL で取得できます。見知らぬ相手がアップロードしたファイルも同様です。アップロードは上記の local のようなディスクに置き、attachments の配信ルート経由で渡してください。guren check は、その形になっている attachments 設定を失敗として報告します。

この形について、2点注意があります。

  • 設定値は解決しないディスクの分も先に読まれます。 オブジェクトを組み立てた時点で評価されるためです。process.env.S3_BUCKET が未設定でも無害ですが、未設定時に例外を投げるヘルパーを書くと、そのディスクを一度も使わなくても起動時に落ちます。そうしたヘルパーはディスクの定義に置かず、storage.registerDisk('s3', () => new S3Driver({ ... })) を使ってください。こちらのコールバックは本当に初回利用時に実行されます。
  • 未知のディスク名は構築時には弾かれません。 createStorageManager({ default: 'typo' }) は成功し、最初にディスクを解決したときに初めて Storage disk not found: typo を投げます。キュージョブの中かもしれません。生成される StorageProvider が起動時に名前を検証しているのはこのためです。設定を手書きする場合も同じようにしてください。

ファイルアップロード

投稿のカバー画像やユーザーのアバターのように、モデルに属するアップロードにはアタッチメントレイヤーが使えます。命名、保存、画像バリデーション、サムネイルバリアント、後片付けまでを1呼び出し(Post.attach(post.id, 'cover', file))で扱います。以下のレシピは、より低レベルのパス指向ストレージ API です。

フォームアップロードの処理

import { Controller } from '@guren/core'

export class UploadController extends Controller {
  async store() {
    const formData = await this.request.formData()
    const file = formData.get('avatar') as File

    if (!file) {
      return this.json({ error: 'ファイルがアップロードされていません' }, 400)
    }

    // ファイルを検証
    if (!file.type.startsWith('image/')) {
      return this.json({ error: '無効なファイルタイプです' }, 400)
    }

    if (file.size > 5 * 1024 * 1024) { // 5MB
      return this.json({ error: 'ファイルが大きすぎます' }, 400)
    }

    // ファイルを保存
    const buffer = Buffer.from(await file.arrayBuffer())
    const ext = file.name.split('.').pop()
    const filename = `avatars/${crypto.randomUUID()}.${ext}`

    // The public disk declares its own visibility, so the upload does not
    // have to ask for one the disk may not be able to honour.
    await storage.disk('public').put(filename, buffer, {
      contentType: file.type,
    })

    const url = storage.disk('public').url(filename)

    return this.json({ url })
  }
}

大きなファイルのストリーミング

大きなファイルの場合はストリーミングを検討してください。

import { Controller } from '@guren/core'

export class DownloadController extends Controller {
  async show() {
    const path = this.request.param('path')
    const content = await storage.disk().get(path)

    if (!content) {
      return this.notFound()
    }

    const metadata = await storage.disk().metadata(path)

    return new Response(content, {
      headers: {
        'Content-Type': metadata?.contentType ?? 'application/octet-stream',
        'Content-Length': String(content.length),
        'Content-Disposition': `attachment; filename="${path.split('/').pop()}"`,
      },
    })
  }
}

テスト

テストにはMemoryドライバを使用します。

import { describe, test, expect, beforeEach } from 'bun:test'
import { StorageManager, MemoryDriver } from '@guren/core'

describe('ファイルアップロード', () => {
  let storage: StorageManager

  beforeEach(() => {
    storage = new StorageManager({
      default: 'memory',
      disks: {
        memory: { driver: 'memory' },
      },
    })
  })

  test('アップロードされたファイルを保存する', async () => {
    const content = Buffer.from('test content')
    await storage.disk().put('test.txt', content)

    expect(await storage.disk().exists('test.txt')).toBe(true)
    expect(await storage.disk().getAsString('test.txt')).toBe('test content')
  })

  test('ファイルを削除する', async () => {
    await storage.disk().put('test.txt', 'content')
    await storage.disk().delete('test.txt')

    expect(await storage.disk().exists('test.txt')).toBe(false)
  })

  test('ディレクトリ内のファイルを一覧する', async () => {
    await storage.disk().put('uploads/file1.txt', 'content1')
    await storage.disk().put('uploads/file2.txt', 'content2')

    const files = await storage.disk().files('uploads')
    expect(files).toHaveLength(2)
  })
})

ベストプラクティス

  1. 環境変数を使用: 認証情報やバケット名をハードコードしない。

  2. アップロードを検証: 保存前にファイルタイプ、サイズ、コンテンツを必ず検証。

  3. 一意なファイル名を生成: UUIDやタイムスタンプを使用して衝突を回避。

  4. 適切な可視性を使用: デフォルトはprivateにし、必要な場合のみファイルを公開。

  5. 署名付きURLを使用: プライベートファイルには公開せず一時URLを生成。モデルアタッチメントでは署名配信ルートがどのドライバでもこれをカバーします(local ディスクやバインディングのみの R2 も含む)。

  6. ディレクトリで整理: 意味のあるディレクトリ構造を使用(avatars/documents/など)。

  7. テストにはMemoryドライバを使用: テストでファイルシステムやネットワーク呼び出しを避ける。

  8. エラーを適切に処理: get()metadata()からのnull戻り値を確認。