Meta Reduce, Volume 2019.1

Time to recap what I’ve been up to lately. Admittedly this post is a bit overdue1, but there has been a lot on my plate lately and I had little time for blogging.

Clojure

Dutch Clojure Days

The most interesting thing I’ve done on the Clojure front recently was speaking at the Dutch Clojure Days conference. I spoke there about nREPL’s revival, future and importance. I think the talk went well and resonated with the audience. I even got an unexpected promise of financial backing for the project by Siili!

Here’s the slide deck from my presentation. I hope the recording of the talk will become available soon.

I think it was a great conference overall and I’m happy I got the opportunity to be a part of it. I certainly hope that I’ll find my way back there, even if I’m just a regular attendee. I have to note that even though the conference was completely free it was organized really well! You could clearly tell that it was a product of love and passion and that’s the only real recipe for a great conference!

By the way, Josh Glover recently wrote a nice summary of the conference.

nREPL

There’s not much to report here. The most important thing that happened was the decision to make op names strings internally. This simplified the implementation of the new EDN transport and make the code simpler overall.

I’ve also updated the nREPL section in Lambda Island’s guide to Clojure REPLs to reflect the recent nREPL changes.

Orchard

On Orchard’s front the big news is that we’re very close to getting rid of almost all third-party dependencies the project currently has - namely tools.namespace and java.classpath. This happened as the side-effect of work to address dynapath-related issues on Java 9+. You can learn more about all of this here. Once we wrap this up, I’ll cut Orchard 0.5 and we’ll focus our attention on Orchard 0.6 and the huge milestone of merging ClojureScript support into Orchard (and deprecating cljs-tooling in the process).

Fun times ahead!

CIDER

I’ve finally found a bit of time to work on CIDER.

My current focus is getting more functionality working without cider-nrepl. The biggest step in this direction was implementing an eval-based fallback for cider-var-info - a function which powers definition lookup and doc lookup (amongst others CIDER commands). You can read more about the scope of that task here and I’d certainly appreciate some help with it.

On a related note - I can’t wait for us to implement client dependency injection in nREPL itself, as it would simplify that functionality tremendously.

In other news - I fixed a long-standing problem with checking for the presence of required ClojureScript dependencies before starting a ClojureScript REPL, but at this point I’m thinking that probably this whole dependency validation idea was a bad one and I should just kill those checks completely.

I’ve also looked into a compilation error highlighting regression caused by changes in Clojure 1.10. It’s a trivial problem, but Emacs Lisp regular expressions have a very messed up syntax that makes it really unpleasant to work with them. Here’s the regular expression in question:

(defvar cider-compilation-regexp
  '("\\(?:.*\\(warning, \\)\\|.*?\\(, compiling\\):(\\)\\(.*?\\):\\([[:digit:]]+\\)\\(?::\\([[:digit:]]+\\)\\)?\\(\\(?: - \\(.*\\)\\)\\|)\\)" 3 4 5 (1))
  "Specifications for matching errors and warnings in Clojure stacktraces.
See `compilation-error-regexp-alist' for help on their format.")

How many things do you need to escape???

Ruby

RubyDay

Only 5 days after DCD I did a second talk at the RubyDay conference in Verona, Italy. I spoke about Ruby 3 there and you can check out my slide deck here. Funny enough, a lot was said about Ruby 3 on RubyKaigi just a week after my talk. I’m pretty glad that I was spot on with most of my predictions though, despite the limited sources of information I was working with.

You’ve got no idea how stressful it is to work on two new talks for back-to-back conferences! I was so relieved after I wrapped up my RubyDay talk! And I’m never doing this again.2

Apart from the stress RubyDay was a really nice event and I had a lot of fun meeting the local Ruby community. The conference was also a nice excuse for me to travel around Verona and spend a week enjoying Italian food and wines.

RuboCop

RuboCop saw a ton of activity lately and an important new release, which improved a lot its pretty-printing capabilities.

RuboCop 0.68 is already right around the corner and I’m really excited about getting some line length autocorrection support in it. That’s going to be a massive improvement in the formatting department.

I was also quite pleased to see what Flexport are doing to push this even further! I’m really grateful to see them making their work open-source and contributing it upstream!

Haskell

I continue slowly with my Haskell explorations and I’m having quite a bit of fun overall. Nothing interesting to report, though.

Joe

I never met Joe Armstrong in person, but I’ve always admired his work and he was a big source of inspiration for me. I was really saddened by the news of his passing on Saturday and I want to take a moment to honour his memory. I’ve often pondered on the legacy of software engineers, as things are some transient and ephemeral in our line of work. I have no doubt, however, that Joe’s legacy will live on for a very long time and his work will continue to inspire software engineer in the years to come!

Rest in Peace, Joe! You’ll be missed, but not forgotten!

Real World

It was really nice to spend some time in Amsterdam and Italy around the two conferences. I finally managed to visit Milan and the nearby Lake Como, after planning to do so for ages, and they didn’t disappoint. I can also heartily recommend to everyone to spend some time in the nearby town of Bergamo (where most low-cost airlines bound for Milan tend to land).

I’ve started reading “Persepolis Rising” (the seventh book in the “The Expanse” sci-fi series) and so far I find it to be pretty disappointing. Generally I’ve noticed that most book series really struggle to keep their momentum past volume #3.

On the movies side I finally watched “Baby Driver” and I certainly enjoyed it a lot. The movie’s style and soundtrack are quite something and felt very refreshing to me! Yesterday I went to see “Pet Sematary” and it was so-so. I think I liked the first movie better (even if I can barely remember it at this point). This was a reminder I should probably read the book one of those days. Later today I plan to hit the movies again with “Shazam!”. Fingers crossed!

As usual, I wanted to do many other fun or healthy things, but I miserably failed. Better luck next time!

  1. I was hoping I’d write one each week. 

  2. Although I’ve promised this to myself in the past as well. 

Permalink

Ep 025: Fake Results, Real Speed

Nate wants to experiment with the UI, but Twitter keeps getting the results.

  • “This thing that we’re making because we’re lazy has now taken 4 or 5 weeks to implement.”
  • Last week: “worker” logic vs “decider” logic. Allowed us to flatten the logic.
  • “You can spend months on the backend and people think you’ve barely got anything done. You can spend two days on the UI and then people think you’re making progress.”
  • (04:20) Now we want to work on the UI, but we don’t want data to post to real Twitter.
  • “Why do you keep posting ‘asdf asdf asdf’ to Twitter?”
  • UI development involves a lot of exploration. Need to try things out with data.
  • We want to be able to control the data we’re working with.
  • “We want carefully-curated abnormal data to test the edges of our UI.”
  • (06:30) We could have a second Twitter account to fill with junk data.
  • Or, we could use the Twitter API sandbox.
  • Problem: we don’t have much control over the data set. Eg. We can’t just “reset” back to what it used to be.
  • Problem: what about when we’re hacking on a plane?
  • Plus, we want to be able to share test data between developers.
  • (09:10) What can we do instead? Let’s make a “fake” Twitter service we run on our machine.
  • “Fake” Twitter gives us something our application can talk to that is under our control.
  • Having a “fake” Twitter service creates new problems
    • Project wants to grow larger and larger
    • Now we have to run more things
    • Creates more obstacles between checking out the code and getting work done
  • (12:35) Rather than a “fake” Twitter service, we want to “fake it” inside the application.
  • What is “faking it”? Is this the same as “mocking”?
  • Use a protocol for our Twitter API “handle”. Allows for an alternative implementation.
  • Not the same as mocking
    • We are not trying to recreate all the possibilities.
    • We are not trying to use it to test the component code.
  • The purpose is different than mocking. The Twitter “faker” is to help us work on the rest of the application.
  • The “faker” is about being productive, not about testing.
  • (17:20) Can have the faker start with useful data.
  • Default data in the faker can launch you straight into dev productivity.
  • “You want automation to support your human-centric exploration of things.”
  • “If your environment is as fast as it can possibly be, then it allows you to be as fast as you can possibly be.”
  • (20:10) Can use the faker for changing the interaction
  • Eg. have a “slow” mode that makes all the requests take longer.
  • Useful to answer the question: “What does this UI feel like when everything gets slow?”
  • “The faker can implement behavior you need, for exploring the space you need to cover, to converge on the right solution.”
  • (22:00) Can have the faker pretend something was posted manually.
  • Allows you to see how the UI behaves when the backend discovers a manual post.
  • (23:00) Goals of faking
    • Support exploration. Not about testing and validation
    • Support the human, creative side of development
    • Support the developer experience

Related episodes:

Clojure in this episode:

  • defprotocol
  • defrecord
  • component/system-map

Related projects:

Permalink

A Bitemporal tale

For those readers whose learning ability is better when affections are in place, we’re offering a short story writing experience through database transactions and queries.

Setup

Assuming you have some basic knowledge of Clojure and you own a REPL. All you need for this tale is to add Crux to your deps. For more configuration details see here.

   ; lein
   [juxt/crux "19.04-1.0.2-alpha"]
   ; deps.edn
   juxt/crux {:mvn/version "19.04-1.0.2-alpha"}

Fire up a repl and create a namespace

(ns a-tale
  (:require [crux.api :as crux]))

Define a system

(def system
  (crux/start-standalone-system
    {:kv-backend "crux.kv.memdb.MemKv"
     :db-dir "data/db-dir-1"}))

; alternatively, you can go with RocksDB for a persistent storage
(comment
  org.rocksdb/rocksdbjni {:mvn/version "5.17.2"} ; add this to your deps
  ; define system as follows
  (def system
    (crux/start-standalone-system ; it has clustering out-of-the-box though
      {:kv-backend "crux.kv.rocksdb.RocksKv"
       :db-dir "data/db-dir-1"})))

Letting data in

The year is 1740. We want to transact in our first character – Charles. Charles is a shopkeeper who possesses a truly magical artefact: A Rather Cozy Mug, which he uses in some of the most sacred morning rituals of caffeinated beverages consumption.

(crux/submit-tx
  system
    ; tx type    | id for the transaction (in-memory db or Kafka)
  [[:crux.tx/put   :ids.people/Charles

    {:crux.db/id :ids.people/Charles  ; id again for the document in Crux
     :person/name "Charles"
     ; age 40 at 1740
     :person/born #inst "1700-05-18"
     :person/location :ids.places/rarities-shop
     :person/str  40
     :person/int  40
     :person/dex  40
     :person/hp   40
     :person/gold 10000}

    #inst "1700-05-18"]]) ; valid time (optional)
; yields transaction data like
{:crux.tx/tx-id 1555661957640
 :crux.tx/tx-time #inst "2019-04-19T08:19:17.640-00:00"}

Ingest the remaining part of the set

(crux/submit-tx
  system
  [; rest of characters
   [:crux.tx/put :ids.people/Mary
    {:crux.db/id :ids.people/Mary
     :person/name "Mary"
     ; age  30
     :person/born #inst "1710-05-18"
     :person/location :ids.places/carribean
     :person/str  40
     :person/int  50
     :person/dex  50
     :person/hp   50}
    #inst "1710-05-18"]
   [:crux.tx/put :ids.people/Joe
    {:crux.db/id :ids.people/Joe
     :person/name "Joe"
     ; age  25
     :person/born #inst "1715-05-18"
     :person/location :ids.places/city
     :person/str  39
     :person/int  40
     :person/dex  60
     :person/hp   60
     :person/gold 70}
    #inst "1715-05-18"]])
; yields tx-data, omitted

(crux/submit-tx
  system
  [; artefacts
   ; In our tale there is a Cozy Mug...
   [:crux.tx/put :ids.artefacts/cozy-mug
    {:crux.db/id :ids.artefacts/cozy-mug
     :artefact/title "A Rather Cozy Mug"
     :artefact.perks/int 3}
    #inst "1625-05-18"]

   ; ...some regular magic beans...
   [:crux.tx/put :ids.artefacts/forbidden-beans
    {:crux.db/id :ids.artefacts/forbidden-beans
     :artefact/title "Magic beans"
     :artefact.perks/int 30
     :artefact.perks/hp -20}

    #inst "1500-05-18"]
   ; ...a used pirate sword...
   [:crux.tx/put :ids.artefacts/pirate-sword
    {:crux.db/id :ids.artefacts/pirate-sword
     :artefact/title "A used sword"}
    #inst "1710-05-18"]
   ; ...a flintlock pistol...
   [:crux.tx/put :ids.artefacts/flintlock-pistol
    {:crux.db/id :ids.artefacts/flintlock-pistol
     :artefact/title "Flintlock pistol"}
    #inst "1710-05-18"]
   ; ...a mysterious key...
   [:crux.tx/put :ids.artefacts/unknown-key
    {:crux.db/id :ids.artefacts/unknown-key
     :artefact/title "Key from an unknown door"}
    #inst "1700-05-18"]
   ; ...and a personal computing device from the wrong century.
   [:crux.tx/put :ids.artefacts/laptop
    {:crux.db/id :ids.artefacts/laptop
     :artefact/title "A Tell DPS Laptop (what?)"}
    #inst "2016-05-18"]])
; yields tx-data, omitted

; places
(crux/submit-tx
  system
  [[:crux.tx/put :ids.places/continent
    {:crux.db/id :ids.places/continent
     :place/title "Ah The Continent"}
    #inst "1000-01-01"]
   [:crux.tx/put :ids.places/carribean
    {:crux.db/id :ids.places/carribean
     :place/title "Ah The Good Ol Carribean Sea"
     :place/location :ids.places/carribean}
    #inst "1000-01-01"]
   [:crux.tx/put :ids.places/coconut-island
    {:crux.db/id :ids.places/coconut-island
     :place/title "Coconut Island"
     :place/location :ids.places/carribean}
    #inst "1000-01-01"]]) ; yields tx-data, omitted

Looking Around : Basic Queries

Get a database value and read from it consistently. Crux uses datalog query language. I’ll try to explain the required minimum, and I recommend learndatalogtoday.org as a follow up read.

(def db (crux/db system))

; we can query entities by id
(crux/entity db :ids.people/Charles)

; yields
{:crux.db/id :ids.people/Charles,
 :person/str 40,
 :person/dex 40,
 :person/location :ids.places/rarities-shop,
 :person/hp 40,
 :person/int 40,
 :person/name "Charles",
 :person/gold 10000,
 :person/born #inst "1700-05-18T00:00:00.000-00:00"}


; Datalog syntax : query ids
(crux/q db
        '[:find ?entity-id ; datalog's find is like SELECT in SQL
          :where
          ; datalog's where is quite different though
          ; datalog's where block combines binding of fields you want with filtering expressions
          ; where-expressions are organised in triplets / quadruplets

          [?entity-id    ; first  : usually an entity-id
           :person/name  ; second : attribute-id by which we filter OR which we want to pull out in 'find'
           "Charles"]])  ; third  : here it's the attribute's value by which we filter

; yields
#{[:ids.people/Charles]}


; Query more fields
(crux/q db
        '[:find ?e ?name ?int
          :where
          ; where can have an arbitrary number of triplets
          [?e :person/name "Charles"]

          [?e :person/name ?name]
          ; see – now we're pulling out person's name into find expression

          [?e :person/int  ?int]])

; yields
#{[:ids.people/Charles "Charles" 40]}


; See all artefact names
(crux/q db
        '[:find ?name
          :where
          [_ :artefact/title ?name]])
; yields
#{["Key from an unknown door"] ["Magic beans"]
  ["A used sword"] ["A Rather Cozy Mug"]
  ["A Tell DPS Laptop (what?)"]
  ["Flintlock pistol"]}

Undoing the Oopsies : Delete and Evict

Ok yes, magic beans once were in the realm, and we want to remember that, but following advice from our publisher we’ve decided to remove them from the story for now. Charles won’t know that they ever existed!

(crux/submit-tx
  system
  [[:crux.tx/delete :ids.artefacts/forbidden-beans
    #inst "1690-05-18"]])

Sometimes people enter data which just doesn’t belong there or that they no longer have a legal right to store (GDPR, I’m looking at you). In our case, it’s the laptop, which ruins the story consistency. Lets completely wipe all traces of that laptop from the timelines.

(crux/submit-tx
  system
  [[:crux.tx/evict :ids.artefacts/laptop]])

Let’s see what we got now

(crux/q (crux/db system)
        '[:find ?name
          :where
          [_ :artefact/title ?name]])

; yields
#{["Key from an unknown door"] ["A used sword"] ["A Rather Cozy Mug"] ["Flintlock pistol"]}


; Historians will know about the beans though
(def world-in-1599 (crux/db system #inst "1599-01-01"))
(crux/q world-in-1599
        '[:find ?name
          :where
          [_ :artefact/title ?name]])

; yields
#{["Magic beans"]}

Plot Development : DB References

Let’s see how Crux handles references. Give our characters some artefacts. We will do with function as we will need it later again.

(defn first-ownership-tx []
  [; Charles was 25 when he found the Cozy Mug
   (let [charles (crux/entity (crux/db system #inst "1725-05-17") :ids.people/Charles)]
     [:crux.tx/put :ids.people/Charles
      (update charles
              ; Crux is schemaless, so we can use :person/has however we like
              :person/has
              (comp set conj)
              ; ...such as storing a set of references to other entity ids
              :ids.artefacts/cozy-mug
              :ids.artefacts/unknown-key)
      #inst "1725-05-18"])
    ; And Mary has owned the pirate sword and flintlock pistol for a long time
    (let [mary  (crux/entity (crux/db system #inst "1715-05-17") :ids.people/Mary)]
      [:crux.tx/put :ids.people/Mary
       (update mary
              :person/has
              (comp set conj)
              :ids.artefacts/pirate-sword
              :ids.artefacts/flintlock-pistol)
       #inst "1715-05-18"])])

(def first-ownership-tx-response
  (crux/submit-tx system (first-ownership-tx)))

first-ownership-tx-response
; yields tx-data
{:crux.tx/tx-id 1555661957644
 :crux.tx/tx-time #inst "2019-04-19T08:19:21.640-00:00"}

Note that transactions in Crux will rewrite the whole entity, there’re no partial updates and no intentions to put them in the core as of yet. This is because the core of Crux is intentionally slim, and features like partial updates shall be kept in the upcoming convenience projects!

Who Has What : Basic Joins

(def who-has-what-query
  '[:find ?name ?atitle
    :where
    [?p :person/name ?name]
    [?p :person/has ?artefact-id]
    [?artefact-id :artefact/title ?atitle]])

(crux/q (crux/db system #inst "1726-05-01") who-has-what-query)
; yields
#{["Mary" "A used sword"]
  ["Mary" "Flintlock pistol"]
  ["Charles" "A Rather Cozy Mug"]
  ["Charles" "Key from an unknown door"]}

(crux/q (crux/db system #inst "1716-05-01") who-has-what-query)
; yields
#{["Mary" "A used sword"] ["Mary" "Flintlock pistol"]}

A few convenience functions

(defn entity-update
  [entity-id new-attrs valid-time]
  (let [entity-prev-value (crux/entity (crux/db system) entity-id)]
    (crux/submit-tx system
      [[:crux.tx/put entity-id
        (merge entity-prev-value new-attrs)
        valid-time]])))

(defn q
  [query]
  (crux/q (crux/db system) query))

(defn entity
  [entity-id]
  (crux/entity (crux/db system) entity-id))

(defn entity-at
  [entity-id valid-time]
  (crux/entity (crux/db system valid-time) entity-id))

(defn entity-with-adjacent
  [entity-id keys-to-pull]
  (let [db (crux/db system)
        ids->entities
        (fn [ids]
          (cond-> (map #(crux/entity db %) ids)
            (set? ids) set
            (vector? ids) vec))]
    (reduce
      (fn [e adj-k]
        (let [v (get e adj-k)]
          (assoc e adj-k
                 (cond
                   (keyword? v) (crux/entity db v)
                   (or (set? v)
                       (vector? v)) (ids->entities v)
                   :else v))))
      (crux/entity db entity-id)
      keys-to-pull)))


; Charles became more studious as he entered his thirties
(entity-update :ids.people/Charles
  {:person/int  50}
  #inst "1730-05-18")

; Check our update
(entity :ids.people/Charles)

;yields
{:person/str 40,
 :person/dex 40,
 :person/has #{:ids.artefacts/cozy-mug :ids.artefacts/unknown-key}
 :person/location :ids.places/rarities-shop,
 :person/hp 40,
 :person/int 50,
 :person/name "Charles",
 :crux.db/id :ids.people/Charles,
 :person/gold 10000,
 :person/born #inst "1700-05-18T00:00:00.000-00:00"}


; Pull out everything we know about Charles and the items he has
(entity-with-adjacent :ids.people/Charles [:person/has])

; yields
{:crux.db/id :ids.people/Charles,
 :person/str 40,
 :person/dex 40,
 :person/has
 #{{:crux.db/id :ids.artefacts/unknown-key,
    :artefact/title "Key from an unknown door"}
   {:crux.db/id :ids.artefacts/cozy-mug,
    :artefact/title "A Rather Cozy Mug",
    :artefact.perks/int 3}},
 :person/location :ids.places/rarities-shop,
 :person/hp 40,
 :person/int 50,
 :person/name "Charles",
 :person/gold 10000,
 :person/born #inst "1700-05-18T00:00:00.000-00:00"}

What Was Supposed To Be The Final

Mary steals The Mug in June

(let [theft-date #inst "1740-06-18"]
  (crux/submit-tx
    system
    [[:crux.tx/put :ids.people/Charles
      (update (entity-at :ids.people/Charles theft-date)
              :person/has
              disj
              :ids.artefacts/cozy-mug)
      theft-date]
     [:crux.tx/put :ids.people/Mary
      (update (entity-at :ids.people/Mary theft-date)
              :person/has
              (comp set conj)
              :ids.artefacts/cozy-mug)
      theft-date]]))

(crux/q (crux/db system #inst "1740-06-18") who-has-what-query)
; yields
#{["Mary" "A used sword"]
  ["Mary" "Flintlock pistol"]
  ["Mary" "A Rather Cozy Mug"]
  ["Charles" "Key from an unknown door"]}

So, for now, we think we’re done with the story. We have a picture and we’re all perfectly ready to blame Mary for stealing a person’s beloved mug. Suddenly a revelation occurs when an upstream data source kicks in. We uncover a previously unknown piece of history. It turns out the mug was Mary’s family heirloom all along!

Correct The Past

(let [marys-birth-inst #inst "1710-05-18"
      db        (crux/db system marys-birth-inst)
      baby-mary (crux/entity db :ids.people/Mary)]
  (crux/submit-tx
    system
    [[:crux.tx/cas :ids.people/Mary
      baby-mary
      (update baby-mary :person/has (comp set conj) :ids.artefacts/cozy-mug)
      marys-birth-inst]]))

; ...and she lost it in 1723
(let [mug-lost-date  #inst "1723-01-09"
      db        (crux/db system mug-lost-date)
      mary      (crux/entity db :ids.people/Mary)]
  (crux/submit-tx
    system
    [[:crux.tx/cas :ids.people/Mary
      mary
      (update mary :person/has (comp set disj) :ids.artefacts/cozy-mug)
      mug-lost-date]]))

(crux/q
  (crux/db system #inst "1715-05-18")
  who-has-what-query)
; yields
#{["Mary" "A used sword"] ["Mary" "Flintlock pistol"]}
; Ah she doesn't have The Mug still.
; Because we store that data in the entity itself
; we now should rewrite its state on "1715-05-18"

(crux/submit-tx system (first-ownership-tx))

(crux/q
  (crux/db system #inst "1715-05-18")
  who-has-what-query)
; yields
#{["Mary" "A used sword"]
  ["Mary" "Flintlock pistol"]
  ["Mary" "A Rather Cozy Mug"]}
; ah, much better

Note that with this particular data model we should also rewrite all the artefacts transactions since 1715. But since it matches the tale we can omit the labour for this time. And if acts of ownership were separate documents, the labour wouldn’t be needed at all.

Fin

(crux/q
  (crux/db system #inst "1740-06-19")
  who-has-what-query)
; yields
#{["Mary" "A used sword"]
  ["Mary" "Flintlock pistol"]
  ["Mary" "A Rather Cozy Mug"]
  ["Charles" "Key from an unknown door"]}

Now, knowing the corrected picture we are more ambiguous in our rooting for Charles or Mary.

Also we are still able to see how wrong we were as we can rewind not only the tale’s history but also the history of our edits to it. Just use the tx-time of the first ownership response.

(crux/q
  (crux/db system
           #inst "1715-06-19"
           (:crux.tx/tx-time first-ownership-tx-response))
  who-has-what-query)
; yields
#{["Mary" "A used sword"]
  ["Mary" "Flintlock pistol"]}

What’s Next?

Crux has a few important features which we left out of scope of this tale.

  • Crux has first-class Java API, so you can use it from Kotlin, Java, Scala or any other JVM-hosted language.

  • There’s history API, so for every entity you get its full history, or history bounded by valid time and transaction time coordinates.

  • Clustering

  • Datalog Rules – powerful query extensions

  • Evict can be scoped

Learn about these and other features on our docs portal.

If you have any suggestions on how to improve this tutorial or docs don’t hesitate to contact us on Zulip or on #crux channel on clojurians.slack.com for tutorial corrections ping @spacegangster

Credits

I want to give my credit on this post to all Crux authors and contributors, thanks to Jon Pither for inviting me to Crux team and a special thanks to Jeremy Taylor for his invaluable input to this tale.

Permalink

Journal 2019.16 - closed spec checking

Closed spec checking

As I mentioned last week, I’ve been working on adding a new form of closed spec checking and that’s in master for spec 2 now, or at least the first cut of it. The idea is that you can mark one or more (or all) specs as “closed” and they will then act in that mode for valid?, conform, and explain until you open them again. The specs themselves are unaltered - there is no marker in the symbolic spec form indicating “closed”-ness, rather this is a checking mode that you can turn on, kind of like instrument.

(require '[clojure.spec-alpha2 :as s])
(s/def ::f string?)
(s/def ::l string?)
(s/def ::s (s/schema [::f ::l]))
(s/valid? ::s {::x 10})  ;; "extra" keys are ok
;;=> true

;; now "close" the ::s spec (no-arg arity closes all specs)
(s/close-specs ::s)
(s/valid? ::s {::x 10})
;;=> false

(s/explain ::s {::x 10})
;; #:user{:x 10} - failed: (subset? #{:user/f :user/l} (set (keys %))) spec: :user/s

;; now open again
(s/open-specs ::s)

(s/valid? ::s {::x 10})
;;=> true

Still a lot of open questions about the API, behavior, etc, but this should give you an idea.

Clojure 1.10.1

We had some good feedback at the beginning of the week on Clojure 1.10.1-beta2, particularly about the error reporting aspects which go to a file. I continue to think that the default of writing to a temp file is best, but grew unsatisfied with the configurability of what was there, which was only a new clojure.main option. Instead of having a flag, decided it should be an enumeration of options - file (for temp file), stderr, and none. Also, having it just as a flag on clojure.main meant that every external launcher didn’t know how to do that, whereas making it a Java system property is already a configurable path that these tools (like Leiningen) can already handle. So, I made it check Java system property, then optionally override from clojure.main flag, and default to file mode. This is all captured in CLJ-2504 which I expect will go through screening next week.

I’m also growing a little concerned at the number of people patching around CLJ-1472 for Graal so will probably discuss that with Rich and consider whether to do anything for 1.10.1. The proposalss are tricky and may have unexpected perf impacts so its a difficult one to assess.

In any case, we would like to bring this release to an RC soon and get it released.

Rich has a couple api additions he put together this week related to metadata retention and those will get slotted in once we’re looking at 1.11 again.

Other stuff I enjoyed this week…

I haven’t had many music recommendations lately as I went through a bit of a dry spell. But I listened to a lot of music this week! My son is a drummer and has been doing an Aretha Franklin / Stevie Wonder show recently so it’s been a lot of fun watching him listen to a lot of stuff I’ve loved for a long time but that’s new to him. So many great tracks on Songs in the Key of Life, but he’s particularly digging on Sir Duke and the oddball Contusion this week.

In newer music, I really dug Lay Back by CLAVVS (pronounced “claws”), that’s from a 3-song EP and I like all 3 tunes on that.

I also want to give a shout-out to Stuart Sierra’s new podcast No Manifestos - the first 3 episdodes were all great! I really enjoyed listening to them.

Also, outside the Clojure world I run this little conference called Strange Loop - the CFP is open now, please submit!

Permalink

Cost of Laziness in Clojure

Cost of Laziness in Clojure

This is a technical post and laziness in this context means deferring execution of code. When the machine first meets the code, it may be executed there and then or it may be stored and executed later. Which of those happens depends on the instructions in the code, the computer just executes them without judgement.

Read more...

Permalink

Clojurex Cfp 2019 - Share Your Experiences

ClojureX conference is a great opportunity to meet and exchange ideas with other Clojure developers at a supportive and friendly conference. As well as learning all the latest technology in the Clojureverse, its a once a year chance to meet other developers in London to build new relationships and renew existing friendships.

We want to hear your experiences with Clojure/ClojureScript in what ever form, as well as any related topics. Have you created some neat technology or discovered a useful practice? Are you working on challenging or innovative projects? Every developers journey with Clojure and functional programming is different, so you will have learnt something that others haven’t and so we encourage you to share.

Submit your talk proposal via the CfP Google form

Deadline for the CfP submission is Monday 24th June 2019.

The CfP process in brief

Talks will be selected by Skills Matter and the Programme Committee on the basis of making the conference a varied and highly valuable event. Submission is open to anyone, and we encourage first-time speakers to submit a proposal. We also encourage co-presenting talks with multiple speakers.

Members of the Programme Committee are happy to offer coaching and assistance on talk proposals and we have a video tutorial to help shape your thoughts into a submission

Every speaker accepted will receive a free ticket to the conference.

All speakers are required to follow the SkillsMatter code of conduct

Suggested Topics

From the Call for Thoughts and your feedback from previous years the following topics are most welcome (although we are open to any talk ideas):

ClojureScript

  • React-style websites, UX/UI libraries, integration with JavaScript
  • Building and distributing apps with Node.js
  • Deploying and optimising for Serverless environments
  • Command line and self hosted ClojureScript tools
  • Testing ClojureScript & mixed language applications

Creative Art and humanities

  • digital art and installations
  • Music and composition
  • Gaming and digital entertainment

Development practices and experiences

  • Experience Reports
  • Testing practices
  • Spec and Generative Testing
  • Performance testing and pitfalls
  • Managing and extending Legacy Applications
  • Refactoring techniques

Machine Learning, AI and Data Science

  • Deep learning (e.g. Cortex, MXNET)
  • Data visualisation (e.g. Oz)
  • Dynamic Bayesian Networks, Geospatial Analysis
  • Data Engineering tools and practices
  • Large Scale Data Processing & Parallel Processing

Open Source Projects

  • projects you love / rely upon
  • contributing to / maintaining projects

Distributing / Deploying Clojure

  • Using GraalVM to develop & distribute Clojure apps
  • JVM optomisations and monitoring
  • Devops tools and practices

Anything else :)

Submissions should be for one of the following session types (time for Q&A is included in the session lengths):

  • 30-minute session talk (with Q&A included)
  • 10-minute lightning talk

We advise that you prepared with this guideline of about 20-25mins of actual presentation in mind, and setting 5-10 mins for Q&A following that. Talks will be ended at the 30 minute mark by the organisers to keep to the schedule.

Thank you.
@jr0cket

Permalink

What is referential transparency?

Referential transparency is a term you’ll hear a lot in functional programming. It means that an expression can be replaced by its result. That is, 5+4 can be replaced by 9, without changing the behavior of the program. You can extend the definition also to functions. So you can say + is referentially transparent, because if you call it with the same values, it will give you the same answer.

Transcript

Eric Normand: What is referential transparency? By the end of this episode, you’ll know what this term means and why it’s important in functional programming. My name is Eric Normand and I help people thrive with functional programming.

This concept is important because it’s used a lot in functional programming circles. Because of that, it’s important to understand what they mean by it. The term could come up in conversation, in even documentation, or a comment somewhere.

I do want to emphasize that this term is one of those terms that was taken from previous work, before functional programming, and kind of changed. This is not the original definition. It’s not the definition you’ll find in linguistics, which is where the term originally comes from, philosophy.

This is what functional programmers mean right now, so what is it? Referential transparency means you can take an expression in your program, and replace it with the result of that expression.

As an example, if you have the expression 5+4, you can replace that with 9. That’s what it means. Now 5+4 is easy because you can do that in your head without running the program. There are some expressions that you don’t know yet what they’re going to give you.

For instance X+Y, you don’t know what that’s going to give you, because you don’t know X yet, and you don’t know Y yet. What you do know is that if X and Y are the same as last time, X hasn’t changed and Y hasn’t changed, it’s going to give you the same value as before.

If X is 3 and Y is 7, it’s always going to give you 10. This is referential transparency. It means you can replace the expression with the result of that expression.

Another way to look at it — this is the way I like to look at it — is it means it doesn’t matter how many times you run that expression. It doesn’t matter how many times you call that function. Sometimes that expression is a function call. Sometimes it’s like an operator. Sometimes it’s some other kind of expression in your language.

It doesn’t matter how many times you run it. You’re always going to get the same answer. When I say it doesn’t matter how many times, it means even zero times is OK. For example, in the 5+4 example that I gave before, the compiler can replace that 5+4 with 9. So, basically, at run time, your expression never runs. It ran once at that compile time just to give you that number.

Or, it might not ever need to be run, because that line of code never happens. Because that value wasn’t needed. We’ll get to that in Lazy Evaluation. That’s what that’s called. Another way to look at it is if you give the same arguments to the operator or to the function, you’re going to get the same result every time. It doesn’t matter how many times you call it.

You can call it zero times, one time, a hundred times. You’re always going to get the same results. Now, notice that you can’t do that if you have side effects in your function. It has to be a pure function to be referentially transparent. If my function sends out an email or moves a robot arm or deletes a file on the disc, it’s not going to be referentially transparent, because it does matter how many times it runs.

I can’t just replace that expression with its return value. If it moves a robot arm and then returns how many inches it moved. So, it’s 10 inches. Then I run it again, and it’s like, “No, I didn’t run again. I replaced that with 10 inches.” It’s not going to work. You need to be able to move the arm every time you run the function.

Likewise, if you’re doing something like fetching a Web page, reading a file from the disc, calculating a random number, giving the current date — and those are functions — those are not referentially transparent either. Every time you read the file from the disc, it could be different. Some other program might be writing to it. Same with a Web request.

You could get a different page each time. The date is obviously going to be different every time you run it, because you might run it today, you might run it tomorrow. The random number, you want it to be different every time. It’s not referentially transparent.

You can’t replace the call to the random number generator with whatever the compiler found the first time it called it. It’s not going to work. You actually need to run it. You can’t just replace it with its expression. This is important because it’s one of the properties of pure functions of calculations.

I like to call them calculations, but you might know them better as pure functions. This is one of the properties of pure functions that we can use in our programs or our programming language to get some benefits from. I’ve already mentioned you can replace 5+4 with 9 at compile-time and you get the same resulting program.

You don’t have to run that at run-time because it’s always going to give you the same answer. That’s an optimization that the compiler can do. Likewise, you can do optimizations like lazy evaluation. What lazy evaluation means, is it says, this expression will be called either zero times or one time.

Not more than one. Zero times, it’s fine, because we said it doesn’t matter if it’s called zero times, because you might never need the answer. You might not need the answer to X plus Y, so why run it? Just remember, if I ask for it, then you calculate it.

If you do need the answer, it calculates it one time, and if you need the answer again, it just uses the pre-calculated answer. That’s lazy evaluation. I should have an episode on that. That sounds like a good thing. Let me make a note.

The other thing that you can do is more like caching or memoizing. Lazy evaluation is saying, “We’re going to run this zero or one times.”

Caching and memoization is very similar. What you’re saying is, “After I calculate it once, I’m going to store it and return that the next time.”

The famous example of memoization is memoizing the recursive version of Fibonacci. If you do a recursive definition of Fibonacci, there’s a lot of repeated work. You’re going to be calling Fibonacci of two or Fibonacci of three a lot of times.

Because every time you call Fibonacci, it’s going to call two more Fibonaccis and add them up. Each one of those is going to call two more Fibonaccis and add them up. Each one of those will call two more so — it doubles every level of that tree.

A lot of them are going to be the same because they’re going to start running into each other. You should program it just to see what I’m talking about. If you do Fibonacci of a high number, it takes a long time to calculate and it’s because it’s calculating the same thing over and over.

You can memoize this function — Fibonacci — so that every time you call it, it’s actually checking, “Hey, I’ve already calculated this one. If I have, I’m not going to calculate it again. I’m just going to return it.” It kind of short-circuits the recursion. You only have to do that the first time. After that, it’ll just use this cached value. It’s very similar to lazy evaluation.

Lazy evaluation is like a generalized thing you can run on any expression. Sometimes the whole language will have every expression be lazy evaluated behind the scenes. You don’t even think about it, like in Haskell. Whereas memoizing is something you do specifically to a function as a way to optimize it.

Like I said, this is a property of pure functions. I want to talk about why I don’t like this term just a little bit.

I already [laughs] mentioned that it is taking a term that already exists and repurposing it, and changing the definition a little bit without reference to its sources, really. That’s one thing I don’t like because then you can’t really communicate across domains. They use it a different way in linguistics and even in computer science.

There’s this whole other branch, not functional programming, called the programming language semantics, that uses it in a totally different way. We have to be careful about that. The other thing I don’t like about it is it’s like pinpointing this one little thing about pure functions. That is part of the definition of pure functions.

Pure function has no effect. The only thing that’s important about the function is its return value. By definition, that means you could replace it with its return value. It’s really not any different from pure functions, not in any practical meaningful way.

It’s a term that, when people throw it around, sometimes it sounds like, “Don’t you just mean that’s a pure function?” “Do you really have to say it’s referentially transparent?” “Are you just trying to sound smarter?” That’s my nitpicking with the term. It’s important though because even though people are just using it to sound smarter, they are using it, and you have to understand what they’re talking about.

It also has an opposite, referentially transparent. There’s referentially opaque, which is the opposite. Referentially opaque, it means you cannot replace it with its value. You actually have to run the thing.

If you do something like a GET request to a Web server, you can’t just replace the GET request with whatever the compiler got when you compiled it, because the Web server on the other side could change that page at any point. You actually have to call it each time.

Same thing for writing out to a disk. You can just not right out to the disk because you said, “Oh, my compiler has optimized that away.” No, you have to write it out each time. That makes it opaque. Let me recap. Referential transparency is a property of an expression.

We can extend it to a function calls or to functions that says that if you give it the same arguments, you can always get the same answer. That means that you can replace that whole function call with the answer. That is, if you know the arguments. Even at runtime, you can do that, which is lazy evaluation.

It means that it doesn’t matter how many times you’ve run it. You don’t even have to run it at all. You can just replace 5+4 with 9, never run the plus, and the program is still correct. It still gives you the same behavior.

It’s using lazy evaluation, compiler optimization, caching, and memorizing. Here’s the thing I didn’t mention, it’s useful when you’re dealing with the algebraic properties of an expression. You don’t have to worry about how an expression got calculated. You could always just replace it with the value. What’s important is the value that came out of it.

That’s a nice thing when you’re reasoning about your program. You don’t have to worry about how the thing got calculated. You can treat this function or this function call, this expression, as a black box. In the same way that you could just say, “Well, it’s going to be replaced by its value.”

The opposite of it is referential opacity. People don’t use that as much as they use referential transparency. I’ve said my piece on referential transparency, but what I haven’t really given is that original definition. I’m talking about original computer science definition. I’m not saying like go back to a Quine, who was doing this in the philosophy of language.

I’m talking about even in computer science, it was used before. I didn’t go over that. If you want me to, I can, in another episode. Let me know and I’ll make an episode about that. If you’ve liked this episode, you should subscribe because there will be more like it. I love to see more people on this channel.

The reason I do this whole show is to help spread these ideas to bring some clarity to them as much as I can. A lot of people will talk about just the term and define and maybe use it a couple times. I like to talk about how the term is used and how people are using it, whether it’s important, things like that.

Just trying to go a little bit deeper than the simple definition because these things are important, that we don’t forget the history of the term, that if you’re reading a paper on it, and you don’t know why it seems a little different from the way people seem to be using it. The terms have history. It’s really the usage of them that matter.

Anyway, if you’re into that, please subscribe. If you have a question, if you want to get in touch with me, if you want to tell me that you want this original definition, from semantics, programming language semantics, you can email me at eric@lyspcast.com. You can also tweet at me, I’m @ericnormand, or you can find me on LinkedIn. Cool, I’ll see you next time.

The post What is referential transparency? appeared first on LispCast.

Permalink

To match the formalism of static typing and analysis, we need to quantify the results of generative…

To match the formalism of static typing and analysis, we need to quantify the results of generative tests. I’m a big fan of generative testing and Clojure spec for exactly the reasons you’ve outlined. But there’s a bug gap in terms of mathematical formality compared with strong type systems such as Haskell’s. Static analysis provides a definite answer, either your program satisfies the constraints specified as types, or it doesn’t. There’s no middle ground, no subjective assessment. And that would be great, except that we know that the vast majority of constraints (or hypotheses) about code behavior cannot be proven to be true or false for all possible inputs.

The attraction of generative testing is that it opens up a much larger set of constraints which can be tested. We take some “large” sample of possible input values, call the function for each, and check against the specifications. It’s the difference between science and math: mathematics provides a framework to prove or disprove assertions, while science gathers evidence and updates beliefs in hypotheses. If one of our inputs causes a failure, we get a definite answer. But say we run a thousand trials and get no failures. How much can we believe that our program is “correct”? We sort of know that running a million trials should make us more confident, but how much?

This is a vital question, because we need to make choices about how much time/money to spend on testing and remediation of failures. When your program won’t compile because it fails static analysis, you almost certainly have to fix the issue. What if it fails generative tests? Or what if it passes the particular set of tests you happened to run? For example. a single failure may have just been “lucky”, perhaps you hit some edge case where floating point error accumulated and blew up your test. Should you fix that case? And the number of tests you can practically run may be a lot smaller than the domain of possible inputs. If they all pass, is it safe to ship your code? Answering these questions requires a couple of things. First is the cost of failure: you’ll make a much different decision if your code is a game as opposed to running a missile defense system or doling out radiation doses to cancer patients. And you need to quantify the probability that your code is actually correct, since the expected value of failure will be the cost times the probability.

These are hard questions to answer. Hard, but not impossible. The mathematical foundation exists to do so, we just need to start making the effort. Generative testing as it stands is certainly better than nothing, but it will remain qualitative hand-waving about code correctness until we can solve this problem.

Permalink

Clojure.spec

Это вторая глава предполагаемой книги по Кложе на русском языке. См. первую главу про веб-разработку.

Содержание

В этой главе мы рассмотрим clojure.spec – библиотеку для проверки данных в Clojure. Это особенная библиотека, поэтому уделим пристальное внимание.

Spec это сокращение от specification, т.е. спецификация, описание. В общих словах, это набор функций и макросов, чтобы схематично описывать структуры данных. Например, из каких ключей состоит словарь и каких типов его значения. Такое описание называют спецификацией, или сокращенно спекой.

Особые функции принимают спеку, данные и проверяют, подходят ли данные под спеку. Если нет, то возвращают отчет: в каком узле данных произошла ошибка и почему.

Spec входит в стандартную поставку Clojure начиная с версии 1.9. Полное имя модуля clojure.spec.alpha. Пусть вас не смущает частичка alpha на конце имени. Она осталась по историческим причинам.

Появление spec стало важной вехой в развитии Clojure. Ключевое свойство spec в том, что она фундаментальна. Валидация данных это всего лишь малая часть ее возможностей. Spec не только проверяет данные, но и преобразует и анализирует их. Например, на spec легко написать преобразование данных или парсер.

Технически spec основана на абстракциях, которые предлагает Clojure. Формально это обычная библиотека. Но абстракции spec оказались настолько мощны, что Clojure переиспользует их в работе. Начиная с 1.10, Clojure анализирует собственный код с помощью spec. Так проекты дополняют друг друга.

Мы начнем описание spec с валидации данных. Но прежде чем браться за техническую часть, разберемся с теорией. Как между собой связаны классы, типы и валидация.

Типы и классы

Принято считать, что код на языке со статической типизацией безопаснее, чем с динамической. Действительно, компилятор не позволит сложить число и строку еще до того, как мы запустим программу. Но сторонники статической типизации забывают, что тип переменной — это всего лишь одно из многочисленных ограничений. Редко случается так, что тип переменной полностью определяет допустимые значения. Чаще всего, в дополнение к типу, учитывают максимальные и минимальные границы, длину, попадание в интервалы и перечисления. Бывает, по отдельности значение верно, но не может соседствовать в паре с другим значением.

Рассмотрим абстракцию сетевого порта. Это целое число от 0 до 2^16-1. Поскольку целочисленные типы, как правило, представлены степенями двойки, наверняка найдется unsigned int, который охватывает именно этот диапазон. Но нулевой порт считается ошибочным, поэтому правильно отсчитывать порт с единицы. Вероятность, что язык реализует такой целочисленный тип, крайне мала.

Лучше всего проблема видна на диапазоне дат. Единичная дата может быть сколь угодно разумной, но диапазон накладывает ограничение: дата начала строго меньше даты конца. Со стороны бизнеса приходят новые требования: разница в датах не больше недели и в рамках текущего месяца.

В мире ООП знают об этой проблеме и решают ее классами. Типичный Java-программист пишет классы UnixPort и DateRange. Условный com.project.net.UnixPort – это класс с одним конструктором. Он принимает целое число и выполняет проверки на диапазон. Если число отрицательное или выходит за рамки 1 — 2^16, то конструктор выбрасывает исключение. Программист уверен, что создал новый тип. Это неверно – классы и типы не тождественны.

Конструктор такого класса это обычный валидатор, проверка во времени исполнения. Он неявно срабатывает, когда мы пишем new UnixPort(8080). Возникает иллюзия, что это тип, но на самом деле это проверка в рантайме. Ее удобство обусловлено синтаксисом.

В промышленных языках невозможно объявить класс так, чтобы выражение new UnixPort(-42) приводило к ошибке компиляции. Это возможно только сторонними утилитами или плагинами к IDE.

Проверки в конструкторах трудно переиспользовать. Предположим, два разработчика написали классы com.somecompany.util.UnixPort и org.community.net.MyPort. Класс UnixPort проверяет порт на диапазон и выбрасывает исключение. Нам выгодно пользоваться этим классом, поскольку он совмещен с валидацией. Однако сторонняя библиотека принимает порт как экземпляр MyPort, который только оборачивает целочисленный тип. Невозможно вызвать конструктор UnixPort для MyPort.

В примере выше мы вынуждены создать экземпляр UnixPort, чтобы проверить данные. Это стадия валидации. Потом извлечь из класса целое число порта. Это данные. Затем создать экземпляр MyPort и передать в библиотеку.

Из сказанного выше определим хорошие практики валидации. Это независимость от данных и компоновка. Независимость означает, что данные не привязаны намертво к валидации. Нет ничего плохого в том, что порт – это целое число. Пусть библиотека принимает integer, а разработчик сам решит, как проверять такое число. Так у него появится выбор, насколько строгой должна быть проверка.

Компоновка означает, что хорошо иметь несколько простых проверок, из которых легко составить сложные. Бывает, по отдельности реализованы проверки “это” и “то”, и теперь нужны их комбинации “это и то”, “это или то”. В идеале мы не должны тратить более одной-двух строк на столь тривиальные вещи.

Оба тезиса ложатся на функции. Вспомним, что функция это объект с операцией вызова. Договоримся, что для верного значения функция-валидатор вернет не ложное значение (отличное от false и nil), а для ошибочного — ложное. Поскольку функция это объект высшего порядка, другие функции принимают валидаторы как аргументы и возвращают их комбинированные версии.

Основы spec

С багажом этих рассуждений мы подошли к тому, как работает clojure.spec.

Импортируем главный модуль spec в текущее пространство:

(require '[clojure.spec.alpha :as s])

Синоним s нужен, чтобы избежать конфликтов имен с модулем clojure.core. Модуль spec несет определения s/and, s/or и другие, которые не имеют ничего общего со стандартными and и or. Считается плохой практикой, когда определения одного модуля заменяют другие. Это называется затенением имен, и мы рассмотрим проблему в отдельной главе. Пока что мы будем обращаться к spec через синоним s.

Базовая операция в spec – определить новую спецификацию:

(s/def ::string string?)

Макрос s/def принимает ключ и предикат. Он создал объект спеки из функции string?. Затем поместил спеку в глобальное хранилище под ключом ::string.

Важно понимать, что ::string – это не спека, а только ее псевдоним. Все макросы spec устроены так, что принимают не объект спеки, а ключ, и сами выводят по нему спеку. Это удобно, потому что ключи глобальны. В любом месте можно сослаться на ::string без нужды что-то импортировать.

Ключ спеки рассматривают как указатель или псевдоним. Легче передать псевдоним, чем реальный объект.

Вторым аргументом мы передали предикат string?. Предикат – это функция, которая возвращает истину или ложь.

Технически возможно получить объект спеки. Функция s/get-spec по ключу спеки возвращает ее Java-объект. Однако, на практике он мало чем полезен.

(s/get-spec ::string)

#object[clojure.spec.alpha$spec_impl$reify__2059 0x3e9dde1d ...]

Спеки хранятся в глобальном регистре под своими ключами. Макрос s/def не проверяет, была ли уже зарегистрирована такая спека. Если под этим ключом уже была спека, она будет потеряна.

Spec не разрешает использовать ключи без пространства, например, просто :error или :message. Это повышает риск конфликта ключей. Чтобы избавиться от конфликтов, используйте ключи с текущим пространством: ::error, ::message.

Самое простое, что можно сделать с новой спекой – проверить, подходит ли под нее значение. Функция s/valid? принимает ключ спеки, значение и возвращает true или false.

(s/valid? ::string 1)
false

(s/valid? ::string "test")
true

Заметим, пустая строка тоже будет валидной, однако чаще всего это не имеет смысла. Пустая строка в поле “имя пользователя” или “название проекта” означает ошибку. Объявим спеку, которая в дополнение к строке проверяет, что она не пустая.

Наивный способ сделать это – передать специальный предикат:

(s/def ::ne-string
  (fn [val]
    (and (string? val)
         (not (empty? val)))))

Быстрая проверка:

(s/valid? ::ne-string "test")
true

(s/valid? ::ne-string "")
false

Ключ ::ne-string это сокращение от ::non-empty-string. Эта спека встречается так часто, что логично сэкономить на ее упоминании.

Более изящный способ объявить ту же спеку – объединить предикаты через every-pred. Это функция, которая принимает предикаты и возвращает супер-предикат. Он вернет истину только если истинны все переданные предикаты.

(s/def ::ne-string
  (every-pred string? not-empty))

(s/valid? ::ne-string "test")
true

(s/valid? ::ne-string "")
false

Этот способ хорош, потому что декларативно собирает новую сущность из старых. Но еще лучше компоновать не предикаты, а спеки. Особый макрос s/and объединяет предикаты и объявленные ранее спеки в новую спеку:

(s/def ::ne-string
  (s/and ::string not-empty))

По такому принципу в Clojure строят сложные спеки. Объявляют примитивы и постепенно строят их усложненные версии.

Во время проверки Spec не перехватывает возможные исключения. Это остается на усмотрение разработчика. Рассмотрим пример – спеку для проверки строки на URL. Проще всего это сделать регулярным выражением:

(s/def ::url
  (partial re-matches #"(?i)^http(s?)://.*"))

(s/valid? ::url "test")
false

(s/valid? ::url "http://test.com")
true

Но не строковое значение вызовет ошибку:

(s/valid? ::url nil)
Execution error (NullPointerException) at java.util.regex.Matcher...

Это потому, что nil был передан в функцию re-matches. Функция трактует аргумент как строку, что приводит к NPE.

Считается хорошей практикой писать спеки, которые не выбрасывают исключения. В примере с ::url будет правильно сначала проверить, что значение строка, и только потом передавать ее в регулярное выражение.

(s/def ::url
  (s/and
   ::ne-string
   (partial re-matches #"(?i)^http(s?)://.*")))

(s/valid? ::url nil)
false

Теперь проверка nil не выбрасывает исключение.

По аналогии напишем проверку возраста пользователя. Это комбинация двух проверок: на число и диапазон.

(s/def ::age
  (s/and int? #(<= 0 % 150)))

(s/valid? ::age nil)
false

(s/valid? ::age -1)
false

(s/valid? ::age 42)
true

Спеки-коллекции

Выше мы проверяли примитивные типы или скаляры. Это удобно для демонстрации, но редко встречается в практике. Гораздо чаще проверяют не скаляры, а коллекции. Spec предлагает набор макросов, чтобы определить спеки-коллекции из примитивов.

Макрос s/coll-of принимает предикат или ключ и возвращает спеку-коллекцию. Она проверяет, что каждый элемент проходит валидацию. Вот так мы определим список адресов URL:

(s/def ::url-list (s/coll-of ::url))

Быстрая проверка:

(s/valid? ::url-list
          ["http://test.com" "http://ya.ru"])
true

(s/valid? ::url-list
          ["http://test.com" "dunno.com"])
false

Макрос s/map-of проверяет ключи и значения словаря. Вспомним поле запроса :params запроса из главы про веб-разработку. Его ключи кейворды, а значения строки.

(s/def ::params
  (s/map-of keyword? string?))

(s/valid? ::params {:foo "test"})
true

(s/valid? ::params {"foo" "test"})
false

Словари заслуживают отдельного упоминания. Проверка s/map-of достаточно слабая, чтобы покрыть все варианты. Факт того, что все значения строки не несет полезной информации. Гораздо важнее знать, что в словаре именно те ключи, что мы ожидаем. К тому же редко бывает так, что значения словаря одного типа. Наоборот, чаще всего словарь объединяет поля разных типов в рамках одной сущности. Например, имя, возраст и статус пользователя.

Для таких проверок используют макрос s/keys. Он выглядит как список спек. Ключи спек совпадают с ключами словаря. Значения таких ключей проверяются одноименными спеками.

Пусть объект страницы задан словарем с двумя полями. Это будут address, строка URL и description, текстовое описание. Объявим поля-примитивы:

(s/def :page/address ::url)
(s/def :page/description ::ne-string)

обратите внимание на пространство ключей :page. Адрес и описание относятся к сущности страницы, поэтому логично задать им особое пространство. Одноименные поля могут быть у пользователя или товара. Пространства гарантируют, что спеки :page/address и :user/address не затронут друг друга.

Составим спеку страницы.

(s/def ::page
  (s/keys :req-un [:page/address
                   :page/description]))

Параметр :req-un содержит вектор спек. Для каждой из них s/keys ищет ключ с таким же именем в словаре и проверяет значение. Рассмотрим, что именно означает :req-un и какие еще параметры принимает s/keys.

Ключ :req-un состоит из двух логических частей: req и un. Это признаки наличия ключа и его типа. Req означает, что перечисленные ключи обязаны быть в словаре. Если хотя бы один ключ отсутствует, спека не проходит проверку. Противоположный по смыслу параметр называется opt. В нем указаны ключи, которых может не быть. Валидация таких ключей случается только если они присутствуют в словаре.

Частичка un означает unqualified, то есть неполные ключи. При проверке un-ключей используются только их имена. Например, если спека :page/address указана в списке -un, то в словаре ищется ключ :address, а не :page/address.

Неполные (краткие) ключи — довольно частое явление в s/keys. В основном мы получаем данные из JSON-документов и баз данных. Такие системы не знают о пространствах имен, поэтому мы игнорируем их. Исключения бывают, когда весь технологический стек фирмы построен на Clojure.

Различают следующие комбинации req, opt и un:

  • :req — необходимые полные ключи,
  • :req-un — необходимые краткие ключи,
  • :opt — опциональные полные ключи,
  • :opt-un — опциональные краткие ключи.

В примере со спекой ::page все ключи обязательны и не учитывают пространство.

Приведем пример ошибочных данных. Это может быть невалидный URL, пустое описание, отсутствующее поле. Если каждый из этих словарей подставить в выражение (s/valid? ::params <data>), результат будет false.

{:address "clojure.org"
 :description "Clojure Language"}

{:address "https://clojure.org/"
 :description ""}

{:address "https://clojure.org/"}

{:page/address "https://clojure.org/"
 :page/description "Clojure Language"}

Обратите внимание на последний случай. Значения полей верны, но ключи содержат пространства. Это потому, что мы поместили спеки адреса и описания под ключом req-un, что значит отбрасывать пространства при поиске в словаре. Чтобы последний пример сработал, измените :req-un на :req в объявлении ::page.

(s/def ::page-fq
  (s/keys :req [:page/address
                :page/description]))

(s/valid? ::page-fq
          {:page/address "https://clojure.org/"
           :page/description "Clojure Language"})

Примечание: частичка -fq означает fully qualified, т.е. полный, разрешенный.

Комбинированный пример. Предположим, у объекта страницы может быть поле HTTP-статуса, который мы получили при последнем обращении к ней. Если к странице еще не обращались, в поле нечего записать. Вот как выглядит спека в таком случае:

(s/def :page/status int?)

(s/def ::page-status
  (s/keys :req-un [:page/address
                   :page/description]
          :opt-un [:page/status]))

Словари без статуса и с правильным статусом проходят валидацию:

(s/valid? ::page-status
          {:address "https://clojure.org/"
           :description "Clojure Language"})

(s/valid? ::page-status
          {:address "https://clojure.org/"
           :description "Clojure Language"
           :status 200})

Заметим, что s/keys различает nil и наличие ключа в словаре. Если статус nil, это значит, что он состоит в словаре. Срабатывает проверка nil на int?, что приводит к ошибке валидации.

(s/valid? ::page-status
          {:address "https://clojure.org/"
           :description "Clojure Language"
           :status nil})
false

Вывод значений

До сих пор мы рассматривали проверку данных с помощью s/valid?. Эта функция возвращает истину или ложь, что значит данные верны или нет. Но одной проверки недостаточно. Часто бывает так, что данные корректны, но требуется привести их к нужному типу.

Например, мы получили число в виде строки. Валидация показывает, что строка состоит только из цифр и не превышает допустимой длины. Но и после валидации значение остается строкой. Это склоняет нас к ручному парсингу, увеличивает код и вообще неудобно. Хотелось бы, чтобы вывод значений происходил автоматически.

Spec предлагает такие возможности. Это функции s/conformer и s/conform (от анг. conform – подчиняться, приспосабливаться).

S/conformer строит спеку особого типа из функции-конформера. Такая функция принимает исходное значение и возвращает либо новое значение, либо ключ :clojure.spec.alpha/invalid.

S/conform принимает ключ спеки-конформера и данные. Если вывод значений прошел без ошибок, результатом будет новое значение. Если с ошибками, то вернется все тот же ключ invalid.

Рассмотрим пример с выводом числа из строки. Чтобы различать спеку-конформер от валидатора, к ее имени добавляют стрелку, что означает вывод, приведение.

(s/def ::->int
  (s/conformer
   (fn [value]
   (try
     (Integer/parseInt value)
     (catch Exception e
       ::s/invalid)))))

Такую спеку передают в s/conform вместе с данными:

(s/conform ::->int "42")
42

(s/conform ::->int "dunno")
:clojure.spec.alpha/invalid

Как и s/valid?, s/conform не перехватывает исключения в процессе вывода. Java устроена так, что вывод данных часто выбрасывает исключения. Хорошей практикой будет перехватывать их на уровне функции-конформера и возвращать ::s/invalid, как в примере выше.

Оба типа спек — валидатор и конформер – можно объединить через s/and. Например, добавить предварительные проверки перед выводом значения. В нашем случае логично убедиться, что значение – строка, чтобы не передать в parseInt значение nil или что-то еще:

(s/def ::->int+
  (s/and ::ne-string
       ::->int))

(s/conform ::->int+ nil)
:clojure.spec.alpha/invalid

Рассмотрим случай с восстановлением дат из строк. С этой проблемой часто сталкиваются веб-разработчики, когда получают от браузера JSON-документ. Формат JSON не поддерживает даты, поэтому их передают строкой или числом секунд.

Нам понадобится функция разбора такой строки и минимальная обвязка, чтобы подружить ее со спекой. Функция read-instant-date из модуля clojure.instant читает строку в дату. Она очень лояльна к входным данным: например, датой может быть только год.

(require '[clojure.instant
         :refer [read-instant-date]])

(read-instant-date "2019")
#inst "2019-01-01T00:00:00.000-00:00"

Обернем функцию в спеку:

(s/def ::->date
  (s/and
   ::ne-string
   (s/conformer
  (fn [value]
    (try
      (read-instant-date value)
      (catch Exception e
        ::s/invalid))))))

Как и в случае с числом, перед разбором значения мы делаем минимальные проверки. Убеждаемся, что это непустая строка, чтобы отсечь nil и другие не имеющие смысла значения.

Строка даты:

(s/conform ::->date "2019-12-31")
#inst "2019-12-31T00:00:00.000-00:00"

Дата и время:

(s/conform ::->date "2019-12-31T23:59:59")
#inst "2019-12-31T23:59:59.000-00:00"

Спеки-перечисления

Интересен вариант, когда возможные значения известны заранее. Например, при вызове определенного API клиент передает архитектуру программы – 32 или 64 бита. Ради двух значений не обязательно парсить число. Достаточно оператора выбора или подбора по словарю.

Рассмотрим вариант с перебором. Оператор case пробегает по вариантам и возвращает аналогичные числовые значения. Если ничего не найдено, возвращаем ::s/invalid.

(s/def ::->bits
  (s/conformer
   (fn [value]
   (case value
     "32" 32
     "64" 64
     ::s/invalid))))

(s/conform ::->bits "32")
32

(s/conform ::->bits "42")
:clojure.spec.alpha/invalid

Вариант со словарем. По заранее определенному словарю ищем результат. Если ключ не найден, возвращаем тег invalid.

(def bits-map {"32" 32 "64" 64})

(s/def ::->bits
  (s/conformer
   (fn [value]
     (get bits-map value ::s/invalid))))

Вариант выше хорош еще тем, что его опорная точка – словарь соответствий – вынесен в отдельную переменную. При желании его легко дополнить новыми значениями без изменения логики. Или даже вынести в конфигурацию.

Подобным способом восстанавливают логические значения из строк. Нет единого соглашения о том, как передавать истину и ложь в тексте. Это может быть True, TRUE, 1, on, yes для истины и их противоположности: FALSE, no, off… При разборе таких значений важно приводить их к одному регистру. В Clojure FALSE и false – это разные строки, хотя отправитель имел в виду одно и то же.

Сценарий вывода выглядит так:

  • убедиться, что значение это строка;
  • привести ее к нижнему регистру;
  • проверить значение словарем или перебором.

Конформер из реального проекта:

(s/def ::->bool
  (s/and
    ::ne-string
    (s/conformer clojure.string/lower-case)
    (s/conformer
      (fn [value]
        (case value
          ("true" "1" "on" "yes") true
          ("false" "0" "off" "no") false
          ::s/invalid)))))

Примеры его работы:

(s/conform ::->bool "True") ;; true
(s/conform ::->bool "yes")  ;; true
(s/conform ::->bool "off")  ;; false

Продвинутые техники

К этому времени мы написали достаточно кода, чтобы увидеть одинаковые участки – паттерны. Перечислим несколько техник, которые ускоряют работу со спекой.

Множества

Когда значения известны заранее, спекой может выступить множество. Вспомним, что множество ведет себя как функция одного аргумента. Если такой аргумент есть в множестве, функция просто вернет его. Если нет, результат будет nil. Предположим, статус сущности может быть "todo", "in_progress" и "done". Тогда спека будет множеством этих значений:

(s/def ::status #{"todo" "in_progres" "done"})

(s/valid? ::status "todo")
true

Перечисления

Множество не подходит в случаях, когда мы считаем false и nil верными значениями. S/valid? трактует их как неудачную проверку. Если nil или false входят в множество значений, проверять проверять следует функцией contains?.

(contains? #{1 :a nil} nil)
true

Чтобы свести повторы кода к минимуму, напишем функцию enum. Она принимает значения и возвращает другую функцию-предикат. Этот предикат принимает один аргумент и проверяет, есть ли такой среди исходных значений.

(defn enum [& args]
  (let [arg-set (set args)]
    (fn [value]
      (contains? arg-set value))))

Внутренняя функция замкнута на переменной arg-set. Это множество, полученное из списка аргументов. Мы создаем его один раз, чтобы не делать это постоянно в теле функции. Теперь регистрировать спеки-перечисления гораздо удобней:

(s/def ::status
  (enum "todo" "in_progres" "done"))

With-conformer

Спеки-конформеры требуют особого внимания. В них легко допустить ошибку: не перехватить исключение, забыть обернуть функцию в s/conformer. Чтобы свести код к минимуму, воспользуйтесь макросом with-conformer. Он принимает символ для переменной и произвольное тело. Результатом макроса будет спека-конформер.

Ее внутренняя функция принимает параметр, заданный символом. Функция исполняет тело в блоке try-catch. Если исключения не было, результатом будет последнее выражение тела. Если было, то вернется тег invalid.

(defmacro with-conformer
  [bind & body]
  `(s/conformer
     (fn [~bind]
       (try
         ~@body
         (catch Exception e#
           ::s/invalid)))))

Примеры из реального проекта. Вывод числа:

(s/def ::->int
  (s/and
   ::ne-string
   (with-conformer val
     (Integer/parseInt val))))

Вывод логического значения:

(s/def ::->bool
  (s/and
   ->lower
   (with-conformer val
     (case val
       ("true"  "1" "on"  "yes") true
       ("false" "0" "off" "no" ) false))))

, где ->lower это тоже обертка для приведения регистра:

(def ->lower
  (s/and
    string?
    (s/conformer clojure.string/lower-case)))

в примере выше не обязательно указывать invalid для значения по умолчанию. Вспомним, что case, если не нашел соответствия и не задан вариант по умолчанию, выбрасывает исключение. With-conformer перехватывает его и возвращает invalid.

Логические пути

Результат s/conform не всегда то, что мы ожидаем. Некоторые спеки оборачивают результат в вектор, где первый элемент – часть логического пути. Такой пусть возникает в тех местах, где проверка ветвится.

До сих пор мы объединяли спеки через s/and. Такая супер-спека последовательно проходит дочерние и выполняет проверки. Это удобно, но недостаточно. Бывает, требуется спека-развилка, которая работает по условию. Например, если значение число, то оставить его как есть, а если строка, то попытаться привести к числу. Такие спеки называют условными.

Макрос s/or — одна из условных спек. Он принимает набор других спек и тегов. Макрос применяет значение к спекам до первого положительного случая. Результатом становится пара из нового значения и тега той спеки, что дала положительный результат.

Этот тег становится частью т.н. логического пути, по которому шла проверка. Логический путь помогает расследовать, в каком именно сценарии произошла ошибка. Для простых спек типа это не проблема. Но в реальных проектах бывает так, что условная спека вложена в другую условную, та тоже и т.д. Выявить ошибку без логического пути в таких случаях трудно.

Если валидация не прошла, то логический путь получают из отладочной информации. Эту информацию возвращают функции семейства s/explain*. Мы рассмотрим эти функции ниже.

Вот как выглядит спека сетевого порта. Она принимает число или строку, которую пытается преобразовать в число.

(s/def ::smart-port
  (s/or :string ::->int :num int?))

Теперь s/conform вернет не просто выведенное значение, а пару тег-значение:

(s/conform ::smart-port 8080)
[:num 8080]

(s/conform ::smart-port "8080")
[:string 8080]

Важно помнить, что если в спеке был условный узел (s/or, s/alt), то структура s/conform отличается от входных данных. Например, на месте скалярного значения появится вектор.

Покажем это на более сложном примере. Пусть порт — одно из полей параметров подключения к базе данных.

(s/def :conn/port ::smart-port)

(s/def ::conn
  (s/keys :req-un [:conn/port]))

Топология выходного словаря отличается от входных данных:

(s/conform ::conn {:port "9090"})
{:port [:string 9090]}

Анализ ошибок

Мы уже выяснили, что в случае неудачи s/valid? и s/conform возвращают false и invalid. Этих данных недостаточно, чтобы понять причину ошибки. Представьте, что у вас спека пользователя системы. У пользователя набор адресов, в каждом адресе несколько строк… и проверка вернула false. Поиск ошибки вручную в таком дереве займет день.

Функции семейства s/explain* принимают спеку и данные. Если ошибок не было, результат будет пустым. Если были, мы получим информацию о том, что пошло не так. Разница между функциями в том, как они обрабатывают результат.

  • s/explain печатает текст ошибки в стандартный поток (на экран);
  • s/explain-str возвращает эту же информацию в виде строки;
  • s/explain-data возвращает словарь с данными. Это самый полный отчет об ошибке.

Рассмотрим s/explain и s/explain-str. Результат их работы одинаковый, разница лишь в том, куда приходит текст — в консоль или переменную.

Подготовим простую спеку:

(s/def :sample/username ::ne-string)

(s/def ::sample
  (s/keys :req-un [:sample/username]))

На корректных данных explain никак не проявляет себя, разве что первый вариант печатает Success!:

(s/explain ::sample {:username "some user"})
Success!
nil

(s/explain-data ::sample {:username "some user"})
nil

Случай с ошибкой, вместо строки передано число:

(s/explain ::sample {:username 42})
42 - failed: string? in: [:username] at: [:username] spec: ::string

Отчет следует читать так: значение 42 не прошло проверку на предикате string?. Путь к значению внутри структуры [:username]. Ключ спеки, на которой произошла ошибка – ::string.

Отчет показывает самые вложенные спеки и предикаты. Вспомним, что ::ne-string это комбинация ::string и not-empty. Ошибка случилась на этапе ::string, что отчет и показывает.

Для пустой строки вывод будет другим. На этот раз проверка оборвется на этапе not-empty. Проверим это:

(s/explain ::sample {:username ""})
"" - failed: not-empty in: [:username] at: [:username] spec: ::ne-string

Сообщения такого рода допустимы для разработчиков, например, при проверке конфигурации на старте приложения. Но чем сложнее структура данных, тем менее понятен отчет explain. И уж точно такое сообщение нельзя показывать пользователям, если они ошиблись в данных. В следующем разделе мы рассмотрим эти проблемы.

Понятные сообщения об ошибках

Когда мы проверяем данные, важно не только зафиксировать факт ошибки. Еще важнее доступно объяснить клиенту, что именно неверно в его данных. Под клиентом не обязательно подразумевают человека. Даже если клиент это другая программа, будет правильно снабдить ответ понятным описанием. Скорее всего, клиент запишет результат в лог, и его прочитает человек.

Многие из нас сталкивались с сообщениями вроде “Ошибка 0x00ffff” без каких-либо деталей. Или красной надписью “проверьте данные” над формой в два экрана. Этих глупостей можно было избежать, умей программисты переводить системные сообщения в человеческие.

Очевидно, фраза "" - failed: not-empty in: [:username] не только ничего не скажет пользователю, но и отпугнет его машинной природой. Возникнет ощущение, что в интерфейсе образовалась брешь, и пользователь видит то, чего не должен. Это резко снижает доверие к системе в целом.

Чтобы сформировать понятное сообщение об ошибке, воспользуемся s/explain-data. Эта функция возвращает словарь со всей необходимой информацией. Вот как он выглядит:

(s/explain-data ::sample {:username ""})

#:clojure.spec.alpha
{:problems
 ({:path [:username]
   :pred clojure.core/not-empty
   :val ""
   :via [::sample ::ne-string]
   :in [:username]})
 :spec ::sample
 :value {:username ""}}

На первый взгляд непонятно, что делать с этой структурой. К сожалению, многие разработчики пасуют перед проблемой и говорят, что spec не подходит для ошибок. На самом деле, это отличная структура данных, нужно только правильно ее обработать.

Вопрос, который задают новички – почему бы не сделать понятные сообщения сразу на уровне библиотеки? Например, назначить спеке дополнительное поле с текстом “введите правильный адрес”? Почему не взять пример с многочисленных фреймворков для Python или JavaScript?

Ответ на этот вопрос не устраивает новичков. Вспомним тезис из начала главы. Spec – это фундаментальная библиотека. Она не предназначена для обработки ввода пользователя. Это набор абстракций и примитивов. То, что мы проверяем спекой поля HTML-формы – всего лишь частный случай. Ниже мы убедимся, что у спеки самые разные области применения. Поэтому структура ошибки тоже фундаментальна.

Во-вторых, трудно создать систему ошибок, которая устроит всех. В каждом проекте свои правила о том, как показывать ошибки. Рассмотрим поле возраста. Где-то пишут “укажите правильный возраст”. Это фиксированное сообщение, которое не меняется. Но в другом проекте ошибку выводят с текущим значением: “999 не подходит под критерии возраста”. Такое сообщение уже не фиксированный текст, а шаблон. Это значит, на одном из этапов срабатывает код, который извлекает ошибочное значение, шаблон, форматирует текст… А теперь добавим локализацию. В зависимости от локали браузера будем формировать сообщения на английском, русском, французском. Это очень, очень сложные сценарии.

Если бы разработчики Spec занялись выводом ошибок по принципу других фреймворков, их фокус был бы смещен с главной цели. И тогда вместо spec мы бы получили валидаторы по типу тех, что пишут десятками для JavaScript. Они скучны, не гибки и без концепции.

Словарь explain-data содержит ключи :spec, :value и :problems (с пространством clojure.spec.alpha). Первые два это спека и значение, которые принимали участие в проверке. Нас интересует поле problems. Это список словарей. Каждый словарь описывает конкретную ошибку валидации. Перечислим его поля и семантику.

  • :path – Логический путь валидации. Вектор ключей, где спеки чередуются с тегами-развилками. Условные спеки типа s/or записывают в этот вектор метки дочерних спек.

  • :pred – полный символ предиката, например clojure.core/string?.

  • :val – конкретное значение, которое не прошло проверку на предикат. Например, один из элементов исходного словаря.

  • :via – цепочка спек, по которым успело пройти значение от верхнего уровня к нижнему.

  • :in – физический путь к значению. Вектор ключей и индексов, который передают в функцию get-in. Если выполнить (get-in <исходные-данные> <путь>), то получим значение, которое вызвало ошибку.

Видим, что отчет содержит все данные, чтобы собрать понятное сообщение. Из :val возьмем конкретное неверное значение. Спека, на которой прервалась валидация это последний элемента вектора :via.

Составим словарь сообщений, где ключ — спека, а значение — понятный текст или шаблон. Зная спеку, в которой произошла ошибка, получим из словаря текст. Так, последним элементом вектора :via была спека ::ne-string. Логичное сопоставить ей сообщение “Строка не должна быть пустой” или что-то похожее.

(def spec-errors
  {::ne-string "Строка не должна быть пустой"})

Напишем наивную функцию, которая принимает словарь ошибки (один из элементов ::s/problems) и возвращает понятное сообщение:

(defn get-message
  [problem]
  (let [{:keys [via]} problem
        spec (last via)]
    (get spec-errors spec)))

(get-message {:via [::sample ::ne-string]})
"Строка не должна быть пустой"

Рассмотрим, как это работает на других полях. Добавим спеку электронной почты и новое поле в ::sample.

(s/def ::email
  (s/and
   ::ne-string
   (partial re-matches #"(.+?)@(.+?)\.(.+?)")))

(s/def :sample/email ::email)

(s/def ::sample
  (s/keys :req-un [:sample/username
                   :sample/email]))

Спека ::email проверяет строку по наивному шаблону электронного адреса. Регулярное выражение из примера выше читают как <что-угодно>@<что-угодно>.<что-угодно>.

Если передать в email пустую строку, последним элементом via будет ::ne-string. Для экономии места слегка сократим вывод explain-data:

(s/explain-data ::sample {:username "test" :email ""})

{:path [:email]
 :pred clojure.core/not-empty
 :val ""
 :via [::sample ::email ::ne-string]
 :in [:email]}

Если передать эту ошибку в get-message, она вернет прежнее сообщение о пустой строке. Но если email был непустой строкой, которая не попала под шаблон регулярного выражения, то последним элементом :via будет :sample/email. Полный словарь ошибки выглядит так:

{:path [:email]
 :pred
 (clojure.core/partial
  clojure.core/re-matches
  #"(.+?)@(.+?)\.(.+?)")
 :val "test"
 :via [::sample ::email]
 :in [:email]}

Чтобы get-message вернул другое сообщение, добавим в словарь ошибок ключ ::email:

(def spec-errors
  {::ne-string "Строка не должна быть пустой"
   ::email "Введите правильный почтовый адрес"})

Остается наращивать словарь все новыми спеками и сообщениями, пока не закроем все возможные ошибки. Но это линейный подход. Существует несколько способов улучшить поиск сообщений.

Например, что случится, если нужного перевода не окажется в словаре? В этом случае вернем нейтральное “исправьте ошибки в поле”. Заодно зафиксируем факт того, что перевод не был найден. Проще всего это сделать записью в лог.

Нормально, что команды разработки и локализации иногда не согласованы. Команда локализаторов время от времени просматривает этот лог и добавляет переводы.

Рассмотрим, как упростить поиск поля в словаре. Поле email может встречаться в разных спеках: :account/email, :patient/email, :client/email. Линейный подход из примера выше требует, чтобы для каждого такого ключа было сообщение об ошибке. Это склоняет нас к повторам в коде.

Чтобы не засорять словарь переводов, пойдем на хитрость. Пусть функция поиска пытается найти сообщение по полному ключу, а в случае неудачи — по его имени. Тогда достаточно ключа :email, и все емейлы будут сходиться на этот перевод. Если для одного конкретного емейла нужен особый перевод, добавим его полную версию в словарь:

(def spec-errors
  {::ne-string "Строка не должна быть пустой"
   :email "Введите правильный почтовый адрес"
   :account/email "Особое сообщение для адреса отправителя"})

Объединим вышесказанное в одну функцию. Вот как выглядит поиск в словаре с учетом неполного ключа и ошибки по умолчанию.

(def default-message
  "Исправьте ошибки в поле")

(defn get-better-message
  [problem]
  (let [{:keys [via]} problem
        spec (last via)]
    (or
     (get spec-errors spec)
     (get spec-errors
          (-> spec name keyword))
     default-message)))

Система, которую мы построили, достаточно проста. Ее легко тестировать и изменять под нужды конкретного проекта. Автор использовал доработанные версии такой системы в реальных проектах. В одном из случаев формы полностью проверялись на стороне клиента до их отправки на сервер. Это возможно, поскольку код на Clojure компилируется в JavaScript. Мощь clojure.spec в полной мере доступна на клиенте.

До сих пор мы использовали фиксированные сообщения. Но легко сделать их шаблонами, куда подставляют текущее значение или имя поля. В этом случае исправления в коде минимальны.

Получим имя поля как последний отличный от цифры элемент вектора :in. Ключ :val хранит текущее ошибочное значение. В тексте сообщения расставим %s для имени поля и значения. Функция (format <шаблон> <поле> <значение>) вернет что-то вроде “Поле email содержит неверное значение test.com”.

За рамками главы остались несколько вопросов. Первый — что делать, если требуется локализация сообщения, то есть вывод на русском или английском в зависимости от состояния? Очевидно, наша структура станет словарем словарей, где на первом уровне будет код локали (ru, en), а на втором — переводы для спек.

Теперь на первом шаге мы получаем по локали словарь переводов, затем переводим как описано выше. С кодом локали тоже можно схитрить, чтобы облегчить поиск. Например, иногда требуется различные написания для локалей en_US и en_GB. Реализуем функцию поиска так, что сперва она ищет по младшей локали (en_US), а затем по старшей (en).

Вопрос откуда приходит локаль остается на усмотрение разработчика. Ее можно хранить в сессии, параметрах адресной строки, базе данных, глобальной переменной, словом — как это удобно в текущем проекте.

Второй вопрос — как связать вывод ошибок с интерфейсом пользователя. Это тоже зависит от конкретного проекта. Хорошей практикой считается отделение модели от ее представления. Это верно и для форм. Удобно, когда форма — это структура данных с набором функций. Тогда все операции над ней будут чистыми функциями, которые легко поддерживать.

Представим форму как дерево, в листьях которого структура виджета. Виджет знает тип поля, текущее значение и ошибку. Специальный react-компонент подписан на этот лист дерева. На каждое его изменения компонент рисует HTML-элемент, например, поле ввода с текущим текстом. Если поле ошибки не nil, то оно выводится над полем.

Функция валидации принимает дерево формы. Она строит дерево значений. Это структура с той же топологией, но вместо листьев-виджетов на их местах значения. С помощью спеки мы проверяем значения (s/valid?) или выводим правильные типы из текста. В случае ошибки мы получаем отчет (s/explain-data). Для каждого элемента из поле problems находим путь, спеку и сообщение об ошибке. Это сообщение добавляем в соответствующий виджет в поле :error. Компонент, который отрисовывает этот виджет, уведомит пользователя об ошибке.

Парсинг

До сих пор мы обсуждали проверку данных и вывод значений. Теперь рассмотрим операцию более высокого уровня — парсинг. Под парсингом понимают разбор данных на части, выделение структуры там, где прежде ее не было.

Читателю наверняка приходилось парсить текст регулярными выражениями. Это особые шаблоны, которые описывают структуру фрагмента. Специальные функции принимают исходный текст и регулярное выражение. Структура результата известна заранее. Например, это список фрагментов, которые совпали с шаблоном.

Простейший пример регулярного выражения — IP-адрес. Это четыре группы, разделенные точками. Каждая группа состоит из числа от 0 до 255.

\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}

В примере выше мы ставим косую черту перед точкой. В регулярных выражениях точка это служебный символ. Мы экранируем его, чтобы описать именно символ точки.

В регулярных выражениях применяют операторы +, ?, * и другие. Они указывают, сколько раз встречается данный шаблон. Например, один и более раз, ни одного или один, произвольное число или ни одного. В зависимости от модификатора, шаблон захватывает разные части текста.

Можно представить, что регулярные выражения откусывают текст частями. Та часть, что легла на шаблон, наполняет структурированный результат. Оставшийся текст передается следующим шаблонам, и так далее.

Регулярные выражения подводят нас к regex-спекам. Это особый тип спек для разбора данных по шаблону. Разница в том, что и шаблон, и исходные данные это структуры, а не текст.

Предположим, мы читаем из текстового файла список пользователей. Каждый пользователь это кортеж вида <номер, емейл, статус>. Все значения в виде текста. Для каждого пользователя требуется:

  • убедиться, что в кортеже именно три элемента;
  • привести номер к числу;
  • проверить емейл на минимальные критерии;
  • привести статус к системному перечислению (одной из констант).

В идеале получить словарь с верными значениями.

Мы уже знакомы с s/conformer. Мы могли бы написать функцию, которая принимает кортеж пользователя и выполняет описанные выше преобразования. Технически это несложно. Но такая функция будет монолитной со слишком большим скоупом. Это плохая практика при работе с clojure.spec.

Для разбиения коллекций подходит s/cat. Эта спека принимает последовательность тегов и спек. На вход s/cat подают коллекцию значений, например список или вектор. Спека накладывает элементы коллекции на спеки. Если они совпали, результатом будет словарь. Ключи этого словаря — теги спек, значения — результат применения спеки к соответствующему значению.

Составим спеку для разбора кортежа. У нас уже есть парсинг чисел и проверка мейла. Опишем вывод статуса и соберем композицию спек:

(s/def :user/status
  (s/and
   ->lower
   (with-conformer val
     (case val
       "active" :USER_ACTIVE
       "pending" :USER_PENDING))))

(s/def ::user
  (s/cat :id ::->int
         :email ::email
         :status :user/status))

Проверим положительный случай:

(s/conform ::user ["1" "test@test.com" "active"])
{:id 1
 :email "test@test.com"
 :status :USER_ACTIVE}

Варианты с плохим номером, почтой или не тем статусом не проходят преобразование. Примеры ниже вернут ::s/invalid:

(s/conform ::user ["" "test@test.com" "active"])
(s/conform ::user ["1" "@test.com" "active"])
(s/conform ::user ["1" "test@test.com" "unknown"])

В примере с пользователем число полей в кортеже фиксировано. На практике так бывает не всегда. Мы все еще сталкиваемся с устаревшими форматами данных. В таких форматах бывают условия вроде “если перед номером строка BLOCKED, то пользователь заблокирован.” Это осложняет задачу, ведь теперь в кортеж состоит или из трех, или четырех элементов. Кроме того, сдвигается семантика полей. Первый элемент теперь не только номер, но и флаг блокировки. Встречаются и более сложные условия.

В императивных языка типа Python и Java такие требования порождают каскад if/else с перезаписью переменных. В Clojure эту проблему решают декларативно. Объявим спеку для флага блокировки:

(s/def ::blocked
  (s/and
   ->lower
   (s/conformer
    #(= % "blocked"))))

Добавим ее в итоговую s/cat, но укажем, что она встречается ноль или один раз:

(s/def ::user
  (s/cat :blocked (s/? ::blocked)
         :id ::->int
         :email ::email
         :status :user/status))

Теперь оба типа кортежа попадают под действие спеки. Если пользователь заблокирован, в итоговом словаре будет поле :blocked:

(s/conform ::user ["1" "test@test.com" "active"])
{:id 1 :email "test@test.com" :status :USER_ACTIVE}

(s/conform ::user ["Blocked" "1" "test@test.com" "active"])
{:blocked true :id 1 :email "test@test.com" :status :USER_ACTIVE}

Представим теперь, что на входе коллекция кортежей. Чтобы не утруждать себя ручной итерацией, объявим спеку-коллекцию:

(s/def ::users
  (s/coll-of ::user))

(def user-data
  [["1" "test@test.com" "active"]
   ["Blocked" "2" "joe@doe.com" "pending"]])

[{:id 1 :email "test@test.com" :status :USER_ACTIVE}
 {:blocked true :id 2 :email "joe@doe.com" :status :USER_PENDING}]

Отсеять заблокированных пользователей можно функцией filter с предикатом (complement :blocked).

С помощью regex-спек можно парсить не только данные, но и текст. Рассмотрим, как распарсить INI-файл в словарь данных. Напомним, INI — это старый текстовый формат для конфигурации приложений. Он состоит из секций в квадратных скобках и пар поле=значение.

# config.ini

[database]
host=localhost
port=5432
user=test

[server]
host=127.0.0.1
port=8080

Хотелось бы получить из файла вложенный словарь вида:

{:database {:host "localhost"
            :port 5432}
 :server {:host "127.0.0.1"}}

Если отбросить пустые и закомментированные строки, то структура файла сводится к грамматике (секция, пара*)*, где звездочка означает сколько угодно раз, в т.ч. ничего.

Сперва прочитаем строки из файла обычной функцией. Эта функция не должна быть частью спеки. Спеки не должны иметь побочных эффектов, они только преобразовывают данные.

(require '[clojure.java.io :as io])

(defn get-ini-lines
  [path]
  (with-open [src (io/reader path)]
    (doall (line-seq src))))

Теперь составим спеку-парсер. Решим, что такая спека принимает список строк из ini-файла. Требуется выполнить следующие шаги:

  • удалить пустые строки и комментарии;
  • оставшиеся строки сгруппировать по заголовкам, распарсить пары поле=значение;
  • реструктурировать данные во вложенный словарь;
  • вывести типы и все проверить.

Выразим это в коде:

(s/def ::->ini-config
  (s/and
   (s/conformer clear-ini-lines)
   (s/* (s/cat :title :ini/title :fields (s/* :ini/field)))
   (s/conformer remap-ini-data)
   ::ini-config))

Убедитесь, что поняли смысл четвертой строки. Мы считаем, что INI-файл это ноль и более блоков. Каждый блок состоит из заголовка и ноль и более пар ключ-значение.

Реализуем недостающие элементы. Функция clear-ini-lines выбрасывает незначащие строки:

(require '[clojure.string :as str])

(defn comment?
  [line]
  (str/starts-with? line "#"))

(defn clear-ini-lines
  [lines]
  (->> lines
       (filter (complement str/blank?))
       (filter (complement comment?))))

Объявим спеку :ini/title. Она проверяет, заголовок ли текущая строка или нет. Заголовок определяют квадратные скобки на границах строки. Если условие выполняется, вернем текст заголовка без скобок:

(s/def :ini/title
  (s/and
   #(str/starts-with? % "[")
   #(str/ends-with? % "]")
   (with-conformer val
     (subs val 1 (dec (count val))))))

Спека :ini/field парсит поле и значение. Просто разбиваем строку по знаку равенства. Цифра 2 означает, что в итоговом списке должно быть не более двух элементов: ключ и значение. Это полезно, когда значение содержит знак равенства (например, base64 строка).

(s/def :ini/field
  (with-conformer val
    (let [[key val :as pair] (str/split val #"=" 2)]
      (if (and key val)
        pair
        ::s/invalid))))

В примере выше мы убеждаемся, что действительно получили ключ и значение. Так мы исключим строки, в которых нет знака равенства.

На текущий момент запустим черновую, урезанную версию спеки:

(s/def ::->ini-config
  (s/and
   (s/conformer clear-ini-lines)
   (s/* (s/cat :title :ini/title :fields (s/* :ini/field)))))

(defn parse
  [path]
  (let [lines (get-ini-lines path)]
    (s/conform ::->ini-config lines)))

(parse "config.ini")

Результат:

[{:title "database"
  :fields [["host" "localhost"]
           ["port" "5432"]
           ["user" "test"]]}
 {:title "server"
  :fields [["host" "127.0.0.1"]
           ["port" "8080"]]}]

Разбор файла прошел удачно. Читатель заметит, что структура словаря отличается от той, что мы предложили вначале. Это неважно. Главное, нам удалось привести набор строк к определенному формату. Не составит труда обработать словарь функцией remap-ini-data:

(defn remap-ini-data
  [data-old]
  (reduce
   (fn [data-new entry]
     (let [{:keys [title fields]} entry]
       (assoc data-new title (into {} fields))))
   {}
   data-old))

Если передать в эту функцию вектор из предыдущего шага, результат будет тем, что мы ожидали:

{"database" {"host" "localhost" "port" "5432" "user" "test"}
 "server" {"host" "127.0.0.1" "port" "8080"}}

Напишем спеку, чтобы проверить конфигурацию и вывести типы. Например, чтобы номера портов были числами:

(s/def :db/host ::ne-string)
(s/def :db/port ::->int)
(s/def :db/user ::ne-string)

(s/def ::database
  (s/keys :req-un [:db/host
                   :db/port
                   :db/user]))

(s/def :server/host ::ne-string)
(s/def :server/port ::->int)

(s/def ::server
  (s/keys :req-un [:server/host
                   :server/port]))

(s/def ::ini-config
  (s/keys :req-un [::database
                   ::server]))

Перед тем, как применять словарь к этой спеке, следует перевести его ключи из строк в кейворды. Вот как выглядит итоговая спека:

(require '[clojure.walk :as walk])

(s/def ::->ini-config
  (s/and
   (s/conformer clear-ini-lines)
   (s/* (s/cat :title :ini/title :fields (s/* :ini/field)))
   (s/conformer remap-ini-data)
   (s/conformer walk/keywordize-keys)
   ::ini-config))

И результат:

(parse "config.ini")

{:database {:host "localhost"
            :port 5432
            :user "test"}
 :server {:host "127.0.0.1"
          :port 8080}}

Упражнение: устраните мелкие недоработки в коде выше. Пусть пара "foo=" становится {:foo nil}, а не {:foo ""}. Удалите пустые символы, которые могли остаться по краям ключа и значения. Опробуйте парсинг на больших ini-файлах.

Разбор Clojure-кода (теория)

В завершении темы немного поговорим о том, как парсить код. Мы уже видели, что clojure.spec подходит для разбора структур данных — в основном последовательностей. Вспомним, что код на Clojure состоит из списков. Это приводит к неожиданному решению: оказывается, исходный код на Clojure можно проверить спекой и вернуть ошибку еще до того, как он запущен.

В начале главы мы упоминали, что Clojure и Spec неразрывно связаны. Объясним эту связь на примере макросов. Большинство форм в Clojure представлены макросами. Это особые функции, которые срабатывают на этапе компиляции кода. Макрос принимает код как список символов. Задача макроса, как правило, в том, чтобы перестроить этот список в другой и вернуть его. Компилятор заменяет макрос на список-результат и выполняет его.

Макросы это отдельная веха в изучении Clojure. Мы поговорим о них в другой главе. Пока что заострим внимание на том, как проверить тело макроса.

Каждый макрос это по сути мини-язык с соглашением о том, что и в каком порядке передавать. Иногда один и тот же макрос допускает разные формы записи. По аналогии с языком, требуется разобрать грамматику кода. В случае ошибки доступно объяснить программисту, где он ошибся.

Хорошим примером служит defn, макрос определения функции. Кроме обязательных параметров он принимает несколько второстепенных: строку документации, пре- и пост-проверки. Справедлива форма записи с несколькими телами:

(defn my-inc
  [x]
  (+ x 1))

(defn my-inc
  "Increase a number"
  [x]
  {:pre [(int? x)]
   :post [(int? %)]}
  (+ x 1))

(defn my-inc
  ([x]
   (my-inc x 1))
  ([x delta]
   (+ x delta)))

Все это одна и та же функция, записанная по-разному. Очевидно, ручной разбор всех вариантов трудоемок. До версии Clojure 1.10 каждый макрос разбирал код по собственным правилам. Это было неорганизованно и неконсистентно. Начиная с 1.10 большинство макросов используют спеку для проверки и вывода ошибок. Так образовался общий подход к проблеме, который легко контролировать.

Разберем устно, как бы мы построили спеку для разбора defn. Очевидно, это список, поэтому на верхнем уровне спеки поместим s/cat. Первый его элемент — символ defn. Второй — символ с именем. После имени следует опциональный параметр строки документации. Затем тело или список тел.

Тело начинается с вектор параметров. После него идет опциональный словарь пре- и пост- проверок. Затем произвольное количество форм, которые составляют тело функции.

Грубая версия:

(s/def ::defn
  (s/cat :tag (partial = 'defn)
         :name symbol?
         :docstring string?
         :body (s/+ :defn/body)))

, где тело функции это

(s/def :defn/body
  (s/cat :args :defn/args
         :prepost (s/? map?)
         :code :defn/code))

В свободное время напишите такую спеку. Передайте в нее замороженную версию defn:

(s/conform
 ::defn
 '(defn my-inc
    "Increase a number"
    [x]
    {:pre [(int? x)]
     :post [(int? %)]}
    (+ x 1)))

Результатом будет что-то отдаленно напоминающее:

{:name my-inc
  :docstring "Increase a number"
  :bodies
  [{:params [x]
    :declaration [(+ x 1)]
    ;; other fields}]}

Каждый следующий уровень можно расширить вглубь. Выше мы упомянули вектор параметров. Будет здорово разобрать их на обязательные и необязательные. Например, чтобы параметры [x y & other] предстали в виде словаря:

{:required [x y] :rest other}

Усложните спеку так, чтобы она различала параметры функции. По аналогии выполните разбор словаря пре- и пост- проверок.

Спецификация функций

В последнем разделе мы поговорим о том, как clojure.spec связана с функциями. Мы уже упоминали проблему с проверкой входных данных. Даже если параметры нужного типа, это не гарантирует, что значения верны. Вспомним функцию, которая принимает диапазон дат. Случай, когда ее вызвали с параметрами start=2010.01.01 и end=2009.01.01, не имеет смысла.

Логично описать параметры этой функции спекой:

(s/def ::date-range-args
  (s/and
   (s/cat :start inst? :end inst?)
   (fn [{:keys [start end]}]
     (<= (compare start end) 0))))

Вторая функция из s/and принимает результат первого s/cat, то есть словарь с ключами :start и :end. Для сравнения дат используют специальную функцию compare, которая возвращает -1, 0 и 1 для случаев меньше, равно и больше. Быстрая проверка:

(s/valid? ::date-range-args [#inst "2019" #inst "2020"]) ;; true
(s/valid? ::date-range-args [#inst "2020" #inst "2019"]) ;; false

Возникает идея написать декоратор, который принимает целевую функцию и спеку ее параметров. Перед тем, как запускать функцию, он проверит параметры и в случае ошибки выкинет исключение. То же самое можно проделать для результата функции.

Нам не придется писать декоратор, потому что его включили в поставку clojure.spec. Речь идет о функции clojure.spec.test.alpha/instrument. Глагол instrument дословно означает оснастить, оборудовать.

Функция принимает символ другой функции, которую мы хотим оснастить проверкой. Вместе с тем она ищет особую функциональную спеку, зарегистрированную под этим же символом. Когда обе сущности найдены, instrument подменяет функцию на такую же, но с проверками. Это своего рода monkey patch, когда один модуль изменяет поведение другого.

Функциональную спеку объявляют макросом s/fdef. Сначала передают символ функции, которую хотели бы оснастить. Затем отдельные спеки для проверки входящих параметров, результата и их композиции.

В качестве примера напишем функцию и спеку к ней. Пусть это будет функция, которая возвращает разницу между двумя датами в секундах. В отличие от примера выше, мы допускаем случай, когда первая дата болье второй. В этом случае разница в секундах будет отрицательной.

(import 'java.util.Date)

(defn date-range-sec
  "Return a difference between two dates in seconds."
  [^Date date1 ^Date date2]
  (quot (- (.getTime date2)
           (.getTime date1))
        1000))

Теги ^Date нужны, чтобы компилятор знал тип объектов date1 и date2. В противном случае компилятор выполнит рефлексию, чтобы узнать тип. Это съедает машинное время. Мы поговорим о типах в отдельной главе.

Посчитаем разницу в сутках:

(date-range-sec
 #inst "2019-01-01" #inst "2019-01-02")
86400

Если поменять даты местами, результат будет отрицательным.

Опишем функциональную спеку. Ее символ будет date-range-sec. Под ключом :args указывают спеку входящих параметров. Поскольку параметры это список, на верхнем уровне почти всегда s/cat. Его задача разбить список на словарь, чтобы спекам ниже было удобно работать с отдельными значениями.

Под :ret указана спека выходного значения. Чаще всего это проверка на число или строку. Например, int?, string? или их nilable-версии: (s/nilable int) и так далее.

Ключ :fn особый. Это спека, которая будет вызвана в контексте входных параметров и результата. Бывает, что результат зависит от входных параметров по определенным правилам. Например, если функция возвращает число из диапазона, то проверка результата на int? недостаточна. Следует убедиться, что результат действительно не выходит за границы аргументов.

Спеке :fn передают словарь с ключами :args и :ret. Значение :args содержит не исходный список параметров, а результат s/conform от :args. Задача спеки — проверить, удовлетворяет ли результат входным аргументам. Если нет, вернуть false для предиката или ::s/invalid для s/conformer.

Напомним, что в ключи :args, :ret, :fn можно передавать объявленные ранее спеки. Это хорошая практика по переиспользованию кода. Например, у вас может быть семейство функций для работы с диапазонами чисел. Будет правильно объявить спеку параметров отдельно и затем ссылаться на нее в каждой из s/fdef.

Опишем спеку для функции date-range-sec. Ограничимся проверкой входных параметров и результата:

(s/fdef date-range-sec
  :args (s/cat :start inst? :end inst?)
  :ret int?)

Объявление функциональной спеки еще не меняет целевую функцию. Это правильно, потому что спека только декларирует проверки, но не запускает их. Чтобы подменить целевую функцию на ее оснащенную версию, используют instrument из модуля clojure.spec.test.alpha:

(require '[clojure.spec.test.alpha
           :refer [instrument]])

(instrument `date-range-sec)

Важно, что символ функции должен быть полным, то есть с пространством. Чтобы подставить в символ текущее пространство, перед ним ставят обратную кавычку ```.

Теперь date-range-sec проверяет аргументы и результат. Попробуем передать nil вместо одной из дат. Получим исключение класса clojure.lang.ExceptionInfo.

(date-range-sec nil #inst "2019")

Его текстовое сообщение и тело уже вам знакомы. Поле message содержит текст, аналогичный s/explain-str:

Execution error - invalid arguments to date-range-sec
nil - failed: inst? at: [:start]

В поле data структура, аналогичная результату s/explain-data. Чтобы получить эти данные из пойманного сообщения, используют функцию (ex-data exception).

Обратите внимание, что instrument расположен в отдельном модуле с доменом “test”. Это потому, что instrument предназначен для тестирования функций, но не продакшена. Разработчики пишут спеки для наиболее важных функций в отдельном модуле. Во время тестов проект стартует с особыми параметрами, где указаны дополнительные модули, которые нужно загрузить. Один из таких тестовых модулей оснащает функции их спеками. Если при прогоне тестов функция вернула неверный результат, это сразу станет заметно.

Instrument не подходит для боевого режима, потому что значительно снижает быстродействие функции. Замерим десять тысяч прогонов оснащенной функции:

(time
 (dotimes [n 10000]
   (date-range-sec #inst "2019" #inst "2020")))
"Elapsed time: 116.984496 msecs"

Получили десятую долю секунды на 10К вызовов. Пока что трудно сказать, быстро это или нет. Посчитаем время для исходной функции. Поскольку date-range-sec уже оснащена, объявим функцию с таким же телом, но другим именем, например date-range-sec-orig. Посчитаем стоимость ее вызова:

(time
 (dotimes [n 10000]
   (date-range-sec-orig
     #inst "2019" #inst "2020")))
"Elapsed time: 1.783962 msecs"

Разница в сто раз, или два порядка! Очевидно, что проверка в рантайме существенно замедляет приложение. По этой причине instrument не претендует на то, чтобы его использовали в продакшене. Замедление кода в десятки раз — слишком дорогая цена за детальный вывод ошибок.

Наоборот, во время тестов быстродействие нас не интересует. Мы стремимся покрыть код как можно большим числом проверок, чтобы отловить необычные сценарии.

В примере выше мы проигнорировали ключ :fn. Напомним, это комплексная проверка, в которой одновременно доступны аргументы и результат. Для функции date-range-sec справедливо правило: если первая дата больше второй, то результат отрицательный. Напишите спеку :fn, которая проверяет это условие. Тем самым вы предотвратите случай, когда кто-то решит, что результат должен быть по модулю.

Наличие спеки для функции улучшает документацию у ней. Специальная функция doc из модуля clojure.repl выводит на экран справку о запрошенной функции. С появлением clojure.spec ее поведение изменилось. Теперь, кроме строки документации, она выводит спеку функции.

Вот как выглядит справка для date-range-sec:

(require '[clojure.repl :refer [doc]])
-------------------------
date-range-sec
([date1 date2])
  Return a difference between two dates in seconds.
Spec
  args: (cat :start inst? :end inst?)
  ret: int?

Функцию doc активно используют различные IDE и редакторы, чтобы показывать сигнатуру по мере написания кода. Даже если вы не пользуетесь instrument для тестирования, спека помогает поддерживать проект.

Переиспользование спек

Писать спеки порой долго и утомительно. В Clojure-сообществе принято снабжать библиотеки спеками, чтобы облегчить труд другим разработчикам. Если библиотека использует какую-то структуру данных, будет правильно описать ее спекой.

Хорошим примером служит clojure.jdbc. Это легковесная Clojure-обертка над реляционными базами данных. Почти каждое веб-приложение использует БД для хранения данных. JDBC-подключение описано словарем с ключами :host, :port, :user и так далее.

Считается правильным проверить конфигурацию базы перед тем, как подключаться к ней. В противном случае вы рискуете получить странное поведение, например NullPointerException при попытке соединения.

Сlojure.jdbc несет на борту семейство спек для всех своих подсистем. Достаточно импортировать модуль clojure.java.jdbc.spec, чтобы описанные в нем спеки попали в глобальный реестр.

Предположим, ключ :db в конфигурации описывает подключение. Пусть это будет edn-файл:

{:db {:dbtype "mysql"
      :host "127.0.0.1"
      :port 3306
      :dbname "project"
      :user "user"
      :password "********"
      :useSSL true}}

Прочитаем файл комбинацией read-string и slurp:

(read-string (slurp "config.edn"))

Спека для этого файла выглядит так:

(require '[clojure.java.jdbc.spec :as jdbc])

(s/def ::db ::jdbc/db-spec)

(s/def ::config
  (s/keys :req-un [::db]))

Нормально, если спеку поставляют в отдельной библиотеке как дополнение. Так поступили разработчики alia — Clojure-клиента для Кассандры. Базовый пакет qbits.alia несет базовую функциональность, а сторонний cc.qbits/alia-spec содержит спеку кластера и основных функций.

Дополнения к spec (обзор)

Spec входит в поставку Clojure и потому не меняется так радикально, как предлагают некоторые разработчики. Дополнения к spec выпускают отдельными библиотеками. Среди прочих заслуживают внимания два проекта: expound и spec.tools. В этом разделе мы коротко опишем возможности каждого.

Библиотека expound улучшает сообщения об ошибках, делает их понятней для человека. Сигнатура функции expound аналогична s/explain. Она тоже принимает спеку и данные. Сообщение об ошибке выглядит примерно так:

(expound/expound string? 1)
-- Spec failed --------------------
  1
should satisfy
  string?
-------------------------
Detected 1 error

Такой отчет все еще выглядит машинным, и мы не можем показать его пользователю. Все же он лучше, чем сырой s/explain. Например, его могут прочитать коллеги из команды Ops, которые не знакомы с Clojure. Особенно хорошо expound подходит для проверки конфигурации на старте приложения. Иногда код приложения не меняется месяцами, но конфигурацию обновляют часто, поэтому отчет о проблемах на старте важен.

Разработчики Metosin собрали ряд улучшений к clojure.spec в проекте spec.tools. В сердце этой библиотеки особый объект Spec. Он оборачивает стандартную спеку и дополняет ее различными методами. С помощью spec.tools удобно формировать JSON-схему или описывать REST-проект по стандарту Swagger. Библиотеку используют в основном как промежуточный слой между REST-фреймворком и спекой.

Мы не будем останавливаться подробно на этих проектах. Они просты в техническом плане и требуют больше кода, чем объяснения. Читателю не составит труда разобраться с ними, когда на то возникнет потребность.

Будущее спеки

На сегодняшний день пакет clojure.spec все еще не избавился от частички “alpha” в названии. Авторы все еще экспериментируют со спекой, пытаются найти лучший способ валидировать данные. Это смущает некоторых разработчиков. Опасаясь, что по окончании эксперимента от spec избавятся, они предпочитают альтернативные библиотеки: schema, bouncer.

Отдельные группы пишут обертки над спекой, чтобы расширить ее возможности. Например, подружить ее с JSON-схемами и популярными инструментами вроде Swagger.

В недавнем докладе Maybe Not Рич Хикки анонсировал вторую версию спеки. Ожидается, что разработчики упростят проверку сложных типов данных. Например, когда значением может быть и строка, и число. Разработка ведется в открытом режиме, но еще рано говорить о конкретных результатах. Обсуждение новой спеки выходит за рамки этой главы.

Итог

Как мы выяснили, clojure.spec — это набор функций и макросов. Ими описывают правила, которым должны удовлетворять данные. Правила это предикаты, т.е. Функции, которые возвращают истину или ложь.

Предикаты гибче и мощнее типов. Если о значении известно, что оно верного типа, это еще не гарантирует его корректность. Значение -1 не может быть Unix-портом. Пользовательские классы вроде UnixPort в конструкторе это не типы, а валидация в рантайме. Она привязана к вызову класса синтаксическим сахаром.

В отличие от классов, предикаты компонуются друг с другом. Легко написать супер-предикат с логикой “все из”, “любой из” и так далее.

Мы рассмотрели основные возможности из пакета clojure.spec. Это далеко не все, о чем еще можно рассказать. В обсуждение не попали различные спеки-комбинаторы вроде s/alt, полезные для тонких проверок. Другая обширная тема по spec — генераторы из модуля clojure.spec.gen.alpha. Мы коснемся их в отдельной главе про тесты.

Код этой главы доступен в одном модуле на Гитхабе.

Permalink

Copyright © 2009, Planet Clojure. No rights reserved.
Planet Clojure is maintained by Baishamapayan Ghose.
Clojure and the Clojure logo are Copyright © 2008-2009, Rich Hickey.
Theme by Brajeshwar.