Có nhiều gem tải lên tệp như CarrierWave, Paperclip, Dragonfly, chỉ nennen vài cái. Chúng đều có đặc thước, và bạn có thể đã dùng ít nhất một trong số đó.
Hôm nay, tuy nhiên, tôi muốn giới thiệu một giải pháp tương đối mới nhưng rất cool gọi là Shrine, do Janko Marohnić tạo. Khác với một số gem tương tự, nó có cách tiếp cận module, nghĩa là mọi tính năng được đóng gói thành module (hoặc plugin trong thuật ngữ Shrine). Muốn hỗ trợ validation? Thêm plugin. Muốn xử lý file? Thêm plugin! Tôi thực sự thích cách này vì nó cho phép dễ dàng kiểm soát tính năng nào khả dụng cho model nào.
Trong bài này tôi sẽ chỉ cách:
- tích hợp Shrine vào ứng dụng Rails
- cấu hình nó (toàn cục và per-uploader)
- thêm khả năng tải lên tệp
- xử lý tệp
- thêm quy tắc validation
- lưu metadata thêm và dùng cloud storage Amazon S3
Mã nguồn bài viết có sẵn trên GitHub.
Demo hoạt động tại đây.
Tích Hợp Shrine
Khởi tạo bằng cách tạo ứng dụng Rails mới không có test suite mặc định:
rails new FileGuru -T
Demo này dùng Rails 5, nhưng hầu hết khái niệm áp dụng cho version 3 và 4.
Thêm gem Shrine vào Gemfile:
gem "shrine"
Chạy:
bundle install
Bây giờ cần model tên Photo. Shrine lưu tất cả thông tin file trong cột text đặc biệt kết thúc bằng hậu tố _data. Tạo và apply migration:
rails g model Photo title:string image_data:text rails db:migrate
Lưu ý với Rails cũ, lệnh sau nên là:
rake db:migrate
Tùy chọn cấu hình Shrine có thể set cả toàn cục và per-model. Cài đặt toàn cục làm trong file initializer. Ở đó hook up file cần thiết và plugins. Plugin dùng trong Shrine để tách chức năng thành module riêng, cho bạn kiểm soát đầy đủ các tính năng. Ví dụ có plugin cho validation, xử lý ảnh, cache attachment, v.v.
Hiện tại thêm hai plugin: hỗ trợ ActiveRecord và setup logging. Sẽ include toàn cục. Cũng setup file system storage:
config/initializers/shrine.rb
require "shrine"
require "shrine/storage/file_system"
Shrine.plugin :activerecord
Shrine.plugin :logging, logger: Rails.logger
Shrine.storages = {
cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
store: Shrine::Storage::FileSystem.new("public", prefix: "uploads/store"),
}
Logger chỉ output thông tin debug ra console cho biết thời gian xử lý file. Có thể hữu ích.
2015-10-09T20:06:06.676Z #25602: STORE[cache] ImageUploader[:avatar] User[29543] 1 file (0.1s) 2015-10-09T20:06:06.854Z #25602: PROCESS[store]: ImageUploader[:avatar] User[29543] 1-3 files (0.22s) 2015-10-09T20:06:07.133Z #25602: DELETE[destroyed]: ImageUploader[:avatar] User[29543] 3 files (0.07s)
Tất cả file upload sẽ lưu trong public/uploads. Không muốn track trong Git, loại thư mục này:
.gitignore
public/uploads
Tạo class "uploader" đặc biệt chứa cài đặt model-specific. Lúc này class rỗng:
models/image_uploader.rb
class ImageUploader < Shrine end
Cuối cùng, include class này vào model Photo:
models/photo.rb
include ImageUploader[:image]
[:image] thêm virtual attribute dùng khi xây form. Dòng trên viết lại được:
include ImageUploader.attachment(:image) # hoặc include ImageUploader::Attachment.new(:image)
Tuyệt! Model giờ có chức năng Shrine, tiến bước tiếp.
Controller, Views, và Routes
Demo này chỉ cần một controller quản lý photo. Trang index làm root:
pages_controller.rb
class PhotosController < ApplicationController
def index
@photos = Photo.all
end
end
View:
views/photos/index.html.erb
<h1>Photos</h1> <%= link_to 'Add Photo', new_photo_path %> <%= render @photos %>
Để render mảng @photos, cần partial:
views/photos/_photo.html.erb
<div>
<% if photo.image_data? %>
<%= image_tag photo.image_url %>
<% end %>
<p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>
image_data? là method ActiveRecord cung cấp kiểm tra record có ảnh hay không.
image_url là method Shrine trả path đến ảnh gốc. Tất nhiên, hiển thị thumbnail nhỏ tốt hơn, sẽ lo sau.
Thêm routes cần thiết:
config/routes.rb
resources :photos, only: [:new, :create, :index, :edit, :update] root 'photos#index'
Xong—căn cơ hoàn tất, tiến phần thú vị!
Tải Lên Tệp
Phần này chỉ cách thêm chức năng upload thực sự. Controller actions đơn giản:
photos_controller.rb
def new
@photo = Photo.new
end
def create
@photo = Photo.new(photo_params)
if @photo.save
flash[:success] = 'Photo added!'
redirect_to photos_path
else
render 'new'
end
end
Điều cần lưu ý: strong parameters phải permit virtual attribute image, không phải image_data.
photos_controller.rb
private def photo_params params.require(:photo).permit(:title, :image) end
Tạo view new:
views/photos/new.html.erb
<h1>Add photo</h1> <%= render 'form' %>
Partial form cũng đơn giản:
views/photos/_form.html.erb
<%= form_for @photo do |f| %> <%= render "shared/errors", object: @photo %> <%= f.label :title %> <%= f.text_field :title %> <%= f.label :image %> <%= f.file_field :image %> <%= f.submit %> <% end %>
Lại nhắc: dùng attribute image, không phải image_data.
Cuối cùng, thêm partial hiển thị lỗi:
views/shared/_errors.html.erb
<% if object.errors.any? %>
<h3>The following errors were found:</h3>
<ul>
<% object.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<% end %>
Đây là cơ bản—bạn có thể upload ảnh ngay.
Validations
Tuy nhiên còn nhiều việc để hoàn thiện app. Vấn đề chính: user có thể upload bất kỳ loại file nào với bất kỳ size, không tốt. Do đó thêm plugin hỗ trợ validation:
config/initializers/shrine.rb
Shrine.plugin :validation_helpers
Setup logic validation cho ImageUploader:
models/image_uploader.rb
Attacher.validate do validate_max_size 1.megabyte, message: "is too large (max is 1 MB)" validate_mime_type_inclusion ['image/jpg', 'image/jpeg', 'image/png'] end
Chỉ cho upload JPG và PNG dưới 1MB. Tùy chỉnh rule theo ý.
MIME Types
Điều quan trọng: mặc định Shrine xác định MIME type file dùng Content-Type HTTP header. Header này do browser pass và set chỉ dựa trên extension file, không luôn mong muốn.
Nếu muốn xác định MIME type dựa trên nội dung file, dùng plugin determine_mime_type. Include trong class uploader vì model khác có thể không cần:
models/image_uploader.rb
plugin :determine_mime_type
Plugin này dùng Linux's file utility mặc định.
Cache Attached Images
Hiện tại, khi user submit form với data sai, form hiển thị lại với lỗi render ở trên. Vấn đề: attached image bị mất, user phải chọn lại. Rất dễ fix với plugin cached_attachment_data:
models/image_uploader.rb
plugin :cached_attachment_data
Thêm hidden field vào form:
views/photos/_form.html.erb
<%= f.hidden_field :image, value: @photo.cached_image_data %> <%= f.label :image %> <%= f.file_field :image %>
Chỉnh Sửa Ảnh
Ảnh đã upload được, nhưng chưa sửa được, fix ngay. Controller actions tương đối đơn giản:
photos_controller.rb
def edit
@photo = Photo.find(params[:id])
end
def update
@photo = Photo.find(params[:id])
if @photo.update_attributes(photo_params)
flash[:success] = 'Photo edited!'
redirect_to photos_path
else
render 'edit'
end
end
Cùng partial _form:
views/photos/edit.html.erb
<h1>Edit Photo</h1> <%= render 'form' %>
Chưa đủ: user vẫn không xóa ảnh upload. Để cho phép, cần—đoán gì—plugin khác:
models/image_uploader.rb
plugin :remove_attachment
Nó dùng virtual attribute :remove_image, permit trong controller:
photos_controller.rb
def photo_params params.require(:photo).permit(:title, :image, :remove_image) end
Hiển thị checkbox xóa ảnh nếu record có attachment:
views/photos/_form.html.erb
<% if @photo.image_data? %> Remove attachment: <%= f.check_box :remove_image %> <% end %>
Tạo Thumbnail Image
Hiện tại hiển thị ảnh gốc, không tốt cho preview: ảnh lớn chiếm diện tích. Dùng CSS width/height cũng không hay. Dù set nhỏ bằng style, user vẫn tải file gốc, có thể to.
Tốt hơn: tạo preview nhỏ server-side khi upload ban đầu. Cần hai plugin và hai gem thêm. Thêm gem:
gem "image_processing" gem "mini_magick", ">= 4.3.5"
image_processing do tác giả Shrine tạo, cung cấp helper high-level manipulate ảnh. Gem này dựa trên mini_magick, Ruby wrapper cho ImageMagick. Bạn cần ImageMagick trên hệ thống để chạy demo.
Cài gem mới:
bundle install
Include plugin cùng dependency:
models/image_uploader.rb
require "image_processing/mini_magick" class ImageUploader < Shrine include ImageProcessing::MiniMagick plugin :processing plugin :versions # other code... end
Processing plugin cho phép manipulate ảnh (thu nhỏ, xoay, convert format...). Versions cho phép ảnh nhiều variant. Demo lưu hai version: "original" và "thumb" (resize 300x300).
Code xử lý ảnh và lưu hai version:
models/image_uploader.rb
class ImageUploader < Shrine
process(:store) do |io, context|
{
original: io,
thumb: resize_to_limit!(io.download, 300, 300)
}
end
end
resize_to_limit! do gem image_processing cung cấp. Thu nhỏ ảnh xuống 300x300 nếu lớn hơn, không làm gì nếu nhỏ hơn. Giữ tỷ lệ khung hình gốc.
Khi hiển thị ảnh, chỉ cần truyền :original hoặc :thumb cho method image_url:
views/photos/_photo.html.erb
<div>
<% if photo.image_data? %>
<%= image_tag photo.image_url(:thumb) %>
<% end %>
<p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>
Tương tự trong form:
views/photos/_form.html.erb
<% if @photo.image_data? %> <%= image_tag @photo.image_url(:thumb) %> Remove attachment: <%= f.check_box :remove_image %> <% end %>
Để tự động xóa file processed sau upload xong, thêm plugin delete_raw:
models/image_uploader.rb
plugin :delete_raw
Metadata Ảnh
Ngoài render ảnh, có thể fetch metadata. Ví dụ hiển thị size và MIME type ảnh gốc:
views/photos/_photo.html.erb
<div>
<% if photo.image_data? %>
<%= image_tag photo.image_url(:thumb) %>
<p>
Size <%= photo.image[:original].size %> bytes<br>
MIME type <%= photo.image[:original].mime_type %><br>
</p>
<% end %>
<p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>
Còn kích thước? Mặc định không lưu, nhưng có thể với plugin store_dimensions.
Kích Thước Ảnh
Plugin store_dimensions dựa trên gem fastimage, hook nó:
gem 'fastimage'
Đừng quên:
bundle install
Include plugin:
models/image_uploader.rb
plugin :store_dimensions
Hiển thị dimensions dùng method width và height:
views/photos/_photo.html.erb
<div>
<% if photo.image_data? %>
<%= image_tag photo.image_url(:thumb) %>
<p>
Size <%= photo.image[:original].size %> bytes<br>
MIME type <%= photo.image[:original].mime_type %><br>
Dimensions <%= "#{photo.image[:original].width}x#{photo.image[:original].height}" %>
</p>
<% end %>
<p><%= photo.title %> | <%= link_to 'Edit', edit_photo_path(photo) %></p>
</div>
Cũng có method dimensions trả array [width, height] (ví dụ [500, 750]).
Chuyển Sang Cloud
Developer thường chọn cloud service host file upload, Shrine hỗ trợ. Phần này chỉ cách upload lên Amazon S3.
Bước đầu, thêm hai gem vào Gemfile:
gem "aws-sdk", "~> 2.1" group :development do gem 'dotenv-rails' end
aws-sdk cần cho S3 SDK, dotenv-rails quản lý env variable development.
bundle install
Trước khi tiếp, cần key pair truy cập S3 qua API. Đăng nhập (đăng ký) Amazon Web Services Console, vào Security Credentials > Users. Tạo user có permission manipulate file trên S3. Policy đơn cho full access S3:
{
"Version": "2016-11-14",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}
]
}
Tải key pair user tạo. Hoặc dùng root access key, nhưng tôi khuyên mạnh không nên vì rất không an toàn.
Tạo S3 bucket host file, thêm file config vào root project:
.env
S3_KEY=YOUR_KEY S3_SECRET=YOUR_SECRET S3_BUCKET=YOUR_BUCKET S3_REGION=YOUR_REGION
Tuyệt đối không expose file này công khai, chắc chắn exclude khỏi Git:
.gitignore
.env
Sửa cấu hình toàn cục Shrine, thêm storage mới:
config/initializers/shrine.rb
require "shrine"
require "shrine/storage/s3"
s3_options = {
access_key_id: ENV['S3_KEY'],
secret_access_key: ENV['S3_SECRET'],
region: ENV['S3_REGION'],
bucket: ENV['S3_BUCKET'],
}
Shrine.storages = {
cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
store: Shrine::Storage::S3.new(prefix: "store", **s3_options),
}
Xong! Không cần thay đổi phần khác của app, test storage mới ngay. Nếu nhận lỗi S3 về key sai, chắc chắn copy key/secret chính xác, không trailing space và ký tự đặc biệt vô hình.
Kết Luận
Đến cuối bài. Hy vọng bạn tự tin dùng Shrine và háo hức dùng cho dự án. Đã thảo luận nhiều tính năng, nhưng còn hơn thế, như lưu context thêm cùng file và direct upload mechanism.
Hãy đọc tài liệu Shrine và trang chính thức, mô tả kỹ tất cả plugin. Nếu còn câu hỏi, đừng ngần ngại hỏi. Cảm ơn bạn theo dõi, hẹn gặp lại!