2023.02.15
Kotlin-Ktorフレームワークで学ぶクリーンアーキテクチャ
次世代システム研究室のT.Sです。私はいわゆる機械学習エンジニアとしての役割を担っており、データサイエンティストと実装の橋掛けを良くしております。その中でDSの方から「ソースコード実装するときにどうやってClassを分けたらいいのか迷うんだよね…」という悩みを共有していただき、であればちょっと基礎的な考え方を共有したく今回のBlogを執筆しようかと至った次第です。
というわけで今回はKotlin/Ktorを利用した簡単なget/postのAPIを、クリーンアーキテクチャにある程度従ったもので組んだ場合の1サンプルをご紹介しようかと思います。(厳密にはこんなのクリーンアーキテクチャじゃない!というお声は100も承知ですがサンプルだと思いご容赦ください…)
またKtorのType-safe routingのktor-server-resourcesを利用したサンプルコードもあまり見かけなかったのでそちらのサンプルも合わせてご参考にいただければ幸いです
Clean Architecture
Clean Architectureとは、ソフトウェアの設計パターンの一つです。システムをレイヤー上に分割した上で、それぞれのレイヤーが単一の責任を持ち関心を分離することによって、依存関係を一方向にして、変更や置き換えを用意にする設計パターンのことをさします。ある程度ソフトウェア設計に関わったかたであれば下記の概念図を見たことがあるのではないでしょうか?
(出典: The Clean Code Blog)
日本語書籍ですと[Clean Code アジャイルソフトウェア達人の技]が有名ですね。ただこの書籍自体はそれなりに難しいもので、読んだ上で実線に移すとなるとプラスで色々学ばなければいけないのではないかというの実感としてあります。
今回のモチベーションは冒頭であったように、Class分けなどにあまりなれていない方向けということもあり、もう少しわかりやすいものベースで説明したいところです。そこで今回は(APIではそのまま使えるものではないですが)基本的な考えとしてAndoridのアプリアーキテクチャガイドラインの考え方をちょっとお借りしようかと思います。
Andorid アーキテクチャガイドラインとは、品質の高い堅牢なAndroidアプリを作成するためのおすすめの方法/アーキテクチャに関する指針です。Androidアプリのためのものなので、そのままAPIに適用できるものではないのですが、Clean Architecture的なモジュールの分け方を理解するにはかなりわかりやすいもののため今回はこちらをちょっと利用させていただ効果と思います。主にData LayerとDomain Layerのところになります。
さあというわけで早速コードを書いてきましょう!
実装
インフラ部分
というわけまずは実態と関係ないDB接続などのインフラ周りを整備しましょう。ここがないと何も動かないですからね… こちらは上記Androidアプリアーキテクチャガイドラインの範囲とはちょっと違うかなと思いますので、Ktorのお作法(?)に従って書いていこうかと思います
まず今回利用するDBを立ち上げましょう。単純にdocker-composeで立ち上げるのが楽だとおもうので、下記をdocker-compose.ymlに記載して
version: "3.7" services: postgres: image: postgres:latest ports: - "5432:5432" environment: - POSTGRES_USER=root - POSTGRES_PASSWORD=root - POSTGRES_DB=root
下記コマンド立ち上げてしまいましょう。
docker-compose up
これでposgresql環境が用意できましたね。次はKtorプロジェクトを作成し、このDBに接続する定義を作成します
まずKtorとはなんぞや?というお話ですが、これはJetBrainsが開発したKotlin純正の軽量サーバサイドライブラリとなります。KotlinでサーバサイドライブラリだとSpring Bootもよく使われていますが、Kotlinを使うのであればやはりKotlin純正をつかいたい…と思い今回はKtorを採用しています。処理もCoroutinesベースだと軽いというお話もありますしね。
Ktorの開発にはIntelliJ IDEAを使うことが多いのですが、Ktorプロジェクトを作成するにはUltimateが必要となります。ただCommunity版もWebでプロジェクトを作る機能を提供しているようです。それがhttps://start.ktor.ioでして、こちらで作成した上でIntelliJで開けばすぐに利用可能となります
今回は使いませんが認証やGSONなどのJson周りのPluginも選択できるので、必要に応じて追加ください
作成、Importできれば開発環境完成です。早速DB接続定義を書いていきましょう。設定をresources配下に下記のように作成します。慣れ親しんだposgresqlの接続定義ですね
# main/resources/application.conf app { database { className = "org.postgresql.Driver" url = "jdbc:postgresql://localhost:5432/root" user = "root" password = "root" } }
定義を作成したので次はDatabaseへ接続しましょう。こういったDatabaseの処理を司るものとして今回はDatabseFactoryを作ります。場所としてはこなものを用意しました。
├── main │ ├── kotlin │ │ ├── infrastructure │ │ │ ├── module │ │ │ └── DatabaseFactory.kt
内容としてはこういった形ですね.先程の定義をenv.config.property経由で呼び出してDatabase.connectするだけの簡単なものです。のちのち使うためのdbQuery用の関数も用意しております
object DatabaseFactory { fun init( env: ApplicationEnvironment, ) { val driverClassName = env.config.property("app.database.className").getString() val jdbcURL = env.config.property("app.database.url").getString() val db = Database.connect( jdbcURL, driverClassName, user = env.config.property("app.database.user").getString(), password = env.config.property("app.database.password").getString(), ) transaction(db) { // TODO: 後ほど実装 } } suspend fun <T> dbQuery(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() } }
あとはこれを呼び出しましょう.ここはKtorで用意されているApplication.ktからCallして以上終了です。こちらはあまり語ることもない事前準備なのでささっと書いて終了としましょう
// infrastructure/Application.kt fun Application.module() { DatabaseFactory.init(environment) }
Data Layer
Data Sources
さあこれで下準備は整ったのでガイドラインに従って書いていきましょう!まずはデータレイヤーになります。DataLayerもData SourcesとRepositoryに分かれていますね。矢印の接続からみてわかるようにData Sourcesを直接公開することはなく、Repository経由になります。というわけでまずData Sourcesから書いていきましょう
(出典: Android アプリアーキテクチャガイドライン)
Data SourcesはファイルやAPI、ローカル データベースなどそれぞれ1つのデータソースのみを取り扱うよう記載します。今回はDB(postgresql)しかいないのであまり意識しないでもいいのですが、AndoridだとLocalのsqliteやSharedPreferencesだったり、リモートのAPIだったり様々用意されるのですが、それぞれ毎に作るわけです。
Data Sourcesを外のレイヤーに直接参照させず、間にRepositoryが挟まることで外側のビジネスロジックなどのレイヤーは、データソースに影響されなくなることが利点の一つです。あるときにデータソースがDB->APIになったとしてもUI側は影響をうけずRepositoryで吸収しちゃえばいいとうわけですね。
では早速作っていきましょう.今回は単純に受け取ったMessageをただStringで格納するだけのテーブルを想定します。一先ずこれを格納するdata classを作成します. ただの入れ物ですね
パッケージ構成としては私はmodelというものを作りここに入れることをよくやります。このパッケージの役割としてはデータ構造を定義し、全員に公開する役割になります。enumとかのデータ意味定義に関わるものも入れてしまいますね。enumなどの外部データソースに関わないものに関しては、クリーンアーキテクチャを違反して、UIやViewModelから直接見てしまうこともあります(Repositoryをすべて作ると逆に煩雑になるので…). ある意味そういった外部に公開するものとして扱うことで綺麗さを保とうとしております
├── main │ ├── kotlin │ │ ├── model
data classはこんな感じですね
// model/Message.kt data class Message( val id: Int, val message: String, )
API側の入れ物としてはこれでいいのですがプラスDatabaseのテーブル定義も作っておきましょう。見通しがいいようにファイルは同じにしています。
// model/Message.kt object Messages: Table() { val id = integer("id").autoIncrement() val message = varchar("message", 256) override val primaryKey = PrimaryKey(id) }
これで入れ物とそれに紐づくDatabase定義を作成することができました。なおこの定義があれば先程作成したDatabaseFactory内からCreate Table/Add Columnなどを実行することもできます。
// model/Message.kt object DatabaseFactory { fun init( env: ApplicationEnvironment, ) { ... transaction(db) { SchemaUtils.create(Messages) SchemaUtils.createMissingTablesAndColumns(Messages) } }
これでコード側とDatabase側の入れ物は完成しました!ただData Layer/Data Sourcesの役割は[あるデータソースからデータを取得し、これをRepositoryに渡す]ことにあります。今のままでは入れ物しか無いのでこれが達成されていませんね。というわけでデータベスから接続して値を取得するものを作りましょう。
場所は先程のmodelとは変えてdata/dbとします。modelの役割としてはenumのようなものも含めデータ定義を公開する、といったものですがこちらはデータベースと接続して値を取得するという役割になり、modelとは全く違うものになります。そのため専用のパッケージにしてあげる必要があるためこうしています。modelがある程度公開範囲が広いものになっているのに対して、こちらはrepositoryのみに公開しているという範囲の違いからもこうしています
├── main │ ├── kotlin │ │ ├── data │ │ │ ├── db
さて格納場所が決まったのでファイルを作ってしまいましょう。本来この段階ではUIorControllerからどういった機能が必要かわからないため実装できないのですが、まあ簡単のため今回は一旦決まっているものとして実装してしまいます。今回はGETでidを指定してMessageを表示するものと、POSTでmessageを投げて登録する2つのエンドポイントが必要だとします。
というわけでまずGETの方を実装します。idが飛んでくるため引数でIDを受けて、結果を返すというものが欲しくなりますね。実装は以下のように記載してしまいます。
// data/db/MessageDao.kt class MessageDao { // dbQueryはDatabaseFactory内に記載 // Transactionを発行し、Queryを実行するもの // その中でTableを継承したクラスに selectなどを重ねていくことでクエリが発行される仕組み suspend fun findById(id: Int): Message? = dbQuery { Messages .select { Messages.id eq id } .map(::toMessage) .firstOrNull() } // modelで公開したMessageという箱に詰め替える // ここからもmodelはある程度全体似公開する役目を追わせている private fun toMessage(row: ResultRow) = Message( id = row[Messages.id], message = row[Messages.message], ) }
次にPOSTです。この場合データを作成する機能が必要ですね。
class MessageDao { // Table.insertでInser文が発行できる suspend fun create(body: String): Message? = dbQuery { val insertStatement = Messages.insert { it[Messages.message] = body } // insertした値はresultedValuesで取得可能. これを返す insertStatement .resultedValues ?.singleOrNull() ?.let(::toMessage) } }
Repository
さあこれでDBアクセスもできるようになったし、こいつを呼び出しましょう
…とならないのがClean Architectureっぽさですね。再度AndroidアーキテクチャガイドラインのData Layerを見てみましょう。今作ったData Sourcesを直接公開しておらず、Repositoryというのが間に挟まっていますね。これが割とミソだったりします。
(出典: Android アプリアーキテクチャガイドライン)
先程実装したようにData SourcesはDBに依存しています。今回だとposgresqlにSQLを発行して..という実装をまさにしました。ただこれをUI/Controller側から呼び出したとして、もしDBがAPIに変更になった場合どうなるでしょうか? 当然Data Sources側にAPI用のクラスを作り、これをUI/Controller側の呼び出しを変えて、、、と影響範囲がUI/Controller側にまで及んでしまいます。
これを防ぐためにRepositoryというレイヤを設けます。RepositryではUI/Controller側に(裏側に実装を一切問わない形で)役割だけを公開します。Javaとかでよく使うInterfaceに近い思想だともいってもいいかもしれません。利用側は[IDを渡して、Messageを受け取る]という役割だけを利用します。それによって裏側のデータソースが何かを隠蔽し、そこが切り替わってもUI/Controller側に影響を与えないようにするわけです
といっても言葉だけではなかなか理解しにくいのでコードで見てみましょう。まずはrepositoryを格納するために以下のパッケージを作ります。先程のdbとも公開範囲が違うのでrepositoryという専用のものを作成しました。
├── main │ ├── kotlin │ │ ├── data │ │ │ └── repository
そして役割を公開するRepositoryクラスを作成します。[IDを与えて検索する][emssageを作成する]という役割がほしいのでこの2つが必要になりますね。引数と返り値はこんな感じでしょうか
// data.repository.MessageRepository.kt class MessageRepository { suspend fun findById(id: Int): Message? { } suspend fun create(body: String): Message? { } }
そしてこの各役割に実際に行う処理を追加させます。今回はDaoを利用するのでこんな形になるでしょうか
class MessageRepository : KoinComponent { private val messageDao: MessageDao by inject() suspend fun findById(id: Int): Message? { return messageDao.findById(id) } suspend fun create(body: String): Message? { return messageDao.create(body) } }
これで完成です。もしAPIに変えたい場合はこのDaoをAPI用のクラスに変えてしまえばUI側への影響を極小に抑えられますね!ここからRepositoryを介してあげることでのメリットがなんとなくわかっていただけるかなと思います。
ただ一個見慣れない?ものがあるかと思います。具体的にはここです
class MessageRepository : KoinComponent { private val messageDao: MessageDao by inject() ....
ここはDependency Injection部分になります。先程言ったようにRepository内部はData Sourcesを入れ替えても外部影響を抑えられるようにする役割がありますが、その入れ替えをやりやすくするようにDIで依存性を注入しています。AndroidだとDagger/Hiltが定番ですが、ここではKoinを使っています。
実際に注入する際には先述したApplication.ktなどで定義が必要になってきます。今回はこのように記載しておりますので合わせてコーディングしている方はこちらも忘れずお願いいたします
// infrastructure/module/KoinModule.kt val koinModules = module { single { MessageDao() } }
// infrastructure/Application.kt fun Application.module() { install(Koin) { modules(koinModules) } }
これでDataLayerは完成です。Data Sourcesから実際のデータを取得し、それを緩衝地としてのRepositoryが公開するという思想とメリットがなんとなく伝わっていれば幸いです
Domain Layer
次はドメインレイヤです。ここはOptionalなので省略してもいいのですが、役割としては複雑なビジネスロジック、または再利用される単純なビジネスロジックのカプセル化が役割となります。必要に応じて欲しいなら作る、そうでなければすっ飛ばしてしまうというものになります。AndroidアプリだとViewModelという中にロジックを書くことができるのですが、複雑になるとこのViewModelが肥大化してしまうので、可読性向上の意味も込めてこのDomain Layerを作ることが多々ありますね
(出典: Android アプリアーキテクチャガイドライン)
今回は単にデータをGET/POSTするだけなのでビジネスロジックは全くいりませんがサンプルということで作成してみましょう。役割としては「MessageをGETした際のIDが存在しなければExceptionを投げるというものです
まずクラス名ですがAndroidアプリアーキテクチャでは、「動詞の現在形 + 名詞 / 対象(省略可) + UseCase」と定められているのでこれに従おうと思います。動詞はExceptionを投げるときのValidateとしましょう(Validate関数がMessageを返すというのが気持ち悪いのですが、一旦目をつぶってください..)
また図にあるようにUseCaseが依存するのはRepositoryになります。そのためRepositoryをInjectionで持つようにしましょう
// domain.usecase.message.ValidateMessageIdUseCase.kt class ValidateMessageIdUseCase : KoinComponent { private val messageRepository: MessageRepository by inject() ... }
あとはビジネスロジックを記載すればよいだけですね。AndroidのUseCaseだとinvokeに実装することも多いので、これに従いたいと思います。invoke operatorはメソッド名なく実行できる演算子で、ここに実装すると呼び出し側はvalidateMessageIdUseCase(id)と関数無しで呼び出すことができます。愚直に実装するとこんな感じでしょうか。
// domain.usecase.message.ValidateMessageIdUseCase.kt class ValidateMessageIdUseCase : KoinComponent { private val messageRepository: MessageRepository by inject() suspend operator fun invoke( id: Int, ): Message { return messageRepository .findById(id) ?: throw Exception() } }
ここもでKoinを使ってInjectionしているのでここの定義を加えておきましょう。本来ならDaoとmoduleを分けておいたほうがいいところですが今回は簡単のため一つのままにしておきます
// infrastructure/module/KoinModule.kt val koinModules = module { single { MessageDao() } factory { ValidateMessageIdUseCase() } }
またValidateMessageIdUseCaseはRepositoryにしか依存していないためUnitTestを作るのも簡単ですね
Controllers
さあここまでで処理それぞれは書けたので最後の部分です。AndroidアプリアーキテクチャガイドラインではUIとなっていますが、今回UIはないので原点のClean Architectureに則りControllersとしてこれを書きましょう。
今回はせっかく型付き言語のKotlinを使っているので、Type-safeなRoutingであるResourcesを使って書いてみましょう。詳細は公式ドキュメントをぜひご参照ください。
まずController ClassにResourcesアノテーションをつけます。今回は/messageとしているので、配下の関数も/message/XXXで呼ばれることになります。
// controller.message.MessageController.kt @Resource("/message") class MessageController : KoinComponent { ... }
そしてControllerから利用するUseCase, RepositoryをInjectionさせます。今回はわかりやすくRepository/UseCaseの2つが混在していますが、個人的にはよほど出ない限りUseCaseを作ったほうがどういった処理をするものがあるか一目瞭然なので全てUseCaseを作った方がいいのでは派だったりします。まああまりに単純なものすべてにUseCaseを作るのも面倒ですけどね。。。
// controller.message.MessageController.kt @Resource("/message") class MessageController : KoinComponent { private val validateMessageIdUseCase: ValidateMessageIdUseCase by inject() private val messageRepository: MessageRepository by inject() }
この次からがResoucesの得意技であるType-safeなRoutingの登場です。今回はGET/POSTの2つだとお話しましたがここで受け取るデータの型を定義し、そこにアノテーションをつけることになります。例えばGETの方はこれで/message/{id}でアクセスできるようになるわけですね。一つ注意点なのがPOSTのほうになります。messageを受け取るはずが何も引数がないですよね?これは仕様でしてクエリストリングにつくものはここのdata classの引数となるのでうが、POSTのBody部は別で取得する必要があります。ちょっとここは面倒だなと思いますが致し方なし。もし他の方法があればぜひ教えていただきたいです。
// controller.message.MessageController.kt @Resource("/message") class MessageController : KoinComponent { ... // @Resource("{id}") -> ?id=1とクエリストリングになる // parentが必須なので注意! @Resource("/{id}") data class Id(val parent: MessageController = MessageController(), val id: Int) @Resource("/create") data class NewData(val parent: MessageController = MessageController()) }
あとは実際にGET/POSTを処理するコードを書いてControllerは終わりです。
// controller.message.MessageController.kt @Resource("/message") class MessageController : KoinComponent { ... suspend fun get(id: Int): Message { return validateMessageIdUseCase(id) } suspend fun post(message: String): Message { return messageRepository .create(message) ?: throw Exception() } }
あとはこれらを組み合わせたRoutingの部分を書けば終了です。詳しくは公式ドキュメントを参照いただければと思いますが、routingを使って先程のdata classと関数を組み合わせていく感じですね。まずGETですがこのように書いています。getというGETメソッドを表すものに、先程 @Resource(“/{id}”)を付与したdata classを型として与えることでURL規則と合致するわけです。そこで受け取ったパラメータがを使ってCntroller -> UseCase -> Repository -> Data Sourcesと呼び出され、結果を取得・返しているというわけですね。
// controller.message.MessageRouting.kt fun Application.messageRouting() { install(Resources) val messageController: MessageController by inject() routing { get<MessageController.Id> { message -> val result = messageController.get(message.id) call.respond(transaction { result }) } } }
POSTも基本は同じです。ただ先程述べたようにMessageController.NewDataにはNewDataにはPOSTされた情報が含まれていないので、こちらでParseしているところが違う点ですね。またtransaction制御もtransaction句である程度自由に制御できるところもPointでしょうか
fun Application.messageRouting() { install(Resources) val messageController: MessageController by inject() routing { ... // curl -X POST -H "Content-Type: application/json" -d '{"message" : "XXXX"}' localhost:8080/message/create post<MessageController.NewData> { val input = Json.decodeFromString<Message>(call.receiveText()) val result = messageController.post(input.message) call.respond(transaction { result }) } } }
さあこれでRoutingも含めて完成です!あとはApplication.ktからこれを呼び出しませば完成です。一応他のものも諸々いれているので全体を再掲しておきます
// infrastructure/Application.kt fun Application.module() { install(ContentNegotiation) { jackson { jackson() enable(SerializationFeature.INDENT_OUTPUT) } } install(Koin) { modules(koinModules) } messageRouting() DatabaseFactory.init(environment) }
とういうわけで今回はClean Architecture超入門編+Ktor紹介+Resourcesを使ったType-safe Routing紹介をしてきました。皆様の助けに少しでもなれば幸いです
次世代システム研究室では、プログラミングが大好きなMLエンジニアも募集しています。興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧からご応募をお願いします。
一緒に勉強しながら楽しく働きたい方のご応募をお待ちしております。
グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。
Follow @GMO_RD