抽象化を再利用するためのガイドライン

GitLabが成長するにつれて、コードベース全体でさまざまなパターンが生まれました。サービスクラス、シリアライザー、プレゼンターなどがその一例です。これらのパターンはコードの再利用を容易にしましたが、同時に特定の場所で間違った抽象化を再利用してしまうこともありました。

これらのガイドラインが必要な理由

コードの再利用は良いことですが、時には間違った抽象化を特定のユースケースに押し込んでしまうことがあります。その結果、メンテナー性や、問題を簡単にデバッグする能力、あるいはパフォーマンスにまで悪影響を及ぼす可能性があります。

例えば、ProjectsFinder あるIssuesFinder プロジェクトに属するイシューに限定 IssuesFinder ProjectsFinderするProjectsFinder ためにinを ProjectsFinder使用するような場合です。ProjectsFinder IssuesFinder 当初は良いアイデアのように思えるかもしれませんが、どちらのクラスも非常に高度なインターフェイスを提供し、制御はほとんどできません。 IssuesFinder ProjectsFinderこれは、クエリの大部分がProjectsFinder.NET Frameworkの内部で制御されているため、より最適化されたデータベースクエリを生成できない可能性がある ProjectsFinderことをProjectsFinder IssuesFinder 意味 IssuesFinder ProjectsFinderします。

この問題を回避するには、.NET自身を直接ProjectsFinder使用するのではなく、. ProjectsFinderNETで使用されているのと同じコードを使用します。これにより、よりよい動作を 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)

これは単なるスケッチですが、一般的なアイデアを示しています。GroupProjectsFinderProjectsFinder のファインダがフードの下で使っているものは何でも使います。

最終目標

このドキュメントのガイドラインは、コードの再利用を促進_する_ためのものです。抽象を明確に分けることで、間違った抽象を使うことが難しくなり、コードのデバッグが容易になり、(うまくいけば)パフォーマンスの問題も少なくなります。

抽象化

では、利用可能なさまざまな抽象化レベルと、それらが再利用できる(またはできない)ものを見てみましょう。次の表は、さまざまな抽象化と、それらが再利用できる(できない)ものを定義したものです:

抽象度サービスクラスファインダープレゼンターシリアライザーモデルインスタンスメソッドモデルクラスのメソッドActive Recordワーカー
コントローラ/APIエンドポイントはいはいはいはいはいなしなしなし
サービスクラスはいはいなしなしはいなしなしはい
ファインダーなしなしなしなしはいはいなしなし
プレゼンターなしはいなしなしはいはいなしなし
シリアライザなしはいなしなしはいはいなしなし
モデルクラスのメソッドなしなしなしなしはいはいはいなし
モデルインスタンスメソッドなしはいなしなしはいはいはいはい
ワーカーはいはいなしなしはいなしなしはい

コントローラ

app/controllers

コントローラはそれ自身ではあまり仕事をせず、他のクラスに入力を渡し、結果を提示します。

APIエンドポイント

lib/api (REST API) とapp/graphql (GraphQL API)のすべて。

API エンドポイントはコントローラと同じ抽象度を持ちます。

サービスクラス

app/services に存在するすべてのもの。

サービスクラスは、モデル(エンティティや値オブジェクトなど)間の変更を調整するオペレーションを表します。変更はアプリケーションの状態に影響を与えます。

  1. オブジェクトがアプリケーションの状態を変更しない場合、それはサービスではありません。ファインダーや値オブジェクトかもしれません。
  2. オペレーションがない場合、サービスを実行する必要はありません。このクラスは、エンティティ、値オブジェクト、またはポリシーとして設計するのがよいでしょう。

サービスクラスを実装するときは、次のことを考慮してください:

  1. サービスクラスのイニシャライザは、その引数の中にコンテナを含む必要があります:
    1. 処理されるモデルのインスタンス。イニシャライザの最初の位置引数。引数の名前は開発者の裁量に任されています:issue project,merge_request.
    2. サービスがユーザーによって開始されたアクションを表す場合、またはユーザーのコンテキストで実行される場合、イニシャライザーはcurrent_user: キーワード引数を current_user:持たなければなりません。引数をcurrent_user: 持つサービスは current_user:高レベルのビジネスロジックを実行し、そのオペレーションを実行するためにユーザーの承認を検証する必要があります。
    3. サービスがユーザーコンテキストを持たず、ユーザーによって直接開始されない場合(バックグラウンドサービスや副作用など)、current_user: 引数は必要ありません。これは低レベルドメインロジックまたはインスタンスワイドロジックを記述します。
    4. サービスが必要とするすべての追加データについては、明示的なキーワード引数を推奨します。サービスがあまりにも長い引数のリストを必要とする場合、それらを分割することを検討してください:
      • 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
      
  2. サービスクラスの振る舞いを呼び出す、1つの公開インスタンスメソッド#execute を実装しています:
    • #execute メソッドは引数を取りません。必要なデータはすべてイニシャライザに渡されます。
    • オプション。必要であれば、#execute メソッドは、ServiceResponseを介してその結果を返します。

いくつかの基本クラスはサービスクラス規約を実装しています。から継承することを検討してください:

  • BaseContainerService コンテナ(プロジェクトまたはグループ)によってスコープされるサービス用。
  • BaseProjectService プロジェクトにスコープされたサービス用。
  • BaseGroupService グループにスコープされたサービスの場合。

サービスオブジェクトではないクラスは、lib などの内部で作成する必要があります。

サービスレスポンス

サービスクラスには通常execute メソッドがあり、ServiceResponse を返すことができます。ServiceResponse.successServiceResponse.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)を載せて、よりリッチなドメインコンセプトを表現することもできます。

ドメイン概念を表すエンティティやバリューオブジェクトはドメインモデルとみなされます。

いくつかの例を示します:

モデルクラスのメソッド

これらは_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 メソッド、savedelete_allなど、Active Record 自体が提供する API。

ワーカー

app/workers

Sidekiqジョブのスケジュールには、SomeWorker.perform_async またはSomeWorker.perform_in を使用してください。決してSomeWorker.new.performを使用して直接ワーカーを起動しないでください。