Babashkaことはじめ - Clojureでシェルスクリプティングを代替する

この記事は[FOLIO Advent Calendar 2023](https://adventar.org/calendars/8882)の2日目です。

![00213-3321221044.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231669/79a49e89-3458-25d0-998d-d400c5237d22.png)

# この記事は?

この記事はClojureランタイムであるBabashkaを簡潔に紹介します。また、いくつかの役に立つコードスニペットを示し、チートシート的に使えることも目指します。

# 対象読者

* シェルスクリプトに疲れた人
* Babashka/Clojure固有の複雑な機能は使いませんが、LISP/Javaの右も左もわからない方には厳しいかも


# Babashkaとは?

Clojureは、LISP系のプログラミング言語で、JVM上で動くことが特徴です。

Babashkaは、Clojureのランタイムの一つで、GraalVMを利用し、Clojureコードを高速に実行できます。bashなどのシェルスクリプトの代替として、Clojureを利用できるようにすることを目指しています。

## なぜBabashkaを使うべきなのか?

* 高速な起動
* 高速な実行
* Clojureによるスクリプティング
* シェルスクリプトの代替として機能するための様々なサポート


# 超速習Clojure

```clojure
; ()はリスト。リストの最初の値が関数、残りが引数として呼び出される
(println "hello, world")
; hello, world

; 関数宣言
(defn greet [name] (str "hello, " name))
(greet "john")
; hello, john

; 変数
(let [foo 123,  ; `,`はなくてもいい
      bar "abc"]
  (println (str foo " " bar)))
; 123 abc

; Vector
(let [vector ["a" "b" "c"]]
  (println (str
    (nth vector 0)
    (nth vector 1)
    (nth vector 2))))
; abc

; Set
(let [set #{:windows :linux :mac}]
  (println (contains? set :linux)))
; true
; `:name`はキーワードと呼ばれ、Mapなどのキーに使われる

; Map
(let [map {:os "linux" :lang "clojure"}]
  (println (get map :os)))
; linux
; キーワードはそれ自体を、値を取得する関数として使用できる
(println (:os map))
; linux
```


# インストール

https://github.com/babashka/babashka#installation のインストールスクリプトを使うのが良い。

```bash
bash < <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install)
```

おま環かもしれないがbrewはぶっ壊れた。


# hello world

```bash
$ bb '(println "hello, world")'
hello, world
```

# スタートアップの方法

```bash
$ bb -e '(println "hello, world")'
hello, world
```

`-e`で、引数を式として評価する。

```bash
$ bb -f 'file.clj'
```

`-f`で、ファイルを実行する。

ただし、Babashkaは賢く、デフォルト引数の最初の文字が`(`だった場合は式とみなし評価、そうでない場合ファイルとして扱うので、実際に意識する必要は殆どない。

## Shebangを使う場合

```clojure
#!/usr/bin/env bb
(println "hello world")
```


# インポートと名前空間

Clojureには他の名前空間の関数を参照する方法が多数あり、非常に混乱する。


```clojure
(use 'babashka.fs)
(use '[babashka.fs :only [windows?]])
(require '[babashka.fs :refer [windows?]])
(require '[babashka.fs :refer :all])
(require '[babashka.fs :as f])

(println (f/windows?))
```

Javaのクラスには`import`を使用する。

```clojure
(import java.time.LocalDateTime)
(import '[java.time LocalDateTime ZonedDateTime])
```

直接インポートする場合FQNを指定するのに対して、リストを使う場合、パッケージ名とクラス名は別に指定し、先頭に`'`が必要。`import`では`:all`は使えない。

名前空間を宣言するには、`ns`を使う。その中で`:require`などを使うこともできる。

```clojure
(ns hoge
  (:use [babashka.fs])
  (:require [babashka.fs :refer :all])
  (:import [java.time LocalDateTime]))
```

見ての通りこちらには`'`は不要。非常に混乱する。[^2]

個人的な意見だが、名前空間が必要になるような大きなプロジェクトにスクリプティングを用いるべきではなく、大人しく静的型付け言語を使うべきだと考える。実際のスクリプティングでは、`(require '[namespace :as binding])`のみ覚えておけばよいだろう。

[^2]: Clojureのこういうところは本当に良くないと思う。


# 引数

```clojure:cli.clj
(require '[babashka.cli :as cli])

(println (str
  (cli/parse-opts *command-line-args*)
  "\n"
  (cli/parse-args *command-line-args*)))
```

```bash
$ bb cli.clj --f1 --f2 aaa -f3 false --f4 123 bbb
{:f1 true, :f2 "aaa", :f3 false, :f4 123}
{:args ["bbb"], :opts {:f1 true, :f2 "aaa", :f3 false, :f4 123}}
```

基本的に`parse-opts`を使う。フラグを利用しない(`--foo`を使わない)引数を指定したい場合、`parse-args`を使う。

```bash
$ bb cli.clj --f1 aaa bbb --this-will-be-erased
{:f1 "aaa"}
{:args ["bbb" "--this-will-be-erased"], :opts {:f1 "aaa"}}
```

見ての通り、若干癖がある。


Gitのようにサブコマンドを使用したい場合、`dispatch`が使用できる。

```clojure:cli2.clj
(def table [{:cmds ["cmd1"] :fn #(println %)}
            {:cmds ["cmd2"] :fn #(println %)}])
(cli/dispatch table *command-line-args*)
```

```bash
$ bb cli2.clj cmd1 --foo bar buz
{:cmds (cmd1), :args (buz), :rest-cmds (buz), :opts {:foo bar}, :dispatch [cmd1]}
```

実際のスクリプティングでは、specを利用するのが便利と思われる。

```clojure:cli3.clj
(def spec
  {:flag1 {:alias :f1
           :desc "Description"
           :required true}
   :flag2 {:default "default value"}
   :flag3 {:coerce :string}})
(println
  (cli/parse-args *command-line-args* {:spec spec}))
```

```bash
$ bb test.clj --f1 aaa --flag3 false
{:opts {:flag2 default value, :flag1 aaa, :flag3 false}}
```


# 標準入出力

`bb -e`を使い式を評価した場合、その評価の結果が標準出力される。

```
$ bb -e '(str "hello" " " "world")'
"hello world"
```

[ただし、`nil`は出力されない。](https://book.babashka.org/#_printing_returned_values)

単に標準入出力を扱うだけなら、Clojure標準の機能を使うのが良い。標準出力には`print` `println`が使える。

標準入力は`*in*`にバインドされるが、これは`Reader`のため、`slurp`を使うと良い。[^0]
[^0]: もちろんこれはすべてのデータをメモリー上に読み込むが、本記事ではスクリプティングという観点から簡易で扱いやすい実装を考えている。本記事では以降も`reader` `writer`を扱うより、`slurp` `spit`を主に使用する。

```clojure:std.clj
(println
  (str "The input is: " (slurp *in*)))
```

```bash
echo -n "May the force be with you." | bb std.clj
The input is: May the force be with you.
```

Clojureの`read-line`はインタラクティブなオプションに便利。

```clojure:std2.clj
(print "> ")
(flush)  ; これがないと出力が遅延される
(println
  "The input is: " (read-line))
```

```bash
bb std.clj
> foo
The input is:  foo
```

`> `のところで標準入力から一行読み込むまで一旦ブロックする。

Babashkaには`*input*`が用意されており、EDN形式で標準入力を読み込む仕組みがあるが、正直あまり使いやすいとは思わない。興味のある方は下記を参考されたし。

https://book.babashka.org/#_input_and_output_flags


# 環境変数

Clojure(というかJava)を使う。

```clojure
(println (System/getenv "JAVA_HOME"))
```


# ファイル操作

[Clojure標準の関数](https://clojuredocs.org/clojure.java.io)の他、[`babashka.fs`](https://github.com/babashka/fs/blob/master/API.md#babashka.fs)に便利な関数が多数定義されているので、眺めてみることを推奨。

## ファイルの読み書き

```clojure
(def f "./test.txt")

(spit f "If you only knew the power of Clojure!")

(println (slurp f))

(babashka.fs/delete f)
(babashka.fs/delete-if-exists f)
```


# データ構造

## JSON

### Read

```json:test.json
{"foo": ["bar", 123]}
```

```clojure:json.clj
(let [result (json/parse-string (slurp "test.json") true)]
  (println result)
  (println (:foo result))
  (println (nth (:foo result) 1)))
```

```bash
$ bb json.clj
{:foo [bar 123]}
[bar 123]
123
```

二番目の引数`true`は、戻り値のキーをキーワードとして返したい場合に指定する。常にしていたほうが良いと思う。

`json`名前空間は、実際には[cheshire](https://github.com/dakrone/cheshire)のエイリアス。


### Write

```clojure
(println
  (json/generate-string {:foo ["bar" 123]}))
```

```bash
$ bb json2.clj
{"foo":["bar",123]}
```

Mapを渡すと簡単にJSONが作れる。


## YAML

### Read

```yaml:test.yml
foo:
  - bar
  - 123
```

```clojure:yaml.clj
(let [result (yaml/parse-string (slurp "test.yml"))]
  (println result)
  (println (:foo result))
  (println (nth (:foo result) 1)))
```

```bash
$ bb yaml.clj
#ordered/map ([:foo (bar 123)])
(bar 123)
123
```

微妙に型が違って気持ち悪いが……。

### Write

```clojure
(println
  (yaml/generate-string {:foo ["bar" 123]}))
```

```bash
$ bb yaml2.clj
foo: [bar, 123]
```

JSONと同様、`generate-string`を使用する。


## CSV

### Read

```csv:test.csv
aaa,bbb,123
"ccc", ddd ,456
```

```clojure:csv.clj
(println
  (csv/read-csv (slurp "test.csv")))
```

```bash
$ bb csv.clj
([aaa bbb 123] [ccc  ddd  456])
```

### Write

```
(print
  (let [writer (new java.io.StringWriter)]
  (csv/write-csv writer [["abc" 123] ["def" 456]])
  (.toString writer)))
```

`Writer`を使用するため、`StringWriter`を使ったJavaの古典的な変換が必要になる(これ、Clojureで便利にやる方法があったら教えてください)。ファイル出力であれば`spit`でサクッと直接出力できる。


# HTTP

いちばん簡単なのは、`slurp`を使うこと。

```clojure
(println
  (slurp "https://catfact.ninja/fact"))
```

```bash
$ bb http.clj
{"fact":"Cats and kittens should be acquired in pairs whenever possible as cat families interact best in pairs.","length":102}
```

もう少し真面目に処理をしたい場合、Babashka http-clientとhttp-kitが利用できる。本記事ではより簡単なhttp-clientを紹介する。

```clojure
(println
  (babashka.http-client/get "https://catfact.ninja/fact"))
```

```bash
$ bb http.clj
{:status 200, :body {"fact":"The Maine Coon cat is America's only natural breed of domestic feline. It is 4 to 5 times larger than the Singapura, the smallest breed of cat.","length":143}, :version :http2, :headers 省略, :uri ;object[java.net.URI 0x682b0c6a https://catfact.ninja/fact], :request 省略, :uri #object[java.net.URI 0x682b0c6a https://catfact.ninja/fact], :method :get}}
```

典型的な使用例は以下のような感じ。

```clojure
(-> (babashka.http-client/get "https://catfact.ninja/fact")
  :body
  (json/parse-string true)
  :fact
  println)
```

```bash
$ bb http.clj
Florence Nightingale owned more than 60 cats in her lifetime.
```

POSTは以下の通り。

```clojure
(-> (babashka.http-client/post
      "https://httpbin.org/post"
      {:headers {:content-type "application/json"}
       :body (json/generate-string {:foo "bar"})})
  :body
  (json/parse-string true)
  :data  ; httpbinは"data"フィールドに、リクエストをそのまま格納して返してくる
  println)
```

```bash
$ bb http.clj
{"foo":"bar"}
```

Basic認証も可能である。

```clojure
(-> (babashka.http-client/get
       "https://httpbin.org/basic-auth/username/password"
       {:basic-auth ["username" "wrong-password"] :throw false})
  :status
  println)
```

`:throw false`を指定しない場合、200以外のステータスコードで例外が投げられてしまうので、適宜OFFに。


# プロセス

単に外部のプロセスを起動したい場合、`shell`を使うのが一番簡単。

```clojure
(require '[babashka.process :refer :all])

(shell "echo" "hello from the shell")
```

```bash
$ bb proc.clj
hello from the shell
```

いくつか注意点があり、まず、標準入出力は`*in*` `*out`が使われる。`shell`という関数名であるにも関わらず、シェルを起動するのではなく直接プロセスを起動する。プロセスの終了コードが0出ない場合、例外を投げる。これを抑制するには、`{:continue true}`を指定する必要がある。

```clojure
(println
  (:exit
    (shell {:continue true} "python" "-c" "exit(42)")))
```

```bash
$ bb proc.clj
42
```

`:out :string` `:err :string`で、標準出力を文字列として取得できる。それぞれ戻り値の`:out` `:error`から取得できる。

```clojure
(println
  (:out (shell {:out :string} "echo" "-n" "So uncivilized!")))
```

```bash
$ bb proc.clj
So uncivilized!
```

ワーキングディレクトリは`:dir "directory"`で指定できる。


# deps

depsを使うことで、ランタイムで、ライブラリーをMavenやClojarsから取得し利用できる。
注意点として、ここで利用できるのは純粋にClojureで書かれたコードのみであり、Javaなどで書かれたJARを利用することはできない。

```clojure
(babashka.deps/add-deps '{
  :deps {lambdaisland/uri {:mvn/version "1.16.134"}}})

(require '[lambdaisland.uri :refer :all])

(println
  (:fragment (uri "https://starwars.fandom.com/wiki/Stormtrooper#Specialized_stormtroopers")))
```

```bash
$ bb deps.clj
# Clojarsからのダウンロードログが表示される(省略)
Specialized_stormtroopers
```


# Clojure Tips

## カンマ

Clojureでは、カンマを区切り文字としてコードを読みやすくするために利用できる。あってもなくても動作に関係はない。

```clojure
(println
  (= ["foo" "bar"] ["foo", "bar"]))
```

## スレッディングマクロ

`->` `->>`を使うと、戻り値をそのまま引数に渡す処理を簡単に書ける。
どちらのマクロも複数の式を引数にとり、それぞれの戻り値を次の戻り値に渡していく。`->`は戻り値をリストの二番目の位置に(つまり最初の引数として)、`->>`は最後の位置に挿入する。例えば、

```clojure
(println
  (:fact
    (json/parse-string
      (:body
        (babashka.http-client/get "https://catfact.ninja/fact"))
      true)))
```

は、

```clojure
(-> (babashka.http-client/get "https://catfact.ninja/fact")
  :body
  (json/parse-string true)
  :fact
  println)
```

と書ける。
……のだが、私は毎度どちらがどちらだったか忘れるので、そういう人は`as->`を使うと明示的に位置を指定できる。

```clojure
(as-> (babashka.http-client/get "https://catfact.ninja/fact") _
  (:body _)
  (json/parse-string _ true)
  (:fact _)
  (println _))
```

`some->` `some->>`は似ているが、`nil`が返されるとすぐに`nil`を返して後続処理を打ち切るものになっている。


## ありがちなタスクをいくつか

```clojure
(defn bytes2hex [bytes]
  (apply str (map (partial format "%02x") bytes)))

(defn base-10-to-n [i r]
  (.toUpperCase (Integer/toString i r)))

(defn base-n-to-10 [s r]
  (Integer/valueOf s r))

(defn sha256 [s]
  (bytes2hex
    (.digest (java.security.MessageDigest/getInstance "SHA-256") (.getBytes s "UTF-8"))))

(defn base64encode [bytes]
  (.encodeToString (java.util.Base64/getEncoder) bytes))

(defn base64decode [s]
  (bytes2hex (.decode (java.util.Base64/getDecoder) s)))

(defn urlencode [s]
  (java.net.URLEncoder/encode s "UTF-8"))

(defn urldecode [s]
  (java.net.URLDecoder/decode s "UTF-8"))

(defn uuid []
  (.toString (java.util.UUID/randomUUID)))

(defn now []
  (.format (java.time.ZonedDateTime/now) java.time.format.DateTimeFormatter/ISO_ZONED_DATE_TIME))

(defn unixtime [n]
  (-> (java.time.Instant/ofEpochSecond n) ; use ofEpochMilli() for milliseconds
    (.atZone (java.time.ZoneId/systemDefault))
    ;; (.atOffset (java.time.ZoneOffset/UTC)) ; for UTC
    (.format  java.time.format.DateTimeFormatter/ISO_ZONED_DATE_TIME)))
```

[^1]

[^1]: https://gist.github.com/kubek2k/8446062





# リファレンス

* [GitHub](https://github.com/babashka/babashka)
* [Babashka book](https://book.babashka.org/)
* [Babashka Babooka](https://www.braveclojure.com/quests/babooka/)