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

抽象复用指南

随着 GitLab 的发展,代码库中出现了不同的模式。服务类(Service classes)、序列化器(serializers)和展示器(presenters)只是其中的一部分。这些模式使得代码复用变得容易,但同时也很容易在特定的地方意外地复用了错误的抽象。

为什么需要这些指南

代码复用是好事,但有时这会导致将错误的抽象强行套用到特定的用例中。这反过来可能会对可维护性、轻松调试问题的能力甚至性能产生负面影响。

一个例子是在 IssuesFinder 中使用 ProjectsFinder 来限制属于一组项目的问题。虽然最初这可能看起来是个好主意,但这两个类都提供了非常高级的接口,几乎没有控制能力。这意味着 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 生成的查询可能开始时很简单。随着时间的推移,越来越多的功能被添加到这个(高级)接口中。它不仅会影响必要的用例,还可能开始以负面方式影响 IssuableFinder。例如,GroupProjectsFinder 生成的查询可能包含不必要的条件。由于我们在这里使用了一个查找器,我们无法轻松地选择退出该行为。我们可以添加选项来做到这一点,但那样我们就需要与功能数量一样多的选项。每个选项都会增加两个代码路径,这意味着对于四个功能,我们必须覆盖 8 个不同的代码路径。

处理这个问题的一个更可靠(也更愉快)的方法是直接使用构成 GroupProjectsFinder 的底层代码。这意味着我们可能需要在 IssuableFinder 中编写更多的代码,但它也给了我们更多的控制和确定性。这意味着我们最终可能会得到类似这样的东西:

return @projects = project if project?

projects =
  if current_user && params[:authorized_only].presence && !current_user_related?
    current_user.authorized_projects
  elsif group
    current_user
      .owned_groups(subgroups: params[:include_subgroups])
      .projects
      .any_additional_method_calls
      .that_might_be_necessary
  else
    current_user
      .projects_visible_to_user
      .any_additional_method_calls
      .that_might_be_necessary
  end

@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)

这只是一个草图,但它展示了总体思路:我们将使用 GroupProjectsFinderProjectsFinder 查找器在底层使用的任何东西。

最终目标

本文档中的指南旨在促进更好的代码复用,通过明确定义可以在哪里复用什么,以及当无法复用某些内容时该怎么做。明确分离抽象使得使用错误的抽象变得更加困难,使调试代码更容易,并(希望)减少性能问题。

抽象层

现在让我们看看可用的各种抽象层,以及它们可以(或不可以)复用什么。为此,我们可以使用下表,该表定义了各种抽象以及它们可以(不可以)复用的内容:

抽象层 服务类 查找器 展示器 序列化器 模型实例方法 模型类方法 Active Record Worker
Controller/API endpoint Yes Yes Yes Yes Yes No No No
Service class Yes Yes No No Yes No No Yes
Finder No No No No Yes Yes No No
Presenter No Yes No No Yes Yes No No
Serializer No Yes No No Yes Yes No No
Model class method No No No No Yes Yes Yes No
Model instance method No Yes No No Yes Yes Yes Yes
Worker Yes Yes No No Yes No No Yes

Controllers

app/controllers 中的所有内容。

控制器不应该自己做太多工作,相反,它们将输入传递给其他类并呈现结果。

API endpoints

lib/api(REST API)和 app/graphql(GraphQL API)中的所有内容。

API 端点与控制器具有相同的抽象级别。

Service classes

app/services 中存在的所有内容。

服务类表示协调模型(如实体和值对象)之间变化的操作。变化会影响应用程序的状态。

  1. 当一个对象不对应用程序状态进行任何更改时,它就不是服务。它可能是 查找器 或值对象。
  2. 当没有操作时,不需要执行服务。该类最好设计为实体、值对象或策略。

在实现服务类时,考虑使用以下模式:

  1. 服务类的初始化器应在其参数中包含:

    1. 一个正在操作的 模型 实例。应该是初始化器的第一个位置参数。参数名称由开发者自行决定,例如:issueprojectmerge_request

    2. 当服务代表由用户发起或在用户上下文中执行的操作时,初始化器必须具有 current_user: 关键字参数。带有 current_user: 参数的服务运行高级业务逻辑,并必须验证用户授权以执行其操作。

    3. 当服务没有用户上下文且不是由用户直接发起的(如后台服务或副作用)时,不需要 current_user: 参数。这描述了低级域逻辑或实例级逻辑。

    4. 对于服务需要的所有其他数据,建议使用显式关键字参数。当一个服务需要太长的参数列表时,考虑将它们拆分为:

      • 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
  2. 服务类应该实现一个公共实例方法 #execute,该方法调用服务类行为:

    • #execute 方法不接受任何参数。所有必需的数据都传递给初始化器。
  3. 如果需要返回值,#execute 方法应通过 ServiceResponse 对象返回其结果。

几个基类实现了服务类约定。你可以考虑继承自:

  • BaseContainerService 用于由容器(项目或组)限定的服务。
  • BaseProjectService 用于限定到项目的服务。
  • BaseGroupService 用于限定到组的的服务。

对于某些领域或限界上下文,服务类使用不同的模式可能是有意义的。例如,远程开发领域使用分层架构,其中域逻辑被隔离到单独的域层,遵循标准模式,这允许非常小的服务层,该层仅由单个可重用的 CommonService 类组成。它还使用具有无状态单例类方法的函数式模式。有关更多详细信息,请参阅远程开发服务层代码示例。然而,即使通过这种模式调用服务的签名不同,它仍然尊重标准服务层约定,即始终通过 ServiceResponse 对象返回所有结果,并执行纵深防御授权

非服务对象应该在其他地方创建,例如在 lib 中。

ServiceResponse

服务类通常有一个 execute 方法,它可以返回一个 ServiceResponse。你可以使用 ServiceResponse.successServiceResponse.errorexecute 方法中返回响应。

在成功的情况下:

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,调用者可以使用它来理解失败的性质。 如果调用者是 HTTP 端点,可以将原因符号转换为 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,我们可以利用 Rails HTTP 状态符号,只要它们对于涉及的域逻辑足够具体。 对于其他失败,尽可能使用领域特定的原因。

例如::job_not_retriable:duplicate_package:merge_request_not_mergeable

Finders

app/finders 中的所有内容,通常用于从数据库检索数据。

查找器不能复用其他查找器,以尝试更好地控制它们生成的 SQL 查询。

查找器的 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),以表达更丰富的域概念。

表示域概念的实体和值对象被视为域模型。

一些示例:

Model class methods

这些是由 _GitLab 本身_定义的类方法,包括 Active Record 提供的以下方法:

  • find
  • find_by_id
  • delete_all
  • destroy
  • destroy_all

任何其他方法,如 find_by(some_column: X) 都不包括在内,而是属于 “Active Record” 抽象。

Model instance methods

GitLab 本身 在 ActiveRecord 模型上定义的实例方法。Active Record 提供的方法不包括在内,但以下方法除外:

  • save
  • update
  • destroy
  • delete

Active Record

Active Record 本身提供的 API,如 where 方法、savedelete_all 等。

Worker

app/workers 中的所有内容。

使用 SomeWorker.perform_asyncSomeWorker.perform_in 来安排 Sidekiq 作业。切勿直接使用 SomeWorker.new.perform 调用 worker。