Help us learn about your current experience with the documentation. Take the survey.

软件设计指南

使用通用语言而非 CRUD 术语

代码应使用与产品文档和用户文档相同的通用语言。未能正确使用通用语言可能会成为贡献者和客户困惑的主要原因,特别是在需要不断转换术语或使用多个术语的情况下。

这也违背了我们的沟通策略

在下面的示例中,CRUD术语引入了歧义。名称表示我们正在创建一个 epic_issues 关联记录,但实际上我们正在将现有问题添加到 epic 中。从 Rails 约定使用的名称 epic_issues 泄漏到了更高级别的抽象,如服务对象。代码说的是框架术语,而不是通用语言。

# Bad
EpicIssues::CreateService

使用通用语言使代码清晰,不会给试图翻译框架术语的读者带来任何认知负担。

# Good
Epic::AddExistingIssueService

当表示不模糊的简单概念(如创建项目)并与现有通用语言匹配时,可以使用 CRUD。

# OK: Matches the product language.
Projects::CreateService

新的类和数据库表应使用通用语言。在这种情况下,模型名称和表名遵循 Rails 约定。

对于不遵循通用语言的现有类,应尽可能重命名。一些低级抽象(如数据库表)不需要重命名。例如,当模型名称与表名不同时,使用 self.table_name=

只有在重命名具有挑战性时,我们才允许例外。例如,当命名用于 STI、暴露给用户或会导致破坏性更改时。

限界上下文

有关限界上下文的目标、动机和方向的更多信息,请参阅限界上下文工作组GitLab 模块化单体设计文档

使用命名空间定义限界上下文

一个健康的应用程序被划分为表示所有限界上下文的宏观和子组件。由于 GitLab 代码具有如此多的功能和组件,很难看出涉及哪些上下文。这些组件可能与业务领域或基础设施代码相关。

我们期望任何类都在表示其操作上下文的模块/命名空间内定义。我们维护一个允许的命名空间列表来定义这些上下文。

当我们在域内对类进行命名空间化时:

  • 相似术语变得明确,因为域澄清了含义:例如,MergeRequests::DiffNotes::Diff
  • 顶级命名空间可以与一个或多个被识别为领域专家的组相关联。
  • 我们可以更好地识别组件之间的交互和耦合。例如,MergeRequests:: 域内的多个类与 Ci:: 域的交互更多,而与 Import:: 的交互较少。
# bad
class JobArtifact ... end

# good
module Ci
  class JobArtifact ... end
end

如何定义限界上下文

允许的限界上下文在 config/bounded_contexts.yml 中定义,其中包含域层和基础设施层的命名空间。

对于域层,我们指的是:

  1. app 中的代码,不包括应用程序适配器(控制器、API 端点和视图)。
  2. lib 中专门与域逻辑相关的代码。

这包括 ActiveRecord 模型、服务对象、worker 和领域特定的普通 Ruby 对象。

目前我们排除应用程序适配器,以保持工作量较小,并且因为给定的端点并不总是与单个域匹配(例如,设置、合并请求视图或项目视图)。

对于基础设施层,我们指的是 lib 中用于通用目的的代码,不包含 GitLab 业务概念,并且可以提取到 Ruby gem 中。

命名顶级命名空间(限界上下文)的一个好准则是使用相关的功能类别。例如,Continuous Integration 功能类别映射到 Ci:: 命名空间。

项目和组通常是容器概念,因为它们标识租户。虽然功能存在于项目或组级别(如仓库或 runner),但我们绝不能将这些功能嵌套在 Projects::Groups:: 下,而应放在它们相关的限界上下文中。

Projects::Groups:: 命名空间应仅用于严格与它们相关的概念:例如 Project::CreateServiceGroups::TransferService

对于控制器,我们允许 app/controllers/projectsapp/controllers/groups 作为例外,也因为限界上下文不应用于应用程序层。我们使用此约定来指示给定 Web 端点的范围。

不要使用阶段或组名称,因为功能类别将来可能会重新分配给不同的组。

# bad
module Create
  class Commit ... end
end

# good
module Repositories
  class Commit ... end
end

另一方面,功能类别有时可能过于细化。功能和营销部门倾向于以不同方式对待功能,而它们在底层可能共享许多域模型和行为。在这种情况下,拥有过多的限界上下文可能会使它们变浅,并与其他上下文耦合更多。

限界上下文(或顶级命名空间)可以被视为整体应用程序中的宏观组件。好的限界上下文应该是深度的,因此考虑使用嵌套命名空间来进一步分解域的复杂部分。例如,Ci::Config::

例如,与其拥有单独且细化的限界上下文,如 ContainerScanning::ContainerHostSecurity::ContainerNetworkSecurity::,我们可以:

module Security::Container
  module Scanning ... end

  module NetworkSecurity ... end

  module HostSecurity ... end
end

如果在命名空间中定义的类与其他命名空间中的类有很多共同点,那么这两个命名空间很可能是同一个限界上下文的一部分。

如何解决 GitLab/BoundedContexts RuboCop 违规

Gitlab/BoundedContexts RuboCop 规则确保每个 Ruby 类或模块都嵌套在 config/bounded_contexts.yml 中存在的顶级 Ruby 命名空间内。

违规应通过将常量嵌套在现有的限界上下文命名空间中来解决。

  • config/bounded_contexts.yml 中搜索与功能更相关的命名空间,例如通过匹配功能类别。
  • 如有必要,使用子命名空间将常量进一步嵌套在命名空间内。例如:Repositories::Mirrors::SyncService
  • 创建后续问题以将现有相关代码移动到同一命名空间中。

在特殊情况下,我们可能需要向列表中添加新的限界上下文。这可以在以下情况下完成:

  • 我们正在引入一个与任何现有限界上下文都不对齐的新产品类别。
  • 我们正在从现有限界上下文中提取一个限界上下文,因为它太大,我们想要解耦这两个上下文。

GitLab/BoundedContexts 和 config/bounded_contexts.yml 常见问题

  1. 是否有应该禁用此规则的情况?

    • 该规则不应该被禁用,但如果违规的类或模块是一组应该一起移动的类的一部分,则可以暂时禁用。 在这种情况下,您可以禁用该规则并创建后续问题以一次性移动所有类。
  2. 是否有建议的时间表来将所有现有代码重构为符合规范?

    • 我们没有定义时间表,但我们越快整合代码,它就越一致。
  3. 限界上下文是否适用于现有的 Sidekiq worker?

    • 现有的 worker 已经在 RuboCop TODO 文件中,因此不会引发违规。但是,它们也应尽可能移动到限界上下文中。 遵循 Sidekiq 重命名 worker 指南。
  4. 我们正在重命名功能类别,而 config/bounded_contexts.yml 引用了该类别。更新安全吗?

    • 是的,该文件只期望映射到限界上下文的功能类别在 config/feature_categories.yml 中定义,并且没有特定依赖这些值。此映射主要供贡献者了解功能可能在代码库中的位置。

区分域代码和通用代码

上述指南主要涉及域代码。对于域代码,我们应该将 Ruby 类放在表示给定限界上下文(一组连贯的功能和能力)的命名空间下。

域代码是 GitLab 产品特有的。它描述了业务逻辑、策略和数据。此代码应存在于 GitLab 仓库中。域代码主要分布在 app/lib/ 中。

在应用程序代码库中,还有允许执行更基础设施级别操作的通用代码。这可以是日志记录器、检测工具、数据存储(如 Redis)的客户端、数据库实用程序等。

尽管对应用程序运行至关重要,但通用代码不描述任何 GitLab 产品特有的业务逻辑。它可以被重写或替换为现成的解决方案,而不会影响业务逻辑。 这意味着通用代码应与域代码分开。

如今,许多通用代码存在于 lib/ 中,但与域代码混合在一起。我们应该按照我们的Gem 开发指南将其提取到 gems/ 目录中。

驯服全知类

我们必须考虑不要向全知类(也称为 god objects)添加新的数据和功能。我们认为 ProjectUserMergeRequestCi::Pipeline 以及任何超过 1000 LOC 的类都是全知的。

这类类承担了过多的职责。新的数据和功能大多数时候可以作为单独的专用类添加。

指南:

  • 如果您主要需要对象 ID 的引用(例如 Project#id),您可以添加一个使用外键或围绕对象的薄包装器来添加特殊行为的新模型。
  • 如果您发现向全知类添加方法最终也添加了几个其他方法(私有或公共),这表明这些方法应该封装在专用类中。
  • Project 添加方法很诱人,因为那是数据和关联的起点。尝试在行为所属的限界上下文中定义行为,而不是在数据(或部分数据)所在的位置。这有助于创建全知对象的方面,这些方面在限界上下文中比具有通用和过载的对象(带来更多耦合和复杂性)更相关。

示例:在通用模型周围定义一个薄的域对象

##
# BAD: Behavior added to User object.
class User
  def spam_score
    abuse_trust_scores.spamcheck.average(:score) || 0.0
  end

  def spammer?
    # Warning sign: we use a constant that belongs to a specific bounded context!
    spam_score > AntiAbuse::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

# Usage:
user = User.find(1)
user.spam_score
user.telesign_score
user.arkose_global_score
##
# GOOD: Define a thin class that represents a user trust score
class AntiAbuse::UserTrustScore
  def initialize(user)
    @user = user
  end

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

  def spammer?
    spam > AntiAbuse::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
    AntiAbuse::TrustScore.for_user(@user)
  end
end

# Usage:
user = User.find(1)
user_score = AntiAbuse::UserTrustScore.new(user)
user_score.spam
user_score.spammer?
user_score.telesign
user_score.arkose_global

查看真实示例合并请求

示例:使用依赖倒置提取域概念

##
# BAD: methods related to integrations defined in Project.
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

  # many more methods...
end
##
# GOOD: All logic related to Integrations is enclosed inside the `Integrations::`
# bounded context.
module Integrations
  class ProjectIntegrations
    def initialize(project)
      @project = project
    end

    def all_integrations
      @project.integrations # can still leverage caching of AR associations
    end

    def find_or_initialize(name)
      # ...
    end

    def all_disabled
      all_integrations.disabled
    end

    def all_ci
      all_integrations.ci_integration
    end
  end
end

类似重构的真实示例合并请求

围绕用例而非实体设计软件

Rails 通过 Active Record 的力量,鼓励开发者设计以实体为中心的软件。控制器和 API 端点往往代表实体和服务对象的 CRUD 操作。新的数据库列往往被添加到现有实体表中,尽管它们引用不同的用例。

这种反模式通常表现为以下一种或多种情况:

反模式示例

我们有 Groups::UpdateService,它是以实体为中心的,并为根本不同的用例重用:

  • 更新组描述,需要组管理员访问权限。
  • 设置命名空间级别的计算配额限制,如 shared_runners_minutes_limit,需要实例管理员访问权限。

这两个不同的用例支持不同的参数集。实例管理员更新 shared_runners_minutes_limit 同时也更新组描述是不太可能或预期的。同样,用户也不期望同时更改分支保护规则和实例 runner 设置。这些代表来自不同域的不同用例。

解决方案

围绕用例而非实体设计。如果角色、用例和意图不同,请创建单独的抽象:

  • 不同的端点(控制器、GraphQL 或 REST),嵌套在用例的特定域中。
  • 不同的服务对象,嵌入特定的权限和一组连贯的参数。例如,Groups::UpdateService 用于组管理员更新通用组设置。 Ci::Minutes::UpdateLimitService 将用于实例管理员,并具有完全不同的权限、期望、参数和副作用。

最终,这需要利用驯服全知类中的原则。我们希望通过避免将不相关的用例逻辑耦合到单个、内聚性较低的类中,来实现松散耦合和高内聚。结果是一个更安全的系统,因为权限一致地应用于整个操作。同样,如果定义在单独的模型或表中,我们不会意外地暴露管理员级别的数据。我们可以在读取或写入属于同一用例的数据之前进行一次权限检查。