Edited at

集合に対する操作を共通化する方法 -ファーストクラスコレクション-


集合に対する操作を共通化する方法


はじめに

最近の自分の中で流行っていることをまとめたよ。


この内容を読んでできるようになること


集合(リストやマップなど)に対する操作に意味をもたせる

もともと集合に対する操作として用意されているものは, 粒度が細かすぎたり, 具体的すぎたりして, ソフトウェアを使う人にとってどんな意味があるのかが分かりづらい. ここでは集合に対しての操作をユーザにとっての意味がある粒度でまとめることにする.


集合(配列、リストなど)に対する操作を限定する

集合を表すクラス(リストやマップ)には予め様々なメソッドが定義されている. しかし, ビジネスロジック上使わせたくないメソッドもあるはずだ. 例えばある集合に対して集計を行うというユースケースでは, 集合に対して書き込みを行う必要はない. 逆に書き込みできる状態がバグを生む可能性がある. ここで紹介する実装方法を使えば, 集合に対する操作を限定することができる.


記事で扱う例

GitHub上で発生したイベント(Pushイベント)の回数をリポジトリごとに集計して, TwitterにシェアするWebアプリケーションを想定する.


アーキテクチャ

以下をモジュールで分けたマルチモジュール構成を想定する.

モジュール名
説明

web
ブラウザのIOを担うwebパッケージとアプリの仕様を実現するapplicationパッケージがある.

domain
エンティティやらモデルによるビジネスロジックを表現する.

infrastructure
ドメイン層で定義されたインターフェースを実装する.


仕様の概要

GitHub上で発生したEventの件数をGitリポジトリごとに集計するロジックをコードで実現する.

コードは

なお, GitEventクラスは以下のように定義している.


domain.GitEvent.scala

class GitEvent(val gitRepository: GitRepository, val eventType: GitEventType)


また, GitEventClientのgetUserEventsメソッドを通じて, GitEventのコレクションを取得できる.

※ ここではClientクラスの実装は割愛し, traitのコードを載せています.


domain.client.GitEventClient.scala

trait GitEventClient {

def getUserEvents(gitAccount: GitAccount): Seq[GitEvent]
}


Before:アプリケーションサービスを作成して集計をする.


コード

私は最初, アプリケーションサービスを使って集計をしていました. おそらく業務でも, アプリケーションサービスでデータアクセス層のメソッドを呼び出し, 取得したデータに対して加工をすることが多いのではないでしょうか.


web.application.GitActivitySummaryService.scala

class GitActivitySummaryService(){

def summarize(userId: UserId): Seq[GitActivitySummary] = {
val accountService: GitAccountService = new GitAccountService()
val gitAccount = accountService.getByUserId(userId)

// 取得したGitアカウント情報をもとにイベントを取得.
val eventClient: GitEventClient = new GitHubEventClient()
val gitEvents: Seq[GitEvent] = eventClient.getUserEvents(gitAccount)

// リポジトリごとにグルーピングして, リポジトリの情報, 回数を持つインスタンスのコレクションを生成
events.groupBy(e => e.gitRepository).map(e => new GitActivitySummary(e._1, e._2.size)).toSeq
}
}



問題点

このコードの最大の問題点は, イベントの集計ロジックが色々な場所で実装されうるということです.

例えば, 今まではWeb上でリアルタイムの集計だけ行っていたが, 以下のような要求が生まれたらどうすればいいだろうか.


  • 1週間毎にGitHub上で発生したイベントを集計し, 自動でTwitterにシェアする.

  • batchとwebは別々にスケーリングさせるため, 別々の成果物を生成し, 別のサーバにデプロイする.

webアプリとbatchではリソースへの権限が異なるため, batchモジュールを作成することになった.

※ Webはユーザ自信のリソース対してしか権限を持たないが, batchは全ユーザのリソースに対して権限を持つことになる.

batchでもwebアプリ同様の集計ロジックを必要とするが, webアプリ実装時にwebモジュール内にロジックを書いたため, batchから参照することができない.

わたし「仕方ない, batchモジュールに同じクラスをコピペするか.」

みんな「待て待て待て.」


ファーストクラスコレクションによる実装


コード

Gitのイベントを集計するのを自動化しようとしているんだから, 集計するロジックってこのアプリにおける最大の関心事といってもいいんじゃないか. そう思いdomainモジュールにGitEventsクラスを作成した.


domain.gitEvents.scala

class GitEvents (private val events: Seq[GitEvent]) {

def countByRepository() = {
val gitActivitySummaries = events.groupBy(e => e.gitRepository).map(e => new GitActivitySummary(e._1, e._2.size)).toSeq
new GitActivitySummaries(gitActivitySummaries)
}
}

GitEventClientでもGitEventsクラスのインスタンスを返却するようにしたので, Seq[GitEvent]をGitEventsクラスの外部では直接操作できなくなった. オブジェクト指向におけるカプセル化ってこういうことなんじゃないかな.


domain.client.GitEventClient.scala

trait GitEventClient {

def getUserEvents(gitAccount: GitAccount): GitEvents
}


余談

以下のようにフィールドのコレクションのメソッドをラッパークラス経由で公開すれば, フィールドのコレクションを直に扱っているかのように操作させることも可能.

def foreach(f: GitEvent => Unit) = events.foreach(f)

def map[T](f: GitEvent => T) = events.map(f)

他にも必要に応じてフィールドのコレクションに定義されているメソッドを実装すると, コレクションを直に操作しているかのように, 呼び出し元のクラスにAPIを提供できる.

iteratorメソッドを実装すればfor式を書くこともできる.

def iterator = events.iterator

for ( event <- events) {
println(event.eventType)
}