谈谈 Clojure/Script

技术没有“银弹”,即便是再趁手的模型或工具,也偷偷的在暗中标好了价码,只待使用者在某个窘迫的场景下无奈的发现或默默接受它。

我使用 Clojure/Script 重构了一个服务自己的 Go 写的 Web 服务,花了一周左右,代码量从 1w 行缩减到 3k 行,表达能力和可扩展性以及稳定性都有了大幅度的提升,这是表象。

这个 Go 服务主要运行一些后台进程,用于同步我的上班工时,Microsoft TODO 待办,查询快递、美剧等更新并且推送到 Slack 上,每天检测 Apple Watch 和 iOS 健康的运动和静息、饮食卡路里消耗并且提供建议。Go 服务没有 Web 前端,所有输入性的交互由 iOS 的快捷指令 App 负责,所有展示性的信息通过 Scriptable 的 iOS 桌面小组件展示,其他消息则通过 Slack 通过 Apple Message Service Push 到 iPhone 和 Watch 上。

这个 Go 程序基于“敏捷”开发,从小到大一步步成长起来的:开始以来内存缓存,后来则是文件系统缓存,最后是 Redis 持久化。因为从 JVM 转到 Go 后最开心的事情莫过于摆脱了极其缓慢的虚拟机预热,因此每次往程序添加功能,都会通过脚本自动触发 Webhook Push 代码自己杀掉进程再重新运行,这套简陋的 CD 体系以及 CI(边开发边使用,有 BUG 直接远程 Debug 并且修复)保证了程序“健壮”的运行 —— 直到无法从中抽离开业务和 Web 模块/参数/数据结构,难以让不同的业务正交并且提供统一的服务,因此便基于 Clojure/Script 进行了重构。

基于 Clojure,这个程序不仅很快的(Go 版本的服务大概写了七八个月,Clojure 重构花费了一周左右的时间,大概有 30 个左右的 REST API,当然大头是和第三方系统的后台同步)。在我的经验里,使用通用的框架和技术,完成相同的业务功能,Go 要经历 5 次左右的大规模重构,而 Java 可能需要 3 次,Clojure 几乎不需要任何大规模改动,这是拜 Lisp 极其丰富的语言表达力实现的。

好的:ClojureScript 面向事件的模型

此外,在 3000 行 Clojure 后,我又用了 4000 行左右的前端 ClojureScript 代码实现了所有功能的前端界面,将其体现在一个 Dashboard 控制面板上,同时整合了物品管理、日记记录等模块。Clojure 的后端使用 PostgreSQL 作为持久化和事务工具,前端则面向基于事件的编程方式,基于数行事件定义即可实现一个交互闭环:从表单到验证,从 AJAX 到返回错误提示、正确保存到内存数据库并供 re-frame 订阅展示。一个类似的例子是 HTMX,2021 年出现的一种试图干掉 JS 并且面向超文本编程的 AJAX 模型(广受类似于 Python 等动态的面向单语言的微码农追捧)。不过我们在这里实现的基于事件的模型扩展性更强,可以随意整合 npm 生态组件,同时开发效率和开发体验也更好。

在下面的例子里,dialog form 提供了表单,当点击确定后,执行 validate! 验证,失败则提示用户,成功则触发 express-add 事件,此事件会触发 /cyber/express/track?no=xx 的 AJAX 请求,此请求的返回数据保存在 express-data 中,dialog 订阅了此 express-data,因此收到数据后会展示出来,AJAX 还提供了一个 express-data-clean 的事件,其在 dialog 点击 x 关闭或者成功后关闭时触发,用于清空 express-data 数据,如果成功,关闭模态框的同时还将触发 recent 事件,其将重新加载仪表盘以反映最新更改。最后,这个模态框的 id 是 :add-express!,其可以通过事件 [:show-model :add-express!] 打开。这里的代码完全反映了这里的描述,没有甚至一个逗号和标点的冗余。

(defn express-add-dialog
  []
  (dialog :add-express!
          "添加追踪快递"
          [[:no "编号 *" "顺丰快递需要在末尾输入 :xxxx 收货人手机号后四位"]
           [:note "备注 *" "此快递的别名"]]
          "确定"
          #(if-let [err (va/validate! @%1 [[:no va/required] [:note va/required]])]
             (reset! %2 err)
             (rf/dispatch [:express/express-add @%1]))
          {:subscribe-ajax    [:express/express-data]
           :call-when-exit    [[:express/express-data-clean]]
           :call-when-success [[:express/express-data-clean]
                               [:dashboard/recent]]}))

(ajax-flow {:call   :express/express-add
            :uri-fn #(str "/cyber/express/track?no=" (:no %) "&note=" (:note %))
            :data   :express/express-data
            :clean  :express/express-data-clean})

中性:Clojure 类型系统之痛

我对 Clojure 整体感到满意,不过确实也有一些小小的不足需要讨论。其一就是参数和返回值缺失类型的问题,尽管有很多途径来弥补,比如使用内置的参数和返回值校验函数,此外 spec 也是很好的处理方法。在我的项目中,REST API 参数类型我都通过 spec 进行了校验,其带来了三大好处:避免不合法数据流入,提供 Swagger 集成,以及自动完成输入数据类型的转换。但大部分时候,参数和返回值类型的缺失使用 spec 有些小题大做,尤其是内部函数的情况下,这时候文档就显得尤其重要 —— 实际上,因为 Clojure 代码如此紧凑和抽象,以至于写文档是一件几乎不得不做的事,大部分情况下,文档甚至可能比代码还要长,比如描述一个业务。但我依然遇到了不少和类型相关的问题:PostgreSQL jsonb 字段返回 String 类型的数字;一个叫做 now 的参数在不同地方可能是 LocalDateTime 或者 LocalDate,而它们的方法不尽相同导致的找不到方法;在一个 1000 行代码的文件中若干个函数合并和重构时 IDE 对类型支持力度差导致重构复杂的问题

(defn handle-dashboard
  "返回前端大屏显示用数据,包括每日 Blue 和 Blue 计数、每日 Fitness 活动、静息和总目标卡路里
  每日 Clean 和 Clean 计数,每日 TODO 列表、正在追踪的快递、正在追踪的美剧,今日自评得分
  以及一个方便生成本周表现的积分系统,其包含了最近一周每天的数据,格式为:
  :blue {UpdateTime IsTodayBlue WeekBlueCount MonthBlueCount}
  :fitness {:active 200 :rest 1000 :diet 300 :goal-active 500}
  :clean {MorningBrushTeeth NightBrushTeeth MorningCleanFace
          NightCleanFace HabitCountUntilNow HabitHint}
  :todo {:2022-03-01 [{title list create_at modified_at
                       due_at finish_at status(finished,notStarted.)
                       importance}]}
  :movie [{name url data(更新列表) last_update}]
  :express [{id name status(0不追踪1追踪) last_update info(最后更新路由)}]
  :work {:NeedWork :OffWork :NeedMorningCheck :WorkHour :SignIn{:source :time}
         :Policy{:exist :pending :success :failed :policy-count}}
  :today 98
  :score {:2022-03-01
           {:blue true
            :fitness {:rest 2000 :active 300 :diet 300}
            :todo {:total 27 :finished 27}
            :clean {:m1xx :m2xx :n1xx :n2xx}
            :today 99}}"
  [{:keys [day] :or {day 7}}]
  (try
    (let [all-week-day (mapv (comp keyword str) (tool/all-week-day))
          today (keyword (tool/today-str))
          ;每一个子项都是 {:2022-03-01 xxx}
          ;要合并为 {:2022-03-01 {:blue xxx}}
          blue-week (clean/handle-blue-week)
          score-week (clean/handle-score-week)
          clean-week (clean/handle-clean-week)
          fitness-week (fitness/week-active)
          todo-week (todo/handle-week-static)
          ; 返回的所有数据
          data {:blue    (clean/handle-blue-show)
                :fitness (fitness/today-active)
                :clean   (clean/handle-clean-show {})
                :todo    (todo/handle-recent {:day day})
                :express (express/recent-express)
                :movie   (mini4k/recent-update {:day day})
                :work    (assoc (get-hcm-hint {})
                           :Policy (policy-oneday (local-date)))
                :today   (get score-week today)
                :score   (reduce #(assoc % (keyword %2)
                                           {:blue    (get blue-week %2)
                                            :today   (get score-week %2)
                                            :clean   (get clean-week %2)
                                            :fitness (get fitness-week %2)
                                            :todo    (get todo-week %2 [])})
                                 {} all-week-day)}]
      {:message "获取数据成功!" :status 1 :data (dashboard-set-marvel data)})
    (catch Exception e
      {:message (str "获取大屏信息失败!" (.getMessage e)) :status 0})))

不过,介于当下“业务代码”已经变成一种几乎没有成本的极其随意的文本,浸淫在 Scala 的类型体操中,操纵 IDE 转半天圈圈都 resolve 不了代码提示的 monid 并且费力拼装数据管道而完全不关心开发效率和执行效率的伪高端码农,而或在象牙塔中幻想着构建静态类型金字塔的代码帝国的 Java/Kotlin 开发者已经几乎完全脱离了时代。

在我有限的经验中,第一次逃离 Python 的原因是类型,而第二次逃离 Scala 的原因还是类型。静态类型是极其迷人的围城,得不到的人想进去,而备受折磨的人则想出来,没有人真的开心。类型缺失影响最大的是 IDE 的重构,总的来说在大部分业务场景下,其缺点大于优点,有了类型的铠甲会严重损害敏捷性,而离开了类型则完全不意味着更低的代码水平和重构能力,其原因在于两者:重构不应当以类型作为保证,而应该以测试进行兜底。类型系统和面向对象带来的优点:IDE 连带方法名、一键修改 100 万行代码库中同一函数参数和名称的优势远远没有其造成的困境多:无穷尽的空指针异常,难以理解的时空状态耦合,不易测试。我的 Go 经验表明,哪怕是一门静态类型语言,充斥着状态和业务耦合的时候,哪怕具有完整的测试和类型约束,重构也是白日做梦。更何况,大部分业务代码在具备重构的价值之前,就已经被扫进了垃圾堆

因此,我非常不认同动态一时爽,重构火葬场的说法,任何经历过动态语言之觞的开发者要么没有意识到测试对于重构的核心价值,要么将充斥着状态和可变性的垃圾代码和怂恿开发者这么做的非系统级用途(是的,我可以认同 C 的指针,C++ 的状态,但是不能理解一个应用级的胶水语言 Python 也是如此)的三流程序设计语言的罪过强加给其动态的特性。

得益于 LISP 的动态性和 Clojure 的不可变性,单元测试非常容易进行且覆盖更多边界场景。with-redefs 可以重新定义测试函数内部使用的其他函数,包括数据库 DAO(很可惜的是,不能是宏或者 Java 类静态方法)。而介于世界上最大的 CI/CD 平台 Circle CI 和 Clojure 的密切关系以及低廉价格,因此在每次提交时都可以进行快速的测试以避免 BUG(当然,CD 是不可能的,ClojureScript release 的速度非常缓慢,且 Clojure 打出来的包如果是 fatjar 大小也非常可观)。

(testing "auto-today-info-check with morning need check finished hcm failed"
    ;早晨自动检查,当前策略已完毕,但失败,通知并更新数据库 check 标记
    (let [a (atom {})
          s #(swap! a assoc %1 %2)
          y #(= (get @a %1) %2)
          n #(not= (get @a %1) %2)]
      (with-redefs [inspur/local-time #(LocalTime/of 10 0)
                    inspur/local-date-time #(LocalDateTime/of 2022 03 03 10 0)
                    inspur/local-date #(LocalDate/of 2022 03 03)
                    inspur/get-hcm-info (fn [& _] (s :get-hcm :true) [])
                    inspur/signin-data (fn [& _] (s :signin-data :true) [])
                    inspur/signin-hint (fn [& _] (s :signin-hint :true)
                                         {:needMorningCheck true :offWork false})
                    slack/notify (fn [& _] (s :notice :true))]
        (let [now (inspur/local-time) now-dt (inspur/local-date-time)
              r1start (.plusMinutes now 1) r1end (.plusMinutes now 100)]
          (with-redefs
            [db/get-today-auto
             (fn [& _]
               (s :fetch-today :true)
               {:r1start r1start :r1end r1end
                :r2start (LocalTime/of 17 30) :r2end (LocalTime/of 18 30)
                :info    {:check               [{:cost   600
                                                 :start  (str (.minusSeconds now-dt 700))
                                                 :status "ready!"}]
                          :mark-morning-failed nil :mark-night-failed nil}})
             db/update-auto-info (fn [{:keys [info]}]
                                   (s :update-info (-> info :check first :status)))]
            (let [_ (inspur/auto-today-info-check!)
                  _ (println @a)]
              (is (y :fetch-today :true)) (is (y :get-hcm :true))
              (is (y :signin-data :true)) (is (y :signin-hint :true))
              (is (y :notice :true)) (is (y :update-info "failed!"))))))))

坏的:不可变数据结构是最好的选择吗?

不可变数据结构在大部分时候是有助于效率和可扩展性的,但是在某些情况下也会遇到问题。比如我有一个日志重整业务,其需要读取大概 10w 行的日志并且截取某一时间段范围,然后将每行日志和若干个正则表达式匹配,如果匹配上,则添加到输出结果。

我很快的写了一个读取文件并截取范围的程序,因为这里的读写效率足够高,大概 50ms 内能完成,本着不过早优化的原则,这里并没有改为流式的 readLine 并且根据日期过滤,将符合日期的按照一定大小进行批量异步正则匹配,然后将结果收集起来的做法。line-seq 在这里返回惰性行序列,drop-while 丢弃了所有开头不是已经小于目标日期字符串的行,take-while 摘取所有不是开头大于目标日期字符串的行 —— 包括非日期开头的行。这里的典型大小是 600 行左右的数据。

(let [range time-range]
     (with-open [f (io/reader file)]
       (reduce conj []
               (->> (line-seq f)
                    (drop-while #(not-start % range))
                    (take-while (comp not #(after-end % range)))))))

而真正挑战的是正则匹配,600 行匹配 100 个正则表达式,60000 * 每个表达式 50ms 的速度 = 300000ms,足够的慢。为了提高效率,将属于同一个类的日志的正则表达式分组,类字符串作为 hashmap key,正则表达式作为 hashmap 的 value,对每行日志先匹配类字符串,如果满足再进行正则表达式匹配,如果匹配到再输出结果。经过优化后大概可以在 3s 内输出结果。

(def mapper
  {"IceNeutronNetworkChangeListener"
   [{:re    #"([0-9-T,:]+).*?IceNeutronNetworkChangeListener.*?SNAPSHOT.*?\|\s(WRITE Network.*?)$"
     :note  "网络:发生变更 %s,开始执行业务序列"
     :level [:network :debug]}]
   "IceNeutronNetworkUtils"
   [{:re    #"([0-9-T,:]+).*?IceNeutronNetworkUtils.*?SNAPSHOT.*?\|\sAdding Network:Network\{getName=(.*?),.*?getTenantId=Uuid \[_value=(\w+).*?getUuid=Uuid \[_value=(\w+).*?isShared=(\w+).*?isExternal=(\w+).*?$"
     :note  "网络:新建网络信息:\uD83C\uDF0D%1$s#%3$s,租户 %2$s,共享?%4$s,外部?%5$s"
     :level [:network]}
    {:re    #"([0-9-T,:]+).*?IceNeutronNetworkUtils.*?SNAPSHOT.*?\|\shandle flat/vlan external network.*?$"
     :note  "网络:正在处理 flat/vlan 外部网络"
     :level [:network :external-network]}]})

为了进一步提升效率,我开始尝试了 reducers 库的 fold 函数并行计算,如下所示,need-keys 是上述 mapper 的 keys,如果匹配类名则进行 re-find,找到则输出结果。read-log 读取截断的日志行。使用 6 核心,下面代码在一个日志上使用了 500ms 完成任务,这里创建了 6 个 ForkJoinPool 并且不断的 submit 新任务,通过 JFR 可以清楚地看出其并行调用堆栈。

(r/fold
    (.availableProcessors (Runtime/getRuntime))
    into                                                ;comb each core
    (fn ([agg line]                                     ;reduce each line
            (let [sharped-line
                (if-let [key (some #(if (str/includes? line %) % nil) need-keys)]
                    ;find regex group first
                    (some (fn [{:keys [re note level]}]
                            (if-let [[_ time & data] (re-find re line)]
                            {:inst    time
                            :level   level
                            :line    line
                            :sharped (apply format note data)}))
                        (get mapper key)))]              ;each regex do match test
            (if sharped-line (conj agg sharped-line) agg))))
    (time (read-log file-name time-range)))

为了进一步提升效率,我开始将输入行分组,一次性将 100 - 200 条日志扔给线程,而在每个线程内部,使用一个内部的 clojure.core/reduce 进行处理合并结果。我以为频繁提交数据给线程池的损耗要远高于多条数据 reduce 的损耗,但是下面代码在同样基准下速度直接降了 1 倍,达到 1000ms。

(r/fold
    (.availableProcessors (Runtime/getRuntime))
    into                                                ;comb each core
    (fn ([agg-thread lines]                             ;reduce each line
            (into agg-thread
                (reduce
                    (fn [agg line]                         ;reduce each line
                    (let [sharped-line
                            (if-let [key (some #(if (str/includes? line %) % nil) need-keys)]
                            ;find regex group first
                            (some (fn [{:keys [re note level]}]
                                    (if-let [[_ time & data] (re-find re line)]
                                        {:inst    time
                                        :level   level
                                        :line    line
                                        :sharped (apply format note data)}))
                                    (get mapper key)))]    ;each regex do match test
                        (if sharped-line (conj agg sharped-line) agg)))
                    [] lines))))
    (time (let [logs (read-log file-name time-range)
                each (/ (count logs) 30)]
            (partition-all each logs))))

我又尝试了 pmap 并行,开始同样的是外部 pmap,内部 reduce,结果一样,效果很差。然后尝试了直接 pmap 后外部 reduce into 合并,效果一样很差,有 500ms 的耗时。但是,最后尝试了 pmap 外部 flatten,现在速度可以达到 100ms,经过 JVM HotSpot 优化后,速度可以达到 60ms,哪怕是在 3 核心的机器上也是如此。

(flatten
    (pmap (fn ([lines]                                  ;reduce each thread
            (reduce
                (fn [agg line]                         ;reduce each line
                (let [sharped-line
                    (if-let [key (some #(if (str/includes? line %) % nil) need-keys)]
                    ;find regex group first
                    (some (fn [{:keys [re note level]}]
                            (if-let [[_ time & data] (re-find re line)]
                                {:inst    time
                                :level   level
                                :line    line
                                :sharped (apply format note data)}))
                            (get mapper key)))]    ;each regex do match test
                    (if sharped-line (conj agg sharped-line) agg)))
                [] lines)))
            (time (let [logs (read-log file-name time-range)
                        each (/ (count logs) 30)]
                    (partition-all each logs)))))

对比 flatten 和 reduce,可以很明显的看到,多次 reduce 时不可变数据结构频繁的合并导致了不小的性能问题,当数据量一大更是如此。虽然 Clojure 提供了暂态,并且我们可以随时回滚 Clojure 到 Java 数据结构和 Java 代码,因此性能优化不会是 Clojure 的问题,但不管怎样,不可变数据结构导致的性能可能成为 Clojure 的问题。HttpKit,Storm 等 Clojure 类库在性能敏感的场合都换用了 Java 就很能说明问题。但是也同样要正视不可变数据结构,其避免了状态,代码更容易理解和重构,更容易测试,BUG 更少,更健壮。在某些场合下,比如早期可变状态的 React 性能甚至还没有 React Wrapper - Reagent 优秀,同样是拜不可变数据结构的功劳。

C 基于指针和可变数组实现的快速/希尔排序很漂亮,这并不影响人们对于 Scheme 通过递归求解斐波那契数列/帕斯卡三角形的赞美。总的来说,Clojure 虽然得名于 Closure 之于 JVM,但它却比 Scala(得名于 Scalable 可扩展的语言) 更像一个不用忍受编译速度痛苦的 Scalable 的 JVM 语言。