Clojure Reload

我使用 Clojure 三年了,作为脚本、后端服务以及界面描述语言,我用它构建了很多软件 —— 自底向上的,这些项目合起来的生产代码行数有 ~100 KLoc 之多。

每当闲时,总是试图为 Clojure 写点什么,但总是提笔又放下,次次搁置。它的乐趣,似在不言中:跨过函数式高山和 LISP 66 年的历史长河,穿越过标准库丛林和 Java 生态沼泽,这里的一切都是那么的不一样,哪怕是写了半辈子的非 LISP 系程序员,这里几乎没有任何的经验可以遵从,一切从头来。Clojure 的 Reddit 社区总为了吸纳新人而喋喋不休,但讨论来讨论去,最终的结论却是:这是一门注定小众的语言,第一次没有在它面前停留的人,或许永远不会体会到它的乐趣,而这乐趣又是无法言说的,因而错过便就永远错过了。

如果说上面的说法过于虚无缥缈,那么下面我将从实际的例子来 Clojure 的乐趣之一:开发体验。很多语言以软件工程和开发体验优先,而不更多的强调架构和学术性(比如 Java 和 Scala),这些语言往往受到开发者的欢迎,典型的比如 Rust 的生命周期、宏、异常处理、模式匹配和基于特质的现代面向对象系统,Kotlin 的协程、标准库、为 GUI 而生的语法糖以及扩展函数,Swift 令人洁癖的语法编译规则,为 SwiftUI 服务的包装器、宏系统、Actor 和 async/await 异步模型,甚至更极端的,Dart,一个专门为 Flutter 优化和进化的语言:可空类型、类构造器以及垃圾回收机制,以及 Flutter 自身毫秒级别的 WidgetTree 热更新。

Clojure 在其中独树一帜,其开发体验的根本在于 基于 S 表达式的函数式编程风格

几乎所有的解释型动态语言都有 REPL(Kotlin、Scala 等编译型语言也有,因为其本质可以通过 JVM 字节码解释运行),所谓 REPL,指的是 Read-Eval-Print Loop, 简单来说就是: 启动一个解释器,读取用户输入的表达式或语句,执行并打印出结果。这种机制可以很好的试验小片段代码,并观察其运行结果。REPL 并不罕见,浏览器控制台就是一个 JavaScript REPL,而 iPython 以及 Jupiter Notebook 则是 Python 的 REPL。

但尽管如此,REPL 的作用还是被大大低估了。曾经我在浏览器控制台上编写 JavaScript 代码时,一个前端同事还很惊讶的问我,为什么要在这里写程序?我觉得对于很多前端开发者,REPL 的使用频率可能如同这个小规模不严谨的观察所显示的那样,非常低。对于后端程序员而言,且不说 Scala 和 Kotlin,很多 Java 开发者甚至不清楚 JDK 中内置了一个叫做 JShell 的 REPL,在 IJ 中能够交互执行 Java 代码。

为什么?答案很简单,不流行的原因无外乎两点:门槛太高、收益太低。实际上手就能发现,Web 控制台中的 REPL 并不好用,尽管它已经代表了当前网页运行 V8 虚拟机,能够调用已经初始化的类库和接口,但假如我们想要试验一个新的日期库,这里不能直接用 ESM 标准导入模块,而仅仅支持 eval 执行字符串形式的 JavaScript 代码,这显然和大部分基于模块的工程化开发工作流不符合。JShell 的低采纳也是同样的原因。即便是当前环境已经有了所有加载好的 API 接口,在 REPL 执行代码的功用也少得可怜:我们想要执行的代码往往是非结构化,且带有上下文的多行指令,要单独将其拎出来运行所要付出的努力很大,状态如何构造?上下文如何创建?有这功夫还不如写一个单元测试来的方便。对于前端而言,则是直接将结果打印在 Web 页面上来测试代码更简单。

Clojure 的 REPL 体验则完全是另外一番景象,甚至可以说,Clojure 开发体验的核心在于 REPL。有大量的库,比如 nREPL 等,支持通过 Socket 远程连接到运行着 Clojure 的 JVM,动态执行代码,类似于一个 Java 世界中更加全能的 Arthas。这是靠什么做到的呢?答案是 Clojure 语言本身:一种 LISP,因为 LISP 基于 S 表达式,所以在 REPL 中执行代码块非常方便,由于大部分 Clojure 代码都是函数式风格的,因此函数基本就是对于状态的映射 —— 返回另外一个状态,因此执行起来非常方便,至于上下文,启动的 JVM 本身已经提供。就实际而言,在 VSCode 中处理 Clojure 工程的典型方式是:启动一个 nREPL,将代码和其依赖全部加载到这个 JVM 虚拟机,之后在任意页面,在任意光标位置,点按多次 Ctrl + W 选中当前需要执行的 S 表达式,Alt + Enter 执行并得到结果,如果想要将整个文件加载并解释,只需要一次快捷键即可。

使用过 Rust 的开发者往往会不由自主的写测试,因为写单元测试太方便了,只需要在编写好的函数周围添加另一个函数,为其添加 #[test] 宏,即可运行测试代码,而对于 Clojure 而言,单元测试也常常位于源代码文件中,以 (comment ) 的形式暴露,解释器不执行这个 S 表达式,但需要运行时,光标选中并直接发送到 REPL 执行即可,相比较测试需要每次启动,发送给 REPL 的代码总是执行在运行中的 JVM 虚拟机中,速度极快且状态得以保留,其方便程度相比 Jupyter Notebook 有过之而不及。

REPL 只是 Clojure 乐趣的表象,如果使用过 Clojure Web 框架或 ClojureScript Web 项目,你一定会惊叹于代码热更新的能力,远远超出 REPL 的地步:前/后端代码编辑后保存,界面/接口立刻所见即所得,哪怕项目再大也是如此。这是因为重载真正的核心,在于语言内建的 动态加载 机制。

Clojure 运行时的动态加载机制主要由如下三点构成:#’ 读入宏、defonce 定义以及 (require ns-sym :reload) 重新加载命名空间接口。简单来说,Clojure 中的变量和函数基于命名空间区分,每个命名空间对应一个 Clojure 文件,这些变量和函数作为运行时中的 Var 对象,被 Namespace 对象管理。在 Clojure 中传递通过 defn 创建的函数(或其他匿名函数),本质上是传递函数的 Var 对象引用,而传递由 def 绑定的变量,则本质是对 Var 对象解引用得到值复制。读入宏 #' 的本质,就是获取一个绑定变量的 Var 引用,而 defonce 区别于 defdefn 在于其保证只被初始化一次,在重新加载时不变。这两个设计是为了重载命名空间服务的,当通过 (require ns-sym :reload) 重新载入命名空间时,Clojure 会重新执行命名空间中的所有代码,由于 defonce 保证其只被初始化一次,且 #' 读入宏则保证其返回的引用始终是同一个 Var 对象。你能想象到这多有用吗?defonce 通常用于存放状态,读入宏读入的变量则用于保证当此变量的值重新求值而变化时,对其引用始终能够得到最新的值(区别于很多语言中的 getter,读取 getter 每次都会重新计算它)。实现这一切的方式并不难,读入宏本质就是一个间接指针而已,Clojure 以运行时轻微的代价实现了代码热加载,同时保留状态和引用变化代码值的能力。

考虑一下 Web 后端开发,对于 Node.js 而言,通常是通过本地文件变更监听重启进程的方式实现“代码热加载”,对于大型项目而言,它非常慢。Rust 也是如此,当本地文件变更,通过 cargo watch 重新编译并运行,尽管编译是增量的 DEBUG 包,但对于大型项目,这一过程仍旧十分痛苦。而 Clojure 采用了什么方式呢?以 Luminus 框架为例,通过 REPL 启动了 Web 和 REPL 服务器后,开发模式下,中间件会在每次请求时检查源代码文件夹中文件是否变化,如果变化则读取其命名空间定义,构建依赖树,并对所有需要重新加载的命名空间进行重新加载(借助于 ns-loader 和 ring-reload 库,核心代码如下所示)。

(ns ring.middleware.reload
  "Middleware that reloads modified namespaces on each request.
  This middleware should be limited to use in development environments."
  (:require [ns-tracker.core :refer [ns-tracker]]))

(defn- reloader [dirs retry?]
  (let [modified-namespaces (ns-tracker dirs)
        load-queue (java.util.concurrent.LinkedBlockingDeque.)]
    (fn []
      (locking load-queue
        (doseq [ns-sym (reverse (modified-namespaces))]
          (.push load-queue ns-sym))
        (loop []
          (when-let [ns-sym (.peek load-queue)]
            (if retry?
              (do (require ns-sym :reload) (.remove load-queue))
              (do (.remove load-queue) (require ns-sym :reload)))
            (recur)))))))

(defn wrap-reload
  ([handler]
   (wrap-reload handler {}))
  ([handler options]
   (let [reload! (reloader (:dirs options ["src"])
                           (:reload-compile-errors? options true))]
     (fn
       ([request]
        (reload!)
        (handler request))
       ([request respond raise]
        (reload!)
        (handler request respond raise))))))

对于实际业务而言,需要热更新的往往是业务代码,其通过路由访问控制器方法,如下所示的路由,其通过 #' 读入宏提供,每次 Http 请求都会 deref 这个 Var 引用并访问路由表,从而找到控制器方法并执行实际业务逻辑,当文件变更,wrap-reload 会在新请求发送时将修改的文件和命名空间重新加载,这里的 app-routes 路由表也会更新,而并未停机的 Http 服务器则只需要数 ms 的时间即可访问到更改后的代码。

(mount/defstate app-routes
  :start
  (ring/ring-handler
    (ring/router
      ["/api"
        {:coercion   spec-coercion/coercion
          :middleware [auth/wrap-basic-auth
                       middleware/wrap-reload
                       middleware/wrap-as-async]}
        ["" {:no-doc  true
              :swagger {:info {:title       "goods-api"
                              :description "https://mazhangjing.com"}}}
          ["/swagger.json"
          {:get (swagger/create-swagger-handler)}]

          ["/api-docs/*"
          {:get (swagger-ui/create-swagger-ui-handler
                  {:url    "/api/swagger.json"
                   :config {:validator-url nil}})}]]])))

(defn app []
  (middleware/wrap-base #'app-routes))

利用语言动态加载机制的 Web 框架,配合上 REPL,Clojure Web 后端的开发体验非常棒。大部分时候,开发者线上部署的时候,会留一个 nREPL 的端口,当需要定位 Bug 时直接连接到生产 JVM 虚拟机重载函数,添加日志打印,定位问题。当需要修复时,再次重载新的函数即可,而无需重启 JVM 虚拟机。当有新的功能整合,在不需要引入额外 jar 包依赖的情况下,编写的服务端代码直接 git pull 拉取并自动或手动重载即可注入字节码,Http 服务器在没有重启的情况下即可实现代码更新。这两者的结合真正体现了 Clojure 动态开发的能力,它完美契合项目的迭代进化,在我的经验中,上线项目往往在数月的时间里,进行大量的 Bug 修复和新功能合入,而服务却永不停机,始终在线。

对于 ClojureScript 的 Web 前端领域,社区在现有的 React.js 生态上结合 Clojure REPL 和动态加载机制实现了创新:如果更简单的 React 应用,使用 reagentatom 管理自己创建的状态,只需要通过 defonce 声明就能确保重载时不变化。对于大型项目,re-frame 提供了中心化的状态管理,类似于 Redux,其开发环境中的代码热更新机制和上面讲到的 Luminus 后端类似,这个中心化的状态本质是通过 defonce 创建并定义的,因此在重载代码时也不会状态丢失。下面的例子展示了一个 shadow-cljs 项目,开发环境重载后重新渲染了 React 组件树,注意 page 这个 Component Root 也是通过读入宏加载的,以便界面 UI 命名空间重新加载时,这里能够获取到最新数据。这里的 after-load 会告知 shadow-cljs,在每次有 reload 时都执行这里的代码,总和起来,这种设计使得状态不变的情况下,界面能够发生变化反映代码的更改。

(defonce ^:private state
         (r/atom {:show-recent   false
                  :show-change   false
                  :change-url    nil
                  :show-settings false
                  :show-ban      false
                  :ban-url       nil}))

(defn ^:dev/after-load mount-components []
  (rf/clear-subscription-cache!)
  (rdom/render [#'page] (.getElementById js/document "app")))

当然,这一切的设计本质是权衡和运行时性能损耗为代价的。正如同业界广为流传的一句话:每增加一层抽象,代码所反映的抽象和开发效率提升一个数量级,而运行效率则损失一个数量级。但我们欣喜地看到,得益于系统研发人员的努力,JVM 虚拟机通过 Hotspot 技术极大的降低了层级的性能损耗,以至于 Clojure 代码的运行性能和 Java 代码相差无几,而经过 JIT 编译的 Java 代码性能也非常出色。

尽管如此,差距依旧存在,在我实验的一个读取双 SQLite 数据库,一个做鉴权,一个做数据查询的例子中,基于 Actix 的 Rust 二进制 8MB,异步运行时单线程基线内存为 3MB,相比较之下,Luminus 框架的 Jar 包 40MB,加上 JRE 400MB,默认 Java 堆配置的基线占用内存为 300MB,至于运行速度,Clojure 大概能在 10ms 完成 SQL 查询并返回结果,而 Rust 只需要 6ms 左右。当然,这不是一个严谨的测试,Rust 手动内存管理和 0 拷贝的所有权数据的确提升了运行时性能,并带来了内存占用的亮眼表现,但 Clojure 也已经足够的好,毕竟,我在 30 分钟内实现了 Luminus 框架的自定义鉴权和数据读取,而 Rust 则用了 4 个小时。

虽然 Rust Web 后端目前体验已经很成熟,基于泛型的数据转换、提取、依赖注入非常方便,一点也不逊色于 Java 生态的 Sping Boot,但这本质上,只是一种风格偏好和抉择,每种语言都是图灵等价的实现,但一些确实比另一些更有乐趣,或者说拥有更高的开发生产力。

Clojure 官方给出的 Clojure 特性包含如下六点:

  • 动态开发
  • 函数式编程风格
  • LISP
  • 运行时多态
  • 并发编程
  • JVM 生态

本文主要介绍了前三点,它们是 Clojure 乐趣的核心体现。至于运行时多态、CSP、Actor 和 Futures 等并发编程模式以及不可变数据结构的内建支持,以及 JVM 丰富的生态,则是 Clojure 优势的另一面 —— 工程能力,毕竟真正的效率不仅在于自己写的有多好,还在于能够使用的工具和库有多少。

退一步讲,如果 Java 衰落了,Clojure 还会好吗?当然。由于 LISP 解释器实现起来太过于简单,实际上,有很多严肃和生产级别的 Clojure 运行时,比如基于 .Net 的 ClojureCLR, 基于 GraalVM SVM 的 babashka, 基于 Google Closure 编译到 JavaScript 的 ClojureScript, 编译到 Dart 的 ClojureDart,编译到 C++ 的 jank, 编译到 C 的 ClojureC, 编译到 ObjectC 的 ClojureM, 编译到 Go 的 gojure 。从这个角度讲,作为编程语言和生态的 LISP 已死,但亦然,作为抽象层和低层级语言交互界面的 LISP 也实现了永生。