EDN data DSL

私は、EDNファイルのセットで、いくつかの疎な構造の習慣と旅行情報を保持しています。 一時期、実際のデータベースを使ったこともありました、しかし、データを入力するためのUIをいじるのに多くの時間を費やしていることにすぐに気づきました。また、表計算ソフトも試しました、しかし、データはまばらで、表形式にするのは厄介でした。また、表計算ソフトはクエリも簡単ではありません。そこで、Clojureのコードを含むEDNファイルのフォルダを用意し、それを評価するとマップのリストが生成されるという仕組みにしました。

個々のファイルを解析するために、その内容を角括弧で囲み、EDNフォームのベクタとして読み込むのです:

(defn parse-file [s]
  (clojure.edn/read-string {} (format "[%s]" s)))

ファイルを評価する前に、ファイル名の日付を動的なvarにバインドして、EDNフォームをClojureコードであるかのように評価するのです:

(def ^:dynamic *date*)

(defn eval-file [nmsp file]
  (binding [*date* (re-find #"\d{4}-\d{2}-\d{2}" (.getName file))]
    (->> (slurp file)
         parse-file
         (mapv (fn [form]
                 (binding [*ns* nmsp]
                   (eval form))))
         (remove var?)
         (mapv #(cond-> %
                  (not (:date %)) (assoc :date *date*))))))

実際には、EDNはClojureの構文のサブセットに過ぎません。アトムをデリファレンスするための @ などのClojureのリーダーマクロは使えませんし、 #(...) で匿名関数を作成することもできません。 しかし、defdefnのような非リーダマクロは動作するので、フォームを評価した後、varsを破棄します。最後に、:dateフィールドを持たないマップには、ファイル名で指定された日付を追加します。この eval には多くのパワーが隠されています。しかし、私はマップのリストを生成しています、私は通常、マップを明示的に書き出すことはしません; 代わりに、Clojureを利用できるようにします、 代わりにClojureが使えるので、マップを返す関数を定義し、その関数を呼び出します。例えば、次のように書く代わりに

{:lat 43 :lon -85}

私が書く

(defn location [lat lon]
  {:lat lat :lon lon})

(location 43 -85)

このように関数を定義して呼び出すことで、特定の情報のマップを一貫した構造に保つことができ、キーの名前を変更するなどマップの詳細を簡単に変更することができます(例えば :lat:latitude に変更するなど)。また、キーの名前のタイプミスを防ぐことができます。これは evaling では検出できませんが、eval では関数の名前をタイプミスした場合にエラーを投げることができます。

このスタイルで、よくあるエントリーのDSLを作り上げました。典型的なファイルは次のようなものです:

(location 43 -85)

(outdoors 1030 1130)

(-> (walk)
    (route "park.geojson")
    (note "warm day, unusually busy"))

(no-tv)

routenote などの関数は、マップにオプションのデータを追加するモディファイア関数で、Clojureのパワーを最大限に活用することで、->でモディファイアを連鎖させることが簡単にできます。

eval-fileのもう一つの微妙な点は、フォームが*date*を参照することができることです。私はこれを直接参照しませんが、#yesterday#tomorrowというタグリーダーマクロがあり、*date*を読み込んで前後の日付にリバインドしてくれます。これらを使用するには、parse-string:readers を指定します:

(defn parse-string [s]
  (clojure.edn/read-string
    {:readers {'yesterday yesterday
               'tomorrow tomorrow}}
    (format "[%s]" s)))

ここでは、ディレクトリ内のファイルを読み込むメインエントリポイントを紹介します:

(defn read-data [directory]
  (let [nmsp *ns*]
    (->> (file-seq (clojure.java.io/file directory))
         (filter #(re-find #"\d{4}-\d{2}-\d{2}" (.getName %)))
         (sort-by #(.getName %))
         (mapcat #(eval-file nmsp %)))))

ファイルは現在の名前空間で評価されるため、あるファイル内で定義された関数は、それ以降のすべてのファイルで利用可能であることに注意してください。 また、関数定義をライブラリファイルにまとめ、データファイルの処理の前にそれを評価することもできます。

このDBへの問い合わせは、read-dataからマップのリストを取得し、Clojureの組み込みコレクション関数でフィルタリングするのと同じくらい簡単です。上記のコードはすべてBabashkaで実行できるので、データの様々な側面(例えば、私が屋外で過ごす時間)についてレポートを印刷する一連の bb タスクがあります。これらのプレーンテキストレポートは、データのビジュアライゼーションを生成するコマンドに接続することができます。

全体として、このセットアップにより、よく構造化され、検索可能なデータを生成し、かつ十分に人間が読めるログを維持することができるようになりました。