Using ZIO's Ref to ensure a singleton in the Environment
In a previous post I’ve shown how to use ZIO environments to provide your program with dependencies, or modules. While using environments at the customer I’m currently working for, we found out that the logic to get a database session object using a module would run over and again. This makes sense, since a ZIO[R, E, A] is a prescribed way of getting an A, and the result is not cached. Our application was reading configuration files and creating SQL sessions on every module call, while the resulting object was obviously constructed from the same underlying values. There are multiple ways to solve this:
-
Creating the singleton objects before running you application logic.
-
Caching the result of the loading code in a reference.
In this post I’ve chosen the latter, because I wanted to show the use of ZIO’s Ref
. Also, I like how semantically the desired data and the logic of retrieving it belong together.
Ref to the rescue!
Going back to a simple application that has a ConfigurationModule
(like here), when we ask the module for the configuration class a few times, it will load the configuration from the classpath again and again.
I added some console logs to show what is happening here.
import zio._
import zio.clock.Clock
import zio.console.Console
object Main extends App {
def run(args: List[String]): ZIO[ZEnv, Nothing, Int] = program
val logic: ZIO[Console with ConfigurationModule, Nothing, Int] = (for {
_ <- console.putStrLn(s"I'm running!")
config <- ConfigurationModule.factory.configuration
_ <- console.putStrLn(s"This is the ${config.appName} application")
config <- ConfigurationModule.factory.configuration
_ <- console.putStrLn(s"Again, this is the ${config.appName} application")
config <- ConfigurationModule.factory.configuration
_ <- console.putStrLn(s"And again, this is still the ${config.appName} application")
} yield 0)
.catchAll(e => console.putStrLn(s"Application run failed $e").as(1))
private val program =
logic.provideSome[Console with Clock] { c =>
new Clock with Console with ConfigurationModule.Live {
override val clock: Clock.Service[Any] = c.clock
override val console: Console.Service[Any] = c.console
}
}
}
trait Live extends ConfigurationModule {
val console: Console.Service[Any]
val configurationModule: ConfigurationModule.Service[Any] = new Service[Any] {
override def configuration: Task[Configuration] = loadHeavyConfigFromFile
}
private def loadHeavyConfigFromFile =
for {
(duration, config) <- ZIO
.fromEither(ConfigSource.default.load[Configuration])
.mapError(e => ConfigurationError(e.toList.mkString(", ")))
.delay(500.milliseconds)
.timed
.provide(Clock.Live)
_ <- console.putStrLn(s"Loaded configuration from file. That took ${duration.toMillis} ms")
} yield config
}
When running Main, it prints:
I'm running!
Loaded configuration from file. That took 841 ms
This is the zio-environment-example application
Loaded configuration from file. That took 505 ms
Again, this is the zio-environment-example application
Loaded configuration from file. That took 502 ms
And again, this is still the zio-environment-example application
Process finished with exit code 0
It would be nice to cache the value that is loaded, so we only need to load it once. ZIO has a great way of achieving this in a functional way: Ref
.
Ref
is a wrapper around an AtomicReference, returns pure values (wrapped in a ZIO[Any, Noting, A]) and is completely thread-safe. We only need the basic methods on it: get
and update
.
Let’s add Ref
to our module:
trait Live extends ConfigurationModule {
val clock: Clock.Service[Any]
val console: Console.Service[Any]
val configRef: Ref[Option[Configuration]]
val configurationModule: ConfigurationModule.Service[Any] = new Service[Any] {
override def configuration: Task[Configuration] = useConfigFromRef.orElse(loadHeavyConfigFromFile)
}
private def useConfigFromRef: IO[ConfigurationError, Configuration] =
for {
configOption <- configRef.get
config <- ZIO.fromOption(configOption).mapError(_ => ConfigurationError("The config isn't loaded"))
_ <- console.putStrLn("The configuration was already loaded. So convenient!")
} yield config
private def loadHeavyConfigFromFile: IO[ConfigurationError, Configuration] =
for {
(duration, config) <- ZIO
.fromEither(ConfigSource.default.load[Configuration])
.mapError(e => ConfigurationError(e.toList.mkString(", ")))
.delay(500.milliseconds)
.timed
.provide(Clock.Live)
_ <- console.putStrLn(s"Loaded configuration from file. That took ${duration.toMillis} ms")
_ <- configRef.update(_ => Some(config))
} yield config
}
There. On initialisation the Ref
will contains a None
, since we did not yet load the configuration. In the loadHeavyConfigFromFile
method, the Ref
is updated with the value of the just created config.
Next time the ConfigurationModule
is called, the Ref
contains the config and don’t need to wait for the heavy loading method.
Because creating a Ref
returns a ZIO, we need to wrap it in a for-comprehension in Main
:
private val program = for {
ref <- Ref.make[Option[Configuration]](None) //initialize the Ref with None here
logic <- logic.provideSome[Console with Clock] { c =>
new Clock with Console with ConfigurationModule.Live {
override val clock: Clock.Service[Any] = c.clock
override val console: Console.Service[Any] = c.console
override val configRef: Ref[Option[Configuration]] = ref
}
}
} yield logic
When we run our application again, the heavy method runs only once:
I'm running!
Loaded configuration from file. That took 849 ms
This is the zio-environment-example application
The configuration was already loaded. So convenient!
Again, this is the zio-environment-example application
The configuration was already loaded. So convenient!
And again, this is still the zio-environment-example application
Process finished with exit code 0
Enjoy using Ref
in your own applications!
The full source code can be found on GitLab.