Webアプリケーションを作る
これまででPlay Frameworkや非同期処理等について学びましたので、 Webアプリケーションを作るだけの基礎知識が身についたと思います。
それでは、少し要件の大きなWebアプリケーションを開発してみましょう。このたび開発するのは、非同期に画像変換を行うWebアプリケーションです。
画像変換は、変換を行うためにCPUリソースを多く使うため、時間がかかる処理になってしまいます。ただし、Webアプリケーションは、すぐに何かしらのレスポンスを返したほうがユーザーに取っては使いやすいため、そこで非同期処理を利用します。
具体的には画像をアップロードしてキューに積み、非同期にバックエンドで画像の変換を行い、それを別なタイミングでUIに表示するといったタイプのアプリケーションを開発します。
課題の要件
それでは具体的な要件をまとめます。この度は機能要件だけを以下に定義し、非機能要件は非同期に画像処理すること、だけとしたいと思います。なお、非機能要件とは、アプリケーションの可用性・性能・拡張性・運用・保守性・移行性・セキュリティ・環境・エコロジーに関しての要件を指し、主要なアプリケーション機能自体とは異なる要件となります。
この画像変換アプリケーションの機能要件は以下のとおり。
- ユーザーがTwitterのアカウントでログインして認証することができる
- ユーザーがログアウトできる
- ユーザーが非認証状態で最近投稿された画像一覧を見ることができる
- ユーザーが認証された状態で画像にテロップとして任意の文字列を加えることができる
- ユーザーがテロップの文字サイズを変更することができる
- ユーザーが認証された状態でドラッグ&ドロップで画像を投稿することができる
- ユーザーが認証された状態で自分が投稿した画像一覧を見ることができる
以上、7つの要件を満たすアプリケーションとして開発しましょう。
なお今回は、認証のスタイルとして、Twitterという外部のサービスを利用した認証方式を使います。最近はウェブサイトへのリスト攻撃が多く行われたり、同じIDやパスワードを使いまわしている人たちの情報を漏洩させた時にWebサービス自体の社会的な責任が発生してしまうという可能性があることから、特に趣味などで作るサイトに関して言えば、外部の認証サービスを利用し、 IDと暗号化されたパスワードの情報などをサービスとして持たないほうが良いとされています。
プロジェクト名と完成の画面イメージ
開発を行う前にプロジェクト名を決めますが、独断と偏見でmojipic
とさせてもらいました。理由は、grepability(grep検索が行いやすい)が高いからです。
Scalaのような静的な型付があるプロジェクトなどでは問題になりにくいですが、言語によってはひたすらgrepを駆使して影響範囲を調べる言語もあるため、プロジェクト名を定めるときには注意が必要です。
加えて画面イメージは、以下のようなものとします。
ログアウト時
ログイン時
このような画面イメージになるように機能を実装していきます。
システム構成
システム構成は以下のコンポーネント図のようにします。
ブラウザからJVM上のPlay FrameworkのControllerオブジェクトにアクセスします。
Controllerではページの表示や画像の取得、投稿、Twitterアカウントを利用した認証などを行います。
Twitter上には専用のアプリケーションを作成して、OAuth1.0の各種keyを発行して利用します。
Twitterからのコールバックで取得したセッション情報の保持は、Play Framework上のEHCache
上に揮発時間を設定して保管し、それとのヒモ付はブラウザのCookie内のセッションキーを利用して行います。
変換する予定の画像は、ControllerがRabbitMQのキューに積み、Play Frameworkはそれを監視しながら、最終的にActorがImageMagickを利用して画像を変換し、それをH2 Dabataseに変換後バイナリごと格納します。
そして、Controllerは格納された変換後バイナリをウェブブラウザに表示することで変換画像の一覧を表現するという構成になります。
サンプルプログラム
サンプルプログラムは http://github.o-in.dwango.co.jp/Scala/mojipic にあります。
プログラム設計
今回は基本的な設計として、ドメイン駆動設計とクリーンアーキテクチャを採用してみましょう。
ドメイン駆動設計とクリーンアーキテクチャについては 大規模システム開発のための開発手法 のドメイン駆動設計の節を参照してください。
また、クリーンアーキテクチャのようなドメインモデル中心の設計をするには依存関係逆転の原則が必要で、そのためには依存性の注入をする必要があるということを述べましたが、今回は依存性の注入にPlay Frameworkでも使われているGuiceをそのまま採用します。
依存性の注入については トレイトの応用編:依存性の注入によるリファクタリング を参照してください。
プログラム中で使われる言葉の定義
プログラムを設計するにあたって、まず最初にプログラム中で使われる基本的な言葉の定義をしましょう。ドメイン駆動設計でいうところのユビキタス言語の定義にあたります。言葉を定義することで、プログラムで使われる基本的な概念を整理し、データ構造の設計にもなります。
- 投稿者
- 画像を投稿した人です。前述のとおり今回の要件では直接投稿者をアプリケーションで管理せずにTwitterの認証で済ませます。
- Twitter ID
- TwitterのユーザーIDです。投稿者はTwitter IDにより一意に区別されます。今回のアプリケーションでは投稿者の情報はTwitter IDのみを保存します。TwitterのユーザーIDというのは`@` から始まるスクリーンネーム(たとえば@sifue)ではなく `13329002`のような 64bit 整数値であることに注意してください。
- 投稿された画像 (OriginalPicture)
- 投稿されたオリジナルの画像です。投稿された画像はデータベースに保存せずにRabbitMQのキューに直接保存することにします。
- 変換後の画像 (ConvertedPicture)
- 投稿された画像にテロップを合成した画像です。前述のとおりImageMagickで変換後、データベースに保存されます。一般的には静的ファイルはファイルサーバーに置き、APIサーバーはファイルサーバーのURLを返すような実装が多いと思いますが、今回は実装を簡単にするため画像をデータベースに入れ、APIサーバーで直接画像ファイルを返すような実装を考えます。
- 画像のプロパティ (PictureProperty)
- 画像に付属する情報です。以下の情報が含まれます。
- ステータス (変換中、変換失敗、変換成功)
- 投稿者のTwitter ID
- 投稿された画像のファイル名
- 投稿時のContent-Typeヘッダの値
- テロップ文字列
- テロップ文字列のサイズ
- 投稿された日時
- 画像ID (PictureId)
- 投稿された画像、変換後の画像、画像のプロパティは共通の画像IDを持ち、画像IDにより一意に区別されます。
画像の投稿のシーケンス図
今回のアプリケーションの処理のうち一番難しいのが画像の投稿から変換される処理です。この部分だけシーケンス図を作ってみましょう。
画像が投稿されたらWebサーバは画像のプロパティをDBに保存し、投稿された画像をRabbitMQのキューに入れます。そして、RabbitMQのキューを常に監視しているスレッドがキューに投稿された画像があるのを発見すると、取り出して変換用のアクターに渡します。アクターは投稿された画像を受け取ったらImageMagickを変換し、変換後の画像をDBに保存します。また、画像のプロパティを変換中から変換成功に更新します。
エンティティの実装
それではドメイン層、インフラストラクチャ層、アプリケーション層の順番に実装してきたいと思います。
最初はドメイン層です。まずはエンティティの実装から始めましょう。上記で定義した概念をそのままコードの落とし込んでいきます。ついでにWeb APIでJSONを返す予定のエンティティについてはPlay JSONの変換用の型クラスインスタンスを定義しておきます。
画像ID
package domain.entity
import play.api.libs.json.JsString
import play.api.libs.json.Writes
/**
* 画像ID
* @param value 画像IDの値
*/
case class PictureId(value: Long)
object PictureId {
implicit val writes: Writes[PictureId] = Writes(id => JsString(id.value.toString))
}
Twitter ID
package domain.entity
import play.api.libs.json.JsString
import play.api.libs.json.Writes
/**
* 投稿者のTwitter ID
* @param value Twitter IDの値
*/
case class TwitterId(value: Long)
object TwitterId {
implicit val writes: Writes[TwitterId] = Writes(id => JsString(id.value.toString))
}
投稿された画像
package domain.entity
/**
* 投稿された画像
* @param id 画像ID
* @param binary 投稿された画像のバイナリデータ
*/
case class OriginalPicture(id: PictureId, binary: Array[Byte])
変換後の画像
package domain.entity
/**
* 変換後の画像
* @param id 画像ID
* @param binary 変換後の画像のバイナリデータ
*/
case class ConvertedPicture(id: PictureId, binary: Array[Byte])
画像のプロパティ
package domain.entity
import java.time.LocalDateTime
import com.google.common.net.MediaType
import play.api.libs.json.JsString
import play.api.libs.json.Json
import play.api.libs.json.Writes
/**
* 画像のプロパティ
* @param id 画像ID
* @param value 画像のプロパティの値
*/
case class PictureProperty(id: PictureId, value: PictureProperty.Value)
object PictureProperty {
/**
* 画像のステータス
* @param value 画像のステータスの値
*/
sealed abstract class Status(val value: String)
object Status {
// 変換に成功した
case object Success extends Status("success")
// 変換に失敗した
case object Failure extends Status("failure")
// 変換中
case object Converting extends Status("converting")
/**
* 画像のステータスの値から画像のステータスに変換する
* @param value 画像のステータスの値
* @return 画像のステータス
*/
def parse(value: String): Option[Status] =
value match {
case Success.value => Some(Success)
case Failure.value => Some(Failure)
case Converting.value => Some(Converting)
case _ => None
}
implicit val writes: Writes[Status] = Writes(s => JsString(s.toString))
}
/**
* 画像のプロパティの値
* @param status 画像のステータス
* @param twitterId 投稿者のTwitter ID
* @param fileName 投稿された画像のファイル名
* @param contentType 投稿された画像のContent-Type
* @param overlayText 変換に使われるテキスト
* @param overlayTextSize 変換に使われるテキストのサイズ
* @param createdTime 投稿された時間
*/
case class Value(
status: Status,
twitterId: TwitterId,
fileName: String,
contentType: MediaType,
overlayText: String,
overlayTextSize: Int,
createdTime: LocalDateTime
)
object Value {
implicit val mediaTypeWrites: Writes[MediaType] = Writes(s => JsString(s.toString))
implicit val writes: Writes[Value] = Json.writes[Value]
}
implicit val writes: Writes[PictureProperty] = Json.writes[PictureProperty]
}
リポジトリの定義
変換後の画像と画像のプロパティの2つはDBに永続化するので、この2つのエンティティを保存するリポジトリを定義しましょう。
リポジトリは、ScalikeJDBCを使ってDBとやりとりしますが、ドメイン層ではインタフェースの定義だけとし、実装はインフラストラクチャ層でやることにします。
変換後の画像のリポジトリ
package domain.repository
import domain.entity.ConvertedPicture
import domain.entity.PictureId
import scala.concurrent.Future
trait ConvertedPictureRepository {
/**
* 変換後の画像を保存する
* @param converted 変換後の画像
* @return Future.successful(()) 保存に成功した
* Future.failed(DatabaseException) 保存に失敗した
*/
def create(converted: ConvertedPicture): Future[Unit]
/**
* 変換後の画像を読み込む
* @param pictureId 画像ID
* @return Future.successful(ConvertedPicture) 読み込みに成功した
* Future.failed(DatabaseException) 読み込みに失敗した
*/
def find(pictureId: PictureId): Future[ConvertedPicture]
}
画像のプロパティのリポジトリ
package domain.repository
import java.time.LocalDateTime
import domain.entity.PictureId
import domain.entity.PictureProperty
import domain.entity.TwitterId
import scala.concurrent.Future
trait PicturePropertyRepository {
/**
* 画像のプロパティを保存する
* @param value 画像のプロパティの値
* @return Future.successful(PictureId) 新しく割り当てられた画像ID
* Future.failed(DatabaseException) 保存に失敗した
*/
def create(value: PictureProperty.Value): Future[PictureId]
/**
* 画像のステータスを更新する
* @param pictureId 画像ID
* @param status ステータス
* @return Future.successful(()) 更新に成功した
* Future.failed(DatabaseException) 更新に失敗した
*/
def updateStatus(pictureId: PictureId, status: PictureProperty.Status): Future[Unit]
/**
* 画像のプロパティを読み込む
* @param pictureId 画像ID
* @return Future.successful(PictureProperty) 読み込みに成功した
* Future.failed(PictureNotFoundException) 画像のプロパティが見つからなかった
* Future.failed(DatabaseException) 読み込みに失敗した
*/
def find(pictureId: PictureId): Future[PictureProperty]
/**
* 投稿者のTwitter IDと最後に読み込まれた作成日時から画像のプロパティを読み込む
* @param twitterId 投稿者のTwitter ID
* @param lastCreatedTime 最後に読み込まれた作成日時
* @return Future.successful(Seq(PictureProperty)) 読み込みに成功した
* Future.failed(DatabaseException) 読み込みに失敗した
*/
def findAllByTwitterIdAndDateTime(twitterId: TwitterId, lastCreatedTime: LocalDateTime): Future[Seq[PictureProperty]]
/**
* 最後に読み込まれた作成日時から画像のプロパティを読み込む
* @param lastCreatedTime 最後に読み込まれた作成日時
* @return Future.successful(Seq(PictureProperty)) 読み込みに成功した
* Future.failed(DatabaseException) 読み込みに失敗した
*/
def findAllByDateTime(lastCreatedTime: LocalDateTime): Future[Seq[PictureProperty]]
}
こちらはやや天下り的な定義ですが、差分取得のために日付を引数に受け取るようにしてあります。
サービスの実装
次にこれまで定義したエンティティとリポジトリを使ってサービスの実装をしましょう。
以下の4つのサービスが考えられます。
- 変換された画像の取得
- 画像のプロパティの取得
- 画像の投稿
- 投稿された画像の変換
投稿された画像の変換サービスではRabbitMQに投稿された画像を入れる処理を行います。ドメイン層ではインタフェースの定義だけとし、実装はインフラストラクチャ層でやることにします。
リポジトリのインスタンスはGuiceによる依存性の注入で取得することに注意してください。このように依存性の注入を使うことで、ドメイン層ではインフラストラクチャ層の具体的な実装を参照せずに依存関係逆転を原則を実現できます。
変換された画像の取得
package domain.service
import javax.inject.Inject
import domain.entity.ConvertedPicture
import domain.entity.PictureId
import domain.entity.PictureProperty
import domain.exception.ConversionFailureException
import domain.exception.ConvertingException
import domain.repository.ConvertedPictureRepository
import domain.repository.PicturePropertyRepository
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
class GetPictureService @Inject() (
convertedPictureRepository: ConvertedPictureRepository,
picturePropertyRepository: PicturePropertyRepository,
executionContext: ExecutionContext
) {
implicit val ec = executionContext
/**
* 画像IDから変換後の画像と画像のプロパティを取得する
* @param pictureId 画像ID
* @return Future.successful((ConvertedPicture, PictureProperty)) 変換後の画像と画像のプロパティ
* Future.failed(PictureNotFoundException) 画像IDの画像が存在しない
* Future.failed(ConversionFailureException) 画像の変換に失敗した
* Future.failed(ConvertingException) 画像の変換中
* Future.failed(DatabaseException) データベースからの読み込みに失敗した
*/
def get(pictureId: PictureId): Future[(ConvertedPicture, PictureProperty)] = {
for {
property <- picturePropertyRepository.find(pictureId)
picture <- property.value.status match {
case PictureProperty.Status.Success =>
convertedPictureRepository.find(pictureId)
case PictureProperty.Status.Failure =>
Future.failed(new ConversionFailureException(s"Picture conversion is failed. Picture Id: ${pictureId.value}"))
case PictureProperty.Status.Converting =>
Future.failed(new ConvertingException(s"Picture is converting. Picture Id: ${pictureId.value}"))
}
} yield (picture, property)
}
}
画像のプロパティの取得
package domain.service
import java.time.LocalDateTime
import javax.inject.Inject
import domain.entity.PictureProperty
import domain.entity.TwitterId
import domain.repository.PicturePropertyRepository
import scala.concurrent.Future
class GetPicturePropertiesService @Inject() (
picturePropertyRepository: PicturePropertyRepository
) {
/**
* 投稿者のTwitter IDと最後に読み込まれた作成日時から画像のプロパティを読み込む
* @param twitterId 投稿者のTwitter ID
* @param lastCreatedTime 最後に読み込まれた作成日時
* @return Future.successful(Seq(PictureProperty)) 読み込みに成功した
* Future.failed(DatabaseException) 読み込みに失敗した
*/
def getAllByTwitterId(twitterId: TwitterId, lastCreatedTime: LocalDateTime): Future[Seq[PictureProperty]] =
picturePropertyRepository.findAllByTwitterIdAndDateTime(twitterId, lastCreatedTime)
/**
* 最後に読み込まれた作成日時から画像のプロパティを読み込む
* @param lastCreatedTime 最後に読み込まれた作成日時
* @return Future.successful(Seq(PictureProperty)) 読み込みに成功した
* Future.failed(DatabaseException) 読み込みに失敗した
*/
def getAll(lastCreatedTime: LocalDateTime): Future[Seq[PictureProperty]] =
picturePropertyRepository.findAllByDateTime(lastCreatedTime)
}
画像の投稿
package domain.service
import javax.inject.Inject
import com.google.common.net.MediaType
import domain.entity.OriginalPicture
import domain.entity.PictureProperty
import domain.exception.ConversionFailureException
import domain.exception.InvalidContentTypeException
import domain.repository.PicturePropertyRepository
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
class PostPictureService @Inject() (
convertPictureService: ConvertPictureService,
picturePropertyRepository: PicturePropertyRepository,
executionContext: ExecutionContext
) {
val availableMediaTypes = Seq(MediaType.JPEG, MediaType.PNG, MediaType.GIF, MediaType.BMP)
implicit val ec = executionContext
/**
* 投稿された画像を受け取り、画像のプロパティを保存し、画像の変換を開始する
* @param binary 投稿された画像
* @param property 投稿された画像のプロパティ
* @return Future.successful(()) 画像を受け取り、変換を開始した
* Future.failed(InvalidContentTypeException) 投稿された画像のContent-Typeが受け付けられないものだった
* Future.failed(DatabaseException) データベースへの保存に失敗した
* Future.failed(ConversionFailureException) 画像の変換に失敗した
*/
def post(binary: Array[Byte], property: PictureProperty.Value): Future[Unit] = {
if (availableMediaTypes.contains(property.contentType)) {
for {
id <- picturePropertyRepository.create(property)
_ <- convertPictureService.convert(OriginalPicture(id, binary)).recoverWith {
case e: ConversionFailureException =>
picturePropertyRepository
.updateStatus(id, PictureProperty.Status.Failure)
.flatMap(_ => Future.failed(e))
}
} yield ()
} else {
Future.failed(InvalidContentTypeException(s"Invalid content type: ${property.contentType}"))
}
}
}
投稿された画像の変換
package domain.service
import domain.entity.OriginalPicture
import scala.concurrent.Future
trait ConvertPictureService {
/**
* 投稿された画像の変換を開始する
* 変換は非同期に実行され、結果はConvertedPictureRepositoryに保存される
* @param original 投稿された画像
* @return Future.successful(()) 変換を開始した
* Future.failed(ConversionFailureException) 変換を開始できなかった
*/
def convert(original: OriginalPicture): Future[Unit]
}
エラー処理
今回のアプリケーションのエラー処理はドメイン層に例外を定義し、インフラストラクチャ層の例外などはドメイン層の例外に例外翻訳することにします。例外は直接上げられるだけでなく、メソッドの返り値のFutureの中に入ることが多いことに注意してください。
詳しくは本テキストの エラー処理 の節を参照してください。
サービスのユニットテスト
GetPictureService
とPostPictureService
の2つについてはロジックが書かれているのでユニットテストも作りましょう。
ユニットテストではGuiceで取得していたインスタンスをモックに差し替えます。例外処理はインテグレーションテストではテストしづらいことが多いので、ユニットテストでは例外処理を重点的にテストすることを心掛けてください。
今回はScalaTestとMockitoを使いますが、それぞれのライブラリの使い方は テスト を参照してください。
変換された画像の取得のテスト
package domain.service
import domain.entity.ConvertedPicture
import domain.entity.PictureId
import domain.entity.PictureProperty
import domain.exception.ConversionFailureException
import domain.exception.ConvertingException
import domain.exception.DatabaseException
import domain.exception.PictureNotFoundException
import domain.repository.ConvertedPictureRepository
import domain.repository.PicturePropertyRepository
import org.mockito.Matchers
import org.mockito.Mockito._
import org.scalatest.mock.MockitoSugar
import org.scalatestplus.play.PlaySpec
import scala.concurrent.Await
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
import scala.concurrent.duration.Duration
class GetPictureServiceSpec extends PlaySpec with MockitoSugar {
trait Setup {
val pictureId = PictureId(123L)
val convertedPicture = ConvertedPicture(pictureId, Array())
val pictureProperty = PictureProperty(pictureId, PictureProperty.Value(PictureProperty.Status.Success, null, null, null, null, 0, null))
val mockedConvertedPictureRepository = mock[ConvertedPictureRepository]
val mockedPicturePropertyRepository = mock[PicturePropertyRepository]
val sut = new GetPictureService(mockedConvertedPictureRepository, mockedPicturePropertyRepository, ExecutionContext.global)
}
"GetPictureService#get" should {
"get ConvertedPicture and PictureProperty" in new Setup {
when(mockedPicturePropertyRepository.find(pictureId)).thenReturn(Future.successful(pictureProperty))
when(mockedConvertedPictureRepository.find(pictureId)).thenReturn(Future.successful(convertedPicture))
val actual = sut.get(pictureId)
assert(Await.result(actual, Duration.Inf) === ((convertedPicture, pictureProperty)))
verify(mockedPicturePropertyRepository, times(1)).find(pictureId)
verify(mockedConvertedPictureRepository, times(1)).find(pictureId)
}
"return Future.failed(ConversionFailureException) if PictureProperty.Status is Failure" in new Setup {
val picturePropertyFailure = PictureProperty(pictureId, PictureProperty.Value(PictureProperty.Status.Failure, null, null, null, null, 0, null))
when(mockedPicturePropertyRepository.find(pictureId)).thenReturn(Future.successful(picturePropertyFailure))
val actual = sut.get(pictureId)
intercept[ConversionFailureException] {
Await.result(actual, Duration.Inf)
}
verify(mockedPicturePropertyRepository, times(1)).find(pictureId)
verify(mockedConvertedPictureRepository, never()).find(Matchers.any())
}
"return Future.failed(ConvertingException) if PictureProperty.Status is Converting" in new Setup {
val picturePropertyFailure = PictureProperty(pictureId, PictureProperty.Value(PictureProperty.Status.Converting, null, null, null, null, 0, null))
when(mockedPicturePropertyRepository.find(pictureId)).thenReturn(Future.successful(picturePropertyFailure))
val actual = sut.get(pictureId)
intercept[ConvertingException] {
Await.result(actual, Duration.Inf)
}
verify(mockedPicturePropertyRepository, times(1)).find(pictureId)
verify(mockedConvertedPictureRepository, never()).find(Matchers.any())
}
"return Future.failed(PictureNotFoundException) if PictureProperty is not found" in new Setup {
when(mockedPicturePropertyRepository.find(pictureId)).thenReturn(Future.failed(PictureNotFoundException()))
val actual = sut.get(pictureId)
intercept[PictureNotFoundException] {
Await.result(actual, Duration.Inf)
}
verify(mockedPicturePropertyRepository, times(1)).find(pictureId)
verify(mockedConvertedPictureRepository, never()).find(Matchers.any())
}
"return Future.failed(DatabaseException) if ConvertedPictureRepository returns return Future.failed(DatabaseException)" in new Setup {
when(mockedPicturePropertyRepository.find(pictureId)).thenReturn(Future.successful(pictureProperty))
when(mockedConvertedPictureRepository.find(pictureId)).thenReturn(Future.failed(DatabaseException()))
val actual = sut.get(pictureId)
intercept[DatabaseException] {
Await.result(actual, Duration.Inf)
}
verify(mockedPicturePropertyRepository, times(1)).find(pictureId)
verify(mockedConvertedPictureRepository, times(1)).find(pictureId)
}
}
}
画像の投稿のテスト
package domain.service
import com.google.common.net.MediaType
import domain.entity.OriginalPicture
import domain.entity.PictureId
import domain.entity.PictureProperty
import domain.exception.ConversionFailureException
import domain.exception.DatabaseException
import domain.exception.InvalidContentTypeException
import domain.repository.PicturePropertyRepository
import org.mockito.Matchers
import org.mockito.Mockito._
import org.scalatest.mock.MockitoSugar
import org.scalatestplus.play.PlaySpec
import scala.concurrent.Await
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
import scala.concurrent.duration.Duration
class PostPictureServiceSpec extends PlaySpec with MockitoSugar {
trait Setup {
val pictureId = PictureId(123L)
val binary = Array[Byte]()
val mediaType = MediaType.JPEG
val picturePropertyValue = PictureProperty.Value(PictureProperty.Status.Converting, null, null, mediaType, null, 0, null)
val originalPicture = OriginalPicture(pictureId, binary)
val mockedConvertPictureService = mock[ConvertPictureService]
val mockedPicturePropertyRepository = mock[PicturePropertyRepository]
val sut = new PostPictureService(mockedConvertPictureService, mockedPicturePropertyRepository, ExecutionContext.global)
}
"PostPictureService#post" should {
"save PictureProperty and convert a picture" in new Setup {
when(mockedPicturePropertyRepository.create(picturePropertyValue)).thenReturn(Future.successful(pictureId))
when(mockedConvertPictureService.convert(originalPicture)).thenReturn(Future.successful(()))
val actual = sut.post(binary, picturePropertyValue)
assert(Await.result(actual, Duration.Inf) === (()))
verify(mockedPicturePropertyRepository, times(1)).create(picturePropertyValue)
verify(mockedConvertPictureService, times(1)).convert(originalPicture)
}
"return Future.failed(InvalidContentTypeException) if Content-Type is invalid" in new Setup {
val invalidMediaType = MediaType.parse("text/html")
val actual = sut.post(binary, picturePropertyValue.copy(contentType = invalidMediaType))
intercept[InvalidContentTypeException] {
Await.result(actual, Duration.Inf)
}
verify(mockedPicturePropertyRepository, never()).create(Matchers.any())
verify(mockedConvertPictureService, never()).convert(Matchers.any())
}
"return Future.failed(DatabaseException) if PicturePropertyRepository returns Future.failed(DatabaseException)" in new Setup {
when(mockedPicturePropertyRepository.create(picturePropertyValue)).thenReturn(Future.failed(DatabaseException()))
val actual = sut.post(binary, picturePropertyValue)
intercept[DatabaseException] {
Await.result(actual, Duration.Inf)
}
verify(mockedPicturePropertyRepository, times(1)).create(picturePropertyValue)
verify(mockedConvertPictureService, never()).convert(Matchers.any())
}
"return Future.failed(ConversionFailureException) if it failed to convert" in new Setup {
when(mockedPicturePropertyRepository.create(picturePropertyValue)).thenReturn(Future.successful(pictureId))
when(mockedConvertPictureService.convert(originalPicture)).thenReturn(Future.failed(ConversionFailureException()))
when(mockedPicturePropertyRepository.updateStatus(pictureId, PictureProperty.Status.Failure)).thenReturn(Future.successful(()))
val actual = sut.post(binary, picturePropertyValue)
intercept[ConversionFailureException] {
Await.result(actual, Duration.Inf)
}
verify(mockedPicturePropertyRepository, times(1)).create(picturePropertyValue)
verify(mockedConvertPictureService, times(1)).convert(originalPicture)
verify(mockedPicturePropertyRepository, times(1)).updateStatus(pictureId, PictureProperty.Status.Failure)
}
}
}
ドメイン層実装まとめ
ここまで要件の定義から、簡単なプログラム設計、および、ドメイン層の実装まで見てきました。
アプリケーションをどう作っていくかという方法は色々なやり方が考えると思います。
今回はドメイン駆動設計のユビキタス言語の定義からエンティティを作り、最も抽象的なレイヤーのドメイン層から作りはじめるという手法を取りました。他には、たとえば他のチームのサービス開発と連携しなければならない場合などは 大規模システム開発のための開発手法 のドメイン駆動設計の節で触れたように、より大きな視点で境界づけられたコンテキストを考えて、それぞれのサービスの役割を考察していくという手法が考えられます。また、たとえばフロントエンドのチームとバックエンドのチームが分かれている場合はお互いのインタフェースとなるWeb APIから決めるやり方のほうがいいかもしれません。
しかし、どんなアプリケーションの作り方でも、コード上は今回のようにドメインモデル中心の設計にしたほうがよいでしょう。アプリケーションの対象となるドメイン知識とコードの対応がわかりやすく、コードの可読性も高くなると思います。 JavaやScalaのような依存性の注入が簡単にできる言語の場合、今回のようなクリーンアーキテクチャ風のコード構成も簡単に実現できます。
これからみなさんもアプリケーションの設計をすることがあると思いますが、このようなドメインモデル中心の設計を心がけてみてください。