มีเจมอัปโหลดไฟล์เยอะมาก เช่น CarrierWave, Paperclip, Dragonfly ทุกตัวมีความเฉพาะตัว คุณน่าจะเคยใช้อย่างน้อยหนึ่งตัวแล้ว
วันนี้จะแนะนำโซลูชันใหม่แต่กนกกินชื่อ Shrine สร้างโดย Janko Marohnić ต่างจากเจมอื่นที่มันมีแนวทาง โมดูลาร์ (modular) หมายถึงทุกฟีเจอร์บรรจุเป็นโมดูล (หรือ plugin ในศัพท์ Shrine) อยาก validation? 加 plugin อยากประมวลผลไฟล์? 加 plugin! ผมชอบแนวทางนี้มาก เพราะควบคุมฟีเจอร์ที่เปิดใช้งานต่อโมเดลได้ง่าย
บทความนี้จะสอน:
- ผสาน Shrine เข้าแอป Rails
- ตั้งค่า (global และ per-uploader)
- เพิ่มความสามารถอัปโหลดไฟล์
- ประมวลผลไฟล์ (processing)
- เพิ่มกฎ validation
- เก็บ metadata เพิ่มเติม และใช้ cloud storage กับ Amazon S3
ซอร์สโค้ดอยู่ที่ GitHub มี working demo ด้วย
ผสานรวม Shrine
สร้างแอป Rails ใหม่โดยไม่มี test suite เริ่มต้น:
rails new FileGuru -T
ใช้ Rails 5 สำหรับเดโม แนวคิดส่วนใหญ่ใช้กับ v3, v4 ได้
เพิ่ม Shrine gem ใน Gemfile:
gem "shrine"
ติดตั้ง:
bundle install
ต้องการโมเดลชื่อ Photo Shrine เก็บข้อมูลไฟล์ในคอลัมน์ text ที่ลงท้าย _data สร้างและรัน migration:
rails g model Photo title:string image_data:text rails db:migrate
สำหรับ Rails เก่า ใช้ rake db:migrate
ตั้งค่า Shrine ทั้ง global และ per-model Global settings ทำใน initializer file hook ไฟล์และ plugins ที่จำเป็น Plugins ใช้แยก functionality เป็นโมดูลแยก ควบคุมฟีเจอร์ทั้งหมดได้ เช่น plugins สำหรับ validation, image processing, caching attachments
ตอนนี้เพิ่ม 2 plugins: รองรับ ActiveRecord และ logging รวม global ตั้งค่า 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 จะ log debug info ใน console บอกเวลาที่ใช้ประมวลผลไฟล์ มีประโยชน์:
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)
ไฟล์อัปโหลดเก็บใน public/uploads ไม่อยาก track ใน Git ให้ ignore:
.gitignore
public/uploads
สร้าง "uploader" class สำหรับ model-specific settings ตอนนี้ว่างเปล่า:
models/image_uploader.rb
class ImageUploader < Shrine end
Include ในโมเดล Photo:
models/photo.rb
include ImageUploader[:image]
[:image] เพิ่ม virtual attribute ใช้ตอนสร้างฟอร์ม บรรทัดนี้เทียบเท่า:
include ImageUploader.attachment(:image) # หรือ include ImageUploader::Attachment.new(:image)
ตอนนี้โมเดลพร้อมใช้งาน Shrine ไปต่อ!
Controller, Views, และ Routes
สำหรับเดโม ต้องการ controller เดียวจัดการ photos index page เป็น 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 %>
ต้องมี partial render @photos array:
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? เป็นเมธอดของ ActiveRecord ตรวจสอบว่าเรคคอร์ดมีรูปหรือไม่
image_url เมธอด Shrine คืนพาธไปรูปต้นฉบับ แต่น่าจะแสดง thumbnail ดีกว่า จะทำในภายหลัง
เพิ่ม routes:
config/routes.rb
resources :photos, only: [:new, :create, :index, :edit, :update] root 'photos#index'
พื้นฐานเสร็จ ไปส่วนสนุก!
อัปโหลดไฟล์
เพิ่มฟังก์ชันอัปโหลดจริง Controller actions เรียบง่าย:
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
จุดสำคัญ: strong parameters ต้อง permit image virtual attribute ไม่ใช่ image_data
photos_controller.rb
private def photo_params params.require(:photo).permit(:title, :image) end
สร้าง new view:
views/photos/new.html.erb
<h1>Add photo</h1> <%= render 'form' %>
Form partial:
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 %>
อีกครั้ง ใช้ image attribute ไม่ใช่ image_data
เพิ่ม partial แสดง errors:
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 %>
ตอนนี้อัปโหลดรูปได้แล้ว
Validations
ยังต้องทำอีกเยอะ ผู้ใช้อาจอัปโหลดไฟล์ประเภทไหนก็ได้ ขนาดไหนก็ได้ ไม่ดี เพิ่ม plugin validation:
config/initializers/shrine.rb
Shrine.plugin :validation_helpers
ตั้งค่า validation logic ใน 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
อนุญาตเฉพาะ JPG และ PNG น้อยกว่า 1MB ปรับกฎได้ตามใจ
MIME Types
โดยค่าเริ่มต้น Shrine กำหนด MIME type จาก Content-Type HTTP header ที่ browser ส่ง ตั้งค่าจาก extension ไฟล์อย่างเดียว อาจไม่เหมาะสม
อยากกำหนด MIME type จากเนื้อหาไฟล์ ใช้ plugin determine_mime_type include ใน uploader class (โมเดลอื่นอาจไม่ต้องการ):
models/image_uploader.rb
plugin :determine_mime_type
Plugin นี้ใช้ Linux file utility โดยค่าเริ่มต้น
Caching Attached Images
ตอนนี้ user ส่งฟอร์มข้อมูลผิด ฟอร์มแสดงใหม่พร้อม errors แตไฟล์แนบหาย ต้องเลือกใหม่ แก้ง่ายด้วย plugin cached_attachment_data:
models/image_uploader.rb
plugin :cached_attachment_data
เพิ่ม hidden field ในฟอร์ม:
views/photos/_form.html.erb
<%= f.hidden_field :image, value: @photo.cached_image_data %> <%= f.label :image %> <%= f.file_field :image %>
แก้ไขรูปภาพ (Editing a Photo)
อัปโหลดได้แล้ว แต่แก้ไขไม่ได้ มาแก้ actions ใน controller:
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
ใช้ _form partial เดียวกัน:
views/photos/edit.html.erb
<h1>Edit Photo</h1> <%= render 'form' %>
ยังไม่พอ: user ลบรูปที่อัปโหลดไม่ได้ ต้องเพิ่ม plugin remove_attachment:
models/image_uploader.rb
plugin :remove_attachment
ใช้ virtual attribute :remove_image permit ใน controller:
photos_controller.rb
def photo_params params.require(:photo).permit(:title, :image, :remove_image) end
แสดง checkbox ลบรูปถ้ามี attachment:
views/photos/_form.html.erb
<% if @photo.image_data? %> Remove attachment: <%= f.check_box :remove_image %> <% end %>
สร้างภาพขนาดย่อ (Generating a Thumbnail Image)
ตอนนี้แสดงรูปต้นฉบับ ไม่ดีสำหรับ preview: รูปใหญ่佔พื้นที่ แม้ CSS width/height ก็ยังดาวน์โหลดไฟล์ต้นฉบับใหญ่ ดีกว่าสร้าง preview เล็กๆ บน server ตอนอัปโหลด ต้องใช้ 2 plugins และ 2 gems เพิ่ม:
gem "image_processing" gem "mini_magick", ">= 4.3.5"
image_processing สร้างโดยผู้เขียน Shrine ให้ high-level helpers manipulate รูป ใช้ mini_magick wrapper ของ ImageMagick ต้องติดตั้ง ImageMagick บนระบบ
ติดตั้ง gems:
bundle install
Include plugins และ dependencies:
models/image_uploader.rb
require "image_processing/mini_magick" class ImageUploader < Shrine include ImageProcessing::MiniMagick plugin :processing plugin :versions # other code... end
processing plugin ให้ manipulate รูป (ย่อ, หมุน, convert format) versions plugin ให้มี variants หลายรูป เดโมนี้เก็บ 2 versions: "original" และ "thumb" (resize 300x300)
โค้ดประมวลผลและเก็บ 2 versions:
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! จาก image_processing gem ย่อรูปเหลือ 300x300 ถ้าใหญ่กว่า ถ้าเล็กกว่าไม่ทำอะไร รักษาอัตราส่วนเดิม
ตอนแสดงรูป ใส่ argument :original หรือ :thumb ให้ 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>
ในฟอร์มก็ทำได้:
views/photos/_form.html.erb
<% if @photo.image_data? %> <%= image_tag @photo.image_url(:thumb) %> Remove attachment: <%= f.check_box :remove_image %> <% end %>
ลบไฟล์ processed อัตโนมัติหลังอัปโหลดเสร็จ ใช้ plugin delete_raw:
models/image_uploader.rb
plugin :delete_raw
Metadata ของรูปภาพ
นอกจากแสดงรูป ยังดึง metadata ได้ เช่น แสดงขนาดและ MIME type ของรูปต้นฉบับ:
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>
ขนาดกว้าง-สูง (dimensions) ไม่เก็บโดยค่าเริ่มต้น แต่ทำได้ด้วย plugin store_dimensions
ขนาดรูปภาพ (Image's Dimensions)
Plugin store_dimensions ต้องใช้ fastimage gem:
gem 'fastimage'
ติดตั้ง:
bundle install
Include plugin:
models/image_uploader.rb
plugin :store_dimensions
แสดง dimensions ด้วยเมธอด width และ 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>
ยังมีเมธอด dimensions คืน array [width, height] เช่น [500, 750]
ย้ายไปคลาวด์ (Moving to the Cloud)
นักพัฒนาบ่อยครั้งเลือก cloud storage สำหรับไฟล์อัปโหลด Shrine รองรับ Amazon S3
เพิ่ม gems ใน Gemfile:
gem "aws-sdk", "~> 2.1" group :development do gem 'dotenv-rails' end
aws-sdk สำหรับ S3 SDK dotenv-rails จัดการ environment variables ใน development
bundle install
ก่อนต่อ ต้องมี key pair เข้า S3 ผ่าน API สมัคร/ล็อกอิน AWS Console ไป Security Credentials > Users สร้าง user มี permission จัดการไฟล์ S3 Policy ให้ full access:
{
"Version": "2016-11-14",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}
]
}
ดาวน์โหลด key pair ของ user ที่สร้าง อย่าใช้ root access keys ไม่ปลอดภัย
สร้าง S3 bucket เก็บไฟล์ เพิ่มไฟล์ config ในรากโปรเจกต์:
.env
S3_KEY=YOUR_KEY S3_SECRET=YOUR_SECRET S3_BUCKET=YOUR_BUCKET S3_REGION=YOUR_REGION
ห้ามเปิดเผย ไฟล์นี้สาธารณะ และ ignore ใน Git:
.gitignore
.env
แก้ global config Shrine เพิ่ม storage ใหม่:
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),
}
เสร็จแล้ว! ไม่ต้องแก้ส่วนอื่น ทดสอบ storage ใหม่ได้เลย หาก error จาก S3 เรื่อง key ผิด ตรวจสอบ key/secret คัดลอกถูกต้อง ไม่มี space หรือ special chars ซ่อน
สรุป
จบบทความ หวังว่าคุณมั่นใจใช้ Shrine และอยากนำไปใช้ในโปรเจกต์ ยังมีฟีเจอร์เพิ่มเติม เช่น เก็บ context เพิ่มเติมกับไฟล์ และ direct upload mechanism
อ่านเอกสารและเว็บไซต์อย่างเป็นทางการของ Shrine รายละเอียด plugins ทั้งหมด ถ้ามีคำถาม ถามได้เลย ขอบคุณที่อ่าน เจอกันใหม่ قريب!