Model ที่อ้วน (Overweight Models)
เมื่อพัฒนาแอป Rails ไม่ว่าจะเป็นเว็บไซต์เต็มรูปแบบหรือ API ผู้พัฒนามักเก็บ logic ส่วนใหญ่ไว้ใน model ในบทความก่อนหน้านี้มีตัวอย่าง class Song ที่ทำหลายอย่าง การเก็บสิ่งมากเกินไปใน model ละเมิด Single Responsibility Principle (SRP)
มาดูตัวอย่าง:
class Song < ApplicationRecord
belongs_to :album
belongs_to :artist
belongs_to :publisher
has_one :text
has_many :downloads
validates :artist_id, presence: true
validates :publisher_id, presence: true
after_update :alert_artist_followers
after_update :alert_publisher
def alert_artist_followers
return if unreleased?
artist.followers.each { |follower| follower.notify(self) }
end
def alert_publisher
PublisherMailer.song_email(publisher, self).deliver_now
end
def includes_profanities?
text.scan_for_profanities.any?
end
def user_downloaded?(user)
user.library.has_song?(self)
end
def find_published_from_artist_with_albums
...
end
def find_published_with_albums
...
end
def to_wav
...
end
def to_mp3
...
end
def to_flac
...
end
end
ปัญหาของ model แบบนี้คือกลายเป็น "ซากขยะ" สำหรับ logic ต่างๆ ที่เกี่ยวข้องกับเพลง Method สะสมมากขึ้นเรื่อยๆ ตามเวลา
เคยแนะนำแยกโค้ดใน model เป็น module ย่อยๆ แต่การทำอย่างนั้นเพียงย้ายโค้ดจากที่หนึ่งไปอีกที่หนึ่ง อย่างไรก็ตาม การย้ายโค้ดช่วยจัดระเบียบโค้ดให้ดีขึ้นและหลีกเลี่ยง model ที่อ้วนและอ่านยาก
บางคนใช้ Rails concerns คิดว่า logic นำกลับมาใช้ซ้ำได้ แต่นี่ก็คล้ายกับ module คือแค่ย้ายโค้ดไปยัง module ที่ include ได้ทุกที่
ทางเลือกอื่นคือสร้าง class เล็กๆ แล้วเรียกใช้เมื่อต้องการ เช่น แยกโค้ดแปลงเพลงไป class แยก:
class SongConverter
attr_reader :song
def initialize(song)
@song = song
end
def to_wav
...
end
def to_mp3
...
end
def to_flac
...
end
end
class Song
...
def converter
SongConverter.new(self)
end
...
end
ตอนนี้มี SongConverter ที่หน้าที่คือแปลงเพลงเป็นรูปแบบต่างๆ มี test ของตัวเองและ logic แปลงเพลงในอนาคต ถ้าอยากแปลงเป็น MP3:
@song.converter.to_mp3
ดูชัดเจนกว่าใช้ module หรือ concern เพราะชอบ composition over inheritance ถือว่า直觀และอ่านง่ายกว่า แนะนำทบทวนทั้งสองกรณีก่อนตัดสินใจ หรือเลือกทั้งสองก็ได้ ไม่มีใครห้าม
SQL Pasta Parmesan (สปาเกตตี้โค้ด SQL)
ใครไม่ชอบพาสต้าจริงๆ? แต่โค้ดพาสต้าเกือบไม่มีแฟน เพราะเหตุผลที่ดี ใน Rails model คุณเร็วๆ นี้จะทำให้ Active Record usage กลายเป็นสปาเกตตี้纏繞ทั่วโค้ดเบส แล้วกันยังไง?
มีไอเดีย 몇 อยู่ที่ช่วยกันไม่ให้คิวรียาวกลายเป็นสายสปาเกตตี้ มาดูว่ารหัสเกี่ยวกับฐานข้อมูลอยู่ทุกที่ได้อย่างไร กลับไปที่ model Song ตอนพยายามดึงข้อมูล:
class SongReportService
def gather_songs_from_artist(artist_id)
songs = Song.where(status: :published)
.where(artist_id: artist_id)
.order(:title)
...
end
end
class SongController < ApplicationController
def index
@songs = Song.where(status: :published)
.order(:release_date)
...
end
end
class SongRefreshJob < ApplicationJob
def perform
songs = Song.where(status: :published)
...
end
end
ในตัวอย่างมีสาม use-case ที่ query model Song ใน SongReporterService ดึงเพลงที่เผยแพร่แล้วจาก artist เฉพาะ ใน SongController ดึงเพลงที่เผยแพร่เรียงตามวันที่ปล่อย และใน SongRefreshJob ดึงเฉพาะเพลงที่เผยแพร่
ดูโอเค แต่ถ้าตัดสินใจเปลี่ยนชื่อ status เป็น released หรือเปลี่ยนวิธีดึงเพลง? ต้องไปแก้ทุกที่แยกกัน โค้ดข้างต้นไม่ DRY ซ้ำซ้อนทั่วแอปพลิเคชัน โชคดีมีวิธีแก้
ใช้ Rails scopes เพื่อ DRY โค้ด Scoping ให้กำหนดคิวรีที่ใช้บ่อย เรียกใช้บน associations และ objects ได้ ทำให้โค้ดอ่านง่ายและเปลี่ยนแปลงง่าย สำคัญคือ scope ช่วย chain method Active Record อื่นๆ เช่น joins, where ได้ มาดูโค้ดกับ scope:
class Song < ApplicationRecord
...
scope :published, -> { where(published: true) }
scope :by_artist, ->(artist_id) { where(artist_id: artist_id) }
scope :sorted_by_title, { order(:title) }
scope :sorted_by_release_date, { order(:release_date) }
...
end
class SongReportService
def gather_songs_from_artist(artist_id)
songs = Song.published.by_artist(artist_id).sorted_by_title
...
end
end
class SongController < ApplicationController
def index
@songs = Song.published.sorted_by_release_date
...
end
end
class SongRefreshJob < ApplicationJob
def perform
songs = Song.published
...
end
end
โค้ดซ้ำลดลง วางไว้ใน model แต่ไม่ได้ดีเสมอไป โดยเฉพาะถ้าคุณเป็น Fat Model หรือ God Object การเพิ่ม method และ responsibility ให้ model อาจไม่ดี
คำแนะนำ: ใช้ scope ขั้นต่ำ แยกเฉพาะคิวรีทั่วไป ในกรณีนี้ where(published: true) เป็น scope เหมาะสม เพราะใช้ทุกที่ สำหรับ SQL อื่นๆ ใช้ Repository pattern มาดูว่าเป็นอะไร
Repository Pattern
สิ่งที่จะแสดงไม่ใช่ Repository pattern 1:1 ตามหนังสือ Domain-Driven Design แต่วิธีคิดคือแยก database logic จาก business logic สร้าง SongRepository ใส่ database logic ไว้:
class SongRepository
class << self
def find(id)
Song.find(id)
rescue ActiveRecord::RecordNotFound => e
raise RecordNotFoundError, e
end
def destroy(id)
find(id).destroy
end
def recently_published_by_artist(artist_id)
Song.where(published: true)
.where(artist_id: artist_id)
.order(:release_date)
end
end
end
class SongReportService
def gather_songs_from_artist(artist_id)
songs = SongRepository.recently_published_by_artist(artist_id)
...
end
end
class SongController < ApplicationController
def destroy
...
SongRepository.destroy(params[:id])
...
end
end
ที่ทำ: isolate querying logic ไป class ที่ testable ได้ model ไม่กังวล scope และ logic controller และ model บางลง ทุกคนมีความสุขใช่ไหม? ยังมี Active Record ทำ heavy lifting อยู่ ในสถานการณ์นี้ใช้ find ที่ generate SQL:
SELECT "songs".* FROM "songs" WHERE "songs"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
วิธี "ถูกต้อง" คือ define ทุกอย่างใน SongRepository แต่ไม่แนะนำ ไม่จำเป็นและอยากควบคุมเต็มที่ Use case สำหรับห่างจาก Active Record คือต้องการ complex SQL tricks ที่ Active Record รองรับยาก
พูดถึง raw SQL และ Active Record ต้องยก migration และวิธีทำให้ถูกต้อง มาลงลึก
Migrations — ใครสนใจ?
บ่อยครั้งได้ยินอ้างว่าการเขียน migration โค้ดไม่ต้องดีเท่าโค้ดอื่นในแอป อ้างนี้ไม่ค่อยพอใจ มักใช้ข้ออ้างนี้เขียนโค้ดกลิ่นใน migration เพราะรันครั้งเดียวจบ และอาจทำลาย development environment ของคนอื่นได้หลายชั่วโมงเพราะ database state เสียหรือ migration แปลกๆ ไม่แน่ใจว่าเป็น anti-pattern หรือไม่ แต่ควรระวัง
ทำไงให้ migration สะดวกกว่าสำหรับคนอื่น? มาดูรายการ:
ให้แน่ใจว่ามี Down Method เสมอ
ไม่รู้เมื่อไหร่จะ rollback ถ้า migration irreversible ให้ raise ActiveRecord::IrreversibleMigration exception:
def down
raise ActiveRecord::IrreversibleMigration
end
พยายามหลีกเลี่ยง Active Record ใน Migration
แนวคิดคือลด external dependencies ยกเว้น state ของฐานข้อมูลตอน migration ควรรัน ไม่มี Active Record validations ทำลาย (หรือช่วย) วันของคุณ เหลือแค่ plain SQL ตัวอย่าง migration publish เพลงทั้งหมดของ artist เฉพาะ:
class UpdateArtistsSongsToPublished < ActiveRecord::Migration[6.0]
def up
execute <<-SQL
UPDATE songs SET published = true WHERE artist_id = 46
SQL
end
def down
execute <<-SQL
UPDATE songs SET published = false WHERE artist_id = 46
SQL
end
end
หากต้องการ model Song จริงๆ แนะนำ define ไว้ใน migration เอง เพื่อ bulletproof migration จากการเปลี่ยนแปลงใน Active Record model จริงใน app/models แต่สิ้นสุดแค่นี้ไหม? ไปจุดต่อไป
แยก Schema Migrations จาก Data Migrations
อ่าน Rails Guides เรื่อง migrations จะเจอ:
Migrations เป็น feature ของ Active Record ให้คุณ evolve database schema ตามเวลา แทนที่จะเขียน schema modifications ใน pure SQL migrations ให้ใช้ Ruby DSL อธิบายการเปลี่ยนแปลงตาราง
ในสรุปคู่มือ ไม่ได้กล่าวถึงการแก้ไขข้อมูลจริงในตาราง แค่โครงสร้าง ดังนั้นการใช้ migration ธรรมดา update เพลงในจุดที่สองไม่ถูกต้องทั้งหมด
หากต้องทำบ่อยๆ ในโปรเจกต์ พิจารณา gem data_migrate วิธีแยก data migration จาก schema migration สามารถเขียนใหม่ตัวอย่างก่อนหน้าได้ สร้าง data migration:
bin/rails generate data_migration update_artists_songs_to_published
เพิ่ม migration logic:
class UpdateArtistsSongsToPublished < ActiveRecord::Migration[6.0]
def up
execute <<-SQL
UPDATE songs SET published = true WHERE artist_id = 46
SQL
end
def down
execute <<-SQL
UPDATE songs SET published = false WHERE artist_id = 46
SQL
end
end
วิธีนี้เก็บ schema migrations ทั้งหมดใน db/migrate และ migration ที่จัดการข้อมูลใน db/data
ข้อคิดสุดท้าย
จัดการ model ให้อ่านง่ายใน Rails เป็นการต่อสู้ตลอดเวลา หวังว่าในบทความนี้ได้เห็น pitfalls และ solution สำหรับปัญหาทั่วไป รายการ model anti-patterns และ patterns ยังไม่ครบในโพสต์นี้ แต่นี่คือที่โดดเด่นที่สุดที่พบล่าสุด
หากสนใจ Rails patterns และ anti-patterns เพิ่มเติม ติดตามตอนต่อไปในซีรีส์ จะเจาะลึกปัญหาและ solution ด้าน view และ controller ของ Rails MVC
จนกว่าจะเจอกันใหม่ cheers!
P.S. หากอยากอ่าน Ruby Magic posts ทันทีที่เผยแพร่ สมัครสมาชิก Ruby Magic newsletter และไม่พลาดโพสต์เด็ดๆ!