CleanArchitectureについて自分なりに整理して実装してみた

はじめに

 こんにちは!MHWIにミラボレアスが来るのを楽しみにしているほんまです@w@

 アドウェイズのアドテクチームでは以前投稿したScalaでマイクロサービス化を進めるために考えたことで紹介したヘキサゴナルアーキテクチャの考えを取り入れたプロジェクトをベースに複数のサービスでScalaを使って開発を行って来ました。各サービスで独自の改善が行われていたりベースプロジェクトの設計思想と異なる実装がされていたりしたので改めてサンプルプロジェクトとドキュメントを作成したので記事にしたいと思います。
 今回はヘキサゴナルアーキテクチャではなく、より実用的に内外の階層の分離され境界線を跨ぐデータの取扱いが明確に提案されているクリーンアーキテクチャの考えを参考にしました。
 ベースプロジェクトの設計思想と異なる実装がされている課題についてはArchUnitというテスト用ライブラリを使ってテストでアーキテクチャを担保する仕組みを導入しました。

依存性のルール

f:id:AdwaysEngineerBlog:20200918174948p:plain

図.1

 図の同心円はエデータベースを使ったウェブベースのJavaシステムの典型的なシナリオを示している。ソフトウェアのさまざまな領域を表している。一般的には、円の中央に近くほどソフトウェアのレベルが上がっていく。円の外側は仕組み。内側は方針である。
 このアーキテクチャを動作させる最も重要なルールは、依存性のルールである。

ソースコードの依存性は、内側(上位レベルの方針)だけに向かっていなければいけない。
 円の内側は外側について何も知らない。特に外側で宣言された名前は、内側にあるコードで触れてはいけない。これは、関数、クラス、変数その他の名前付きソフトウェアエンティティが含まれる。
 同様に、外側で宣言されたデータフォーマットは、内側から使ってはいけない。外側のフレームワークで生産されたフォーマットは特にそうだ。円の外側にあるものから内側にあるものに影響を及ぼしたくない。

Clean Architecture 達人に学ぶソフトウェアの構造と設計

4つの円

 図.1の同心円は、概念を示したものです。この4つ以外にも必要なものがあれば増やしても良いです。ただし、依存性のルールは常に適用されます。

エンティティ

 エンティティはビジネスルールをカプセル化したものです。どのユースケースからでもエンティティを同じロジックで操作できるようにメソッドを持たせます。必要があればコレクションオブジェクトを作成して配列やオプションについても共通のロジックを呼び出せるようにします。
 最上位のビジネスルール(方針)をカプセル化したもので外部(ページのナビゲーションやセキュリティ)に変更があっても影響を受けることはありません。

ユースケース

 ユースケースは名前の通りユースケースの機能、振る舞いをカプセル化したものです。エンティティに入出力するデータの流れを調整し、ユースケースの目的を達成できるようにエンティティを作成、操作します。
 ユースケースは外部(データベース、UI、フレームワーク)の変更の受けないように分離します。

インターフェースアダプター

 インターフェースアダプターは内部(ユースケース、エンティティ)のためのデータフォーマットと外部(データベース、ウェブ)のためデータフォーマットに相互変換を行います。MVCアーキテクチャ(プレゼンター、ビュー、コントローラー)を保持するのはこのレイヤーになります。
 データベースがSQLであれば、全てのSQLはこのレイヤー(特にデータベースに関連する部分)に限定する必要があります。

フレームワークとドライバ

 最も外側の円は、フレームワークやツールで構成されています。例えばデータベースやウェブフレームワークなどです。通常このレイヤーにはコードをあまり書かず、簡単なラッパーを書きます。
 ライブラリにインターフェースがあればそれをそのまま使います。

境界線の越え方

 図.1の右下に、円の境界線をどのように越えるべきか例が示されています。コントローラーとプレゼンターは、次のレイヤーのユースケースと通信しています。制御の流れに注目するとコントローラーから始まり、ユースケースを経由して、最後にプレゼンターで実行されています。ソースコードの依存関係に注目するとそれぞれ内側のユースケースに向かっていることがわかります。
 この対立は依存関係逆転の法則(DIP)を使って解消します。Scalaではtraitを使用して、境界線を越えたところでソースコードの依存関係が制御の流れと逆転するようにします。

境界線を越えるデータ

 境界線を越えるデータは、単純なデータ構造にします。境界線を越えてデータを渡すときは、常に内側の円にとって便利な形式にします。

具体例

f:id:AdwaysEngineerBlog:20200918175127p:plain 図.2

 図.2はデータベースを使ったウェブベースのJavaシステムの典型的なシナリオを示しています。

f:id:AdwaysEngineerBlog:20200918175027p:plain 図.3

 図.3は図.2を少し変更してplay frameworkを使ってデータベースにアクセスしJSONを返すシナリオを示しています。図.3を例に具体的な実装例を示します。名前は図.3とは無理に一致させずに役割と境界線だけを一致させます。表.1に図.3とソースコードの対応表を示します。

表.1

図.3での名称 ソースコードでの名称
Controller MediaController ※1 MediaTransform
InputData MediaInput
InputBundary MediaUseCase
UseCaseIntercator MediaUseCaseImpl
OutputData MediaOutput
Entities Media
DataAccessInterface MediaRepository
DataAccess MixinMediaRepository
MediaRepositoryImpl
MediaMySQLRepository ※1 MediaTransform
MediaMyRedisRepository
Database MySQL
Redis

Controller

MediaController

 コントローラーでは、リクエストデータをユースケースに渡すデータ構造に変換しユースケースのメソッドを呼び出します。コントローラーからRepositoryを参照できないようにすることで間違ってコントローラーではデータソースからデータ取得ができないようにしています。また、EntityではなくInputDataを使用しているのでコントローラーではエンティティのロジックを呼び出すことができないようにしています。

package net.adways.infrastructure.controllers

import net.adways.infrastructure.transformers.MediaTransform
import javax.inject._
import play.api.mvc._
import net.adways.usecase.MixinMediaUseCase

@Singleton
class MediaController @Inject() (cc: ControllerComponents)
  extends AbstractController(cc)
  with MixinMediaUseCase {

  def index(): Action[AnyContent] = Action { implicit request: Request[AnyContent] =>
    val medias = mediaUseCase.getMedias()
    MediaTransform.mediasResponse(medias)
  }

  def get(mediaId: Int): Action[AnyContent] = Action { implicit request: Request[AnyContent] =>
    val mediaInput = MediaTransform.mediaRequest(mediaId)
    val media = mediaUseCase.getMedia(mediaInput)
    MediaTransform.mediaResponse(media)
  }
}

InputData

MediaInput

 コントローラーから取得できるデータをユースケースに渡すためのデータ構造です。

package net.adways.usecase

case class MediaInput(mediaId: Int)

InputBoundary

MediaUseCase 

 コントローラーから呼び出すことができるメソッドのみを定義しています。ユースケースが複雑になりメソッドを複数に分割した場合にコントローラーから全てを参照できないように制御しています。

package net.adways.usecase

trait MediaUseCase {
  def getMedias(): Seq[MediaOutput]
  def getMedia(mediaInput: MediaInput): Option[MediaOutput]
}

UseCaseInteractor

MixinMediaUseCase & MediaUseCaseImpl

 MixinMediaUseCase はコントローラーにインジェクトするための変数を用意しています。コントローラーにユースケースのインターフェースとして認識させるために変数の型は MediaUseCase にします。
 MediaUseCaseImpl にはユースケースの実際の処理を実装します。エンティティの生成、データソースに対するCRUD、エンティティのロジックの呼び出しを行いユースケースの目的を達成させます。
 データソースに対するCRUDはユースケースで行うのでユースケースでリポジトリーをインジェクトします。

package net.adways.usecase

import net.adways.infrastructure.datastores.MixinMediaRepository

trait MixinMediaUseCase {
  val mediaUseCase: MediaUseCase = MediaUseCaseImpl
}

object MediaUseCaseImpl extends MediaUseCase with MixinMediaRepository {
  override def getMedia(mediaInput: MediaInput): Option[MediaOutput] = {
    val media = mediaRepository.getMedia(mediaInput.mediaId)
    media.map(e => MediaOutput(mediaId = e.mediaId))
  }

  override def getMedias(): Seq[MediaOutput] = {
    val medias = mediaRepository.getMedias()
    medias.map(e => MediaOutput(mediaId = e.mediaId))
  }
}

OutputData

MediaOutput

 ユースケースからコントローラーへデータを返すためのデータ構造です。

package net.adways.usecase

case class MediaOutput(mediaId: Int)

Entities

Media

 ビジネスルールに沿ったメソッドを定義します。必要があれば、コレクションオブジェクトを定義して配列やオプションに対するビジネスルールに沿ったメソッドを定義します。

package net.adways.domain.models

import java.time.ZonedDateTime

case class Media(
  mediaId: Int,
  hashcode: String,
  updateTime: ZonedDateTime,
  createTime: ZonedDateTime)

DataAccessInterface

MediaRepository

 ユースケースから呼び出すことができるメソッドのみを定義している。実際のデータソースに依存しなようにインターフェースとして定義している。

package net.adways.domain.repositories

import net.adways.domain.models.Media

trait MediaRepository {
  def getMedia(mediaId: Int): Option[Media]
  def getMedias(): Seq[Media]
}

DataAccess

MixinMediaRepository & MediaRepositoryImpl

 MixinMediaRepository はユースケースにインジェクトするための変数を用意しています。ユースケースにリポジトリーのインターフェースとして認識させるために変数の型は MediaRepository にします。
 1つのモデルに対してメソッド単位でデータソースを切り替えられるように MediaRepositoryImpl には実際のCRUDの処理を実装せず、各データソース毎にCURDの処理を実装したtraitをMixinします。 リポジトリーのインターフェースを継承しているのでMixinした各データソースのいずれかでインターフェースのメソッドを実装しなければなりません。また、複数のデータソースで同じメソッドを実装した場合はコンパイル時に inherits conflicting members のエラーが発生するので検出できます。

package net.adways.infrastructure.datastores

import net.adways.domain.repositories.MediaRepository
import net.adways.infrastructure.datastores.mysql.MediaMySQLRepository
import net.adways.infrastructure.datastores.redis.MediaRedisRepository

trait MixinMediaRepository {
  val mediaRepository: MediaRepository = MediaRepositoryImpl
}

object MediaRepositoryImpl extends MediaRepository with MediaMySQLRepository with MediaRedisRepository

MediaMySQLRepository

 MySQLに対する実際のCRUD処理を実装します。

package net.adways.infrastructure.datastores.mysql

import net.adways.domain.models.Media
import net.adways.infrastructure.transformers.MediaTransform
import slick.jdbc.MySQLProfile.api._

import scala.concurrent.duration.Duration.Inf
import scala.concurrent.Await

trait MediaMySQLRepository extends MySQL {
  def getMedias(): Seq[Media] = {
    val sql =
      sql"""
           select
             media_id,
             hashcode,
             update_time,
             create_time
           from
             media""".as[Media](MediaTransform.mediaGetResult)
    Await.result(db.run(sql), Inf)
  }
  def getMedia(mediaId: Int): Option[Media] = {
    val sql =
      sql"""
           select
             media_id,
             hashcode,
             update_time,
             create_time
           from
             media
           where
             media_id = $mediaId
        """.as[Media](MediaTransform.mediaGetResult).headOption
    Await.result(db.run(sql), Inf)
  }
}

MediaRedisRepository

 Redisに対する実際のCRUD処理を実装します。
※サンプルなので実際にRedisは用意していません。
getMedias が重複実装にならないようにコメントアウトしています。

package net.adways.infrastructure.datastores.redis

import java.time.{ZoneId, ZonedDateTime}

import net.adways.domain.models.Media

trait MediaRedisRepository extends Redis {
  // def getMedias(): Seq[Media] = Seq(Media(
  //   mediaId    = 1,
  //   hashcode   = "hashcode",
  //   updateTime = ZonedDateTime.now(ZoneId.of("Asia/Tokyo")),
  //   createTime = ZonedDateTime.now(ZoneId.of("Asia/Tokyo"))))
}

Database

 ライブラリのインスタンスの生成を行います。必要があればライブラリのラッパーを実装します。ライブラリのインスタンスを RepositoryImpl でMixinするtrait以外から参照できないようにprivateにします。

MySQL

package net.adways.infrastructure.datastores.mysql

private[mysql] trait MySQL {
  protected val db = MySQLDatabase.db
}

private object MySQLDatabase {
  val db = slick.jdbc.MySQLProfile.api.Database.forConfig("db")
}

Redis

※サンプルなので実際にRedisは用意していない。

package net.adways.infrastructure.datastores.redis

private[redis] trait Redis {
  protected val redis = RedisClient.redis
}

private object RedisClient {
  val redis = 1
}

※1 MediaTransform

 境目を越えるデータの変換を行います。JSONの変換など共通の変換がある可能性があるのでコントローラーやデータソース毎に実装せずに共通クラスに実装しています。

package net.adways.infrastructure.transformers

import java.time.ZoneId

import net.adways.domain.models.Media
import io.circe.Encoder
import io.circe.generic.semiauto._
import io.circe.syntax._
import play.api.http.ContentTypes.JSON
import play.api.mvc.{Result, Results}
import slick.jdbc.GetResult
import net.adways.usecase.{MediaInput, MediaOutput}

object MediaTransform {
  def mediaRequest(mediaId: Int): MediaInput = {
    MediaInput(mediaId = mediaId)
  }

  implicit val encoder: Encoder[MediaOutput] = deriveEncoder
  def mediaResponse(mediaOutput: Option[MediaOutput]): Result = {
    Results.Ok(mediaOutput.asJson.toString).as(JSON)
  }

  def mediasResponse(mediasOutput: Seq[MediaOutput]): Result = {
    Results.Ok(mediasOutput.asJson.toString).as(JSON)
  }

  val mediaGetResult = GetResult(r =>
    Media(
      mediaId    = r.<<[Int],
      hashcode   = r.<<[String],
      updateTime = r.nextTimestamp.toLocalDateTime.atZone(ZoneId.of("Asia/Tokyo")),
      createTime = r.nextTimestamp.toLocalDateTime.atZone(ZoneId.of("Asia/Tokyo"))))
}

ディレクトリ構造

app/
└── net
    └── adways
        ├── domain
        │   ├── models
        │   │   └── Media.scala
        │   └── repositories
        │       └── MediaRepository.scala
        ├── infrastructure
        │   ├── controllers
        │   │   └── MediaController.scala
        │   ├── datastores
        │   │   ├── MediaRepositoryImpl.scala
        │   │   ├── mysql
        │   │   │   ├── MediaMySQLRepository.scala
        │   │   │   └── MySQL.scala
        │   │   └── redis
        │   │       ├── MediaRedisRepository.scala
        │   │       └── Redis.scala
        │   └── transformers
        │       └── MediaTransform.scala
        └── usecase
            ├── MediaUseCase.scala
            └── MediaUseCaseImpl.scala

ArchUnitを使ってアーキテクチャのレイヤー構造を担保する

Installation with Other Test Frameworks

build.sbtに下記を追加

libraryDependencies += "com.tngtech.archunit" % "archunit" % "0.14.1" % Test

Getting Started

1.Importing Classes

val packageClasses: JavaClasses = new ClassFileImporter().importPackages("com.mycompany.myapp")

2.Asserting (Architectural) Constraints

アーキテクチャルールを定義し、インポートしたクラスに対してチェックします。

import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;

// ...

// アーキテクチャルールの定義
val myRule: ArchRule = classes()
    .that().resideInAPackage("..service..")
    .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..")

// インポートしたクラスに対してチェック
myRule.check(importedClasses)

What to Check

CleanArchitectureで使えそうなものを抜粋しました。

LayerChecks

f:id:AdwaysEngineerBlog:20200918175343p:plain

layeredArchitecture()
    .layer("Controller").definedBy("..controller..")
    .layer("Service").definedBy("..service..")
    .layer("Persistence").definedBy("..persistence..")

    .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
    .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
    .whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service")

ArchUnit x ScalaTestの実装例

package architecture

import com.tngtech.archunit.core.domain.JavaClasses
import com.tngtech.archunit.core.importer.ClassFileImporter
import org.scalatest.{FlatSpec, Matchers}
import com.tngtech.archunit.library.Architectures.layeredArchitecture

class ArchTest extends FlatSpec with Matchers {
  val rootPackage = "net.adways"
  val domainPackage = s"$rootPackage.domain"
  val infrastructurePackage = s"$rootPackage.infrastructure"
  val packageClasses: JavaClasses = new ClassFileImporter().importPackages(rootPackage)

  it should "layeredArchitecture" in {
    layeredArchitecture()
      .layer("Models").definedBy(s"$domainPackage.models")
      .layer("Repositories").definedBy(s"$domainPackage.repositories")
      .layer("Controllers").definedBy(s"$infrastructurePackage.controllers..")
      .layer("DataStores").definedBy(s"$infrastructurePackage.datastores..")
      .layer("Transformers").definedBy(s"$infrastructurePackage.transformers..")
      .layer("UseCase").definedBy(s"$rootPackage.usecase..")

      .whereLayer("Models").mayOnlyBeAccessedByLayers("Transformers", "UseCase")
      .whereLayer("Repositories").mayOnlyBeAccessedByLayers("DataStores", "UseCase")
      .whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
      .whereLayer("DataStores").mayOnlyBeAccessedByLayers("UseCase")
      .whereLayer("Transformers").mayOnlyBeAccessedByLayers("Controllers", "DataStores")
      .whereLayer("UseCase").mayOnlyBeAccessedByLayers("Controllers", "Transformers")

      .check(packageClasses)
  }
}

さいごに

 お疲れさまでした。ここまで読んでいただいてありがとうございます。
今回はクリーンアーキテクチャを参考にしつつ各レイヤーの役割(やるべき事)を明確にし、やるべき事が正しいレイヤーで行われるようにmixinの方法やテストツールを使って制限する方法を考えました。まだパッケージの構成やクラスの配置場所は改善の余地がありそうなので実際に開発を進めていく上で改善していきたいと思います。

参考文献

www.amazon.co.jp

www.m3tech.blog

nrslib.com

qiita.com