추상화 재사용 가이드라인
GitLab이 성장함에 따라 코드베이스 전반에 걸쳐 다양한 패턴이 나타났습니다. 서비스 클래스, 직렬화 프로그램 및 프리젠터 등이 그 예입니다. 이러한 패턴은 코드 재사용을 쉽게 만들었지만, 동시에 특정 위치에서 잘못된 추상화를 실수로 재사용할 수 있는 가능성을 키웁니다.
이러한 가이드라인이 필요한 이유
코드 재사용은 좋지만, 때로는 이로 인해 특정 사용 사례에 잘못된 추상화를 강제할 수 있습니다. 결과적으로 유지 관리 가능성, 문제 해결 용이성 또는 성능에 부정적인 영향을 끼칠 수 있습니다.
예를 들어, ProjectsFinder
를 IssuesFinder
에서 사용하여 특정 프로젝트에 속하는 이슈를 제한하는 것이 있습니다. 처음에는 좋은 아이디어로 보일 수 있지만, 두 클래스 모두 매우 높은 수준의 인터페이스를 제공하면서 매우 적은 제어를 제공합니다. 이는 IssuesFinder
가 더 나은 최적화된 데이터베이스 쿼리를 생성하지 못할 수 있음을 의미합니다. 그 이유는 쿼리의 대부분이 ProjectsFinder
의 내부에서 제어되기 때문입니다.
이 문제를 해결하기 위해 당신은 ProjectsFinder
를 직접 사용하는 대신에, ProjectsFinder
가 사용하는 동일한 코드를 사용해야 합니다. 이를 통해 코드 동작을 더 잘 구성하여 코드의 동작에 대한 더 많은 제어권을 제공합니다.
예를 들어, IssuableFinder#projects
에서 다음과 같은 코드를 고려해보십시오:
return @projects = project if project?
projects =
if current_user && params[:authorized_only].presence && !current_user_related?
current_user.authorized_projects
elsif group
finder_options = { include_subgroups: params[:include_subgroups], exclude_shared: true }
GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute
else
ProjectsFinder.new(current_user: current_user).execute
end
@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
여기서는 데이터를 제한할 프로젝트를 결정하기 위해 세 가지 다른 접근 방식을 사용합니다. 그룹이 지정된 경우 GroupProjectsFinder
를 사용하여 해당 그룹의 모든 프로젝트를 검색합니다. 표면적으로는 무해해 보일 수 있지만, 실제로는 상황이 매우 복잡해질 수 있습니다. 예를 들어, GroupProjectsFinder
에 의해 생성된 쿼리는 간단하게 시작될 수 있지만, 시간이 지날수록 더 많은 기능이 추가됩니다.
더 신뢰할 수 있는 (그리고 즐거운) 처리 방법은 GroupProjectsFinder
의 구성 요소를 직접 사용하는 것입니다. 이는 IssuableFinder
에 더 많은 코드가 필요할 수 있지만, 더 많은 제어와 확신을 제공합니다.
최종 목표
본 문서의 가이드라인은 명확히 어디에서 무엇을 재사용할 수 있는지, 재사용할 수 없는 경우 어떻게 해야 하는지를 정의함으로써 더 좋은 코드 재사용을 촉진하는 데 목적이 있습니다. 추상화를 명확히 분리함으로써 잘못된 추상화를 사용하는 것을 어렵게 만들고, 코드를 쉽게 디버깅할 수 있게 하며, (희망컨대) 성능 문제를 줄이도록 합니다.
추상화
이제 사용 가능한 다양한 추상화 수준을 살펴보고, 그것들이 무엇을 (또는 할 수 없는지) 재사용할 수 있는지 살펴보겠습니다. 여기에서는 다음의 표를 사용하여 각각의 추상화를 정의하고 그것들이 무엇을 (또는 할 수 없는지) 재사용할 수 있는지를 정의합니다.
추상화 | 서비스 클래스 | 파인더 | 프리젠터 | 직렬화 프로그램 | 모델 인스턴스 메소드 | 모델 클래스 메소드 | 액티브 레코드 | 워커 |
---|---|---|---|---|---|---|---|---|
컨트롤러/API 엔드포인트 | 예 | 예 | 예 | 예 | 예 | 아니요 | 아니요 | 아니요 |
서비스 클래스 | 예 | 예 | 아니요 | 아니요 | 예 | 아니요 | 아니요 | 예 |
파인더 | 아니요 | 아니요 | 아니요 | 아니요 | 예 | 예 | 아니요 | 아니요 |
프리젠터 | 아니요 | 예 | 아니요 | 아니요 | 예 | 예 | 아니요 | 아니요 |
직렬화 프로그램 | 아니요 | 예 | 아니요 | 아니요 | 예 | 예 | 아니요 | 아니요 |
모델 클래스 메소드 | 아니요 | 아니요 | 아니요 | 아니요 | 예 | 예 | 예 | 아니요 |
모델 인스턴스 메소드 | 아니요 | 예 | 아니요 | 아니요 | 예 | 예 | 예 | 예 |
워커 | 예 | 예 | 아니요 | 아니요 | 예 | 아니요 | 아니요 | 예 |
컨트롤러
app/controllers
의 모든 것.
컨트롤러는 자체적으로 크게 일하지 않아야 하며, 대신 입력을 다른 클래스로 전달하고 결과를 제공해야 합니다.
API 엔드포인트
lib/api
(REST API) 및 app/graphql
(GraphQL API)의 모든 것.
API 엔드포인트는 컨트롤러와 동일한 추상화 수준을 갖습니다.
서비스 클래스
app/services
에 있는 모든 것.
서비스 클래스는 모델(엔터티 및 값 객체) 간의 변경을 조정하는 작업을 나타냅니다. 변경 사항은 응용 프로그램의 상태에 영향을 미칩니다.
- 객체가 애플리케이션의 상태에 변경 사항을 주지 않으면 서비스가 아닙니다. 이는 파인더 또는 값 객체일 수 있습니다.
- 조작이 없는 경우 서비스를 실행할 필요는 없습니다. 클래스는 아마도 엔터티, 값 객체 또는 정책으로 더 잘 설계될 수 있습니다.
서비스 클래스를 구현할 때 다음 패턴 사용을 고려하십시오:
- 서비스 클래스 이니셜라이저는 다음의 인수를 포함해야 합니다:
- 작업을 수행 중인 모델 인스턴스. 이는 이니셜라이저의 첫 번째 위치 인수여야 합니다. 이러한 인수의 이름은
건의된
이나프로젝트
와 같이 개발자의 재량에 따라 지정될 수 있습니다. - 서비스가 사용자에 의해 시작된 작업이나 사용자 컨텍스트에서 실행되는 작업을 나타낼 때, 이니셜라이저는
current_user:
키워드 인수를 가져야 합니다.current_user:
인수를 갖는 서비스는 고수준 비즈니스 논리를 실행하고 작업을 수행하기 위해 사용자 인가를 유효화해야 합니다. - 서비스가 사용자 컨텍스트를 갖지 않고 직접적으로 사용자에 의해 시작된 작업이 아닐 때(백그라운드 서비스 또는 부작용과 같은 경우),
current_user:
인수는 필요하지 않습니다. 이는 저수준 도메인 논리 또는 인스턴스 전용 논리를 나타냅니다. - 서비스가 필요로 하는 모든 추가 데이터에 대해 명시적인 키워드 인수를 권장합니다.
서비스가 너무 많은 인수를 요구하는 경우, 이를 다음과 같이 분할하는 것을 고려하십시오:
-
params
: 직접 할당할 모델 속성이 있는 해시. -
options
: 추가 매개변수가 있는 해시(처리되어야 하는 매개변수 또는 모델 속성이 아닌 매개변수).options
해시는 인스턴스 변수에 저장해야 합니다.
# merge_request: 작업을 수행 중인 모델 인스턴스. # assignee: 서비스 실행 후 해당 MR에 할당될 새 MR 담당자. def initialize(merge_request, assignee:) @merge_request = merge_request @assignee = assignee end
# issue: 작업을 수행 중인 모델 인스턴스. # current_user: 현재 사용자. # params: 모델 속성. # options: 이 서비스를 위한 구성. 다음 중 하나일 수 있습니다: # - notify: 현재 사용자에게 알림을 보낼지 여부. # - cc: 알림을 보낼 때 참조할 이메일 주소. def initialize(issue:, current_user:, params: {}, options: {}) @issue = issue @current_user = current_user @params = params @options = options end
-
- 작업을 수행 중인 모델 인스턴스. 이는 이니셜라이저의 첫 번째 위치 인수여야 합니다. 이러한 인수의 이름은
- 서비스 클래스는 하나의 공용 인스턴스 메소드
#execute
를 구현해야 합니다. 이를 통해 서비스 클래스 동작을 호출합니다:-
#execute
메소드는 인수를 가져서는 안됩니다. 필요한 모든 데이터는 이니셜라이저를 통해 전달됩니다.
-
- 반환 값이 필요한 경우
#execute
메소드는 결과를ServiceResponse
객체를 통해 반환해야 합니다.
몇 가지 베이스 클래스는 서비스 클래스 규칙을 구현합니다. 다음 중 하나를 상속하는 것을 고려할 수 있습니다:
- 서비스 컨테이너에 의해 범위가 지정된 서비스에는
BaseContainerService
. - 프로젝트 범위로 제한된 서비스는
BaseProjectService
. - 그룹에 범위로 제한된 서비스는
BaseGroupService
.
일부 도메인이나 유계적 컨텍스트를 위해 서비스 클래스가 다양한 패턴을 사용하는 것이 의미가 있을 수 있습니다. 예를 들어 원격 개발 도메인은 계층화된 아키텍처를 사용하여 도메인 논리를 독립적으로 분리시킵니다. 이는 매우 최소한의 서비스 레이어로 구성되는 하나의 재사용 가능한 CommonService
클래스만 포함합니다. 또한 상태없는 싱글톤 클래스 메소드를 사용하는 함수형 패턴를 사용합니다. 자세한 내용은 Remote Development 서비스 레이어 코드 예제를 참조하십시오. 그러나 이 패턴을 통해 서비스 호출 시그니처가 다르더라도, 항상 모든 결과를 항상 ServiceResponse
객체를 통해 반환함으로써 표준 서비스 클래스 계약을 준수합니다.
서비스가 아닌 클래스는
다른 곳,
예를 들어 lib
에서 생성되어야 합니다.
ServiceResponse
서비스 클래스에는 일반적으로 execute
메서드가 있으며, 이 메서드를 사용하여 ServiceResponse
를 반환할 수 있습니다.
성공한 경우:
response = ServiceResponse.success(message: 'Branch was deleted')
response.success? # => true
response.error? # => false
response.status # => :success
response.message # => 'Branch was deleted'
실패한 경우:
response = ServiceResponse.error(message: 'Unsupported operation')
response.success? # => false
response.error? # => true
response.status # => :error
response.message # => 'Unsupported operation'
추가적인 페이로드도 첨부할 수 있습니다:
response = ServiceResponse.success(payload: { issue: issue })
response.payload[:issue] # => issue
에러 응답은 호출자가 실패의 reason
을 이해하는 데 사용할 수 있는 실패 reason
을 지정할 수도 있습니다.
HTTP 엔드포인트인 경우에는 호출자가 reason 심볼을 HTTP 상태 코드로 변환할 수 있습니다.
response = ServiceResponse.error(
message: 'Job is in a state that cannot be retried',
reason: :job_not_retrieable)
if response.success?
head :ok
elsif response.reason == :job_not_retriable
head :unprocessable_entity
else
head :bad_request
end
리소스 :not_found
나 연산 :forbidden
과 같이 일반적인 실패의 경우, 도메인 로직에 충분히 특정한 한정된 서형상태 심볼을 활용할 수 있습니다. 다른 실패의 경우에는 가능한한 도메인별 이유를 사용하세요.
예를 들어: :job_not_retriable
, :duplicate_package
, :merge_request_not_mergeable
.
Finders
app/finders
의 모든 것은 일반적으로 데이터베이스에서 데이터를 검색하는 데 사용됩니다.
Finders는 다른 finders를 재사용하지 않으려고 시도하여 생성되는 SQL 쿼리를 더 잘 제어하려고 합니다.
Finders의 execute
메서드는 ActiveRecord::Relation
를 반환해야 합니다. 예외는 spec/support/finder_collection_allowlist.yml
에 추가될 수 있습니다.
자세한 내용은 #298771
을(를) 참조하세요.
Presenters
app/presenters
의 모든 것은 복잡한 데이터를 Rails 뷰에 노출시키기 위해 사용되며 많은 인스턴스 변수를 만들지 않아도 됩니다.
자세한 정보는 문서를 참조하세요.
Serializers
app/serializers
의 모든 것은 요청에 대한 응답을 일반적으로 JSON 형식으로 표현하는 데 사용됩니다.
Models
app/models
의 클래스 및 모듈은 데이터 및 동작을 캡슐화하는 도메인 개념을 나타냅니다.
이러한 클래스는 데이터 저장소 (예: ActiveRecord 모델)와 직접 상호 작용할 수도 있고 더 풍부한 도메인 개념을 표현하기 위해 ActiveRecord 모델 위에 얇은 래퍼 (Plain Old Ruby Objects)로 사용될 수도 있습니다.
도메인 개념을 나타내는 엔터티 및 값 객체는 도메인 모델로 간주됩니다.
일부 예시:
-
DesignManagement::DesignAtVersion
는 디자인과 버전을 결합하기 위해 유효성 검사를 활용한 모델입니다. -
Ci::Minutes::Usage
는 주어진 네임스페이스에 대한 사용량 계산을 제공하는 값 객체입니다.
Model 클래스 메서드
이들은 Active Record에서 제공하는 다음 메서드를 포함하여 GitLab 자체에 의해 정의된 클래스 메서드입니다.
find
find_by_id
delete_all
destroy
destroy_all
다른 메서드들인 find_by(some_column: X)
와 같은 메서드들은 포함되지 않으며, 대신 “Active Record” 추상화에 속합니다.
Model 인스턴스 메서드
GitLab 자체에 의해 Active Record 모델에 정의된 인스턴스 메서드입니다. Active Record에서 제공되는 메서드들은 포함되지 않으며, 다음 메서드들만 포함됩니다:
save
update
destroy
delete
Active Record
where
메서드, save
, delete_all
등과 같이 Active Record 자체에 의해 제공되는 API입니다.
Worker
app/workers
의 모든 것을 포함합니다.
Sidekiq 작업을 예약하기 위해 SomeWorker.perform_async
또는 SomeWorker.perform_in
을 사용하세요. 절대로 SomeWorker.new.perform
를 사용하여 worker를 직접적으로 호출하지 마십시오.