抽象化を再利用するためのガイドライン
GitLabが成長するにつれて、コードベース全体でさまざまなパターンが生まれました。サービスクラス、シリアライザー、プレゼンターなどがその一例です。これらのパターンはコードの再利用を容易にしましたが、同時に特定の場所で間違った抽象化を再利用してしまうこともありました。
これらのガイドラインが必要な理由
コードの再利用は良いことですが、時には間違った抽象化を特定のユースケースに押し込んでしまうことがあります。その結果、メンテナー性や、問題を簡単にデバッグする能力、あるいはパフォーマンスにまで悪影響を及ぼす可能性があります。
例えば、ProjectsFinder
あるIssuesFinder
プロジェクトに属するイシューに限定 IssuesFinder
ProjectsFinder
するProjectsFinder
ためにinを ProjectsFinder
使用するような場合です。ProjectsFinder
IssuesFinder
当初は良いアイデアのように思えるかもしれませんが、どちらのクラスも非常に高度なインターフェイスを提供し、制御はほとんどできません。 IssuesFinder
ProjectsFinder
これは、クエリの大部分がProjectsFinder
.NET Frameworkの内部で制御されているため、より最適化されたデータベースクエリを生成できない可能性がある ProjectsFinder
ことをProjectsFinder
IssuesFinder
意味 IssuesFinder
ProjectsFinder
します。
この問題を回避するには、.NET自身を直接ProjectsFinder
使用するのではなく、. ProjectsFinder
NETで使用されているのと同じコードを使用します。これにより、よりよい動作を Composer で構成できるようになり、コードの動作をより制御できるようになります。
例として、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)
ここでは、3つの異なるアプローチを使って、どのプロジェクトにデータをスコープするかを決定しています。グループが指定されると、GroupProjectsFinder
を使ってそのグループのすべてのプロジェクトを取得します。これは表面的には無害に見えます。
実際には、すぐに面倒なことになります。例えば、GroupProjectsFinder
が生成するクエリは、最初はシンプルなものです。時間の経過とともに、この(高レベルの)インターフェースにどんどん機能が追加されていきます。それは必要な_場合だけに_影響するのではなく、IssuableFinder
にも悪影響を及ぼし始めるかもしれません。例えば、GroupProjectsFinder
が生成するクエリには不要な条件が含まれる可能性があります。ここではファインダーを使用しているので、その動作を簡単にオプトアウトすることはできません。オプションを追加することもできますが、その場合は機能の数だけオプションが必要になります。すべてのオプションが2つのコードパスを追加するので、4つの機能のために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 | ワーカー |
---|---|---|---|---|---|---|---|---|
コントローラ/APIエンドポイント | はい | はい | はい | はい | はい | なし | なし | なし |
サービスクラス | はい | はい | なし | なし | はい | なし | なし | はい |
ファインダー | なし | なし | なし | なし | はい | はい | なし | なし |
プレゼンター | なし | はい | なし | なし | はい | はい | なし | なし |
シリアライザ | なし | はい | なし | なし | はい | はい | なし | なし |
モデルクラスのメソッド | なし | なし | なし | なし | はい | はい | はい | なし |
モデルインスタンスメソッド | なし | はい | なし | なし | はい | はい | はい | はい |
ワーカー | はい | はい | なし | なし | はい | なし | なし | はい |
コントローラ
app/controllers
。
コントローラはそれ自身ではあまり仕事をせず、他のクラスに入力を渡し、結果を提示します。
APIエンドポイント
lib/api
(REST API) とapp/graphql
(GraphQL API)のすべて。
API エンドポイントはコントローラと同じ抽象度を持ちます。
サービスクラス
app/services
に存在するすべてのもの。
サービスクラスは、モデル(エンティティや値オブジェクトなど)間の変更を調整するオペレーションを表します。変更はアプリケーションの状態に影響を与えます。
- オブジェクトがアプリケーションの状態を変更しない場合、それはサービスではありません。ファインダーや値オブジェクトかもしれません。
- オペレーションがない場合、サービスを実行する必要はありません。このクラスは、エンティティ、値オブジェクト、またはポリシーとして設計するのがよいでしょう。
サービスクラスを実装するときは、次のことを考慮してください:
- サービスクラスのイニシャライザは、その引数の中にコンテナを含む必要があります:
- 処理されるモデルのインスタンス。イニシャライザの最初の位置引数。引数の名前は開発者の裁量に任されています:
issue
project
,merge_request
. - サービスがユーザーによって開始されたアクションを表す場合、またはユーザーのコンテキストで実行される場合、イニシャライザーは
current_user:
キーワード引数をcurrent_user:
持たなければなりません。引数をcurrent_user:
持つサービスはcurrent_user:
高レベルのビジネスロジックを実行し、そのオペレーションを実行するためにユーザーの承認を検証する必要があります。 - サービスがユーザーコンテキストを持たず、ユーザーによって直接開始されない場合(バックグラウンドサービスや副作用など)、
current_user:
引数は必要ありません。これは低レベルドメインロジックまたはインスタンスワイドロジックを記述します。 - サービスが必要とするすべての追加データについては、明示的なキーワード引数を推奨します。サービスがあまりにも長い引数のリストを必要とする場合、それらを分割することを検討してください:
-
params
:直接代入されるモデルのプロパティを持つハッシュ。 -
options
:モデル・プロパティではなく、処理が必要な)追加パラメータを含むハッシュ。ハッシュはoptions
インスタンス変数に格納する必要があります。
# merge_request: A model instance that is being acted upon. # assignee: new MR assignee that will be assigned to the MR # after the service is executed. def initialize(merge_request, assignee:) @merge_request = merge_request @assignee = assignee end
# issue: A model instance that is being acted upon. # current_user: Current user. # params: Model properties. # options: Configuration for this service. Can be any of the following: # - notify: Whether to send a notification to the current user. # - cc: Email address to copy when sending a notification. def initialize(issue:, current_user:, params: {}, options: {}) @issue = issue @current_user = current_user @params = params @options = options end
-
- 処理されるモデルのインスタンス。イニシャライザの最初の位置引数。引数の名前は開発者の裁量に任されています:
- サービスクラスの振る舞いを呼び出す、1つの公開インスタンスメソッド
#execute
を実装しています:-
#execute
メソッドは引数を取りません。必要なデータはすべてイニシャライザに渡されます。 - オプション。必要であれば、
#execute
メソッドは、ServiceResponse
を介してその結果を返します。
-
いくつかの基本クラスはサービスクラス規約を実装しています。から継承することを検討してください:
-
BaseContainerService
コンテナ(プロジェクトまたはグループ)によってスコープされるサービス用。 -
BaseProjectService
プロジェクトにスコープされたサービス用。 -
BaseGroupService
グループにスコープされたサービスの場合。
サービスオブジェクトではないクラスは、lib
などの内部で作成する必要があります。
サービスレスポンス
サービスクラスには通常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
.
ファインダー
app/finders
のすべて。通常、データベースからデータを取得するために使用されます。
ファインダは、生成するSQLクエリをより適切に制御するために、他のファインダを再利用することはできません。
ファインダーのexecute
メソッドはActiveRecord::Relation
を返すべきです。spec/support/finder_collection_allowlist.yml
詳細は#298771
を参照してください。
プレゼンター
インスタンス変数を多数作成することなく、Railsビューに複雑なデータを公開するために使用される、app/presenters
のすべて。
詳しくはドキュメントをご覧ください。
シリアライザー
app/serializers
内のすべてで、リクエストに対するレスポンスを、通常は JSON で表示するために使用されます。
モデル
app/models
のクラスとモジュールは、データと振る舞いの両方をカプセル化したドメイン概念を表します。
これらのクラスは(ActiveRecordモデルのように)データストアと直接やり取りすることもできますし、ActiveRecordモデルの上に薄いラッパー(Plain Old Ruby Objects)を載せて、よりリッチなドメインコンセプトを表現することもできます。
ドメイン概念を表すエンティティやバリューオブジェクトはドメインモデルとみなされます。
いくつかの例を示します:
-
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
where
メソッド、save
、delete_all
など、Active Record 自体が提供する API。
ワーカー
app/workers
。
Sidekiqジョブのスケジュールには、SomeWorker.perform_async
またはSomeWorker.perform_in
を使用してください。決してSomeWorker.new.perform
を使用して直接ワーカーを起動しないでください。