はじめに
こんにちは!MHWIにミラボレアスが来るのを楽しみにしているほんまです@w@
アドウェイズのアドテクチームでは以前投稿したScalaでマイクロサービス化を進めるために考えたことで紹介したヘキサゴナルアーキテクチャの考えを取り入れたプロジェクトをベースに複数のサービスでScalaを使って開発を行って来ました。各サービスで独自の改善が行われていたりベースプロジェクトの設計思想と異なる実装がされていたりしたので改めてサンプルプロジェクトとドキュメントを作成したので記事にしたいと思います。
今回はヘキサゴナルアーキテクチャではなく、より実用的に内外の階層の分離され境界線を跨ぐデータの取扱いが明確に提案されているクリーンアーキテクチャの考えを参考にしました。
ベースプロジェクトの設計思想と異なる実装がされている課題についてはArchUnitというテスト用ライブラリを使ってテストでアーキテクチャを担保する仕組みを導入しました。
依存性のルール
図.1
図の同心円はエデータベースを使ったウェブベースのJavaシステムの典型的なシナリオを示している。ソフトウェアのさまざまな領域を表している。一般的には、円の中央に近くほどソフトウェアのレベルが上がっていく。円の外側は仕組み。内側は方針である。
このアーキテクチャを動作させる最も重要なルールは、依存性のルールである。
ソースコードの依存性は、内側(上位レベルの方針)だけに向かっていなければいけない。
円の内側は外側について何も知らない。特に外側で宣言された名前は、内側にあるコードで触れてはいけない。これは、関数、クラス、変数その他の名前付きソフトウェアエンティティが含まれる。
同様に、外側で宣言されたデータフォーマットは、内側から使ってはいけない。外側のフレームワークで生産されたフォーマットは特にそうだ。円の外側にあるものから内側にあるものに影響を及ぼしたくない。
4つの円
図.1の同心円は、概念を示したものです。この4つ以外にも必要なものがあれば増やしても良いです。ただし、依存性のルールは常に適用されます。
エンティティ
エンティティはビジネスルールをカプセル化したものです。どのユースケースからでもエンティティを同じロジックで操作できるようにメソッドを持たせます。必要があればコレクションオブジェクトを作成して配列やオプションについても共通のロジックを呼び出せるようにします。
最上位のビジネスルール(方針)をカプセル化したもので外部(ページのナビゲーションやセキュリティ)に変更があっても影響を受けることはありません。
ユースケース
ユースケースは名前の通りユースケースの機能、振る舞いをカプセル化したものです。エンティティに入出力するデータの流れを調整し、ユースケースの目的を達成できるようにエンティティを作成、操作します。
ユースケースは外部(データベース、UI、フレームワーク)の変更の受けないように分離します。
インターフェースアダプター
インターフェースアダプターは内部(ユースケース、エンティティ)のためのデータフォーマットと外部(データベース、ウェブ)のためデータフォーマットに相互変換を行います。MVCアーキテクチャ(プレゼンター、ビュー、コントローラー)を保持するのはこのレイヤーになります。
データベースがSQLであれば、全てのSQLはこのレイヤー(特にデータベースに関連する部分)に限定する必要があります。
フレームワークとドライバ
最も外側の円は、フレームワークやツールで構成されています。例えばデータベースやウェブフレームワークなどです。通常このレイヤーにはコードをあまり書かず、簡単なラッパーを書きます。
ライブラリにインターフェースがあればそれをそのまま使います。
境界線の越え方
図.1の右下に、円の境界線をどのように越えるべきか例が示されています。コントローラーとプレゼンターは、次のレイヤーのユースケースと通信しています。制御の流れに注目するとコントローラーから始まり、ユースケースを経由して、最後にプレゼンターで実行されています。ソースコードの依存関係に注目するとそれぞれ内側のユースケースに向かっていることがわかります。
この対立は依存関係逆転の法則(DIP)を使って解消します。Scalaではtraitを使用して、境界線を越えたところでソースコードの依存関係が制御の流れと逆転するようにします。
境界線を越えるデータ
境界線を越えるデータは、単純なデータ構造にします。境界線を越えてデータを渡すときは、常に内側の円にとって便利な形式にします。
具体例
図.2
図.2はデータベースを使ったウェブベースのJavaシステムの典型的なシナリオを示しています。
図.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
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の方法やテストツールを使って制限する方法を考えました。まだパッケージの構成やクラスの配置場所は改善の余地がありそうなので実際に開発を進めていく上で改善していきたいと思います。