소프트웨어 디자인 가이드

CRUD 용어 대신 보편적 언어 사용

코드는 제품 및 사용자 설명서에서 사용되는 것과 동일한 보편적 언어를 사용해아합니다. 보편적 언어를 올바르게 사용하지 않으면 기여자와 고객들 사이에 끊임없는 번역 또는 다중 용어 사용으로 혼란이 야기될 수 있습니다. 이는 또한 우리의 커뮤니케이션 전략에 반하는 것입니다.

아래 예시에서 CRUD 용어는 모호함을 야기합니다. 이름이 ‘epic_issues’ 연관 레코드를 생성하고 있는 것으로 보이지만, 실제로는 기존 이슈를 epic에 추가하고 있습니다. Rails 규칙에서 사용하는 ‘epic_issues’라는 이름이 서비스 객체와 같은 더 높은 추상화로 스며들어 있습니다. 이 코드는 보편적 언어가 아닌 프레임워크 용어를 사용합니다.

# 나쁨
EpicIssues::CreateService

보편적 언어를 사용하면 코드가 명확해지고, 프레임워크 용어를 번역하려는 독자에게 어떠한 인지 부담도 주지 않습니다.

# 좋음
Epic::AddExistingIssueService

CRUD는 모호하지 않은 간단한 개념을 나타내거나 기존의 보편적 언어와 일치할 때 사용할 수 있습니다. 예를 들어, 프로젝트를 생성하는 경우와 같이요.

# Oㅋ: 제품 언어와 일치함
Projects::CreateService

새 클래스 및 데이터베이스 테이블은 보편적 언어를 따라야 합니다. 이 경우 모델 이름과 테이블 이름은 Rails 규칙을 따릅니다.

보편적 언어를 따르지 않는 기존 클래스는 가능한 경우 이름을 변경해야 합니다. 데이터베이스 테이블과 같이 낮은 수준의 추상화는 이름을 바꿀 필요가 없습니다. 예를 들어, 모델 이름이 테이블 이름과 다른 경우 self.table_name=을 사용하세요.

이름을 변경하기 어려운 경우에만 예외를 허용할 수 있습니다. 예를 들어, 네이밍이 STI에 사용되는 경우, 사용자에게 노출되는 경우, 또는 파괴적인 변경이 될 경우입니다.

바운드 된 맥락을 정의하기 위해 네임스페이스 사용

건강한 애플리케이션은 비즈니스 도메인 또는 인프라 코드와 관련된 맥락을 나타내는 매크로 및 서브 구성 요소로 나누어집니다.

GitLab 코드에는 많은 기능과 구성 요소가 있기 때문에 어떤 맥락들이 관련되어 있는지 파악하기 어렵습니다. 우리는 어떠한 클래스도 그가 작동하는 맥락을 나타내는 모듈/네임스페이스 내에 정의되어 있다고 기대해야 합니다.

우리가 클래스를 도메인 내에 네임스페이스화할 때:

  • 비슷한 용어는 도메인이 의미를 명확하게 해주기 때문에 모호하지 않아집니다. 예를 들어, MergeRequests::DiffNotes::Diff.
  • 최상위 네임스페이스는 도메인 전문가 집단으로 식별된 하나 이상의 그룹에 연결될 수 있습니다.
  • 구성 요소 간의 상호 작용 및 결합을 더 잘 식별할 수 있습니다. 예를 들어, MergeRequests:: 도메인 내의 여러 클래스는 Ci:: 도메인과의 상호 작용이 더 많고 ImportExport::와는 덜합니다.

상위 수준 네임스페이스(바운드 된 맥락)에 이름을 지정하는 좋은 지침은 관련된 기능 카테고리를 사용하는 것입니다. 예를 들어, Continuous Integration 기능 카테고리는 Ci:: 네임스페이스로 매핑됩니다.

# 나쁨
class JobArtifact
end

# 좋음
module Ci
  class JobArtifact
  end
end

일반적으로 프로젝트와 그룹은 컨테이너 개념입니다. 프로젝트 또는 그룹 수준에서 저장소나 러너와 같은 기능을 존재하게 할 수 있지만 Projects:: 또는 Groups:: 아래에 이러한 기능을 중첩시키지 않아야합니다.

Projects::Groups:: 네임스페이스는 주로 그들과 엄격하게 관련된 개념에만 사용해야합니다: 예를 들어, Project::CreateService 또는 Groups::TransferService.

컨트롤러의 경우 app/controllers/projectsapp/controllers/groups를 예외로 허용합니다. 우리는 특정 웹 엔드포인트의 범위를 나타내기 위해 이러한 규칙을 사용합니다.

미래에 특정 기능 카테고리가 다른 그룹으로 재할당될 수 있기 때문에 단계 또는 그룹 이름을 사용하지 않도록합니다.

# 나쁨
module Create
  class Commit
  end
end

# 좋음
module Repositories
  class Commit
  end
end

반면, 기능 카테고리는 때때로 너무 세분되기도 합니다. 제품 및 마케팅에서 기능들은 다른 방식으로 다루는 경향이 있지만 내부에서는 많은 도메인 모델과 기능을 공유할지도 모릅니다. 이 경우, 너무 많은 바운드된 맥락을 가지면 이들은 얇아지고 다른 맥락과 더 결합될 수 있습니다.

바운드된 맥락(또는 최상위 네임스페이스)는 전체 앱에서의 매크로 구성 요소로 볼 수 있습니다. 좋은 바운드된 맥락은 깊은 것이기 때문에, 도메인의 복잡한 부분을 더 분해하기 위해 중첩된 네임스페이스를 고려해야합니다. 예를 들어, Ci::Config::입니다.

예를 들어, ContainerScanning::, ContainerHostSecurity::, ContainerNetworkSecurity::와 같이 각각 개별적이고 세분화된 바운드된 맥락 대신에:

module ContainerSecurity
  module HostSecurity
  end

  module NetworkSecurity
  end

  module Scanning
  end
end

네임스페이스 내에서 정의된 클래스가 다른 네임스페이스 내의 클래스들과 많은 공통점을 가지고 있는 경우, 이 두 네임스페이스는 동일한 바운드된 맥락의 일부일 가능성이 큽니다.

도메인 코드와 일반 코드 구분하기

위의 지침은 주로 도메인 코드를 참조합니다. 도메인 코드의 경우 주어진 경계 컨텍스트를 나타내는 네임스페이스 아래에 루비 클래스를 배치해야 합니다 (일관된 기능 및 능력 집합).

도메인 코드는 GitLab 제품에 고유합니다. 비즈니스 로직, 정책 및 데이터를 설명합니다. 이 코드는 GitLab 저장소에 있어야 합니다. 도메인 코드는 주로 app/lib/에 분할되어 있습니다.

응용 프로그램 코드베이스에는 인프라 수준 작업을 수행할 수 있게 하는 일반 코드도 있습니다. 이것은 로거, 계측, Redis와 같은 데이터스토어용 클라이언트, 데이터베이스 유틸리티 등이 될 수 있습니다.

응용 프로그램을 실행하는 데 중요하지만, 일반 코드는 GitLab 제품에 고유한 비즈니스 로직을 설명하지 않습니다. 온-더-셀프 솔루션으로 대체하거나 다시 작성할 수 있습니다.

이것은 일반 코드가 도메인 코드와 분리되어야 함을 의미합니다.

현재 많은 일반 코드가 lib/에 있지만, 도메인 코드와 섞여 있습니다. 대신에, 젬 개발 지침에 설명된 대로 젬을 gems/ 디렉터리로 추출해야 합니다.

만병통치약 클래스 다루기

우리는 만병통치약 클래스에 새로운 데이터와 동작을 추가하지 않아야 합니다. 우리는 Project, User, MergeRequest, Ci::Pipeline 및 1000줄 이상의 클래스를 만병통치약 클래스로 간주합니다.

이러한 클래스는 책임이 과도하게 많습니다. 새 데이터와 동작은 대부분 별도 및 전용 클래스로 추가할 수 있습니다.

지침:

  • 대부분 객체 ID에 대한 참조만 필요한 경우 (예: Project#id) 외래 키를 사용하는 새 모델 또는 특별한 동작을 추가한 얇은 래퍼를 추가할 수 있습니다.
  • 만병통치약 클래스에 메서드를 추가하면 다른 몇 가지 메서드(비공개 또는 공개)도 추가된다는 것을 발견하면, 이러한 메서드가 전용 클래스에 캡슐화되어야 한다는 신호입니다.
  • 데이터(또는 일부)의 시작점이기 때문에 Project에 메서드를 추가하는 것이 유혹스럽습니다. 데이터가 속한 곳에 동작을 정의해야 하고, 일반적 및 과부하된 객체보다 경계 컨텍스트에서 훨씬 관련성이 높은 만병통치약 객체의 측면을 만들 수 있도록 합니다. 이는 더 많은 결합 및 복잡성을 가져오는 일반적 및 과부하된 객체보다 경계 컨텍스트에서 훨씬 관련성이 높은 만병통치약 객체의 측면을 만듭니다.

예시: 일반 모델 주변에 얇은 도메인 객체 정의하기

여러 메서드를 User에 추가하는 대신에 abuse_trust_scores에 연관이 있기 때문에, 의존성을 거꾸로 생각해보세요.

##
# 안 좋음: User 객체에 동작 추가
class User
  def spam_score
    abuse_trust_scores.spamcheck.average(:score) || 0.0
  end

  def spammer?
    # 경고 표지: 특정한 경계 컨텍스트에 속하는 상수를 사용합니다!
    spam_score > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
  end

  def telesign_score
    abuse_trust_scores.telesign.recent_first.first&.score || 0.0
  end

  def arkose_global_score
    abuse_trust_scores.arkose_global_score.recent_first.first&.score || 0.0
  end

  def arkose_custom_score
    abuse_trust_scores.arkose_custom_score.recent_first.first&.score || 0.0
  end
end

# 사용:
user = User.find(1)
user.spam_score
user.telesign_score
user.arkose_global_score
##
# 좋음: 사용자 신뢰 점수를 나타내는 얇은 클래스 정의
class Abuse::UserTrustScore
  def initialize(user)
    @user = user
  end

  def spam
    scores.spamcheck.average(:score) || 0.0
  end

  def spammer?
    spam > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
  end

  def telesign
    scores.telesign.recent_first.first&.score || 0.0
  end

  def arkose_global
    scores.arkose_global_score.recent_first.first&.score || 0.0
  end

  def arkose_custom
    scores.arkose_custom_score.recent_first.first&.score || 0.0
  end

  private

  def scores
    Abuse::TrustScore.for_user(@user)
  end
end

# 사용:
user = User.find(1)
user_score = Abuse::UserTrustScore.new(user)
user_score.spam
user_score.spammer?
user_score.telesign
user_score.arkose_global

실제 예제는 병합 요청에서 확인하세요.

예시: 의존성 역전을 사용하여 도메인 개념 추출하기


##
# BAD: 프로젝트에서 정의된 통합 관련 메서드입니다.
class Project
  has_many :integrations

  def find_or_initialize_integrations
    # ...
  end

  def find_or_initialize_integration(name)
    # ...
  end

  def disabled_integrations
    # ...
  end

  def ci_integrations
    # ...
  end

  # 많은 메서드가 더 있습니다...
end
##
# GOOD: 모든 통합 관련 로직은 `Integrations::`에 캡슐화되어 있습니다.
module Integrations
  class ProjectIntegrations
    def initialize(project)
      @project = project
    end

    def all_integrations
      @project.integrations # AR 관계의 캐싱을 여전히 활용할 수 있습니다.
    end

    def find_or_initialize(name)
      # ...
    end

    def all_disabled
      all_integrations.disabled
    end

    def all_ci
      all_integrations.ci_integration
    end
  end
end

유사한 리팩터링의 실제 예시.

사용 사례를 중심으로 소프트웨어를 설계하십시오, 엔터티가 아닌

액티브 레코드의 파워를 통해 레일즈는 개발자들에게 엔터티 중심의 소프트웨어를 설계하도록 유도합니다. 컨트롤러와 API 엔드포인트는 주로 엔터티와 서비스 개체 모두에 대해 CRUD 작업을 나타냅니다. 새로운 데이터베이스 열은 서로 다른 사용 사례를 나타내더라도 기존 엔터티 테이블에 추가될 경향이 있습니다.

이 안티-패턴은 흔히 다음 중 하나 이상으로 나타납니다:

안티-패턴 예시

우리는 Groups::UpdateService가 엔터티 중심이며 근본적으로 다른 사용 사례에 재사용되는 것입니다:

  • 그룹 설명을 업데이트하려면 그룹 관리자 액세스가 필요합니다.
  • 인스턴스 관리자 액세스가 필요한 compute quota, 예를 들어 shared_runners_minutes_limit의 네임스페이스 수준 제한 설정입니다.

이 두 가지 다른 사용 사례는 서로 다른 매개변수 집합을 지원합니다. 인스턴스 관리자가 shared_runners_minutes_limit를 업데이트하고 그룹 설명을 업데이트하는 것은 그다지 가능하거나 예상되는 일이 아닙니다. 마찬가지로 사용자가 동시에 브랜치 보호 규칙과 공유 러너 설정을 변경하는 것도 예상되지 않습니다. 이것들은 서로 다른 도메인에서 나오는 다른 사용 사례를 나타냅니다.

해결 방법

엔터티가 아닌 사용 사례를 중심으로 설계하십시오. 만약 사람, 사용 사례, 의도가 다른 경우에는 별도의 추상화를 생성하십시오:

  • 특정 사용 사례의 도메인에 중첩된 다른 엔드포인트(컨트롤러, GraphQL 또는 REST).
  • 특정 권한과 응집된 매개변수 집합을 포함하는 다른 서비스 개체. 예를 들어, 그룹 관리자가 일반적인 그룹 설정을 업데이트하는 Groups::UpdateService, 인스턴스 관리자를 위한 Ci::Minutes::UpdateLimitService는 완전히 다른 권한, 기대, 매개변수 및 부작용을 갖게 될 것입니다.

최종적으로, 이것은 Omniscient 클래스 길들이기의 원리를 활용하는 것을 요구합니다. 우리는 상관 없는 사용 사례 로직이 하나의, 응집력이 낮은 클래스에 결합되지 않도록 함으로써 느슨한 결합과 높은 응집력을 이뤄내려고 합니다. 결과적으로 권한이 일관되게 작업 전체에 적용되므로 보안이 높아집니다. 마찬가지로, 하나의 권한 확인만 하면 됩니다. 데이터를 읽거나 쓰는 데 항상 같은 사용 사례에 속하는 데이터를 노출하지도 않습니다.