Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Ruby on Rails: Làm Chủ Model Patterns & Tránh Anti-Patterns

Chào mừng quay lại bài thứ hai trong series Ruby on Rails Patterns and Anti-patterns. Bài trước, chúng ta đã xem pattern và anti-pattern là gì nói chung. Cũng đã nhắc đến một số pattern và anti-pattern nổi tiếng trong thế giới Rails. Bài này sẽ đi qua một vài model anti-pattern và pattern của Rails.

Nếu bạn đang संघर्ष với model, bài này dành cho bạn. Chúng ta sẽ nhanh chóng qua quy trình "giảm cân" cho model và kết thúc mạnh mẽ với những thứ cần tránh khi viết migration. Hãy bắt đầu ngay.

Model Béo Thừa Cân

Khi phát triển ứng dụng Rails, dù là website đầy đủ hay API, mọi người thường để hầu hết logic trong model. Bài trước có ví dụ class Song làm nhiều việc. Giữ nhiều thứ trong model vi phạm Nguyên tắc Trách Nhiệm Đơn Độc (SRP).

Hãy xem xét.

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

Vấn đề với model như này là chúng trở thành "địa rác" cho logic khác nhau liên quan đến bài hát. Method tích lũy dần theo thời gian.

Tôi đã gợi ý tách code trong model thành các module nhỏ hơn. Nhưng làm vậy chỉ di chuyển code từ nơi này sang nơi khác. Tuy nhiên, di chuyển code cho phép tổ chức tốt hơn và tránh model phình to, giảm readability.

Một số người dùng Rails concerns và thấy logic có thể tái sử dụng across model. Tôi đã viết về nó trước và có người thích, người không. Dù sao, chuyện concerns cũng giống module. Bạn nên nhận thức rằng bạn chỉ di chuyển code sang module có thể include ở đâu.

Cách khác là tạo class nhỏ và gọi khi cần. Ví dụ, tách code convert bài hát sang class riêng.

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

Giờ có SongConverter với mục đích convert bài hát sang format khác. Nó có test riêng và logic convert sau này. Muốn convert sang MP3, làm:

@song.converter.to_mp3

Theo tôi, này rõ ràng hơn dùng module hay concern. Có lẽ vì tôi thích composition hơn inheritance. Coi đó trực quan và readable hơn. Tôi đề nghị review cả hai trước khi quyết định. Hoặc dùng cả hai, không ai cản bạn.

SQL Pasta Parmesan

Ai không thích pasta trong đời thật? Nhưng code pasta, hầu như không ai thích. Và có lý do. Trong model Rails, bạn nhanh chóng biến Active Record usage thành spaghetti, xoay xoáy khắp codebase. Làm sao tránh?

Có vài ý tưởng giữ cho query dài không thành spaghetti. Hãy xem code liên quan database có thể ở khắp nơi. Quay lại model Song. Cụ thể khi fetch dữ liệu.

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

Trong ví dụ trên, có ba use-case query model Song. Trong SongReporterService lấy published songs từ artist cụ thể. Trong SongController lấy published songs order by release date. Trong SongRefreshJob chỉ lấy published songs.

Điều này ổn, nhưng nếu đột ngột đổi status name thành released hoặc thay đổi cách fetch songs? Phải sửa tất cả occurrences riêng lẻ. Code cũng không DRY. Lặp lại across ứng dụng. Đừng buồn. May mắn có giải pháp.

Ta dùng Rails scopes để DRY code. Scoping cho phép định nghĩa query thường dùng, gọi trên associations và objects. Code readable và dễ thay đổi. Quan trọng nhất, scope cho phép chain các Active Record method như joins, where, v.v. Xem code với scopes:

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

Xong. Đã cắt code lặp và đặt vào model. Nhưng không phải lúc nào cũng tốt, nhất là nếu model béo hoặc God Object. Thêm method và trách nhiệm vào model có thể không hay.

Lời khuyên: giữ usage scope tối thiểu và chỉ extract common queries. Trong trường hợp này, where(published: true) là scope hoàn hảo vì dùng khắp nơi. Cho SQL khác, dùng Repository pattern. Hãy tìm hiểu.

Repository Pattern

Điều sắp trình bày không phải Repository pattern 1:1 như trong sách Domain-Driven Design. Ý tưởng sau chúng ta và Rails Repository pattern là tách logic database khỏi business logic. Có thể đi full tạo repository class làm raw SQL calls thay Active Record, nhưng không khuyến khích trừ khi thực sự cần.

Ta có thể tạo SongRepository và đặt logic database ở đó.

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 vào class testable. Model không còn lo scopes và logic. Controller và model mảnh, mọi người vui. Đúng không? Vẫn còn Active Record làm heavy lifting. Trong scenario này, dùng find sinh ra:

SELECT "songs".* FROM "songs" WHERE "songs"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]

Cách "đúng" là định nghĩa tất cả trong SongRepository. Như nói, không khuyến khích. Bạn không cần và muốn full control. Use case đi xa Active Record là cần complex SQL tricks không dễ support bởi Active Record.

Nói về raw SQL và Active Record, phải nhắc migrations và cách làm đúng. Hãy đi sâu.

Migrations — Ai Quan Tâm?

Thường nghe argument khi viết migration: code ở đó không cần tốt như phần còn lại của ứng dụng. Argument đó không hợp với tôi. Mọi người dùng cái cớ này để setup smelly code trong migrations vì chỉ chạy một lần và quên. Có thể đúng nếu làm việc với vài người và luôn sync thường xuyên.

Thực tế thường khác. Ứng dụng có thể dùng bởi nhiều người không biết chuyện gì xảy ra với các phần khác. Nếu để code one-off đáng ngờ, có thể break development environment của ai đó vài giờ vì corrupted database state hoặc migration lạ. Không chắc đây là anti-pattern, nhưng nên nhận thức.

Làm sao migrations tiện hơn cho người khác? Xem danh sách giúp migrations dễ dàng cho mọi người trong dự án.

Luôn Cung Cấp Method Down

Không bao giờ biết khi nào rollback. Nếu migration không reversible, hãy raise ActiveRecord::IrreversibleMigration exception như sau:

def down
  raise ActiveRecord::IrreversibleMigration
end

Cố Gắng Tránh Active Record Trong Migrations

Ý tưởng: minimize external dependencies trừ state của database tại thời điểm migration chạy. Vậy không có Active Record validations làm hỏng (hoặc cứu) ngày của bạn. Chỉ còn plain SQL. Ví dụ, viết migration publish tất cả bài hát của artist nhất định.

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

Nếu rất cần model Song, gợi ý định nghĩa nó bên trong migration. Cách đó bulletproof migration khỏi thay đổi tiềm năng trong model Active Record thực trong app/models. Nhưng có ổn không? Đi điểm tiếp.

Tách Schema Migrations Khỏi Data Migrations

Đọc Rails Guides về migrations, bạn sẽ thấy:

Migrations là tính năng của Active Record cho phép bạn phát triển database schema theo thời gian. Thay vì viết schema modifications bằng pure SQL, migrations cho phép dùng Ruby DSL mô tả thay đổi tables.

Trong summary của guide, không nhắc đến edit actual data của table, chỉ structure. Vì vậy, dùng regular migration update songs ở điểm hai không hoàn toàn đúng.

Nếu cần thường xuyên làm tương tự, cân nhắc gem data_migrate. Cách hay tách data migrations khỏi schema migrations. Dễ rewrite ví dụ trước với nó. Generate data migration:

bin/rails generate data_migration update_artists_songs_to_published

Thêm logic migration:

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

Cách này giữ schema migrations trong db/migrate và data migrations trong db/data.

Suýt Quên

Xử lý model và giữ readable trong Rails là cuộc đấu tranh liên tục. Hy vọng bài này cho thấy pitfalls và giải pháp cho common problems. Danh sách model anti-patterns và patterns còn xa từ complete, nhưng đây là những nổi bật tôi tìm thấy gần đây.

Nếu quan tâm thêm Rails patterns và anti-patterns, stay tuned cho bài tiếp theo. Sắp tới sẽ đi qua common problems và solutions cho view và controller của Rails MVC.

Hẹn gặp lại, cheers!

P.S. Nếu muốn đọc bài Ruby Magic ngay khi xuất bản, đăng ký newsletter Ruby Magic để không bỏ lỡ!