GitLab EventStore
배경
모놀리식인의 GitLab 프로젝트는 점점 커지고 더 많은 도메인이 정의되고 있습니다. 결과적으로 이러한 도메인들은 시간적 결합으로 인해 얽히게 되고 있습니다.
상징적인 예는 PostReceive
워커인데, 이곳에서 여러 도메인에 걸쳐 많은 일이 발생합니다. 새로운 커밋이 푸시되면 새로운 동작이 반응한다면, 우리는 PostReceive
또는 하위 구성요소들(Git::ProcessRefChangesService
예를 들어) 어딘가에 코드를 추가합니다.
이 유형의 아키텍처는 다음을 포함합니다:
- 단일 책임 원칙 위배
- 낯선 코드베이스에 코드를 추가하는 위험
- 도메인 경계 위배. 특정 네임스페이스(예:
Git::
) 내에서 갑자기 다른 도메인의 클래스(예:Ci::
또는MergeRequests::
)가 끼어들게 됩니다.
EventStore란?
Gitlab:EventStore
는 기존 Sidekiq 워커와 현재의 감시 기능을 기반으로 구축된 기본적인 pub-sub 시스템입니다.
우리는 이 시스템을 사용하여 도메인을 모델링할 때 이벤트 기반 접근을 적용하고 동시에 결합도를 최소화합니다.
이는 본질적으로 기존 Sidekiq 워커를 그대로 두고 비동기 작업을 수행하지만 의존성을 역전시킵니다.
EventStore 예시
CI 파이프라인이 생성되면 해당 파이프라인의 ref
와 일치하는 어떤 Merge Request에 대한 헤드 파이프라인을 업데이트합니다. 그러면 Merge Request에서 최신 파이프라인의 상태를 표시할 수 있습니다.
EventStore가 없는 경우
우리는 Ci::CreatePipelineService
를 변경하고 파이프라인이 생성되었는지 확인하는 로직(예: if
문)을 추가합니다. 그런 다음 MergeRequests::
도메인에 대해 어떤 부수 효과를 실행할 워커를 예약합니다.
이 스타일은 개방-폐쇄 원칙을 위배하고 결합도를 증가시키는 다른 도메인의 부수 효과 로직을 불필요하게 추가합니다.
EventStore가 있는 경우
Ci::CreatePipelineService
는 Ci::PipelineCreatedEvent
이벤트를 발행하고 그 책임은 여기서 끝납니다.
MergeRequests::
도메인은 워커 MergeRequests::UpdateHeadPipelineWorker
로 이 이벤트를 구독할 수 있으므로:
- 부수 효과는 비동기로 예약되고 주요 업무 트랜잭션에 영향을주지 않습니다.
- 주된 비즈니스 트랜잭션을 수정하지 않고도 더 많은 부수 효과를 추가할 수 있습니다.
- 참여한 도메인과 해당 권한을 명확히 파악할 수 있습니다.
- 시스템에서 발생하는 이벤트를 명시적으로 선언하기 때문에 어떤 이벤트가 발생하는지 확인할 수 있습니다.
Gitlab::EventStore
를 사용하면 구독자(Sidekiq 워커)와 도메인 이벤트의 스키마 사이에 결합이 여전히 존재합니다. 그러나 이러한 결합도는 다음과 같은 점에서 훨씬 작습니다:
- 여러 구독자에 대한 결합
- 구독자를 호출하는 여러 방법(조건부 호출 포함)
- 매개변수를 전달하는 여러 방법
각 구독자(자체적인 Sidekiq 워커)는 그들이 책임지는 작업 유형과 관련된 어떤 특성을 지정할 수 있습니다. 예를 들어, 한 구독자는 urgent: high
를 정의하고 다른 구독자는 덜 중요한 작업일 경우 urgent: low
로 설정할 수 있습니다.
EventStore는 사실상 비즈니스 트랜잭션을 부수 효과로부터 분리하는데 도움이 되는 추상화일 뿐입니다. 이것은 종종 다른 도메인에서 실행되는 부수 효과로 생각할 수 있습니다.
이벤트가 발행되면 EventStore는 각 구독 워커에 perform_async
를 호출하여 이벤트 정보를 인수로 전달합니다. 이는 사실상 각 구독자의 대기열에 Sidekiq 작업을 예약하는 것입니다.
즉, 구독자가 어떻게 작동하는지를 제외하고는 다른 변화가 없습니다. 그들은 그냥 Sidekiq 워커일 뿐입니다. 예를 들어: 구독자(워커)가 작업을 실행하지 못하면, 해당 작업은 다시 시도되기 위해 Sidekiq에 다시 넣습니다.
EventStore의 장점
- 부수 효과가 중요한 경우 워커 가중치를 조정하여 더 빨리 실행하도록 할 수 있습니다.
- 부수 효과가 비동기로 실행되도록 자동으로 강제함으로써, 다른 도메인이 주된 비즈니스 트랜잭션의 성능에 영향을 미치지 않고 이벤트에 구독할 수 있게 됩니다.
이벤트 정의
Event
객체는 한정된 컨텍스트에서 발생한 도메인 이벤트를 나타냅니다.
다른 한정된 컨텍스트에게 일어난 일에 대해 이벤트를 발행하여 반응하도록합니다.
과거에 일어난 일을 나타내는 이름으로 app/events/<namespace>/
하위에 새 이벤트 클래스를 정의하세요.
class Ci::PipelineCreatedEvent < Gitlab::EventStore::Event
def schema
{
'type' => 'object',
'required' => ['pipeline_id'],
'properties' => {
'pipeline_id' => { 'type' => 'integer' },
'ref' => { 'type' => 'string' }
}
}
end
end
이 스키마는 유효한 JSON 스키마여야 하며, JSONSchemer
젬에 의해 유효성이 검사됩니다. 이 유효성 검사는 구독자가 계약을 따르도록 발행자가 이벤트 객체를 초기화할 때 즉시 발생합니다.
가능한 한 선택적 속성을 사용하여야 하며, 스키마 변경에 대한 롤아웃이 적게 필요합니다.
그러나 이벤트 주체의 고유 식별자에 대한 required
속성을 사용할 수 있습니다. 예:
-
Ci::PipelineCreatedEvent
의 경우pipeline_id
는 필수 속성이 될 수 있습니다. -
Projects::ProjectDeletedEvent
의 경우project_id
는 필수 속성이 될 수 있습니다.
구독자가 필요로 하는 속성만을 발행하고 특정 구독자에게 페이로드를 맞추지 않도록 하세요. 페이로드는 이벤트를 완전히 나타내야 하며 느슨하게 관련된 속성이 포함되어서는 안됩니다. 예:
Ci::PipelineCreatedEvent.new(data: {
pipeline_id: pipeline.id,
# 모든 구독자가 Merge Request ID를 필요로 하지 않는 한
# 이것은 구독자가 가져올 수있는 데이터입니다.
merge_request_ids: pipeline.all_merge_requests.pluck(:id)
})
보다 많은 속성을 포함하여 이벤트를 발행하면 구독자에게 처음부터 필요한 데이터가 제공됩니다. 그렇지 않으면 구독자는 데이터를 추가로 데이터베이스에서 가져와야 합니다. 그러나 이렇게 하면 스키마 변경이 계속되고 단일 진실의 소스를 나타내지 못하는 속성이 추가될 수 있습니다. 이 기술은 성능 최적화로 사용하는 것이 좋습니다. 예를 들어, 한 이벤트에 모든 구독자가 데이터베이스에서 동일한 데이터를 반복해서 가져오게 되는 경우.
스키마 업데이트
스키마 변경에는 여러 번의 배포가 필요합니다. 새 버전이 배포되는 동안:
- 기존 발행자는 이전 버전을 사용하여 이벤트를 발행할 수 있습니다.
- 기존 구독자는 이전 버전을 사용하여 이벤트를 소비할 수 있습니다.
- 이벤트는 작업 인수로 Sidekiq 대기열에 지속됩니다. 따라서 배포 중에 스키마의 2가지 버전을 가질 수 있습니다.
스키마 변경이 결국 Sidekiq 인수에 영향을 미치므로 다수의 배포에 대한 세부 내용은 다음 링크의 Sidekiq 스타일 가이드를 참조하십시오.
속성 추가
- 배포 1:
- 새로운 속성을 선택적으로 추가합니다 (
required
가 아님). - 구독자를 업데이트하여 새로운 속성을 포함하거나 포함하지 않는 이벤트를 소비할 수 있도록 업데이트하십시오.
- 새로운 속성을 선택적으로 추가합니다 (
- 배포 2:
- 발행자를 변경하여 새로운 속성을 제공합니다.
- 배포 3: (속성이
required
인 경우):- 스키마 및 구독자 코드를 항상 해당 속성을 기대하도록 변경하십시오.
속성 제거
- 배포 1:
- 속성이
required
인 경우 선택적으로 만듭니다. - 구독자를 업데이트하여 항상 해당 속성을 기대하지 않도록 업데이트하십시오.
- 속성이
- 배포 2:
- 이벤트 발행에서 속성을 제거합니다.
- 해당 속성을 처리하는 구독자의 코드를 제거하십시오.
기타 변경 사항
기타 변경 사항에 대해서는 속성 이름을 변경하는 등 동일한 단계를 사용하십시오:
- 이전 속성을 제거합니다.
- 새로운 속성을 추가합니다.
이벤트 발행
이전 예제에서 이벤트를 발행하려면:
Gitlab::EventStore.publish(
Ci::PipelineCreatedEvent.new(data: { pipeline_id: pipeline.id })
)
이벤트는 가능한 경우 관련 Service 클래스에서 디스패치되어야 합니다. 상태 머신 전이와 같은 경우와 같이 모델이 이벤트를 발행할 수 있도록 허용하는 경우도 있습니다.
예를 들어, 사이드 이펙트 집합을 실행하는 Ci::BuildFinishedWorker
를 예약하는 대신 Ci::BuildFinishedEvent
를 발행하고 다른 도메인이 비동기적으로 반응하도록 할 수 있습니다.
ActiveRecord
콜백은 도메인 이벤트를 표현하기에는 너무 저수준입니다. 이것들은 더 많은 데이터베이스 레코드 변경을 나타냅니다. 그런 예외적인 경우가 있을 수 있지만 고려해야 합니다.
구독자 생성
구독자는 Gitlab::EventStore::Subscriber
모듈을 포함하는 Sidekiq 워커입니다.
이 모듈은 perform
메서드를 처리하고 handle_event
메서드를 통해 이벤트를 안전하게 처리하기 위한 더 나은 추상화를 제공합니다. 예를 들어:
module MergeRequests
class UpdateHeadPipelineWorker
include Gitlab::EventStore::Subscriber
def handle_event(event)
Ci::Pipeline.find_by_id(event.data[:pipeline_id]).try do |pipeline|
# ...
end
end
end
end
이벤트에 대한 구독자 등록
lib/gitlab/event_store.rb
의 Gitlab::EventStore.configure!
메서드에 다음과 같이 구독자를 특정 이벤트에 구독하는 코드를 추가하십시오:
module Gitlab
module EventStore
def self.configure!(store)
# ...
store.subscribe ::Sbom::ProcessTransferEventsWorker, to: ::Projects::ProjectTransferedEvent,
if: ->(event) do
actor = ::Project.actor_from_id(event.data[:project_id])
Feature.enabled?(:sync_project_archival_status_to_sbom_occurrences, actor)
end
# ...
end
end
end
EE 코드베이스에만 정의된 워커는 ee/lib/ee/gitlab/event_store.rb
에서 구독을 선언함으로써 동일한 방식으로 이벤트에 구독할 수 있습니다.
구독은 Rails 앱이 로드될 때 메모리에 저장되며 즉시 동결됩니다. 런타임에서 구독을 수정할 수 없습니다.
이벤트의 조건부 디스패치
구독은 이벤트를 수용할 조건을 지정할 수 있습니다:
store.subscribe ::MergeRequests::UpdateHeadPipelineWorker,
to: ::Ci::PipelineCreatedEvent,
if: -> (event) { event.data[:merge_request_id].present? }
이렇게하면 조건이 충족되면 구독자에게 Ci::PipelineCreatedEvent
를 디스패치하도록 이벤트 스토어가됩니다.
이 기술을 사용하면 구독이 일부 이벤트에 관심이 있는 경우 Sidekiq 작업을 예약할 필요가 없습니다.
복잡한 조건의 경우 모든 이벤트에 구독한 다음 구독자 워커의 handle_event
메서드에서 로직을 처리하는 것이 가장 좋습니다.
이벤트의 지연된 디스패치
구독은 이벤트를 받을 때 지연을 지정할 수 있습니다:
store.subscribe ::MergeRequests::UpdateHeadPipelineWorker,
to: ::Ci::PipelineCreatedEvent,
delay: 1.minute
delay
매개변수는 이벤트를 perform_async
대신 구독자 Sidekiq 워커의 perform_in
메서드를 사용하여 디스패치하도록 전환합니다.
이 기술은 많은 이벤트를 게시하고 Sidekiq 이중화를 활용하는 경우 유용합니다.
이벤트의 그룹 게시
일부 시나리오에서는 동일한 유형의 여러 이벤트를 비즈니스 트랜잭션에서 한 번에 게시합니다.
이렇게하면 각 이벤트에 대해 작업이 호출되어 부하가 추가됩니다. 이러한 경우 Gitlab::EventStore.publish_group
을 호출하여 여러 이벤트 그룹을 게시할 수 있습니다. 이 메서드는 유사한 유형의 이벤트 배열을 수락합니다. 기본적으로 구독자 워커는 최대 10개의 이벤트 그룹을 수신하지만 group_size
매개변수를 정의하여 구독을 생성할 때 배치 기반으로 그룹에 디스패치되는 게시 이벤트 수를 구성할 수 있습니다. 그룹의 수가 100개를 초과하는 경우 각 그룹을 10초의 지연으로 스케줄링하여 Sidekiq의 부하를 줄일 수 있습니다.
store.subscribe ::Security::RefreshProjectPoliciesWorker,
to: ::ProjectAuthorizations::AuthorizationsChangedEvent,
delay: 1.minute,
group_size: 25
구독자 워커의 handle_event
메서드는 그룹의 각 이벤트에 대해 호출됩니다.
테스트
발행자 테스트
발견자의 책임은 이벤트가 올바르게 발행되었음을 보장하는 것입니다.
이벤트가 올바르게 발행되었는지 테스트하려면 RSpec 매처 :publish_event
를 사용할 수 있습니다:
it '프로젝트 ID 및 네임스페이스 ID를 사용하여 ProjectDeleted 이벤트를 발행합니다' do
expected_data = { project_id: project.id, namespace_id: project.namespace_id }
# 매처는 블록이 호출될 때 블록이 예상 이벤트 및 데이터를 게시하는지 확인합니다.
expect { destroy_project(project, user, {}) }
.to publish_event(Projects::ProjectDeletedEvent)
.with(expected_data)
end
매처 내부에 매처를 구성할 수도 있습니다. 이는 새 레코드를 생성한 후 이벤트가 특정 유형의 값으로 생성되었음을 단언하려는 경우에 유용할 수 있습니다. 예를 들어, 새 기록을 생성한 후 이벤트를 게시하는 경우입니다.
it '프로젝트 ID 및 네임스페이스 ID를 사용하여 ProjectCreatedEvent를 발행합니다' do
# 프로젝트 ID는 `create_project`에서 호출될 때만 생성됩니다.
expected_data = { project_id: kind_of(Numeric), namespace_id: group_id }
expect { create_project(user, name: 'Project', path: 'project', namespace_id: group_id) }
.to publish_event(Projects::ProjectCreatedEvent)
.with(expected_data)
end
여러 이벤트를 발행하는 경우 미발행 이벤트를 확인할 수도 있습니다.
it '프로젝트 ID 및 네임스페이스 ID를 사용하여 ProjectCreatedEvent를 발행합니다' do
# `create_project`에서 `project_id`가 생성됩니다.
expected_data = { project_id: kind_of(Numeric), namespace_id: group_id }
expect { create_project(user, name: 'Project', path: 'project', namespace_id: group_id) }
.to publish_event(Projects::ProjectCreatedEvent)
.with(expected_data)
.and not_publish_event(Projects::ProjectDeletedEvent)
end
구독자 테스트
구독자는 발행된 이벤트를 올바르게 사용할 수 있어야 합니다. 이를 위해 우리는 구독자를 테스트하는 방법을 표준화하기 위해 도우미 및 공유 예제를 추가했습니다.
RSpec.describe MergeRequests::UpdateHeadPipelineWorker do
let(:pipeline_created_event) { Ci::PipelineCreatedEvent.new(data: ({ pipeline_id: pipeline.id })) }
# 이 공유 예제는 이벤트가 발행되고 현재 구독자(`described_class`)에 의해 올바르게 처리되는지를 보장합니다. 또한 작업자가 멱등성을 유지하는지도 보장합니다.
it_behaves_like 'subscribes to event' do
let(:event) { pipeline_created_event }
end
# 이 공유 예제는 발행된 이벤트가 무시되는지를 보장합니다. 이는 조건부 디스패치 테스트에 유용할 수 있습니다.
it_behaves_like 'ignores the published event' do
let(:event) { pipeline_created_event }
end
it '어떤 작업을 수행합니다' do
# 이 도우미는 `perform`을 직접 실행하여 `handle_event`가 올바르게 호출되는지를 보장합니다.
consume_event(subscriber: described_class, event: pipeline_created_event)
# 기대 동작 실행
end
end
모범 사례
-
CE 및 EE 분리 및 호환성 유지:
- 이벤트 클래스를 정의하고 이벤트를 항상 발생하는 코드(CI 또는 EE)와 동일한 코드에서 발행합니다.
- 이벤트가 CE 기능의 결과로 발생하는 경우, 이벤트 클래스는 CE에서 정의 및 발행되어야 합니다. 마찬가지로, EE 기능의 결과로 이벤트가 발생하는 경우, 이벤트 클래스는 EE에서 정의 및 발행되어야 합니다.
- 이벤트에 의존하는 구독자를 종속 기능이 존재하는 동일한 코드 (CE 또는 EE)에서 정의합니다.
- CE에서 발행된 이벤트(예:
Projects::ProjectCreatedEvent
)와 이 이벤트에 의존하는 구독자가 EE에서 정의될 수 있습니다(예:Security::SyncSecurityPolicyWorker
).
- CE에서 발행된 이벤트(예:
- 이벤트 클래스를 정의하고 이벤트를 항상 발생하는 코드(CI 또는 EE)와 동일한 코드에서 발행합니다.
- 이벤트 클래스를 정의하고 이벤트를 동일한 바운디드 컨텍스트(최상위 루비 네임스페이스) 내에서 발행합니다.
- 특정한 바운디드 컨텍스트는 해당 컨텍스트에 관련된 이벤트만을 발행해야 합니다.
- 이벤트에 구독할 때의 신호 대 잡음 비율을 평가하십시오. 구독자 내에서 처리하는 이벤트 대 무시하는 이벤트 갯수를 고려하십시오. 관심 있는 이벤트의 소규모 하위 집합에만 관심이 있다면 조건부 디스패치 사용을 고려하십시오. 조건부 디스패치 또는 잠재적으로 중복되는 작업자를 실행하는 동기적 확인 실행 사이의 균형을 유지하십시오.