この記事はFOLIO Advent Calendar 2023の2日目です。
この記事は?
この記事はClojureランタイムであるBabashkaを簡潔に紹介します。また、いくつかの役に立つコードスニペットを示し、チートシート的に使えることも目指します。
対象読者
- シェルスクリプトに疲れた人
- Babashka/Clojure固有の複雑な機能は使いませんが、LISP/Javaの右も左もわからない方には厳しいかも
Babashkaとは?
Clojureは、LISP系のプログラミング言語で、JVM上で動くことが特徴です。
Babashkaは、Clojureのランタイムの一つで、GraalVMを利用し、Clojureコードを高速に実行できます。bashなどのシェルスクリプトの代替として、Clojureを利用できるようにすることを目指しています。
なぜBabashkaを使うべきなのか?
- 高速な起動
- 高速な実行
- 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 < <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install)
おま環かもしれないがbrewはぶっ壊れた。
hello world
$ bb '(println "hello, world")'
hello, world
スタートアップの方法
$ bb -e '(println "hello, world")'
hello, world
-e
で、引数を式として評価する。
$ bb -f 'file.clj'
-f
で、ファイルを実行する。
ただし、Babashkaは賢く、デフォルト引数の最初の文字が(
だった場合は式とみなし評価、そうでない場合ファイルとして扱うので、実際に意識する必要は殆どない。
Shebangを使う場合
#!/usr/bin/env bb
(println "hello world")
インポートと名前空間
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
を使用する。
(import java.time.LocalDateTime)
(import '[java.time LocalDateTime ZonedDateTime])
直接インポートする場合FQNを指定するのに対して、リストを使う場合、パッケージ名とクラス名は別に指定し、先頭に'
が必要。import
では:all
は使えない。
名前空間を宣言するには、ns
を使う。その中で:require
などを使うこともできる。
(ns hoge
(:use [babashka.fs])
(:require [babashka.fs :refer :all])
(:import [java.time LocalDateTime]))
見ての通りこちらには'
は不要。非常に混乱する。1
個人的な意見だが、名前空間が必要になるような大きなプロジェクトにスクリプティングを用いるべきではなく、大人しく静的型付け言語を使うべきだと考える。実際のスクリプティングでは、(require '[namespace :as binding])
のみ覚えておけばよいだろう。
引数
(require '[babashka.cli :as cli])
(println (str
(cli/parse-opts *command-line-args*)
"\n"
(cli/parse-args *command-line-args*)))
$ 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
を使う。
$ bb cli.clj --f1 aaa bbb --this-will-be-erased
{:f1 "aaa"}
{:args ["bbb" "--this-will-be-erased"], :opts {:f1 "aaa"}}
見ての通り、若干癖がある。
Gitのようにサブコマンドを使用したい場合、dispatch
が使用できる。
(def table [{:cmds ["cmd1"] :fn #(println %)}
{:cmds ["cmd2"] :fn #(println %)}])
(cli/dispatch table *command-line-args*)
$ bb cli2.clj cmd1 --foo bar buz
{:cmds (cmd1), :args (buz), :rest-cmds (buz), :opts {:foo bar}, :dispatch [cmd1]}
実際のスクリプティングでは、specを利用するのが便利と思われる。
(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}))
$ bb test.clj --f1 aaa --flag3 false
{:opts {:flag2 default value, :flag1 aaa, :flag3 false}}
標準入出力
bb -e
を使い式を評価した場合、その評価の結果が標準出力される。
$ bb -e '(str "hello" " " "world")'
"hello world"
単に標準入出力を扱うだけなら、Clojure標準の機能を使うのが良い。標準出力にはprint
println
が使える。
標準入力は*in*
にバインドされるが、これはReader
のため、slurp
を使うと良い。2
(println
(str "The input is: " (slurp *in*)))
echo -n "May the force be with you." | bb std.clj
The input is: May the force be with you.
Clojureのread-line
はインタラクティブなオプションに便利。
(print "> ")
(flush) ; これがないと出力が遅延される
(println
"The input is: " (read-line))
bb std.clj
> foo
The input is: foo
>
のところで標準入力から一行読み込むまで一旦ブロックする。
Babashkaには*input*
が用意されており、EDN形式で標準入力を読み込む仕組みがあるが、正直あまり使いやすいとは思わない。興味のある方は下記を参考されたし。
環境変数
Clojure(というかJava)を使う。
(println (System/getenv "JAVA_HOME"))
ファイル操作
Clojure標準の関数の他、babashka.fs
に便利な関数が多数定義されているので、眺めてみることを推奨。
ファイルの読み書き
(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
{"foo": ["bar", 123]}
(let [result (json/parse-string (slurp "test.json") true)]
(println result)
(println (:foo result))
(println (nth (:foo result) 1)))
$ bb json.clj
{:foo [bar 123]}
[bar 123]
123
二番目の引数true
は、戻り値のキーをキーワードとして返したい場合に指定する。常にしていたほうが良いと思う。
json
名前空間は、実際にはcheshireのエイリアス。
Write
(println
(json/generate-string {:foo ["bar" 123]}))
$ bb json2.clj
{"foo":["bar",123]}
Mapを渡すと簡単にJSONが作れる。
YAML
Read
foo:
- bar
- 123
(let [result (yaml/parse-string (slurp "test.yml"))]
(println result)
(println (:foo result))
(println (nth (:foo result) 1)))
$ bb yaml.clj
#ordered/map ([:foo (bar 123)])
(bar 123)
123
微妙に型が違って気持ち悪いが……。
Write
(println
(yaml/generate-string {:foo ["bar" 123]}))
$ bb yaml2.clj
foo: [bar, 123]
JSONと同様、generate-string
を使用する。
CSV
Read
aaa,bbb,123
"ccc", ddd ,456
(println
(csv/read-csv (slurp "test.csv")))
$ 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
を使うこと。
(println
(slurp "https://catfact.ninja/fact"))
$ 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を紹介する。
(println
(babashka.http-client/get "https://catfact.ninja/fact"))
$ 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}}
典型的な使用例は以下のような感じ。
(-> (babashka.http-client/get "https://catfact.ninja/fact")
:body
(json/parse-string true)
:fact
println)
$ bb http.clj
Florence Nightingale owned more than 60 cats in her lifetime.
POSTは以下の通り。
(-> (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)
$ bb http.clj
{"foo":"bar"}
Basic認証も可能である。
(-> (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
を使うのが一番簡単。
(require '[babashka.process :refer :all])
(shell "echo" "hello from the shell")
$ bb proc.clj
hello from the shell
いくつか注意点があり、まず、標準入出力は*in*
*out
が使われる。shell
という関数名であるにも関わらず、シェルを起動するのではなく直接プロセスを起動する。プロセスの終了コードが0出ない場合、例外を投げる。これを抑制するには、{:continue true}
を指定する必要がある。
(println
(:exit
(shell {:continue true} "python" "-c" "exit(42)")))
$ bb proc.clj
42
:out :string
:err :string
で、標準出力を文字列として取得できる。それぞれ戻り値の:out
:error
から取得できる。
(println
(:out (shell {:out :string} "echo" "-n" "So uncivilized!")))
$ bb proc.clj
So uncivilized!
ワーキングディレクトリは:dir "directory"
で指定できる。
deps
depsを使うことで、ランタイムで、ライブラリーをMavenやClojarsから取得し利用できる。
注意点として、ここで利用できるのは純粋にClojureで書かれたコードのみであり、Javaなどで書かれたJARを利用することはできない。
(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")))
$ bb deps.clj
# Clojarsからのダウンロードログが表示される(省略)
Specialized_stormtroopers
Clojure Tips
カンマ
Clojureでは、カンマを区切り文字としてコードを読みやすくするために利用できる。あってもなくても動作に関係はない。
(println
(= ["foo" "bar"] ["foo", "bar"]))
スレッディングマクロ
->
->>
を使うと、戻り値をそのまま引数に渡す処理を簡単に書ける。
どちらのマクロも複数の式を引数にとり、それぞれの戻り値を次の戻り値に渡していく。->
は戻り値をリストの二番目の位置に(つまり最初の引数として)、->>
は最後の位置に挿入する。例えば、
(println
(:fact
(json/parse-string
(:body
(babashka.http-client/get "https://catfact.ninja/fact"))
true)))
は、
(-> (babashka.http-client/get "https://catfact.ninja/fact")
:body
(json/parse-string true)
:fact
println)
と書ける。
……のだが、私は毎度どちらがどちらだったか忘れるので、そういう人はas->
を使うと明示的に位置を指定できる。
(as-> (babashka.http-client/get "https://catfact.ninja/fact") _
(:body _)
(json/parse-string _ true)
(:fact _)
(println _))
some->
some->>
は似ているが、nil
が返されるとすぐにnil
を返して後続処理を打ち切るものになっている。
ありがちなタスクをいくつか
(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)))