抽象重用指南
随着极狐GitLab 的发展,代码库中出现了不同的模式。服务类、序列化器和展示器只是其中一部分。这些模式使得代码重用变得容易,但同时也容易在特定位置意外地重用错误的抽象。
为什么需要这些指南
代码重用是好的,但有时这会导致将错误的抽象硬塞进特定的用例中。这反过来会对可维护性、轻松调试问题的能力甚至性能产生负面影响。
例如,在 IssuesFinder 中使用 ProjectsFinder 来将议题限制为属于一组项目的议题。虽然最初这似乎是个好主意,但这两个类都提供了非常高级的接口,控制力很小。这意味着 IssuesFinder 可能无法生成更优化的数据库查询,因为查询的很大一部分由 ProjectsFinder 的内部机制控制。
为了解决这个问题,你应该使用 ProjectsFinder 所使用的相同代码,而不是直接使用 ProjectsFinder 本身。这允许你更好地组合行为,让你对代码的行为有更多的控制。
为了说明,考虑以下来自 IssuableFinder#projects 的代码:
ruby1return @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 中需要多一点代码,但它也给了我们更多的控制和确定性。这意味着我们最终可能会得到类似这样的代码:
ruby1return @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)
这只是一个草图,但它展示了总体思路:我们将使用 GroupProjectsFinder 和 ProjectsFinder 查找器在底层使用的任何东西。
最终目标
本文档中的指南旨在通过明确定义什么可以在哪里重用,以及当无法重用某些东西时该怎么做,来促进更好的代码重用。清晰地分离抽象使得更难使用错误的抽象,更容易调试代码,并且(希望)能减少性能问题。
抽象
现在让我们看看可用的各种抽象级别,以及它们可以(或不可以)重用哪些内容。为此,我们可以使用下表,该表定义了各种抽象以及它们可以(不可以)重用哪些内容:
| 抽象 | 服务类 | 查找器 | 展示器 | 序列化器 | 模型实例方法 | 模型类方法 | Active Record | Worker |
|---|---|---|---|---|---|---|---|---|
| 控制器/API 端点 | 是 | 是 | 是 | 是 | 是 | 否 | 否 | 否 |
| 服务类 | 是 | 是 | 否 | 否 | 是 | 否 | 否 | 是 |
| 查找器 | 否 | 否 | 否 | 否 | 是 | 是 | 否 | 否 |
| 展示器 | 否 | 是 | 否 | 否 | 是 | 是 | 否 | 否 |
| 序列化器 | 否 | 是 | 否 | 否 | 是 | 是 | 否 | 否 |
| 模型类方法 | 否 | 否 | 否 | 否 | 是 | 是 | 是 | 否 |
| 模型实例方法 | 否 | 是 | 否 | 否 | 是 | 是 | 是 | 是 |
| Worker | 是 | 是 | 否 | 否 | 是 | 否 | 否 | 是 |
控制器
app/controllers 中的所有内容。
控制器自身不应该做太多工作,而是将输入传递给其他类并呈现结果。
视图
app/views 和 ee/app/views 中的所有内容。
视图仅负责呈现。它们通过控制器分配的实例变量接收数据,并将其呈现为 HTML、XML、Markdown 或文本。
视图不得:
- 执行数据库查询。将所有数据检索移到控制器或展示器中,并将结果作为实例变量传递。视图中的查询会绕过缓存层,对查询分析工具不可见,并使 N+1 问题难以检测。
- 包含业务逻辑。避免评估模型状态的条件判断,除了 nil?、present? 或布尔属性检查。避免服务对象实例化和多步骤计算。将此逻辑提取到辅助方法、展示器或 ViewComponent 中。
API 端点
lib/api(REST API)和 app/graphql(GraphQL API)中的所有内容。
API 端点与控制器具有相同的抽象级别。
服务类
位于 app/services 中的所有内容。
服务类表示协调模型(如实体和值对象)之间更改的操作。更改会影响应用程序的状态。
- 当一个对象不对应用程序的状态进行任何更改时,它就不是服务。它可能是一个查找器或值对象。
- 当没有操作时,不需要执行服务。该类可能更好地设计为实体、值对象或策略。
在实现服务类时,请考虑使用以下模式:
-
服务类初始化器的参数应包含:
-
一个被操作的模型实例。应该是初始化器的第一个位置参数。参数的名称由开发者自行决定,例如:issue、project、merge_request。
-
当服务表示由用户发起或在用户上下文中执行的操作时,初始化器必须具有 current_user: 关键字参数。带有 current_user: 参数的服务运行高级业务逻辑,并且必须验证用户授权以执行其操作。
-
当服务没有用户上下文并且不是由用户直接发起时(如后台服务或副作用),不需要 current_user: 参数。这描述了低级域逻辑或实例范围的逻辑。
-
对于服务所需的所有额外数据,建议使用显式关键字参数。当服务需要过长的参数列表时,请考虑将它们拆分为:
- params:一个包含模型属性的哈希,将直接赋值。
- options:一个包含额外参数的哈希(需要处理,且不是模型属性)。options 哈希应存储在实例变量中。
ruby1# 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 7endruby1# 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
-
-
服务类应实现一个单一的公共实例方法 #execute,该方法调用服务类的行为:
- #execute 方法不接受任何参数。所有必需的数据都传递给初始化器。
-
如果需要返回值,#execute 方法应通过 ServiceResponse 对象返回其结果。
有几个基类实现了服务类约定。你可以考虑继承自:
- BaseContainerService:用于按容器(项目或群组)范围划分的服务。
- BaseProjectService:用于按项目范围划分的服务。
- BaseGroupService:用于按群组范围划分的服务。
对于某些领域或限界上下文,服务类使用不同的模式可能是有意义的。例如,远程开发领域使用分层架构,将域逻辑隔离到一个遵循标准模式的单独域层中,这允许一个非常最小服务层,该层仅由一个可重用的 CommonService 类组成。它还使用无状态单例类方法的功能模式。有关更多详细信息,请参阅远程开发服务层代码示例。然而,尽管通过这种模式调用服务的签名不同,但它仍然遵守标准的服务层契约,即始终通过 ServiceResponse 对象返回所有结果,并执行纵深防御授权。
不是服务对象的类应该在其他地方创建,例如在 lib 中。
ServiceResponse
服务类通常有一个 execute 方法,它可以返回一个 ServiceResponse。你可以使用 ServiceResponse.success 和 ServiceResponse.error 在 execute 方法中返回响应。
在成功的情况下:
ruby1response = ServiceResponse.success(message: 'Branch was deleted') 2 3response.success? # => true 4response.error? # => false 5response.status # => :success 6response.message # => 'Branch was deleted'
在失败的情况下:
ruby1response = ServiceResponse.error(message: 'Unsupported operation') 2 3response.success? # => false 4response.error? # => true 5response.status # => :error 6response.message # => 'Unsupported operation'
还可以附加额外的负载:
rubyresponse = ServiceResponse.success(payload: { issue: issue }) response.payload[:issue] # => issue
错误响应还可以指定失败 reason,调用者可以使用它来了解失败的性质。如果调用者是 HTTP 端点,则可以将原因符号转换为 HTTP 状态码:
ruby1response = 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 对象),以表达更丰富的领域概念。
表示领域概念的实体和值对象被视为领域模型。
一些例子:
- DesignManagement::DesignAtVersion 是一个利用验证来组合设计和版本的模型。
- Ci::Minutes::Usage 是一个值对象,为给定命名空间提供计算用量。
模型类方法
这些是由极狐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、