This post is about how to implement well structured, and purely functional command line applications in Scala using PureApp.
PureApp originated in an experiment while refactoring out some glue code of an interactive command line application. At the same time it was inspired by the Elm Architecture Pattern, and scalaz's SafeApp, as well as scalm.
To show the really cool things we can do with PureApp, we will implement a self-contained example application from scratch.
This application translates texts from and into different languages. And it provides basic user interactions via the command line.
The complete source code is compiled with tut. Every output (displayed as code comments) is generated by tut.
Outline of this post:
- Referential transparency and separation of concerns - the main driving principles
- PureApp 101 - basic usage and Hello World example
- Implementation of an example application
PREREQUISITES
To follow the concepts from this post, the understanding of some basic functional programming principles and techniques is very helpful. In particular, the concept of IO
and how to compose IO
values with operations like map
or flatMap
.
In the section on testing, the tagless final pattern will be used. This is a somewhat advanced technique. It's used for making the application more modular and testable. Nevertheless, it is absolutely possible to implement PureApp programs without using tagless final.
Referential transparency and separation of concerns
The main driving principles
Consider functions with these three properties:
- Determinism
Given the same input they always return the same output -
Totality
They produce a result for every possible input - Purity
They have no side effects other than computing the return value
Such deterministic, total, and pure functions are also referentially transparent and give us incredible abilities.
REFERENTIAL TRANSPARENCY
An expression e is referentially transparent if, for all programs p, all occurrences of e in p can be replaced by the result of evaluating e without changing the behavior of p.
Referentially transparent functions are inherently:
- Easily testable
We just pass in test inputs and evaluate the result. There will be no side effects. -
Easy to reason about
All we need to know about a function is the input and the output values and types. There is no global mutable state involved nor any hidden inputs or outputs. - Composable
We can build larger referentially transparent functions by composing smaller ones together. Laws guarantee that we don't break anything when we do this.
Imagine we could write a program completely and only built with referentially transparent functions.
While there are purely functional programming languages e.g. like Haskell, in Scala it might not be obvious how to do this well, let alone if it is possible.
Well, it is possible, off course. And in this post I will present a way to do this using PureApp.
Well organised
Moreover, we will see how PureApp enforces a program structure with cleanly separated concerns very similar to the Elm Architecture Pattern.
The clear structure of a PureApp program will make it easier to:
- reason about and understand the code
- maintain and test the code
- refactor and add features
- get started with purely functional programming
PureApp 101
Basic usage and Hello World example
A PureApp program has three main components:
- Model
The application state. -
Update
A way to update the application state. - Io
Input and output operations. (A description of the side effects.)
Three different flavours
PureApp supports three slightly different program types that build upon each other.
- SimplePureApp
A simple program which extendsSimplePureApp[F[_]]
knows only about a model and messages. The model represents the application state. And messages are applied to the model to update the application state. -
StandardPureApp
In a simple program we can output the current state and read inputs. If we want to conditionally perform other side effecting operations, we might not want to do this based on the application state. That's why a standard program which extendsStandardPureApp[F[_]]
introduces commands. -
PureApp
If we need to use disposable resources that do not belong into the domain model we can extendPureApp[F[_]]
.
Overview of the three different flavors:
model | messages | commands | resources | |
---|---|---|---|---|
SimplePureApp |
✔ | ✔ | ✗ | ✗ |
StandardPureApp |
✔ | ✔ | ✔ | ✗ |
PureApp |
✔ | ✔ | ✔ | ✔ |
How to implement and use these components can best be shown by a concrete example. So let's do a little set up and get going.
Let's see some code! - Hello World
First we are going to need some imports:
import cats.effect.IO
import cats._
import cats.implicits._
import com.github.battermann.pureapp._
import com.github.battermann.pureapp.interpreters.Terminal._
We will start by implementing the abstract class SimplePureApp
with the type parameter IO
from cats-effect
. This is an almost minimal working Hello World program:
object Main extends SimplePureApp[IO] {
// MODEL
final case class Model(value: String)
sealed trait Msg
case object Quit extends Msg
def init: Model = Model("Hello PureApp!")
def quit(msg: Msg): Boolean = msg == Quit
// UPDATE
def update(msg: Msg, model: Model): Model =
msg match {
case Quit => model
}
// IO
def io(model: Model): IO[Msg] =
putStrLn(model.value) as Quit
}
// defined object Main
What's going on?
- The object
Main
extends the abstract classSimplePureApp
. The typesMsg
andModel
and the functionsinit
,quit
,update
andio
have to implemented. -
The type parameter
F[_]
ofSimplePureApp
is fixed toIO
.F
has to have an implicit instance ofEffect[F]
. ForIO
this is defined incats-effect
. - The application state is defined by the case class
Model
. - The very basic message type
Msg
can only have the single valueQuit
. - The
init
function returns the initial application state. quit
is a function that causes the app to terminate if it evaluates totrue
when applied to the inner value of the result fromio
.update
applies aMsg
to the application state and returns an updated application state. In this case it simply returns the unchanged model.- The function
io
describes input and output actions based on theModel
. Here it just prints the model's value and returnsQuit
wrapped in anIO
. (putStrLn
is defined asdef putStrLn(line: String): IO[Unit] = IO { println(line) }
.) SimplePureApp
provides an implementation of themain
method which runs theIO
effect. So we have an executable application.
Also in the REPL we can run the program and observe that it outputs the value of its initial state:
Main.main(Array())
// Hello PureApp!
This seems like a ridiculous amount of ceremony for a Hello World program. However, when the program becomes more complex, this structure will always stay the same. And this will help us a lot.
A NOTE ON IO
AND SIDE EFFECTS
The expression println("hi!")
writes to the outside world (in this case the console).
While this is a side effecting and impure operation it might not be obvious to a functional programming beginner that IO { println("hi!) }
is a referentially transparent value.
One of the reasons it's referentially transparent is that a value of type IO[A]
just represents the description of a side effecting computation without executing it. (Just as a recipe for a cake doesn't produce a cake.)
We can think of an IO[A]
as a program that when evaluated can perform side effects and might produce an A
. The evaluation itself is therefore impure. For that reason, an IO
value must not be evaluated anywhere but only once at the very end of the application's main method.
In a PureApp program we do not have to worry about this. In the io
function we can implement small functionalities by safely combining IO
values e.g. with map
and flatMap
. PureApp will internally take care of composing the whole application into a single IO
value and evaluate it at the application's exit point.
For more information on IO
, please refer to:
- Cats Effect: IO - Typelevel.scala
- The introduction of Functional Programming for Mortals (free online version)
- An IO monad for cats
Translator - model and messages
The model
Now, since we are going to build a translation app, we need a more useful Model
than just a String
.
Let's start by defining a type that represents a language:
sealed trait Language { def id: String }
case object English extends Language { val id = "en" }
case object German extends Language { val id = "de" }
case object French extends Language { val id = "fr" }
case object Spanish extends Language { val id = "es" }
We have to be able to specify the source and the target language for the translation.
The source language is special in the way that it can be either auto detected or set to a fixed value:
sealed trait SourceLanguage { def id: String }
case object Auto extends SourceLanguage { val id = "auto" }
final case class Fixed(language: Language) extends SourceLanguage {
val id: String = language.id
}
The complete model of the application state consists of a source and target language and an output message of type String
.
final case class Model(sourceLanguage: SourceLanguage,
targetLanguage: Language,
outputMsg: String)
// defined class Model
The messages
All user interactions are modelled as values of type Msg
. In our translator tool the user should be able to:
- quit the program
- see the help message
- see language settings
- have a text be translated
- and set the source and target language
Additionally we need a message InvalidInput
that represents an invalid user input:
sealed trait Msg
case object Quit extends Msg
case object ShowHelp extends Msg
case object ShowLanguageSettings extends Msg
case object InvalidInput extends Msg
final case class Translate(text: String) extends Msg
final case class SetLanguage(
input: SourceLanguage,
target: Language) extends Msg
The initial state
A PureApp program always has to have an initial state. Which has to be a value of type Model
.
For the translator app we set the initial state's source language to Auto
, the target language to English
, and the output message to a fancy logo:
def translatorInit: Model = {
val intro: String =
"""| _ _
|| | | | |
|| |_ _ __ __ _ _ __ ___| | __ _| |_ __
|| __| '__/ _` | '_ \/ __| |/ _` | __/ _
|| |_| | | (_| | | | \__ \ | (_| | || __
| \__|_| \__,_|_| |_|___/_|\__,_|\__\___| Type :? for help
|""".stripMargin
Model(Auto, English, intro)
}
// translatorInit: Model
Updating the state
The state gets updated by applying a Msg
to the Model
.
This is handled by the update
function. The model that is passed in will not be mutated which would break referential transparency. Instead the function will return a new updated model.
def translatorUpdate(msg: Msg, model: Model): Model =
msg match {
case Quit =>
model
case ShowHelp =>
val help: String =
"""
| Command Arguments Purpose
|
| <text> Translate the text
| :s :set <lang> <lang> Set source and target language (source: auto, en, fr, es, de. target: en, fr, es, de)
| :l :language Display current source and target language settings
| :q :quit Exit the app
| :? :h :help Display this help text
|""".stripMargin
model.copy(outputMsg = help)
case Translate(_) =>
model.copy(outputMsg = "<translated text placeholder>")
case InvalidInput =>
model.copy(
outputMsg = "Unrecognised command or option\nType :? for help")
case ShowLanguageSettings =>
model.copy(outputMsg =
s"Language settings: ${model.sourceLanguage.id} -> ${model.targetLanguage.id}")
case SetLanguage(in, out) =>
model.copy(sourceLanguage = in,
targetLanguage = out,
outputMsg = s"Language settings: ${in.id} -> ${out.id}")
}
// translatorUpdate: (msg: Msg, model: Model)Model
For now the Translate
message doesn't initiate a real translation. We will use a placeholder. The actual translation cannot be done in the update
function because it would be side effecting. We will come back to this problem later.
Input and output
Before we can implement input and output operations with the io
function we need a way to transform a user input to its Msg
representation.
Parsing input
The best way to parse a String
entered by user to a Msg
is with parser combinators. Since this post is not about parser combinators I will just present some code that works using atto, another very nice library by Rob Norris:
object InputParser {
import atto.Atto._
import atto._
val language: Parser[Language] = choice(
List(English, Spanish, French, German)
.map(l => string(l.id) >| (l: Language)))
val sourceLanguage: Parser[SourceLanguage] =
string(Auto.id) >| (Auto: SourceLanguage) | language
.map(Fixed(_): SourceLanguage)
val set: Parser[Msg] =
token(string("set") | string("s")) ~> token(sourceLanguage) ~
language map SetLanguage.tupled
val quit: Parser[Msg] = (string("quit") | string("q")) >| Quit
val display: Parser[Msg] =
(string("language") | string("l")) >| ShowLanguageSettings
val help: Parser[Msg] =
(string("?") | string("h") | string("help")) >| ShowHelp
val cmd: Parser[Msg] =
char(':') ~> choice(quit, help, set, display)
val translate: Parser[Msg] = notChar(':') ~ takeRest map {
case (h, t) => Translate((h :: t).mkString)
}
val inputParser: Parser[Msg] = cmd | translate
def parse(input: String): Msg = {
inputParser
.parse(input.trim)
.done
.either
.leftMap(_ => InvalidInput)
.merge
}
}
// defined object InputParser
Printing and reading
The io
function prints the output message from the model followed by a prompt. It then reads user input and parses it to a Msg
.
def prompt: IO[Msg] =
putStr("translate> ") *> readLine map InputParser.parse
// prompt: cats.effect.IO[Msg]
def translatorIo(model: Model): IO[Msg] =
putStrLn(model.outputMsg) *> prompt
// translatorIo: (model: Model)cats.effect.IO[Msg]
Note that the *>
operator combines two IO
values by discarding the result from the first and keeping the result of the second one. It is a succinct alternative for ioa.flatMap(_ => iob)
. (It is short for map2(ioa, iob)((_, b) => b)
.)
Termination
When the quit command is entered the program should terminate. This is defined like this:
def translatorQuit(msg: Msg) = msg == Quit
// translatorQuit: (msg: Msg)Boolean
Putting it together
Now we can put everything together that we've implemented above.
One way to make this work with tut for this post is to define the following two type aliases. This is not needed if everything is either implemented inside the SimpleTranslator
class or in some other object
which we would normally do.
type TranslatorModel = Model
type TranslatorMsg = Msg
Because SimplePureApp
implements the main
method that runs the IO
, this is enough to create an executable application:
object SimpleTranslator extends SimplePureApp[IO] {
type Model = TranslatorModel
type Msg = TranslatorMsg
def init: Model = translatorInit
def quit(msg: Msg): Boolean = translatorQuit(msg)
def update(msg: Msg, model: Model): Model =
translatorUpdate(msg, model)
def io(model: Model): IO[Msg] = translatorIo(model)
}
// defined object SimpleTranslator
Testing
Testing the core domain logic, which is implemented in the update
function, is really easy. Since update
is pure and deterministic, it can be tested in isolation by passing in test values and comparing the result with the expected outcome.
In some cases that can be good enough.
If we want to test our complete implementation including the side effecting part, however, we run into a little problem. We cannot provide user input that easily because user input is read from the console at runtime.
Tagless final
Fortunately there is an elegant solution to this.
We can implement the io
function as a tagless final program and use different interpreters for testing and in production.
Algebra
First we need to define an algebra that describes the input and output operations that we need:
trait Console[F[_]] {
def putStr(str: String): F[Unit]
def putStrLn(str: String): F[Unit]
def readLine: F[String]
}
// defined trait Console
This enables us to implement the io
function in an abstract way:
def prompt[F[_]: Applicative](C: Console[F]): F[Msg] =
C.putStr("translate> ") *> C.readLine map InputParser.parse
// prompt: [F[_]](C: Console[F])(implicit evidence$1: cats.Applicative[F])F[Msg]
def io[F[_]: Applicative](C: Console[F])(model: Model): F[Msg] =
C.putStrLn(model.outputMsg) *> prompt(C)
// io: [F[_]](C: Console[F])(model: Model)(implicit evidence$1: cats.Applicative[F])F[Msg]
Interpreter
Now we can implement an interpreter to use for the production code:
object ConsoleInterpreter extends Console[IO] {
def putStr(str: String): IO[Unit] = IO(print(str))
def putStrLn(str: String): IO[Unit] = IO(println(str))
def readLine: IO[String] = IO(scala.io.StdIn.readLine)
}
// defined object ConsoleInterpreter
In the io
function of the SimpleTranslator
we can explicitly pass in the production interpreter:
def translatorIo(model: Model): IO[Msg] =
io(ConsoleInterpreter)(model)
// translatorIo: (model: Model)cats.effect.IO[Msg]
The test interpreter
For tests we can model inputs and outputs with the state monad.
One way to interpret the console state is using two lists of strings. Where the first list represents the inputs and the second list represents the outputs.
type Inputs = List[String]
type Outputs = List[String]
type ConsoleState = (Inputs, Outputs)
When we implement an interpreter for the Console
algebra with State
we still need to add IO
to the stack. This is because the PureApp program expects an implicit instance of Effect[F]
.
Fortunately cats-effect
provides this instance for StateT[F, S, ?]
with any F
that also implements Effect
.
So here is an interpreter that we can use for tests:
import cats.data.StateT
// import cats.data.StateT
val testConsole = new Console[StateT[IO, ConsoleState, ?]] {
def putStr(str: String): StateT[IO, ConsoleState, Unit] =
StateT.modify[IO, ConsoleState] { case (in, out) => (in, out :+ str) }
def putStrLn(str: String): StateT[IO, ConsoleState, Unit] =
StateT.modify[IO, ConsoleState] { case (in, out) => (in, out :+ s"$str\n") }
def readLine: StateT[IO, ConsoleState, String] =
StateT
.get[IO, ConsoleState]
.map { case (in, _) => in.head } <* StateT.modify[IO, ConsoleState] {
case (in, out) => (in.tail, out :+ s"${in.head}\n")
}
}
// testConsole: Console[[γ$0$]cats.data.IndexedStateT[cats.effect.IO,(List[String], List[String]),(List[String], List[String]),γ$0$]] = $anon$1@15e991e8
All the abstract PureApp classes provide an accessor program
to get the underlying program. It has the type Program[F[_]: Effect, Model, Msg, Cmd, Resource, A]
and is a value that contains all the PureApp components.
Now we want to replace the io
function of the program under test with the one that uses the test interpreter.
There is a convenient function for that called withIo
, withIoSimple
, or withIoStandard
. Depending on the type of the program.
Program
provides a function called build
. This function transforms a program to it's representation in the context of it's effect type F[_]
. Without actually running it.
Because in our test the type F[_]
is StateT[IO, ConsoleState, ?]
we can run the state monad with the initial console state (the test inputs) with runS
. As a result we get a value of type IO[ConsoleState]
that we can execute with unsafeRunSync
to produce the final console state.
Here is the implementation of a test where we use the test interpreter from above:
import org.scalatest._
// import org.scalatest._
class SimpleTranslatorTests extends FeatureSpec with GivenWhenThen with Matchers {
feature("translator") {
scenario("translate a text") {
Given("a program with an empty output message")
val sut =
SimpleTranslator
.program
.withIoSimple(io(testConsole))
.copy(init = (Model(Auto, English, ""), ()))
When("a text is entered, followed by the quit command")
val inputs = List("foobar", ":q")
val (_, actual) = sut.build().runS((inputs, Nil)).unsafeRunSync
Then("the translated text should be displayed")
val expected =
List("\n", "translate> ", "foobar\n", "<translated text placeholder>\n", "translate> ", ":q\n")
actual shouldEqual expected
And("the program should terminate")
}
}
}
// defined class SimpleTranslatorTests
Let's run the test with tut:
run(new SimpleTranslatorTests)
// SimpleTranslatorTests:
// Feature: translator
// Scenario: translate a text
// Given a program with an empty output message
// When a text is entered, followed by the quit command
// Then the translated text should be displayed
// And the program should terminate
Translator - adding commands
In this section we are going to implement a service to translate texts from and to different languages. The translate function will be side effecting. Therefore, we cannot do this in the update
function because the update
function must not perform any side effects. We have to do this in the io
function instead.
To represent a request to perform a translation, we need to use a command.
To enable commands, the program signature changes slightly and we have to extend StandardPureApp[F[_]]
instead of SimplePureApp[F[_]]
.
Introducing commands
Here is a command for the translation as well as an empty command which we will need, too.
sealed trait Cmd
object Cmd {
case object Empty extends Cmd
final case class Translate(text: String) extends Cmd
}
The result of a translation will be a message of type Msg
. Because the translation operation can fail, we will model it as an Either[Error, String]
where we will just use String
as the error type:
final case class TranslationResult(result: Either[String, String]) extends Msg
// defined class TranslationResult
Besides the initial model, the init
function now also returns an initial command, which will be empty in this case:
def translatorInit: (Model, Cmd) = {
val intro: String =
"""| _ _
|| | | | |
|| |_ _ __ __ _ _ __ ___| | __ _| |_ __
|| __| '__/ _` | '_ \/ __| |/ _` | __/ _
|| |_| | | (_| | | | \__ \ | (_| | || __
| \__|_| \__,_|_| |_|___/_|\__,_|\__\___| Type :? for help
|""".stripMargin
(Model(Auto, English, intro), Cmd.Empty)
}
// translatorInit: (Model, Cmd)
Updating the state
Also, the signature of the update
function changes. When we apply a message to the model, the result will be enhanced with a command.
The command will be empty in most cases. Only when the message Translate
is applied to the model the Translate
command will be produced.
Note that the last two cases of the pattern match handle the translation result:
def translatorUpdate(msg: Msg, model: Model): (Model, Cmd) =
msg match {
case Quit =>
(model, Cmd.Empty)
case ShowHelp =>
val help: String =
"""
| Command Arguments Purpose
|
| <text> Translate the text
| :s :set <lang> <lang> Set source and target language (source: auto, en, fr, es, de. target: en, fr, es, de)
| :l :language Display current source and target language settings
| :q :quit Exit the app
| :? :h :help Display this help text
|""".stripMargin
(model.copy(outputMsg = help), Cmd.Empty)
case Translate(text) =>
(model, Cmd.Translate(text))
case InvalidInput =>
(model.copy(
outputMsg = "Unrecognised command or option\nType :? for help"),
Cmd.Empty)
case ShowLanguageSettings =>
(model.copy(outputMsg =
s"Language settings: ${model.sourceLanguage.id} -> ${model.targetLanguage.id}"),
Cmd.Empty)
case SetLanguage(in, out) =>
(model.copy(sourceLanguage = in,
targetLanguage = out,
outputMsg = s"Language settings: ${in.id} -> ${out.id}"),
Cmd.Empty)
case TranslationResult(Right(text)) =>
(model.copy(outputMsg = text), Cmd.Empty)
case TranslationResult(Left(msg)) =>
(model.copy(outputMsg = s"Translation failed. $msg"), Cmd.Empty)
}
// translatorUpdate: (msg: Msg, model: Model)(Model, Cmd)
Translator algebra and interpreter
We introduced the tagless final pattern already in the section on testing.
We will stick to this pattern and first implement an algebra for the translation service:
trait Translator[F[_]] {
def translate(text: String,
source: SourceLanguage,
target: Language): F[Either[String, String]]
}
// defined trait Translator
We now can implement a non-side effecting fake interpreter:
class FakeTranslatorInterpreter[F[_]: Applicative] extends Translator[F] {
def translate(text: String,
source: SourceLanguage,
target: Language): F[Either[String, String]] =
Applicative[F].pure(
s"<$text> (source: ${source.id}, target: ${target.id})".asRight)
}
// defined class FakeTranslatorInterpreter
In the io
function we can use the Translator
algebra that we defined above. The new signature of io
now has a command as an extra argument. If the command is Empty
we print the output message and read and parse input, just as before. And on Translate(text)
we call the translation service and map the result to the TranslationResult
message.
def io[F[_]: Applicative](C: Console[F], T: Translator[F])(model: Model,
cmd: Cmd): F[Msg] =
cmd match {
case Cmd.Empty =>
C.putStrLn(model.outputMsg) *> prompt(C)
case Cmd.Translate(text) =>
T.translate(text, model.sourceLanguage, model.targetLanguage)
.map(TranslationResult)
}
// io: [F[_]](C: Console[F], T: Translator[F])(model: Model, cmd: Cmd)(implicit evidence$1: cats.Applicative[F])F[Msg]
We call the tagless final program by passing in the ConsoleInterpreter
and the FakeTranslatorInterpreter
:
def translatorIo(model: Model, cmd: Cmd): IO[Msg] =
io(ConsoleInterpreter, new FakeTranslatorInterpreter[IO])(model, cmd)
// translatorIo: (model: Model, cmd: Cmd)cats.effect.IO[Msg]
Testing the program
Before we implement a real translation service, let's do a sample run with tut, using the test console interpreter. As stated above, we can extend StandardPureApp[F[_]]
to create the application.
However, in this case it is convenient to directly use the standard
constructor of the type Program
:
val program =
Program.standard(
translatorInit,
translatorUpdate,
io(testConsole,
new FakeTranslatorInterpreter[StateT[IO, ConsoleState, ?]]),
translatorQuit
)
Just as in the section on testing, we build the program with build
to get a value of type StateT[IO, ConsoleState, Model]
. Then we run the state monad with runS
and pass in some test inputs.
val inputs =
List(":l", "foobar", ":s es fr", ":xyz", ":q")
val (_, output) = program.build().runS((inputs, Nil)).unsafeRunSync
Now we can print the complete output of the application, given our initial test inputs:
println(output.mkString)
// _ _
// | | | |
// |_ _ __ __ _ _ __ ___| | __ _| |_ __
// __| '__/ _` | '_ \/ __| |/ _` | __/ _
// |_| | | (_| | | | \__ \ | (_| | || __
// \__|_| \__,_|_| |_|___/_|\__,_|\__\___| Type :? for help
//
// translate> :l
// Language settings: auto -> en
// translate> foobar
// <foobar> (source: auto, target: en)
// translate> :s es fr
// Language settings: es -> fr
// translate> :xyz
// Unrecognised command or option
// Type :? for help
// translate> :q
//
The real translator
Now it's time to implement a real translation service.
The quickest way that I found was to use Google Translate with Selenium. It's probably not the best solution but good enough for demonstration purposes.
And a great advantage of the tagless final pattern is, that it is very easy to replace interpreters.
The implementation below works, but unfortunately it doesn't run with tut. (Google Chrome and ChromeDriver have to be installed on the application's host OS e.g. with brew install chromedriver
.)
Note that cats-effect
's bracket
operation is used for safe acquisition and release of resources. The rest is implementation details and not relevant for this post.
object TranslatorInterpreter extends Translator[IO] {
import org.openqa.selenium.{By, WebElement}
import org.openqa.selenium.chrome.{ChromeDriver, ChromeOptions}
import scala.annotation.tailrec
import scala.concurrent.duration._
import scala.language.postfixOps
import java.util.concurrent.TimeUnit
def translate(text: String,
source: SourceLanguage,
target: Language): IO[Either[String, String]] =
create.bracket { driver =>
translate(driver, text, source, target)
} { driver =>
dispose(driver)
}
def translate(driver: ChromeDriver,
text: String,
source: SourceLanguage,
target: Language): IO[Either[String, String]] =
IO {
driver.manage.timeouts.implicitlyWait(5, TimeUnit.SECONDS)
driver.get(s"http://translate.google.com/#${source.id}/${target.id}")
val input = driver.findElement(By.id("source"))
input.sendKeys(text)
val submit = driver.findElement(By.id("gt-submit"))
submit.click()
@tailrec
def loop(resultBox: WebElement, start: Long, wait: FiniteDuration): String =
if (resultBox.getText.length > 0 || System.currentTimeMillis - start > wait.toMillis)
resultBox.getText
else
loop(resultBox, start, wait)
loop(driver.findElement(By.id("result_box")),
System.currentTimeMillis,
10 seconds)
}.attempt
.map(_.leftMap(_.getMessage))
def create: IO[ChromeDriver] = IO {
System.setProperty("webdriver.chrome.driver", "/usr/local/bin/chromedriver")
val options = new ChromeOptions()
options.addArguments("--headless",
"--disable-gpu",
"--silent",
"--disable-logging")
new ChromeDriver(options)
}
def dispose(chromeDriver: ChromeDriver): IO[Unit] = IO {
chromeDriver.close()
chromeDriver.quit()
}
}
// defined object TranslatorInterpreter
Using the real translation service:
def translatorIo(model: Model, cmd: Cmd): IO[Msg] =
io(ConsoleInterpreter, TranslatorInterpreter)(model, cmd)
// translatorIo: (model: Model, cmd: Cmd)cats.effect.IO[Msg]
Translator - reusable resources
The implementation we did so far works. However, it's not ideal because on every translation a new instance of ChromeDriver
is created.
While in some other application this might be desirable, it's not very useful here.
Fortunately PureApp has a way to reuse resources by extending PureApp[F[_]]
.
Internally PureApp uses cat-effect
's Bracket
type class to make sure that resources are acquired and released in a purely functional and safe way.
Acquiring resources
To acquire a resource we have to implement the method def acquire: IO[Resource]
. We've already implemented it in the TranslatorInterpreter
object:
import org.openqa.selenium.chrome.ChromeDriver
// import org.openqa.selenium.chrome.ChromeDriver
def acquire: IO[ChromeDriver] = TranslatorInterpreter.create
// acquire: cats.effect.IO[org.openqa.selenium.chrome.ChromeDriver]
Disposing resources
We define how to release resources by implementing def dispose(resource: ChromeDriver): IO[Unit]
. Again, we reuse the implementation from TranslatorInterpreter
.
def dispose(resource: ChromeDriver): IO[Unit] =
TranslatorInterpreter.dispose(resource)
// dispose: (resource: org.openqa.selenium.chrome.ChromeDriver)cats.effect.IO[Unit]
Interpreting commands
The resource will be acquired at application start and it will be disposed just before the application terminates.
When implementing the io
function, we have the resource as an extra argument at our disposal:
def prompt: IO[Msg] =
putStr("translate> ") *> readLine map InputParser.parse
// prompt: cats.effect.IO[Msg]
def translatorIo(model: Model, cmd: Cmd, resource: ChromeDriver): IO[Msg] =
cmd match {
case Cmd.Empty =>
putStrLn(model.outputMsg) *> prompt
case Cmd.Translate(text) =>
TranslatorInterpreter.translate(
resource,
text,
model.sourceLanguage,
model.targetLanguage) map TranslationResult
}
// translatorIo: (model: Model, cmd: Cmd, resource: org.openqa.selenium.chrome.ChromeDriver)cats.effect.IO[Msg]
Note that for the sake of brevity I left out the tagless final implementation. In fact, for better modularity and testability it would be a better design to add a dispose
function to the Translator[F[_]]
algebra and use that as the Resource
type.
Putting it together
Again, the following type aliases are a work around to make everything compile with tut:
type TranslatorModel = Model
type TranslatorMsg = Msg
type TranslatorCmd = Cmd
Just plugging things together:
object TranslatorApp extends PureApp[IO] {
type Model = TranslatorModel
type Msg = TranslatorMsg
type Cmd = TranslatorCmd
type Resource = ChromeDriver
def acquire: IO[Resource] = TranslatorInterpreter.create
def dispose(resource: Resource): IO[Unit] =
TranslatorInterpreter.dispose(resource)
def init: (Model, Cmd) = translatorInit
def quit(msg: Msg): Boolean = msg == Quit
def update(msg: Msg, model: Model): (Model, Cmd) =
translatorUpdate(msg, model)
def io(model: Model, cmd: Cmd, resource: Resource): IO[Msg] =
translatorIo(model, cmd, resource)
}
// defined object TranslatorApp
Conclusion
We first talked about the main driving principles behind PureApp:
- Referential transparency
- Clean separation of concerns
After demonstrating the basic usage of PureApp we implemented a self-contained example application that translates texts from and to different languages.
We did this in three iteration steps:
- Modelling the application state and messages
- Introducing commands
- Implementing safe acquisition and release of reusable resources
PureApp started out as an exercise and experiment. I think the result turned out quite nice and powerful. It was also a great learning opportunity for me. And it was definitely a lot of fun to implement and use it. There is definitely a lot of room for improvement.
What we didn't cover is composability of PureApp programs themselves. There are multiple ways. Programs can be transformed to its representation in the context of it's effect type F[_]
, e.g. IO
. Then we can use all the composition mechanisms that F[_]
provides. Another way is to run other PureApp programs inside the io
function.
Let me know what you think. Do you have suggestions for improvements or other comments?
Scala libraries and compiler plugins used:
libraryDependencies += "com.github.battermann" %% "pureapp" % "0.6.0"
libraryDependencies += "org.seleniumhq.selenium" % "selenium-java" % "2.23.1"
libraryDependencies += "org.tpolecat" %% "atto-core" % "0.6.2-M1"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.5"
addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.9.6")