画像変換と表示の実装 - 画像変換サービス開発5

# 目次

* 【実装】非同期でタスクを処理するアクター
* 【実装】画像の変換
* 【実装】変換後の画像のプロパティ情報の更新
* 【実装】画像を取得する API
* 【実装】画像のプロパティ情報の取得
* 【実装】フロントのコンポーネントの実装
* 【実装】自動更新されるコンポーネントの実装

# 【実装】非同期でタスクを処理するアクター

前回、画像ファイルをサーバー上に送信し、一緒に送られてきたプロパティ情報を MySQL に保存して、PictureId を Redis 上のタスクとして保存するところまで実装した。

ここからは設計に合わせ、非同期でタスクを処理するアクターの実装・画像の変換、そして変換した画像を表示するための各種 API の実装とクライアントの実装までを終わらせる。

今回実装するのは、`infrastructure.actor` パッケージの ConvertPictureActor クラスと ActorSAcheduler クラス。
今回は、ActorScheduler にて 500 ms毎にタスクを取得し、ConvertPictureActor にて具体的な処理の実行を行う。
また、これらのアクターが動作できるよう DI のバインディングも行う。

まずは、`infrastructure.actor`を作成し、`infrastructure.actor.ConvertPictureActor` クラスを以下のように実装する。

```scala:app/infrastructure/actor/ConvertPictureActor.scala
package infrastructure.actor


import akka.actor.Actor
import com.google.inject.Inject
import com.redis.RedisClient
import domain.repository.PicturePropertyRepository
import play.api.Configuration

sealed abstract trait ConvertPictureActorMessage

case object ConvertPictureMessage extends ConvertPictureActorMessage

class ConvertPictureActor @Inject()(
                                     redisClient: RedisClient,
                                     picturePropertyRepository: PicturePropertyRepository,
                                     configuration: Configuration
                                   ) extends Actor {

  override def receive = {

    case ConvertPictureMessage => {
      // TODO 画像変換処理の実装
      println("画像変換処理を実行")
    }
  }

}
```

このようにアクターだけを実装していく。
ここでは、コンソールに`画像処理を実行`と表示するだけにとどめる。
また、RedisClient と PicturePropertyRepository は後に使うことが分かっているため、もう DI でインスタンスを取得している。

そして次は、アクターにメッセージを送り続けるスケジューラーを実装する。
`infrastructure.actor.ActorSAcheduler` クラスを以下のように実装する。

```scala:app/infrastructure/actor/ActorScheduler.scala
package infrastructure.actor

import javax.inject._

import akka.actor._

import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
import scala.language.postfixOps

@Singleton
class ActorScheduler @Inject()(@Named("convert-picture-actor") convertPictureActor: ActorRef,
                               system: ActorSystem) {

  system.scheduler.schedule(0 milliseconds, 500 milliseconds) {
    convertPictureActor ! ConvertPictureMessage
  }

}
```

ここでは、 @Named アノテーションを利用して、`"convert-picture-actor"` という文字列をクラスに付与した。
この文字列は、後でアクターを DI でバインディングするために利用する。
そしてこのスケジューラー自体は、シングルトンとしてインスタンス化されるように、@Singleton をつけてある。
この ActorScheduler は 500 ms 毎に `ConvertPictureMessage` というメッセージオブジェクトを ConvertPictureActor へ送信する。

次に、Module クラスを編集して、このスケジューラーが動作するようにする。

```scala:Module.scala
import java.time.Clock

import com.google.inject.AbstractModule
import com.google.inject.name.Names
import com.redis.RedisClient
import domain.repository.PicturePropertyRepository
import infrastructure.actor.{ActorScheduler, ConvertPictureActor}
import infrastructure.repository.PicturePropertyRepositoryImpl
import play.api.libs.concurrent.AkkaGuiceSupport
import play.api.{Configuration, Environment}

class Module(environment: Environment,
             configuration: Configuration) extends AbstractModule with AkkaGuiceSupport {

  val redisHost = configuration.get[String]("mojipic.redis.host")
  val redisPort = configuration.get[Int]("mojipic.redis.port")

  def configure() = {
    bind(classOf[Clock]).toInstance(Clock.systemDefaultZone)
    bind(classOf[PicturePropertyRepository]).to(classOf[PicturePropertyRepositoryImpl])
    bind(classOf[RedisClient]).toInstance(new RedisClient(redisHost, redisPort))
    bindActor[ConvertPictureActor]("convert-picture-actor")
    bind(classOf[ActorScheduler])
      .annotatedWith(Names.named("actor-scheduler"))
      .to(classOf[ActorScheduler])
      .asEagerSingleton()
  }
}
```

以上でスケジューラーが動作するようになる。
`.asEagerSingleton()` メソッドは、シングルトンのインスタンスを強制的に作成するというバインディング。
これによりスケジューラーが動作し始める。
また、Akka のアクターを DI としてバインディングする際には、`AkkaGuiceSupport` のトレイトをミックスインする必要がある。
この詳しいやり方は、[アクターの DI に関する公式ドキュメント](https://www.playframework.com/documentation/2.6.x/ScalaAkka#Dependency-injecting-actors)を参照すると良い。

ここまで実装できたら、`sbt run` で起動を行い、http://localhost:9000/ にアクセスして、コンソールに `画像変換処理を実行` と 500 ms 毎に表示され続けるかを確認しよう。
きちんと表示されていれば問題ない。

なお、通信の効率化を行うためには、このような Redis へのポーリングではなく、[Redis の Pub/Sub](https://redis.io/topics/pubsub)の機能を用いて実装することも可能。
だがここでは、再接続処理の実装の簡略化のため、ポーリングを行っている。

# 【実装】画像の変換

無事アクターとスケジューラーが用意できたので、今度は Redis からタスクを取得し、画像を変換する部分を実装していこう。
まずは、ImageMagick を利用するため、[im4java](http://im4java.sourceforge.net/) という Java のライブラリを導入する。
`build.sbt` を以下のように編集後、

```scala:build.sbt
libraryDependencies += "org.scalikejdbc" %% "scalikejdbc-play-dbapi-adapter" % "2.6.0-scalikejdbc-3.0"
libraryDependencies += "mysql" % "mysql-connector-java" % "5.1.36"
libraryDependencies += "net.debasishg" %% "redisclient" % "3.4"
libraryDependencies += "org.im4java" % "im4java" % "1.4.0"

// Adds additional packages into Twirl
//TwirlKeys.templateImports += "jp.ed.nnn.controllers._"
```

Refresh project のリンクをクリックして再度 IDEA 上でのビルドを行う。
これで im4java がプロジェクトに読み込まれる。
そして、画像を変換するためには、一度 MySQL 上に保存してある、書き込む文字列やサイズが含まれた PictureProperty を取得する必要がある。
そのため PicturePropertyRepository も更新する。

find メソッドを実装して、PictureProperty の情報を MySQL から取得できるようにする。
`app/domain/repository/PicturePropertyRepository` を以下のように実装する。

```scala:app/domain/repository/PicturePropertyRepository
    */
  def create(value: PictureProperty.Value): Future[PictureId]

  /**
    * 画像のプロパティを読み込む
    *
    * @param pictureId 画像ID
    * @return Future.successful(PictureProperty)         読み込みに成功した
    */
  def find(pictureId: PictureId): Future[PictureProperty]

}
```

そして実装である、app/infrastructure/repository/PicturePropertyRepositoryImpl にも同様に機能を加える。
この辺りの実装は、[ScalikeJDBC の QueryDSL](http://scalikejdbc.org/documentation/query-dsl.html) のページを見ながら実装していく。

```scala:app/infrastructure/repository/PicturePropertyRepositoryImpl
package infrastructure.repository

import com.google.common.net.MediaType
import domain.entity.{PictureId, PictureProperty, TwitterId}
import domain.repository.PicturePropertyRepository
import scalikejdbc._
```

このように、ScalikeJDBC では SQL を利用して簡潔に書くことが可能。
これで、PicturePropertyRepository に find 機能が実装された。

そのまま、`app/infrastructure/actor/ConvertPictureActor` も実装していく。
[im4java のドキュメント](http://im4java.sourceforge.net/docs/dev-guide.html) を確認しながら、以前行った convert コマンドをそのまま実装として移植していく。

```scala:app/infrastructure/actor/ConvertPictureActor
package infrastructure.actor

import java.nio.file.{FileSystems, Files, Path}
import akka.actor.Actor
import com.google.inject.Inject
import com.redis.RedisClient
import domain.entity.{PictureId, PictureProperty}
import domain.repository.PicturePropertyRepository
import infrastructure.redis.RedisKeys
import org.im4java.core.{ConvertCmd, IMOperation}
import play.Logger
import play.api.Configuration
import scala.util.{Failure, Success, Try}
import scala.concurrent.ExecutionContext.Implicits.global

...

                                     configuration: Configuration
                                   ) extends Actor {

  val convertStoreDirPath = "./filesystem/converted"
  val storeDirPath = FileSystems.getDefault.getPath(convertStoreDirPath)
  if (!Files.exists(storeDirPath)) Files.createDirectories(storeDirPath)

  val imageMagickPath = configuration.get[String]("imagemagick.path")
  val imageMagickFontPath = configuration.get[String]("imagemagick.fontpath")

  override def receive = {

    case ConvertPictureMessage => {
      val stringId = redisClient.lpop[String](RedisKeys.Tasks)
      val pictureId = stringId.map(id => PictureId(id.toLong))
      val pictureProperty = pictureId.map(id => picturePropertyRepository.find(id))
      pictureProperty match {
        case Some(f) => f.foreach(p => convert(p, picturePropertyRepository))
        case None => // ignore Logger.info("Tasks queue is empty.")
      }
    }
  }

  private[this] def convert(pictureProperty: PictureProperty,
                            picturePropertyRepository: PicturePropertyRepository): Unit = {

    val filepath = FileSystems.getDefault.getPath(storeDirPath.toString, System.currentTimeMillis().toString)
    val convertedPictureProperty: PictureProperty = Try {
      invokeCmd(pictureProperty, filepath)
    } match {
      case Success(_) => PictureProperty(pictureProperty.id,
        pictureProperty.value
          .copy(status = PictureProperty.Status.Success,
            convertedFilepath = Some(filepath.toString))
      )
      case Failure(t) => {
        Logger.error("Fail to convert.", t)
        PictureProperty(pictureProperty.id, pictureProperty.value.copy(status = PictureProperty.Status.Failure))
      }
    }

    // TODO 変換後の情報で picture_properties を更新
  }

  private[this] def invokeCmd(property: PictureProperty, convertedFilepath: Path): Unit = {
    val cmd = new ConvertCmd()
    cmd.setSearchPath(imageMagickPath)
    val op = new IMOperation()
    op.addImage(property.value.originalFilepath.getOrElse(""))
    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(convertedFilepath.toAbsolutePath.toString)
    cmd.run(op)
  }

}
```

そして今回は、設定ファイルに ImageMagick の convert コマンドが存在するパスとフォントとのパスを用意しておく。
`conf/application.conf` を自分の環境に合わせた絶対パスで `imagemagick.path` と `imagemagick.fontpath` を設定する。
ここでは、各人の環境にあった設定を行ないように注意。

```scala:conf/application.conf
# Redis settings
mojipic.redis.host="localhost"
mojipic.redis.port=6379

#ImageMagick (Mac)
imagemagick.path="/usr/local/bin/"
imagemagick.fontpath="/Users/${soichiro_yoshimura}/workspace/mojipic/sp-setofont.ttf"

#ImageMagick (Win)
#imagemagick.path="C:\\Program Files\\ImageMagick-7.0.6-Q16\\"
#imagemagick.fontpath="C:\\Users\\${soichiro_yoshimura}\\workspace\\mojipic\\sp-setofont.ttf"
```

そしてこれに合わせる形で、[瀬戸フォント](http://setofont.sourceforge.jp/) をダウンロードして利用する。
`sp-setofont.ttf` ファイルを先程の mojipic プロジェクトのディレクトリに配置しておこう。
ここまで実装できれば、変換のタスクが実行される。

`sbt run` で起動して、画像ファイルをアップロードし、`./filesystem/original` というフォルダに新しいファイルが作られるか確認しよう。
また、そこにできたファイルの拡張子をドラッグ&ドロップした画像の拡張子に変更して、ちゃんとテロップがオーバーレイされているかどうかも確認しよう。
きちんと画像が変換されて、テロップ付きの画像ができていれば成功。

# 【実装】変換後の画像のプロパティ情報の更新

既に、PictureProperty のインスタンス自体は更新してあるので、それを PicturePropertyRepository を利用して MySQL に保存する実装を行う。

まずは、インタフェースである `app/domain/repository/PicturePropertyRepository` を以下のように実装する。

```scala:app/domain/repository/PicturePropertyRepository
    */
  def find(pictureId: PictureId): Future[PictureProperty]

  /**
    * 画像を更新する
    *
    * @param pictureId 画像ID
    * @param value     値
    * @return Future.successful(())               更新に成功した
    */
  def update(pictureId: PictureId, value: PictureProperty.Value): Future[Unit]

}
```

次は、`PicturePropertyRepositoryImpl` の実装。
基本的に create メソッドを多少改変するだけで実装可能。

```scala:app/infrastructure/repository/PicturePropertyRepositoryImpl.scala
      )
    PictureProperty(PictureId(rs.long("picture_id")), value)
  }

  def update(pictureId: PictureId, value: PictureProperty.Value): Future[Unit] =
    Future.fromTry(Try {
      using(DB(ConnectionPool.borrow())) { db =>
        db.localTx { implicit session =>
          val sql =
            sql"""UPDATE picture_properties SET
                 | status =  ${value.status.value},
                 | twitter_id = ${value.twitterId.value},
                 | file_name = ${value.fileName},
                 | content_type = ${value.contentType.toString},
                 | overlay_text = ${value.overlayText},
                 | overlay_text_size = ${value.overlayTextSize},
                 | original_filepath = ${value.originalFilepath.getOrElse("")},
                 | converted_filepath = ${value.convertedFilepath.getOrElse("")},
                 | created_time = ${value.createdTime}
                 | WHERE picture_id = ${pictureId.value}""".stripMargin
          sql.update().apply()
          ()
        }
      }
    })
}
```

そして、リポジトリが実装できたら、それを呼び出す `ConvertPictureActor` を以下のように実装する。

```scala:app/infrastructure/actor/ConvertPictureActor.scala
      }
    }

    picturePropertyRepository.update(
      convertedPictureProperty.id,
      convertedPictureProperty.value
    ).onComplete {
      case Success(_) => Logger.info(s"Converted and updated. convertedPictureProperty: ${convertedPictureProperty}")
      case Failure(t) => Logger.error("Fail to update.", t)
    }
  }

  private[this] def invokeCmd(property: PictureProperty, convertedFilepath: Path): Unit = {
```

これで実装は完成。
`sbt run` で再度画像を投稿してみて、

```:Terminal
$ docker exec -it mojipic-mysql mysql -u root -pmysql
```

より起動する mysql のコンソールより、

```:MySQL_on_Docker
mysql> use mojipic;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> select * from picture_properties;
+------------+------------+------------+----------------------+--------------+--------------+-------------------+-------------------------------------+--------------------------------------+---------------------+
| picture_id | status     | twitter_id | file_name            | content_type | overlay_text | overlay_text_size | original_filepath                   | converted_filepath                   | created_time        |
+------------+------------+------------+----------------------+--------------+--------------+-------------------+-------------------------------------+--------------------------------------+---------------------+
|          1 | converting |   97052502 | a.jpg                | image/jpeg   | LGTM         |                60 | ./filesystem/original/1626958287465 | NULL                                 | 2021-07-22 21:51:28 |
|          2 | converting |   97052502 | a.jpg                | image/jpeg   | LGTM         |                60 | ./filesystem/original/1627047606212 | NULL                                 | 2021-07-23 22:40:06 |
|          3 | converting |   97052502 | a.jpg                | image/jpeg   | LGTM         |                10 | ./filesystem/original/1627047660002 | NULL                                 | 2021-07-23 22:41:00 |
|          4 | converting |   97052502 | fc15d532-private.png | image/png    | LGTM         |                10 | ./filesystem/original/1627047753246 | NULL                                 | 2021-07-23 22:42:33 |
|          5 | success    |   97052502 | HappyFace.jpg        | image/jpeg   | LGTM         |                10 | ./filesystem/original/1627079523674 | ./filesystem/converted/1627079524062 | 2021-07-24 07:32:04 |
+------------+------------+------------+----------------------+--------------+--------------+-------------------+-------------------------------------+--------------------------------------+---------------------+
5 rows in set (0.00 sec)
```

きちんと converted_filepath が更新して値が設定されていることを確認しよう。
以上のように MySQL のレコードが更新されていれば、この画像変換の実装は完成となる。

# 【実装】画像を取得する API

変換された画像を保存するところが全て実装できたので、あとは Web アプリケーションの HTML クライアント上に表示させていくだけ。

まずは認証なしの状態で、この変換された画像が表示できるようにしていこう。
URL 設計とクラス設計に合わせて、routes ファイルと PicturesController クラスの get メソッドを実装していく。
まずは、`conf/routes` を以下のように編集しよう。

```scala:conf/routes
# REST API
+ nocsrf
POST    /pictures                      controllers.PicturesController.post
GET     /pictures/:pictureId           controllers.PicturesController.get(pictureId: Long)

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)
```

そして、`app/controllers/PicturesController` に get メソッドを実装する。
今回は認証が不要なので、`Action.async` を利用して実装する。

```scala:app/controllers/PicturesController
package controllers

import java.io.File
import java.nio.file.{FileSystems, Files, Path, StandardCopyOption}
import java.time.{Clock, LocalDateTime}
import javax.inject.{Inject, Singleton}

import akka.stream.scaladsl.FileIO
import com.google.common.net.MediaType
import com.redis.RedisClient
import domain.entity.{PictureId, PictureProperty, TwitterId}
import domain.repository.PicturePropertyRepository
import infrastructure.redis.RedisKeys
import play.api.cache.SyncCacheApi
import play.api.http.HttpEntity
import play.api.libs.Files.TemporaryFile
import play.api.mvc._
import play.api.mvc.MultipartFormData.FilePart
...
      LocalDateTime.now(clock))
  }

  def get(pictureId: Long) = Action.async { request =>
    val pictureProperty = picturePropertyRepository.find(PictureId(pictureId))
    pictureProperty.map(pictureProperty => {
      pictureProperty.value.convertedFilepath match {
        case Some(convertedFilepath) => {
          val file = new File(convertedFilepath)
          val source = FileIO.fromPath(file.toPath)
          Result(
            header = ResponseHeader(200, Map.empty),
            body = HttpEntity.Streamed(source, None, Some(pictureProperty.value.contentType.toString))
          )
        }
        case None => NotFound
      }
    })
  }

}
```

ここでは get メソッドを、HttpEntitiy.Streamed というクラスを利用して実装している。
これにより、ファイルの内容をそのまま HTTP のボディとして書き出すことが可能。
ここまで実装できたら、先程 MySQL の更新がうまくいった PictureId を利用して、画像が表示できるかを見てみよう。

ここでは、先程の ID が 5 であったため、http://localhost:9000/pictures/5 にアクセスを行う。
自分が追加した画像の ID を利用してアクセスしてみよう。
無事、きちんとテロップがオーバーレイされた画像を表示できれば完了。

# 【実装】画像のプロパティ情報の取得

画像自体の表示ができたので、今度は PictureProperty を取得できる Web API を実装していく。
この Web API は、後々 JavaScript で実装する HTML クライアントから利用する。
実装としては、リポジトリを実装し、コントローラーを実装して動作を確認していく。
また、今回は用意したエンティティが JavaScript で利用しやすいように JSON に変換される実装もしていく。

まずは、URL 設計とクラス設計を参考に `conf/routes` を以下のように編集する。

```scala:conf/routes
+ nocsrf
POST    /pictures                      controllers.PicturesController.post
GET     /pictures/:pictureId           controllers.PicturesController.get(pictureId: Long)
GET     /properties                    controllers.PropertiesController.getAll(last_created_time: Option[String])
GET     /users/:twitterId/properties   controllers.UsersController.getProperties(twitterId: Long, last_created_time: Option[String])

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)
```

`last_created_time` というクエリパラメーターで、指定日時以降の作成日時の画像を取得できるようにした。
これにより通信量を減らすのが目的。

まずは、必要となるリポジトリを実装していく。
最初に `app/domain/repository/PicturePropertyRepository` を以下のように編集する。

```scala:app/domain/repository/PicturePropertyRepository
package domain.repository

import java.time.LocalDateTime

 import domain.entity.{PictureId, PictureProperty, TwitterId}

import scala.concurrent.Future
...
    */
  def update(pictureId: PictureId, value: PictureProperty.Value): Future[Unit]


  /**
    * 投稿者のTwitter IDと最後に読み込まれた作成日時から画像のプロパティを読み込む
    *
    * @param twitterId       投稿者のTwitter ID
    * @param lastCreatedTime 最後に読み込まれた作成日時
    * @return Future.successful(Seq(PictureProperty)) 読み込みに成功した
    */
  def findAllByTwitterIdAndDateTime(twitterId: TwitterId, lastCreatedTime: LocalDateTime): Future[Seq[PictureProperty]]

  /**
    * 最後に読み込まれた作成日時から画像のプロパティを読み込む
    *
    * @param lastCreatedTime 最後に読み込まれた作成日時
    * @return Future.successful(Seq(PictureProperty)) 読み込みに成功した
    */
  def findAllByDateTime(lastCreatedTime: LocalDateTime): Future[Seq[PictureProperty]]

}
```

URL 設計ではクライアントが余計な情報を読み込む必要がないように、最後に取得した日付を持っていれば、プロパティを取得できる実装としている。
そしてこの実装となる `app/infrastructure/repository/PicturePropertyRepositoryImpl` は以下のようになる。

```scala:app/infrastructure/repository/PicturePropertyRepositoryImpl
package infrastructure.repository

import java.time.LocalDateTime


import com.google.common.net.MediaType
import domain.entity.{PictureId, PictureProperty, TwitterId}
import domain.repository.PicturePropertyRepository
import scalikejdbc._
...
        }
      }
    })

  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,
                 | original_filepath,
                 | converted_filepath,
                 | 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()
        }
      }
    })

  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,
                 | original_filepath,
                 | converted_filepath,
                 | created_time
                 | FROM picture_properties WHERE created_time > ${toDateTime} ORDER BY created_time DESC
              """.stripMargin
          sql.map(resultSetToPictureProperty).list().apply()
        }
      }
    })
}
```

基本的に、WHERE を使い最終取得時間で絞ったり、利用者の TwitterID で絞って PictureProperty を取得できるものとなる。
TwitterId で絞り込める findAllByTwitterIdAndDateTime メソッドと、全取得できる findAllByDateTime メソッドと、2 つを実装した。
また、変換中の画像の情報も、クライアントでどの様に表示するか判断ができる様な取得をできる様にしてある。
これでリポジトリは完成。

あとは、`PropertiesController` と `UsersController` を実装していこう。
利用しやすい Web API とするため、認証をしなくても利用できる実装にする。
まずは、`app/controllers/PropertiesController.scala` を以下のように実装する。

```scala:app/controllers/PropertiesController.scala
package controllers

import java.time.LocalDateTime
import javax.inject.{Inject, Singleton}

import domain.repository.PicturePropertyRepository
import play.api.libs.json.Json
import play.api.mvc.{AbstractController, ControllerComponents}

@Singleton
class PropertiesController @Inject()(cc: ControllerComponents,
                                     picturePropertyRepository: PicturePropertyRepository,
                                    ) extends AbstractController(cc) {

  implicit val ec = cc.executionContext

  def getAll(lastCreatedDate: Option[String]) = Action.async {
    val localDateTime = lastCreatedDate.map(LocalDateTime.parse).getOrElse(LocalDateTime.parse("0000-01-01T00:00:00"))
    picturePropertyRepository.findAllByDateTime(localDateTime).map(properties => {
      Ok(Json.toJson(properties)).as("application/json")
    })
  }
}
```

今回は、取得したデータを JSON で返す様に実装している。
なお、エンティティを JSON で返すには、 `play.api.libs.json.Json` の仕様に合わせて、後でエンティティのコンパニオンオブジェクトに implicit パラメーターとして、Writes のインスタンスを設定する必要がある。
これは後で実装する。

次に、`app/controllers/UsersController.scala` を以下の様に実装する。

```scala:app/controllers/UsersController.scala
package controllers

import java.time.LocalDateTime
import javax.inject.{Inject, Singleton}

import domain.entity.TwitterId
import domain.repository.PicturePropertyRepository
import play.api.libs.json.Json
import play.api.mvc.{AbstractController, ControllerComponents}

@Singleton
class UsersController @Inject()(cc: ControllerComponents,
                                picturePropertyRepository: PicturePropertyRepository,
                               ) extends AbstractController(cc) {

  implicit val ec = cc.executionContext

  def getProperties(twitterId: Long, lastCreatedTime: Option[String]) = Action.async {
    val localDateTime = lastCreatedTime.map(LocalDateTime.parse).getOrElse(LocalDateTime.parse("0000-01-01T00:00:00"))
    picturePropertyRepository.findAllByTwitterIdAndDateTime(TwitterId(twitterId), localDateTime).map(properties => {
      Ok(Json.toJson(properties)).as("application/json")
    })
  }

}
```

これは、TwitterId で絞り込みできる。

そして次に、各種エンティティに implicit パラメーターを実装する。
これは、Play Framework の持つ JSON の機能。
ここでは、implicit パラメーターとして Writes 型のインスタンスを定義する。
実装方法は、[Play JSON のドキュメント](https://www.playframework.com/documentation/2.6.x/ScalaJson) を読みながら進めていく。

まずは、`PictureId` を以下の様に実装する。

```scala:app/domain/entity/PictureId.scala
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))
}
```

このように、ID を JSON の String とすることを定義する。
次は、`TwitterId`。

```scala:app/domain/entity/TwitterId.scala
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))
}
```

PictureId と同様の実装を行う。
次に、`PictureProperty`を以下のように実装する。

```scala:app/domain/entity/PictureProperty.scala
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 originalFilepath 変換前ファイルのパス
    * @param convertedFilepath 変換後ファイルのパス
    * @param createdTime 投稿された時間
    */
  case class Value(
                    status: Status,
                    twitterId: TwitterId,
                    fileName: String,
                    contentType: MediaType,
                    overlayText: String,
                    overlayTextSize: Int,
                    originalFilepath: Option[String],
                    convertedFilepath: Option[String],
                    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]
}
```

こちらでも同様に、Writes のインスタンスを用意して設定する。
これにより、先程の Controller でエンティティをそのまま JSON のレスポンスとして返すことができる。

以上で実装は完了。
無事ここまで実装できたら、実際に実行して試してみよう。
`sbt run` で実行して、まずは全ての PictureProperty を取得する。
http://localhost:9000/properties にアクセスし、

```scala
[{"id":"5","value":{"status":"Success","twitterId":"97052502","fileName":"HappyFace.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":10,"originalFilepath":"./filesystem/original/1627079523674","convertedFilepath":"./filesystem/converted/1627079524062","createdTime":"2021-07-24T07:32:04"}},{"id":"4","value":{"status":"Converting","twitterId":"97052502","fileName":"fc15d532-private.png","contentType":"image/png","overlayText":"LGTM","overlayTextSize":10,"originalFilepath":"./filesystem/original/1627047753246","createdTime":"2021-07-23T22:42:33"}},{"id":"3","value":{"status":"Converting","twitterId":"97052502","fileName":"a.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":10,"originalFilepath":"./filesystem/original/1627047660002","createdTime":"2021-07-23T22:41:00"}},{"id":"2","value":{"status":"Converting","twitterId":"97052502","fileName":"a.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":60,"originalFilepath":"./filesystem/original/1627047606212","createdTime":"2021-07-23T22:40:06"}},{"id":"1","value":{"status":"Converting","twitterId":"97052502","fileName":"a.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":60,"originalFilepath":"./filesystem/original/1626958287465","createdTime":"2021-07-22T21:51:28"}}]
```

のようなデータが表示されれば成功。
ではここで取得された `createdTime` を利用して、http://localhost:9000/properties?last_created_time=2021-07-22T21:51:28 などの URL を作成、きちんと `last_created_time` のクエリで絞り込めることを確かめよう。

```
[{"id":"5","value":{"status":"Success","twitterId":"97052502","fileName":"HappyFace.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":10,"originalFilepath":"./filesystem/original/1627079523674","convertedFilepath":"./filesystem/converted/1627079524062","createdTime":"2021-07-24T07:32:04"}},{"id":"4","value":{"status":"Converting","twitterId":"97052502","fileName":"fc15d532-private.png","contentType":"image/png","overlayText":"LGTM","overlayTextSize":10,"originalFilepath":"./filesystem/original/1627047753246","createdTime":"2021-07-23T22:42:33"}},{"id":"3","value":{"status":"Converting","twitterId":"97052502","fileName":"a.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":10,"originalFilepath":"./filesystem/original/1627047660002","createdTime":"2021-07-23T22:41:00"}},{"id":"2","value":{"status":"Converting","twitterId":"97052502","fileName":"a.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":60,"originalFilepath":"./filesystem/original/1627047606212","createdTime":"2021-07-23T22:40:06"}}]
```

このように最後に取得された作成日時を使って新しいものだけに絞り込めれば成功。
また同様にして、twitterId を取得して、これで絞り込めるかも確認しよう。
http://localhost:9000/users/${twitterId}/properties にアクセスしよう。

```
[{"id":"5","value":{"status":"Success","twitterId":"97052502","fileName":"HappyFace.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":10,"originalFilepath":"./filesystem/original/1627079523674","convertedFilepath":"./filesystem/converted/1627079524062","createdTime":"2021-07-24T07:32:04"}},{"id":"4","value":{"status":"Converting","twitterId":"97052502","fileName":"fc15d532-private.png","contentType":"image/png","overlayText":"LGTM","overlayTextSize":10,"originalFilepath":"./filesystem/original/1627047753246","createdTime":"2021-07-23T22:42:33"}},{"id":"3","value":{"status":"Converting","twitterId":"97052502","fileName":"a.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":10,"originalFilepath":"./filesystem/original/1627047660002","createdTime":"2021-07-23T22:41:00"}},{"id":"2","value":{"status":"Converting","twitterId":"97052502","fileName":"a.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":60,"originalFilepath":"./filesystem/original/1627047606212","createdTime":"2021-07-23T22:40:06"}},{"id":"1","value":{"status":"Converting","twitterId":"97052502","fileName":"a.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":60,"originalFilepath":"./filesystem/original/1626958287465","createdTime":"2021-07-22T21:51:28"}}]
```

またここでも同様に `last_created_time` のクエリで絞り込めることも確かめよう。
http://localhost:9000/users/${twitterId}/properties?last_created_time=2017-10-24T23:21:45 のように取得した `createdTime` を利用して URL を作成してアクセスしてみよう。

```
[{"id":"5","value":{"status":"Success","twitterId":"97052502","fileName":"HappyFace.jpg","contentType":"image/jpeg","overlayText":"LGTM","overlayTextSize":10,"originalFilepath":"./filesystem/original/1627079523674","convertedFilepath":"./filesystem/converted/1627079524062","createdTime":"2021-07-24T07:32:04"}}]
```

以上のように絞り込むことができれば成功。


これで Web API の実装が完了した。
Scala 側の実装はこれが全てとなるので、あとは JavaScript でクライアントの実装を行う。

# 【実装】フロントのコンポーネントの実装

ここからは [React.js](https://reactjs.org/docs/rendering-elements.html) を利用して、画像一覧を実装していく。
React.js は HTML の UI 部品をコンポーネントとして描画するライブラリ。
Virtual DOM という DOM を描画するために必要なモデルを利用して HTML を描画する。
また、[JSX](https://reactjs.org/docs/glossary.html#jsx) という JavaScript 内に HTML を記述できる記法を用いることで、簡潔に JavaScript 内から DOM を生成可能。

では実際に、作成した Web API を利用して、画像一覧を作るコードを実装する。
既に、`views/index.scala.html` に画像一覧を表示するための

```html:views/index.scala.html
<div id="picture-grid" class="row center">
</div>
```

以上の div 要素が用意されているので、ここに描画する。
以下のように `views/index.js` を実装できる。

```javascript:views/index.js
import $ from 'jquery';
import 'materialize-css';
import Dropzone from 'dropzone';
import React from 'react';
import ReactDOM from 'react-dom';

// Dropzoneのドラッグ&ドロップエリアの設定
Dropzone.options.filedropzone = {
  paramName: 'file', // The name that will be used to transfer the file
  maxFilesize: 2, // MB
  dictDefaultMessage: '',
  thumbnailHeight: 200,
  thumbnailWidth: 200,
  init: function () {
    this.on('addedfile', function (file) {
      $('#overlaytext').val($('#overlaytext-shown').val());
      $('#overlaytextsize').val($('#overlaytextsize-shown').val());
    });
  },
  accept: function (file, done) {
    done();
    $('.dz-details').remove();
    $('.dz-progress').remove();
    $('.dz-error-message').remove();
    $('.dz-success-mark').remove();
    $('.dz-error-mark').remove();
  }
};

// Materializedの設定
$(function () {
  // Materialized Menu
  $('.button-collapse').sideNav();
}); // end of document ready

/**
 * Reactの画像表示エリア部品定義
 */
class Pictures extends React.Component {

  constructor(props) {
    super(props);
    this.lastCreatedTime = null; // 最後に描画したもののcreatedDateの文字列 例: "2015-03-02T00:00:00.000+09:00"
    this.state = { pictures: [] };
  }

  componentDidMount() {
    this.updatePictures();
  }

  updatePictures() {
    var url = '';
    if (Mojipic.twitterId()) { // 認証時
      url = `/users/${Mojipic.twitterId().toString()}/properties`;
    } else { // 非認証時
      url = '/properties';
    }

    fetch(this.appendLastCreatedDate(url)).then((res) => res.json()).then((json) => {
      const pictures = json.filter((p) => p.value.status === 'Success');
      if (pictures.length > 0) {
        this.lastCreatedTime = pictures[0].value.createdTime;
      }
      this.setState((prevState, props) => ({
        pictures: pictures.concat(prevState.pictures)
      }));
    });
  }

  /**
  * もしlastCreatedTimeもしが存在すればurlにパラメータを追加する
  * @param url {String}
  * @returns {String}
  */
  appendLastCreatedDate(url) {
    if (this.lastCreatedTime) { // すでに読み込んでいるもの以降のものを読み込む
      url = url + '?last_created_time=' + encodeURIComponent(this.lastCreatedTime);
    }
    return url;
  }

  render() {
    const pictureItems = this.state.pictures.map((picture) =>
      <div className="col s3" key={picture.id}>
        <div className="card">
          <div className="card-image">
            <a href={'/pictures/' + picture.id}>
              <img src={'/pictures/' + picture.id} height="150px" />
            </a>
          </div>
        </div>
      </div>
    );
    return (<div id="picture-grid" className="row center"> {pictureItems} </div>);
  }
}

// React Componentのレンダリング
ReactDOM.render(
  <Pictures />,
  document.getElementById('picture-grid')
);
```

ここでは、React.js の記法に沿って JavaScript の [class 構文](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Classes) を利用する。
コードを解説していく。

```javascript
/**
 * Reactの画像表示エリア部品定義
 */
class Pictures extends React.Component {

  constructor(props) {
    super(props);
    this.lastCreatedTime = null; // 最後に描画したもののcreatedDateの文字列 例: "2015-03-02T00:00:00.000+09:00"
    this.state = { pictures: [] };
  }
```

以上が React.js におけるコンポーネントの定義クラス。
`React.Component` を継承してこのクラスを定義する必要がある。
コンストラクタには、このクラスのフィールドとなる `lastCreatedTime` と `state` を初期化するコードが含まれる。

```javascript
 componentDidMount() {
    this.updatePictures();
  }
```

以上は、コンポーネントができた際に、updatePictures メソッドを呼び出す実装。

```javascript
  updatePictures() {
    var url = '';
    if (Mojipic.twitterId()) { // 認証時
      url = `/users/${Mojipic.twitterId().toString()}/properties`;
    } else { // 非認証時
      url = '/properties';
    }

    fetch(this.appendLastCreatedDate(url)).then((res) => res.json()).then((json) => {
      const pictures = json.filter((p) => p.value.status === 'Success');
      if (pictures.length > 0) {
        this.lastCreatedTime = pictures[0].value.createdTime;
      }
      this.setState((prevState, props) => ({
        pictures: pictures.concat(prevState.pictures)
      }));
    });
  }
```

コンポーネント作成時に呼び出される updataPictures では、認証時と非認証時で、画像一覧を自分のものを表示するか全てを表示するかが異なるため、それぞれの URL を生成する。
そして URL が生成された後、ブラウザが持つ [Fetch API](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API) を利用することで、AJAX により Web API にアクセスし、画像一覧を取得する。
ここでは status が `Success` のもののみを利用する。
さらに、最も新しい作成日時を取得しておく。
そして、React.js の setState 関数を利用してフィールドを更新する。

```javascript
  /**
  * もしlastCreatedTimeもしが存在すればurlにパラメータを追加する
  * @param url {String}
  * @returns {String}
  */
  appendLastCreatedDate(url) {
    if (this.lastCreatedTime) { // すでに読み込んでいるもの以降のものを読み込む
      url = url + '?last_created_time=' + encodeURIComponent(this.lastCreatedTime);
    }
    return url;
  }
```

以上は、`lastCreatedTime` のフィールドがあったら URL にクエリを追加するメソッド。

```javascript
  render() {
    const pictureItems = this.state.pictures.map((picture) =>
      <div className="col s3" key={picture.id}>
        <div className="card">
          <div className="card-image">
            <a href={'/pictures/' + picture.id}>
              <img src={'/pictures/' + picture.id} height="150px" />
            </a>
          </div>
        </div>
      </div>
    );
    return (<div id="picture-grid" className="row center"> {pictureItems} </div>);
  }
}

// React Componentのレンダリング
ReactDOM.render(
  <Pictures />,
  document.getElementById('picture-grid')
);
```

以上が JSX を利用した、HTML の表現とその描画の呼び出し。
`class` が JSX 上では、`className` となるが、他は生成される HTML と同様のものになる。
pictureItems には JSX によって記述された複数の画像を表すための div 要素が含まれる。
そしてそれを含む形で、`picture-grid` の id を持つ div 要素が描画される。
定義したコンポーネントは、最終的に `ReactDOM.render` メソッドを利用して描画を行う。

では実装ができたら、

```:Terminal
node_modules/.bin/webpack --config conf/webpack.config.js
```

を実行して JavaScript をコンパイルして、動作させてみよう。
無事、画像一覧が表示されていれば成功。
非ログイン時とログイン時で再読み込みしてみよう。
うまく一覧が表示されれば成功。

# 【実装】自動更新されるコンポーネントの実装

ただし今の実装では、再読み込みした時でないと最新の画像が取得されないので、5 s 毎に自動で更新されるよう実装する。
[公式のドキュメントが提供する setInterval メソッドを利用した方法](https://reactjs.org/docs/state-and-lifecycle.html#adding-lifecycle-methods-to-a-class) で、自動更新され、定期的に画像の一覧情報が取得されるようにしよう。
また同じタイミングで、ドラッグ&ドロップエリアに残るサムネイル画像も消す。

`views/index.js` を以下のように実装する。

```javascript:views/index.js

  componentDidMount() {
    this.updatePictures();
    this.timerID = setInterval(
      () => this.updatePictures(),
      5000
    );
  }

  componentWillUnmount() {
    clearInterval(this.timerID);
  }

  updatePictures() {
...
        pictures: pictures.concat(prevState.pictures)
      }));
    });

    $('.dz-preview').hide('slow', function () { $(this).remove(); }); // 最新を読み込んだらアップロード実行したサムネは消す
  }

  /**
```

ここでは、componentDidMount メソッドが呼ばれるタイミングで、5000 ms 毎に updatePictures メソッドを呼び出すタイマーを setInterval 関数で設定する。
そして、そのタイマー ID を取得した時、コンポーネントが消されるタイミングで、タイマーを clearInterval 関数により除去する。
これがあることで、コンポーネントを再利用できるようになる。
また、updatePictures の処理の最後では、アップロードしてドラッグ&ドロップエリアに表示されているサムネイル画像を除去するように実装した。

これで、必要な用件は完全に実装しきった。

```
node_modules/.bin/webpack --config conf/webpack.config.js
```

を実行して JavaScript をコンパイルして、動作させよう。

`sbt run` をして、実際に様々なテロップが様々な大きさで付けられた画像を作成しよう。
また、その一覧を見たり利用できることを確かめてみよう。

これで画像変換サービス mojipic は完成。
`sbt dist` コマンドでデプロイ用のパッケージも作れるので、自分が用意したサーバーなどにデプロイしても良いだろう。

# まとめ

* Play Framework で Akka Actor を DI で利用するためには、Modules クラスに AkkaGuiceSupport をミックスインスる必要がある
* Play Framework では、Writes 型のインスタンスを implicit パラメーターで用意することで、JSON に変換する機能を利用できる
* React.js を利用すると仮想 DOM を利用したコンポーネントを作成でき、そこから DOM を描画できる