-
Notifications
You must be signed in to change notification settings - Fork 2
Step 3 Quatro
As with our previous versions, we'll start by adding the RethinkDB driver to project.json
; we'll also bring the data-config.json
file from Dos/Tres into this project, changing the database name to O2F4
. Follow the instructions for Tres up though the point where it says "we'll create a file Data.fs
".
Parsing data.config
We'll use Data.fs
in this project as well, but we'll do things a bit more functionally. We'll use Chiron to parse the JSON file, and we'll set up a discriminated union (DU) for our configuration parameters.
First, to be able to use Chiron, we'll need the package. Add the following line within the dependencies
section:
"Chiron": "6.2.1",
Then, we'll start Data.fs
with our DU.
namespace Quatro
open Chiron
...
type ConfigParameter =
| Hostname of string
| Port of int
| AuthKey of string
| Timeout of int
| Database of string
This DU looks a bit different than the single-case DUs or enum-style DUs that we made in step 2. This is a full-fledged DU with 5 different types, 3 strings and 2 integers. The DataConfig
record now becomes dead simple:
type DataConfig = { Parameters : ConfigParameter list }
We'll populate that using Chiron's Json.parse
function.
with
static member FromJson json =
match Json.parse json with
| Object config ->
let options =
config
|> Map.toList
|> List.map (fun item ->
match item with
| "Hostname", String x -> Hostname x
| "Port", Number x -> Port <| int x
| "AuthKey", String x -> AuthKey x
| "Timeout", Number x -> Timeout <| int x
| "Database", String x -> Database x
| key, value ->
raise <| InvalidOperationException
(sprintf "Unrecognized RethinkDB configuration parameter %s (value %A)" key value))
{ Parameters = options }
| _ -> { Parameters = [] }
There is a lot to learn in these lines.
- Before, if the JSON didn't parse, we raised an exception, but that was about it. In this one, if the JSON doesn't parse, we get a default connection. Maybe this is better, maybe not, but it demonstrates that there is a way to handle bad JSON other than an exception.
-
Object
,String
, andNumber
are Chiron types (cases of a DU, actually), so ourmatch
statement uses the destructuring form to "unwrap" the DU's inner value. ForString
,x
is a string, and forNumber
,x
is a decimal (that's why we run it throughint
to make our DUs. - This version will raise an exception if we attempt to set an option that we do not recognize (something like "databsae" - not that anyone I know would ever type it like that...).
Now, we'll adapt the CreateConnection ()
function to read this new configuration representation:
member this.CreateConnection () : IConnection =
let folder (builder : Connection.Builder) block =
match block with
| Hostname x -> builder.Hostname x
| Port x -> builder.Port x
| AuthKey x -> builder.AuthKey x
| Timeout x -> builder.Timeout x
| Database x -> builder.Db x
let bldr =
this.Parameters
|> Seq.fold folder (RethinkDB.R.Connection ())
upcast bldr.Connect()
Our folder function utilizes a match
on our ConfigParameter
DU. Each time through, it will return a modified version of the builder
parameter, because one of them will match. We then create our builder by folding the parameter, using R.Connection ()
as our beginning state, then return its Connect ()
method.
For now, let's copy the rest of Data.fs
from Tres to Quatro - this gives us the table constants and the table/index initialization code.
Dependency Injection: Functional Style
One of the concepts that dependency injection is said to implement is "inversion of control;" rather than an object compiling and linking a dependency at compile time, it compiles against an interface, and the concrete implementation is provided at runtime. (This is a bit of an oversimplification, but it's the basic gist.) If you've ever done non-DI/non-IoC work, and learned DI, you've adjusted your thinking from "what do I need" to "what will I need". In the functional world, this is done through a concept called the Reader
monad. The basic concept is as follows:
- We have a set of dependencies that we establish and set up in our code.
- We a process with a dependency that we want to be injected (in our example, our
IConnection
is one such dependency). - We construct a function that requires this dependency, and returns the result we seek. Though we won't see it in this step, it's easy to imagine a function that requires an
IConnection
and returns aPost
. - We create a function that, given our set of dependencies, will extract the one we need for this process.
- We run our dependencies through the extraction function, to the dependent function, which takes the dependency and returns the result.
Confused yet? Me too - let's look at code instead. Let's create Dependencies.fs
and add it to the build order above Entities.fs
. This wiki won't expound on every line in this file, but we'll hit the highlights to see how all this comes together. ReaderM
is a generic class, where the first type is the dependency we need, and the second type is the type of our result.
After that (which will come back to in a bit), we'll create our dependencies, and a function to extract an IConnection
from it.
type IDependencies =
abstract Conn : IConnection
[<AutoOpen>]
module DependencyExtraction =
let getConn (deps : IDependencies) = deps.Conn
Our IDependencies
are pretty lean right now, but that's OK; we'll flesh it out in future steps. We also wrote a dead-easy function to get the connection; the signature is literally IDependencies -> IConnection
. No ReaderM
funkiness yet!
Now that we have a dependency "set" (of one), we need to go to App.fs
and make sure we actually have a concrete instance of this for runtime. Add this just below the module declaration:
let lazyCfg = lazy (File.ReadAllText "data-config.json" |> DataConfig.FromJson)
let cfg = lazyCfg.Force()
let deps = {
new IDependencies with
member __.Conn
with get () =
let conn = lazy (cfg.CreateConnection ())
conn.Force()
}
Here, we're using lazy
to do this once-only-and-only-on-demand, then we turn around and pretty much demand it. If you're thinking this sounds a lot like singletons - your thinking is superb! That's exactly what we're doing here. We're also using F#'s inline interface declaration to create an implementation without creating a concrete class in which it is held.
Maybe being our own IoC container isn't so bad! Now, let's take a stab at actually connection, and running the EstablishEnvironment
function on startup. At the top of main
:
let initDb (conn : IConnection) = conn.EstablishEnvironment cfg.Database |> Async.RunSynchronously
let start = liftDep getConn initDb
start |> run deps
If Jiminy Cricket had written F#, he would have told Pinocchio "Let the types be your guide". So, how are we doing with these? initDb
has the signature IConnection -> unit
, start
has the signature ReaderM<IDependencies, unit>
, and the third line is simply unit
. And, were we to run it, it would work, but... it's not really composable.
// TODO: finish fleshing out this idea
What is liftDep
?
let liftDep (proj : 'd2 -> 'd1) (rm : ReaderM<'d1, 'output>) : ReaderM<'d2, 'output> = proj >> rm
// TODO: finish writing it up