Skip to content

Rails Active StorageとLaravel Filesystemのファイルアップロード機能比較: 実装方法と特徴の違い

はじめに

モダンなWebアプリケーションにおいて、ファイルアップロード機能は欠かせない要素です。Ruby on RailsのActive StorageとLaravelのFilesystemは、それぞれ異なるアプローチでファイル管理機能を提供しています。

この記事では、両フレームワークのファイルアップロード機能を詳細に比較し、実装方法の違いや特徴を具体的なコード例と共に解説します。

1. ファイルアップロード機能の基本的な違い

Rails Active Storage

  • 統合型アプローチ: Active Recordとの密接な統合
  • メタデータ管理: ファイル情報をデータベースで管理
  • バリアント機能: 画像の自動リサイズ・変換
  • Direct Upload: クライアントサイドからの直接アップロード

Laravel Filesystem

  • ディスク抽象化: 複数のストレージドライバーの統一インターフェース
  • 柔軟な設定: 詳細な設定オプション
  • マニュアル管理: ファイル情報の手動管理
  • 豊富なクラウド対応: AWS S3、Google Cloud Storage等の豊富な対応

2. 基本的な実装方法

Rails Active Storage

ruby
# モデルの設定
# app/models/user.rb
class User < ApplicationRecord
  has_one_attached :avatar
  has_many_attached :documents
  
  # バリデーション
  validates :avatar, content_type: ['image/png', 'image/jpeg'], 
                     size: { less_than: 5.megabytes }
end
ruby
# app/models/post.rb
class Post < ApplicationRecord
  has_one_attached :featured_image
  has_many_attached :gallery_images
  
  # カスタムバリデーション
  validate :acceptable_image
  
  private
  
  def acceptable_image
    return unless featured_image.attached?
    
    unless featured_image.blob.byte_size <= 10.megabytes
      errors.add(:featured_image, "は10MB以下にしてください")
    end
    
    acceptable_types = ["image/jpeg", "image/png", "image/gif"]
    unless acceptable_types.include?(featured_image.blob.content_type)
      errors.add(:featured_image, "はJPEG、PNG、GIF形式のみ対応しています")
    end
  end
end

Laravel Filesystem

php
// モデルの設定
// app/Models/User.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Facades\Storage;

class User extends Model
{
    use HasFactory;

    protected $fillable = ['name', 'email', 'avatar_path'];

    protected $appends = ['avatar_url'];

    public function getAvatarUrlAttribute()
    {
        return $this->avatar_path ? Storage::url($this->avatar_path) : null;
    }

    public function documents()
    {
        return $this->hasMany(Document::class);
    }
}
php
// app/Models/Document.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;

class Document extends Model
{
    protected $fillable = ['user_id', 'filename', 'original_name', 'file_path', 'mime_type', 'size'];

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function getFileUrlAttribute()
    {
        return Storage::url($this->file_path);
    }
}

3. アップロード処理の実装

Rails

ruby
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def update
    @user = User.find(params[:id])
    
    if @user.update(user_params)
      redirect_to @user, notice: 'プロフィールが更新されました'
    else
      render :edit
    end
  end
  
  private
  
  def user_params
    params.require(:user).permit(:name, :email, :avatar, documents: [])
  end
end
erb
<!-- app/views/users/_form.html.erb -->
<%= form_with(model: user, local: true, multipart: true) do |form| %>
  <div class="field">
    <%= form.label :name %>
    <%= form.text_field :name %>
  </div>

  <div class="field">
    <%= form.label :avatar, "プロフィール画像" %>
    <%= form.file_field :avatar, accept: 'image/*' %>
    <% if user.avatar.attached? %>
      <div class="current-avatar">
        <%= image_tag user.avatar, size: "100x100" %>
      </div>
    <% end %>
  </div>

  <div class="field">
    <%= form.label :documents, "ドキュメント" %>
    <%= form.file_field :documents, multiple: true %>
  </div>

  <%= form.submit %>
<% end %>

Laravel

php
// app/Http/Controllers/UserController.php
<?php

namespace App\Http\Controllers;

use App\Models\User;
use App\Models\Document;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class UserController extends Controller
{
    public function update(Request $request, User $user)
    {
        $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users,email,' . $user->id,
            'avatar' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:5120',
            'documents.*' => 'nullable|file|max:10240',
        ]);

        $data = $request->only(['name', 'email']);

        // アバター画像の処理
        if ($request->hasFile('avatar')) {
            // 古いファイルを削除
            if ($user->avatar_path) {
                Storage::delete($user->avatar_path);
            }
            
            $avatarPath = $request->file('avatar')->store('avatars', 'public');
            $data['avatar_path'] = $avatarPath;
        }

        $user->update($data);

        // ドキュメントファイルの処理
        if ($request->hasFile('documents')) {
            foreach ($request->file('documents') as $file) {
                $path = $file->store('documents', 'private');
                
                Document::create([
                    'user_id' => $user->id,
                    'filename' => $file->getClientOriginalName(),
                    'original_name' => $file->getClientOriginalName(),
                    'file_path' => $path,
                    'mime_type' => $file->getMimeType(),
                    'size' => $file->getSize(),
                ]);
            }
        }

        return redirect()->route('users.show', $user)->with('success', 'プロフィールが更新されました');
    }
}
html
<!-- resources/views/users/edit.blade.php -->
<form action="{{ route('users.update', $user) }}" method="POST" enctype="multipart/form-data">
    @csrf
    @method('PUT')
    
    <div class="form-group">
        <label for="name">名前</label>
        <input type="text" name="name" id="name" value="{{ old('name', $user->name) }}" 
               class="form-control @error('name') is-invalid @enderror">
        @error('name')
            <div class="invalid-feedback">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <label for="avatar">プロフィール画像</label>
        <input type="file" name="avatar" id="avatar" accept="image/*" 
               class="form-control @error('avatar') is-invalid @enderror">
        @if($user->avatar_path)
            <div class="current-avatar mt-2">
                <img src="{{ $user->avatar_url }}" alt="現在のアバター" style="width: 100px; height: 100px;">
            </div>
        @endif
        @error('avatar')
            <div class="invalid-feedback">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <label for="documents">ドキュメント</label>
        <input type="file" name="documents[]" id="documents" multiple 
               class="form-control @error('documents.*') is-invalid @enderror">
        @error('documents.*')
            <div class="invalid-feedback">{{ $message }}</div>
        @enderror
    </div>

    <button type="submit" class="btn btn-primary">更新</button>
</form>

4. 画像処理とバリアント機能

Rails Active Storage

ruby
# app/models/user.rb
class User < ApplicationRecord
  has_one_attached :avatar
  
  # バリアント用のメソッド
  def avatar_thumbnail
    avatar.variant(resize_to_limit: [100, 100])
  end
  
  def avatar_large
    avatar.variant(resize_to_limit: [500, 500])
  end
end
erb
<!-- ビューでのバリアント使用 -->
<%= image_tag user.avatar.variant(resize_to_limit: [200, 200]) if user.avatar.attached? %>

<!-- プリセットされたバリアント使用 -->
<%= image_tag user.avatar_thumbnail if user.avatar.attached? %>

<!-- 複数の変換を組み合わせ -->
<%= image_tag user.avatar.variant(
  resize_to_limit: [300, 300],
  quality: 80,
  format: :webp
) if user.avatar.attached? %>

Laravel with Intervention Image

php
// config/filesystems.php
'disks' => [
    'public' => [
        'driver' => 'local',
        'root' => storage_path('app/public'),
        'url' => env('APP_URL').'/storage',
        'visibility' => 'public',
    ],
],
php
// app/Services/ImageService.php
<?php

namespace App\Services;

use Intervention\Image\Facades\Image;
use Illuminate\Support\Facades\Storage;

class ImageService
{
    public function processAndStoreImage($file, $directory = 'images')
    {
        $image = Image::make($file);
        
        // オリジナルサイズ
        $originalPath = $directory . '/' . uniqid() . '_original.' . $file->getClientOriginalExtension();
        Storage::disk('public')->put($originalPath, $image->encode());
        
        // サムネイル
        $thumbnailPath = $directory . '/' . uniqid() . '_thumb.' . $file->getClientOriginalExtension();
        $thumbnail = $image->resize(200, 200, function ($constraint) {
            $constraint->aspectRatio();
            $constraint->upsize();
        });
        Storage::disk('public')->put($thumbnailPath, $thumbnail->encode());
        
        return [
            'original' => $originalPath,
            'thumbnail' => $thumbnailPath,
        ];
    }
}
php
// app/Http/Controllers/UserController.php
use App\Services\ImageService;

class UserController extends Controller
{
    protected $imageService;
    
    public function __construct(ImageService $imageService)
    {
        $this->imageService = $imageService;
    }
    
    public function update(Request $request, User $user)
    {
        // バリデーション...
        
        if ($request->hasFile('avatar')) {
            $paths = $this->imageService->processAndStoreImage($request->file('avatar'), 'avatars');
            
            $user->update([
                'avatar_path' => $paths['original'],
                'avatar_thumbnail_path' => $paths['thumbnail'],
            ]);
        }
        
        // その他の処理...
    }
}

5. クラウドストレージの活用

Rails Active Storage with AWS S3

ruby
# config/storage.yml
amazon:
  service: S3
  access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
  secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
  region: ap-northeast-1
  bucket: your-bucket-name
  
# config/environments/production.rb
config.active_storage.service = :amazon
ruby
# app/models/user.rb
class User < ApplicationRecord
  has_one_attached :avatar
  
  # Direct Upload用のメソッド
  def avatar_direct_upload_url
    avatar.blob.service_url if avatar.attached?
  end
end

Laravel with AWS S3

php
// config/filesystems.php
's3' => [
    'driver' => 's3',
    'key' => env('AWS_ACCESS_KEY_ID'),
    'secret' => env('AWS_SECRET_ACCESS_KEY'),
    'region' => env('AWS_DEFAULT_REGION'),
    'bucket' => env('AWS_BUCKET'),
    'url' => env('AWS_URL'),
    'endpoint' => env('AWS_ENDPOINT'),
    'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
],
php
// app/Http/Controllers/UserController.php
use Illuminate\Support\Facades\Storage;

class UserController extends Controller
{
    public function update(Request $request, User $user)
    {
        if ($request->hasFile('avatar')) {
            // S3に直接アップロード
            $path = $request->file('avatar')->store('avatars', 's3');
            
            // パブリックアクセス設定
            Storage::disk('s3')->setVisibility($path, 'public');
            
            $user->update(['avatar_path' => $path]);
        }
    }
    
    public function downloadDocument(Document $document)
    {
        return Storage::disk('s3')->download($document->file_path, $document->original_name);
    }
}

6. セキュリティとアクセス制御

Rails

ruby
# app/controllers/documents_controller.rb
class DocumentsController < ApplicationController
  before_action :authenticate_user!
  
  def show
    @document = current_user.documents.find(params[:id])
    redirect_to @document.file
  end
  
  private
  
  def authorize_document_access
    redirect_to root_path unless @document.user == current_user
  end
end

Laravel

php
// app/Http/Controllers/DocumentController.php
class DocumentController extends Controller
{
    public function show(Document $document)
    {
        $this->authorize('view', $document);
        
        return Storage::disk('private')->download($document->file_path, $document->original_name);
    }
}
php
// app/Policies/DocumentPolicy.php
<?php

namespace App\Policies;

use App\Models\User;
use App\Models\Document;

class DocumentPolicy
{
    public function view(User $user, Document $document)
    {
        return $user->id === $document->user_id;
    }
}

7. パフォーマンスとベストプラクティス

Rails

  • Eager Loading: with_attached_*を使用してN+1問題を回避
  • バリアント最適化: 必要な時にのみバリアントを生成
  • CDN活用: Active Storageの配信にCDNを使用
ruby
# N+1問題の回避
@users = User.with_attached_avatar.limit(10)

# バリアント生成の最適化
class User < ApplicationRecord
  has_one_attached :avatar
  
  def avatar_thumbnail_url
    Rails.cache.fetch("user_#{id}_avatar_thumbnail", expires_in: 1.hour) do
      avatar.variant(resize_to_limit: [200, 200]).processed.url
    end
  end
end

Laravel

  • ファイルキャッシュ: CDNやキャッシュを活用
  • 非同期処理: キューを使用して画像処理を非同期化
  • ストレージ最適化: 適切なディスクドライバーの選択
php
// app/Jobs/ProcessImageJob.php
<?php

namespace App\Jobs;

use App\Services\ImageService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;

class ProcessImageJob implements ShouldQueue
{
    use Queueable;
    
    protected $filePath;
    protected $userId;
    
    public function handle(ImageService $imageService)
    {
        $imageService->processImage($this->filePath, $this->userId);
    }
}

8. 実際のプロジェクトでの選択指針

Railsを選ぶべき場合

  • Active Recordとの統合を重視する場合
  • 画像処理が多い場合(バリアント機能が便利)
  • 開発速度を重視する場合

Laravelを選ぶべき場合

  • 柔軟なストレージ設定が必要な場合
  • 複雑なファイル管理ロジックが必要な場合
  • 既存のクラウドインフラとの統合が重要な場合

まとめ

Rails Active StorageとLaravel Filesystemは、それぞれ異なる哲学でファイル管理機能を提供しています。Railsは統合性と使いやすさを重視し、Laravelは柔軟性と詳細な制御を重視しています。

プロジェクトの要件、チームの経験、インフラストラクチャの制約などを総合的に考慮して、適切なフレームワークを選択することが重要です。どちらを選択しても、モダンなファイル管理機能を効率的に実装できます。

AI が自動生成した技術記事をまとめたテックブログ