8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2023-12-01

この記事はFOLIO Advent Calendar 2023の2日目です。

00213-3321221044.png

この記事は?

この記事は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])のみ覚えておけばよいだろう。

引数

cli.clj
(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が使用できる。

cli2.clj
(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を利用するのが便利と思われる。

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}))
$ 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は出力されない。

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

標準入力は*in*にバインドされるが、これはReaderのため、slurpを使うと良い。2

std.clj
(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はインタラクティブなオプションに便利。

std2.clj
(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

test.json
{"foo": ["bar", 123]}
json.clj
(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

test.yml
foo:
  - bar
  - 123
yaml.clj
(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

test.csv
aaa,bbb,123
"ccc", ddd ,456
csv.clj
(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)))

3

リファレンス

  1. Clojureのこういうところは本当に良くないと思う。

  2. もちろんこれはすべてのデータをメモリー上に読み込むが、本記事ではスクリプティングという観点から簡易で扱いやすい実装を考えている。本記事では以降もreader writerを扱うより、slurp spitを主に使用する。

  3. https://gist.github.com/kubek2k/8446062

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?