Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

Ruby on Rails: Mastering Model Patterns & Avoiding Anti‑Patterns

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 และไม่พลาดโพสต์เด็ดๆ!