インフラストラクチャ層の実装

前回に引き続き、今回はインフラストラクチャ層の実装を進めていきましょう。

データベース

まずは前回インタフェースだけ決めたリポジトリの実装をしましょう。

使用するデータベースライブラリ

この度は、保存するデータベースとしてH2 Databaseという組み込んで使うことのできるデータベースを利用します。このH2 Databaseは、Javaのライブラリで、ファイルデータベースとしてもインメモリのデータベースとしても使うことができる十分に高速なデータベースです。ファイルサイズが小さいうちはほぼ問題なく利用することができます。また、ダウンロードできる配布パッケージの bin/h2.shまたはbin/h2.batを実行することでWebベースのビュアーを利用することもできます。

加えて、データベースにアクセスするためのライブラリとして、 ScalikeJDBCを利用します。ScalikeJDBCは、 JDBCというJavaのデータベースにつなぐためのAPIをScalaの記述でも簡単に扱えるようにしてくれるライブラリです。ほとんどそのままのSQLで書くことができる他、定義されたDSLでSQLのようなScalaのコードの記述で実装することもできます。そして、

  • PostgreSQL
  • MySQL
  • H2 Database Engine
  • HSQLDB

以上のデータベースを同じコードで透過的に扱うこともできます。

テーブルスキーマ

RDBMSを使うことに決めたので、ConvertedPicturePictureProperty エンティティに対応するテーブルスキーマを作ります。検索することになるカラムにはインデックスを作っておきます。

このSQLはPlayフレームワークのEvolutionsという機能を使ってH2に適用します。所定のディレクトリにSQLを入れておくと、アプリケーションの初回起動時にSQLを適用するかどうか訊かれるので、ボタンを押すとデータベースにテーブルが作成されます。小さなアプリケーションやプロトタイピングでは便利な機能だと思います。

# picture_properties and converted_pictures schema

# --- !Ups
CREATE TABLE "picture_properties" (
"picture_id" BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1) NOT NULL PRIMARY KEY,
"status" VARCHAR NOT NULL,
"twitter_id" BIGINT NOT NULL,
"file_name" VARCHAR NOT NULL,
"content_type" VARCHAR NOT NULL,
"overlay_text" VARCHAR NOT NULL,
"overlay_text_size" INT NOT NULL,
"created_time" TIMESTAMP NOT NULL);
CREATE INDEX ON "picture_properties" ("twitter_id");
CREATE INDEX ON "picture_properties" ("created_time");

CREATE TABLE "converted_pictures" (
"picture_id" BIGINT NOT NULL PRIMARY KEY,
"binary" BLOB NOT NULL);

# --- !Downs
DROP TABLE "picture_properties";
DROP TABLE "converted_pictures";

リポジトリの実装

次にScalikeJDBCを使ってリポジトリを実装していきます。 ScalikeJDBCはString InterpolationでSQLを書けるライブラリで、直感的にわかりやすくデータベース処理を書くことができます。 ScalikeJDBCにはデータベースのテーブル定義からソースコードを自動生成する機能1がありますが、今回は使用しないことにします。

今回は処理ごとにデータベーストランザクションが分かれてしまっていますが 【ScalaMatsuriセッション当選御礼】ドワンゴ秘伝のトランザクションモナドを解説! - Qiita で説明されているFujitaskというライブラリを使うと、リポジトリを抽象化したままデータベーストランザクションを扱うことができるようになるので、場合よっては使ってみるのもよいと思います。

変換後の画像のリポジトリの実装

package infrastructure.repository

import domain.entity.ConvertedPicture
import domain.entity.PictureId
import domain.exception.DatabaseException
import domain.exception.DomainException
import domain.exception.PictureNotFoundException
import domain.repository.ConvertedPictureRepository
import scalikejdbc._
import scala.concurrent.Future
import scala.util.Failure
import scala.util.Try
import scala.util.control.NonFatal

class ConvertedPictureRepositoryImpl extends ConvertedPictureRepository {

  def create(picture: ConvertedPicture): Future[Unit] =
    Future.fromTry(Try {
      using(DB(ConnectionPool.borrow())) { db =>
        db.localTx { implicit session =>
          val sql =
            sql"""INSERT INTO "converted_pictures" (
                 | "picture_id",
                 | "binary"
                 | ) VALUES (
                 | ${picture.id.value},
                 | ${picture.binary}
                 | )
               """.stripMargin
          sql.update().apply()
          ()
        }
      }
    }.recoverWith {
      case NonFatal(e) => Failure(DatabaseException(s"ConvertedPictureRepository failed to create. PictureId: ${picture.id.value}", e))
    })

  def find(pictureId: PictureId): Future[ConvertedPicture] =
    Future.fromTry(Try {
      using(DB(ConnectionPool.borrow())) { db =>
        db.readOnly { implicit session =>
          val sql =
            sql"""SELECT "binary" FROM "converted_pictures" WHERE "picture_id" = ${pictureId.value}"""
              .map(rs => ConvertedPicture(pictureId, rs.bytes("binary")))
          sql.single().apply().getOrElse(throw PictureNotFoundException(s"Picture is notfound. PictureId: ${pictureId.value}"))
        }
      }
    }.recoverWith {
      case e: DomainException => Failure(e)
      case NonFatal(e) => Failure(DatabaseException(s"ConvertedPictureRepository failed to find. PictureId: ${pictureId.value}", e))
    })
}

画像のプロパティのリポジトリの実装

package infrastructure.repository

import java.time.LocalDateTime
import com.google.common.net.MediaType
import domain.entity.PictureId
import domain.entity.PictureProperty
import domain.entity.TwitterId
import domain.exception.DatabaseException
import domain.exception.DomainException
import domain.exception.PictureNotFoundException
import domain.repository.PicturePropertyRepository
import scalikejdbc._
import scalikejdbc.jsr310._
import scala.concurrent.Future
import scala.util.Failure
import scala.util.Try
import scala.util.control.NonFatal

class PicturePropertyRepositoryImpl extends PicturePropertyRepository {

  def create(value: PictureProperty.Value): Future[PictureId] =
    Future.fromTry(Try {
      using(DB(ConnectionPool.borrow())) { db =>
        db.localTx { implicit session =>
          val sql =
            sql"""INSERT INTO "picture_properties" (
                 | "status",
                 | "twitter_id",
                 | "file_name",
                 | "content_type",
                 | "overlay_text",
                 | "overlay_text_size",
                 | "created_time"
                 | ) VALUES (
                 | ${value.status.value},
                 | ${value.twitterId.value},
                 | ${value.fileName},
                 | ${value.contentType.toString},
                 | ${value.overlayText},
                 | ${value.overlayTextSize},
                 | ${value.createdTime}
                 | )
              """.stripMargin
          PictureId(sql.updateAndReturnGeneratedKey().apply())
        }
      }
    }.recoverWith {
      case NonFatal(e) =>
        Failure(DatabaseException("PicturePropertyRepository failed to create", e))
    })

  def updateStatus(pictureId: PictureId, status: PictureProperty.Status): Future[Unit] =
    Future.fromTry(Try {
      using(DB(ConnectionPool.borrow())) { db =>
        db.localTx { implicit session =>
          val sql =
            sql"""UPDATE "picture_properties" SET "status" = ${status.value} WHERE "picture_id" = ${pictureId.value}"""
          sql.update().apply()
          ()
        }
      }
    }.recoverWith {
      case NonFatal(e) => Failure(DatabaseException(s"PicturePropertyRepository failed to findAllByTwitterIdAndDateTime. PictureId: ${pictureId.value}", e))
    })

  def find(pictureId: PictureId): Future[PictureProperty] =
    Future.fromTry(Try {
      using(DB(ConnectionPool.borrow())) { db =>
        db.readOnly { implicit session =>
          val sql =
            sql"""SELECT
                 | "picture_id",
                 | "status",
                 | "twitter_id",
                 | "file_name",
                 | "content_type",
                 | "overlay_text",
                 | "overlay_text_size",
                 | "created_time"
                 | FROM "picture_properties" WHERE "picture_id" = ${pictureId.value}
              """.stripMargin
          sql.map(resultSetToPictureProperty).single().apply()
            .getOrElse(throw PictureNotFoundException(s"Picture is notfound. PictureId: ${pictureId.value}"))
        }
      }
    }.recoverWith {
      case e: DomainException => Failure(e)
      case NonFatal(e) => Failure(DatabaseException(s"PicturePropertyRepository failed to find. PictureId: ${pictureId.value}", e))
    })

  def findAllByTwitterIdAndDateTime(twitterId: TwitterId, toDateTime: LocalDateTime): Future[Seq[PictureProperty]] =
    Future.fromTry(Try {
      using(DB(ConnectionPool.borrow())) { db =>
        db.readOnly { implicit session =>
          val sql =
            sql"""SELECT
                 | "picture_id",
                 | "status",
                 | "twitter_id",
                 | "file_name",
                 | "content_type",
                 | "overlay_text",
                 | "overlay_text_size",
                 | "created_time"
                 | FROM "picture_properties"
                 | WHERE "twitter_id" = ${twitterId.value} AND "created_time" > $toDateTime ORDER BY "created_time" DESC
              """.stripMargin
          sql.map(resultSetToPictureProperty).list().apply()
        }
      }
    }.recoverWith {
      case NonFatal(e) => Failure(DatabaseException(s"PicturePropertyRepository failed to findAllByTwitterIdAndDateTime. TwitterId: ${twitterId.value}", e))
    })

  def findAllByDateTime(toDateTime: LocalDateTime): Future[Seq[PictureProperty]] =
    Future.fromTry(Try {
      using(DB(ConnectionPool.borrow())) { db =>
        db.readOnly { implicit session =>
          val sql =
            sql"""SELECT
                 | "picture_id",
                 | "status",
                 | "twitter_id",
                 | "file_name",
                 | "content_type",
                 | "overlay_text",
                 | "overlay_text_size",
                 | "created_time"
                 | FROM "picture_properties" WHERE "created_time" > $toDateTime ORDER BY "created_time" DESC
              """.stripMargin
          sql.map(resultSetToPictureProperty).list().apply()
        }
      }
    }.recoverWith {
      case NonFatal(e) => Failure(DatabaseException("PicturePropertyRepository failed to findAllByDateTime", e))
    })

  private[this] def resultSetToPictureProperty(rs: WrappedResultSet): PictureProperty = {
    val value =
      PictureProperty.Value(
        PictureProperty.Status.parse(rs.string("status")).get,
        TwitterId(rs.long("twitter_id")),
        rs.string("file_name"),
        MediaType.parse(rs.string("content_type")),
        rs.string("overlay_text"),
        rs.int("overlay_text_size"),
        rs.localDateTime("created_time")
      )
    PictureProperty(PictureId(rs.long("picture_id")), value)
  }
}

RabbitMQ

次に投稿された画像を入れるRabbitMQに関わる処理を実装していきましょう。

ここからErlangとRabbitMQとImageMagickを各プラットフォームに合わせてインストールしていきますが、すでにdockerの環境が利用できる人は、Erlang/RabbitMQに関しては、 RabbitMQのdockerfileを利用すると数段早く環境構築が可能です。 dockerの環境が利用できないという方は、そのまま以下の手順に沿って、ErlangとRabbitMQのインストールをしていきましょう。

Erlangのインストール

ErlangはScalaと同じ関数型のプログラミング言語です。また、Akka Actorとアクターモデルの参考になっている言語で、同様に同時並行性と高い信頼性を持っています。ただしScalaが持っているオブジェクト指向のパラダイムは持っておらず、その反面バイナリパターンマッチなどのプリミティブなバイナリ操作に強い機能を持っています。より詳しく知りたい方は、 Learn you some Erlang for great good!の日本語訳 を読むのをおすすめします。

この度は、RabbitMQというキューを実現するためのサービスをインストールするために利用します。 RabbitMQの基本的な機能を利用するならばバージョンは、 R14B以上で構いませんが、ここでは17.5以上のインストールをします。

MacでHomebrewを利用している方は、

$ brew install erlang

でインストールが可能です。ビルドにすごく時間がかかります。なお、Macではbrewでインストールをすることで自動的に、17.5以上がインストールされるのではと思います。 Windowsの方は、Erlang Downloadより、自分のプラットフォームにあった最新のErlangのWindowsのバイナリをダウンロードしてインストールしてください。

$ erl
Erlang/OTP 17 [erts-6.4] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V6.4  (abort with ^G)
1>

このようにErlangシェルが表示されるようになれば問題ありません。 erlに対してパスが通っていない場合には通すようにしてください。終了するためには、Ctrl+Gを入力後、qとタイプすれば終了することができます。動作を確認したら終了となります。

RabbitMQのインストール

次にRabbitMQのインストールです。 RabbitMQは、オープンソースのメッセージキューイングサーバーです。 AMQPというプロトコルが利用されており、メッセージのキューイングやルーティングに加えて、 Webベースの管理ツール、クラスタリングとフェイルオーバー、SSLを利用した通信などの機能があります。 AMQPというプロトコルを介しているため、様々な言語プラットフォームから利用可能です。

Macの場合、

$ brew install rabbitmq

でインストール可能です。Windowsの場合、Downloading and Installing RabbitMQからRabbitMQ Serverをダウンロードしてインストールすることでスタートメニューからサーバーを開始することが可能です。

なお、Macの場合は、export PATH=$PATH:/usr/local/sbin.bashrcに書き加えて/usr/local/sbinにパスを通して、

$ rabbitmq-server
              RabbitMQ 3.2.1. Copyright (C) 2007-2013 GoPivotal, Inc.
  ##  ##      Licensed under the MPL.  See http://www.rabbitmq.com/
  ##  ##
  ##########  Logs: /usr/local/var/log/rabbitmq/rabbit@localhost.log
  ######  ##        /usr/local/var/log/rabbitmq/rabbit@localhost-sasl.log
  ##########
              Starting broker... completed with 10 plugins.

を実行して、以上のように起動できるかどうかを試してみてください。デーモン起動されるのでそのままコンソールを閉じてしまっても問題ありません。

終了する際には、

$ rabbitmqctl stop
Stopping and halting node rabbit@localhost ...
...done.

と入力します。Windowsの方はインストール時にスタートメニューに登録されるものをそのまま利用しても、コマンドプロンプトを利用してもどちらでも問題ありません。

なお、Webのインタフェースを持った管理ツールを起動する方法も紹介します。以下のコマンドを入力後、

$ rabbitmq-plugins enable rabbitmq_management
Plugin configuration unchanged.

再度、rabbitmq-serverコマンドにてRabbitMQのサーバーを起動します。その後、http://localhost:15672/にアクセスして、ID: guest Password: guestでログインすることで管理ツールを利用できます。

RabbitMQ management

以上のような管理ツールが表示されるのではと思います。以上で、RabbitMQのインストールは完了です。

RabbitMQの設定を保持するクラス

必要なミドルウェアのインストールが済んだので、次はコードに移っていきましょう。

まずはRabbitMQと通信する処理で共通に参照される設定値を保持するクラスです。 GuiceによりPlayのConfigurationオブジェクトを受け取り、またこのクラス自身もGuiceによりインスタンス化され、他のところで使われることになります。

package infrastructure.rabbitmq

import javax.inject.Inject
import play.api.Configuration

class RabbitMQConfiguration @Inject() (
  configuration: Configuration
) {

  val OriginalPictureQueueName = "original_pictures"

  val HostName = configuration.getString("rabbitmq.hostname")
    .getOrElse(throw new IllegalStateException("rabbitmq.hostname is not set."))
}

投稿された画像をRabbitMQに入れる処理の実装

次にドメイン層で定義した ConvertPictureService の実装をします。

ConvertPictureService は実際には投稿された画像をRabbitMQに入れるだけです。その後の変換処理は、RabbitMQを監視しているスレッドとアクターによっておこなわれます。

RabbitMQとの通信にはRabbitMQ公式の RabbitMQ Java client library を使います。また、投稿された画像のシリアライズには Scala Pickling というシリアライズライブラリを使うことにします。

package infrastructure.service

import javax.inject.Inject
import com.rabbitmq.client.ConnectionFactory
import domain.entity.OriginalPicture
import domain.exception.ConversionFailureException
import domain.service.ConvertPictureService
import infrastructure.rabbitmq.RabbitMQConfiguration
import scala.concurrent.Future
import scala.pickling.Defaults._
import scala.pickling.binary._
import scala.util.Failure
import scala.util.Try
import scala.util.control.NonFatal

class ConvertPictureServiceImpl @Inject() (
  rabbitMQConfiguration: RabbitMQConfiguration
) extends ConvertPictureService {

  val factory = new ConnectionFactory()
  factory.setHost(rabbitMQConfiguration.HostName)

  def convert(original: OriginalPicture): Future[Unit] =
    Future.fromTry(Try {
      val connection = factory.newConnection()
      val channel = connection.createChannel()
      channel.queueDeclare(rabbitMQConfiguration.OriginalPictureQueueName, false, false, false, null)
      channel.basicPublish("", rabbitMQConfiguration.OriginalPictureQueueName, null, original.pickle.value)
    }.recoverWith {
      case NonFatal(e) => Failure(ConversionFailureException(s"It failed to send a picture to RabbitMQ. PictureId: ${original.id.value}", e))
    })
}

RabbitMQの監視スレッド

RabbitMQに入れられた投稿された画像は、シーケンス図で見たようにRabbitMQを監視しているスレッドにより取り出され、実際の変換処理をおこなうアクターに渡されます。

アプリケーションの終了処理のためにPlayフレームワークの ApplicationLifecycle を使わなければならないことに注意してください。

またGuiceはデフォルトでは必要になるときまでインスタンスが作られないので、監視スレッドのために asEagerSingleton メソッドを使って特殊なインスタンス生成をおこなっています。

package infrastructure.rabbitmq

import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
import akka.actor.ActorRef
import com.google.inject.AbstractModule
import com.rabbitmq.client.ConnectionFactory
import com.rabbitmq.client.QueueingConsumer
import com.rabbitmq.client.ShutdownSignalException
import domain.entity.OriginalPicture
import infrastructure.actor.ConvertPictureActor
import play.api.Logger
import play.api.inject.ApplicationLifecycle
import scala.annotation.tailrec
import scala.concurrent.Future
import scala.pickling.Defaults._
import scala.pickling.binary._
import scala.util.Failure
import scala.util.Success
import scala.util.Try
import scala.util.control.NonFatal

/**
 * RabbitMQのキューを監視し続けるランナー
 */
@Singleton
class RabbitMQConsumer @Inject() (
  worker: RabbitMQConsumeWorker,
  applicationLifeCycle: ApplicationLifecycle
) {
  private[this] val executor = Executors.newFixedThreadPool(1)
  executor.submit(worker)
  applicationLifeCycle.addStopHook { () =>
    Future.successful(worker.close())
  }
}

class RabbitMQConsumeWorker @Inject() (
  rabbitMQConfiguration: RabbitMQConfiguration,
  @Named("convert-picture-actor") convertPictureActor: ActorRef
) extends Runnable {

  val factory = new ConnectionFactory()
  factory.setHost(rabbitMQConfiguration.HostName)
  val connection = factory.newConnection()
  val channel = connection.createChannel()

  override def run(): Unit = {
    channel.queueDeclare(rabbitMQConfiguration.OriginalPictureQueueName, false, false, false, null)
    val consumer = new QueueingConsumer(channel)
    channel.basicConsume(rabbitMQConfiguration.OriginalPictureQueueName, true, consumer)
    Logger.logger.info(s"RabbitMQの監視を開始しました スレッド名:${Thread.currentThread().getName}")
    messageLoop(consumer)
  }

  @tailrec private def messageLoop(consumer: QueueingConsumer): Unit = {
    Try(consumer.nextDelivery()) match {
      case Success(delivery) =>
        val original = BinaryPickle(delivery.getBody).unpickle[OriginalPicture]
        convertPictureActor ! ConvertPictureActor.Convert(original)
        messageLoop(consumer)
      case Failure(e: ShutdownSignalException) =>
        // channel が close されたときに発生するため、正常系の一部
        Logger.info("RabbitMQの監視を終了します")
      case Failure(NonFatal(e)) =>
        Logger.warn("RabbitMQの監視が中断されました", e)
    }
  }

  def close(): Unit = {
    println("closing...")
    channel.close()
    connection.close(5000)
  }
}

class RabbitMQConsumerModule extends AbstractModule {
  def configure(): Unit = {
    bind(classOf[RabbitMQConsumer]).asEagerSingleton()
  }
}

ImageMagick

最後に実際に画像変換をおこなうImageMagickに関わる処理を実装していきましょう。

ImageMagickをインストールする

Macの場合、

$ brew install imagemagick

でインストール可能です。もしくは、Image Magickのバイナリリリースのページより、自分のプラットフォームにあったものを選択してそのインストール手順に沿ってインストールすることもできます。

インストールの確認は、コンソールにて

$ convert logo: logo.gif

と入力して、

ImageMagick logo

以上のような画像が出力されればインストールは完了となります。詳しい使い方は、ImageMagickの使い方のドキュメントをご覧ください。

ImageMagickのJavaライブラリ

ImageMagickを利用するにあたって、 convertコマンドを利用するにそのためのインタフェースを提供するライブラリの im4javaというLGPLのライブラリを利用します。

なお、もう1つJMagickというImageMagickのCのAPIを JNIを利用して実行するというパフォーマンスの良いライブラリもあるのですが、各プラットフォームごとにCのコードをコンパイルしなくてはならない点や、各バージョンのAPIに対しての安定性が高くない点から、このたびはim4javaを利用させてもらいました。

convertコマンドで変換してみる

すでに、ImageMagikのconvertコマンドがインストールされて動くことは確認してあると思いますが、今回はこれに指定したフォントで、好きな文章を好きなサイズでいれて見るようにしましょう。

フォントは、瀬戸フォントを利用します。ライセンスは、"SIL Open Font License"で、フォントそのものを販売しない限りは商用・非商用も問わず、改変や再配布も認められています。

ダウンロードして、sp-setofont.ttfファイルをプロジェクトのルートに配置して利用してみましょう。まず、logoのgifを出力しておきます。以下のコマンドをコンソールで実行することでlogo.gifが出力されます。

convert logo: logo.gif

さらにこのlogo.gifに対して、テロップをいれます。

convert logo.gif -gravity south -font './sp-setofont.ttf' -pointsize 30 \
 -stroke '#000C' -strokewidth 2 -annotate 0 'プログラムを魔法か何かと勘違いしている' \
 -stroke  none   -fill white    -annotate 0 'プログラムを魔法か何かと勘違いしている' \
 logo_annotate.gif

Windows環境の場合は以下のコマンドです。

convert logo.gif -gravity south -font ./sp-setofont.ttf -pointsize 30  -stroke #000C -strokewidth 2 -annotate 0 'プログラムを魔法か何かと勘違いしている'  -stroke  none   -fill white    -annotate 0 'プログラムを魔法か何かと勘違いしている'  logo_annotate.gif

このようなコマンドを実行してみましょう。

ImageMagick Logo Annotated

以上のような画像が出力されれば、問題なくImageMagickが利用できているということになります。

画像変換アクター

必要なミドルウェアのインストールが済んだので、次はコードに移っていきましょう。

ConvertPictureActorRabbitMQConsumer から投稿された画像を受け取りImageMagickを使い画像を変換し、変換後の画像を ConvertedPictureRepository 経由でデータベースに保存します。また変換結果により画像のプロパティをアップデートします。

アクターのインスタンス化には普通のクラスと違い、Playにある AkkaGuiceSupport を使っていることに注意してください。

以下のように設定することで "convert-picture-actor" という名前で ActorRef をGuice経由で受け取ることができるようになります。

bindActor[ConvertPictureActor]("convert-picture-actor")

RabbitMQConsumer はこの機能を使って ConvertPictureActor と通信しています。

package infrastructure.actor

import java.nio.file.Files
import java.nio.file.Path
import javax.inject.Inject
import akka.actor.Actor
import akka.event.Logging
import com.google.inject.AbstractModule
import domain.entity.ConvertedPicture
import domain.entity.OriginalPicture
import domain.entity.PictureProperty
import domain.repository.ConvertedPictureRepository
import domain.repository.PicturePropertyRepository
import org.im4java.core.ConvertCmd
import org.im4java.core.IMOperation
import play.api.Configuration
import play.api.libs.concurrent.AkkaGuiceSupport
import scala.concurrent.Future
import scala.util.Try
import scala.util.control.NonFatal

object ConvertPictureActor {
  case class Convert(original: OriginalPicture)
}

class ConvertPictureActor @Inject() (
  convertedPictureRepository: ConvertedPictureRepository,
  picturePropertyRepository: PicturePropertyRepository,
  configuration: Configuration
) extends Actor {
  import context.dispatcher
  import ConvertPictureActor.Convert

  val log = Logging(context.system, this)

  val imageMagickPath = configuration.getString("imagemagick.path")
    .getOrElse("`imagemagick.path' is not specified.")
  val imageMagickFontPath = configuration.getString("imagemagick.fontpath")
    .getOrElse("`imagemagick.fontpath' is not specified.")

  def receive = {
    case Convert(original) =>
      log.info(s"Conversion started. PictureId: ${original.id.value}")
      (for {
        property <- picturePropertyRepository.find(original.id)
        converted <- convert(original, property)
        _ <- convertedPictureRepository.create(converted)
        _ <- picturePropertyRepository.updateStatus(original.id, PictureProperty.Status.Success)
      } yield {
        log.info(s"Conversion finished. PictureId: ${original.id.value}")
      }).onFailure {
        case NonFatal(e) =>
          log.error(e, s"Conversion failed. PictureId: ${original.id.value}")
          picturePropertyRepository.updateStatus(original.id, PictureProperty.Status.Failure)
      }
  }

  private[this] def convert(original: OriginalPicture, property: PictureProperty): Future[ConvertedPicture] =
    Future.fromTry(Try {
      val originalPath = Files.createTempFile("mojipic", ".tmp")
      val convertedPath = Files.createTempFile("mojipic", ".converted")
      Files.write(originalPath, original.binary)

      invokeCmd(original, property, originalPath, convertedPath)

      val converted = ConvertedPicture(original.id, Files.readAllBytes(convertedPath))

      Files.delete(originalPath)
      Files.delete(convertedPath)

      converted
    })

  private[this] def invokeCmd(original: OriginalPicture, property: PictureProperty, originalPath: Path, convertedPath: Path): Unit = {
    val cmd = new ConvertCmd()
    cmd.setSearchPath(imageMagickPath)
    val op = new IMOperation()
    op.addImage(originalPath.toAbsolutePath.toString)
    op.gravity("south")
    op.font(imageMagickFontPath)
    op.pointsize(property.value.overlayTextSize)
    op.stroke("#000C")
    op.strokewidth(2)
    op.annotate(0, 0, 0, 0, property.value.overlayText)
    op.stroke("none")
    op.fill("white")
    op.annotate(0, 0, 0, 0, property.value.overlayText)
    op.addImage(convertedPath.toAbsolutePath.toString)
    cmd.run(op)
  }
}

class ConvertPictureActorModule extends AbstractModule with AkkaGuiceSupport {
  def configure(): Unit = {
    bindActor[ConvertPictureActor]("convert-picture-actor")
  }
}

インフラストラクチャ層実装まとめ

今回はインフラストラクチャ層の実装を進めました。

インフラストラクチャ層の実装はドメイン層の実装から分離することが重要です。今回のインフラストラクチャ層はH2、RabbitMQ、ImageMagickなどのライブラリや外部サーバに依存しましたが、一般的にインフラストラクチャ層はドメイン層に比べて外的な要因に左右されやすく、また実装が変更しやすい部分です。よって、ドメイン層とインフラストラクチャ層を分離しておくと、変更に強いアーキテクチャになります。

また、インフラストラクチャ層をアプリケーションの中心に置いてしまうと、アプリケーション全体が特定のフレームワークやライブラリに強く影響を受けてしまい、アプリケーションのビジネス的価値を産み出すドメインロジックがないがしろになってしまいかねません。もちろん実装を特定のフレームワークなどで単純化できればよいのですが、あくまでアプリケーションで最優先すべきなのでビジネス的な価値を生む機能です。この点で本末転倒にならないように、アプリケーションの中心はドメイン層にしたほうがよいです。

それと、インフラストラクチャ層は他のサービスとの通信の窓口となる部分です。なので、特にエラー処理は入念におこない、他のサービスのトラブルに自分のアプリケーションが巻き込まれないようにすることが重要です。 大規模システム開発のための開発手法 のマイクロサービスの節で述べたように、サーキットブレイカーを使うことも推奨します。 Scalaから簡単に使えるサーキットブレイカーとして Failurewall というライブラリがあるので紹介しておきます。

1. Reverse Engineering - ScalikeJDBC

results matching ""

    No results matching ""