リラクのサーバサイド事情 with Scala - Re.Ra.Ku アドベントカレンダー day 2
Re.Ra.Ku アドベントカレンダー 2日目です。
こんにちは。ヘルステックチームの近藤です。開発では主にサーバサイドを担当しています。
ところでみなさん、リラクのサーバサイドに使われているプログラミング言語をご存知でしょうか?ご存知の方は通ですね。ご存知でない方はこれを機に興味を持っていただけると幸いです。リラクではScalaを採用しています。
ですので、サーバサイド担当の私の話はほとんどがScalaの話です。しょうがないですよね。そもそも私Scala大好きですし。
ということで、リラクのサーバサイド事情 with Scalaと称して、Re.Ra.Ku アドベントカレンダー 2日目もといRe.Ra.Ku Scalaアドベントカレンダー 1日目です。と言いつつ、サーバサイドの一般的な話が多めなので、Scala成分は少し薄めです。
ちょとした方針
サーバサイド事情とか言っていますが、特に変わったことはしていません。クライアントからのリクエストはHTTPで受け付けていますし、データベースにはRDBを利用しています。よくあるものです。
ただ弊社では以下の方針を執っています。
JSONしか喋らない
サーバサイドと言うと範囲が広いのですが、実際はほぼアプリ(クライアント)のAPIサーバーしか書いていません(あとは非同期処理用のワーカーとか)。ですので、HTTPのレスポンスボディはJSONです。
ただし、アプリはスマートフォンアプリに限らずウェブアプリも指しています。ウェブアプリは静的ファイルによるSPAになっていて、そのファイルをScalaから出力することはありません。
よって本当にJSONしか喋りません。
操作指向のURL
JSONしか喋らないとは言え、プロトコルはHTTPですので、当然リクエストメソッドやURLという概念があります。
APIサーバーを書くときにRESTを採用することって結構あると思います。http://example.com/users/1 というURLで「ユーザID: 1のユーザー」というリソースを表し、そのURLへのGETで情報の取得、PUTで更新、DELETEで削除といったやつですね。
しかし弊社ではRESTのようなリソース指向のURLにせず、操作指向のURLにしています。
操作指向のURLとは何か。「ユーザID: 1のユーザー」の情報を取得する場合は GET http://example.com/getUser?id=1 とし、更新する場合は POST http://example.com/updateUser とするものです。
なぜ操作指向のURLを採用したか、ですが:
- リソース指向のURLでは表現に無理が生じる(ときがある)*1
- クライアントが求めるものは画面に表示するための情報(レポート)であり、リソース指向だとひとつのAPIで得られる情報に過不足が生じる
あたりが理由です。もちろん広く公開するAPIならば誰が利用するか分からないのでリソース指向になるでしょう。しかし、クライアントが求めている情報が明確なのであれば、それ専用のAPIを作ってしまった方が得策です。そうなってくると操作指向のURLの方が都合が良くなってきます。
アーキテクチャ
アーキテクチャは一応レイヤードアーキテクチャを採用しています。何故「一応」なのかと言うと、使っている言葉はレイヤードアーキテクチャのものなのですが、依存関係逆転の原則などを用いて依存方向を厳密にしているので、実態はオニオンアーキテクチャやクリーンアーキテクチャの様相を呈しているためです。が、一応レイヤードアーキテクチャと言っておきます。
ドメイン層はDDDによってモデリングしています。
アプリケーション層はドメイン層のモデルを活用してクライアントが直接利用するアプリケーションサービス(ユースケース)を実現しています。
プレゼンテーション層はHTTPリクエストを解釈し、アプリケーションサービスが求める形に変換、アプリケーションサービスから得られた結果をHTTPレスポンスに変換しています。
そしてインフラストラクチャ層はドメイン層のインタフェースを実装しています(RDBへの永続化、外部ウェブサービスとのHTTP通信など)。
レイヤードアーキテクチャを採用した理由ですが、少なくともトランザクションスクリプトやアクティブレコードパターンでは自身の首を締めることになるだろう、という考えがあったからで、あまり積極的な理由はありません。実際今のアーキテクチャで不自然なところもあったりするので、それは今後の開発で次第に変えていくと思います。
プロジェクトの分割
ビルドツールにはsbtを採用しています。ですので、ディレクトリ構造もsbtに準拠しています。
ただし、よほど単純なものでない限りは必ずマルチプロジェクトにします。以下は実際に使っている project/build.scala
を簡略化したものです。
// project/build.scala import sbt._ import sbt.Keys._ import org.scalatra.sbt._ import org.scalatra.sbt.PluginKeys._ import com.earldouglas.xwp.JettyPlugin object ServerBuild extends Build { val serverSettings = Defaults.coreDefaultSettings ++ Seq( organization := "jp.co.reraku", version := "0.0.1", scalaVersion := "2.11.8" ) // ルートプロジェクト: 他のすべてのプロジェクトをまとめているプロジェクト lazy val server = Project( id = "server", base = file("."), settings = serverSettings ) aggregate(domain, jdbcImpl, appApi, adminApi) // アプリ用APIプロジェクト: スマートフォンアプリに提供するAPI用プロジェクト(要件によって名前が変わる) lazy val appApi = Project( id = "app_api", base = file("app_api"), settings = serverSettings ++ ScalatraPlugin.scalatraSettings ++ Seq( parallelExecution in Test := false, libraryDependencies ++= Seq( // Scalatraなどに依存 ) ) ) dependsOn(domain, jdbcImpl) enablePlugins JettyPlugin // 管理画面用APIプロジェクト: 管理画面ウェブアプリに提供するAPIプロジェクト(要件によって名前が変わる) lazy val adminApi = Project( id = "admin_api", base = file("admin_api"), settings = serverSettings ++ ScalatraPlugin.scalatraSettings ++ Seq( parallelExecution in Test := false, libraryDependencies ++= Seq( // Scalatraなどに依存 ) ) ) dependsOn(domain, jdbcImpl) enablePlugins JettyPlugin // JDBC実装プロジェクト: ドメイン層のインタフェースをJDBCで実装したクラス群がまとまったプロジェクト lazy val jdbcImpl = Project( id = "jdbc_impl", base = file("jdbc_impl"), settings = serverSettings ++ Seq( parallelExecution in Test := false, libraryDependencies ++= Seq( // ScalikeJDBCなどに依存 ) ) ) dependsOn domain // ドメインプロジェクト: ドメインモデルを表しているプロジェクト lazy val domain = Project( id = "domain", base = file("domain"), settings = serverSettings ++ Seq( libraryDependencies ++= Seq( // Joda-Timeなどの基本的な値を表現するライブラリのみに依存(フレームワークなどの詳細に依存しない) ) ) ) }
実際は serverSettings
にscalac用のオプションを設定したり、sbt-scalariformを設定していたりします。もちろんプロジェクトの数も要件に合わせて増減します。
ですがコアはさきほどの project/build.scala
のようになっており、ドメイン層をひとつのプロジェクトとして分離しているところが肝です。
サーバサイドにおいて、管理画面から登録したデータはアプリ側から参照され、アプリ側から記録したデータは管理画面で閲覧されるように、アプリ側APIと管理画面側APIの核となるビジネス要件、つまりドメインモデルは同一でないと都合が悪いです。そのため各APIごとにGitリポジトリを分けるといったことはしていません。以前1度だけそのように開発しましたが、各Gitリポジトリのドメインモデルがほとんど似たコードになってしまいました。
ではひとつのGitリポジトリかつひとつのプロジェクトで開発をするのか。いや、コードの規模が膨らむにつれて大変なことになるのが目に見えます。ですので、ひとつのGitリポジトリで、複数のプロジェクトに分ける。そして各ユースケース群(例えばアプリ側APIと管理画面側API)から共通して参照され、操作を行うドメインモデルはそれひとつでプロジェクトとして切り離す、という風にしました。
ちなみにドメインモデル以外にも共通で参照されるものがあります。それはドメインモデルの実装です。jdbcImplプロジェクトがそれに該当します。共通で参照されるものであればdomainプロジェクトに含めてしまっても、と考えるかもしれませんが、domainプロジェクトはドメインモデルの集合で、何を用いて永続化するのか、という詳細を知るべきではありません。よって各実装もそれぞれひとつのプロジェクトに切り離しています。jdbcImplはその例のひとつですね。
このように分けると、各プロジェクトがどのライブラリに依存するべきなのかはっきりしてきます。domainプロジェクトはさきほども述べた通り、詳細を知るべきではありません。よってdomainプロジェクトの依存ライブラリにScalatraやScalikeJDBCが含まれていたら危険信号というわけです。プロジェクトを分割することによって、こういった効果も生まれます。
アプリケーション層
それでは各層がどのように実装されているかコードで示していきます。ちなみにドメイン層はDDDの話が絡み、複雑になってくるので、今回触れずに次回触れてみようと思います。よってまずはアプリケーション層から。
package application.user import domain.user._ import infrastructure.jdbc.user._ import scalikejdbc._ class UpdateUserNameCommand(val userId: UserId, val name: String) { // 引数のバリデーションを行う } object UpdateUserNameApplicationService { val userRepo: UserRepository[DBSession] = new JDBCUserRepository() def apply(command: UpdateUserNameCommand): User = DB localTx { implicit session => val user = userRepo.find(command.uesrId) val nameUpdatedUser = user.updateName(command.name) userRepo.store(nameUpdatedUser) nameUpdatedUser } }
アプリケーションサービスはドメインモデルを操作し、クライアントのユースケースを実現するためにあります。APIで言うと各APIひとつひとつがアプリケーションンサービスに該当します。上記は「ユーザーの名前を更新する」というユースケースを実現しています。また用いる実装の選択やトランザクションの制御もアプリケーション層が担います。よってdomainとinfrastructure.jdbc、そして実装の詳細(scalikejdbcが該当)をインポートしています。
ちょっと特殊なところはUpdateUserNameCommandというクラスでしょうか。これはプレゼンテーション層の処理を簡易にするためにあります。
プレゼンテーション層
ということでプレゼンテーション層です。各クライアントからの入力をアプリケーションサービスの入力に整え、アプリケーションサービスからの出力を各クライアントの出力に整える役割があります。
今回の話で言うと、APIはHTTPにて受け付けるので、入力および出力はすべてHTTPになります。弊社ではウェブアプリケーションフレームワークとしてScalatraを採用しています。よってHTTPの解釈に関してはScalatraに委ねており、入力および出力はすべてScalatraのオブジェクトをどうこうする形になります。
以下がScalatraからの入力をアプリケーションサービスの入力に変換する例です。
package presentation.user import application.user._ import domain.user._ import org.scalatra._ import org.json4s._ object UpdateUserNameInbound { case class Body(userId: String, name: String) private implicit val jsonFormats: Formats = DefaultFormats def apply(context: ScalatraContextWrapper[ScalatraBase with JsonSupport[_]]): UpdateUserNameCommand = { val body = context.parsedBody.extract[Body] UpdateUserNameCommand( userId = UserId(body.userId), name = body.name ) } }
ScalatraContextWrapperに関しては詳しい説明を割愛します。ざっくり言うと、Scalatraからの入力を1枚クラスで包んだものです。
大切なことはそのScalatraからの入力を解釈し、アプリケーションサービスが求める入力に変換する点です。これがもし別のウェブアプリケーションフレームワークを使うことになったらそれ用の変換を行うオブジェクトを追加してあげれば良い、ということになります。
次にアプリケーションサービスからの出力をScalatraの出力に変換する例です。Scalatraの出力、まあHTTPの出力になるわけですが、ActionResultというおあつらえ向きのものがあるので、それを出力とします。
package presentation.user import application.user._ import domain.user._ import org.scalatra._ object UpdateUserNameOutbound { case class Body(userId: String, name: String) def apply(command: UpdateUserNameCommand, user: User): ActionResult = Ok { Body( userId = user.id.toString, name = user.name ) } }
本来はアプリケーションサービス内で発生した例外のハンドリングも行うのですが、それについては省略しています。
入力と同様、大切なことはアプリケーションサービスからの出力をScalatraが求める出力に変換する点です。その点のみに終始した十分にシンプルなコードだと思います。
アプリケーション層と同様に、インポートしているパッケージに注目すると、application(と一部ドメインモデルのクラスを利用するためにdomain)とその詳細(org.scalatraやorg.json4s)の2種類だけです。決してinfrastructure.jdbcなどには依存しません。
これで:
- Scalatraからの入力をUpdateUserNameCommandに変換
- UpdateUserNameCommandをアプリケーションサービスが受け取り、Userを返す
- UserをScalatraへの出力に変換
という流れが出来ました。ですので:
package presentation import presentation.user._ import application.user._ import org.scalatra._ import org.scalatra.json._ class UserServlet extends ScalatraServlet with JacksonJsonSupport { private implicit val formats: Formats = DefaultFormats before { contentType = formats("json") } post("/UpdateUserName") { val command = UpdateUserNameInbound(ScalatraContextWrapper(this)) val user = UpdateUserNameApplicationService(command) UpdateUserNameOutbound(command, user) } }
のようにScalatraのルールに則って書いてあげると動作します。
ちなみに実際のコードではこれと同じことを実現するために様々なインタフェースを定義したり、もっと簡便に書けるようになっています。
インフラストラクチャ層
インフラストラクチャ層はドメイン層のあらゆる詳細を実装する役割を担います。以下はdomain.user.UserRepository[X]のScalikeJDBC実装例です(UserTableやUserRecordは実際のテーブルと密接に結び付いたクラス)。
package infrastructure.jdbc.user import domain.user._ class JDBCUserRepositroy extends UserRepository[DBSession] { def find(id: UserId)(implicit session: DBSession): User = { val u = UserTable.u val user = withSQL { select.from(UserTable as u).where.eq(u.id, id.value) }.map(UserRecord(u)).single.apply user getOrElse { throw new EntityNotFoundException(s"$userId is not found") } } def store(user: User)(implicit session: DBSession): Unit = if (exists(user.id)) { UserTable.update(user.id, user.name) } else { UserTable.insert(user.id, user.name) } def exists(id: UserId)(implicit session: DBSession): Boolean = try { find(id) true } catch { case _: EntityNotFoundException => false } }
うーん普通のコードですね。まあそれだけ驚きが少ないということで。
最後に
サーバサイドの事情としてAPIの細かい方針やらプロジェクトの概観をだらだらと述べてみました。実際のコードはもっと複雑ですし、核となるドメイン層のコードはまったく掲載していません。いやー実際のコードおもしろいんですけどね。でもまるまる掲載するとあれもこれも紹介したくなってしまい、2日目にしてやたらと長大な記事が上がってしまいます。うーん断念です。
ですので、次はドメイン層がどうなっているか紹介していければなと思います。それでは。
*1:私は常々ログイン/ログアウトを http://example.com/session へのPOST/DELETEで表現することに疑問がありました