Getting Started With Clojure CLI Tools

Clojure Command Line Interface (CLI) tools provide a fast way for developers to get started with Clojure and simplify an already pretty simple experience. With tools.deps it also provides a more flexible approach to including libraries, including the use of code from a specific commit in a Git repository.

Practicalli Clojure 35 - Clojure CLI tools - an introduction is a video of a live broadcast of this content (inclucing typos)

Clojure CLI tools provide:

  • Running an interactive REPL (Read-Eval-Print Loop)
  • Running Clojure programs
  • Evaluating Clojure expressions
  • Managing dependencies via tools.deps

Clojure CLI tools allow you to use other libraries to, referred to as dependencies or ‘deps’. These may be libraries you are writing locally, projects in git (e.g. on GitHub) or libraries published to Maven Central or Clojars.

The Clojure CLI tools can cover the essential features of Clojure Build tools Leiningen and Boot, but are not designed as a complete replacement. Both these build tools are mature and may have features you would otherwise need to script in Clojure CLI tools.

This article is a follow on from new Clojure REPL Experience With Clojure CLI Tools and Rebel Readline

Getting started

Clojure is packaged as a complete library, a JVM JAR file, that is simply included in the project like any other library you would use. You could just use the Java command line, but then you would need to pass in quite a few arguments as your project added other libraries.

Clojure is a hosted language, so you need to have a Java runtime environment (Java JRE or SDK) and I recommend installing this from Adopt OpenJDK. Installation guides for Java are covered on the ClojureBridge London website

The Clojure.org getting started guide covers instructions for Linux and MacOXS operating systems. There is also an early access release of clj for windows

Basic usage

The installation provides the command called clojure and a wrapper called clj that provides a readline program called rlwrap to make editing nicer on the command line.

Use clj when you want to run a repl (unless you are using rebel readline) and clojure for everything else.

Start a Clojure REPL using the clj command in a terminal window. This does not need to be in a Clojure project for a simple repl.

1
clj

A Clojure REPL will now run. Type in a Clojure expression and press Return to see the result

Clojure CLI Tools REPL

Exit the REPL by typing Ctrl+D (pressing the Ctrl and D keys at the same time).

Run a Clojure program in a the given file. This would be useful if you wanted to run a script or batch jobs.

1
$ clojure script.clj

Aliases can be added that define configurations for a specific build task:

1
$ clojure -A:my-task

deps.edn

deps.edn is a configuration file using extensible data notation (edn), the language that is used to define the structure of Clojure itself.

Configuration is defined using a map with top-level keys for :deps, :paths, and :aliases and any provider-specific keys for configuring dependency sources (e.g. GitHub, GitLab, Bitbucket).

~/.clojure/deps.edn for global configurations that you wish to apply to all the projects you work with

project-directory/deps.edn for project specific settings

The installation directory may also contain a deps.edn file. On my Ubuntu Linux system this location is /usr/local/lib/clojure/deps.edn and contains the following configuration.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
:paths ["src"]
:deps {
org.clojure/clojure {:mvn/version "1.10.1"}
}
:aliases {
:deps {:extra-deps {org.clojure/tools.deps.alpha {:mvn/version "0.6.496"}}}
:test {:extra-paths ["test"]}
}
:mvn/repos {
"central" {:url "https://repo1.maven.org/maven2/"}
"clojars" {:url "https://repo.clojars.org/"}
}
}

The deps.edn files in each of these locations (if they exist) are merged to form one combined dependency configuration. The merge is done in the order above install/config/local, last one wins. The operation is essentially merge-with merge, except for the :paths key, where only the last one found is used (they are not combined).

You can use the -Sverbose option to see all of the actual directory locations.

Much more detail is covered in the Clojure.org article - deps and cli

Using Libraries - deps.edn

deps.edn file in the top level of your project can be used to include libraries in your project. These may be libraries you are writing locally, projects in git (e.g. on GitHub) or libraries published to Maven Central or Clojars.

Include a library by providing its name and other aspects like version. This information can be found on Clojars if the library is published there.

Libraries as JAR files will be cached in the $HOME/.m2/repository directory.

Example clj-time

Declare clj-time as a dependency in the deps.edn file, so Clojure CLI tools can downloaded the library and add it to the classpath.

1
2
{:deps
{clj-time {:mvn/version "0.15.1"}}}

Writing code

For larger projects you should definately find an editor you find productive and has great CLojure support. You can write code in the REPL and you can just run a specific file of code, if you dont want to set up a full project.

Create a directory hello-world. Create a deps.edn file in this directory using the example above.

The clj tool look for source code files in the src directory,

Create a src directory and the source code file src/hello.clj which contains the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
(ns hello
(:require [clj-time.core :as t]
[clj-time.format :as f]))
(defn time-str
"Returns a string representation of a datetime in the local time zone."
[dt]
(f/unparse
(f/with-zone (f/formatter "hh:mm aa") (t/default-time-zone))
dt))
(defn -main []
(println "Hello world, the time is" (time-str (t/now))))

The code has a static entry point named -main that can be called from Clojure CLI tools. The -m option defines the main namespace and by default the -main function is called. So the Clojure CLI tools provide program launcher for a specific namespace:

1
2
$ clj -m hello
Hello world, the time is 02:04 PM

Using libraries from other places

With deps.edn you are not limited to using just dependencies from JAR files, its much easier to pull code from anywhere.

TODO: Expand on this section in another article with some useful examples

rebl readline

Rebel readline enhances the REPL experience by providing multi-line editing with auto-indenting, language completions, syntax highlighting and function argument hints as you code.

clj-new

clj-new project Generate new projects from Leiningen or Boot templates, or clj-template projects, using just the clj command-line installation of Clojure!

add clj-new as an alias in your ~/.clojure/deps.edn like this:

1
2
3
4
5
{:aliases
{:new {:extra-deps {seancorfield/clj-new
{:mvn/version "0.7.6"}}
:main-opts ["-m" "clj-new.create"]}}
...}

Create a basic application:

1
2
3
clj -A:new app myname/myapp
cd myapp
clj -m myname.myapp

Run the tests:

1
clj -A:test:runner

Built-in templates are:

app – A minimal Hello World! application with deps.edn. Can run it via clj -m and can test it with clj -A:test:runner.
lib – A minimal library with deps.edn. Can test it with clj -A:test:runner.
template – A minimal clj-new template. Can test it with clj -A:test:runner. Can produce a new template with clj -m clj-new.create mytemplate myname/mynewapp (where mytemplate is the appropriate part of whatever project name you used when you asked clj-new to create the template project).

figwheel-main

Use the figwheel-main template to create a project for a simple Clojurescript project, optionally with one or reagent, rum or om libraries.

creating aliases

- creating a build in a different location
- using an alias from CIDER / SpacemacsRebel-readline

Misc

Despite the seemingly stripped-down set of options available in deps.edn (just :paths, :deps, and :aliases), it turned out that the :aliases feature really provides all you need to bootstrap a wide variety of build tasks directly into the clojure command.

What I really like best about this approach is that I can now introduce new programmers to Clojure using command line conventions that they are likely already familiar with coming from many other popular languages like perl, python, ruby, or node.

References

Thank you.
@jr0cket

Permalink

CIDER Jack-in to Clojure CLI Projects From Spacemacs

Running a Clojure project created with CLI tools or clj-new may require you to pass in an alias for the REPL to pick up the right libraries.

A few days ago I created a new ClojureScript and reagent project, using the Clojure CLI tools and clj-new project creation tool, which converts Leiningen and Boot templates into a deps.edn based project. Unfortunately when I created a project from the fighwheel-main template the REPL failed to run from CIDER using cider-jack-in-cljs, saying that figwheel-main was not found. All that was required was to specify the :fig alias when running a REPL.

This article covers two approaches to running Clojure CLI projects from CIDER jack-in that require setting of an alias.

See Getting started with Clojure CLI tools for background to this article.

Understanding the problem

I created a new project with the Clojure CLI tools and the figwheel-main template (using clj-new). This is the first time with this approach, so I may have missed something.

1
clj -A:new figwheel-main practicalli/study-group-guide -- --reagent

I ran cider-jack-in-cljs from Spacemacs and was prompted for the build tool. I selected figwheel-main and rather than being prompted for the name of the build to run, I got an error in the mini-buffer.

1
error in process filter: Figwheel-main is not available. Please check https://docs.cider.mx/cider/basics/clojurescript

The same error was seen when looking at the output in the *messages* buffer.

1
2
3
4
5
[nREPL] Starting server via /usr/local/bin/clojure -Sdeps &apos{:deps {nrepl {:mvn/version "0.6.0"} cider/piggieback {:mvn/version "0.4.1"} refactor-nrepl {:mvn/version "2.5.0-SNAPSHOT"} cider/cider-nrepl {:mvn/version "0.22.0-beta8"}}}&apos -m nrepl.cmdline --middleware &apos["refactor-nrepl.middleware/wrap-refactor", "cider.nrepl/cider-middleware", "cider.piggieback/wrap-cljs-repl"]&apos...
[nREPL] server started on 40737
[nREPL] Establishing direct connection to localhost:40737 ...
[nREPL] Direct connection to localhost:40737 established
error in process filter: user-error: Figwheel-main is not available.

The web page for the ClojureScript did not automatically open because figwheel-main is not running and the application was not built.

The project fails to run when using cider-jack-in-cljs as it cannot find the figwheel-main namespace. This is because CIDER is not being called with the -A:fig alias, which has a configuration to include figwheel-main as a dependency.

Hacking the CIDER jack-in command

Its very easy to hack the cider-jack-in-* commands command that CIDER uses to start a REPL using the universal argument.

SPC u , " or SPC u , s j s calls cider-jack-in-cljs with the universal argument. This will display an editable prompt for Cider jack-in in the mini-buffer.

Spacemacs Clojure - CIDER jack-in command line hacking

Use the arrow keys to edit this command and add the -A:fig option just after the /usr/local/bin/clojure executable name.

1
/usr/local/bin/clojure -A:fig -Sdeps &apos{:deps {nrepl {:mvn/version "0.6.0"} cider/piggieback {:mvn/version "0.4.1"} refactor-nrepl {:mvn/version "2.5.0-SNAPSHOT"} cider/cider-nrepl {:mvn/version "0.22.0-beta8"}}}&apos -m nrepl.cmdline --middleware &apos["refactor-nrepl.middleware/wrap-refactor", "cider.nrepl/cider-middleware", "cider.piggieback/wrap-cljs-repl"]&apos...

Emacs would use C-u before a cider-jack-in-* keybinding, C-u C-c M-J for the same results.

The *messages* buffer also shows the edited command line used to start a ClojureScript REPL.

1
2
3
4
[nREPL] Starting server via /usr/local/bin/clojure -A:fig -Sdeps &apos{:deps {nrepl {:mvn/version "0.6.0"} cider/piggieback {:mvn/version "0.4.1"} refactor-nrepl {:mvn/version "2.5.0-SNAPSHOT"} cider/cider-nrepl {:mvn/version "0.22.0-beta8"}}}&apos -m nrepl.cmdline --middleware &apos["refactor-nrepl.middleware/wrap-refactor", "cider.nrepl/cider-middleware", "cider.piggieback/wrap-cljs-repl"]&apos...
[nREPL] server started on 35247
[nREPL] Establishing direct connection to localhost:35247 ...
[nREPL] Direct connection to localhost:35247 established

Adding CIDER configuration with .dir-locals.el

Rather than edit the cider jack-in command options each time, a local configuration file can be created to set a variable defining the :fig alias we want to include when running a REPL for this project.

.dir-locals.el is an Emacs configuration file in which you can set variables for use with all files within the current directory or its child directories.

SPC SPC add-dir-local-variable is a simple wizard function to help you create the .dir-locals.el file. It will prompt you for the major mode, a variable name and variable value.

This variable will be used with the clojure-mode (using nil rather than clojure-mode the variable would be applied to all modes).

A variable called cider-clojure-clj-global-options will be used to set the :fig alias.

1
((clojure-mode . ((cider-clojure-cli-global-options . "-A:fig"))))

SPC SPC revert-buffer on one of the project source code files will load the variable from .dir-locals.el into Spacemacs. Otherwise, you can close the project buffer(s) and re-open them to load this variable into Emacs. Once the buffer is loaded again, running cider-jack-in-cljs works perfectly.

You can check the results by looking at the *mesages* buffer and you will see the details of the command that cider-jack-in-cljs function ran.

The .dir-locals.el is a list of lists. Each inner list contains which maps major mode names (symbols) to alists (see Association Lists). Each alist entry consists of a variable name and the directory-local value to assign to that variable, when the specified major mode is enabled. Instead of a mode name, you can specify ‘nil’, which means that the alist applies to any mode; or you can specify a subdirectory (a string), in which case the alist applies to all files in that subdirectory.

Understanding the :fig alias

deps.edn has a top-level key called :aliases that can include one or more alias definitions as maps. This example is from the figwheel-main template and has an extra dependency for the figwheel-main and rebel-readline-cljs libraries. So when starting a REPL with this alias, both those dependencies are available in the project.

1
2
3
4
5
:aliases
{:fig
{:extra-deps
{com.bhauman/rebel-readline-cljs {:mvn/version "0.1.4"}
com.bhauman/figwheel-main {:mvn/version "0.1.9"}}

The alias keeps these develop time libraries out of our application dependencies, as they are not required for running the application.

Leiningen includes figwheel-main as a dependency in the project.clj file in the :profiles {:dev ,,,} section. The dev profile is used by Leiningen by default, so the figwheel-main dependency is always there.

Summary

Using CIDER with projects created with Clojure CLI tools and clj-new works very well and only requires specification of which alias to use when starting the REPL from within Spacemacs.

If you have multiple aliases needed each time, you can chain them together:-A:fig:build:custom by editing the jack-in command line or including those aliases in the .dir-locals.el

Thank you.
@jr0cket

Practicalli - free online books on Spacemacs and Clojure development

Permalink

Software Engineer at StatsBomb (Full-time)

Background

StatsBomb was founded in January 2017 to provide data, analytics and consultancy to football clubs, media and gambling companies. StatsBomb continually undertake new research and are well known in the analytics industry for providing unique insights into the game of football. We have developed our own proprietary industry leading data collection and analytics software with a user-friendly high-vis front end.

StatsBomb have grown from our initial team of 3 at the start of 2017 to over 100 employees based in the UK, US and Egypt and are continuing to grow and expand our operation. We currently process over 200 games per week across 26 European and American leagues, with over 20 million events every day and will be covering 40 leagues by the start of the 2019/20 season.

We have custom built all systems for data collection and our AI driven analytics/insight platform and are currently enhancing and scaling our entire infrastructure to cater for live data collection and analytical modelling, potentially across multiple sports.

The Software Engineer role

StatsBomb is looking for a skilled software developer to join our growing company, serving some of the biggest clubs in world football.

The role will focus on developing our web-based analytics platform, built on a database of hundreds of millions of events captured from football games around the world. The platform provides tools and visualisations to explore player, team and match data, so experience developing attractive and usable user interfaces is a must, to ensure our product remains a leader within the industry. Our tools must be easy to use and understand at all levels within a football club, but also support advanced analysis by statisticians, data scientists and club analysts, so a solid mathematical background is desirable.

Experience with our software stack would be beneficial:

  • Clojure & ClojureScript
  • HTML, Javascript & SVG
  • React
  • PostgreSQL

We will consider experience across a range of software languages and frameworks, providing a proven track record of picking up new technologies quickly.

We are a new, fast growing company, with an incredibly full roadmap and are therefore looking for someone who is driven, self-motivated, flexible and comfortable working at a fast pace delivering new functionality on a regular basis.

The ideal candidate will have an interest in football/soccer data, interesting UI as well as excellent technical development skills.

The position is full time, based in our Bath office.

Must have:

  • BSc in a science based degree
  • 3-5 years experience with a range of software languages
  • Experience working with complex databases
  • Excellent SQL skills
  • Excellent communication skills, both written and verbal
  • Basic visualisation skills in Tableau/d3js/ggplot/SVG
  • Source control and collaboration with Git
  • Basic understanding of football
  • Team player, self-motivated, hard-working, keen to learn

Desirable:

  • MSc/PhD in a quantitative subject
  • Experience with:
    • PostgreSQL databases
    • Clojure
    • Clojurescript/React Framework
  • Experience with developing for production
  • Experience with Linux servers

Salary: Based on experience and will include a competitive stock option package.

Start date: 1st September 2019

Get information on how to apply for this position.

Permalink

Crux Development Diary

We launched Crux at the inaugural Clojure/north in Toronto in April of this year, 2019.

So far, so good. People have started using Crux, to the extent of swapping out some of pluggable modules for their own, to better fit their organisational requirements. An example is a team wiring in their own Active Objects event log.

Our immediate plans are to make Crux a more approachable product to use. The foundation is there - an unbundled architecture with a bottom-up design to serve bitemporal queries with ease of data eviction - but there is a risk that it could be seen as esoteric/academic, whereas we built Crux to be used in the general purpose sense of the word "database".

Therefore over the next couple of months we will publish tutorials and videos showing how easy Crux is to use. At its heart, Crux is a document store that you can smash EDN maps into. There’s no need for configuring upfront schemas, and Crux indexes everything at the top-level of the supplied document to make it available for query (here exists a useful rule of thumb: put fields you don’t want indexed into a document sub-map).

Our technical lead - Håkan Råberg - built the query engine and tested it using test suites such as LUBM (Lehigh University Benchmark from Lehigh University) to validate our SPARQL-equivalent query capability, and WatDiv (Waterloo SPARQL Diversity Test from the University of Waterloo) to stress-test the engine.

The engine uses a Worse Case Optimal Join algorithm, and it benchmarks well against competitive products. We have plans to iteratively speed it up further through cache-usage optimisation, and it’s on our short-term roadmap to publish our benchmarking results.

crux stocks perf

Crux has been in development for around 18 months at the time of writing, and it was born in the embers of projects that we felt necessitated its development. We have always felt strongly that bitemporality is an essential capability for a temporal database, but existing options in the marketplace weren’t enough. We wanted to use a product that had the ease and power of supporting Datalog, and also one that married well into enterprises where there existed event streaming architectures built using tools such as Apache Kafka. We also wanted a product that was pluggable (i.e. internally unbundled), customisable, and crucially, open-source.

And so we built Crux. I wrote the very first version that offer some basic query functionality using a Triple store, before Håkan came in and refactored nearly all my code away (he called it 'leanification'), to use his document-store approach. The document model offered simplicity and better promise of playing nicely with the other features we needed: bitemporal query, range searches, and data eviction.

By the end of a second phase, with Håkan at the helm, we had an MVP of sorts. We took a 2018 summer’s break, and we ramped up development through subsequent phases with the addition of engineers Patrik Kårlin and Ivan Fedorov. We also managed to attract an 'Offering Manager' from IBM - Jeremy Taylor - who is now, to all intents and purposes, running Crux.

Ahead of us we’ve got a roadmap that we passionately want to see delivered, and so there’s a feeling of pressing down on the accelerator, to make Crux more compelling and attractive to use.

We’ve split up the Crux repo, from monolith into sub-modules, and now you can see with a glance what unbundled parts Crux comprises of. There’s crux-core, crux-kafka, crux-rocksdb…​ and there’s a crux-jdbc that’s in the works, fast approaching a formal release status.

One piece of feedback that has come our way, is our endorsement of Kafka as the event-log and golden primary store of choice. Kafka is great, but when massive scale isn’t your objective and you don’t want the operational burden, then the only other option we currently support is to use Crux in a 'standalone' node configuration, using a local-disk based event-log.

The new JDBC event-log is more of a 'goldilocks' middle-ground option. We can use an existing JDBC compatible database as the event-log, meaning that in the cloud, where database-as-a-service offerings are common (i.e. AWS RDS), then Crux doesn’t require much setup at all. You can use Crux as an embedded library in your application instances, where the nodes will fetch the data they need for local indexing purposes straight from the JDBC store (Postgresql, Oracle and more).

A JDBC setup may not necessarily scale as well as Kakfa for ingestion and replayability, but if you’re not interested in immense scale, and if you’re comfortable working with cloud-based relational databases, then this approach could be for you.

I do feel obliged to say that if you are concerned about managing a Kafka setup, then check out this very recent and extremely competitively priced managed Kafka hosting service provided by Confluent, with no minimum pricing or need to think about brokers.

We have further plans for Crux. We intend to grow our development team first and foremost with imminent additions, and then we intend to release the Crux Console that Ivan and Jeremy have been cooking up. We will publish reproducable benchmarking results - triggered daily from CircleCI - and then we intend to build transaction functions, for the case where Compare-and-Swap simply doesn’t cut it (I’ve learnt that what you thought you needed transaction functions for, you can often just do with CAS, which has much less overhead to manage).

Beyond the main product updates, we have been busy working with community members, clients and partners, and generally ramping up for conferences, webinars and meetups. Replays from the recent Crux Night in London (hosted by Funding Circle) will be available soon. Jeremy and I will be speaking at Strange Loop in September. And finally, many of the JUXT team will be attending Heart of Clojure in August. We hope to see some of you soon!

Thanks for reading this journal. Please give Crux a try and see if it fits for your document store and Datalog query needs. Our official support channel is Zulip, but most people appear in the #crux channel on the Clojurians slack. You can also reach us via crux@juxt.pro.

Permalink

Article: Java InfoQ Trends Report - July 2019

The InfoQ Java trend report provides an overview of technology adoption and commentary on how we see the Java and JVM-related space evolving in 2019. Key developments include the release of Java 13, the rise of non-HotSpot JVMs and the evolution of GraalVM, and the changing landscape of Java microservice frameworks.

By Ben Evans, Erik Costlow, Dustin Schultz, Charles Humble

Permalink

Exploring REPL tooling with socket prepl

This post is mainly to help me plan my talk at London Clojurians on 16th July 2019 on the same topic.

If you're reading this, you're probably a Clojure programmer to some degree, even if that's just dipping your toes into the pool of immutability now and again. Chances are you've encountered some sort of command line tooling such as Leiningen or the Clojure CLI as well as some sort of REPL tooling for your editor.

This post is (hopefully) going to explain the inner workings of your current REPL tooling, as well as explain how my preferred tooling works and how it's different.

What is REPL tooling?

For those of you that aren't sure, you probably already use it already, here's an incomplete list of tools to give you an idea.

There's essentially one or more tools for every editor in existence out there somewhere. REPL tooling, to me, means a plugin that connects to some remote Clojure (or ClojureScript) process and allows you to send code to that process for evaluation from your text editor of choice.

It's much richer than a normal terminal REPL since you can use mappings to send specific forms inside your editor to the REPL and get the results beside the source code. They can provide autocomplete, documentation lookup, go to definition, formatting and much more without any static analysis or extra programs. The tooling gets to be your IDE by running inside your existing Clojure process!

This is a super power very few languages get to enjoy, it's something that's hard to understand as a beginner. It's something that, when it clicks, can't easily be left behind. REPL tooling is how we write our Clojure programs, it's the single essential tool in any Clojure programmer's toolbelt. Without this kind of tooling your only way to try something new is to turn it off and on again, which is completely normal across the industry. Normal isn't always good.

These plugins do not exist in a vacuum, they're built upon a shared interface for connecting to REPLs over a network. These interfaces influence the design of the plugins, their methodologies morph to fit the foundation they're built on. Let's explore what your REPL tooling uses to actually make things happen.

nREPL

nREPL is the golden standard of networked REPLs, it always has been and probably always will be. Bozhidar has done a great job of building up a community around the CIDER and nREPL stack. Although originally tailored for the Emacs crowd, nREPL and some of CIDER's middleware (we'll get to what that is soon) has been extracted in such a way that any other editor tooling can lean on this solid foundation.

I used fireplace in Vim for years which connected to the same server as my colleague in Emacs, they get to use the same community effort to share that power. The editor plugins are then thin clients around this nREPL based stack, the majority of the clever Clojure workings occur within the nREPL server which sits inside our project's process.

To extend nREPL we have to write middleware for our nREPL server, this can add new operations and capabilities although it requires writing an nREPL specific wrapper to hook it all together. Just like Leiningen plugins, you can rely on a generic library but you need to write something nREPL specific to connect it up in such a way that editors can use it.

Let's start an nREPL server and see how it behaves when we connect to it via telnet (I'm going to use the Clojure CLI for this). Feel free to follow along in your terminal!

clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version "0.7.0-alpha1"}}}' -m nrepl.cmdline -t nrepl.transport/edn
nREPL server started on port 35177 on host localhost - nrepl+edn://localhost:35177

We have to depend on nrepl/nrepl, enter the nrepl.cmdline namespace and then specify that we want to use EDN. It defaults to Bencode which is a binary representation that's not usable from the CLI. EDN support isn't in a stable release at the time of writing, so we need to rely on 0.7.0-alpha1.

So it's chosen a port for us, 35177 in this case (yours will probably be different!), let's telnet into that and try evaluating something.

telnet 127.0.0.1 35177
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
(+ 10 10)

And in our nREPL server we see the following with a stack trace.

ERROR: Unhandled REPL handler exception processing message (+ 10 10)

That's because nREPL expects all messages to be wrapped in a map data structure with an op key that we can set to :eval to perform an evaluation. Middleware adds more ops to your server. Let's send this over telnet instead with a new "session".

telnet 127.0.0.1 35177
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'
-> {:op :clone}
<- {:session "621d5eda-799c-4447-b3e9-4a358eeee821", :new-session "8ece86b4-79d8-4753-a5f2-d0246d86fe83", :status #{:done}}
-> {:op :eval, :code "(+ 10 10)", :session "8ece86b4-79d8-4753-a5f2-d0246d86fe83"}
<- {:session "8ece86b4-79d8-4753-a5f2-d0246d86fe83", :ns "user", :value "20"}
<- {:session "8ece86b4-79d8-4753-a5f2-d0246d86fe83", :status #{:done}}

I've added arrows to illustrate where I was sending (->) and where nREPL was responding (<-), as you can see, it's a little back and forth. We have to clone the root session, grab that new ID, send an :eval with our code and the session ID then get back two responses.

The first contains the value, the second tells us the session is :done, I'm not really sure what that means. I think it means whatever we evaluated is done and there will be no further output.

So, your nREPL tooling essentially connects for you, manages your sessions and dishes out various ops for you as you work. I think things like autocompletion are actually an op, for example. This does mean that nREPL has a bunch of plumbing that you need to be aware of while building tools (sessions etc) but for good reasons, it'll allow you to cancel long running or infinite evaluations, for example.

There's not really much else to show with regards to nREPL, I think JUXT's post on nREPL is a fantastic resource if you wish to know more. We're going to move onto an equivalent technology that's built into newer Clojure (and ClojureScript!) versions, let's compare the value and trade offs.

Socket REPL

So you may have seen the term thrown about in various Clojure circles but not many people are using it "in anger" right now. The socket REPL is exactly what the name implies, a REPL attached to a socket. Let's start a server now, you can do it from the CLI.

clj -J-Dclojure.server.jvm="{:port 5555 :accept clojure.core.server/repl}"
Clojure 1.10.1
user=>

So we don't need any dependencies (other than Clojure 1.10.0+) and we get dropped into a regular REPL after it starts. Let's telnet into port 5555 (which I've selected) and send it some code!

telnet 127.0.0.1 5555
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
user=> (+ 10 10)
20
user=>

What's interesting here is that we have the user=> prefix, just like the original REPL in the first terminal. It's exactly the same as if we typed that code into the normal default REPL, but we can do it over the network. What happens when we print something though.

user=> (println "Hello, World!")
Hello, World!
nil
user=>

Ah, herein lies a problem. Although we as humans can pretty easily tell that the first line is from stdout and the second is the nil returned from calling println, programs can't. Writing some software to understand what's an error, stdout, stderr or a successful evaluation result with this tool would be a nightmare.

What we really need is a REPL over the network that evaluates code for us and wraps the responses in some sort of data structure so we knew what kind of response it was.

Enter the prepl

Say hello to your new best friend, the prepl (pronounced like "prep-ul", not "p-repl"), it does just what we described! Let's start up a prepl and give our previous println evaluation another go.

clj -J-Dclojure.server.jvm="{:port 5555 :accept clojure.core.server/io-prepl}"
Clojure 1.10.1
user=>

Starting a prepl is done by starting a normal socket REPL but you give it a different :accept function, this handles all input and output for the socket. You can learn a little more about starting prepls in my Clojure socket prepl cookbook post.

telnet 127.0.0.1 5555
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
(println "Hello, World!")
{:tag :out, :val "Hello, World!\n"}
{:tag :ret, :val "nil", :ns "user", :ms 121, :form "(println \"Hello, World!\")"}

Excellent! We connect to the same port as before, send the same code as before, but we get back two wrapped responses. We can parse these two EDN values one line at a time and dispatch some code based on the :tag.

This is more than enough information for some remote program to connect, evaluate and act on the responses. These are the exact principals that Conjure is built on top of, it builds strings of Clojure code and fires them at a prepl for you. This means your project doesn't require any dependencies to enable your REPL tooling, you can just start a server and connect your editor to it, it'll handle the rest.

One of my favourite things about this is that ClojureScript support doesn't require you to jump through any hoops like piggieback for nREPL. We can just start a ClojureScript prepl and connect to that, let's start one that automatically opens and runs in our browser.

Yes, this is all built into vanilla ClojureScript, just make sure you're using the latest version! I've had a few patches already merged to unify the ClojureScript prepl with the canonical Clojure one, but I still have patch outstanding (CLJS-3096). Hopefully my work here makes future prepl tooling authors lives a lot easier!

clj -J-Dclojure.server.browser="{:port 5555 :accept cljs.server.browser/prepl}"
Clojure 1.10.1
user=>

So our prepl server is up (on the same port as before) and we get dropped into a regular Clojure REPL, this isn't ClojureScript. We've started a ClojureScript prepl from inside a JVM process. If you want to have figwheel building your ClojureScript as well as a prepl then check out the figwheel section in my prepl post. A prepl can be plugged into any ClojureScript environment, it just might take a little research.

telnet 127.0.0.1 5555
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
(println "Hello, World!")
{:tag :out, :val "Hello, World!"}
{:tag :out, :val "\n"}
{:tag :ret, :val "nil", :ns "cljs.user", :ms 161, :form "(println \"Hello, World!\")"}

Well that's pretty cool but how did this work. The JVM process ended up compiling our ClojureScript to JavaScript, sent that off to a newly opened browser tab in Firefox and evaluated it there. It gathered the results and printed that out of my socket prepl with each output wrapped in machine friendly data.

The two :out results is probably due to how println is implemented in ClojureScript. If you write prepl tooling finding these sorts of differences in the two becomes quite common place.

How Conjure uses the prepl

Conjure is my Clojure(Script) tooling for Neovim, written in Clojure and running on top of prepl connections. It has it's own JVM that build strings of Clojure code to send to your prepl for evaluations.

It supports things like documentation lookup, go to definition and completion (via Compliment which is injected for you). None of this requires any dependencies or changes to your existing project, other than starting a prepl.

That prepl isn't modified in any way though, it just acts as a way to evaluate code remotely that Conjure takes advantage of. If it was built on top of nREPL I supposed I'd be relying on a few bits of middleware, I'd maybe be more inclined to require a project dependency since using nREPL requires one anyway.

Let's look at how Conjure prepares any code you send it for evaluation. It doesn't just evaluate the code as-is, it wraps it up in such a way that the symbols defined in that evaluation will get the correct source file and line associated with them (not in ClojureScript, yet).

(defn eval-str [{:keys [ns path]} {:keys [conn code line]}]
  (let [path-args-str (when-not (str/blank? path) ;; 1
                        (str " \"" path "\" \"" (last (str/split path #"/")) "\""))]
    (case (:lang conn) ;; 2
      :clj
      (str "
           (do ;; 3
             (ns " (or ns "user") ") ;; 4
             (let [rdr (-> (java.io.StringReader. \"" (util/escape-quotes code) "\n\") ;; 5
                           (clojure.lang.LineNumberingPushbackReader.) ;; 6
                           (doto (.setLineNumber " (or line 1) ")))]
               (binding [*default-data-reader-fn* tagged-literal]
                 (let [res (. clojure.lang.Compiler (load rdr" path-args-str "))] ;; 7
                   (cond-> res (seq? res) (doall)))))) ;; 8
           ")

      :cljs
      (str "
           (in-ns '" (or ns "cljs.user") ") ;; 9
           (do " code "\n)
           "))))

This is probably the most complex code rendering function in Conjure, let's step through it with the number comments I've added.

  1. Optionally build a string that'll be an argument to (.load clojure.lang.Compiler), it sets the path for all defs within this evaluation.
  2. Build different strings for Clojure (:clj) and ClojureScript (:cljs) connections. I'm working to patch prepl to require less of these language specific things but there will always be subtle differences.
  3. Wrap the two parts of Clojure evaluations in a do so we only get one output from the prepl.
  4. Swap the namespace before the evaluation, this is read out of your buffer in Neovim through some interesting process.
  5. Wrap the code to be evaluated in a StringReader.
  6. Pass that to a LineNumberingPushbackReader then set the line number to what was specified or 1 by default.
  7. Actually evaluate the code, I use clojure.lang.Compiler because some of the higher level functions don't let you set this path.
  8. If the result is a sequence, fully realise it with doall otherwise we'll get weird behavior with lazy sequences that print things.
  9. In ClojureScript we perform two evaluations: Swapping the namespace and evaluating the code in a do. This means that the code calling this in Conjure needs to throw away the first prepl result since it's just a confirmation that the namespace was changed.

An evaluation function inside Conjure will execute this template function with the appropriate code and connection information. It'll then pass the result off to your prepl, get the result and deal with it accordingly, showing you any errors. Everything in Conjure works like this to some degree, building up code from template functions, evaluating it then working with the result.

Trade offs

All of this is pretty great but it comes at a cost: We don't have anything like middleware, the only feature we have available is evaluation. Now some may say that's bad, I think that's totally fine. We can now craft evaluations in such a way that we can do anything we want.

What better API than Clojure itself, we can build any tool imaginable with a REPL that lets us evaluate something. nREPL definitely has benefits by managing our sessions, allowing us to cancel execution and extend the messaging layer itself, but I don't miss them here. I like the fact that I have one infinitely powerful thing, I just have to send it the right code.

Wrap up

I hope this tour has taught you even one small thing about any of these technologies. My main takeaway from this is that nREPL is super powerful, but you have to learn nREPL. The socket REPL and prepl are much simpler but still allow you to do anything you want, albeit with carefully crafted Clojure code strings.

There's a lot to be said for middleware, it definitely feels like a more proper way to do some things, but so far in my 6-12 months worth of work on Conjure the lack of it hasn't hindered me.

To all of you future or current Clojure tool authors out there, whatever technology you end up building upon, build amazing usable tools that will draw more people to our lovely language.

Let's make everyone else jealous.

Permalink

Hire these DEVs!

Our community is full of talented and dedicated programmers. Many of them are looking for work so I thought I'd share a few DEV Listings that caught my eye:

Looking for company to work with on Master's thesis project in Front-end, UX, and/or Accessibility

Hi! 👋

I'm a last-year MSc student in product design. I'll soon begin my master's thesis project—hopefully in the field of front-end, UX, and accessibility—and am now looking for a company to work with!

I'm willing to relocate anywhere! 😍

More about me

My GitHub

Get in touch for more details!

DEV Listings is a dedicated area where members and organizations can post about opportunities ranging from upcoming events, to job openings, to mentorship offers, and everything in-between.

Permalink

PurelyFunctional.tv Newsletter 335: Idea: when can you use property-based testing?

Issue 335 – July 15, 2019 · Archives · Subscribe

Idea 💡

When can you use Property-Based Testing?

I’m working on a course on Property-Based Testing, and I’d like to compare it to example-based testing. My understanding of example-based testing is that people recommend you test before you write code. The reason you test before is because once you have written the code, you are very biased toward the details of the implementation. The one exception to this is that after a bug is found, you can add tests that reproduce the bug.

Property-Based Testing is different. It actually works well even after the implementation. Because it generates tests randomly, it won’t be biased toward your implementation.

For the course, I’ve come up with four times that I think Property-Based Testing works well for.

  1. Before implementing (like example-based tests)
  2. After implementing (to increase confidence in the code)
  3. When a bug is reported (to find a way to reproduce it)
  4. At design time (to verify a design)

Number 3 with property-based tests is different from with example-based tests is because with property-based tests, the testing engine does a search to find a way to reproduce it. In example-based testing, you, the developer, have to find it, then you manually add it as a test case.

Number 4 is also unique to property-based tests. If you’re designing a new login system, there are certain invariants you want to ensure, like “no one can set an insecure password”. Yet there are many endpoints the operate on the user’s account records. Is there some sequence of operations that can lead to a user setting an insecure password? Property-based testing can search for one for you in a simple model, before you’ve even implemented the system. You can then use the model (called an “oracle”) to verify the correctness of the actual implementation.

I think property-based testing is a super important technique, and I’m trying to make it more accessible with this course. I’ve already published a lesson on this idea.

My question to you is: are there any more times when you can use property-based testing? Have I covered everything? I’d love to hear your thoughts and questions.

Just finished reading 📖

Functional JavaScript by Michael Fogus is an interesting book. It is a glimpse over Fogus’s shoulder as he pushes JavaScript’s first-class functions to the limit, exploring lambda calculus gymnastics and the practicality of FP in JavaScript.

If you’re new to functional programming, I wouldn’t recommend this one. It talks much more about how to build the abstractions and how to use them than about why. But if you’ve done FP before, this might be a good book for deepening some concepts. It spends a lot of time showing how functions can be made to compose. Fogus is obviously quite adept with functions.

I came away from the book thinking that a lot of the abstractions were not worth it. For instance, the author builds a kind of state monad to be able to compose effectful functions. But the code is no clearer or more concise than the straightforward imperative implementation. While it’s useful as an exercise for those curious to know how far FP can go in JavaScript, I worry that people looking to improve their codebases with FP techniques might obfuscate their code.

The title also promises to teach Underscore.js (which has since been supplanted by Lodash), but it only seems to use it as a “missing standard library” instead of teaching how to use it effectively. Much more focus is placed on the humble first-class function. It’s a cool exploration, but in my experience, most programmers would be better served with practical techniques for programming with pure functions; skill with map, filter, and reduce; and a bit of algebraic thinking.

Sadly, these skills are for the most part assumed and are treated with an academic playfulness. That’s why I don’t think it would be good for beginners. If you’re into higher-order abstractions and you want to find out what can be done with JavaScript, give this book a discriminating read.

Brain skill 😎

get exercise

Exercise builds neuronal connections, regulates blood sugar, and oxygenates the blood. If your mind is moving slowly, go out for a run or do something strenuous. I don’t have any science to back this up, but movement helps me get back to a more grounded mode of thinking.

Currently recording 🎥

Recently, I started a course called Property-Based Testing with test.check. Property-based testing (PBT) is a powerful tool for generating tests instead of writing them. You’ll love the way PBT makes you think about your system. And you can buy it now.

I managed to record one lesson before my microphone burned out (I’ve ordered a new one) and Hurricane Barry moved through the region. The challenges of self-employment. Expect more next week.

  1. An overview of property-based testing with an example test, in which we test some existing code and see how the process of Property-Based Testing works, shrinkage and all.

I’ve made the first and fifth lessons free to watch. Go check them out.

Members already have access to the lessons. The Early Access Program is open. If you buy now, you will get the already published material and everything else that comes out at a serious discount. There is already 84 minutes of video, and looking at my plan, this one might be 6-7 hours. But that’s just an estimate. It could be more or less. The uncertainty about it is why there’s such a discount for the Early Access Program.

Clojure Challenge 🤔

Last week’s challenge

The puzzle in Issue 334 was to write an implementation of merge sort.

You can check the submissions out here.

I went the easy route and wrote a straightforward, lazy implementation with no bonuses. I tried a reducers solution but it was not faster.

This week’s challenge

calculate quartiles

It’s often useful to know the quartiles of a dataset. The quartiles define the lowest quarter, lowest half, and lowest three quarters data points. Those are typically called q1, q2, and q3. I also like to include q0 and q4, which are the min and max.

q2 is just the median of the dataset. q1 is the median of the lower half of the dataset. And q3 is the median of the upper half of the dataset.

Write a function which calculates the quartiles of a sequence of numbers. You will need to be able to calculate the median (which you can look up easily).

As usual, please send me your answers. I’ll share them all in next week’s issue. If you send me one, but you don’t want me to share it publicly, please let me know.

Rock on!
Eric Normand

PS ✍️ Book status

As you may know, I’m writing a book called Taming Complex Software, which is an introduction to functional programming. It has been a long journey and lots of work, but I’ve turned in the first three chapters for Manning’s Early Access Program. The gears are turning inside Manning’s publishing machine. Very soon, you will read here and elsewhere that those chapters are available for you to purchase. Stay tuned for more information.

The post PurelyFunctional.tv Newsletter 335: Idea: when can you use property-based testing? appeared first on PurelyFunctional.tv.

Permalink

Конфигурация

Содержание

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

Постановка проблемы

Когда мы читаем документацию к библиотекам Clojure, иногда встречаем подобные выражения:

(def server
  (jetty/run-jetty app {:port 8080}))

(def mysql-db
  {:dbtype   "mysql"
   :dbname   "book"
   :user     "ivan"
   :password "****"})

Это веб-сервер на 8080 порту и параметры подключения к базе. Примеры полезны тем, что их можно скопировать в REPL, выполнить и оценить результат. Например, открыть браузер со страницей веб-приложения или сделать запрос к базе данных.

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

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

Код, написанный разработчиком, обычно проходит несколько стадий. Их набор и структура отличаются в зависимости от фирмы, но в целом это разработка, автоматическое и ручное тестирование (юнит-тесты и QA), пре-прод и боевой режим.

На каждой стадии приложение запускают бок о бок с другими проектами. Предположение о том, что порт 8080 свободен в любой момент на каждой машине, звучит как утопия. На жаргоне разработчиков это называется “хардкод” (hardcode), что соответствует идиоме “прибито гвоздями”.

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

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

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

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

Семантика

Конечная цель конфигурации в том, чтобы управлять программой, не меняя ее код. Это становится очевидным с ростом кодовой базы и инфраструктуры фирмы. Когда у вас скрипт на Perl или Python, нет ничего зазорного в том, чтобы открыть его и поменять константу. На некоторых предприятиях такие скрипты работают годами.

Но чем совершенней инфраструктура фирмы, тем больше в ней ограничений. Сегодняшние практики нацелены на то, чтобы свести на нет спонтанные изменения в проекте. Например, невозможно сделать git push напрямую в мастер; нельзя сделать merge, пока pull request не одобрят минимум два члена команды; приложение не загрузится на сервер, пока не пройдут тесты.

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

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

Цикл конфигурации

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

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

На первом этапе программа читает конфигурацию извне. Чаще всего это текстовый файл или переменные среды. Чтобы хранить данные в тексте, уже придуманы специальные форматы: JSON, EDN, YAML. Приложение несет на борту модуль, чтобы получить их этих файлов структуру данных. Мы подробно рассмотрим плюсы и минусы каждого формата ниже.

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

Файлы и переменные среды дополняют друг друга в различных комбинациях. Например, приложение читает данные из файла, но путь к нему извлекает из переменной среды. Или в файле конфигурации опущены критические данные: пароли и API-ключи. Так поступают, чтобы посторонняя программа не смогла считать их. Приложение читает основные параметры из файла, а секретные ключи из переменных среды.

Продвинутые системы конфигурации поддерживают систему тегов. Например, в файле перед значением переменной ставят тег: :password #env DB_PASSWORD. Это значит, что в поле password следует поместить не строку DB_PASSWORD, а значение одноименной переменной. Мы подробно рассмотрим систему тегов в следующем разделе.

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

Не всегда данные, которые вы прочитали извне, соответствуют типам вашего языка или платформы. Форматы JSON или YAML выделяют базовые типы: строки, числа, булево, null и коллекции: словарь и список. Легко заметить, что среди них нет типа даты. В то же время даты часто встречаются в конфигурации, чтобы регулировать промо-акции или события, связанные с праздниками (черная пятница, Новый год, и тд).

В структурированных файлах даты задают либо ISO-строкой, либо числом секунд c 1 января 1970 года (эпоха UNIX). Специальный код должен пробежать про структуре данных и перевести такие поля в тип даты, принятый в языке.

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

Читатель, знакомый с Clojure, заметит, что эти проблемы решены в формате EDN. Он по умолчанию поддерживает даты и множества. Это не совсем верно: в некоторых проектах пользуются сторонним типом дат, например, JodaTime. Это значит, нам потребуется вывод верного типа из строки или стандартного Date.

В других случаях требуется обернуть скалярное значение в класс. Скажем, чтобы передать ENUM-значение в запрос, нужно обернуть его в класс PGObject. Чтобы не создавать новый объект каждый раз, логично вывести тип на этапе конфигурации. Поэтому даже EDN-данные нуждаются в выводе типов.

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

После вывода типов приступают к валидации данных. В главе про Clojure.spec мы упоминали, что верный тип еще гарантирует корректное значение. Предположим, мы считали переменную среды DB_PORT="8080". До тех пор, пока это строка, мы не можем проверить значение на числовой диапазон. Проверка нужна, чтобы в конфигурации нельзя было указать порт 0, -1 или 80.

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

Без валидации вы рискуете передать в Java-код пустые значения. Вы не заметите ошибки на уровне Clojure, потому что там nil трактуется как пустая коллекция. Но в случае с Java приложение выложит длинный трейс NullPointerException, расследовать который будет тяжело.

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

Ошибки конфигурации

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

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

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

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

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

Если с конфигурацией что-то не так, программа не должна работать дальше в надежде, что все обойдется. Технически возможна ситуация, когда один из параметров не задан (nil вместо числа), но программа к нему не обращается. Этого поведения следует избегать, потому что рано или поздно ошибка всплывет, и ее будет трудно расследовать.

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

Практика

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

Будем хранить конфигурацию в json-файле. Это не потому, что мы не знаем про edn. Предположим, что конфигурации других проектов тоже работаю на json-файлах, и у коллег из DevOps отдела уже написаны скрипты на Python для управления этими файлами. Проект с новым форматом усложнит коллегам жизнь.

Путь к файлу конфигурации передают в переменной среды CONFIG_PATH. От конфигурации мы ожидаем порт веб-сервера, параметры базы данных и диапазон дат для промо-акции. Текстовые даты должны стать объектами java.util.Date. Левое значение интервала строго меньше правого.

Готовый словарь запишем в глобальную переменную CONFIG. Если на одном из шагов случилась ошибка, выводим сообщение и завершаем программу.

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

(defn exit

  ([code template & args]
   (exit code (apply format template args)))

  ([code message]
   (let [out (if (zero? code) *out* *err*)]
     (binding [*out* out]
       (println message)))
   (System/exit code)))

В зависимости от статуса выводим сообщение либо в стандартный поток, либо в поток ошибок и завершаем программу.

Напишем загрузчик конфигурации. Это последовательность шагов, каждый из которых принимает результат предыдущего. Логику каждого шага легко понять из имени функции. Для краткости мы совместили вывод типов и валидацию в одном шаге coerce-config. Оба этих действия работают через clojure.spec, поэтому технически срабатывают за один вызов s/conform.

(defn load-config!
  []
  (-> (get-config-path)
      (read-config-file)
      (coerce-config)
      (set-config!)))

Осталось написать логику каждого шага. Функция get-config-path читает переменную среды и проверяет, есть ли такой файл на диске. Если все в порядке, функция вернет путь к файлу. В негативных случаях она вызывает exit:

(import 'java.io.File)

(defn get-config-path
  []
  (if-let [filepath (System/getenv "CONFIG_PATH")]
    (if (-> filepath File. .exists)
      filepath
      (exit 1 "Config file does not exist"))
    (exit 1 "File path is not set")))

Шаг read-config-file считывает данные из файла по его пути. Для разбора json воспользуемся библиотекой cheshire. Поскольку файл конфигурации небольшой, нет смысла читать из файла поток. Достаточно прочитать содержимое в большую строку и разобрать ее функций json/parse-string.

(require '[cheshire.core :as json])

(defn read-config-file
  [filepath]
  (try
    (-> filepath slurp (json/parse-string true))
    (catch Exception e
      (exit 1 "Malformed config file: %s" (ex-message e)))))

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

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

Для простоты воспользуемся библиотекой expound. Ее функция expound-str вернет улучшенный вариант стандартного отчета spec. Он не настолько удобен, как написанные вручную сообщения, но все же лучше, чем explain.

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

(defn coerce-config
  [config]
  (try
    (let [result (s/conform ::config config)]
      (if (= result ::s/invalid)
        (let [report (expound/expound-str ::config config)]
          (exit 1 "Invalid config values: %s %s" \newline report))
        result))
    (catch Exception e
      (exit 1 "Wrong config values: %s" (ex-message e)))))

Логика coerce-config проста: мы выводим чистые данные из исходных функцией s/conform. Ее вызов потенциально несет исключения, поэтому оборачиваем в try/catch. Если результат conform был ключом :invalid из пакета спеки, формируем отчет об ошибке и выводим пользователю

Не хватает определения спеки. Откроем файл конфигурации и изучим его структуру. Это json-файл следующего содержания:

{
    "server_port": 8080,
    "db": {
        "dbtype":   "mysql",
        "dbname":   "book",
        "user":     "ivan",
        "password": "****"
    },
    "event": [
        "2019-07-05T12:00:00",
        "2019-07-12T23:59:59"
    ]
}

Опишем спеку сверху вниз. На верхнем уровне это словарь с ключами:

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

Порт сервера это комбинация из двух предикатов: число и вхождение в диапазон. Первичная проверка на число нужна, чтобы во второе выражение не попали nil и строка. Это вызовет исключение там, где его не ждали.

(s/def ::server_port
  (s/and int? #(< 0x400 % 0xffff)))

Для подключения к базе данных используем готовую спеку из пакета jdbc:

(require '[clojure.java.jdbc.spec :as jdbc])
(s/def ::db ::jdbc/db-spec)

Остался диапазон дат. Импортируем функцию для разбора строки в дату. Это пакет clojure.instant из стандартной поставки Clojure. Функция read-instant-date довольно лояльна к формату строки и читает неполные даты. Обернем ее в s/conform:

(require '[clojure.instant :as inst])
(def ->date (s/conformer inst/read-instant-date))

Запишем проверку диапазона как функцию. Она принимает вектор двух объектов java.util.Date и сравнивает их. Даты нельзя сравнивать операторами больше или меньше. Причина кроется в реализации класса Date на Java-уровне. Специальная функция compare принимает два объекта и возвращает -1, 0 и 1, что означает меньше, равно и больше.

(s/def ::date-range
  (fn [[date1 date2]]
    (neg? (compare date1 date2))))

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

(s/def ::event
  (s/and
   (s/tuple ->date ->date)
   ::date-range))

Спека готова, и это значит, что шаг coerce-config тоже завершен. Уже на этом этапе можно закомментировать последнюю строку в load-config! и убедиться, что на выходе словарь с правильными значениями.

Шаг set-config! записывает конфигурацию в глобальную переменную. Объявим будущую конфигурацию под именем CONFIG. Написание в верхнем регистре, во-первых, подчеркивает, что это глобальная переменная. Во-вторых, тем самым мы не затеним ее локальным параметром функции.

(def CONFIG nil)

(defn set-config!
  [config]
  (alter-var-root (var CONFIG) (constantly config)))

Достаточно вызвать (load-config!) на старте программы, чтобы в CONFIG появилась проверенная конфигурация. Другие подсистемы импортируют CONFIG в свои модули и читают нужные им ключи. Вот как выглядит запуск сервера или запрос к базе данных с учетом конфигурации:

(require '[project.config :refer [CONFIG]])

(def server
  (jetty/run-jetty app {:server_port CONFIG}))

(jdbc/query (:db CONFIG) "select * from users")

Анализ результата

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

Преимущество загрузчика в том, что в любой момент можно считать конфигурацию заново. Это особенно удобно в сеансе REPL: достаточно изменить json-файл и вызвать load-config! повторно.

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

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

Наивный способ исправить ситуацию — вызывать load-config! внутри макроса, который переопределяет exit. Пусть новая функция не вызывает System/exit, а просто кидает исключение с тем же сообщением. Вынесем эту логику в отдельную функцию:

(defn load-config-repl!
  []
  (with-redefs
    [exit (fn [_ ^String msg]
            (throw (new Exception msg)))]
    (load-config!)))

Теперь вызов load-config-repl! на конфигурации с ошибками просто бросит исключение, но не завершит сеанс.

Более удачное решение в том, чтобы передать функцию в дополнительные параметры загрузки. В сторонних библиотеках встречается параметр :die-fn или аналогичный. Это функция, которая принимает экземпляр исключения. Тем самым вы управляете реакцией на ошибку в зависимости от контекста. В боевом запуске die function завершает программу, в режиме разработки просто пишет сообщение в REPL.

В свободное время доработайте загрузчик так, чтобы он поддерживал параметр :die-fn. Продумайте поведение по умолчанию, если он не задан.

Подробнее о переменных среды

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

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

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

Чтобы увидеть текущие переменные, выполните в терминале команду env или printenv. На экране появится список пар вида ИМЯ=значение. Имена переменных принято записывать в верхнем регистре, чтобы выделить на фоне других переменных и подчеркнуть их приоритет. Большинство систем различают регистр, поэтому foo и FOO будут разными переменными. Пробелы и дефисы в имени переменных недопустимы. Лексемы разделяют подчеркиванием.

Фрагмент вывода printenv:

LANG=en_US.UTF-8
PWD=/Users/ivan
SHELL=/bin/zsh
TERM_PROGRAM=iTerm.app
COMMAND_MODE=unix2003

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

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

Локальные и глобальные переменные

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

FOO=42

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

echo $FOO
42

Если выполнить printenv, мы не увидим FOO в списке переменных среды. Это потому, что выражение FOO=42 устанавливает переменную в текущий шелл, а не среду. Переменные шелла видны только ему и не наследуются потомками. Проверим это: из текущего шелла запустим новый и попытаемся напечатать переменную.

sh
echo $FOO

Результатом будет пустая строка, потому что в процессе-потомке такой переменной нет. Выполните exit, чтобы вернуться в родительский шелл.

Выражение export означает, что переменная становится частью среды шелла. Установленная таким образом, она видна в списке printenv и доступна процессам-потомкам:

export FOO=42

printenv | grep FOO
FOO=42

sh
echo $FOO
42

Иногда мы хотим запустить процесс с переменной, но так, чтобы она не была видна другим процессам. В таком случае команда export не подойдет, потому что об этой переменной узнают все дочерние процессы. Чтобы сообщить переменную только одному процессу, его запускают сразу после выражения ИМЯ=значение:

BAR=99 printenv | grep BAR
BAR=99

Вызов printenv порождает новый процесс, внутри которого доступна переменная BAR. Но если снова напечатать $BAR, мы получим пустую строку.

Переменные особенно удобны в связке с программами, которые читают из них настройки. Например, клиент базе данных PostgreSQL различает около двадцати переменных: PGHOST, PGDATABASE, PGUSER и многие другие. У переменных среды приоритет выше, чем у параметров --host, --user и аналогов. Это значит, если в текущем шелле выполнить команды

export PGHOST=some.staging.com PGDATABASE=project

То любая утилита из поставки PostgreSQL сработает на заданном сервере, базе и пользователе. Это удобно, если требуется выполнить серию команд, например дамп базы, набор запросов и восстановление. Не придется передавать каждой команде параметры --host и другие.

Переменные как конфигурация

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

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

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

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

Даже когда конфигурация составлена удачно, не всегда удобно править ее вручную. Современная индустрия постепенно уходит от файлов в сторону контейнеров и сервисов. Если раньше мы копировали файлы по FTP или SSH, то сегодня приложение запускается как множество экземпляров одного образа. Так работают системы виртуализации Kubernetes и другие.

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

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

Принцип “конфигурация в среде” встречается в одном из пунктов The Twelve-Factor App. Это набор хороших практик при разработке приложений, надежных и удобных в поддержке. Пункт №3 документа перечисляет достоинства переменных среды: независимость от файлов, конкретного языка или формата данных, поддержка на всех платформах.

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

Так, в проектах на Python часто встречается код:

db_port = int(os.environ["DB_PORT"])

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

env_mapping = {"DB_PORT": int}

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

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

;; so-so
{:db-name "book"
 :db-user "ivan"
 :db-pass "****"}

;; better
{:db {:name "book"
      :user "ivan"
      :pass "****"}}

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

"DB_NAME=book"
;; {:db_name "book"}

"DB__NAME=book"
;; {:db {:name "book"}}

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

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

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

Env-файлы

Когда переменных среды становится слишком много, их ручной ввод через export становится утомителен. Поэтому переменные выносят в файл, который по-другому называют env-конфигурацией.

С технической точки зрения такой файл представляет собой шелл-скрипт. Однако, чем меньше скриптовых возможностей использует файл, тем лучше. В идеале файл содержит только пары ИМЯ=значение по одной на каждую строку.

DB_NAME=book
DB_USER=ivan
DB_PASS=****

Чтобы считать эти данные в шелл, вызывают команду source <file>. Это одна из команд bash, которая выполняет указанный скрипт в текущем сеансе. Cкрипт добавит переменные в шелл, и вы увидите их после завершения source. Это важное отличие от команды bash <file>, которая выполнит скрипт в отдельном шелле.

source ENV
echo $DB_NAME
book

Но если запустить из текущего шелла приложение, оно не увидит эти переменные. Вспомним, что выражение VAR=value задает локальную переменную шелла, а не среды. Поэтому DB_NAME и другие не попадут в окружение шелла и не будут унаследованы приложением. Это легко проверить с помощью printenv:

source ENV
printenv | grep DB
# exit 1

Существует два способа решить эту проблему. Первый — открыть файл и расставить перед каждой парой выражение export. Тогда source этого файла вынесет переменные в окружение:

cat ENV
export DB_NAME=book
export DB_USER=ivan
export DB_PASS=****

source ENV
printenv | grep DB
DB_NAME=book
DB_USER=ivan
DB_PASS=****

Недостаток этого способа в том, что в env-файле появляется дополнительная логика. При редактировании файла легко потерять export перед переменной, и приложение не сможет ее считать.

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

set -a
source ENV
printenv | grep DB
# prints all the vars
set +a

Выражение set немного противоречиво: параметр с минусом включает параметр, а с плюсом — отключает. Это исключение, которое придется запомнить.

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

Пусть тестовые настройки отличаются от стандартных только именем базы. Файл ENV несет в себе базовые параметры, а в файл ENV_TEST поместим новое значение параметра:

cat ENV_TEST
DB_NAME=test

set -a
source ENV
source ENV_TEST
set +a

echo $DB_NAME
test

Читатель заметит, что идея env-файлов противоречива. Сначала мы говорили, что переменные среды снимают зависимость от файла конфигурации. Но закончили тем, что создали файл с переменными. Какой в этом смысл, если просто сменился формат записи?

Разница между json- и env-файлами в том, кто их читает. В первом случае конфигурацию читает приложение, а во втором — операционная система. Если json-файл располагается в строго заданной директории, то переменные среды могут быть прочитаны откуда угодно. Тем самым мы выносим из приложения часть, ответственную за поиск конфигурации, и оставляем запас для маневра.

Переменные среды в Clojure

Переходим к практической части. С учетом всего сказанного рассмотрим, как работать с env-переменными в Clojure.

Вспомним, что Clojure это гостевая платформа. Это значит, язык не предлагает возможностей для доступа к системным ресурсам. В библиотеке Clojure нет функции для чтении переменных среды. Мы получим их из класса java.lang.System. Это особый класс Java, который не требуется импортировать. Он доступен в любом пространстве имен.

Статический метод getenv этого класса возвращает либо значение переменной по ее имени, либо словарь всех переменных, если вызвать метод без параметров.

;; a single variable
(System/getenv "HOME")
"/Users/ivan"

;; the whole map
(System/getenv)
{"JAVA_ARCH" "x86_64", "LANG" "en_US.UTF-8"} ;; truncated

Уточним, что метод без параметров возвращает не Clojure-, а Java-коллекцию. Это неизменяемая версия java.util.Map. Поэтому переменные невозможно изменить после запуска виртуальной машины JVM.

Чтобы упростить работу Java-коллекцией, переведем ее в аналогичный тип Clojure. Заодно преобразуем ключи: сейчас это строки в верхнем регистре и подчеркиваниями. В мире Clojure пользуются кейвордами для словарей и записью, известной как kebab-case: нижний регистр с дефисами.

Напишем функцию для перевода ключа:

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

(defn remap-key
  [^String key]
  (-> key
      str/lower-case
      (str/replace #"_" "-")
      keyword))

и убедимся в ее работе:

(remap-key "DB_PORT")
:db-port

Функция remap-env проходит по Java-коллекции и возвращает ее Clojure-версию с привычными ключами:

(defn remap-env
  [env]
  (reduce
   (fn [acc [k v]]
     (let [key (remap-key k)]
       (assoc acc key v)))
   {}
   env))

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

(remap-env (System/getenv))

{:home "/Users/ivan"
 :lang "en_US.UTF-8"
 :term "xterm-256color"
 :java-arch "x86_64"
 :term-program "iTerm.app"
 :shell "/bin/zsh"}

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

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

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

Проблема лишних ключей

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

Лучший способ выбрать подмножество словаря это функция select-keys. Она принимает словарь и список ключей. Но как узнать, какие именно ключи нужно выбрать? Перечислять их вручную долго, и фактически мы дублируем данные. Центральный объект, который знает о том, какие поля проверять это спека ::config. С помощью небольшого трюка мы вытащим из нее список ключей.

Функция s/form принимает ключ спеки и возвращает ее замороженное определение. Это цепочка cons-ячеек, где каждый элемент это символ, кейворд, вектор и так далее. Для спеки ::config мы получим список:

(clojure.spec.alpha/keys
 :req-un [:book.config/server_port
          :book.config/db
          :book.config/event])

В нашем случае достаточно взять третий элемент формы, это и будет вектор ключей спеки. Но это наивное решение. У спеки s/keys может быть до четырех типов ключей, и мы должны учесть их все.

Отбросим первый элемент формы. В ее остатке нечетные элементы будут типом ключа, а четные — ключами. Для -un ключей мы должны отбросить их пространство. Все вместе дает нам следующую функцию:

(defn spec->keys
  [spec-keys]
  (let [form (s/form spec-keys)
        params (apply hash-map (rest form))
        {:keys [req opt req-un opt-un]} params
        ->unqualify (comp keyword name)]
    (concat
     req
     opt
     (map ->unqualify opt-un)
     (map ->unqualify req-un))))

Проверим спеку загрузчика:

(spec->keys ::config)
(:server_port :db :event)

Перепишем чтение переменных в словарь:

(defn read-env-vars
  []
  (let [cfg-keys (spec->keys ::config)]
    (-> (System/getenv)
        remap-env
        (select-keys cfg-keys))))

Так вы получите только полезные ключи, то есть те, что описаны в спеке.

Загрузчик среды

Внесем изменения в загрузчик конфигурации так, чтобы он работал с переменными среды. Достаточно заменить первые два шага на read-env-vars:

(defn load-config!
  []
  (-> (read-env-vars)
      (coerce-config)
      (set-config!)))

В свободное время доработайте загрузчик так, чтобы источник данных можно было задать параметром. Например, :source "/path/to/config.json" означает считать файл, а :source :env — переменные среды.

Еще сложнее: как считать оба источника и объединить их? Как сделать объединение ассиметричным, то есть когда левый словарь замещает существующие поля правого, но не дополняет новыми?

Вывод структуры

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

Улучшим загрузчик: реализуем вложенные словари для переменных окружения. Договоримся, что двойное подчеркивание означает вложенную структуру. Поместим в файл ENV_NEST следующие переменные:

DB__NAME=book
DB__USER=ivan
DB__PASS=****
HTTP__PORT=8080
HTTP__HOST=api.random.com

Прочитаем его и запустим REPL с новой средой:

set -a
source ENV_NEST
lein repl

Достаточно изменить функции преобразования ключа и обхода окружения. Функция remap-key-nest принимает строковый ключ и возвращает вектор составных частей:

(def ->keywords (partial map keyword))

(defn remap-key-nest
  [^String key]
  (-> key
      str/lower-case
      (str/replace #"_" "-")
      (str/split #"--")
      ->keywords))

(remap-key-nest "DB__PORT")
;; (:db :port)

Новая функция обхода отличается тем, что делает не assoc, а assoc-in, что порождает вложенные словари:

(defn remap-env-nest
  [env]
  (reduce
   (fn [acc [k v]]
     (let [key-path (remap-key-nest k)]
       (assoc-in acc key-path v)))
   {}
   env))

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

(-> (System/getenv)
    remap-env-nest
    (select-keys [:db :http]))

{:db {:user "ivan", :pass "****", :name "book"},
 :http {:port "8080", :host "api.random.com"}}

Дальше действуем как обычно: пишем спеку, выводим типы из строк и так далее.

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

Простой менеджер конфигурации

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

Рассмотрим несколько способов как подружить файлы и окружение. Наивное решение не потребует писать код: оно работает на утилитах командной строки. Утилита envsubst из пакета GNU gettext реализует простую шаблонную систему. Чтобы установить gettext, выполните в терминале команду

<manager> install gettext

, где <manager> это менеджер пакетов вашей системы, например brew, apt, yum и другие.

Текст шаблона приходит из stdin, а роль контекста играют переменные среды. Утилита заменяет выражения $VAR_NAME на значения одноименной переменной. Напишем шаблон будущей конфигурации:

cat config.tpl.json
{
    "server_port": $HTTP_PORT,
    "db": {
        "dbtype":   "mysql",
        "dbname":   "$DB_NAME",
        "user":     "$DB_USER",
        "password": "$DB_PASS"
    },
    "event": [
        "$EVENT_START",
        "$EVENT_END"
    ]
}

Поместим переменные среды в отдельный файл:

cat ENV_TPL

DB_NAME=book
DB_USER=ivan
DB_PASS='*(&fd}A53z#$!'
HTTP_PORT=8080
EVENT_START='2019-07-05T12:00:00'
EVENT_END='2019-07-12T23:59:59'

Считаем переменные в память и “отрендерим” шаблон:

source ENV_TPL
cat config.tpl.json | envsubst
{
    "server_port": 8080,
    "db": {
        "dbtype":   "mysql",
        "dbname":   "book",
        "user":     "ivan",
        "password": "*(&fd}A53z#$!"
    },
    "event": [
        "2019-07-05T12:00:00",
        "2019-07-12T23:59:59"
    ]
}

Чтобы записать результат в файл, добавьте в конец выражения оператор вывода:

cat config.tpl.json | envsubst > config.ready.json

Способ с envsubst кажется примитивным на первый взгляд, но неожиданно оказывается полезным на практике. Принцип шаблона снимает вопрос структуры данных. Мы храним переменные как плоский словарь и подставляем их в нужное место. Не приходится думать о правилах вложенности, массивах и так далее.

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

Так envsubst становится простым менеджером конфигураций. Чтобы автоматизировать процесс, добавьте простой шелл-скрипт или Make-файл. Решение не дотягивает до промышленного уровня, но подходит для несложных проектов.

Чтение среды из конфигурации

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

Предположим, основные параметры указаны в файле, а пароль базы данных приходит из среды. Договоримся с членами команды, что поле :password в файле содержит не настоящий пароль, а имя переменной, например "DB_PASS".

Напишем спеку, которая выводит значение переменной среды по ее имени:

(s/def ::->env
  (s/conformer
   (fn [varname]
     (or (System/getenv varname)
         ::s/invalid))))

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

(s/def ::db-password
  (s/and ::->env
         string?
         (s/conformer str/trim)
         not-empty))

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

(s/conform ::db-password "DB_PASS")
"*(&fd}A53z#$!"

Теперь чтобы вынести любое поле из файла в переменную, замените его значение на имя переменной. Обновите спеку этого поля: добавьте ::->env в начало цепочки s/and.

Другой способ прочитать переменные из файла — расширить его синтаксис тегами. Теги это короткие символы, которые указывают, что значение за тегом следует прочитать особым образом. Из известных форматов теги поддерживают yaml и edn. Библиотеки для работы с ними предлагают уже готовые теги. В основном это вывод нативных типов платформы из примитивных значений.

Пользователь может задать теги с собственной логикой. Рассмотрим пример с EDN. Тег начинается со знака решетки и захватывает следующее за ним значение. Например, #inst "2019-07-10" означает вывод даты из строки.

Технически тег связан с функцией одного аргумента, которая вычисляет новое значение на базе исходного. Чтобы задать свой тег, в функцию clojure.edn/read-string передают дополнительный словарь тегов. Ключи этого словаря символы тегов, а значения функции.

Добавим тег #env, который вернет значение переменной по ее имени. Имя переменной может быть строкой или символом. Определим функцию:

(defn tag-env
  [varname]
  (cond
    (symbol? varname)
    (System/getenv (name varname))
    (string? varname)
    (System/getenv varname)
    :else
    (throw (new Exception "wrong var type"))))

Прочитаем edn-строку с новым тегом:

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

(edn/read-string
 {:readers {'env tag-env}}
 "{:db-password #env DB_PASS}")

;; {:db-password "*(&fd}A53z#$!"}

У вас могут быть и другие теги для вывода специфичных Java-объектов. Чтобы не передавать словарь тегов каждый раз, объявим partial от edn/read-string. Получим функцию, которая принимает только текст:

(def read-config
  (partial edn/read-string
           {:readers {'env tag-env}}))

Чтобы прочитать edn-конфигурацию с особыми тегами, достаточно считать его содержимое в строку и передать в read-config:

(-> "/path/to/config.edn"
    slurp
    read-config)

Формат yaml устроен похожим образом: его стандарт предусматривает теги. Тег начинается с одного или двух восклицательных знаков в зависимости от семантики. Согласно документации, сторонние теги должны начинаться с !!, чтобы их можно было отделить от стандартных, но этому соглашению не всегда следуют.

Библиотека yummy это парсер yaml-файлов, “заряженный” полезными тегами. Среди прочих он предлагает !envvar. Тег возвращает значение переменной среды по ее имени.

Опишем конфигурацию в файле config.yaml:

server_port: 8080
db:
  dbtype:   mysql
  dbname:   book
  user:     !envvar DB_USER
  password: !envvar DB_PASS

Импортируем библиотеку и прочитаем конфигурацию. На месте тегов получим значения переменных:

(require '[yummy.config :as yummy])
(yummy/load-config {:path "config.yaml"})

{:server_port 8080
 :db {:dbtype "mysql"
      :dbname "book"
      :user "ivan"
      :password "*(&fd}A53z#$!"}}

У библиотеки yummy есть и другие преимущества, о которых мы поговорим в следующем разделе.

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

С другой стороны, теги привязывают конфигурацию к одной платформе и библиотеке. Например, если в вашем .yaml-файле встречается тег !envvar, то библиотека на Python не сможет его прочитать, потому что в ней нет этого тега. С технической стороны это можно исправить: игнорировать незнакомые теги или установить заглушку. Но такой подход и не гарантирует одинаковый результат на разных платформах.

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

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

Короткий обзор форматов

К этому моменту мы упомянули три формата для хранения конфигурации. Это JSON, EDN и YAML. Перечислим особенности каждого из них. Мы ставим цель не выявить идеальный формат, а подготовить читателя к неочевидным моментам, которые возникнут в работе с этими файлами.

JSON

Формат JSON наверняка известен каждому разработчику. Это способ записать данные в том виде, как это принято в JavaScript. Стандарт определяет числа, строки, логический тип, null и два вида коллекций: массив и словарь. Коллекции могут быть произвольно вложены друг в друга.

Ключевое преимущество JSON в его популярности. Он стал де-факто стандартом для обмена данными между клиентом и сервером. По сравнению с XML его легче читать и поддерживать. JSON поддерживают все современные редакторы, языки и платформы. Это нативный способ хранить данные в мире JavaScript.

К сожалению, формат не предусматривает комментарии. На первый взгляд это кажется мелочью. На практике комментарии важны, особенно если в фирме принято разделение труда. Если разработчик добавил новый параметр, хорошим тоном будет написать комментарий о том, что он делает и какие значения принимает. Ради интереса посмотрите файлы конфигурации Redis, PostgreSQL или Nginx — на 90 процентов они состоят из комментариев.

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

{
    "server_port": "A port which the web-server is bound to.",
    "server_port": 8080
}

Расчет сделан на то, что библиотеки не проверяют ключ на вхождение в словарь, и второй server_port заменит первый. Заметим, что стандарт JSON не регулирует такой сценарий; он остается на откуп разработчиков. Логика может быть иной, например бросить исключение или не заменять ключ, который уже в словаре.

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

На момент появления JSON выгодно отличался от многословного XML. JSON-данные выглядят чище и удобнее для человека, чем дерево XML-тегов. Но современные форматы выражают данные еще чище. Например, в YAML любую структуру можно записать без единой скобки, пользуясь только отступами.

Синтаксис JSON “шумит”: он требует кавычек, двоеточий и запятых в случаях, где другие форматы лояльны. Например, запятая на конце последнего элемента массива или объекта считается ошибкой. Ключи не могут быть числами или токенами.

Сравните одну и ту же структуру данных в YAML и JSON:

                              {
server_port: 8080                  "server_port": 8080,
db:                                "db": {
  dbtype:   mysql                      "dbtype":   "mysql",
  dbname:   book                       "dbname":   "book",
  user:     user                       "user":     "ivan",
  password: '****'                     "password": "****"
event:                             },
  - 2019-07-05T12:00:00            "event": [
  - 2019-07-12T23:59:59                "2019-07-05T12:00:00",
                                       "2019-07-12T23:59:59"
                                   ]
                               }

Стандарт JSON не поддерживает теги, о которых мы говорили выше.

YAML

Язык разметки YAML, как и JSON, различает базовые типы: скаляры, null и коллекции. YAML делает упор на краткости записи. Это выражается в том, что вложенность структур определяют отступы, а не скобки. Запятые не обязательны там, где они выводятся логически. Например, массив чисел в одну строку выглядит как в JSON:

numbers: [1, 2, 3]

Но в случае с многострочной записью нужда в скобках и запятых отпадает:

numbers:
  - 1
  - 2
  - 3

YAML поддерживает комментарии в стиле Python. Благодаря этому формат популярен у DevOps-инженеров. Программы docker-compose и Kubernetes работают на yaml-файлах.

В отличии от JSON, YAML предлагает синтаксис для многострочного текста. Такой текст проще читать и копировать, чем одну строку с вкраплениями \n.

description: |
  To resolve the error, please do the following:

  - Press Control + Alt + Delete;
  - Turn off your computer;
  - Walk for a while.

  Then try again.

Язык официально поддерживает теги.

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

Иногда отказ от кавычек приводит к неверным типам или структуре. Предположим, в поле phrases перечислены фразы, которые увидит пользователь:

phrases:
  - Welcome aboard!
  - See you soon!
  - Warning: wrong email address.

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

{:phrases
 ["Welcome aboard!"
  "See you soon!"
  {:Warning "wrong email address."}]}

Другие примеры: версия продукта 3.3 это число, но 3.3.1 — строка. Телефон +79625241745 это число, потому что знак плюса считается унарным оператором по аналогии с минусом. Лидирующие нули означают восьмеричную запись: если не добавить кавычки к идентификатору 000042, вы получите число 34.

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

EDN

Формат EDN занимает особое место в этом обзоре. Он предназначен для хранения данных Clojure и поэтому играет такую же роль в экосистеме языка, как JSON в мире JavaScript. Это естественный, родной способ связать данные с файлом в Clojure.

Синтаксис EDN почти полностью соответствует компилятору Clojure. Поэтому формат охватывает больше типов, чем JSON и YAML. Например, из скалярных типов доступны символы и кейворды (типы clojure.lang.Symbol и Keyword). Кроме вектора и словаря формат поддерживает списки и множества.

Теги начинаются с символа решетки. По умолчанию стандарт предлагает два тега: #inst и #uuid. Первый читает строку в дату, а второй в экземпляр UUID. Такие идентификаторы используют в распределенных системах, например БД Cassandra. Выше мы показывали, как зарегистрировать свой тег. Достаточно связать его с функцией одного аргумента при чтении EDN-строки.

Собирательный пример с разными коллекциями и тегами:

{:task-state #{:pending :in-progress :done}
 :account-ids [1001 1002 1003]
 :server {:host "127.0.0.1" :port 8080}
 :date-range [#inst "2019-07-01" #inst "2019-07-31"]
 :cassandra-id #uuid "26577362-902e-49e3-83fb-9106be7f60e1"}

EDN-данные не отличаются от записи в .clj-файлах. Если скопировать содержимое файла в REPL или модуль Clojure, компилятор просто выполнит их. Правило работает и наоборот: вывод REPL можно скопировать в файл для дальнейшей работы.

Функция pr-str возвращает текстовую версию данных. Поэтому сброс в EDN сводится к простым шагам: “напечатать” данные в строку и записать ее в файл. Пример ниже записывает в файл dataset.edn результат функции get-huge-dataset:

(-> (get-huge-dataset)
    pr-str
    (as-> dump
        (spit "dataset.edn" dump)))

EDN поддерживает не только обычные комментарии. Комбинация #_ игнорирует следующий за ним элемент. Под элементом понимается что угодно: скаляр, коллекция. Например, если нужно проигнорировать словарь, который занимает несколько строк, достаточно поставить перед ним #_, и парсер пропустит его.

Это позволяет “отключить” целые части конфигурации. В следующем примере мы игнорируем пользователя Ioann. Если поставить обычный комментарий, он заденет закрывающие скобки, и выражение не будет окончено.

{:users [{:id 1 :name "Ivan"}
         {:id 2 :name "Juan"}
         #_{:id 3 :name "Ioann"}]}

EDN становится особо удачным выбором, когда и бекенд, и фронтенд приложения построены на одном стеке Clojure + ClojureScript.

Отметим, что EDN привязан к экосистеме Clojure и потому не известен разработчикам на других языках. Современные редакторы, как правило, не подсвечивают его синтаксис без сторонних плагинов. Это может доставить проблем вашим DevOps-коллегами, которые работали только с JSON и YAML.

Если конфигурацию обрабатывают сторонние Python- или Ruby-скрипты, придется ставить библиотеку для работы с этим форматом. Поэтому выбор в пользу EDN делают фирмы, где Clojure преобладает над другими технологиями.

Промышленные решения

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

Мы остановили внимание на проектах cprop, aero и yummy. Библиотеки отличаются в идеологии и архитектуре. Мы специально подобрали их так, чтобы увидеть проблему с разных сторон.

Cprop

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

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

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

На верхнем уровне доступна функция load-config. Вызванная без параметров, функция запускает стандартный загрузчик. По умолчанию cprop ищет два источника данных: Java-ресурс и property-файл.

Под ресурсом понимают особый файл, который становится частью Java-проекта. На этапе разработки файлы ресурсов хранят в директории resources. Cprop ожидает ресурс с именем config.edn. Это первичный источник данных.

Если системное свойство conf не пустое, библиотека полагает, что это путь к property-файлу и загружает из него словарь. Системные свойства это особая группа переменных в рамках Java-машины. Можно сказать, что свойства это аналог среды окружения для JVM.

При загрузке JVM получает набор свойств по умолчанию, например, тип и версию операционной системы, параметры файловой системы, опции JVM и другие. Дополнительные свойства передают параметром -D при запуске java-программы. Пример ниже запускает скомпилированный jar-файл со свойством conf:

java -Dconf="/path/to/config.properties" -jar project.jar

Файлы .properties это пары поле=значение по одной на строку. Поля носят доменную структуру: это лексемы, разделенные точкой. Лексемы убывают по старшинству:

db.type=mysql
db.host=127.0.0.1
db.pool.connections=8

Библиотека расценивает точки как вложенные словари. Загрузка такого файла вернет структуру:

{:db {:type "mysql"
      :host "127.0.0.1"
      :pool {:connections 8}}}

Получив конфигурацию из ресурса или property-файла, cprop ищет переопределения в переменных среды. Для них работают те же правила, что и в нашем загрузчике. Например, если задана переменная DB__POOL__CONNECTIONS=16, она заменит значение 8 в примере выше. При этом cprop игнорирует переменные, которые не входят в ключи конфигурации, и тем самым не загрязняет ее.

Стандартные пути поиска можно задать ключами:

(load-config
 :resource "private/config.edn"
 :file "/path/custom/config.edn")

Для работы с ресурсами cprop предлагает функции из модуля cprop.source. Например, from-env Вернет словарь всех переменных среды, from-props-file загрузит properties-файл и так далее. Разработчик вправе построить их них такую комбинацию, которая нужна проекту.

Ключ :merge объединяет конфигурацию с произвольными источниками. Убер-пример из документации:

(load-config
 :resource "path/within/classpath/to.edn"
 :file "/path/to/some.edn"
 :merge [{:datomic {:url "foo.bar"}}
         (from-file "/path/to/another.edn")
         (from-resource "path/within/classpath/to-another.edn")
         (from-props-file "/path/to/some.properties")
         (from-system-props)
         (from-env)])

В вектор :merge можно добавить любое выражение, которое вернет словарь. Но в общем случае (load-config) без параметров будет достаточным решением.

Чтобы отследить загрузку такой сложной конфигурации, установите переменную среды DEBUG:

export DEBUG=y

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

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

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

Aero

Проект aero предлагает другой подход. В отличии от cprop, библиотека работает только с одним источником данных: файлами *.edn. Aero несет на борту теги, с которыми EDN становится похож на мини-язык программирования. В нем появляются базовые операторы ветвления, импорта, форматирования. По-другому это можно назвать “EDN на стероидах”.

Функция read-config читает файл или ресурс EDN:

(require '[aero.core :refer (read-config)])

(read-config "config.edn")
(read-config (clojure.java.io/resource "config.edn"))

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

{:db {:passwod #env DB_PASS}}

Тег #envf форматирует строку переменными среды. Например, параметры базы данных заданы отдельными полями, но вы предпочитаете JDBC URI – длинную строку, похожую на веб-адрес. Чтобы не дублировать данные, адрес вычисляют на базе исходных полей:

{:db-uri #envf ["jdbc:postgresql://%s/%s?user=%s"
                DB_HOST DB_NAME DB_USER]}

Тег #or похож на одноименный оператор Clojure и в основном нужен для значений по умолчанию. Так, в нашем env-файле не задан порт базы данных. Чтобы избежать неочевидности, укажем стандартный порт:

{:db {:port #or [#env DB_PORT 5432]}}

Оператор #profile предлагает интересный способ взять значение в зависимости от профиля. Значение за тегом обязательно словарь. Ключи словаря это профили, а значения – то, что получим в результате его разрешения. Профиль задают в параметрах read-config.

Пример ниже показывает, как определить имя базы данных по профилю. Без профиля мы получим "book", но для :test имя станет "book_TEST":

{:db {:name #profile {:default "book"
                      :dev     "book_DEV"
                      :test    "book_TEST"}}}

(read-config "aero.test.edn" {:profile :test})
{:db {:name "book_TEST"}}

Тег #include внедряет в конфигурацию содержимое другого edn-файла. При этом дочерний файл тоже содержит aero-теги, и библиотека выполняет их рекурсивно. К импорту прибегают, когда конфигурация отдельных компонентов становится большой.

{:queue #include "message-queue.edn"}

Тег #ref описывает ссылку на любое место конфигурации. Это вектор ключей, который обычно передают в get-in. Ссылка позволяет избежать дублирования данных. Например, сторонний компонент нуждается в пользователе, под которым мы подключаемся к базе данных:

;; config.edn
{:db {:user #env DB_USER}
 :worker {:user #ref [:db :user]}}

;; result
{:db {:user "ivan"}, :worker {:user "ivan"}}

Эти и другие теги можно комбинировать различными способами и добавлять собственные.

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

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

Негибкие JSON- и properties-файлы обладают важным свойством: они декларативны. Когда вы открыли файл в редакторе или “катнули” его в консоль, то сразу видите данные. Отдельные их части могут дублироваться, синтаксис не настолько удобен для чтения. Но данные выражаются сами в себя, и ошибки быть не может. Вы просто видите их.

Наоборот, файл с нестандартными тегами труден в поддержке. Это не конфигурация, а код. Файл становится трудно читать, его нужно выполнить. При чтении файла у вас в голове запускается мини-интерпретатор, который не гарантирует точный результат.

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

Yummy

Библиотека yummy замыкает наш обзор. Yummy отличается от аналогов двумя свойствами. Во-первых, она работает с файлами YAML для чтения конфигурации (отсюда и название). Во-вторых, процесс ее загрузки максимально похож на тот, что мы рассмотрели в начале главы.

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

Библиотека читает YAML-разметку из файла. Путь к файлу либо передан в параметрах, либо библиотека ищет его по определенному имени в переменных среды или свойствах JVM.

Вариант, когда путь задан явно:

(require '[yummy.config :refer [load-config]])

(load-config {:path "config.yaml"})

Во втором примере вместо пути задали имя программы. Yummy ищет путь к файлу в переменной среды <name>_CONFIGURATION или свойстве <name>.configuration:

export BOOK_CONFIGURATION=config.yaml
(load-config {:program-name :book})

Библиотека расширяет YAML несколькими тегами. Это знакомый вам !envvar для переменных среды:

db:
  password: !envvar DB_PASS

Тег !keyword полезен в случаях, когда вместо строки ожидают кейворд:

states:
  - !keyword task/pending
  - !keyword task/in-progress
  - !keyword task/done

Результат:

{:states [:task/pending :task/in-progress :task/done]}

Тег !uuid аналогичен #uuid для EDN. Он возвращает объект java.util.UUID из строки:

system-user: !uuid cb7aa305-997c-4d53-a61a-38e0d8628dbb

Тег !slurp читает текст из стороннего файла. Это полезно для сертификатов шифрования. Их содержимое это длинная строка, которая плохо укладываются в общую конфигурацию:

tls:
  auth: !slurp "certs/ca.pem"
  cert: !slurp "certs/cert.pem"
  pkey: !slurp "certs/key.pk8"

Если в директории certs оказались все нужные сертификаты, в ключах :auth, :cert и :pkey будет содержимое этих файлов.

Чтобы проверить данные, в параметры load-config передают ключ спеки. Когда ключ указан, yummy выполняет s/assert для параметров из yaml-файла. Если данные не прошли проверку, всплывает исключение. Yummy использует библиотеку expound, чтобы улучшить отчет спеки об ошибке.

(load-config {:program-name :book
              :spec ::config})

Словарь опцией yummy принимает параметр :die-fn. Это функция, которая будет вызвана, если любая из стадий завершится с ошибкой. Функция принимает два аргумента: объект исключения и текстовую метку, которая подсказывает, на какой стадии произошла ошибка.

Если :die-fn не задан, yummy вызывает обработчик по умолчанию. Обработчик выводит текст в stderr и завершает программу со кодом 1. Вспомним, что это неудобно на этапе разработки: мы не хотим обрывать REPL из-за ошибки в конфигурации. В интерактивном сеансе die-fn подавляет исключение и только выводит текст:

(load-config
 {:program-name :book
  :spec ::config
  :die-fn (fn [e msg]
            (binding [*out* *err*]
              (println msg)
              (println (ex-message e))))})

Но в боевом режиме мы запишем исключение в лог и завершим программу.

(load-config
 {:program-name :book
  :spec ::config
  :die-fn (fn [e msg]
            (log/errorf e "Config error")
            (System/exit 1))})

Из недостатков yummy отметим, что для работы со спекой используется s/assert. Функция не выводит новые значения, как это делает s/conform, а только выбрасывает исключение, если проверка не прошла. Поэтому эффект conform-спек не окажет действия на данные, которые вы получите.

С другой стороны, это сделано нарочно. Библиотеку писали так, что вывод типов срабатывает на этапе тегов, а спека только проверяет данные. С таким подходом все преобразования видны на уровне yaml-файла.

Заключение

Перечислим основные тезисы из разделов этой главы.

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

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

Источником конфигурации может быть файл, Java-ресурс, переменные окружения. Допустимы гибридные схемы, когда основные данные приходят из файла, а секретные поля из окружения.

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

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

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

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

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

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

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.