Scala
AWS
S3
Play
PlayFramework

Play Framework 2.6 / ScalaでAmazon S3から画像をダウンロードしてブラウザに表示させるまで

バケットとオブジェクトを非公開の状態で、S3をWebサービスの画像ファイルストレージとしてPlay2.6から活用する方法をまとめました。

バケットとオブジェクトを公開した状態であれば、ViewにコンテンツファイルのURLを貼り付けるだけで表示可能なのですが、非公開状態だと、実装上にダウンロードとControllerからViewへInputStreamの受け渡しを書く必要があり、少々厄介だったので参考にしていただければと。

S3のパブリックアクセス設定機能については下記の記事が参考になると思います。
S3で誤ったデータの公開を防ぐパブリックアクセス設定機能が追加されました

準備

事前にaws-java-sdkの依存を、build.sbtに記述しておきます。

build.sbt
libraryDependencies += "com.amazonaws" % "aws-java-sdk" % "1.11.478"

S3からファイルをダウンロード

S3からファイルをダウンロードする実装例はいくつかあると思いますが、以下はその一例です。

import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials}
import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration
import com.amazonaws.services.s3.AmazonS3ClientBuilder
import com.amazonaws.services.s3.model.S3ObjectInputStream
import com.amazonaws.{ClientConfiguration, Protocol}
import com.google.inject.Singleton
import javax.inject.Inject

@Singleton
class S3Service {

  private val accessKey = "xxx"
  private val secretKey = "xxx"
  private val bucketName = "MyBucket"
  private val serviceEndpoint = "s3-ap-northeast-1.amazonaws.com"
  private val regionName = "ap-northeast-1"

  def downloadS3(objectKey: String): S3ObjectInputStream = {

    val s3Client = getClient(bucketName)

    val s3Object = s3Client.getObject(bucketName, objectKey)

    s3Object.getObjectContent
  }

  private def getClient(bucketName: String) = {

    val credentials = new BasicAWSCredentials(accessKey, secretKey)

    val clientConfig = new ClientConfiguration()
    clientConfig.setProtocol(Protocol.HTTPS)
    clientConfig.setConnectionTimeout(10000)

    val endpointConfiguration =
      new EndpointConfiguration(serviceEndpoint, regionName)

    val s3Client = AmazonS3ClientBuilder
      .standard()
      .withCredentials(new AWSStaticCredentialsProvider(credentials))
      .withClientConfiguration(clientConfig)
      .withEndpointConfiguration(endpointConfiguration)
      .build()

    s3Client
  }
}

Controller

ダウンロードしたファイルは、Chunked transfer encodingによってViewへ送ります。
今回のように動的に生成されたストリームなどは事前にサイズを知ることが難しく、メッセージボディの長さの決定を困難にするため、メッセージボディをチャンクという塊に分けて、転送する仕組みがHTTP1.1にはあります。

Chunked transfer encodingの詳細は、以下を参照。
https://www.playframework.com/documentation/2.6.x/ScalaStream

package controllers

import java.io.{File, InputStream}

import akka.stream.scaladsl.{Source, StreamConverters}
import akka.util.ByteString
import javax.inject.Inject
import models._
import play.api.mvc._
import services.amazon.S3Service

import scala.concurrent.ExecutionContext

class ImageController @Inject()(
    cc: MessagesControllerComponents,
    s3Service: S3Service
)(implicit ec: ExecutionContext)
    extends MessagesAbstractController(cc) {

  def image(): Action[AnyContent] = Action { implicit request =>
    val objectKey = ""// get a object key.
    val s3is = s3Service.downloadS3(objectKey)

    s3is match {
      case i: InputStream => {
        val dataContent: Source[ByteString, _] =
          StreamConverters.fromInputStream(() => i)
        Ok.chunked(dataContent)
      }
    }
  }
}

Play2.6でのこの書き方を知らなかったので、ダウンロード後にどうやってViewにストリームを渡すかで結構詰まりました。

View

viewは渡されたファイルオブジェクトを格納する変数をsrc属性に記述するだけでOKです。
素晴らしい、twirl。。

@file = {@routes.ImageController.image()}
@import helper._
<img src="@file"/>

まとめ

Chunked transfer encodingが今回の肝でした。
S3からダウンロード -> Viewへファイル転送という実装を書く必要があり少々面倒なのですが、こう記述すればS3のバケットとオブジェクトを非公開状態で運用できるので参考までに。