cats/cats-effect and webservices function composition
If you use webservices, say in a CLI program, then you are familiar with the following pattern:
// typing in the types to show what they are...
val entityA: IO[Option[A]] = getAByName("aname")
val entityB: IO[Option[B]] = getBByName("bname")
// ...
// useIt does not return an Option.
def useIt(a: A, b: B, ...): IO[D] = { ... }
// how do you call `useIt`?
Here, the input “aname” comes from the user and is not a primary key (PK). The “get” has to search a list and if it finds a result, returns a single object. Or if the name is not found, it returns None
. If the request finds more than 1 value, perhaps it returns None
or signals an error (your choice).
This is a classic problem in scala, that is, the effects returned in the get*ByName
functions have efffect wrappers and we need to unwrap/drill-into them to use the data inside. There are also a few other other concerns that also come up:
- Can we find all the issues with the inputs prior to calling
useIt
so that we can inform the user (it’s a CLI) that there are multiple issues with the parameters they provided? - If
entityC
is needed but requesting it depends onentityA
how do we compose that? - If we need to return an
IO[Unit]
instead of whateveruseIt
returns, how do we do that while still providing output to the user?
cats and cats-effect has machinery to help us out. The documentation suggests that we can treat the “fetching” of values using get*ByName
as a “configuration parameter” type problem. cats recommends using Validated to handle this. Let’s see how this might work.
At the end we will discuss whether if all this composition and use of FP structures was worth it. This post uses content covered in the cats documentation but focuses on a specific use-case.
This post does not cover the use-case of serially fetching remote values where each remote value feeds the parameters of the next fetch in a pure “one parent” cascade. That scenario is handled by using multiple for-comprehensions chained together or shoving the effectful values into a common monad structure (which is often quite messy) and using a single for-comprehension.
Returning Effectful Options is Ok, but not great
Our webservice calls return an optional value wrapped in an effect.
def getAByName(n: Name): IO[Option[A]] = {
// ...
}
We see that the error is carried as a Throwable in IO
(see bifunctor for the latest thoughts on representing errors). This allows us to track errors and and return an optional value.
If there is only one input parameter, aname
we can easily use this:
getAByName("aname")
.flatMap { _.map { a =>
useIt(a).flatMap(_ => IO(println(s"$a used.")))
}
}
.getOrElse(IO(println("No A name found"))
I think this is a bit hard to read, so we could do something like :
getAByName("name")
.flatMap {
case Some(a) => useit(a).flatMap(_ => IO(println(s"$a used.")))
case _ => IO(println("No A name found"))
}
Some people do not like writing out the match this way but sometimes it is easier to read.
A problem arises if we have two input parameters but only one is available, what should we do? That would be multiple match statements and the code would be quite long. We need to be more succinct to ensure that the code is still readable. Expanding out matches (or using getOrElse
) for multiple input parameters can create alot of boilerplate code very quickly.
Let’s think about the problem with multiple input parameters:
val ioa: IO[Option[A]] = getAByName("aname") // returns Some
val iob: IO[Option[B]] = getBByName("bname") // returns None
// Can't use useIt, print a message and return
We want the None
to propagate through naturally so we do not have to write more complicated if-then/match logic explicitly. The obvious thing to do with the outer effect monad, IO, is to use a for-comprehension.
We can use cats
and a monad transformer to create a single for-comprehension. Using both of these approaches allows us to pretend that the outer effect, IO
, is not present and we are working with Option
directly. Unfortunately, the final yield
is wrapped in an IO
so in the end we have IO[IO[Option[D]]
. Remember, useIt
does not return an Option
just a plain D
so we would have to wrap that return value in some way in order to push the final call to useIt
into the for-part of the for-comprehension:
// broken out before "for" for illustratation purposes
val ioa = getAByName("aname")
val iob = getBByName("bname")
val iod = for {
a <- OptionT(ioa)
b <- OptionT(iob)
// if we wanted an Option out of useIt we could
// r <- OptionT.liftF(useIt(a,b)) then yield r
} yield useIt(a, b)
To unwrap it to a IO[Unit]
, our target return type, we can do:
iod.cata(
IO(println("Either a or b did not have a value")),
identity
).flatten.void
cata
allows us to extract out the value but we had to flatten it to get the value out. Do not forget to import cats.implicits._
to use .flatten
and .void
directly as syntax. Using .void
simply maps the effect to Unit
–it hollows out the value but keeps the “shell”.
This approach works but it is not great for the user because we do not know if “aname” or “bname” was invalid. We want check the validity of both. If both are valid, call useIt
but if one of them is not valid because their request returned None
, we want to print a more helpful message indicating which one failed to help the user more quickly re-issue the CLI command.
Note that some people also believe that monad transformers have issues. See getting rid of the T in MTL for more details. Personally, transfomers feel like yet another layer of wrapping a “strategy/adapter design pattern” around yet another value which at some point will cause confusion and complexity (at least confusion and complexity to me).
Validated and ValidatedNel
cats contains Validated which helps with this use-case. The idea is to use something like an Either
so that we can return a user message on the Left
or the value on the Right
but we need to ensure that for each input parameter, we collect “user messages” across all input parameters. If we have several input parameters and 3 return None
but 4 return Some
, then we want to print messages about those 3 None
s . “Collecting” each message means that the “Left” needs be a non-empty list of “messages”—in this case a non-empty list of strings. Instead of scala’s Either
we should use cats’ ValidatedNel[String, A]
which expands out to Validate[NonEmptyList[String], A]
.
One problem we have is that our input parameters return IO[Option[A]]
but we need a IO[ValidatedNel[String, A]]
. If we used OptionT(returned valued)
to create the transformer for our for-comprehension as described in the previous section, what do we use now?
cats
documentation describes a conversion from Option[A] => ValidatedNel[?, A]
available in their “syntax” classes. So we can do something like:
val iova = getAByName("aname").map(_.toValidNel[String](s"$aname not found"))
val iovb = getAByName("bname").map(_.toValidNel[String](s"$bname not found"))
Unfortunately, we cannot use this in a for-comprehension because ValidatedNel
is not a monad and a for-comprehension requires a “map” and “flatMap” (and “filter”) to use in a “for”.
Let’s think about the problem as “combining” each input parameter’s ValidatedNel
together such that a single “invalid” fails the overall “combining” process although each parameter is validated.
cats has a “mapN” function under Applicative
that does this. But our ValidatedNel
is in an IO
and there is no monad transformer for ValdatedNel
since ValidatedNel
is not a monad. Hence, we need to:
- Use a for-comprehension to work within the IO monad
- Use
(...some tuple...).mapN( ... => )
inside the yield to combine theValidationNel
instances before applying (mapping) them into theuseIt
function.
If you only have one parameter you can use just “map” inside the yield because you can map into the “value” side of a ValidatedNel
without needing to worry about combining them before hand.
The result of using the “for” and .mapN
will be an IO[ValidatedNel[String, IO[D]]]
which is fairly complex looking but we can read it as: “the effect that wraps requesting our input parameters leads to either a list of messages indicating the parameters are invalid or a command to run that is wrapped in the same type of effect as the outer effect.”
val iova = getAByName("aname").map(_.toValidNel[String](s"$aname not found"))
val iovb = getBByName("bname").map(_.toValidNel[String](s"$bname not found"))
val iovc = getCByName("cname").map(_.toValidNel[String](s"$cname not found"))
// use for-comprehension to combine independent parameters
// peel off IO
val iod = for {
va <- iova
vb <- iovb
vc <- iovc
} yield
// use Applicative to combine ValidatedNels
// peel off ValidatedNel
(va,vb,vc).mapN{(a, b, c) =>
// other calculations based on a, b, c
useIt(a,b,c)
}
// we have a IO[ValidatedNel[String, IO[D]]], want IO[Unit] that
// either prints out a message for the user indicating an issue
// with the parameters, or the output of the command useIt.
iod.flatMap(_ fold(
msglist => IO(msglist.toList.foreach(println)),
// Could also useIt.flatMap(...IO(println)...) then use identity here
// The IO in ValidatedNel is "mapped" into.
_.flatMap(d => IO(println(s"Done! $d")))
))
// or
iod.flatMap {
case Validated.Invalid(msglist) => IO(msglist.toList.foreach(println))
case Validated.Valid(outputf) => outputf.map(_ => IO(println(s"Done! $d")))
}
Notice that we had the variable names go from iova
to va
to a
along the way. The variables names clearly suggest that at each level we are peeling away a layer of “container” to get to the inner value.
It is also apparent that all formulations, everywhere, are really about drilling into data structures to perform function composition along the way in either a clever or less-than-clever fashion. Whether you map or wrap (which also maps), it is fundamentally the same thing.
The last line maps into the resulting outer effect and then extracts the final value or, if the input parameters were not found, prints out user messages. It is advised that println
should not be run as plain statement but to ensure that the println
are wrapped in IO
s.
Parameters depending on other parameters
The above assumed that each input parameter was independent of the others, but it is also a common case that one input parameter is dependent on another.
val a = getAByName("aname").map(_.toValidNel[String](s"$aname not found"))
// this does not compile
val b = getBByNameAndA("bname", a.title).map(_.toValidNel[String](s"$bname not found"))
This seems like yet another layer to fold into the mix here. And to some degree that’s true.
The issue is that inside the for-comprehension, the left hand side values are ValidatedNel
s and hence, we cannot just use them directly as a bare parameter. We can, however, just use the same trick as before with mapN
:
val io = for {
av <- a
bv <- (av).mapN(a => getBByName("bname", a))
} yield ...
This clearly shows that the cats Applicative mapN
combines ValidatedNel
s together and if they are all Valid
calls the function inside the map or returns a ValidatedNel
with all the the “messages” combined together–which in this case is a non-empty list of strings. It essentially peels away one layer of data structure.
Evaluating parameters in parallel
One problem with
val iova = getAByName("aname").map(_.toValidNel[String](s"$aname not found"))
val iovb = getBByName("bname").map(_.toValidNel[String](s"$bname not found"))
val iovc = getCByName("cname").map(_.toValidNel[String](s"$cname not found"))
val iod = for {
a <- iova
b <- iovb
c <- iovc
} yield...
is that the parameters are evaluated in sequential fashion. If these were Future
s, which are eager and start processing as soon you create them, they would have started evaluating when declared val io* = ...
since they are outside the map/flatMap sugar that for-comprehensions offer. If futures were defined in the “for” they would run sequentially as soon as they are declared.
However, we have IO
s which are typically suspended. It is quite common that input parameters can be requested independently of each other, and hence, we want to do that in parallel. How do we do that?
cats has a typeclass Parallel
with a (...some tuple).parMapN{ (...) => ... }
function that looks like the parallel version of the Applicative that we used for processing the ValidatedNel
s. We have to make sure that each parameter fetch is asynchronous. See here for details on parMapN
.
Clearly, the for-comprehension has to go, so lets substitute the for-comprehension with parMapN
and lets ensure that our parameter fetch effects are asynchronous by using the IO.shift *> get*ByName(...)
trick described on the documentation page.
val iova = IO.shift *> getAByName(...).map(...)
val iovb = IO.shift *> getBByName(...).map(...)
val iovc = IO.shift *> getCByName(...).map(...)
(iova,iovb,iovc).parMapN{ (va, vb, vc) =>
(va, vb, vc).mapN{ (a, b, c) =>
useIt(a,b,c)
}}
If our IOs were already asynchronous, which you cannot tell from the signatures above, we would not need the shift.
This fomulation is not too bad, it is fairly succinct. Clearly, since we have to drill through 2 layers of monadic/data structures, we see evidence of that in the 2 layers of “map”. Plus we get some parallel evaluation and failure insights that help our CLI users.
Is it worth it?
The alternative to using these functional data structures is use classic if-then statements, scala match statements (or roll your own) or more complicated for-comprehensions most of which is prone to breakage, verboseness, inconsistency or bad cut/paste programming craziness. cats/cats-effect helps codify these patterns to something that is succinct and once learned, easier to reproduce.
The basic idea is not that whether you think that FP programming is useful, but that codifying the pattern is useful. FP happens to help us codify the pattern using FP vocabulary and concepts.
If you use this pattern over and over, then using the patterns as described above is worth it. You will make (and I made) many less mistakes compared to code that does not use these patterns. We also gain the ability to provide more helpful feedback to our CLI users.
Just to remind us we have:
- Input parameters processed in parallel (where that makes sense).
- Input parameters provide parameter-specific user messages that are combined with other user messages to be presented to the user if needed.
- Evaluates the final “command”
- Is “reactive” all the way down including printing the final user messages.
Full Example
Here’s an example from web services software that I use. Action
is a Kleisli that should return a IO[Unit]
and dynclient
is a web services client that makes a HTTP call and in this case with the map and recover, returns an IO[String]
val setLogo = Action { config =>
val name = config.themes.source.get
val wname = config.themes.webresourceName.get
val theme = IO.shift *> getByName(name).map(_.toValidNel[String](s"Theme $name not found."))
val wr = IO.shift *> getWebResource(wname).map(_.toValidNel[String](s"Web resource $wname not found."))
val io = (theme, wr).parMapN { (tv, wv) =>
(tv, wv).mapN { (t, w) =>
dynclient
.associate("themes", t.themeid, "logoimage", "webresourceset", w.webresourceid, true)
.map(_ => s"Logo updated to $wname on theme $name")
.recover {
case NonFatal(e) => s"Unable to set logo for theme $name to $wname."
}
}
}
io.flatMap{
case Validated.Invalid(msglist) => IO(msglist.toList.foreach(println))
case Validated.Valid(msgio) => msgio.map(println)
}
}
Comments
Post a Comment