About Boot, Clojure’s other development environment

Web Technology   Digital Transformation Technology Artificial Intelligence Technology   Natural Language Processing Technology   Semantic Web Technology   Deep Learning Technology   Online Learning & Reinforcement Learning Technology  Chatbot and Q&A Technology   User Interface Technology   Knowledge Information Processing Technology   Reasoning Technology  Clojure Programming

In this article, we will discuss Boot, another development environment that has been used frequently in recent years along with Clojure’s Leiningen.

What is Boot?

Boot is a Clojure build framework and ad-hoc Clojure script evaluator that provides a runtime environment that includes all the tools necessary to build a Clojure project from scripts written in Clojure that run in the context of a project. It provides a runtime environment that includes all the tools needed to build a project.

The process of building an application is always complex. For example, even a simple web application may require a lot of integration, including asset pipelines, deployment to different environments, compilation of multiple artifacts by different compilers, and packaging.

The more complex the build process, the more flexible the build tool needs to be. Static build specifications become less useful as the project nears completion, and as Lisper, we know what to do: Lambda is the ultimate declarative type.

Instead of building a project based on a global configuration map, boot provides an execution environment where build scripts written in Clojure are evaluated. This script, or Turing-complete build specification, builds the project.”

Leiningen is also an excellent development tool, but its configuration, especially for complex applications such as web page system development, can be complicated and make Leningen’s project files very verbose, for example.

In contrast, Boot creates a file called “build.boot” in which libraries and tasks to be used are written, similar to Ruby’s Gemfile or NodeJS’s package.json.

Boot is installed using various package managers.

  • Hombrew. “brew install boot-clj”
  • nix. “nix-env -i boot”
  • aur.  “yaourt –noconfigrm -Syy boot”
  • Installing on docker clojure image with boot tag

You can check whether Boot has been installed or not with “boot -V”.

task

One of the features of boot that is not present in leiningen will be the task management feature. Task functions can be defined in the same way as make, rake, grunt, and other build tools, as named operations that take command line options that are dispatched by some intermediate program (make, rake, Boot).

Boot provides a dispatch program and Clojure library that allows you to easily define named operations and their command line options using the deftask macro. Here is a transcription of the print task from the “My pants are on fire” tutorial on braveclojure.com.

The first task is to

  1. Install Boot (see above)
  2. Create a new directory named “boot-walkthrough
  3. Go to that directory and create a file named “build.boot” and write the following
(deftask fire
  "Prints 'My pants are on fire!'"
  []
  (println "My pants are on fire!"))

Here, typing “boot fire” on the command line will output “Prints ‘My pants are on fire!

This example does not require a project.clj like leiningen, a directory structure, or namespaces.

Next, we will extend this example to demonstrate how to write command line options.

(deftask fire
  "Announces that something is on fire"
  [t thing   THING str  "The thing that's on fire"
   p pluralize  bool "Whether to pluralize"]
  (let [verb (if pluralize "are" "is")]
    (println "My" thing verb "on fire!")))

Enter the following here

boot fire -t heart
# => My heart is on fire!

boot fire -t logs -p
# => My logs are on fire!

This improvement to the Fire task introduces two command line options: thing and pluralize. Both options are defined using a domain-specific language (DSL), which is a topic in its own right, but simply put, a mini-language that can be used to write compact and expressive code for a narrowly defined area (like defining options) within a larger program. It refers to a mini-language that can be used to write

In the option thing, t specifies its short name, and thing specifies its long name; str specifies the type of the option, and Boot uses it to validate and convert the arguments.” The thing that’s on fire” becomes the documentation of the option. The task documentation can be viewed in the terminal by doing “boot fire(task name) -h”.

Options:
  -h, --help         Print this help info.
  -t, --thing THING  THING sets the thing that's on fire.
  -p, --pluralize    Whether to pluralize

Here, THING is optarg, indicating that this option requires an argument. It is not necessary to include optarg when defining the option (note that the pluralize option has no optarg). optarg need not match the full name of the option. If you replace THING with BILLY_JOEL or something similar, the task will work the same way. You can also use opttarg to specify complex options. (See https://github.com/boot-clj/boot/wiki/Task-Options-DSL#complex-options for Boot’s documentation on this subject). Basically, complex options allow you to specify that option arguments be treated as maps, sets, vectors, and even nested collections, which can be quite powerful.

Boot provides all the tools necessary to build command line interfaces in Clojure.

REPL

Boot comes with many useful built-in tasks, including the REPL task. boot repl will launch the REPL. boot REPL will be similar to Leiningen, which reads the project code and allows you to evaluate it. You may think that this does not apply to the projects you have written so far, since you have only written tasks, but in fact you can run tasks in the REPL. The options can be specified as strings.

boot.user=> (fire "-t" "NBA JAM guy")
My NBA JAM guy is on fire!
nil

It should be noted that the value of an option comes immediately after the option. You can also use keywords to specify options.

boot.user=> (fire :thing "NBA Jam guy")
My NBA Jam guy is on fire!
nil

It will also be possible to combine options.

boot.user=> (fire "-p" "-t" "NBA Jam guys")
My NBA Jam guys are on fire!
nil
boot.user=> (fire :pluralize true :thing "NBA Jam guys")
My NBA Jam guys are on fire!
nil
Composition and Coordination

With all this functionality, Boot would be a rather complex and bloated tool. Where it differs from other tools is in the way it generates tasks. As an example, here is an invocation of Rake (Rake would be a Ruby build tool)

rake db:create d{:tag :a, :attrs {:href "db:seed"}, :content ["b:migra"]}te db:seed

When executed in a Rails project, this code creates a database, performs migrations on it, and populates it with seed data. It is worth noting, however, that Rake does not provide a way for these tasks to communicate with each other. Specifying multiple tasks is only a convenience to avoid the hassle of running rake db:create; rake db:migrate; rake db:seed. If you want to access the results of Task A within Task B, the build tool will not help you. It usually does this by moving the results of Task A to a special location on the filesystem and allowing Task B to read that special location. This looks like programming with mutable or global variables and is just as fragile.

Handlers and Middleware

Boot addresses this task communication problem by treating tasks as middleware factories. Middleware becomes a set of conventions that programmers adhere to in order to create a flexible domain-specific function pipeline.

To understand how the middleware approach differs from mundane function synthesis, an example of synthesizing an everyday function is given.

(def strinc (comp str inc))
(strinc 3)
; => "4"

The structure of the function would be very simple: there would be two functions, each performing its own processing, that are synthesized into one.

Middleware introduces a new step in the function synthesis and allows the function pipeline to be defined more flexibly. In the previous example, suppose we want to return “I don’t like the number X” for any number, and a string representation of the number for anything else. The method is as follows.

(defn whiney-str
  [rejects]
  {:pre [(set? rejects)]}
  (fn [x]
    (if (rejects x)
      (str "I don't like " x)
      (str x))))

(def whiney-strinc (comp (whiney-str #{2}) inc))
(whiney-strinc 1)
; => "I don't like 2"

Now, let’s take it one step further and consider what to do if you want to decide whether or not to call inc in the first place, as follows

(defn whiney-middleware
  [next-handler rejects]
  {:pre [(set? rejects)]}
  (fn [x]
➊     (if (= x 1)
        "I'm not going to bother doing anything to that"
        (let [y (next-handler x)]
          (if (rejects y)
            (str "I don't like " y)
            (str y))))))

(def whiney-strinc (whiney-middleware inc #{2}))
(whiney-strinc 1)
; => "I'm not going to bother doing anything to that"

Here, instead of using comp to create a pipeline of functions, the next function in the pipeline is passed as the first argument to the middleware function. In this case, we pass inc as the first argument to whiney-middleware as the next-handler. whiney-middleware closes inc and returns an anonymous function with the ability to choose whether to call it. This choice can be seen in ➊.

The middleware takes a handler as its first argument and returns the handler. In this example, whiney-middleware takes a handler as its first argument, inc, and returns another handler, an anonymous function with x as its only argument. The middleware can also take extra arguments, such as rejects, that serve as configuration. As a result, the handler returned by the middleware can behave more flexibly (thanks to the configuration), allowing more control over the function pipeline (since it can choose whether to call the next handler).

Tasks are middleware factory

Boot takes this pattern one step further by separating the configuration of the middleware from the creation of the handler, making function configuration more flexible. First, create a function that takes n configuration arguments. This is the middleware factory, which returns a middleware function. The middleware function takes only one argument, the next handler, and returns a handler, as in the previous example. Here is an example of a middleware factory as follows.

(defn whiney-middleware-factory
  [rejects]
  {:pre [(set? rejects)]}
  (fn [handler]
    (fn [x]
      (if (= x 1)
        "I'm not going to bother doing anything to that"
        (let [y (handler x)]
          (if (rejects y)
            (str "I don't like " y " :'(")
            (str y)))))))

(def whiney-strinc ((whiney-middleware-factory #{3}) inc))

As you can see, this code is almost identical to the previous example. The only change is that the top function, whiney-middleware-factory, now takes only one argument, rejects. This returns the middleware, which is an anonymous function. The middleware expects one argument, a handler. The rest of the code will be the same.

In Boot, a task can act as a middleware factory. To demonstrate this, let’s split the fire task into two tasks, what and fire (see code below): in what, we can specify an object and whether it is plural, and in fire, we announce that it is burning. This is an example of modular software engineering. Because we can add other tasks, such as gnomes, to announce that something is full of gnomes, and this is equally useful objectively. (As an exercise, try creating a gnome task; it should be composited with the what task, just as FIRE is.)

(deftask what
  "Specify a thing"
  [t thing     THING str  "An object"
   p pluralize       bool "Whether to pluralize"]
  (fn middleware [next-handler]
➊     (fn handler [fileset]
      (next-handler (merge fileset {:thing thing :pluralize pluralize})))))

(deftask fire
  "Announce a thing is on fire"
  []
  (fn middleware [next-handler]
➋     (fn handler [fileset]
      (let [verb (if (:pluralize fileset) "are" "is")]
        (println "My" (:thing fileset) verb "on fire!")
        fileset))))

If this is done with Repl, it would look like this

boot.user=> (boot (what :thing "pants" :pluralize true) (fire))
My pants are on fire!
nil
file set

Earlier, we mentioned that the middleware is designed to create domain-specific function pipelines. That is, each handler expects to receive domain-specific data and return domain-specific data. For example, in the case of Ring, each handler expects to receive a request map representing an HTTP request, which would look something like this

{:server-port 80
 :request-method :get
 :scheme :http}

Each handler can choose to modify this request map in some way before passing it to the next handler by adding a :params key with a nice Clojure map of all query strings and POST parameters. The ring handler returns a response map consisting of the keys :status:headers, and :body, and each handler can transform this data in some way before returning it to the parent handler.

At boot, each handler receives and returns a file set. This is a major innovation for build tools because the fileset abstraction allows files on the file system to be treated as immutable data, and building a project is file-centric. For example, a project may need to place temporary intermediate files on the file system. Typically, in most build tools, these files are placed in a specially named location, e.g., project/target/tmp. The problem with this would be that project/target/tmp is effectively a global variable and other tasks could accidentally undo it.

Boot’s file set abstraction solves this problem by adding an indirect layer on top of the file system. Suppose task A creates file X and instructs it to store it in a file set. Behind the scenes, the fileset stores the file in an anonymous temporary directory. The file set is then passed to Task B. Task B modifies file X and asks the file set to store the result. Behind the scenes, a new file, File Y, is created and saved, but File X remains untouched. Task B returns the updated file set. This will be the same as performing an ASSOC-IN on the map. Task A has access to the original fileset and the referenced file.

And although this file management functionality is also not used by the what and fire tasks in the above code, when Boot creates a task, it expects the handler to receive and return the file set record. Therefore, to communicate data between tasks, we secretly add it to the file set record (merge fileset {:thing thing thing :pluralize pluralize})

These are the concepts behind Boot. Besides those, Boot has many other features that make programming easier in practice, such as set-env! For example, it separates classpaths so that you can run multiple projects in one JVM, it allows you to add new dependencies to a project without restarting the REPL, and so on.

コメント

タイトルとURLをコピーしました