Rails 서비스 객체: 종합 가이드

게시 됨: 2022-03-11

Ruby on Rails는 애플리케이션을 빠르게 프로토타이핑하는 데 필요한 모든 것을 제공하지만 코드베이스가 증가하기 시작하면 기존의 Fat Model, Skinny Controller 만트라가 깨지는 시나리오에 직면하게 될 것입니다. 비즈니스 로직이 모델이나 컨트롤러에 맞지 않을 때 서비스 개체가 들어오고 모든 비즈니스 작업을 자체 Ruby 개체로 분리할 수 있습니다.

Rails 서비스 객체가 있는 요청 주기의 예

이 기사에서는 서비스 객체가 필요한 경우를 설명합니다. 깨끗한 서비스 객체를 작성하고 기여자 온전함을 위해 그룹화하는 방법; 내 비즈니스 로직에 직접 연결하기 위해 내 서비스 개체에 부과하는 엄격한 규칙. 그리고 어떻게 해야 할 지 모르는 모든 코드에 대해 서비스 개체를 쓰레기 더미로 만들지 않는 방법.

서비스 개체가 필요한 이유는 무엇입니까?

이것을 시도하십시오: 애플리케이션이 params[:message] 에서 텍스트를 트윗해야 할 때 무엇을 합니까?

지금까지 바닐라 레일을 사용해 왔다면 아마도 다음과 같이 했을 것입니다.

 class TweetController < ApplicationController def create send_tweet(params[:message]) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(tweet) end end

여기서 문제는 컨트롤러에 최소한 10개의 라인을 추가했지만 실제로는 거기에 속하지 않는다는 것입니다. 또한 다른 컨트롤러에서 동일한 기능을 사용하려면 어떻게 해야 합니까? 이것을 우려 사항으로 옮기십니까? 잠깐만, 이 코드는 실제로 컨트롤러에 전혀 속하지 않습니다. Twitter API는 내가 호출할 준비된 단일 개체와 함께 제공되지 않는 이유는 무엇입니까?

처음 이 일을 했을 때 뭔가 더러운 짓을 한 것 같은 기분이 들었습니다. 이전에는 아름답게 마른 나의 Rails 컨트롤러가 뚱뚱해지기 시작했고 나는 무엇을 해야 할지 몰랐습니다. 결국 서비스 개체로 컨트롤러를 수정했습니다.

이 기사를 읽기 전에 다음과 같이 가정해 보겠습니다.

  • 이 응용 프로그램은 Twitter 계정을 처리합니다.
  • Rails Way는 "일을 하는 전통적인 Ruby on Rails 방식"을 의미하며 책은 존재하지 않습니다.
  • 저는 Rails 전문가입니다. 매일 듣게 되는 Rails 전문가이지만 믿기 어렵습니다. 그래서 그냥 제가 진짜 전문가인 척 합시다.

서비스 객체란 무엇입니까?

서비스 개체는 도메인 논리에서 하나의 단일 작업을 실행하고 잘 수행하도록 설계된 PORO(Plain Old Ruby Objects)입니다. 위의 예를 고려하십시오. 우리 방법에는 이미 한 가지 일, 즉 트윗을 만드는 논리가 있습니다. 이 로직이 우리가 인스턴스화하고 메소드를 호출할 수 있는 단일 Ruby 클래스 내에 캡슐화되어 있다면 어떨까요? 다음과 같은 것:

 tweet_creator = TweetCreator.new(params[:message]) tweet_creator.send_tweet # Later on in the article, we'll add syntactic sugar and shorten the above to: TweetCreator.call(params[:message])

이 정도입니다. TweetCreator 서비스 객체는 일단 생성되면 어디에서나 호출할 수 있으며 이 한 가지를 매우 잘 수행합니다.

서비스 객체 생성

먼저 app/services 라는 새 폴더에 새 TweetCreator 를 만들어 보겠습니다.

 $ mkdir app/services && touch app/services/tweet_creator.rb

새로운 Ruby 클래스 안에 모든 로직을 덤프해 보겠습니다.

 # app/services/tweet_creator.rb class TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end

그런 다음 앱의 아무 곳에서나 TweetCreator.new(params[:message]).send_tweet 를 호출하면 작동합니다. Rails는 이 객체를 마술처럼 불러올 것입니다. 왜냐하면 app/ 아래의 모든 것을 자동 로드하기 때문입니다. 다음을 실행하여 확인합니다.

 $ rails c Running via Spring preloader in process 12417 Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths ... /Users/gilani/Sandbox/nazdeeq/app/services

autoload 작동 방식에 대해 더 알고 싶으십니까? 상수 자동 로드 및 다시 로드 가이드를 읽으십시오.

Rails 서비스 객체를 덜 쓰게 만들기 위해 구문 설탕 추가하기

보세요. 이론상으로는 훌륭하지만 TweetCreator.new(params[:message]).send_tweet 는 한 입에 불과합니다. 중복된 단어로 너무 장황합니다... HTML과 매우 유사합니다(ba-dum tiss! ). 그러나 진지하게 HAML이 있을 때 사람들이 HTML을 사용하는 이유는 무엇입니까? 또는 심지어 슬림. 다른 시간에 대한 다른 기사라고 생각합니다. 당면한 작업으로 돌아가기:

TweetCreator 는 멋진 짧은 클래스 이름이지만 개체를 ​​인스턴스화하고 메서드를 호출하는 데 필요한 추가 정보가 너무 깁니다! Ruby에서 무언가를 호출하고 주어진 매개변수를 사용하여 즉시 실행되도록 하는 우선순위가 있었다면… 오, 잠깐만요! Proc#call 입니다.

Proccall 은 의미론을 호출하는 메소드에 가까운 것을 사용하여 블록의 매개변수를 params의 값으로 설정하여 블록을 호출합니다. 블록에서 평가된 마지막 표현식의 값을 반환합니다.

 aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } } aproc.call(9, 1, 2, 3) #=> [9, 18, 27] aproc[9, 1, 2, 3] #=> [9, 18, 27] aproc.(9, 1, 2, 3) #=> [9, 18, 27] aproc.yield(9, 1, 2, 3) #=> [9, 18, 27]

선적 서류 비치

이것이 당신을 혼란스럽게한다면, 내가 설명하겠습니다. proc 은 -ed를 call 하여 주어진 매개변수로 스스로를 실행할 수 있습니다. 즉, TweetCreatorproc 인 경우 TweetCreator.call(message) 로 호출할 수 있으며 그 결과는 다루기 힘든 이전 TweetCreator.new(params[:message]).send_tweet 와 매우 유사한 TweetCreator.new(params[:message]).call 과 동일합니다. TweetCreator.new(params[:message]).send_tweet .

따라서 서비스 객체가 proc 처럼 작동하도록 합시다!

먼저 모든 서비스 객체에서 이 동작을 재사용하기를 원할 것이므로 Rails Way에서 차용하여 ApplicationService 라는 클래스를 생성하겠습니다.

 # app/services/application_service.rb class ApplicationService def self.call(*args, &block) new(*args, &block).call end end

내가 거기서 뭘 했는지 봤어? 인수 또는 블록으로 클래스의 새 인스턴스를 만들고 인스턴스에 대한 call 을 호출하는 call 이라는 클래스 메서드를 추가했습니다. 우리가 원했던 바로 그것! 마지막으로 해야 할 일은 TweetCreator 클래스의 메서드 이름을 call 로 변경하고 클래스가 ApplicationService 에서 상속하도록 하는 것입니다.

 # app/services/tweet_creator.rb class TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end

마지막으로 컨트롤러에서 서비스 개체를 호출하여 이 작업을 마무리하겠습니다.

 class TweetController < ApplicationController def create TweetCreator.call(params[:message]) end end

온전함을 위해 유사한 서비스 개체 그룹화

위의 예에는 서비스 객체가 하나만 있지만 실제 세계에서는 상황이 더 복잡해질 수 있습니다. 예를 들어 수백 개의 서비스가 있고 그 중 절반이 관련된 비즈니스 활동(예: 다른 Twitter 계정을 팔로우하는 Follower 서비스가 있는 경우)이라면 어떨까요? 솔직히 말해서, 폴더에 200개의 독특한 모양의 파일이 포함되어 있다면 정말 미쳐버릴 것입니다. 그래서 우리가 복사할 수 있는 Rails Way의 또 다른 패턴이 있다는 것은 좋은 일입니다. 즉, 영감으로 사용하는 네임스페이스입니다.

다른 Twitter 프로필을 따르는 서비스 개체를 생성하는 임무를 받았다고 가정해 보겠습니다.

이전 서비스 객체의 이름인 TweetCreator 를 살펴보겠습니다. 그것은 사람처럼 들리거나 최소한 조직의 역할처럼 들립니다. 트윗을 만드는 사람. 저는 서비스 개체의 이름을 마치 조직의 역할인 것처럼 지정하고 싶습니다. 이 규칙에 따라 새 개체를 ProfileFollower 라고 합니다.

이제 저는 이 앱의 최고 지배자이므로 서비스 계층 구조에서 관리 직책을 만들고 이 두 서비스에 대한 책임을 해당 직책에 위임하겠습니다. 저는 이 새로운 관리 직위를 TwitterManager 라고 부를 것입니다.

이 관리자는 관리만 하므로 모듈로 만들고 이 모듈 아래에 서비스 개체를 중첩해 보겠습니다. 이제 폴더 구조는 다음과 같습니다.

 services ├── application_service.rb └── twitter_manager ├── profile_follower.rb └── tweet_creator.rb

그리고 우리의 서비스 객체:

 # services/twitter_manager/tweet_creator.rb module TwitterManager class TweetCreator < ApplicationService ... end end
 # services/twitter_manager/profile_follower.rb module TwitterManager class ProfileFollower < ApplicationService ... end end

그리고 우리의 호출은 이제 TwitterManager::TweetCreator.call(arg)TwitterManager::ProfileManager.call(arg) 이 됩니다.

데이터베이스 작업을 처리하기 위한 서비스 개체

위의 예에서는 API를 호출했지만 모든 호출이 API 대신 데이터베이스에 대한 것일 때 서비스 개체를 사용할 수도 있습니다. 이는 일부 비즈니스 작업에 트랜잭션에 래핑된 여러 데이터베이스 업데이트가 필요한 경우에 특히 유용합니다. 예를 들어, 이 샘플 코드는 서비스를 사용하여 발생하는 환전을 기록합니다.

 module MoneyManager # exchange currency from one amount to another class CurrencyExchanger < ApplicationService ... def call ActiveRecord::Base.transaction do # transfer the original currency to the exchange's account outgoing_tx = CurrencyTransferrer.call( from: the_user_account, to: the_exchange_account, amount: the_amount, currency: original_currency ) # get the exchange rate rate = ExchangeRateGetter.call( from: original_currency, to: new_currency ) # transfer the new currency back to the user's account incoming_tx = CurrencyTransferrer.call( from: the_exchange_account, to: the_user_account, amount: the_amount * rate, currency: new_currency ) # record the exchange happening ExchangeRecorder.call( outgoing_tx: outgoing_tx, incoming_tx: incoming_tx ) end end end # record the transfer of money from one account to another in money_accounts class CurrencyTransferrer < ApplicationService ... end # record an exchange event in the money_exchanges table class ExchangeRecorder < ApplicationService ... end # get the exchange rate from an API class ExchangeRateGetter < ApplicationService ... end end

내 서비스 개체에서 무엇을 반환합니까?

서비스 객체를 call 하는 방법에 대해 논의했지만 객체는 무엇을 반환해야 할까요? 이에 접근하는 세 가지 방법이 있습니다.

  • true 또는 false 을 반환
  • 값 반환
  • 열거형 반환

true 또는 false 을 반환

이것은 간단합니다. 작업이 의도한 대로 작동하면 true 를 반환합니다. 그렇지 않으면 false 를 반환합니다.

 def call ... return true if client.update(@message) false end

값 반환

서비스 객체가 어딘가에서 데이터를 가져오는 경우 해당 값을 반환하고 싶을 것입니다.

 def call ... return false unless exchange_rate exchange_rate end

열거형으로 응답

서비스 개체가 좀 더 복잡하고 다른 시나리오를 처리하려는 경우 열거형을 추가하여 서비스 흐름을 제어할 수 있습니다.

 class ExchangeRecorder < ApplicationService RETURNS = [ SUCCESS = :success, FAILURE = :failure, PARTIAL_SUCCESS = :partial_success ] def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something end end

그런 다음 앱에서 다음을 사용할 수 있습니다.

 case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end

app/services lib/services 에 서비스 개체를 넣어야 하지 않습니까?

이것은 주관적입니다. 서비스 개체를 어디에 둘 것인지에 대한 사람들의 의견은 다릅니다. 어떤 사람들은 그것들을 lib/services 에 넣고 어떤 사람들은 app/services 를 만듭니다. 나는 후자의 진영에 속한다. Rails의 시작 가이드에서는 lib/ 폴더를 "응용 프로그램의 확장 모듈"을 넣을 위치로 설명합니다.

내 겸손한 생각에 "확장 모듈"은 핵심 도메인 논리를 캡슐화하지 않고 일반적으로 여러 프로젝트에서 사용할 수 있는 모듈을 의미합니다. 무작위 스택 오버플로 답변의 현명한 표현에 따르면 "잠재적으로 자신의 보석이 될 수 있는" 코드를 넣으십시오.

서비스 객체는 좋은 아이디어입니까?

사용 사례에 따라 다릅니다. 보세요. 지금 이 기사를 읽고 있다는 사실은 여러분이 모델이나 컨트롤러에 정확히 속하지 않는 코드를 작성하려고 한다는 것을 암시합니다. 나는 최근에 서비스 객체가 어떻게 안티 패턴인지에 대한 이 기사를 읽었습니다. 저자는 자신의 의견을 가지고 있지만 나는 정중하게 동의하지 않습니다.

다른 사람이 서비스 개체를 과도하게 사용했다고 해서 서비스 개체가 본질적으로 나쁘다는 의미는 아닙니다. 제 스타트업인 Nazdeeq에서는 서비스 개체와 비 ActiveRecord 모델을 사용합니다. 그러나 어디로 가는지 차이는 항상 분명했습니다. 모든 비즈니스 작업 은 서비스 개체에 유지하면서 실제로 지속성이 필요하지 않은 리소스는 비 ActiveRecord 모델에 유지합니다. 하루가 끝나면 어떤 패턴이 당신에게 좋은지 결정하는 것은 당신의 몫입니다.

그러나 일반적으로 서비스 객체가 좋은 생각이라고 생각합니까? 전적으로! 그들은 내 코드를 깔끔하게 정리하고 내가 PORO를 사용하는 데 자신감을 갖게 하는 것은 Ruby가 객체를 좋아한다는 것입니다. 아니, 진지하게 Ruby는 물건을 좋아 합니다. 그것은 미쳤고 완전히 어리둥절하지만 나는 그것을 좋아합니다! 지목 사항:

 > 5.is_a? Object # => true > 5.class # => Integer > class Integer ?> def woot ?> 'woot woot' ?> end ?> end # => :woot > 5.woot # => "woot woot"

보다? 5 는 말 그대로 객체입니다.

많은 언어에서 숫자 및 기타 기본 유형은 객체가 아닙니다. Ruby는 모든 유형에 메서드와 인스턴스 변수를 제공하여 Smalltalk 언어의 영향을 따릅니다. 객체에 적용되는 규칙이 모든 Ruby에 적용되기 때문에 Ruby를 쉽게 사용할 수 있습니다. Ruby-lang.org

언제 서비스 개체를 사용하지 않아야 합니까?

이것은 쉽습니다. 다음 규칙이 있습니다.

  1. 코드가 라우팅, 매개변수 또는 기타 컨트롤러 작업을 처리합니까?
    그렇다면 서비스 개체를 사용하지 마십시오. 코드는 컨트롤러에 속합니다.
  2. 다른 컨트롤러에서 코드를 공유하려고 합니까?
    이 경우 서비스 객체를 사용하지 말고 우려 사항을 사용하십시오.
  3. 코드가 지속성이 필요하지 않은 모델과 같습니까?
    그렇다면 서비스 개체를 사용하지 마십시오. 대신 비 ActiveRecord 모델을 사용하십시오.
  4. 귀하의 코드는 특정 비즈니스 작업입니까? (예: "쓰레기 버리기", "이 텍스트를 사용하여 PDF 생성" 또는 "이 복잡한 규칙을 사용하여 관세 계산")
    이 경우 서비스 객체를 사용하십시오. 그 코드는 아마도 당신의 컨트롤러나 모델에 논리적으로 맞지 않을 것입니다.

물론 이것은 규칙이므로 자신의 사용 사례에 적용할 수 있습니다. 이것들은 저에게 매우 효과적이었지만 귀하의 마일리지는 다를 수 있습니다.

좋은 서비스 객체를 작성하기 위한 규칙

서비스 개체를 생성하기 위한 네 가지 규칙이 있습니다. 이것들은 돌로 쓰여진 것이 아니며 정말로 깨고 싶다면 할 수 있지만 추론이 건전하지 않다면 코드 리뷰에서 변경하도록 요청할 것입니다.

규칙 1: 서비스 개체당 하나의 공용 메서드만

서비스 개체는 단일 비즈니스 작업입니다. 원하는 경우 공개 메서드의 이름을 변경할 수 있습니다. 나는 call 을 사용하는 것을 선호하지만 Gitlab CE의 코드베이스는 그것을 execute 하고 다른 사람들은 perform 을 사용할 수 있습니다. 원하는 것을 사용하십시오. 내가 관심을 갖는 모든 것을 nermin 이라고 부를 수 있습니다. 단일 서비스 개체에 대해 두 개의 공용 메서드를 만들지 마십시오. 필요한 경우 두 개체로 나눕니다.

규칙 2: 회사의 멍청한 역할과 같은 이름 서비스 개체

서비스 개체는 단일 비즈니스 작업입니다. 회사에서 그 한 가지 일을 하기 위해 한 사람을 고용했다고 상상해 보세요. 그들을 뭐라고 부르겠습니까? 그들의 직업이 트윗을 만드는 TweetCreator 라고 부르세요. 그들의 직업이 특정 트윗을 읽는 TweetReader 라고 부르세요.

규칙 3: 여러 작업을 수행하기 위해 일반 개체를 만들지 마십시오.

서비스 개체는 단일 비즈니스 작업 입니다. 기능을 TweetReaderProfileFollower 두 부분으로 나누었습니다. 내가 하지 않은 것은 TwitterHandler 라는 단일 일반 개체를 만들고 거기에 모든 API 기능을 덤프하는 것입니다. 이러지 마세요. 이것은 "비즈니스 행동" 사고방식에 어긋나며 서비스 개체를 Twitter 요정처럼 보이게 만듭니다. 비즈니스 개체 간에 코드를 공유하려면 BaseTwitterManager 개체 또는 모듈을 만들고 이를 서비스 개체에 혼합하기만 하면 됩니다.

규칙 4: 서비스 개체 내부의 예외 처리

열 번째로: 서비스 개체는 단일 비즈니스 작업입니다. 나는 이것을 충분히 말할 수 없다. 트윗을 읽는 사람이 있으면 트윗을 주거나 "이 트윗은 존재하지 않습니다."라고 말합니다. 유사하게, 서비스 객체가 패닉에 빠지도록 하지 말고 컨트롤러의 책상 위로 뛰어올라 "오류!" 때문에 모든 작업을 중지하도록 지시하십시오. false 를 반환하고 컨트롤러가 거기에서 계속 이동하도록 하십시오.

크레딧 및 다음 단계

이 기사는 Toptal의 놀라운 Ruby 개발자 커뮤니티가 없었다면 불가능했을 것입니다. 문제가 발생하면 커뮤니티는 내가 만난 재능 있는 엔지니어 그룹 중 가장 도움이 되는 그룹입니다.

서비스 개체를 사용하는 경우 테스트하는 동안 특정 응답을 강제 실행하는 방법이 궁금할 수 있습니다. Rspec에서 실제로 서비스 개체를 건드리지 않고 항상 원하는 결과를 반환하는 모의 서비스 개체를 만드는 방법에 대한 이 기사를 읽는 것이 좋습니다!

Ruby 트릭에 대해 더 알고 싶다면 동료 Toptaler Mate Solymosi의 Creation a Ruby DSL: A Guide to Advanced Metaprogramming을 추천합니다. 그는 routes.rb 파일이 Ruby처럼 느껴지지 않는 방식을 분석하고 자신만의 DSL을 구축하는 데 도움을 줍니다.