Computer >> 컴퓨터 >  >> 프로그램 작성 >> Ruby

Ruby on Rails 컨트롤러 패턴 및 안티 패턴

Ruby on Rails Patterns and Anti-Patterns 시리즈의 네 번째 기사에 오신 것을 환영합니다.

이전에는 Rails Models 및 Views와의 관계뿐만 아니라 일반적인 패턴과 안티패턴에 대해 다루었습니다. 이 포스트에서는 MVC(Model-View-Controller) 디자인 패턴의 마지막 부분인 Controller를 분석할 것입니다. Rails Controller와 관련된 패턴과 안티패턴에 대해 알아보자.

최전선에서

Ruby on Rails는 웹 프레임워크이므로 HTTP 요청은 필수 요소입니다. 모든 종류의 클라이언트는 요청을 통해 Rails 백엔드에 도달하며 컨트롤러가 빛나는 곳입니다. 컨트롤러는 요청을 수신하고 처리하는 최전선에 있습니다. 이것은 그것들을 Ruby on Rails 프레임워크의 근본적인 부분으로 만듭니다. 물론 컨트롤러보다 먼저 오는 코드가 있지만 컨트롤러 코드는 우리 대부분이 제어할 수 있는 것입니다.

config/routes.rb에서 경로를 정의하면 , 설정된 경로에서 서버에 도달할 수 있으며 나머지는 해당 컨트롤러가 처리합니다. 앞의 문장을 읽으면 모든 것이 그것만큼 간단하다는 인상을 줄 수 있습니다. 그러나 종종 많은 무게가 컨트롤러의 어깨에 가집니다. 인증 및 권한 부여에 대한 우려가 있고 필요한 데이터를 가져오는 방법과 비즈니스 로직을 수행하는 위치 및 방법에 대한 문제가 있습니다.

컨트롤러 내부에서 발생할 수 있는 이러한 모든 우려와 책임은 일부 안티 패턴으로 이어질 수 있습니다. 가장 '유명한' 것 중 하나는 "뚱뚱한" 컨트롤러의 안티 패턴입니다.

지방(비만) 조절기

컨트롤러에 너무 많은 논리를 넣는 것의 문제는 단일 책임 원칙(SRP)을 위반하기 시작한다는 것입니다. 이는 컨트롤러 내부에서 너무 많은 작업을 수행하고 있음을 의미합니다. 종종 이로 인해 많은 코드와 책임이 쌓이게 됩니다. 여기서 '뚱뚱한'은 컨트롤러 파일에 포함된 광범위한 코드와 컨트롤러가 지원하는 논리를 나타냅니다. 이는 종종 안티 패턴으로 간주됩니다.

컨트롤러가 무엇을 해야 하는지에 대해 많은 의견이 있습니다. 컨트롤러가 가져야 하는 책임의 공통 근거는 다음과 같습니다.

  • 인증 및 승인 — 요청 뒤에 있는 엔터티(종종 사용자)가 요청자가 누구인지 확인하고 리소스에 액세스하거나 작업을 수행할 수 있는지 여부를 확인합니다. 종종 인증은 세션이나 쿠키에 저장되지만 컨트롤러는 여전히 인증 데이터가 유효한지 확인해야 합니다.
  • 데이터 가져오기 — 요청과 함께 제공된 매개변수를 기반으로 올바른 데이터를 찾기 위한 논리를 호출해야 합니다. 완벽한 세계에서는 모든 작업을 수행하는 하나의 메서드에 대한 호출이어야 합니다. 컨트롤러는 무거운 작업을 수행해서는 안 되며 추가로 위임해야 합니다.
  • 템플릿 렌더링 — 마지막으로 적절한 형식(HTML, JSON 등)으로 결과를 렌더링하여 올바른 응답을 반환해야 합니다. 또는 다른 경로나 URL로 리디렉션되어야 합니다.

이러한 아이디어를 따르면 일반적으로 컨트롤러 작업 및 컨트롤러 내부에서 너무 많은 작업을 수행하는 것을 방지할 수 있습니다. 컨트롤러 수준에서 단순하게 유지하면 애플리케이션의 다른 영역에 작업을 위임할 수 있습니다. 책임을 위임하고 하나씩 테스트하면 앱을 강력하게 개발할 수 있습니다.

물론 위의 원칙을 따를 수는 있지만 몇 가지 예를 열망해야 합니다. 컨트롤러의 무게를 줄이기 위해 어떤 패턴을 사용할 수 있는지 살펴보겠습니다.

쿼리 개체

컨트롤러 작업 내에서 발생하는 문제 중 하나는 데이터 쿼리가 너무 많다는 것입니다. Rails Model 안티 패턴 및 패턴에 대한 블로그 게시물을 팔로우했다면 모델에 너무 많은 쿼리 로직이 있는 유사한 문제를 겪었습니다. 하지만 이번에는 Query Object라는 패턴을 사용할 것입니다. 쿼리 개체는 복잡한 쿼리를 단일 개체로 분리하는 기술입니다.

대부분의 경우 Query Object는 ActiveRecord로 초기화되는 Plain Old Ruby Object입니다. 관계. 일반적인 쿼리 개체는 다음과 같습니다.

# app/queries/all_songs_query.rb
 
class AllSongsQuery
  def initialize(songs = Song.all)
    @songs = songs
  end
 
  def call(params, songs = Song.all)
    songs.where(published: true)
         .where(artist_id: params[:artist_id])
         .order(:title)
  end
end

다음과 같이 컨트롤러 내부에서 사용하도록 만들어졌습니다.

class SongsController < ApplicationController
  def index
    @songs = AllSongsQuery.new.call(all_songs_params)
  end
 
  private
 
  def all_songs_params
    params.slice(:artist_id)
  end
end

쿼리 개체의 다른 접근 방식을 시도해 볼 수도 있습니다.

# app/queries/all_songs_query.rb
 
class AllSongsQuery
  attr_reader :songs
 
  def initialize(songs = Song.all)
    @songs = songs
  end
 
  def call(params = {})
    scope = published(songs)
    scope = by_artist_id(scope, params[:artist_id])
    scope = order_by_title(scope)
  end
 
  private
 
  def published(scope)
    scope.where(published: true)
  end
 
  def by_artist_id(scope, artist_id)
    artist_id ? scope.where(artist_id: artist_id) : scope
  end
 
  def order_by_title(scope)
    scope.order(:title)
  end
end

후자의 접근 방식은 params를 만들어 쿼리 개체를 보다 강력하게 만듭니다. 선택 과목. 또한 이제 AllSongsQuery.new.call을 호출할 수 있습니다. .당신이 이것의 열렬한 팬이 아니라면, 당신은 클래스 메소드에 의지할 수 있습니다. 쿼리 클래스를 클래스 메소드로 작성하면 더 이상 '객체'가 아니지만 이것은 개인 취향의 문제입니다. 설명을 위해 AllSongsQuery를 만드는 방법을 살펴보겠습니다. 야생에서 호출하는 것이 더 간단합니다.

# app/queries/all_songs_query.rb
 
class AllSongsQuery
  class << self
    def call(params = {}, songs = Song.all)
      scope = published(songs)
      scope = by_artist_id(scope, params[:artist_id])
      scope = order_by_title(scope)
    end
 
    private
 
    def published(scope)
      scope.where(published: true)
    end
 
    def by_artist_id(scope, artist_id)
      artist_id ? scope.where(artist_id: artist_id) : scope
    end
 
    def order_by_title(scope)
      scope.order(:title)
    end
  end
end

이제 AllSongsQuery.call을 호출할 수 있습니다. 그리고 우리는 끝났습니다. params를 전달할 수 있습니다. artist_id 포함 . 또한 어떤 이유로 변경해야 하는 경우 초기 범위를 전달할 수 있습니다. new 호출을 정말로 피하고 싶다면 쿼리 클래스에 대해 다음 '트릭'을 시도해 보세요.

# app/queries/application_query.rb
 
class ApplicationQuery
  def self.call(*params)
    new(*params).call
  end
end

ApplicationQuery를 만들 수 있습니다. 그런 다음 다른 쿼리 클래스에서 상속합니다.

# app/queries/all_songs_query.rb
class AllSongsQuery < ApplicationQuery
  ...
end

여전히 AllSongsQuery.call을 유지했습니다. , 하지만 당신은 그것을 더 우아하게 만들었습니다.

쿼리 개체의 장점은 격리된 상태에서 테스트하고 수행해야 하는 작업을 수행하는지 확인할 수 있다는 것입니다. 또한 컨트롤러의 논리에 대해 너무 걱정하지 않고도 이러한 쿼리 클래스를 확장하고 테스트할 수 있습니다. 한 가지 주의할 점은 요청 매개변수를 다른 곳에서 처리해야 하며 쿼리 개체에 의존해서는 안 된다는 것입니다. 어떻게 생각하세요? 쿼리 개체를 시도하시겠습니까?

봉사 준비

자, 우리는 QueryObjects에 데이터 수집 및 가져오기를 위임하는 방법을 처리했습니다. 데이터 수집과 그것을 렌더링하는 단계 사이에 쌓인 논리로 무엇을 합니까? 솔루션 중 하나가 서비스라는 것을 사용하는 것이기 때문에 질문하신 것이 좋습니다. 서비스는 종종 단일(비즈니스) 작업을 수행하는 PORO(Plain Old Ruby Object)로 간주됩니다. 이 아이디어를 아래에서 조금 더 살펴보겠습니다.

두 개의 서비스가 있다고 상상해보십시오. 하나는 영수증을 생성하고 다른 하나는 다음과 같이 사용자에게 영수증을 보냅니다.

# app/services/create_receipt_service.rb
class CreateReceiptService
  def self.call(total, user_id)
    Receipt.create!(total: total, user_id: user_id)
  end
end
 
# app/services/send_receipt_service.rb
class SendReceiptService
  def self.call(receipt)
    UserMailer.send_receipt(receipt).deliver_later
  end
end

그런 다음 컨트롤러에서 SendReceiptService를 호출합니다. 이렇게:

# app/controllers/receipts_controller.rb
 
class ReceiptsController < ApplicationController
  def create
    receipt = CreateReceiptService.call(total: receipt_params[:total],
                                        user_id: receipt_params[:user_id])
 
    SendReceiptService.call(receipt)
  end
end

이제 모든 작업을 수행하는 두 개의 서비스가 있고 컨트롤러가 이를 호출합니다. 이를 별도로 테스트할 수 있지만 문제는 서비스 간에 명확한 연결이 없다는 것입니다. 예, 이론적으로 모두 단일 비즈니스 작업을 수행합니다. 그러나 이해 관계자의 관점에서 추상화 수준을 고려하면 영수증을 만드는 작업에 대한 그들의 관점은 이메일을 보내는 것과 관련이 있습니다. 누구의 추상화 수준이 'right'™️입니까?

이 사고 실험을 좀 더 복잡하게 만들기 위해 영수증의 총 합계가 영수증을 생성하는 동안 어딘가에서 계산되거나 가져와야 한다는 요구 사항을 추가해 보겠습니다. 그러면 우리는 무엇을 합니까? 총액의 합계를 처리하는 다른 서비스를 작성하시겠습니까? 이에 대한 답은 SRP(SingleResponsibility Principle)를 따르고 서로를 추상화하는 것일 수 있습니다.

# app/services/create_receipt_service.rb
class CreateReceiptService
  ...
end
 
# app/services/send_receipt_service.rb
class SendReceiptService
  ...
end
 
# app/services/calculate_receipt_total_service.rb
class CalculateReceiptTotalService
  ...
end
 
# app/controllers/receipts_controller.rb
class ReceiptsController < ApplicationController
  def create
    total = CalculateReceiptTotalService.call(user_id: receipts_controller[:user_id])
 
    receipt = CreateReceiptService.call(total: total,
                                        user_id: receipt_params[:user_id])
 
    SendReceiptService.call(receipt)
  end
end

SRP를 따르면 서비스가 ReceiptCreation과 같은 더 큰 추상화로 함께 구성될 수 있습니다. 프로세스. 이 '프로세스' 클래스를 생성하여 프로세스를 완료하는 데 필요한 모든 작업을 그룹화할 수 있습니다. 이 아이디어에 대해 어떻게 생각하세요? 처음에는 너무 추상적인 것처럼 들릴 수 있지만 이러한 작업을 여기저기서 호출하는 경우 유용할 수 있습니다. 이것이 좋게 들리면 Trailblazer's Operation을 확인하십시오.

요약하자면, 새로운 CalculateReceiptTotalService 서비스는 모든 숫자 크런칭을 처리할 수 있습니다. CreateReceiptService 데이터베이스에 영수증을 쓰는 일을 담당합니다. SendReceiptService 은(는) 사용자에게 영수증에 대한 이메일을 발송하기 위해 존재합니다. 이러한 작고 집중된 클래스를 사용하면 다른 사용 사례에서 더 쉽게 결합할 수 있으므로 유지 관리가 더 쉽고 코드베이스를 더 쉽게 테스트할 수 있습니다.

서비스 배경

Ruby 세계에서 서비스 클래스를 사용하는 접근 방식은 작업, 작업 등으로도 알려져 있습니다. 이 모든 것이 요약되는 것은 Command 패턴입니다. Command 패턴의 이면에 있는 아이디어는 개체(또는 이 예에서는 aclass)가 비즈니스 작업을 수행하거나 이벤트를 트리거하는 데 필요한 모든 정보를 캡슐화한다는 것입니다. 명령 호출자가 알아야 하는 정보는 다음과 같습니다.

  • 명령 이름
  • 명령 개체/클래스에서 호출할 메서드 이름
  • 메서드 매개변수에 전달할 값

따라서 우리의 경우 명령 호출자는 컨트롤러입니다. 접근 방식은 Ruby의 이름이 '서비스'라는 점만 제외하면 매우 유사합니다.

작업 분할

컨트롤러가 일부 타사 서비스를 호출하고 렌더링을 차단하는 경우 이러한 호출을 추출하고 다른 컨트롤러 작업으로 별도로 렌더링해야 할 때입니다. 예를 들어 책의 정보를 렌더링하고 실제로 영향을 줄 수 없는 다른 서비스(예:Goodreads)에서 해당 등급을 가져오려고 할 때를 들 수 있습니다.

# app/controllers/books_controller.rb
 
class BooksController < ApplicationController
  def show
    @book = Book.find(params[:id])
 
    @rating = GoodreadsRatingService.new(book).call
  end
end

Goodreads가 다운되거나 이와 유사한 경우 사용자는 Goodreads 서버에 대한 요청이 시간 초과될 때까지 기다려야 합니다. 또는 서버에서 속도가 느린 경우 페이지가 느리게 로드됩니다. 다음과 같이 타사 서비스 호출을 다른 작업으로 추출할 수 있습니다.

# app/controllers/books_controller.rb
 
class BooksController < ApplicationController
  ...
 
  def show
    @book = Book.find(params[:id])
  end
 
  def rating
    @rating = GoodreadsRatingService.new(@book).call
 
    render partial: 'book_rating'
  end
 
  ...
end

그런 다음 rating을 호출해야 합니다. 그러나 이봐, 쇼액션에는 더 이상 차단기가 없습니다. 또한 'book_rating' 부분이 필요합니다. 이를 보다 쉽게 ​​수행하려면 render_async gem을 사용할 수 있습니다. 책의 등급을 렌더링하는 위치에 다음 명령문을 넣으면 됩니다.

<%= render_async book_rating_path %>

평가를 book_rating으로 렌더링하기 위한 HTML 추출 부분적으로, 그리고 넣어:

<%= content_for :render_async %>

레이아웃 파일 내에서 gem은 book_rating_path를 호출합니다. 페이지가 로드되면 AJAXrequest를 사용하고 등급을 가져오면 페이지에 표시됩니다. 이것의 한 가지 큰 이점은 사용자가 등급을 별도로 로드하여 책 페이지를 더 빨리 볼 수 있다는 것입니다.

또는 원하는 경우 Basecamp의 Turbo Frames를 사용할 수 있습니다. 아이디어는 동일하지만 <turbo-frame>만 사용하면 됩니다. 다음과 같이 마크업의 요소:

<turbo-frame id="rating_1" src="/books/1/rating"> </turbo-frame>

어떤 옵션을 선택하든 메인 컨트롤러 작업에서 무겁거나 불안정한 작업을 분리하고 가능한 한 빨리 사용자에게 페이지를 표시하는 것이 좋습니다.

최종 생각

컨트롤러를 얇게 유지하는 아이디어가 마음에 들고 다른 메서드의 '호출자'로 생각한다면 이 게시물이 컨트롤러를 그렇게 유지하는 방법에 대한 통찰력을 가져왔다고 생각합니다. 여기서 언급한 몇 가지 패턴과 안티 패턴은 물론 전체 목록이 아닙니다. 더 나은 점이나 선호하는 점에 대한 아이디어가 있으면 Twitter에 연락해 주시면 논의해 드리겠습니다.

이 시리즈를 계속 지켜봐 주시기 바랍니다. 우리는 일반적인 Rails 문제와 시리즈의 요점을 요약하는 블로그 게시물을 적어도 하나 더 만들 예정입니다.

다음 시간까지, 건배!

추신 Ruby Magic 게시물이 언론에 공개되는 즉시 읽고 싶다면 Ruby Magic 뉴스레터를 구독하고 게시물을 놓치지 마세요!