抽象复用指南
随着 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)这只是一个草图,但它展示了总体思路:我们将使用 GroupProjectsFinder 和 ProjectsFinder 查找器在底层使用的任何东西。
最终目标
本文档中的指南旨在促进更好的代码复用,通过明确定义可以在哪里复用什么,以及当无法复用某些内容时该怎么做。明确分离抽象使得使用错误的抽象变得更加困难,使调试代码更容易,并(希望)减少性能问题。
抽象层
现在让我们看看可用的各种抽象层,以及它们可以(或不可以)复用什么。为此,我们可以使用下表,该表定义了各种抽象以及它们可以(不可以)复用的内容:
| 抽象层 | 服务类 | 查找器 | 展示器 | 序列化器 | 模型实例方法 | 模型类方法 | 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 中存在的所有内容。
服务类表示协调模型(如实体和值对象)之间变化的操作。变化会影响应用程序的状态。
- 当一个对象不对应用程序状态进行任何更改时,它就不是服务。它可能是 查找器 或值对象。
- 当没有操作时,不需要执行服务。该类最好设计为实体、值对象或策略。
在实现服务类时,考虑使用以下模式:
-
服务类的初始化器应在其参数中包含:
-
一个正在操作的 模型 实例。应该是初始化器的第一个位置参数。参数名称由开发者自行决定,例如:
issue、project、merge_request。 -
当服务代表由用户发起或在用户上下文中执行的操作时,初始化器必须具有
current_user:关键字参数。带有current_user:参数的服务运行高级业务逻辑,并必须验证用户授权以执行其操作。 -
当服务没有用户上下文且不是由用户直接发起的(如后台服务或副作用)时,不需要
current_user:参数。这描述了低级域逻辑或实例级逻辑。 -
对于服务需要的所有其他数据,建议使用显式关键字参数。当一个服务需要太长的参数列表时,考虑将它们拆分为:
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
-
-
服务类应该实现一个公共实例方法
#execute,该方法调用服务类行为:#execute方法不接受任何参数。所有必需的数据都传递给初始化器。
-
如果需要返回值,
#execute方法应通过ServiceResponse对象返回其结果。
几个基类实现了服务类约定。你可以考虑继承自:
BaseContainerService用于由容器(项目或组)限定的服务。BaseProjectService用于限定到项目的服务。BaseGroupService用于限定到组的的服务。
对于某些领域或限界上下文,服务类使用不同的模式可能是有意义的。例如,远程开发领域使用分层架构,其中域逻辑被隔离到单独的域层,遵循标准模式,这允许非常小的服务层,该层仅由单个可重用的 CommonService 类组成。它还使用具有无状态单例类方法的函数式模式。有关更多详细信息,请参阅远程开发服务层代码示例。然而,即使通过这种模式调用服务的签名不同,它仍然尊重标准服务层约定,即始终通过 ServiceResponse 对象返回所有结果,并执行纵深防御授权。
非服务对象应该在其他地方创建,例如在 lib 中。
ServiceResponse
服务类通常有一个 execute 方法,它可以返回一个 ServiceResponse。你可以使用 ServiceResponse.success 和 ServiceResponse.error 在 execute 方法中返回响应。
在成功的情况下:
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),以表达更丰富的域概念。
表示域概念的实体和值对象被视为域模型。
一些示例:
DesignManagement::DesignAtVersion是一个利用验证来组合设计和版本的模型。Ci::Minutes::Usage是一个值对象,为给定命名空间提供计算使用量。
Model class methods
这些是由 _GitLab 本身_定义的类方法,包括 Active Record 提供的以下方法:
findfind_by_iddelete_alldestroydestroy_all
任何其他方法,如 find_by(some_column: X) 都不包括在内,而是属于 “Active Record” 抽象。
Model instance methods
由 GitLab 本身 在 ActiveRecord 模型上定义的实例方法。Active Record 提供的方法不包括在内,但以下方法除外:
saveupdatedestroydelete
Active Record
Active Record 本身提供的 API,如 where 方法、save、delete_all 等。
Worker
app/workers 中的所有内容。
使用 SomeWorker.perform_async 或 SomeWorker.perform_in 来安排 Sidekiq 作业。切勿直接使用 SomeWorker.new.perform 调用 worker。