Quản lý các file cá nhân trong Rails app với S3

Để quản lý các file cá nhân trên Rails app, gem Paperclip có thể thực hiện tốt nhiệm vụ này. Khi application lớn dần, ta sẽ muốn lưu trữ các file ở một vị trí khác, thay vì lưu trực tiếp trên server. Đưa các file lưu trữ ra khỏi server vừa giúp cho application có thêm không gian phát triển vừa có thể cứu nguy cho bạn nếu buộc phải recovery. Nếu server của bạn sập, các file quý giá của bạn vẫn được an toàn ở trong một service khác. Vậy, ta nên lưu trữ file ở đâu?

Amazon S3

Như mọi khi, các phổ biến nhất hiện tại để làm gì đó trên web là sử dụng dịch vụ của Amazon. Dịch vụ này có tên gọi là Simple Storage Service. Nếu bạn không có sẵn một tài khoản AWS, bạn cần phải đăng ký ngay lập tức. S3 sẽ lưu trữ các file theo bucket, sau khi có một account AWS, bạn sẽ có thể tạo một bucket, thường thì mỗi bucket cho mỗi môi trường, nhưng nếu chỉ sử dụng trong quá trình development, ta chỉ cần có một bucket là đủ.

Kết nối với S3

Sau khi đã có bucket, ta cần phải kết nối application với S3. Điều này có nghĩa là ta phải chỉnh sửa configuration một chút. Hoặc bạn chỉ cần sử dụng gem dotenv để tự chỉnh sửa các biến môi trường. Chỉ cần add vào Gemfile:

# Gemfile
gem 'dotenv-rails'

Sau đó, ta chỉ cần chạy bundle để cài đặt và tự động tùy chỉnh các thông số môi trường.

Tùy chỉnh S3

Ta có dotenv trong app đồng nghĩa với việc ta đã sẵn sàng cài đặt để kết nối đến S3:

# .env
AWS_ACCESS_KEY_ID = access_key_id
AWS_SECRET_ACCESS_KEY = secret_access_key
AWS_BUCKET = bucket-name
AWS_REGION = bucket-region

Trên đây là các thông tin cần thiết lấy từ tài khoản S3 để có thể kết nối với bucket. Sau khi điền đầy đủ thông tin, ta cần phải thông báo với Paperclip rằng ta chuyển sang sử dụng S3 thay vì file system hiện tại trên application server.

Kết nối Paperclip và S3

Có một số cách để kết nối đến bucket sử dụng Paperclip. Ta có thể sử dụng default setting để gọi mỗi khi khởi động application:

# config/applicaton.rb
module SecureDownloads
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 5.1

    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration should go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded.

    # Don't generate system test files.
    config.generators.system_tests = nil

    config.paperclip_defaults = {
      storage: :s3,
      s3_permissions: 'private',
      s3_region: ENV['AWS_REGION'],
      s3_credentials: {
        bucket: ENV['AWS_BUCKET'],
        access_key_id: ENV['AWS_ACCESS_KEY_ID'],
        secret_access_key: ENV['AWS_SECRET_ACCESS_KEY']
      }
    }
  end
end

Như bạn có thể thấy, phần thêm vào file application.rb chính là config.paperclip_defaults. Nếu bạn có nhiều hơn một class Paperclip trong application, bạn sẽ không muốn sử dụng phương pháp này, hoặc bạn có thể override setting cho các class khác nếu muốn.

Bây giờ ta đã có configuration để kết nối Paperclip và S3, ta cần phải thông báo cho class Image biết để sử dụng nó. Sau đây là những việc cần phải làm:

# app/models/image.rb
class Image < ApplicationRecord

  belongs_to :user

  has_attached_file :asset, styles: { thumb: "200x200>" }, url: ':s3_domain_url', path: 'assets/:class/:id/:style.:extension'

  validates_attachment_content_type :asset, content_type: /\Aimage\/.*\z/

  def s3_path(style: nil)
    asset.s3_object(style).presigned_url("get", expires_in: 10.seconds)
  end

  def s3_download_path
    asset.s3_object.presigned_url("get", expires_in: 30.seconds)
  end

end

Đầu tiên ta phải đổi khai báo lại thành has_attached_file, sau đó thêm vào URL s3_domain_url và path assets/:class/:id/:style.:extension

Dòng này sẽ báo cho class Image các địa chỉ cần thiết cho S3 URL, nó có dạng https://bucketname.s3.aws.com và path được sử dụng bên trong bucket. Như vậy, app sẽ biết được phải lưu ảnh ở đâu khi upload lên, và URL được sử dụng sau đó là gì.

Chuẩn bị controller

Việc đầu tiên sau khi có ảnh lưu trên S3 và có được presigned URL là loại bỏ route secure_image_display và các action liên quan trong controller. Kiểu như

# config/routes.rb
get '/images/:id/display', to: "images#display", as: "secure_image_display"

Và:

# app/controllers/images_controller.rb
def display
  @image = Image.find(params[:id])
  send_file @image.asset.path(:thumb)
end

Để lấy file từ S3 server, ta cần một method s3_path:

def s3_path(style: nil)
  asset.s3_object(style).presigned_url("get", expires_in: 10.seconds)
end
<% # app/view/users/show.html.erb %>
<h1>Images offered by <%= @user.name %></h1>

<% @user.images.each do |image| %>
  <%= link_to image_tag(image.s3_path(style: :thumb)), user_image_path(current_user, image) %>
<% end %>

<% # app/views/images/show.html.erb %>
<h1><%= @image.asset_file_name %> offered by <%= @image.user.name %></h1>

<% unless @image.user == current_user %>
  <%= form_for [current_user, @image], url: user_image_purchase_path(current_user, @image), method: :post do |f| %>
    <%= f.submit "Purchase" %>
  <% end %>
<% end %>

<%= image_tag @image.s3_path(style: :thumb) %>

Tải xuống file

Tất cả những việc còn lại là cho phép user được quyền tải xuống cái file. Ta có hai lựa chọn. Ta có thể cho phép browser link file từ S3 và show ngay trên brower, hoặc ta có thể bắt buộc tải xuống máy. Tùy thuộc vào mục đích của app, ta có thể sử dụng một trong hai cách.

Mở file trên browser

Nếu muốn mở file trên brower, ta cần thay đổi trang mua bán một chút:

<h1>Images purchased by <%= current_user.name %></h1>

<% current_user.purchased_images.each do |purchase| %>
  <%= link_to image_tag(purchase.image.s3_path(style: :thumb)), purchase.image.s3_download_path %>
<% end %>

Sử dụng methos s3_download_path ngay trên trang, chỉ cần click vào là file sẽ được mở ngay trên brower nếu có thể:

def s3_download_path
  asset.s3_object.presigned_url("get", expires_in: 30.seconds)
end

Bắt buộc tải file xuống

Nếu muốn chắc chắn rằng file được tải xuống máy của user trước ta cần thay đổi một chút. Ta cần sử dụng s3_path trong trang mua bán:

<% # app/views/users/purchased.html.erb
<h1>Images purchased by <%= current_user.name %></h1>

<% current_user.purchased_images.each do |purchase| %>
  <%= link_to image_tag(purchase.image.s3_path(style: :thumb)), user_image_download_path(current_user, purchase.image) %>
<% end %>

Trong trường hợp này, ta hiện thumbnail từ S3, sau đó ta kết nối đến action download trong image controller.

# app/controllers/images_controller.rb
def download
  image = Image.find(params[:image_id])
  data = open(image.s3_download_path)
  send_data data.read, :type => data.content_type, :x_sendfile => true, filename: image.asset_file_name
end

Để tải ảnh xuống, ta cần phải lấy Image từ database như bình thường, nhưng sau đó ta cần phải mở presigned_url từ S3 bằng method s3_download_path, rồi lấy data từ đó gửi đến máy tính bằng send_data. Bằng cách này sẽ kích hoạt quá trình download của brower, và user sẽ nhận được file.

Có một hạn chế khi sử dụng phương pháp này đó là file sẽ được download về application server trước sau đó mới được chuyển đến user. Nếu file có dung lượng lớn, user sẽ nhận thấy được độ trễ, hoặc thậm chí ngăn chặn cả một tiến trình khác khi đang tải file trên server.

Việc sử dụng phương án nào tùy thuộc vào điều kiện của app.