November 20, 2023
By: Kevin

Babashka Babooka: 使用Clojure开发命令行工具

  1. 引言
    1. 赞助
    2. 什么是Babashka?
    3. 为什么您应该使用它?
    4. 安装
  2. 你的第一个脚本
    1. Babashka的输出
    2. 命名空间是可选的
    3. 引入其他命名空间
    4. 让你的脚本可执行
    5. 总结
  3. 处理文件
    1. 处理文件
    2. 为脚本创建接口
    3. 使用babashka.cli解析选项
    4. 使用babashka.cli分派子命令
    5. 总结
  4. 组织你的项目
    1. 文件系统结构
    2. 命名空间
    3. 总结
  5. 添加依赖项
  6. Pods
    1. Pod实现
    2. 总结
  7. 其他执行代码的方式
    1. 直接表达式求值
    2. 调用Clojure函数
  8. 任务
    1. 基础任务
    2. 如何为任务引入命名空间
    3. 使用exec解析参数并调用函数
    4. 任务依赖、并行任务等
    5. 总结
  9. 其他资源
  10. 致谢
  11. 反馈

翻译自Clojure For Brave and True的作者的一本开源的关于Babashka的小册子.

使用GPT4"帮助"完成翻译, 所有的功劳都归我, 所有的错误都怪它✌️.

封面

引言

世界上有两种程序员:一种是实用且理智的,他们需要Google搜索如何正确使用ln -s这样的Shell命令的参数顺序;另一种则是扭曲的斯德哥尔摩受害者,他们会兴高采烈地用57个命令拼凑成一个单行的bash脚本来运行他们公司的整个基础设施。

本指南面向的是前者。至于后者:抱歉,我帮不了你。

Babashka是一个Clojure脚本运行时,它是一个强大且令人愉悦的替代方案,与你习惯的Shell脚本不同。这个全面的教程将教会你:

  • Babashka是什么,它做什么,它是如何工作的,以及它如何融入你的工作流程。
  • 如何编写Babashka脚本。
  • 如何组织你的Babashka项目。
  • 什么是pods,以及它们如何为外部程序提供一个原生的Clojure接口。
  • 如何使用任务来创建类似于make或npm的接口。

如果你想停止做一些让人头疼的事情(编写难以理解的Shell脚本),并开始做一些让人感觉棒极了的事情(编写Babashka脚本),那就继续阅读吧!

注意

如果您不熟悉Clojure,Babashka实际上是一个很好的学习工具!本书的这个快速入门课程和关于命名空间的章节涵盖了您需要理解此处使用的Clojure的内容。有许多优秀的编辑器扩展可用于编写Clojure代码,包括VS Code的Calva和emacs的CIDER。如果您是命令行的新手,可以看看《Learn Enough Command Line to be Dangerous》。

赞助

如果您喜欢这个教程,请考虑通过GitHub赞助我,Daniel Higginbotham。截至2022年4月,我每周花两天时间致力于开发免费的Clojure教育材料和开源库,以使Clojure更加适合初学者,感谢您的任何支持!

还请考虑赞助Michiel Borkent,也就是borkdude,他是babashka的创造者。Michiel正在做一些真正不可思议的工作,以改变Clojure的格局,扩展其实用性和影响力,造福我们所有人。他在提供有用工具和与社区互动方面有着良好的记录。

什么是Babashka?

从用户的角度看,babashka是Clojure编程语言的一个脚本运行时。它允许您在通常会使用bash、ruby、python等的情境中执行Clojure程序。使用场景包括构建脚本、命令行工具、小型Web应用程序、git钩子、AWS Lambda函数,以及任何您希望在快速启动和/或低资源使用的情况下使用Clojure的场合。

您可以在终端运行类似以下的命令来立即执行您的Clojure程序:

bb my-clojure-program.clj

如果您熟悉Clojure,您会发现这很重要,因为它消除了您对JVM编译的Clojure程序所必须应对的启动时间,更不用说您不必编译文件。它还比运行jar文件使用更少的内存。Babashka使得在您已经使用的基础上更加频繁地使用Clojure成为可能。

如果您不熟悉Clojure,使用Babashka是尝试这种语言的绝佳方式。Clojure是一种托管语言,意味着这种语言是独立于底层运行环境定义的。大多数Clojure程序被编译以在Java虚拟机(JVM)上运行,因此它们可以在任何Java运行的地方运行。另一个主要目标是JavaScript,允许Clojure在浏览器中运行。有了Babashka,您现在可以在通常运行bash脚本的地方运行Clojure程序。您投入在Clojure上的时间将带来回报,因为您的知识可以转移到这些不同的环境中。

从实现的角度来看,Babashka是一个独立的、本地编译的二进制文件,意味着操作系统直接执行它,而不是在JVM中运行。当babashka二进制文件被编译时,它包含了许多Clojure命名空间和库,以便它们可以以本地性能使用。

为什么您应该使用它?

我不会深入讲述Clojure本身的好处,因为其他地方有很多关于这方面的资料。

除了它是Clojure之外,Babashka还带来了一些使其与竞争者区别开来的特性:

  • 一流的多线程编程支持。Clojure使得多线程编程变得简单易写,易于推理。使用Babashka,您可以编写直接的脚本,例如并行地从多个数据库中获取和处理数据。

  • 真正的测试。您可以像测试任何其他Clojure项目一样对您的Babashka代码进行单元测试。您甚至如何测试bash脚本?

  • 真正的项目组织。Clojure命名空间是组织项目功能和构建可复用库的明智方式。

  • 跨平台兼容性。不必担心一个在OS X上开发的脚本在持续集成管道中出现故障,这感觉很好。

  • 交互式开发。遵循Lisp传统,Babashka提供了一个读取-求值-打印循环(REPL),给您带来快速反馈的良好体验。脚本开发本质上是快速的;Babashka让它更快。

  • 内置工具用于定义脚本的接口。编写Shell脚本的一个原因是为复杂的过程提供一个简洁、易懂的接口。例如,您可能会编写一个包含构建和部署命令的构建脚本,像这样调用:

    ./my-script build
    ./my-script deploy
    

    Babashka提供了工具,让您以一致的方式定义此类命令,并将命令行参数解析为Clojure数据结构。看看吧,bash!

  • 丰富的库集。Babashka带有助手实用程序,用于完成典型的Shell脚本工作,如与进程交互或处理文件系统。它还支持以下功能,无需额外依赖:

    • JSON解析
    • YAML解析
    • 启动HTTP服务器
    • 编写生成性测试

    当然,您还可以添加Clojure库作为依赖,以实现更多功能。Clojure是通往其他编程范式的入口,所以如果您曾经想要在命令行中进行逻辑编程,现在就是您的机会!

  • 良好的错误信息。Babashka在所有Clojure实现中提供了最友好的错误处理,能够精确地指出错误发生的位置。

安装

在macos上 brew install borkdude/brew/babashka, 其他操作系统 参照官方文档.

你的第一个脚本

在这个教程中,我们将尝试构建一个基于命令行界面(CLI)的梦境记录工具。为什么呢?因为你们这些书呆子记录下你们奇怪的潜意识幻觉的想法对我来说非常有趣。

在这一节中,你将学到:

  • 如何编写和运行你的第一个Babashka脚本
  • 默认输出是如何处理的
  • Babashka如何处理命名空间

创建一个名为hello.clj的文件,并写入以下内容:

(require '[clojure.string :as str])
(prn (str/join " " ["Hello" "inner" "world!"]))

现在用bb(babashka的可执行文件)运行它:

bb hello.clj

你应该会看到它打印出"Hello inner world!"。

这里有几点需要对有经验的Clojurian指出:

  • 你不需要deps.edn文件或project.clj
  • 没有命名空间声明;我们使用(require ...)
  • 它仅仅是Clojure

我非常推荐你在继续之前实际尝试这个例子,因为它与你习惯的感觉不同。你可能不习惯于仅仅把几个Clojure表达式放入一个文件中并立即运行它们。

当我第一次开始使用Babashka时,它感觉如此不同以至于让我感到迷失。这就像我第一次尝试驾驶电动汽车时,我的身体有点不适应,因为我没有得到像听到和感觉到引擎启动那样的典型感官线索。

Babashka就是这样:体验如此平静和流畅以至于令人震惊。没有deps.edn,没有命名空间声明,只写你需要的代码,然后它就运行了!

这就是为什么我包含了"它仅仅是Clojure"这一点。它可能感觉不同,但这仍然是Clojure。让我们更详细地探索其他几点。

Babashka的输出

以下是正在发生的事情:bb解释了你写的Clojure代码,并即时执行它。prn打印到stdout,这就是为什么"Hello inner world!"在你的终端中返回。

注意

当你向stdout打印文本时,它会在你的终端被打印。这个教程并没有深入讲述stdout实际上是什么,但你可以将它视为你的程序内部世界和调用你程序的外部环境之间的通道。当你的程序向stdout发送东西时,你的终端接收并打印它。

注意,当值被打印时,引号是保持的。bb会打印你的数据结构的字符串化表示。如果你更新hello.clj为以下内容:

"Hello inner world!"
(prn ["It's" "me" "your" "wacky" "subconscious!"])

那么["It's" "me" "your" "wacky" "subconscious!"]将被打印出来,而"Hello inner world!"则不会。你必须在一个表单上使用打印函数,才能将其发送到stdout。

如果你想打印一个没有引号的字符串,你可以使用:

(println "Hello inner world!")

命名空间是可选的

关于缺少命名空间:这是Babashka作为脚本工

具的一部分。当你处于脚本状态时,你想立即开始尝试想法;你不想为了开始就必须处理样板代码。Babashka在你背后支持你。

你可以定义一个命名空间(当我们进入项目组织时会更多地看这一点),但如果你不这样做,那么Babashka默认使用user命名空间。尝试更新你的文件为:

(str "Hello from " *ns* " inner world!")

运行它将打印“Hello from user inner world!”。这可能会令人惊讶,因为文件名(hello.clj)和命名空间名之间存在不匹配。在其他Clojure实现中,当前命名空间严格对应于源文件的文件名,但Babashka在这个特定情境中稍微放宽了这一点。它提供了一个更符合使用其他脚本语言时所期望的脚本体验。

引入其他命名空间

你可能想要包含一个命名空间声明,因为你想要引用一些命名空间。在JVM Clojure和Clojurescript中,你通常会像这样引用命名空间:

(ns user
  (:require
   [clojure.string :as str]))

在源代码中使用(require '[clojure.string :as str])来引用命名空间通常被认为是不好的形式。

但在Babashka中情况不同。你会看到其他例子中自由地使用(require ...),对你来说也是可以的。

让你的脚本可执行

如果你想通过输入像./hello这样的命令来执行你的脚本,而不是bb hello.clj呢?你只需要重命名你的文件,添加一个shebang,并给它执行权限。更新hello.clj为:

#!/usr/bin/env bb

(str "Hello from " *ns* " inner world!")

注意 第一行#!/usr/bin/env bb是"shebang",我不会对此进行解释。

然后在你的终端运行:

mv hello{.clj}
chmod +x hello
./hello

首先你重命名文件,然后调用chmod +x使其可执行。然后你实际上执行它,向你自己的内部世界打招呼,这有点可爱。

总结

以下是你在本节中学到的内容:

  • 你可以使用bb script-name.clj来运行脚本。
  • 你可以通过在顶部添加#!/usr/bin/env bb并使用chmod +x script-name.clj来直接使脚本可执行。
  • 你不必在脚本中包含(ns ...)声明。但它仍然运行,它仍然是Clojure!
  • 使用(require ...)来引用命名空间是可以接受甚至鼓励的。
  • Babashka会将它遇到的最后一个值写入stdout,除非该值为nil。

处理文件

Shell脚本通常需要从命令行读取输入并在某处产生输出,我们的梦日记工具也不例外。它将在名为entries.edn的文件中存储条目。日记将是一个向量,每个条目将是一个带有键:timestamp和:entry的映射(条目包含换行符以便阅读):

[{:timestamp 0
  :entry "Dreamt the drain was clogged again except when I went to unclog
   it it kept growing and getting more clogged and eventually it
   swallowed up my little unclogger thing"}
 {:timestamp 1
  :entry "Bought a house in my dream was giving a tour of the backyard and
   all the... topiary? came alive and I had to fight it with a sword.
   I understood that this happens every night was very annoyed that
   this was not disclosed in the listing."}]

要写入日记,我们想运行命令./journal add --entry "Hamsters. Hamsters everywhere. Again."。结果应该是一个映射被追加到向量vector中。

让我们先完成一部分。创建名为journal的文件,并使用chmod +x journal使其可

处理文件

Shell脚本通常需要从命令行读取输入并在某处产生输出,我们的梦日记实用工具也不例外。它将在entries.edn文件中存储条目。日记将是一个向量,每个条目将是一个带有:timestamp:entry键的映射(条目包括为了可读性而设置的换行):

[{:timestamp 0
  :entry "梦见下水道又堵了,但当我去疏通时,它不断变大并且越堵越严重,最终吞噬了我的小疏通工具"}
 {:timestamp 1
  :entry "梦里买了一座房子,在后院导游时,所有的……园艺造型?突然活了过来,我不得不用剑与之战斗。我明白这每晚都会发生,对于这个情况在房源描述中没有提及感到非常恼火。"}]

要写入日记,我们运行命令./journal add --entry "到处都是仓鼠。又是仓鼠。"。结果应该是一个映射被添加到向量中。

让我们先实现部分功能。创建journal文件并使其可执行,使用chmod +x journal,然后使其内容如下:

#!/usr/bin/env bb

(require '[babashka.fs :as fs])
(require '[clojure.edn :as edn])

(def ENTRIES-LOCATION "entries.edn")

(defn read-entries
  []
  (if (fs/exists? ENTRIES-LOCATION)
    (edn/read-string (slurp ENTRIES-LOCATION))
    []))

(defn add-entry
  [text]
  (let [entries (read-entries)]
    (spit ENTRIES-LOCATION
      (conj entries {:timestamp (System/currentTimeMillis) :entry text}))))

(add-entry (first *command-line-args*))

我们需要几个命名空间:babashka.fsclojure.ednbabashka.fs是一个用于处理文件系统的函数集合。当编写Shell脚本时,你很可能需要处理文件系统,因此这个命名空间将成为你的好朋友。

我们使用fs/exists?函数检查entries.edn是否存在,因为如果找不到传递给slurp的路径对应的文件,slurp会抛出异常。

add-entry函数使用read-entries获取条目的向量,使用conj添加条目,然后使用spit将其写回entries.edn。默认情况下,spit会覆盖文件;如果你想要追加内容,你可以像这样调用它:(spit "entries.edn" {:timestamp 0 :entry ""} :append true)

为脚本创建接口

在最后一行,我们调用(add-entry (first *command-line-args*))*command-line-args*是一个序列,包含传递给脚本的所有命令行参数。如果你创建一个名为args.clj的文件,内容为*command-line-args*,然后运行bb args.clj 1 2 3,它将打印("1" "2" "3")

我们的日记文件已经可以通过调用./journal "飞行!但是去家得宝??"来添加条目。这几乎是我们想要的;我们实际上想要调用./journal add --entry "飞行!但是去家得宝??"。这里的假设是我们将想要有其他命令,如./journal list./journal delete

为了实现这一点,我们需要以更复

杂的方式处理命令行参数。最直接且最省力的方式是这样处理*command-line-args*的第一个参数:

(let [[command _ entry] *command-line-args*]
  (case command
    "add" (add-entry entry)))

对于你的使用情况,这可能已经足够好,但有时你可能想要更健壮的功能。你可能希望你的脚本能够:

  • 列出有效的命令。
  • 当用户调用不存在的命令时给出智能的错误消息(例如,如果用户调用./journal add-dream而不是./journal add)。
  • 解析参数,识别选项标志并将值转换为关键字、数字、向量、映射等。

一般来说,你希望为你的脚本定义一个清晰且一致的接口。这个接口负责获取命令行提供的数据,包括传递给脚本的参数以及通过标准输入管道传入的数据,并使用这些数据来进行处理。

这些数据用于处理以下三个责任:

  • 调度到Clojure函数。
  • 将命令行参数解析为Clojure数据,并将这些数据传递给被调度的函数。
  • 在执行上述责任时出现问题的情况下提供反馈。

Clojure生态系统至少提供了两个用于处理参数解析的库:

  • clojure.tools.cli
  • nubank/docopt.clj

Babashka提供了babashka.cli库,用于解析选项和分派子命令。我们将重点关注babashka.cli

使用babashka.cli解析选项

babashka.cli文档很好地解释了如何使用该库来满足所有命令行解析需求。我不会讨论每个选项,只关注构建我们的梦日记所需内容。要解析选项,我们需要引入babashka.cli命名空间,并定义一个CLI规范:

(require '[babashka.cli :as cli])
(def cli-opts
  {:entry {:alias :e
    :desc "你的梦境。"
    :require true}
   :timestamp {:alias :t
    :desc "你记录这个的unix时间戳。"
    :coerce {:timestamp :long}}})

CLI规范是一个映射,其中每个键是一个关键字,每个值是一个选项规范。这个键是你选项的长名称;:entry对应于命令行上的--entry标志。

选项规范是一个映射,你可以使用它来进一步配置选项。:alias允许你为你的选项指定一个短名称,这样你可以在命令行上使用-e而不是--entry:desc用于创建接口的摘要,:require用于强制存在一个选项。:coerce用于将选项的值转换为其他数据类型。

我们可以在REPL中使用这个CLI规范进行实验。启动Babashka REPL有许多选项,最直接的方式就是在命令行输入bb repl。如果你想使用CIDER,首先添加文件bb.edn并放入一个空映射{}。然后你可以使用cider-jack-in。之后你可以粘贴上面的代码片段,然后粘贴这个片段:

(cli/parse-opts ["-e" "越割草草越高 :("] {:spec cli-opts})
;; =>
{:entry "越割草草越高 :("}

注意,cli/parse-opts返回一个解析选项的映射,这将使我们以后使用选项变得容易。

遗漏一个必需的标志会抛出异常:

(cli/parse-opts [] {:spec cli-opts})
;; 异常被抛出,打印出来:
: 需要选项::entry 用户

cli/parse-opts是为简单脚本构建接口的绝佳工具!你可以使用cli/format-opts将该接口传达给外部世界。这个函数将接受一个选项规范并返回一个字符串,你可以打印这个字符串来帮助人们使用你的程序。请看:

(println (cli/format-opts {:spec cli-opts}))
;; =>
-e --entry 你的梦境。
-t --timestamp 你记录这个的unix时间戳。

使用babashka.cli分派子命令

babashka.cli不仅提供了选项解析,还提供了分派子命令的方式,这正是我们想要实现./journal add --entry "..."工作的功能。以下是journal的最终版本:

#!/usr/bin/env bb



(require '[babashka.cli :as cli])
(require '[babashka.fs :as fs])
(require '[clojure.edn :as edn])

(def ENTRIES-LOCATION "entries.edn")

(defn read-entries
  []
  (if (fs/exists? ENTRIES-LOCATION)
    (edn/read-string (slurp ENTRIES-LOCATION))
    []))

(defn add-entry
  [{:keys [opts]}]
  (let [entries (read-entries)]
    (spit ENTRIES-LOCATION
      (conj entries
      (merge {:timestamp (System/currentTimeMillis)} ;; 默认时间戳
      opts)))))

(def cli-opts
  {:entry {:alias :e
    :desc "你的梦境。"
    :require true}
   :timestamp {:alias :t
    :desc "你记录这个的unix时间戳。"
    :coerce {:timestamp :long}}})

(defn help
  [_]
  (println
    (str "add\n"
    (cli/format-opts {:spec cli-opts}))))

(def table
  [{:cmds ["add"] :fn add-entry :spec cli-opts}
   {:cmds [] :fn help}])

(cli/dispatch table *command-line-args*)

尝试在终端上运行以下命令:

./journal
./journal add -e "梦见他们再做了一集《萤火虫》而我在其中"

底部的cli/dispatch函数接受一个分派表作为其第一个参数。cli/dispatch会找出你在命令行中传递的哪些参数对应于命令,然后调用相应的:fn。如果你输入./journal add ...,它将分派add-entry函数。如果你只输入./journal而没有参数,那么help函数将被分派。

分派的函数接收一个映射作为其参数,该映射包含:opts键。这是一个解析过的命令行选项映射,我们在add-entry函数中使用它来构建我们的梦日记条目。

朋友们,这就是为你的脚本构建接口的方式!

总结

  • 对于任何复杂度的脚本,你通常需要将命令行选项解析为Clojure数据结构。
  • clojure.tools.clinubank/docopts会将命令行参数解析为选项。
  • 我更喜欢使用babashka.cli,因为它还处理子命令的分派,但这个决定实际上是个人口味问题。

组织你的项目

现在你可以记录下你的潜意识每晚的即兴表演了。这太棒了!在这个成就的推动下,你决定再进一步,添加一个列出条目的功能。你想运行./journal list并让脚本返回类似以下内容:

2022-12-07 08:03am
有两个版本的我,其中一个版本把另一个烤成派吃了。既自豪又困惑。

2022-12-06 07:43am
我在一条船上,但船是由黄瓜三明治驱动的,我不得不不断制作这些三明治,以免在海上搁浅。

你在某处读到源文件的长度应该最多25行,所以你决定将你的代码库分割开来,将这个列表功能放在它自己的文件中。你该怎么做呢?

你可以像组织其他Clojure项目一样组织你的Babashka项目,将代码库分割成多个文件,每个文件定义一个命名空间,并且命名空间对应于文件名。让我们稍微重组一下我们当前的代码库,确保一切正常工作,然后为列出条目添加一个命名空间。

文件系统结构

组织我们的梦日记项目的一种方式可能是创建以下文件结构:

你的项目结构现在可能如下:

  • ./journal
  • ./src/journal/add.clj
  • ./src/journal/utils.clj

这种结构既类似于典型的Clojure项目文件结构,又有所不同。我们将命名空间放在src/journal目录中,这与JVM或ClojureScript项目中的做法一致。不同的是,我们的Babashka项目仍然使用./journal作为程序的可执行入口点,而不是使用./src/journal/core.clj或类似的文件。这可能感觉有点奇怪,但这是有效的,而且仍然是Clojure。

像其他Clojure环境一样,你需要告诉Babashka在require命名空间时查看src目录。你可以通过在journal相同目录下创建bb.edn文件,并在其中写入以下内容来实现这一点:

{:paths ["src"]}

bb.edn文件类似于deps.edn文件,其职责之一就是告诉Babashka如何构建你的类路径。类路径是Babashka在require命名空间时应查看的目录集合,通过添加"src",你可以在项目中使用(require '[journal.add])。Babashka将能够找到对应的文件。

注意"src"目录没有什么特别之处。如果你愿意,你可以使用"my-code"或甚至".",并且可以添加多个路径。"src"只是世界各地精明的Clojurian们通常喜欢的约定。

有了这些设置,我们现在将更新journal文件,使其看起来像这样:

#!/usr/bin/env bb

(require '[babashka.cli :as cli])
(require '[journal.add :as add])

(def cli-opts
  {:entry {:alias :e
    :desc "你的梦境。"
    :require true}
    :timestamp {:alias :t
    :desc "你记录这个的unix时间戳。"
    :coerce {:timestamp :long}}})

(def table
  [{:cmds ["add"] :fn add/add-entry :spec cli-opts}])

(cli/dispatch table *command-line-args*)

现在,该文件仅负责解析命令行参数并分派到正确的函数。添加功能已移至另一个命名空间。

命名空间

你可以在第4行看到,我们引入了一个新的命名空间journal.add。与这个命名空间对应的文件是./src/journal/add.clj。它的内容如下:

(ns journal.add
  (:require
    [journal.utils :as utils]))

(defn add-entry
  [opts]
  (let [entries (utils/read-entries)]
    (spit utils/ENTRIES-LOCATION
    (conj entries
    (merge {:timestamp (System/currentTimeMillis)} ;; 默认时间戳
    opts)))))

看,这是一个命名空间声明!并且这个命名空间声明有一个(:require ...)形式。我们知道,当你编写Babashka脚本时,如果所有代码都在一个文件中,像journal的原始版本那样,你可以不声明命名空间。然而,一旦你开始将代码分割成多个文件,普通的Clojure项目组织规则就适用了:

  • 命名空间名称必须对应文件系统路径。如果你想命名一个命名空间为journal.add,Babashka必须能够在journal/add.clj中找到它。
  • 你必须告诉Babashka在哪里查找与命名空间对应的文件。你可以通过创建bb.edn文件并在其中放入{:paths ["src"]}来做到这一点。

要完成对我们新项目组织的介绍,这里是./src/journal/utils.clj:

(ns journal.utils
  (:require
    [bab

ashka.fs :as fs]
    [clojure.edn :as edn]))

(def ENTRIES-LOCATION "entries.edn")

(defn read-entries
  []
  (if (fs/exists? ENTRIES-LOCATION)
    (edn/read-string (slurp ENTRIES-LOCATION))
    []))

如果你调用./journal add -e "被牙仙拜访,除了他是个来自布鲁克林的秃顶、啤酒肚的45岁男人",它应该仍然可以工作。

现在让我们创建journal.list命名空间。打开文件src/journal/list.clj,并在其中放入以下内容:

(ns journal.list
  (:require
    [journal.utils :as utils]))

(defn list-entries
  [_]
  (let [entries (utils/read-entries)]
    (doseq [{:keys [timestamp entry]} (reverse entries)]
    (println timestamp)
    (println entry "\n"))))

这没有格式化时间戳,但除此之外,它按照我们希望的逆时序列出了我们的条目。耶!

为了完成,我们需要将journal.list/list-entries添加到journal文件中的分派表。该文件现在应该看起来像这样:

#!/usr/bin/env bb

(require '[babashka.cli :as cli])
(require '[journal.add :as add])
(require '[journal.list :as list])

(def cli-opts
  {:entry {:alias :e
    :desc "你的梦境。"
    :require true}
    :timestamp {:alias :t
    :desc "你记录这个的unix时间戳。"
    :coerce {:timestamp :long}}})

(def table
  [{:cmds ["add"] :fn #(add/add-entry (:opts %)) :spec cli-opts}
    {:cmds ["list"] :fn #(list/list-entries %)}])

(cli/dispatch table *command-line-args*)

总结

  • 命名空间的工作方式类似于JVM Clojure和Clojurescript:命名空间名称必须对应文件系统结构。
  • 通过bb.edn中的map {:paths ["src"]} 来告知 Babashka 到哪里找自己的源代码

添加依赖项

你可以通过在bb.edn文件中添加:deps键来为你的项目添加依赖项,结果看起来像这样:

{:paths ["src"]
 :deps {medley/medley {:mvn/version "1.3.0"}}}

不过,Babashka的酷之处在于你也可以直接在脚本中或甚至在REPL中添加依赖项,像这样:

(require '[babashka.deps :as deps])
(deps/add-deps '{:deps {medley/medley {:mvn/version "1.3.0"}}})

这符合脚本语言的本质,避开繁文缛节, 直达目的.

到目前为止,你应该已经完全准备好开始用Babashka编写你自己的Clojure shell脚本了。哇哦!

在接下来的章节中,我将介绍Babashka的一些方面,这些方面你可能不会立即需要,但随着你对Clojure脚本的热爱日益浓厚,它们将对你非常有用,直到完全吸引你。

Pods

Babashka pods介绍了一种通过调用Clojure函数与外部进程交互的方式,使你能够编写看起来和感觉都像Clojure的代码(因为它就是),即使是在与你的Clojure应用程序外部的进程交互,甚至当该进程是用另一种语言编写的时候

让我们看看这在更具体的术语中意味着什么。假设你想加密你的梦日记。你发现了stash,“一个用于以加密形式存储文本数据的命令行程序。”这正是你需要的!但它是用Haskell编写的,而且它有一个终端用户界面(TUI)而不是命令行界面。

也就是说,当你从命令行运行stash时,它会在你的终端“绘制”一个ascii界面,你必须提供额外的输入来存储文本。你不能用像这样的命令直接从命令行存储文本:

stash store dreams.stash \
  --key 20221210092035 \
  --value "担心房子的基础出了问题,然后整个房子都陷入了一个不断扩大的
  沉降洞,直到吞没了整个邻居"

如果这是可能的,那么你可以在Bashka项目中使用babashka.process/shell函数像这样使用stash:

(require '[babashka.process :as bp])
(bp/shell "stash store dreams.stash --key 20221210092035 --value \"...\"")

bp/shell让你利用程序的命令行界面;但再次强调,stash并没有提供这样的功能。

然而,stash提供了一个pod接口,所以我们可以在Clojure文件中这样使用它:

(require '[babashka.pods :as pods])
(pods/load-pod 'rorokimdim/stash "0.3.1")
(require '[pod.rorokimdim.stash :as stash])

(stash/init {"encryption-key" "foo"
  "stash-path" "foo.stash"
  "create-stash-if-missing" true})

(stash/set 20221210092035 "dream entry")

让我们从最后一行(stash/set 20221210092035 "dream entry")开始。这就是pods的关键点:它们将外部进程的命令作为Clojure函数暴露出来。它们允许这些进程拥有Clojure界面,这样你就可以通过编写Clojure代码来与它们交互,而不是需要使用shell命令或进行HTTP调用之类的操作。

在下一节中,我将解释上面代码片段的其余部分

Pod实现

stash/set函数是从哪里来的?pod.rorokimdim.stash命名空间及其内部的函数是由调用(pods/load-pod 'rorokimdim/stash "0.3.1")动态生成的。

为了实现这一点,外部程序必须编写为支持pod协议。这里的“协议”并不是指Clojure协议,而是指交换信息的标准。你的Clojure应用程序和外部应用程序需要有某种方式进行相互通信,考虑到它们并不在同一个进程中运行,并且甚至可能是用不同的语言编写的。

通过实现pod协议,一个程序就成为了一个pod。这样做的好处是,它获得了告知客户端Clojure应用程序哪些命名空间和功能可用的能力。当客户端应用程序调用这些函数时,它会对数据进行编码并将其作为消息发送给pod。pod将被编写为能够监听这些消息,解码它们,内部执行所需的命令,并向客户端发送响应消息。

pod协议在pod GitHub仓库中有文档记录。

总结

Babashka的pod系统允许你使用Clojure函数与外部进程交互,而不是通过babashka.process/shell进行shell操作或进行HTTP请求等操作。

这些外部进程被称为pods,必须实现pod协议,以告知客户端程序如何与它们交互.

其他执行代码的方式

这个教程主要是帮助你构建一个独立的脚本,你可以像与典型的bash脚本一样与之交互:你通过chmod +x使其可执行,并像这样从命令行调用它 ./journal add -e "dream entry"

还有其他bash支持的shell脚本编写风格(如果可以这么说的话):

  • 直接表达式求值
  • 调用Clojure函数
  • 命名任务

直接表达式求值

你可以给Babashka一个Clojure表达式,它会求值并打印结果:

$ bb -e '(+ 1 2 3)'
9

$ bb -e '(map inc [1 2 3])'
(2 3 4)

我个人并没有太多使用这个功能,但如果你需要,它就在那里!

调用Clojure函数

如果我们想直接调用我们的journal.add/add-entry函数,我们可以这样做:

bb -x journal.add/add-entry --entry "dreamt of foo"

当你使用bb -x时,你可以指定一个函数的完全限定名,Babashka会调用它。它会使用babashka.cli将命令行参数解析为Clojure值,并将其传递给指定的函数。有关更多信息,请参阅Babashka文档中的-x部分.

你还可以使用bb -m some-namespace/some-function来调用函数。这与bb -x的不同之处在于,使用bb -m时,每个命令行参数都未经解析就直接传递给了Clojure函数。例如:

$ bb -m clojure.core/identity 99
"99"

$ bb -m clojure.core/identity "[99 100]"
"[99 100]"

$ bb -m clojure.core/identity 99 100
----- 错误 --------------------------------------------------------------------
类型:clojure.lang.ArityException
消息:传递给:clojure.core/identity的参数数目错误 (2)
位置:<表达式>:1:37

使用bb -m时,你只需传入一个命名空间,Babashka会调用该命名空间的-main函数。例如,如果我们想让我们的journal.add命名空间适用于这种调用方式,我们可以这样写:

(ns journal.add
  (:require
   [journal.utils :as utils]))

(defn -main
  [entry-text]
  (let [entries (utils/read-entries)]
   (spit utils/ENTRIES-LOCATION
   (conj entries
   {:timestamp (System/currentTimeMillis)
   :entry entry-text})))

我们可以这样做:

$ bb -m journal.add "recurring foo dream"

请注意,要使bb -xbb -m工作,你必须设置你的bb.edn文件,以便你调用的命名空间可在类路径上找到.

任务

运行命令行程序的另一种风格类似于调用makenpm。作为程序员,你可能在命令行运行过这些:

make install
npm build
npm run build
npm run dev

Babashka允许你以类似方式编写命令。对于我们的梦日记,我们可能想要在终端执行以下命令:

bb add -e "A monk told me the meaning of life. Woke up for got it."
bb list

我们将逐步构建这些功能。

基础任务

首先,我们来看一个非常基本的任务定义。任务在你的bb.edn文件中定义。更新你的文件以使其看起来如下:

{:tasks {welcome (println "welcome to your dream journal")}}

任务是在:tasks关键字下使用映射定义的。映射的每个键命名一个任务,并且应该是一个符号。每个值应该是一个Clojure表达式。在这个示例中,welcome命名了一个任务,相关联的表达式是(println "welcome to your dream journal")

当你调用bb welcome时,它会在:tasks下查找welcome键,并评估相关联的表达式。请注意,如果你想让它们发送到标准输出,你必须明确打印值;这将不会打印任何内容:

{:tasks {welcome "welcome to your dream journal"}}

如何为任务引入命名空间

假设你想创建一个任务来删除你的日记条目。它可能看起来像这样:

{:tasks {welcome (println "welcome to your dream journal")
         clear (shell "rm -rf entries.edn")}}

如果你运行bb clear,它将删除你的entries.edn文件。这有效是因为shell在命名空间中被自动引用,就像clojure.core函数一样。

如果你想以跨平台友好的方式删除文件,你可以使用babashka.fs/delete-if-exists函数。为此,你必须引入babashka.fs命名空间。你可能会假设可以像这样更新你的bb.edn文件并使其工作,但实际上不会:

{:tasks {clear (do (require '[babashka.fs :as fs])
                   (fs/delete-if-exists "entries.edn"))}}

相反,要引入命名空间,你必须这样做:

{:tasks {:requires ([babashka.fs :as fs])
         clear (fs/delete-if-exists "entries.edn")}}

使用exec解析参数并调用函数

我们仍然希望能够调用bb addbb list。我们有实现bb list所需的一切;我们可以像这样更新bb.edn:

{:paths ["src"]
 :tasks {:requires ([babashka.fs :as fs]
                    [journal.list :as list])
         clear (fs/delete-if-exists "entries.edn")
         list (list/list-entries nil)}}

在之前的任务示例中,我排除了:paths键,因为它不是必需的,但我们需要将其带回,以便Babashka能够在类路径上找到journal.list。journal.list/list-entries接受一个被忽略的参数,所以我们可以简单地传入nil,它就会工作。

但是,journal.add/add-entries需要一个包含:entries键的Clojure映射。因此,我们需要某种方式将命令行参数解析为该映射,然后将其传递给journal.add/add-entries。Babashka提供了exec函数来做到这一点。像这样更新你的bb.edn,一切应该都可以工作:

{:paths ["src"]
 :tasks {:requires ([babashka.fs :as fs]
                    [journal.list :as list])
         clear (fs/delete-if

-exists "entries.edn")
         list (list/list-entries nil)
         add  (exec 'journal.add/add-entry)}}

现在我们可以调用这个,它应该可以工作:

$ bb add --entry "dreamt I was done writing a tutorial. bliss"
$ bb list
1670718856173
dreamt I was done writing a tutorial. bliss

关键在于exec函数。使用(exec 'journal.add/add-entry),就好像你在命令行上调用了:

$ bb -x journal.add/add-entry --entry "dreamt I was done writing a tutorial. bliss"

exec将以与bb -x相同的方式解析命令行参数,并将结果传递给指定的函数,这个例子中是journal.add/add-entry。

任务依赖、并行任务等

Babashka的任务系统有更多功能,我不打算详细介绍,但你可以在Babashka文档的“任务运行器”部分阅读更多内容。

我确实想强调两个非常有用的特性:任务依赖和并行任务执行。

Babashka允许你定义任务依赖,这意味着你可以定义task-a依赖于task-b,这样如果你运行bb task-a,内部将根据需要执行task-b。这对创建编译脚本很有用。例如,如果你在构建一个Web应用,你可能有单独的任务来编译后端jar文件和前端JavaScript文件。你可以拥有build-backend、build-frontend任务,然后有一个依赖于其他两个的build任务。如果你调用bb build,Babashka将能够确定哪些其他任务需要运行,并且仅在必要时运行它们。

并行任务执行将让Babashka同时运行多个任务。在我们的构建示例中,bb build可以同时运行build-backend和build-frontend,这可能是一个真正的时间节省者。

总结

  • 在bb.edn下的:tasks键下定义任务。
  • 任务定义是键值对,键是命名任务的符号,值是Clojure表达式。
  • :tasks键下添加:requires键来引入命名空间。
  • exec执行函数就像用bb -x journal.add/add-entry调用一样;它在传递给函数之前解析命令行参数。
  • 你可以声明任务依赖。
  • 你可以并行运行任务.

其他资源

致谢

以下人员阅读了这份草稿并提供了反馈。感谢你们!

  • Michiel Borkent @borkdude
  • Marcela Poffalo
  • Gabriel Horner @cldwalker
  • @geraldodev
  • Andrew Patrick @Ajpatri
  • Alex Gravem @kartesus
  • Inge Solvoll @ingesol
  • @focaskater
  • @monkey1@fosstodon.org
  • Kira McLean

反馈

如果你有反馈,请在https://github.com/braveclojure/babooka开一个issue。我不能保证我会及时回应,甚至可能根本不会回应,所以我提前道歉!我只是不擅长回应,这是我的性格缺陷之一,但我很感激你的反馈!

Tags: clojure babashka