Skip to content

Commit 3b4232f

Browse files
authored
feat: Implement Redis support via Lettuce (#382)
* feat: Implement Redis support via Lettuce Closes #51 * fix: Compilation error * docs: Add simple Lettuce documentation
1 parent 91d2cff commit 3b4232f

File tree

7 files changed

+262
-0
lines changed

7 files changed

+262
-0
lines changed

build.sbt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ lazy val root = project
2626
jvm,
2727
jvmMicrometer,
2828
jvmPureConfig,
29+
lettuce,
30+
lettucePureConfig,
2931
micrometer,
3032
micrometerJmx,
3133
micrometerJmxPureConfig,
@@ -323,6 +325,23 @@ lazy val jvmPureConfig = project
323325
libraryDependencies += Dependencies.pureConfig
324326
)
325327

328+
lazy val lettuce = project
329+
.in(file("lettuce"))
330+
.settings(BuildSettings.common)
331+
.settings(
332+
name := "sst-lettuce",
333+
libraryDependencies += Dependencies.lettuce
334+
)
335+
336+
lazy val lettucePureConfig = project
337+
.in(file("lettuce-pureconfig"))
338+
.dependsOn(lettuce)
339+
.settings(BuildSettings.common)
340+
.settings(
341+
name := "sst-lettuce-pureconfig",
342+
libraryDependencies += Dependencies.pureConfig
343+
)
344+
326345
lazy val micrometer = project
327346
.in(file("micrometer"))
328347
.settings(BuildSettings.common)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.avast.sst.lettuce.pureconfig
2+
3+
import java.nio.charset.Charset
4+
5+
import cats.syntax.either._
6+
import com.avast.sst.lettuce.LettuceConfig
7+
import com.avast.sst.lettuce.LettuceConfig.{SocketOptions, SslOptions, TimeoutOptions}
8+
import io.lettuce.core.ClientOptions.DisconnectedBehavior
9+
import io.lettuce.core.protocol.ProtocolVersion
10+
import pureconfig.ConfigReader
11+
import pureconfig.error.CannotConvert
12+
import pureconfig.generic.ProductHint
13+
import pureconfig.generic.semiauto.deriveReader
14+
15+
trait ConfigReaders {
16+
17+
implicit protected def hint[T]: ProductHint[T] = ProductHint.default
18+
19+
implicit val lettuceDisconnectedBehaviorConfigReader: ConfigReader[DisconnectedBehavior] = ConfigReader.stringConfigReader.emap {
20+
case "DEFAULT" => DisconnectedBehavior.DEFAULT.asRight
21+
case "ACCEPT_COMMANDS" => DisconnectedBehavior.ACCEPT_COMMANDS.asRight
22+
case "REJECT_COMMANDS" => DisconnectedBehavior.REJECT_COMMANDS.asRight
23+
case unknown =>
24+
CannotConvert(
25+
unknown,
26+
"DisconnectedBehavior",
27+
s"Unknown enum value: ${DisconnectedBehavior.values().map(_.name()).mkString("|")}"
28+
).asLeft
29+
}
30+
31+
implicit val lettuceProtocolVersionConfigReader: ConfigReader[ProtocolVersion] = ConfigReader.stringConfigReader.emap {
32+
case "RESP2" => ProtocolVersion.RESP2.asRight
33+
case "RESP3" => ProtocolVersion.RESP3.asRight
34+
case unknown =>
35+
CannotConvert(
36+
unknown,
37+
"ProtocolVersion",
38+
s"Unknown enum value: ${ProtocolVersion.values().map(_.name()).mkString("|")}"
39+
).asLeft
40+
}
41+
42+
implicit val lettuceCharsetConfigReader: ConfigReader[Charset] = ConfigReader.stringConfigReader.emap { charset =>
43+
Either.catchNonFatal(Charset.forName(charset)).leftMap(ex => CannotConvert(charset, "java.nio.Charset", ex.getMessage))
44+
}
45+
46+
implicit val lettuceSocketOptionsReader: ConfigReader[SocketOptions] = deriveReader
47+
48+
implicit val lettuceSslOptionsReader: ConfigReader[SslOptions] = deriveReader
49+
50+
implicit val lettuceTimeoutOptionsReader: ConfigReader[TimeoutOptions] = deriveReader
51+
52+
implicit val lettuceConfigReader: ConfigReader[LettuceConfig] = deriveReader
53+
54+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.avast.sst.lettuce.pureconfig
2+
3+
import pureconfig.ConfigFieldMapping
4+
import pureconfig.generic.ProductHint
5+
6+
/** Contains [[pureconfig.ConfigReader]] instances with default "kebab-case" naming convention. */
7+
object implicits extends ConfigReaders {
8+
9+
/** Contains [[pureconfig.ConfigReader]] instances with "kebab-case" naming convention.
10+
*
11+
* This is alias for the default `implicits._` import.
12+
*/
13+
object KebabCase extends ConfigReaders
14+
15+
/** Contains [[pureconfig.ConfigReader]] instances with "camelCase" naming convention. */
16+
object CamelCase extends ConfigReaders {
17+
implicit override protected def hint[T]: ProductHint[T] = ProductHint(ConfigFieldMapping(pureconfig.CamelCase, pureconfig.CamelCase))
18+
}
19+
20+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.avast.sst.lettuce
2+
3+
import java.nio.charset.Charset
4+
5+
import com.avast.sst.lettuce.LettuceConfig.{SocketOptions, SslOptions, TimeoutOptions}
6+
import io.lettuce.core.ClientOptions.DisconnectedBehavior
7+
import io.lettuce.core.protocol.ProtocolVersion
8+
import io.lettuce.core.{ClientOptions, SocketOptions => LettuceSocketOptions, TimeoutOptions => LettuceTimeoutOptions}
9+
10+
import scala.concurrent.duration.Duration
11+
12+
final case class LettuceConfig(
13+
uri: String,
14+
pingBeforeActivateConnection: Boolean = ClientOptions.DEFAULT_PING_BEFORE_ACTIVATE_CONNECTION,
15+
autoReconnect: Boolean = ClientOptions.DEFAULT_AUTO_RECONNECT,
16+
cancelCommandsOnReconnectFailure: Boolean = ClientOptions.DEFAULT_CANCEL_CMD_RECONNECT_FAIL,
17+
suspendReconnectOnProtocolFailure: Boolean = ClientOptions.DEFAULT_SUSPEND_RECONNECT_PROTO_FAIL,
18+
requestQueueSize: Int = ClientOptions.DEFAULT_REQUEST_QUEUE_SIZE,
19+
disconnectedBehavior: DisconnectedBehavior = DisconnectedBehavior.DEFAULT,
20+
protocolVersion: Option[ProtocolVersion] = None,
21+
scriptCharset: Charset = ClientOptions.DEFAULT_SCRIPT_CHARSET,
22+
publishOnScheduler: Boolean = ClientOptions.DEFAULT_SUSPEND_RECONNECT_PROTO_FAIL,
23+
socketOptions: SocketOptions = SocketOptions(),
24+
sslOptions: SslOptions = SslOptions(),
25+
timeoutOptions: TimeoutOptions = TimeoutOptions()
26+
)
27+
28+
object LettuceConfig {
29+
30+
final case class SocketOptions(
31+
connectTimeout: Duration = Duration.fromNanos(LettuceSocketOptions.DEFAULT_CONNECT_TIMEOUT_DURATION.toNanos),
32+
keepAlive: Boolean = LettuceSocketOptions.DEFAULT_SO_KEEPALIVE,
33+
tcpNoDelay: Boolean = LettuceSocketOptions.DEFAULT_SO_NO_DELAY
34+
)
35+
36+
final case class SslOptions(
37+
keyStoreType: Option[String] = None,
38+
keyStorePath: Option[String] = None,
39+
keyStorePassword: Option[String] = None,
40+
trustStorePath: Option[String] = None,
41+
trustStorePassword: Option[String] = None
42+
)
43+
44+
final case class TimeoutOptions(timeoutCommands: Boolean = LettuceTimeoutOptions.DEFAULT_TIMEOUT_COMMANDS)
45+
46+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.avast.sst.lettuce
2+
3+
import java.io.File
4+
import java.time.Duration
5+
6+
import cats.effect.{Async, Resource, Sync}
7+
import cats.syntax.either._
8+
import io.lettuce.core.api.StatefulRedisConnection
9+
import io.lettuce.core.codec.RedisCodec
10+
import io.lettuce.core.resource.ClientResources
11+
import io.lettuce.core.{ClientOptions, RedisClient, RedisURI, SocketOptions, SslOptions, TimeoutOptions}
12+
13+
object LettuceModule {
14+
15+
/** Makes [[io.lettuce.core.RedisClient]] initialized with the given config and optionally [[io.lettuce.core.resource.ClientResources]]. */
16+
def makeClient[F[_]: Sync](config: LettuceConfig, clientResources: Option[ClientResources] = None): Resource[F, RedisClient] = {
17+
val create = clientResources match {
18+
case Some(resources) => RedisClient.create(resources)
19+
case None => RedisClient.create()
20+
}
21+
val sync = Sync[F]
22+
Resource.make {
23+
sync.delay {
24+
val client = create
25+
client.setOptions(makeClientOptions(config))
26+
client
27+
}
28+
}(c => sync.delay(c.shutdown()))
29+
}
30+
31+
/** Makes [[io.lettuce.core.api.StatefulRedisConnection]] initialized with the given config and optionally [[io.lettuce.core.resource.ClientResources]]. */
32+
def makeConnection[F[_]: Async, K, V](
33+
config: LettuceConfig,
34+
clientResources: Option[ClientResources] = None
35+
)(implicit codec: RedisCodec[K, V]): Resource[F, StatefulRedisConnection[K, V]] = {
36+
makeClient[F](config, clientResources).flatMap { client =>
37+
val async = Async[F]
38+
Resource.make[F, StatefulRedisConnection[K, V]] {
39+
async.asyncF[StatefulRedisConnection[K, V]] { cb =>
40+
async.delay {
41+
client
42+
.connectAsync(codec, RedisURI.create(config.uri))
43+
.handle[Unit] { (connection, ex) =>
44+
if (ex == null) {
45+
cb(connection.asRight)
46+
} else {
47+
cb(ex.asLeft)
48+
}
49+
}
50+
()
51+
}
52+
}
53+
}(c => async.delay(c.close()))
54+
}
55+
}
56+
57+
private def makeClientOptions(config: LettuceConfig): ClientOptions =
58+
ClientOptions
59+
.builder()
60+
.pingBeforeActivateConnection(config.pingBeforeActivateConnection)
61+
.autoReconnect(config.autoReconnect)
62+
.cancelCommandsOnReconnectFailure(config.cancelCommandsOnReconnectFailure)
63+
.suspendReconnectOnProtocolFailure(config.suspendReconnectOnProtocolFailure)
64+
.requestQueueSize(config.requestQueueSize)
65+
.disconnectedBehavior(config.disconnectedBehavior)
66+
.protocolVersion(config.protocolVersion.orNull)
67+
.scriptCharset(config.scriptCharset)
68+
.publishOnScheduler(config.publishOnScheduler)
69+
.socketOptions(
70+
SocketOptions
71+
.builder()
72+
.connectTimeout(Duration.ofNanos(config.socketOptions.connectTimeout.toNanos))
73+
.keepAlive(config.socketOptions.keepAlive)
74+
.tcpNoDelay(config.socketOptions.tcpNoDelay)
75+
.build()
76+
)
77+
.timeoutOptions(TimeoutOptions.builder().timeoutCommands(config.timeoutOptions.timeoutCommands).build())
78+
.sslOptions {
79+
val opts = SslOptions
80+
.builder()
81+
.jdkSslProvider()
82+
83+
config.sslOptions.keyStoreType.foreach(opts.keyStoreType)
84+
config.sslOptions.keyStorePath.zip(config.sslOptions.keyStorePassword).foreach { case (path, pass) =>
85+
opts.keystore(new File(path), pass.toCharArray)
86+
}
87+
config.sslOptions.trustStorePath.zip(config.sslOptions.trustStorePassword).foreach { case (path, pass) =>
88+
opts.truststore(new File(path), pass)
89+
}
90+
91+
opts.build()
92+
}
93+
.build()
94+
95+
}

project/Dependencies.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ object Dependencies {
1818
val http4sServer = "org.http4s" %% "http4s-server" % Versions.http4s
1919
val jsr305 = "com.google.code.findbugs" % "jsr305" % "3.0.2"
2020
val kindProjector = "org.typelevel" % "kind-projector" % "0.11.0" cross CrossVersion.full
21+
val lettuce = "io.lettuce" % "lettuce-core" % "6.0.1.RELEASE"
2122
val logbackClassic = "ch.qos.logback" % "logback-classic" % "1.2.3"
2223
val micrometerCore = "io.micrometer" % "micrometer-core" % Versions.micrometerCore
2324
val micrometerJmx = "io.micrometer" % "micrometer-registry-jmx" % Versions.micrometerJmx

site/docs/subprojects/lettuce.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
layout: docs
3+
title: "Lettuce (Redis)"
4+
---
5+
6+
# FS2 Kafka
7+
8+
`libraryDependencies += "com.avast" %% "sst-lettuce" % "@VERSION@"`
9+
10+
This subproject initializes [Lettuce](https://lettuce.io) Redis driver:
11+
12+
```scala mdoc:silent
13+
import cats.effect.Resource
14+
import com.avast.sst.lettuce.{LettuceConfig, LettuceModule}
15+
import io.lettuce.core.codec.{RedisCodec, StringCodec}
16+
import zio._
17+
import zio.interop.catz._
18+
19+
implicit val runtime = zio.Runtime.default // this is just needed in example
20+
21+
implicit val lettuceCodec: RedisCodec[String, String] = StringCodec.UTF8
22+
23+
for {
24+
connection <- LettuceModule.makeConnection[Task, String, String](LettuceConfig("redis://localhost"))
25+
value <- Resource.liftF(Task.effect(connection.sync().get("key")))
26+
} yield value
27+
```

0 commit comments

Comments
 (0)