Skip to content
This repository has been archived by the owner on Apr 3, 2024. It is now read-only.

Step 3 Quatro

Daniel J. Summers edited this page Nov 23, 2016 · 5 revisions

Quatro - Step 3

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, and Number are Chiron types (cases of a DU, actually), so our match statement uses the destructuring form to "unwrap" the DU's inner value. For String, x is a string, and for Number, x is a decimal (that's why we run it through int 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 a Post.
  • 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

Back to Step 3

Clone this wiki locally