Ubuntu TechHive
save-tokens-and-build-with-data-driven-clarity-using-clojure-script-part.md
使用 Clojure(Script) 节省 Token 并以数据驱动的清晰度进行构建
article.细节

使用 Clojure(Script) 节省 Token 并以数据驱动的清晰度进行构建

reading.进展 8 分钟阅读数

使用 Clojure(Script) 节省 Token 并以数据驱动的清晰度进行构建

简短故事:Clojure(Script) 与 Web

从 21 世纪初至今

  • 学过 Vim 和 Emacs,但两个都用得很烂
  • HTTP 和 Web
  • 从 LAMP 到 SPA 以及更多的框架
  • 大量的脚本编写和元编程(JavaScript, Ruby, Groovy, Scala 等)
  • JavaScript 从玩具变成了承载核心业务的基石

LISP

  • 括号多到数不清
  • 既迷人又怪异
  • 大神们写了各种书(《Practical Common Lisp》、《SICP》等)
  • 听过很多关于它的疯狂故事(PG, HN, 《黑客与画家》等)
  • 各种 LISP 实现都非常奇特

实用的 LISP

  • 2009 年左右在一次 Java 会议上发现了 Clojure
  • 因为 JVM 的存在,终于有了一个值得尝试的相关技术
  • 第一次认真尝试:在 Spring 应用中结合 Java 接口与 Clojure 实现
  • 情况变得更好:基于 JavaScript 的新 Clojure(Google Closure Library,Google Closure Compiler:生产级的激进压缩)
  • 渐进式启蒙:(动词 数据)

Clojure(Script):清晰思考的工具

  • REPL(交互式开发环境)
  • 语言适应领域,而非领域适应语言
  • 语法?什么语法?一切皆为数据结构
  • 同构(Isomorphic)
  • Clojure 是一门宿主语言(JVM, JavaScript, Dart 等)
  • 去除冗余,直击本质
  • 规范 (Specs) + 函数 + 数据 + 验证:(带规范的动词 数据)

Clojure(Script) 基础

以下每个代码块都经过处理,你可以直接将其粘贴到 ClojureScript REPL 中进行现场演示。
在 Lisp 语法中,左括号后的第一个元素即为函数:

(+ 1 2 3)
;; => 6

(str "Hello, " "ClojureScript")
;; => "Hello, ClojureScript"

数据字面量:值优先

Clojure(Script) 代码主要由简单的数据形态构成:

42                         ;; 数字
"Ada"                      ;; 字符串
:admin                     ;; 关键字,常作为数据标签
[1 2 3]                    ;; 向量,有序值
{:name "Ada" :visits 1}    ;; 映射(Map),键值对
#{:cljs :repl :data}        ;; 集合(Set),唯一值

命名空间与 require

在 REPL 中,使用 require 加载命名空间并为其指定简短别名。
实际文件中通常在顶部使用 ns 表单,但 require 对于现场 REPL 演示更友好。

(require '[clojure.string :as str])

(str/upper-case "fast twitch")
;; => "FAST TWITCH"

(str/trim "  hello  ")
;; => "hello"

关键字也可以像微型查找函数一样使用:

(def user {:name "Ada" :visits 1})

(:name user)
;; => "Ada"

(assoc user :active? true)
;; => {:name "Ada", :visits 1, :active? true}

(update user :visits inc)
;; => {:name "Ada", :visits 2}

键与命名空间键

映射的键是用于查找值的标签。关键字是常用的映射键,因为它们短小、易读,且能作为查找函数使用。

(def response
  {:status 200
   :body "ok"})

(get response :status)
;; => 200

(:body response)
;; => "ok"

命名空间关键字在斜杠前添加了前缀。它们本质上仍然是关键字,但前缀有助于在大型数据映射中避免冲突。

(def person
  {:person/name "Ada"
   :account/name "ada-lovelace"
   :account/id 42})

(:person/name person)
;; => "Ada"

(:account/name person)
;; => "ada-lovelace"

(select-keys person [:person/name :account/id])
;; => {:person/name "Ada", :account/id 42}

你可以检查关键字的命名空间部分和名称部分:

(namespace :account/id)
;; => "account"

(name :account/id)
;; => "id"

(keyword "account" "email")
;; => :account/email

函数

defn 用于给函数命名。名称后的向量是参数列表。

(defn greet [name]
  (str "Hello, " name "!"))

(greet "Ada")
;; => "Hello, Ada!"

当函数较小且仅在局部使用时,匿名函数非常有用:

((fn [x] (* x 10)) 7)
;; => 70

(#(* % 10) 7)
;; => 70

多元函数

同一个函数名可以支持不同数量的参数。

(defn greeting
  ([] (greeting "friend"))
  ([name] (str "Hello, " name))
  ([title name] (str "Hello, " title " " name)))

(greeting)
;; => "Hello, friend"

(greeting "Ada")
;; => "Hello, Ada"

(greeting "Dr." "Hopper")
;; => "Hello, Dr. Hopper"

这使得简单的默认值可以紧邻完整版本定义。

map

map 对集合中的每一项运行一个函数。它返回一个惰性序列,因此如果你想在 REPL 中看到向量,请使用 vec

(map inc [1 2 3])
;; => (2 3 4)

(vec (map inc [1 2 3]))
;; => [2 3 4]

(def todos
  [{:title "Read the schema" :done? true}
   {:title "Try the REPL" :done? false}])

(map :title todos)
;; => ("Read the schema" "Try the REPL")

->

线程优先宏 -> 将前一个结果作为第一个数据参数放入下一个表单中。它使从左到右的数据处理逻辑更易于阅读。

(-> {:name "Ada" :visits 1}
    (assoc :active? true)
    (update :visits inc))
;; => {:name "Ada", :visits 2, :active? true}

读作:从这个 map 开始,然后关联一个值,再更新一个值。

->>

线程最后宏 ->> 将前一个结果放在下一个表单的末尾。这在集合处理管道中很常见。

(def todos
  [{:title "Read the schema" :done? true}
   {:title "Try the REPL" :done? false}])

(->> todos
     (filter :done?)
     (map :title)
     vec)
;; => ["Read the schema"]

读作:获取 todos,保留已完成项,提取它们的标题,生成一个向量。

使用 let 定义局部名称

let 为值赋予临时名称。这些名称仅在 let 块内部有效。

(let [title "Try the REPL"
      done? false]
  {:title title
   :status (if done? "done" "open")})
;; => {:title "Try the REPL", :status "open"}

解构 (Destructuring)

解构无需手动调用 get 即可从映射和向量中提取名称。

(def user {:name "Ada" :visits 1})

(let [{:keys [name visits]} user]
  (str name " visited " visits " time(s)"))
;; => "Ada visited 1 time(s)"

(let [ [first-item second-item & remaining] ["a" "b" "c" "d"]]
  {:first first-item
   :second second-item
   :remaining remaining})
;; => {:first "a", :second "b", :remaining ("c" "d")}

这对于请求映射(request maps)特别方便:

(def request
  {:request-method :get
   :params {:id "42"}
   :headers {"accept" "application/json"}})

(let [{:keys [request-method params]} request
      {:keys [id]} params]
  {:method request-method
   :todo-id id})
;; => {:method :get, :todo-id "42"}

if, when, if-letwhen-let

if 在两个值之间进行选择。在 Clojure(Script) 中,只有 falsenil 为假;0"" 和空集合均为真。

(if (:done? {:title "Read" :done? true})
  "Done"
  "Open")
;; => "Done"

(if 0 "yes" "no")
;; => "yes"

when 用于“仅当测试为真时执行此操作”;否则返回 nil

(when (seq "Ada")
  "There is a name")
;; => "There is a name"

(when (seq "")
  "This will not appear")
;; => nil

if-letwhen-let 仅在值非假时绑定名称。这种模式常出现在路由匹配、Cookie 处理和浏览器渲染中。

(def request-with-user {:session {:user "Ada"}})

(if-let [user (get-in request-with-user [:session :user])]
  (str "Signed in as " user)
  "Please log in")
;; => "Signed in as Ada"

(when-let [title (get-in {:params {:todo {:title "Ship demo"}}}
                         [:params :todo :title])]
  (str "Todo: " title))
;; => "Todo: Ship demo"

cond, casecondp

当有多个测试条件时使用 cond。它会按顺序检查。

(defn todo-status [{:keys [done? blocked?]}]
  (cond
    blocked? "blocked"
    done? "done"
    :else "open"))

(todo-status {:done? false :blocked? true})
;; => "blocked"

当一个值与已知常量进行比较时,使用 case

(defn method-action [request-method]
  (case request-method
    :get "read"
    :post "write"
    :delete "remove"
    "unknown"))

(method-action :post)
;; => "write"

condp 在此仓库中不太常用,但当每个分支使用相同的谓词时非常有用。

(defn score-band [score]
  (condp <= score
    90 :great
    75 :solid
    :needs-practice))

(score-band 82)
;; => :solid

cond->

cond-> 从一个值开始,仅当测试为真时才应用每一步。本项目使用它来构建请求映射、头部信息和中间件配置。

(defn response-map [{:keys [body location]}]
  (cond-> {:status 200
           :headers {}}
    body
    (assoc :body body)

    location
    (assoc-in [:headers "Location"] location)))

(response-map {:body "ok" :location "/todos"})
;; => {:status 200, :headers {"Location" "/todos"}, :body "ok"}

some->

some-> 类似于 ->,但一旦某一步返回 nil,它就会停止并返回 nil。这在数据可能缺失时非常有用。

(some-> {:pathname {:groups {:id "42"}}}
        :pathname
        :groups
        :id)
;; => "42"

(some-> nil
        :pathname
        :groups
        :id)
;; => nil

嵌套映射:get-in, assoc-inupdate-in

请求映射和响应映射通常是嵌套的。这些辅助函数避免了手动层层挖掘。

(def response {:status 200 :headers {}})

(assoc-in response [:headers "Content-Type"] "text/plain")
;; => {:status 200, :headers {"Content-Type" "text/plain"}}

(get-in {:params {:todo {:title "Learn get-in"}}}
        [:params :todo :title])
;; => "Learn get-in"

(update-in {:todos {1 {:done? false}}}
           [:todos 1 :done?]
           not)
;; => {:todos {1 {:done? true}}}

使用 atom, @, swap!reset! 管理状态

大多数数据是不可变的。当应用需要更改状态时,本仓库使用 atom,例如用于待办事项、计数器和浏览器 UI 状态。

(def store
  (atom {:clicks 0
         :chips []}))

@store
;; => {:clicks 0, :chips []}

(swap! store update :clicks inc)
;; => {:clicks 1, :chips []}

(swap! store update :chips conj "REPL")
;; => {:clicks 1, :chips ["REPL"]}

(reset! store {:clicks 0 :chips []})
;; => {:clicks 0, :chips []}

在源文件中,本仓库常使用 defonce 来定义浏览器或服务器状态。defonce 的意思是“仅在不存在时定义”,这在 REPL 重载期间非常方便。在现场演示中,普通的 def 较少引起困惑,因为它每次都会重置示例。

reduce

reduce 在遍历集合的同时携带一个累加器。源码中使用它来构建映射、头部信息和配置。

(reduce
 (fn [total n]
   (+ total n))
 0
 [10 20 30])
;; => 60

(reduce
 (fn [headers [k v]]
   (assoc headers k v))
 {}
 [ [:content-type "text/plain"]
  [:etag "v1"]])
;; => {:content-type "text/plain", :etag "v1"}

looprecur

当你需要显式循环而无需可变计数器时,请使用 looprecur。在许多情况下,mapfilterreduce 会更简单。

(loop [n 3
       result []]
  (if (zero? n)
    result
    (recur (dec n) (conj result n))))
;; => [3 2 1]

JavaScript 互操作

ClojureScript 托管在 JavaScript 之上,因此当我们需要使用平台功能时,可以直接调用 JavaScript。

(.log js/console "Hello from ClojureScript")
;; 在 JS 控制台打印

(.toISOString (js/Date.))
;; => 当前时间的 ISO 字符串

(.round js/Math 4.6)
;; => 5

在 API 边界处使用 clj->jsjs->clj

(def json-text
  (.stringify js/JSON (clj->js {:ok true :items [1 2 3]}) nil 2))

json-text
;; => "{\n  \"ok\": true,\n  \"items\": [\n    1,\n    2,\n    3\n  ]\n}"

(js->clj (.parse js/JSON json-text) :keywordize-keys true)
;; => {:ok true, :items [1 2 3]}

对于原生 JavaScript 对象,属性访问是显式的:

(def js-user #js {:name "Ada" :visits 2})

(.-name js-user)
;; => "Ada"

(aget js-user "visits")
;; => 2

JavaScript Promise

许多浏览器、Node、Deno 和 Bun API 都返回 Promise。本仓库常使用 -> 来使 Promise 链更具可读性。

(-> (js/Promise.resolve "hello")
    (.then (fn [text]
             (.toUpperCase text)))
    (.then (fn [loud]
             (.log js/console loud)))
    (.catch (fn [error]
              (.error js/console error))))
;; 打印 "HELLO"; 返回一个 Promise

try, catchthrow

当 JavaScript 或解析代码可能抛出异常时使用 try。在 ClojureScript 中,(catch :default error ...) 可以捕获普通的 JavaScript 错误。

(try
  (.parse js/JSON "{bad json")
  (catch :default error
    (.-message error)))
;; => JSON 解析错误消息

(try
  (throw (js/Error. "Boom"))
  (catch :default error
    (str "Caught: " (.-message error))))
;; => "Caught: Boom"

宏基础

宏在代码运行前对其进行转换。我们已经使用过宏了:->, ->>, when, cond-> 以及本仓库的 env-var / serve 辅助工具。

(macroexpand-1
 '(-> {:status 200}
      (assoc :body "ok")))
;; => (assoc {:status 200} :body "ok")

(macroexpand-1
 '(when true
    "runs when true"))
;; => (if true (do "runs when true"))

在本仓库中,自定义宏(如 env-varserve)位于 src/fast_twitch/macros.cljc。对于新手演示,在编写自定义宏之前,先从核心宏的 macroexpand-1 开始。

一旦理解了概念,就可以正常评估条件线程:

(cond-> {:status 200}
  true
  (assoc :body "ok")

  false
  (assoc :debug true))
;; => {:status 200, :body "ok"}

实用库

对于库代码片段,请使用在类路径中具有依赖项的项目或示例。在本仓库中,examples/07-json-api-malli-openapi 已经包含了 Malli 和 Integrant。

Malli

Malli 让我们用数据来描述数据。模式(Schema)只是一个 Clojure(Script) 值,我们可以对其进行验证、解释、转换或重用于文档。

(require '[malli.core :as m])
(require '[malli.error :as me])

(def TodoCreate
  [:map
   {:closed true}
   [:title [:string {:min 1}]]
   [:done {:optional true} :boolean]])

(m/validate TodoCreate {:title "Learn Malli" :done false})
;; => true

(m/validate TodoCreate {:title "" :extra "not allowed"})
;; => false

(me/humanize
 (m/explain TodoCreate {:title "" :extra "not allowed"}))
;; => {:title ["should be at least 1 character"],
;;     :extra ["disallowed key"]}

当你需要一个可重用的验证函数时,使用 m/validator

(require '[malli.core :as m])

(def TodoCreate
  [:map
   {:closed true}
   [:title [:string {:min 1}]]
   [:done {:optional true} :boolean]])

(def valid-todo? (m/validator TodoCreate))

(valid-todo? {:title "Ship the demo"})
;; => true

(valid-todo? {:done true})
;; => false

对初学者来说重要的概念是:与其将验证规则隐藏在许多条件判断中,不如将有效数据的形态放在一个简单的数据值中。

Integrant

Integrant 从配置映射启动应用。每个顶级键描述系统的一部分,ig/ref 表示某一部分依赖于另一部分。

(require '[integrant.core :as ig])

(defmethod ig/init-key ::settings [_ options]
  options)

(defmethod ig/init-key ::greeter [_ {:keys [settings]}]
  (fn [name]
    (str (:prefix settings) ", " name "!")))

(def config
  {::settings {:prefix "Hello"}
   ::greeter {:settings (ig/ref ::settings)}})

(def system (ig/init config))

((::greeter system) "Ada")
;; => "Hello, Ada!"

发生了什么:

  • ::settings 首先初始化。
  • ::greeter 接收了已初始化的设置。
  • system 是一个包含已初始化组件的映射。

对于拥有资源的组件,使用 ig/halt-key! 添加清理逻辑:

(require '[integrant.core :as ig])

(defmethod ig/init-key ::store [_ initial-state]
  (atom initial-state))

(defmethod ig/halt-key! ::store [_ store]
  (reset! store {}))

(def store-system
  (ig/init {::store {:todos []}}))

@(::store store-system)
;; => {:todos []}

(ig/halt! store-system)
;; => 忽略返回值;halt! 用于副作用

对初学者来说重要的概念是:将配置保持为数据,并将启动和关闭行为保持在附加到这些数据键的小方法中。

FastTwitch

源码:FastTwitch 🏃💨

  • 基于主流 JavaScript 运行时的 Web 后端
  • 捕捉构建 Web 后端真正需要的东西
  • 经过多次尝试
  • ExpressJs
  • Fastify
  • 现在仅使用 fetch API 和 Web 标准
  • API 灵感来源于 ring