アプリケーション層の実装

これまでドメイン層、インフラストラクチャ層と実装をすすめてきたので、最後にアプリケーション層の実装をして、アプリケーションを完成させましょう。

Twitterによるログイン

まずは、

  • ユーザーがTwitterのアカウントでログインして認証することができる
  • ユーザーがログアウトすることができる

以上の要件を実装してみましょう。

Twitter上のアプリケーションの用意

Twitterで認証するためにはTwitter上にアプリケーションをつくる必要があります。まず、Twitterにアカウントを作成ください。すでに持っている方はそれを利用して問題ありません。

それでは、Twitter Developersにアクセスし、下のフッターにあるTOOLSからMamage Your Appという部分をクリックしましょう。

Twitter developers tools

Application ManagementのページでCreate New Appという名前のボタンをクリックすると作成ホームが表示されるので、各種値を入力していきます。実際の入力例は以下のとおり、

とします。soichiro.orgの代わりとして自分が持っているドメインがあればそれを利用してください。なければ世の中のドメインとかぶらなければ好きなものを利用して問題ありません。あとで、各自のhostsファイルに登録して利用するだけです。

最後に、Developer Agreementを確認した後、了承してCreate your Twitter applicationボタンを押してください。無事作成されると、Applicationのページに飛びますので、そのあと、Keys and Access Tokensタブをクリックして、

  • Consumer Key (API Key)
  • Consumer Secret (API Secret)

の値をどこかに控えて置くようにしてください。あとで利用します。なお、Access Levelに関しては、認証にしか利用しないため、Read-onlyで問題無いですが、ツイートなどをしたりするアプリケーションを作る場合には、この部分を変更します。

次に、hostsファイルに先ほどのコールバックを開発のためだけに、localhostのIPを設定するようにしましょう。各OSのプラットフォームに合わせたhostsファイル (*nix系では/etc/hosts)を管理者権限で編集し、

127.0.0.1 mojipic.soichiro.org

などの追記をお願いします。soichiro.orgは、先ほど作成したApplicationで利用したものを設定ください。

routesとapplication.confの記述

まず、routesファイルに必要になりそうなエントリーポイントとそのAPIを記述します。

GET     /login                      controllers.OAuthController.login
GET     /logout                     controllers.OAuthController.logout
GET     /oauth_callback             controllers.OAuthController.oauthCallback(oauth_verifier: Option[String])

以上3つを追記します。また、conf/application.confにも先ほど取得したTwitterアプリケーションのキーとドキュメントルートのURLの設定を追加します。

# Mojipic
mojipic.documentrooturl="http://mojipic.soichiro.org:9000"
mojipic.consumerkey="LAdrAbR8HoDMK9ggZE3WBtJdr"
mojipic.consumersecret="WVOBr4JJ28uzIdTEiXAQnf2I9AjaSQTFsPVuLwSNXpmepK8dMf"

例えばこのような形になります。

Twitter4jを使ったTwitter認証処理

Twitterとの通信にはTwitter4jというライブラリを使います。 Twitter4jを使ってTwitterのOAuth認証をおこなう処理を書いてみます。

Twitter4jはインスタンスに状態を持つライブラリなので、処理の途中でEHCacheにTwitter4jのインスタンスを格納しています。

package infrastructure.twitter

import javax.inject.Inject
import play.api.Configuration
import play.api.cache.CacheApi
import twitter4j.Twitter
import twitter4j.TwitterFactory
import twitter4j.auth.AccessToken
import scala.concurrent.duration._
import scala.util.control.NonFatal

class TwitterAuthenticator @Inject() (
  configuration: Configuration,
  cache: CacheApi
) {

  val CacheKeyPrefixTwitter = "twitterInstance"

  val ConsumerKey = configuration.getString("mojipic.consumerkey")
    .getOrElse(throw new IllegalStateException("mojipic.consumerkey is not set."))
  val ConsumerSecret = configuration.getString("mojipic.consumersecret")
    .getOrElse(throw new IllegalStateException("mojipic.consumersecret is not set."))

  private[this] def cacheKeyTwitter(sessionId: String): String = CacheKeyPrefixTwitter + sessionId

  /**
   * Twitterの認証を開始する
   * @param sessionId Twitterの認証をしたいセッションID
   * @param callbackUrl コールバックURL
   * @return 投稿者に認証してもらうためのURL
   * @throws TwitterException 何らかの理由でTwitterの認証を開始できなかった
   */
  def startAuthentication(sessionId: String, callbackUrl: String): String =
    try {
      val twitter = new TwitterFactory().getInstance()
      twitter.setOAuthConsumer(
        ConsumerKey,
        ConsumerSecret
      )
      val requestToken = twitter.getOAuthRequestToken(callbackUrl)
      cache.set(cacheKeyTwitter(sessionId), twitter, 30.seconds)
      requestToken.getAuthenticationURL
    } catch {
      case NonFatal(e) =>
        throw TwitterException(s"Could not get a request token. SessionId: $sessionId", e)
    }

  /**
   * Twitterのアクセストークンを取得する
   * @param sessionId Twitterの認証をしたいセッションID
   * @param verifier OAuth Verifier
   * @return アクセストークン
   * @throws TwitterException 何らかの理由でTwitterのアクセストークンを取得できなかった
   */
  def getAccessToken(sessionId: String, verifier: String): AccessToken =
    try {
      cache.get[Twitter](cacheKeyTwitter(sessionId)).get.getOAuthAccessToken(verifier)
    } catch {
      case NonFatal(e) =>
        throw TwitterException(s"Could not get an access token. SessionId: $sessionId", e)
    }
}

セッション処理をおこなうActionBuilder

今回のアプリケーションでは、ログイン状態を判別するためにTwitterから得られたAccessTokenをEHCacheに入れ、そのEHCacheのキーをHTTPのCookieに入れて通信することでセッション管理をおこないます。そのセッション処理をおこなうActionBuilderを作っておきましょう。各コントローラーはこの TwitterLoginAction を使うことで、アクセスしてきたユーザーがログイン状態かどうかが判別でき、ログイン状態だった場合はAccessTokenからTwitter IDを取得することができるようになります。

package controllers

import java.util.UUID
import play.api.cache.CacheApi
import play.api.libs.concurrent.Execution.Implicits.defaultContext
import play.api.mvc.ActionBuilder
import play.api.mvc.Controller
import play.api.mvc.Cookie
import play.api.mvc.Request
import play.api.mvc.Result
import play.api.mvc.WrappedRequest
import twitter4j.auth.AccessToken
import scala.concurrent.Future

case class TwitterLoginRequest[A](sessionId: String, accessToken: Option[AccessToken], request: Request[A]) extends WrappedRequest[A](request)

trait TwitterLoginController extends Controller {
  val cache: CacheApi

  val sessionIdName = "mojipic.sessionId"

  def TwitterLoginAction = new ActionBuilder[TwitterLoginRequest] {
    def invokeBlock[A](request: Request[A], block: TwitterLoginRequest[A] => Future[Result]) = {
      val sessionIdOpt = request.cookies.get(sessionIdName).map(_.value)
      val accessToken = sessionIdOpt.flatMap(cache.get[AccessToken])
      val sessionId = sessionIdOpt.getOrElse(UUID.randomUUID().toString)
      val result = block(TwitterLoginRequest(sessionId, accessToken, request))
      result.map(_.withCookies(Cookie(sessionIdName, sessionId, Some(30 * 60))))
    }
  }
}

コントローラー

TwitterAuthenticatorTwitterLoginController を使うと OAuthController は以下のように書くことができます。

package controllers

import javax.inject.Inject
import infrastructure.twitter.TwitterAuthenticator
import infrastructure.twitter.TwitterException
import play.api.Configuration
import play.api.cache.CacheApi
import scala.concurrent.duration._

class OAuthController @Inject() (
  twitterAuthenticator: TwitterAuthenticator,
  configuration: Configuration,
  val cache: CacheApi
) extends TwitterLoginController {

  val documentRootUrl = configuration.getString("mojipic.documentrooturl").getOrElse(
    throw new IllegalStateException("mojipic.documentrooturl is not set.")
  )

  def login = TwitterLoginAction { request =>
    try {
      val callbackUrl = documentRootUrl + routes.OAuthController.oauthCallback(None).url
      val authenticationUrl = twitterAuthenticator.startAuthentication(request.sessionId, callbackUrl)
      Redirect(authenticationUrl)
    } catch {
      case e: TwitterException => BadRequest(e.message)
    }
  }

  def oauthCallback(verifierOpt: Option[String]) = TwitterLoginAction { request =>
    try {
      verifierOpt.map(twitterAuthenticator.getAccessToken(request.sessionId, _)) match {
        case Some(accessToken) =>
          cache.set(request.sessionId, accessToken, 30.minutes)
          Redirect(documentRootUrl + routes.EntryPointController.index().url)
        case None => BadRequest(s"Could not get OAuth verifier. SessionId: ${request.sessionId}")
      }
    } catch {
      case e: TwitterException => BadRequest(e.message)
    }
  }

  def logout = TwitterLoginAction { request =>
    cache.remove(request.sessionId)
    Redirect(documentRootUrl + routes.EntryPointController.index().url)
  }
}

このコントローラーの動作を説明します。

まず login メソッドで TwitterLoginAction で付与されたセッションIDと TwitterAuthenticator を使ってTwitter認証を開始します。 TwitterAuthenticator はPlayのキャッシュにTwitterのインスタンス自体をセッションIDをキーに保存し、認証用のページのURLを返すので、そのままリダイレクトします。 Twitterの認証のページとしては以下の様なページが表示されます。

Twitter authrize

無事以上のページで認証されると、oauthVerifierの文字列と共にoauthCallbackの実装にコールバックがやってきます。ここでは、先ほどセッションIDで格納されたTwitterのインスタンスをPlayのキャッシュから取得し、 twitterに認証した証となるAccessTokenを取得しています。

さらにこのAccessTokenのインスタンスも、同様にセッションIDを利用して30分で揮発する設定で、 Playのキャッシュ上に保存します。最後に無事認証が終わったのでトップページにリダイレクトを行っています。

logout/logoutに直接URLアクセスしないと利用できませんが、実装します。実装は簡単で、AccessTokenをキャッシュ上から除去してトップページにリダイレクトしているだけです。

以上が、Twitter4jを利用したOAuth1.0aによるTwitterアカウントのサインインによる実装となります。

REST API

次にフロントエンドとデータをやりとりするREST APIを作成します。今回はエンティティをそのままJSONにして返してしまいます。一般的にはエンティティとは別にAPI用のデータ構造を定義したほうがよいと思います。

コントローラーの実装はドメイン層で定義したサービスを呼び出すだけなので特筆すべきことはありません。

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])

画像の投稿と、変換後の画像の取得をするコントローラー

画像の投稿をおこなう post では TwitterLoginAction を使ってTwitter認証されたかどうかをチェックしています。

package controllers

import java.time.Clock
import java.time.LocalDateTime
import javax.inject.Inject
import com.google.common.io.Files
import com.google.common.net.MediaType
import domain.entity.PictureId
import domain.entity.PictureProperty
import domain.entity.TwitterId
import domain.exception.ConversionFailureException
import domain.exception.ConvertingException
import domain.exception.DatabaseException
import domain.exception.InvalidContentTypeException
import domain.exception.PictureNotFoundException
import domain.service.GetPictureService
import domain.service.PostPictureService
import play.api.cache.CacheApi
import play.api.libs.Files.TemporaryFile
import play.api.mvc.Action
import play.api.mvc.MultipartFormData
import play.api.mvc.MultipartFormData.FilePart
import scala.concurrent.ExecutionContext
import scala.concurrent.Future

class PicturesController @Inject() (
  postPictureService: PostPictureService,
  getPictureService: GetPictureService,
  clock: Clock,
  executionContext: ExecutionContext,
  val cache: CacheApi
) extends TwitterLoginController {

  implicit val ec = executionContext

  private[this] def createPictureProperty(
    twitterId: TwitterId,
    file: FilePart[TemporaryFile],
    form: MultipartFormData[TemporaryFile]
  ): PictureProperty.Value = {
    val overlayText = form.dataParts.get("overlaytext").flatMap(_.headOption).getOrElse("")
    val overlayTextSize = form.dataParts.get("overlaytextsize").flatMap(_.headOption).getOrElse("60").toInt
    val contentType = MediaType.parse(file.contentType.getOrElse("application/octet-stream"))
    PictureProperty.Value(
      PictureProperty.Status.Converting,
      twitterId,
      file.filename,
      contentType,
      overlayText,
      overlayTextSize,
      LocalDateTime.now(clock))
  }

  def post = TwitterLoginAction.async { request =>
    (request.accessToken, request.body.asMultipartFormData) match {
      case (Some(accessToken), Some(form)) =>
        form.file("file") match {
          case Some(file) =>
            val property = createPictureProperty(TwitterId(accessToken.getUserId), file, form)
            postPictureService
              .post(Files.toByteArray(file.ref.file), property)
              .map(_ => Ok)
              .recover {
              case e: InvalidContentTypeException => BadRequest(e.message)
              case e: DatabaseException => InternalServerError(e.message)
              case e: ConversionFailureException => InternalServerError(e.message)
            }
          case None =>
            Future.successful(BadRequest("File parameter is not found"))
        }
      case (None, _) =>
        Future.successful(Unauthorized("Need to login by Twitter to post a picture"))
      case _ =>
        Future.successful(BadRequest("Body is not found"))
    }
  }

  def get(pictureId: Long) = Action.async { request =>
    (for {
      (converted, property) <- getPictureService.get(PictureId(pictureId))
    } yield Ok(converted.binary).as(property.value.contentType.toString)).recover {
      case e: PictureNotFoundException => NotFound(e.message)
      case e: ConversionFailureException => BadRequest(e.message)
      case e: ConvertingException => BadRequest(e.message)
    }
  }
}

画像のプロパティの取得をするコントローラー

package controllers

import java.time.LocalDateTime
import javax.inject.Inject
import domain.exception.DatabaseException
import domain.service.GetPicturePropertiesService
import play.api.libs.json.Json
import play.api.mvc.Action
import play.api.mvc.Controller
import scala.concurrent.ExecutionContext

class PropertiesController @Inject() (
  getPicturePropertiesService: GetPicturePropertiesService,
  executionContext: ExecutionContext
) extends Controller {

  implicit val ec = executionContext

  def getAll(lastCreatedDate: Option[String]) = Action.async {
    val localDateTime = lastCreatedDate.map(LocalDateTime.parse).getOrElse(LocalDateTime.parse("0000-01-01T00:00:00"))
    (for {
      properties <- getPicturePropertiesService.getAll(localDateTime)
    } yield {
      Ok(Json.toJson(properties)).as("application/json")
    }).recover {
      case e: DatabaseException => InternalServerError(e.message)
    }
  }
}

投稿者が投稿した画像のプロパティの一覧を取得するコントローラー

package controllers

import java.time.LocalDateTime
import javax.inject.Inject
import domain.entity.TwitterId
import domain.exception.DatabaseException
import domain.service.GetPicturePropertiesService
import play.api.libs.json.Json
import play.api.mvc.Action
import play.api.mvc.Controller
import scala.concurrent.ExecutionContext

class UsersController @Inject() (
  getPicturePropertiesService: GetPicturePropertiesService,
  executionContext: ExecutionContext
) extends Controller {

  implicit val ec = executionContext

  def getProperties(twitterId: Long, lastCreatedTime: Option[String]) = Action.async {
    val localDateTime = lastCreatedTime.map(LocalDateTime.parse).getOrElse(LocalDateTime.parse("0000-01-01T00:00:00"))
    (for {
      properties <- getPicturePropertiesService.getAllByTwitterId(TwitterId(twitterId), localDateTime)
    } yield {
      Ok(Json.toJson(properties)).as("application/json")
    }).recover {
      case e: DatabaseException => InternalServerError(e.message)
    }
  }
}

トップページとフロントエンド

そして、最後にトップページとフロントエンドのページとJavaScriptを作成し、アプリケーションを完成させます。こちらは非常に素朴な実装なので、説明は割愛します。

package controllers

import javax.inject.Inject
import play.api.cache.CacheApi

class EntryPointController @Inject() (
  val cache: CacheApi
) extends TwitterLoginController {
  def index = TwitterLoginAction { request =>
    Ok(views.html.index(request.accessToken))
  }
}
@(accessToken: Option[twitter4j.auth.AccessToken])
<html>
    <head>
        <meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>
        <title>MOJIPIC</title>
        <link rel="stylesheet" type="text/css" href="@routes.Assets.at("stylesheets/index.css")">
    </head>
    <body>
        <div id="container">
            <div id="header">
                <a href="/" ><img border="0" src="@routes.Assets.at("images/MOJIPIC-logo.png")" title="MOJIPIC" alt="MOJIPIC"></a>
            </div>
            <div id="navigation">
                <ul>
                    <li>
                    @if(accessToken.isEmpty) {
                        <a href="login">twitterでログイン</a>
                    } else {
                        <a href="logout">ログアウト</a>
                    }
                    </li>
                </ul>
            </div>
            <div id="content">
            @if(accessToken.isDefined) {
                <h2>
                    画像をドラッグ&ドロップ
                </h2>
                <div id="filedropzone-wrapper">
                    <form  action="/pictures" class="dropzone" id="filedropzone">
                        <input type="hidden" id="overlaytext" name="overlaytext">
                        <input type="hidden" id="overlaytextsize" name="overlaytextsize">
                    </form>
                </div>
                <div id="orverlaysettings">
                    テロップ: <input type="text" id="overlaytext-shown" name="overlaytext-shown" size="40" maxlength="20" value="LGTM">
                    文字サイズ: <input type="text" id="overlaytextsize-shown" name="overlaytextsize-shown" size="5" maxlength="5" value="60">
                </div>
                <p>
            <h2>
                @{accessToken.get.getScreenName}さんの画像一覧
            </h2>
                <div id="picture-grid">
                    <ul>
                    </ul>
                </div>
            } else {
                <h2>
                    最近投稿された画像一覧
                </h2>
                <div id="picture-grid">
                    <ul>
                    </ul>
                </div>
            }
            </div>
        </div>
    </body>
    <script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
    <script src="@routes.Assets.at("javascripts/dropzone.js")"></script>
    <script src="@routes.Assets.at("javascripts/mojipic.js")"></script>
    <script>
    var Mojipic = (function(){
    var accessTokenUserId = @{accessToken.map(_.getUserId).getOrElse("null")};
        return {
            twitterId : function(){return accessTokenUserId;}
        };
    })();
    </script>
</html>
Error: file not found: /Users/seitaro_yuki/github/scala_text/gitbook/example_projects/mojipic/public/javascripts/mojipic.js

スケールできるような再設計

今回は、いろいろな部分のパフォーマンスを犠牲にしながら簡単に実装してきました。仮にもし、このサービスが大ヒットしてしまって、大規模なアクセスに耐えうるようにするにはどのようにすればよいでしょうか。

気にすべきボトルネックは

  1. CPUボトルネック
  2. IOボトルネック

の2つとなります。

このアプリケーションのボトルネックを分析すると

  1. CPUボトルネック
    1. HTTPのコネクション生成
    2. ImageMagicによる変換処理
  2. IOボトルネック
    1. キャッシュへのセッションの読み込み
    2. DBへの読み込み
      1. 画像リストの読み込み
      2. 画像バイナリの読み込み

これらが主要因となります。これらを一気に解消するためには、簡単な戦略としては今作っているオールインワンの構成を複数のマシンで構成するものに変える必要があります。

具体的には、

  • Webフロントサーバー (Play2)
  • ジョブキューサーバー (RabbitMQ)
  • 画像変換処理のみを行うサーバー (Play2, Akka, ImageMagic)
  • セッション管理用のKVSサーバー (twemproxy/Redis, riak, cassandra等の分散可能なKVS)
  • 画像メタ情報用のRDBサーバー (MySQL, Oracle, PostgresSQL等のマスタ/スレーブ構成可能でインデックスの利用できるRDB)
  • 画像バイナリファイル用の大容量ファイルサーバー

以上のような6種類のサーバー構成にし、以上であげたようなボトルネックを解消できるように構成します。

Webフロントに関しては、裏側のデータストアが全て中央管理にすることで増やすことができるようになります。ジョブキューサーバーに関しては、すでにRabbitMQがありますのでそれをそのまま独立させることが可能です。画像変換処理は非常にCPUコストの高い処理なのでこれも別にサーバーを用意してしまいます。すでにAkka Actorで構成されているため、Remote Actorなどを利用することが可能です。

最後にデータストア系は、

  • 高速な読み込みが求められるものにKVSを利用
  • 複雑な検索が求められるものにRDBを利用
  • データ量の多いものにファイルサーバーを利用

以上のような特化させた使い分けによって、高速化し更にスケールできるようにします。このような対応をすれば仮に大きなサービスになった際にも運用していくことは可能です。

最後に

ここまで実装お疲れ様でした。まだ時間に余裕のある方は、このアプリケーションを改造しておもしろいアプリケーションを作ってみるとよいでしょう。 ImageMagickにはgifアニメーションを作る機能があるのでそれを使って画像上に流れるコメントを作ってみたり、画像にお気に入りの印をつけて、それでソートして表示するような機能を作ってみるとよいかもしれません。

results matching ""

    No results matching ""