极狐 GitLab

抽象重用指南

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

为什么需要这些指南#

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

例如,在 IssuesFinder 中使用 ProjectsFinder 来将议题限制为属于一组项目的议题。虽然最初这似乎是个好主意,但这两个类都提供了非常高级的接口,控制力很小。这意味着 IssuesFinder 可能无法生成更优化的数据库查询,因为查询的很大一部分由 ProjectsFinder 的内部机制控制。

为了解决这个问题,你应该使用 ProjectsFinder 所使用的相同代码,而不是直接使用 ProjectsFinder 本身。这允许你更好地组合行为,让你对代码的行为有更多的控制。

为了说明,考虑以下来自 IssuableFinder#projects 的代码:

ruby
1return @projects = project if project? 2 3projects = 4 if current_user && params[:authorized_only].presence && !current_user_related? 5 current_user.authorized_projects 6 elsif group 7 finder_options = { include_subgroups: params[:include_subgroups], exclude_shared: true } 8 GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute 9 else 10 ProjectsFinder.new(current_user: current_user).execute 11 end 12 13@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)

这里我们使用三种不同的方法来确定将数据范围限定到哪些项目。当指定了群组时,我们使用 GroupProjectsFinder 来检索该群组的所有项目。表面上这似乎无害:它易于使用,而且我们只需要两行代码。

实际上,事情很快就会变得棘手。例如,GroupProjectsFinder 生成的查询可能一开始很简单。随着时间的推移,越来越多的功能被添加到这个(高级)接口中。它不仅会影响必要的场景,还可能开始以负面的方式影响 IssuableFinder。例如,GroupProjectsFinder 生成的查询可能包含不必要的条件。由于我们在这里使用了查找器,我们无法轻易选择退出该行为。我们可以添加选项来实现,但那样我们需要与功能一样多的选项。每个选项都会增加两条代码路径,这意味着对于四个功能,我们必须覆盖 8 条不同的代码路径。

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

ruby
1return @projects = project if project? 2 3projects = 4 if current_user && params[:authorized_only].presence && !current_user_related? 5 current_user.authorized_projects 6 elsif group 7 current_user 8 .owned_groups(subgroups: params[:include_subgroups]) 9 .projects 10 .any_additional_method_calls 11 .that_might_be_necessary 12 else 13 current_user 14 .projects_visible_to_user 15 .any_additional_method_calls 16 .that_might_be_necessary 17 end 18 19@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)

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

最终目标#

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

抽象#

现在让我们看看可用的各种抽象级别,以及它们可以(或不可以)重用哪些内容。为此,我们可以使用下表,该表定义了各种抽象以及它们可以(不可以)重用哪些内容:

抽象服务类查找器展示器序列化器模型实例方法模型类方法Active RecordWorker
控制器/API 端点
服务类
查找器
展示器
序列化器
模型类方法
模型实例方法
Worker

控制器#

app/controllers 中的所有内容。

控制器自身不应该做太多工作,而是将输入传递给其他类并呈现结果。

视图#

app/viewsee/app/views 中的所有内容。

视图仅负责呈现。它们通过控制器分配的实例变量接收数据,并将其呈现为 HTML、XML、Markdown 或文本。

视图不得:

  • 执行数据库查询。将所有数据检索移到控制器或展示器中,并将结果作为实例变量传递。视图中的查询会绕过缓存层,对查询分析工具不可见,并使 N+1 问题难以检测。
  • 包含业务逻辑。避免评估模型状态的条件判断,除了 nil?present? 或布尔属性检查。避免服务对象实例化和多步骤计算。将此逻辑提取到辅助方法展示器ViewComponent 中。

API 端点#

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

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

服务类#

位于 app/services 中的所有内容。

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

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

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

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

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

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

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

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

      • params:一个包含模型属性的哈希,将直接赋值。
      • options:一个包含额外参数的哈希(需要处理,且不是模型属性)。options 哈希应存储在实例变量中。
      ruby
      1# merge_request: A model instance that is being acted upon. 2# assignee: new MR assignee that will be assigned to the MR 3# after the service is executed. 4def initialize(merge_request, assignee:) 5 @merge_request = merge_request 6 @assignee = assignee 7end
      ruby
      1# issue: A model instance that is being acted upon. 2# current_user: Current user. 3# params: Model properties. 4# options: Configuration for this service. Can be any of the following: 5# - notify: Whether to send a notification to the current user. 6# - cc: Email address to copy when sending a notification. 7def initialize(issue:, current_user:, params: {}, options: {}) 8 @issue = issue 9 @current_user = current_user 10 @params = params 11 @options = options 12end
  2. 服务类应实现一个单一的公共实例方法 #execute,该方法调用服务类的行为:

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

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

  • BaseContainerService:用于按容器(项目或群组)范围划分的服务。
  • BaseProjectService:用于按项目范围划分的服务。
  • BaseGroupService:用于按群组范围划分的服务。

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

不是服务对象的类应该在其他地方创建,例如在 lib 中。

ServiceResponse#

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

在成功的情况下:

ruby
1response = ServiceResponse.success(message: 'Branch was deleted') 2 3response.success? # => true 4response.error? # => false 5response.status # => :success 6response.message # => 'Branch was deleted'

在失败的情况下:

ruby
1response = ServiceResponse.error(message: 'Unsupported operation') 2 3response.success? # => false 4response.error? # => true 5response.status # => :error 6response.message # => 'Unsupported operation'

还可以附加额外的负载:

ruby
response = ServiceResponse.success(payload: { issue: issue }) response.payload[:issue] # => issue

错误响应还可以指定失败 reason,调用者可以使用它来了解失败的性质。如果调用者是 HTTP 端点,则可以将原因符号转换为 HTTP 状态码:

ruby
1response = ServiceResponse.error( 2 message: 'Job is in a state that cannot be retried', 3 reason: :job_not_retrieable) 4 5if response.success? 6 head :ok 7elsif response.reason == :job_not_retriable 8 head :unprocessable_entity 9else 10 head :bad_request 11end

对于常见的失败,如资源 :not_found 或操作 :forbidden,只要它们对所涉及的域逻辑足够具体,我们就可以利用 Rails 的 HTTP 状态符号。对于其他失败,尽可能使用特定于域的原因。

例如::job_not_retriable:duplicate_package:merge_request_not_mergeable

查找器#

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

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

查找器的 execute 方法应返回 ActiveRecord::Relation。例外情况可以添加到 spec/support/finder_collection_allowlist.yml 中。有关更多详细信息,请参阅 #298771

展示器#

app/presenters 中的所有内容,用于将复杂数据暴露给 Rails 视图,而无需创建许多实例变量。

有关更多信息,请参阅文档

序列化器#

app/serializers 中的所有内容,用于呈现对请求的响应,通常以 JSON 格式。

模型#

app/models 中的类和模块表示封装了数据和行为的领域概念。

这些类可以直接与数据存储交互(如 ActiveRecord 模型),或者可以是 ActiveRecord 模型之上的薄包装器(普通 Ruby 对象),以表达更丰富的领域概念。

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

一些例子:

模型类方法#

这些是由极狐GitLab 自身定义的类方法,包括 Active Record 提供的以下方法:

  • find
  • find_by_id
  • delete_all
  • destroy
  • destroy_all

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

模型实例方法#

极狐GitLab 自身在 Active Record 模型上定义的实例方法。Active Record 提供的方法不包括在内,除了以下方法:

  • save
  • update
  • destroy
  • delete

Active Record#

Active Record 自身提供的 API,例如 where 方法、save