こんにちは、delyコマース事業部エンジニアの小川です。
先月11月に入社し、エキサイティングな毎日を過ごしています。
この記事はdely Advent Calendar 2019 - Qiitaの24日目の記事です。
昨日はSREの松嶋さんが「AWS RunCommandを使ってEC2上に監視ダッシュボードをサクッと作る(Ansible+Terraform+Grafana編)」という記事を書いてくれましたので是非そちらも読んでみてください!
コマース事業部では、現在「事業開発」と「ソフトウェア開発」がほぼ同時に進行しており、プロジェクトにおける確定要素と不確定要素が複雑に絡み合っています。 スピード重視でゴリゴリ実装していくのも興奮しますが、変化に耐えづらい実装をしてしまうと、その後の開発スピードに影響していまい、事業のスピードが落ちるなんて事にもなりかねません。
そこで、プロジェクトの保守性や拡張性をあげるには、どういった設計をしたら良いかを、OSSであるGitLabを一例として見ていきたいと思います。
今回は、model view controller 以外のどんなディレクトリ(=クラス)を導入しているか、それがどんな役割を担っているかを見ていきましょう。
それではいってみましょう。
GitLabが導入しているディレクトリ
GitLabはRailsで実装されています。app配下のディレクトリで、デフォルトで作成されないものを抽出すると以下のようになりました。
- finders
さまざまな条件に基づいたコレクションを取得するクラス
- graphql
Graphqlに関するクラス
- policies
権限確認系に使われるクラス。独自のDSLで実装されている。
- presenters
viewに関わるロジックやデータを持つオブジェクトをviewに提供するクラス
- serializers
フロントで使われるJSONを構築するためのビジネスルールをカプセル化しているクラス
- uploaders
CarrierWaveに依存しているUploader
- services
ビジネスロジックが取りまとめられているクラス
- validators
Activerecordが提供しているカスタムバリデーター
- workers
SidekiqのWorkerクラス
今回はこの中から、個人的にあまり導入したことがない、finders、presentersあたりを見ていきます。
finders
finderクラスは「さまざまな条件に基づいたコレクションを取得する」責務を持つクラスになっています。
例えば、プロジェクトモデルの中でこのようなイシューを取得するメソッドを実装するより、
class Project def issues_for_user_filtered_by(user, filter) # たくさんのロジック... end end issues = project.issues_for_user_filtered_by(user, params)
下記のように実装すると、よりモデルを薄く保つことができるよ!っていうイメージですね。
issues = IssuesFinder.new(project, user, filter).execute
GitLabのFinderクラスは、基本的に#executeのみをパブリックメソッドとして持っているみたいです。
実装を見ていく
では実際にProjectsFinderを例に挙げて見ていきましょう。
まずはどこで#executeが実行されているか探してみます。 ありました、Admin::ProjectsControllerで以下のように実行されています。
ProjectsFinder.new(params: finder_params, current_user: current_user) .execute .includes(:route, :creator, :group, namespace: [:route, :owner]) .preload(:project_feature) .page(finder_params[:page])
paramsにfinder_paramsを、current_userにはcurrent_userを指定しています。 find_paramsは、取得するプロジェクトの条件、つまりフィルタリングするパラメーターや、ソートの条件などのパラメーターを含めています。
では、ProjectsFinderの実装はどうなっているのでしょうか。
#initializeには、paramsとcurrent_userとproject_ids_relationをキーワード引数で渡します。
def initialize(params: {}, current_user: nil, project_ids_relation: nil) @params = params @current_user = current_user @project_ids_relation = project_ids_relation end
#excuteの実装は以下のような形です。
def execute user = params.delete(:user) collection = if user PersonalProjectsFinder.new(user, finder_params).execute(current_user) # rubocop: disable CodeReuse/Finder else init_collection end collection = filter_projects(collection) sort(collection) end
変数collectionに、フィルタリングのベースになる、プロジェクトのコレクションを代入しています。 その後、#filter_projectsでプロジェクトのフィルタリングをおこなった後、#sortにて結果のソートをおこなっていました。
フィルタリングとソートの条件は、#initializeの時に渡したparamsで指定しています。
メリットになりそうな事
パブリックメソッドである#executeが見通しがよく、コードリーディングしやすいと感じました。 デルメルの法則にも違反していなく、依存も少ない(浅い?)と言えそうです。
このコレクションの取得を、modelに#filterのようなメソッドで実装したらどうなるでしょうか? ProjectsFinderに実装されている、多くのprivateメソッドがmodelにも実装されることになります。しかもそのメソッド達は、#filterの結果を達成するために切り出されている(特に他のメソッドでは使われない)ロジックなので、そのメソッド達はfat modelになってしまう要因の一つだと思います。
取得のロジックが単純なうちは良いですが、上記まで複雑になってきたり、必要なパラメーターが増えてきたら、Finderクラスの実装を考えていいかもしれません。
ですが、プロジェクトの初期段階でも、取得系の実装が大きくなることが分かっている、かつ不確定要素がたくさんありそうならば、取得系のロジックをFinderに集約し、呼び出し側が変更に影響しないように実装するのもありだと思いました。
presenters
presenterは、viewに関わるロジックやデータを持つオブジェクトをviewに提供するクラスとなっています。 viewに直接書いてあるロジックや、modelにviewに関連するロジックやデータのメソッドは、presenterに実装します。
実装をみていく
使われ方を見てみます。今回はProjectPresenterを見ていきます。 presenterはviewで下記のように呼び出されていました。
-# @projectはProjectPresenterのオブジェクト = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
パーシャルのreder時に、引数として ProjectPresenter#statistics_anchors の返り値を渡しています。 renderされているパーシャルは、
- anchors = local_assigns.fetch(:anchors, []) - return unless anchors.any? %ul.nav - anchors.each do |anchor| %li.nav-item = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.is_link ? 'nav-link stat-link d-flex align-items-center' : "nav-link btn btn-#{anchor.class_modifier || 'missing'} d-flex align-items-center" do .stat-text.d-flex.align-items-center= anchor.label
のようになっています。
anchorsで渡されたデータを使って、リンクを生成していますね。 このパーシャルは、#link, #label, #is_link, #class_modifierのメソッドもつオブジェクトに依存しています。
では次に、ProjectPresenter#statistics_anchorsの実装見てみます。 下記はProjectPresenterの実装の一部です。
class ProjectPresenter < Gitlab::View::Presenter::Delegated presents :project AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon) def statistics_anchors(show_auto_devops_callout:) [ commits_anchor_data, branches_anchor_data, tags_anchor_data, files_anchor_data ].compact.select(&:is_link) end def commits_anchor_data AnchorData.new(true, statistic_icon('commit') + n_('%{strong_start}%{commit_count}%{strong_end} Commit', '%{strong_start}%{commit_count}%{strong_end} Commits', statistics.commit_count).html_safe % { commit_count: number_with_delimiter(statistics.commit_count), strong_start: '<strong class="project-stat-value">'.html_safe, strong_end: '</strong>'.html_safe }, empty_repo? ? nil : project_commits_path(project, repository.root_ref)) end end
ProjectPresenter#statistics_anchorsは#commits_anchor_dataや、#branches_anchor_dataを集めて返しています。
#commits_anchor_dataは、構造体であるAnchorDataを生成・返却しています。ProjectPresenterは、Gitlab::View::Presenter::Delegated(Rubyの標準のSimpleDelegatorを使用)を継承しているので、Projectのインスタンスメソッドである、#statisticsにもアクセスできます。
ちなみに、#branches_anchor_dataなど、#*_anchor_dataという命名のメソッドは全てAnchorDataを返却していました。
他にもオブジェクトの生成の仕方や、URL生成のヘルパーなど、しっかり作り込まれているのですが、長くなりそうなので割愛します。 オブジェクト生成の部分を端的に紹介すると、直接ProjectPresenter.newすることを禁止しており、下記のようなパターンで生成するようにしています。
# presentメソッドで生成するパターン @project.present # Factoryクラスを使って生成するパターン Gitlab::View::Presenter::Factory.new(@project).fabricate!
メリットになりそうな事
viewの表示のルールがpresenterに集約されていて読みやすいと思いました。 表示してはいけないものを表示してしまったなどの事故が起きにくくなりそうです。viewがコンフリクトした時の恐怖からバイバイできるのも個人的に好きです。
また、viewに比べかなりテストしやすくなっています。
導入するときには、presenterがどういった粒度で実装されるかをチームで決めておいた方が良いかもしれません。 GitLabはmodelに対してpresenterが実装されている風に見受けられました。(models/project.rbに対して、presenters/project_presenter.rbのイメージ)
プロジェクトによって、違う粒度での実装もありえると思います。例えば、viewで関連データが複雑かつ、多くのところで使われているパーシャルがあったら、それと1対1で対応させるようにすれば幸せになれそうですね。コントローラーにも同じロジックを書かなくて済むし、変更に対する影響範囲も明確です。もしコマース事業部で導入するとしたら、draperを使ったDecoratorとどんな住み分けするか(そもそも導入しない)とか、チーム内で議論してみたいです。
最後に
今回はこんな風にGitLabのクラス設計を見ていきました。 プロダクトの特性によって設計の仕方も変わってくると思うので、違うOSSも読んでみるのも楽しいと思います。
「保守性がモリモリ上がるクラス設計」と題しましたが、クラス設計に銀の弾丸はない気がするので、プロダクトの変化しやすい部分はどこか、設計することでどんな問題を解決したいかを、チーム内で議論を重ねて実装・検証することが良さそうです。
コマース事業部では事業も開発も挑戦することが多く、エンジニア・デザイナーを強く募集しています。 もし興味がある方がいましたら、お気軽にご連絡ください!