はじめに
PlayFrameworkを使って勉強シリーズ第三弾です。今回はRedisにアクセスしてみます。
ScalaからRedisへアクセスするライブラリはいろいろあるのですが、今回は扱いやすさからscredisを使用しました。
なお、Scala、PlayFramework共に勉強中なため、至らぬ点がありましたら平にご容赦願いたてまつりまつる。
実装
前提
今回使用した各種バージョンは以下の通りです。
名前 | バージョン |
---|---|
Scala | 2.12.4 |
PlayFramework | 2.6.7 |
Redis | 3.0.6 |
scredis | 2.1.1 |
Redis
Redisは、今回、非clusterのMaster-Read Replica構成を想定します。
build.sbt
build.sbtに、必要なライブラリを追記します。
// scredis
resolvers += Resolver.bintrayRepo("jastice", "maven")
libraryDependencies ++= Seq(
"com.github.scredis" %% "scredis" % "2.1.1"
)
Redisアクセス
Redisへの接続方法ですが、今回は、Factoryパターンを使ってscredis.Redisオブジェクトを生成するような形にします。
Factory自体は、Moduleクラスでシングルトンを登録しておき、Injectionして使うような形にします。
シングルトンは、以下のように定義します。
package services
import javax.inject.{Inject, Singleton}
import play.api.Configuration
import scredis.Redis
import scala.util.Random
// PrimaryかRead Replicaへのアクセスかを識別するためのtype
sealed trait RedisType
object RedisTypes {
class WriterType extends RedisType
class ReaderType extends RedisType
implicit case object RedisWriterObject extends WriterType
implicit case object RedisReaderObject extends ReaderType
}
// 公開されたtrait
trait RedisConnector {
def redis[T <: RedisType](implicit redisType: T): Redis
}
// 実装クラス
@Singleton
class DefaultRedisConnector @Inject()(config: Configuration)
extends RedisConnector
{
// configの読み込み
private val defaultPort = config.getOptional[Int]("redis.port").getOrElse(6379)
private val writerHost = config.getOptional[String]("redis.writer.host").get
private val (writerHostName, writerPort) = splitHostAndPort(writerHost)
private val readerHosts =
config.getOptional[String]("redis.reader.host").getOrElse(writerHost).split(",").map(splitHostAndPort)
// read replicaが複数あるときのrandomizer
private lazy val randomizer = Random
// ReaderType, WriterTypeの読み込み
import RedisTypes._
override def redis[T <: RedisType](implicit redisType: T): Redis = {
redisType match {
case _: WriterType => writerClient
case _: ReaderType => selectReaderClient
}
}
private def splitHostAndPort(host: String): (String, Int) =
host.split(":") match {
case Array(hostname, port) => (hostname, port.toInt)
case Array(hostname) => (hostname, defaultPort)
}
private def writerClient: Redis = Redis(writerHostName, writerPort)
private def selectReaderClient: Redis = {
readerHosts.length match {
// To-Do: case 0
case 1 => readerClient(0)
case n if n > 1 => readerClient(randomizer.nextInt(n))
}
}
private def readerClient(n: Int): Redis = {
val (hostName, port) = readerHosts(n)
Redis(hostName, port)
}
}
Module
Moduleクラスに、上記のクラスを登録します。
import services._
class Module extends AbstractModule {
override def configure(): Unit = {
bind(classOf[RedisConnector]).to(classOf[DefaultRedisConnector])
}
}
applicaton.conf
接続先のRedisホストは、application.confに記載することになります。
なお、Read Replicaについては、複数指定が可能になっています。(複数指定されている場合、ランダムに選択する)。
redis {
port: 6379
writer {
host: "redis.hogehoge.ng.0001.apne1.cache.amazonaws.com"
}
reader {
host: "redis-002.hogehoge.0001.apne1.cache.amazonaws.com,redis-003.hogehoge.0001.apne1.cache.amazonaws.com"
}
}
Controllerからの使い方
package controllers
import java.nio.charset.StandardCharsets
import javax.inject._
import play.api.mvc._
import scredis.serialization.{Reader, Writer}
import services.RedisConnector
import scala.concurrent.duration.Duration
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class HogeController @Inject()(cc: ControllerComponents,
rc: RedisConnector,
implicit val ec: ExecutionContext) extends AbstractController(cc)
{
import services.RedisTypes._
// Redisに格納されている(String, Int)をGetするためのReader。なお、文字列に","は含まれていないものとする(手抜き)
implicit val stringIntReader: Reader[(String, Int)] = { (bytes: Array[Byte]) =>
new String(bytes, StandardCharsets.UTF_8).split(",") match {
case Array(strValue, intValue) => (strValue, intValue)
}
}
// Redisに(String, Int)をPutするためのWriter
implicit val stringIntWriter: Writer[(String, Int)] = { (value: (String, Int)) =>
(value._1.toString + "," + value._2.toString).getBytes(StandardChasets.UTF_8)
// ReadReplicaからの読み込み(stringIntReaderが暗黙的に参照される)
private def getValue: Future[Option[(String, Int)]] =
rc.redis[ReaderType].get[(String, Int)]("hogehoge_key")
// Masterへの書き込み(stringIntWriterが暗黙的に参照される)
private def setValue(textValue: String, intValue: Int): Future[Unit] =
rc.redis[WriterType].set("hogehoge_key", (textValue, intValue))
}
DNSキャッシュ(おまけ)
AWS環境では、短期間にDNSクエリが集中すると、途中からエラーを返すようになります (PHPからElastiCacheを参照してて痛い目にあいました)。
(参考) https://dev.classmethod.jp/cloud/aws/amazon-dns-threshold-exceeded-action/
しかし、scredisのベースとなっているakka.ioおよびjavaのInetAddressでは、ローカルにキャッシュするのでこの問題は回避できると思われます。ただ、インターバル時間がデフォルトの30秒では長すぎるといった場合には、これを短くする必要があります。
独自のApplicationLoaderを定義し、そこで定数を書き換えてやることにします。下の例ではDNSクエリ成功時・失敗時ともに2秒に変更しています。
import java.security.Security
import play.api.ApplicationLoader
import play.api.inject.guice.{GuiceApplicationBuilder, GuiceApplicationLoader}
class MyApplicationLoader extends GuiceApplicationLoader {
override protected def builder(context: ApplicationLoader.Context): GuiceApplicationBuilder = {
Security.setProperty("networkaddress.cache.ttl", "2")
Security.setProperty("networkaddress.cache.negative.ttl", "2")
super.builder(context)
}
}
play.application {
loader=MyApplicationLoader
}