Malli 中文文档

Table of Contents

Malli 是一个强大而灵活的数据驱动模式库, 适用于 Clojure 和 ClojureScript.

1. 引言

Malli 是一个数据驱动的模式库, 专为 Clojure 和 ClojureScript 设计. 它基于现有库的最佳部分, 并结合了我们多年来开发的多个项目特定工具, 旨在满足动态系统开发中所有重要需求.

如果你对别人未能满足你的期望有期待, 这些期待是你自己的责任. 你需要为自己的需求负责. 如果你想要某些东西, 就去创造它们.

2. 库介绍

Clojars 项目

Malli 需要 Clojure 1.11.

Malli 已在以下长期支持版本的 Java 上进行了测试: Java 8, 11, 17 和 21.

3. 快速开始

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

(def UserId :string)

(def Address
  [:map
   [:street :string]
   [:country [:enum "FI" "UA"]]])

(def User
  [:map
   [:id #'UserId]
   [:address #'Address]
   [:friends [:set {:gen/max 2} [:ref #'User]]]])

(require '[malli.generator :as mg])

(mg/generate User)
{:id "PP3D5Mr7",
 :address {:street "4NlqZ", :country "FI"},
 :friends
 #{{:id "k", :address {:street "", :country "FI"}, :friends #{}}}}

(m/validate User *1)
false

4. 语法

Malli 支持 Vector语法 Map语法 和 简洁语法.

4.1. Vector语法

默认语法使用向量, 灵感来自 hiccup

type
[type & children]
[type properties & children]

示例:

;; 仅类型(字符串)
:string

;; 带属性的类型
[:string {:min 1, :max 10}]

;; 带属性和子项的类型
[:tuple {:title "location"} :double :double]

;; 一个函数模式: 从 :int 到 :int
[:=> [:cat :int] :int]
[:-> :int :int]
:string
[:string {:min 1, :max 10}]
[:tuple {:title "location"} :double :double]
[:=> [:cat :int] :int]
[:-> :int :int]

用法:

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

(def non-empty-string
  (m/schema [:string {:min 1}]))

(m/schema? non-empty-string)
; => true

(m/validate non-empty-string "")
; => false

(m/validate non-empty-string "kikka")
; => true

(m/form non-empty-string)
; => [:string {:min 1}]
[:string {:min 1}]

4.2. Map 语法

类似于 cljfx 的替代 map 语法:

注意: 目前, Map 语法被视为内部使用, 因此不要将其用作数据库持久化模型.

;; 仅类型(字符串)
{:type :string}

;; 带属性的类型
{:type :string
 :properties {:min 1, :max 10}}

;; 带属性和子项的类型
{:type :tuple
 :properties {:title "location"}
 :children [{:type :double}
            {:type :double}]}

;; 一个函数模式: 从 :int 到 :int
{:type :=>
 :input {:type :cat, :children [{:type :int}]}
 :output :int}
{:type :->
 :children [{:type :int} {:type :int}]}
{:type :string}
{:type :string, :properties {:min 1, :max 10}}
{:type :tuple, :properties {:title "location"}, :children [{:type :double} {:type :double}]}
{:type :=>, :input {:type :cat, :children [{:type :int}]}, :output :int}
{:type :->, :children [{:type :int} {:type :int}]}

用法:

(def non-empty-string
  (m/from-ast {:type :string
               :properties {:min 1}}))

(m/schema? non-empty-string)
; => true

(m/validate non-empty-string "")
; => false

(m/validate non-empty-string "kikka")
; => true

(m/ast non-empty-string)
; => {:type :string,
;     :properties {:min 1}}
#'user/non-empty-string
true
false
true
{:type :string, :properties {:min 1}}

4.3. 为什么有多种语法?

Malli 最初只有 vector-syntax. 它非常强大且相对易读, 但并不适用于所有用例.

又引入了map-syntax, 因为我们发现解析大量向量语法在像 JavaScript 移动端这样运行缓慢的单线程环境中可能成为瓶颈. Map 语法允许延迟和无需解析的模式创建.

还添加了 lite 作为简化的模式创建, 适用于特殊情况, 例如与 reitit coercion 一起使用, 并且便于从 data-specs 迁移.

5. 示例地址模式

以下示例模式在许多后续示例中假定存在.

(def Address
  [:map
   [:id string?]
   [:tags [:set keyword?]]
   [:address
    [:map
     [:street string?]
     [:city string?]
     [:zip int?]
     [:lonlat [:tuple double? double?]]]]])
#'user/Address

6. 验证

根据模式验证值:

;; 使用模式实例
(m/validate (m/schema :int) 1)
; => true

;; 使用向量语法
(m/validate :int 1)
; => true

(m/validate :int "1")
; => false

(m/validate [:= 1] 1)
; => true

(m/validate [:enum 1 2] 1)
; => true

(m/validate [:and :int [:> 6]] 7)
; => true

(m/validate [:qualified-keyword {:namespace :aaa}] :aaa/bbb)
; => true

;; 优化的(纯粹的)验证函数, 性能最佳
(def valid?
  (m/validator
    [:map
     [:x :boolean]
     [:y {:optional true} :int]
     [:z :string]]))

(valid? {:x true, :z "kikka"})
; => true
true
true
false
true
true
true
true
#'user/valid?
true

模式可以有属性:

(def Age
  [:and
   {:title "Age"
    :description "It's an age"
    :json-schema/example 20}
   :int [:> 18]])

(m/properties Age)
; => {:title "Age"
;     :description "It's an age"
;     :json-schema/example 20}
#'user/Age
{:title "Age", :description "It's an age", :json-schema/example 20}

默认情况下, Maps 是开放的:

(m/validate
  [:map [:x :int]]
  {:x 1, :extra "key"})
; => true
true

可以使用 :closed 属性关闭 Maps:

(m/validate
  [:map {:closed true} [:x :int]]
  {:x 1, :extra "key"})
; => false

Maps 的键不限于关键词:

(m/validate
  [:map
   ["status" [:enum "ok"]]
   [1 :any]
   [nil :any]
   [::a :string]]
  {"status" "ok"
   1 'number
   nil :yay
   ::a "properly awesome"})
; => true
true

大多数核心谓词都映射到模式:

(m/validate string? "kikka")
; => true
true

参见 [默认模式注册表的完整列表](#schema-registry).

7. 枚举模式

:enum 模式 [:enum V1 V2 ...] 表示一个枚举值集合 V1 V2 ....

这大多数情况下如你所愿, 值如果包含在集合中则通过模式, 生成器返回其中一个值, 并缩减到最左边的值.

需要注意一些关于语法的特殊情况. 由于模式属性可以使用 map 或 nil, 枚举以 map 或 nil 开始时必须使用稍微不同的语法.

如果你的 :enum 没有属性, 必须提供 nil 作为属性.

[:enum nil {}]  ;; 单例模式, 值为 {}
[:enum nil nil] ;; 单例模式, 值为 nil

如果你的 :enum 有属性, 前导 map 会被解释为属性, 而不是枚举值.

[:enum {:foo :bar} {}]  ;; 单例模式, 值为 {}, 具有属性 {:foo :bar}
[:enum {:foo :bar} nil] ;; 单例模式, 值为 nil, 具有属性 {:foo :bar}

实际上, 这些语法规则适用于所有模式, 但 :enum 是最常见的需要注意的模式, 因此特别提及.

8. Map 中的限定键

你也可以使用 解构的 map 键和值 使用注册表引用. 引用必须是限定关键词或字符串.

(m/validate
  [:map {:registry {::id int? ::country string?}}
   ::id
   [:name string?]
   [::country {:optional true}]]
  {::id 1
   :name "kikka"})
; => true

9. 同质化 Maps

有时, 我们使用 map 作为同质索引. 在这种情况下, 所有键值对具有相同的类型. 对于这种用例, 我们可以使用 :map-of 模式.

(m/validate
  [:map-of :string [:map [:lat number?] [:long number?]]]
  {"oslo" {:lat 60 :long 11}
   "helsinki" {:lat 60 :long 24}})
;; => true
true

10. Map 与默认模式

Map 模式可以定义一个特殊的 :malli.core/default 键来处理额外的键:

(m/validate
 [:map
  [:x :int]
  [:y :int]
  [::m/default [:map-of :int :int]]]
 {:x 1, :y 2, 1 1, 2 2})
; => true
true

默认分支可以任意嵌套:

(m/validate
 [:map
  [:x :int]
  [::m/default [:map
                [:y :int]
                [::m/default [:map-of :int :int]]]]]
 {:x 1, :y 2, 1 1, 2 2})
; => true
true

11. 可序列化的模式

:seqable:every 模式描述 seqable? 集合. 它们在处理既不是 counted? 也不是 indexed? 的集合以及它们的 [解析器](#parsing-values) 上有所不同:

  1. :seqable 解析其元素, 而 :every 不解析并返回相同的输入.
  2. 有效的未解析 :seqable 值会丢失原始集合类型, 而 :every 返回相同的输入.

:seqable 验证整个集合, 而 :every 仅检查 :min, (inc :max)(::m/coll-check-limit options 101) 的最大值, 或者如果输入是 counted?indexed? 则检查整个集合.

;; :seqable 和 :every 在小型, 已计数或已索引的集合上验证相同
(m/validate [:seqable :int] #{1 2 3})
;=> true
(m/validate [:seqable :int] [1 2 3])
;=> true
(m/validate [:seqable :int] (sorted-set 1 2 3))
;=> true
(m/validate [:seqable :int] (range 1000))
;=> true
(m/validate [:seqable :int] (conj (vec (range 1000)) nil))
;=> false

(m/validate [:every :int] #{1 2 3})
;=> true
(m/validate [:every :int] [1 2 3])
;=> true
(m/validate [:every :int] (sorted-set 1 2 3))
;=> true
(m/validate [:every :int] (vec (range 1000)))
;=> true
(m/validate [:every :int] (conj (vec (range 1000)) nil))
;=> false

;; 对于大型未计数和未索引的集合, :every 仅检查特定长度
(m/validate [:seqable :int] (concat (range 1000) [nil]))
;=> false
(m/validate [:every :int] (concat (range 1000) [nil]))
;=> true

12. 序列模式

你可以使用 :sequential 来描述同质的顺序 Clojure 集合.

(m/validate [:sequential any?] (list "this" 'is :number 42))
;; => true

(m/validate [:sequential int?] [42 105])
;; => true

(m/validate [:sequential int?] #{42 105})
;; => false
true
true
false

Malli 还支持类似 Seqexp 和 Spec 的序列正则表达式(也称为序列表达式). 支持的操作符包括 :cat:catn 用于连接/序列化:

(m/validate [:cat string? int?] ["foo" 0]) ; => true

(m/validate [:catn [:s string?] [:n int?]] ["foo" 0]) ; => true

:alt:altn 用于替代:

(m/validate [:alt keyword? string?] ["foo"]) ; => true

(m/validate [:altn [:kw keyword?] [:s string?]] ["foo"]) ; => true

以及 :?, :*, :+:repeat 用于重复:

(m/validate [:? int?] []) ; => true
(m/validate [:? int?] [1]) ; => true
(m/validate [:? int?] [1 2]) ; => false

(m/validate [:* int?] []) ; => true
(m/validate [:* int?] [1 2 3]) ; => true

(m/validate [:+ int?] []) ; => false
(m/validate [:+ int?] [1]) ; => true
(m/validate [:+ int?] [1 2 3]) ; => true

(m/validate [:repeat {:min 2, :max 4} int?] [1]) ; => false
(m/validate [:repeat {:min 2, :max 4} int?] [1 2]) ; => true
(m/validate [:repeat {:min 2, :max 4} int?] [1 2 3 4]) ; => true (:max 是包含的, 如同其他地方在 Malli 中)
(m/validate [:repeat {:min 2, :max 4} int?] [1 2 3 4 5]) ; => false

:catn:altn 允许命名子序列/替代项:

(m/explain
  [:* [:catn [:prop string?] [:val [:altn [:s string?] [:b boolean?]]]]]
  ["-server" "foo" "-verbose" 11 "-user" "joe"])
;; => {:schema [:* [:map [:prop string?] [:val [:map [:s string?] [:b boolean?]]]]],
;;     :value ["-server" "foo" "-verbose" 11 "-user" "joe"],
;;     :errors ({:path [0 :val :s], :in [3], :schema string?, :value 11}
;;              {:path [0 :val :b], :in [3], :schema boolean?, :value 11})}

:cat:alt 则仅使用数字索引作为路径:

(m/explain
  [:* [:cat string? [:alt string? boolean?]]]
  ["-server" "foo" "-verbose" 11 "-user" "joe"])
;; => {:schema [:* [:cat string? [:alt string? boolean?]]],
;;     :value ["-server" "foo" "-verbose" 11 "-user" "joe"],
;;     :errors ({:path [0 1 0], :in [3], :schema string?, :value 11}
;;              {:path [0 1 1], :in [3], :schema boolean?, :value 11})}

所有这些示例显示, 序列表达式(seqex)操作符接受任何非 seqex 子模式, 意味着匹配该模式的单个元素序列. 要强制这种行为, 可以使用 :schema:

(m/validate
  [:cat [:= :names] [:schema [:* string?]] [:= :nums] [:schema [:* number?]]]
  [:names ["a" "b"] :nums [1 2 3]])
; => true

;; 
(m/validate
  [:cat [:= :names] [:* string?] [:= :nums] [:* number?]]
  [:names "a" "b" :nums 1 2 3])
; => true

尽管在 seqex 实现上投入了大量精力以提高速度:

(require '[clojure.spec.alpha :as s])
(require '[criterium.core :as cc])

(let [valid? (partial s/valid? (s/* int?))]
  (cc/quick-bench (valid? (range 10)))) ; 执行时间均值: 27µs

(let [valid? (m/validator [:* int?])]
  (cc/quick-bench (valid? (range 10)))) ; 执行时间均值: 2.7µs

但总是最好尽可能使用更少通用的工具:

(let [valid? (partial s/valid? (s/coll-of int?))]
  (cc/quick-bench (valid? (range 10)))) ; 执行时间均值: 1.8µs

(let [valid? (m/validator [:sequential int?])]
  (cc/quick-bench (valid? (range 10)))) ; 执行时间均值: 0.12µs

13. 向量模式

你可以使用 :vector 来描述同质的 Clojure 向量.

(m/validate [:vector int?] [1 2 3])
;; => true

(m/validate [:vector int?] (list 1 2 3))
;; => false

:tuple 模式描述固定长度的 Clojure 向量, 包含异质元素:

(m/validate [:tuple keyword? string? number?] [:bing "bang" 42])
;; => true

要基于 seqex 创建向量模式, 使用 :and

;; 非空向量, 以关键词开头
(m/validate [:and [:cat :keyword [:* :any]]
                  vector?]
            [:a 1])
; => true

(m/validate [:and [:cat :keyword [:* :any]]
                  vector?]
            (:a 1))
; => false

注意: 要从向量 seqex 生成值, 请参见 [:and 生成](#and-generation).

14. 集合模式

你可以使用 :set 来描述同质的 Clojure 集合.

(m/validate [:set int?] #{42 105})
;; => true

(m/validate [:set int?] #{:a :b})
;; => false

15. 字符串模式

使用谓词:

(m/validate string? "kikka")

使用 :string 模式:

(m/validate :string "kikka")
;; => true

(m/validate [:string {:min 1, :max 4}] "")
;; => false

使用正则表达式:

(m/validate #"a+b+c+" "abbccc")
;; => true

;; 使用字符串的 :re
(m/validate [:re ".{3,5}"] "abc")
;; => true

;; 使用正则表达式的 :re
(m/validate [:re #".{3,5}"] "abc")
;; => true

;; 注意: re-find 语义
(m/validate [:re #"\d{4}"] "1234567")
;; => true

;; 如果你想严格匹配整个字符串, 请使用 ^...$
(m/validate [:re #"^\d{4}$"] "1234567")
;; => false

16. 可能的模式

使用 :maybe 表示元素应该匹配某个模式或为 nil:

(m/validate [:maybe string?] "bingo")
;; => true

(m/validate [:maybe string?] nil)
;; => true

(m/validate [:maybe string?] :bingo)
;; => false

17. 函数模式

:fn 允许使用任何谓词函数:

(def my-schema
  [:and
   [:map
    [:x int?]
    [:y int?]]
   [:fn (fn [{:keys [x y]}] (> x y))]])

(m/validate my-schema {:x 1, :y 0})
; => true

(m/validate my-schema {:x 1, :y 2})
; => false

18. 错误消息

使用 m/explain 获取详细错误:

(m/explain
  Address
  {:id "Lillan"
   :tags #{:artesan :coffee :hotel}
   :address {:street "Ahlmanintie 29"
             :city "Tampere"
             :zip 33100
             :lonlat [61.4858322, 23.7854658]}})
; => nil

(m/explain
  Address
  {:id "Lillan"
   :tags #{:artesan "coffee" :garden}
   :address {:street "Ahlmanintie 29"
             :zip 33100
             :lonlat [61.4858322, nil]}})
; => {:schema [:map
;            [:id string?]
;            [:tags [:set keyword?]]
;            [:address [:map
;                      [:street string?]
;                      [:city string?]
;                      [:zip int?]
;                      [:lonlat [:tuple double? double?]]]]],
;     :value {:id "Lillan",
;             :tags #{:artesan :garden "coffee"},
;             :address {:street "Ahlmanintie 29"
;                       :zip 33100
;                       :lonlat [61.4858322 nil]}},
;     :errors ({:path [:tags 0]
;               :in [:tags 0]
;               :schema keyword?
;               :value "coffee"}
;              {:path [:address :city],
;               :in [:address :city],
;               :schema [:map
;                        [:street string?]
;                        [:city string?]
;                        [:zip int?]
;                        [:lonlat [:tuple double? double?]]],
;               :type :malli.core/missing-key}
;              {:path [:address :lonlat 1]
;               :in [:address :lonlat 1]
;               :schema double?
;               :value nil})}

:errors 下, 你会得到一个错误列表, 每个错误包含以下键:

  • :path, 模式中的错误位置
  • :in, 值中的错误位置
  • :schema, 错误中的模式
  • :value, 错误中的值
(def Schema [:map [:x boolean?] [:y [:maybe [:tuple :string]]]])

(def value {:x 1})

(def error (-> Schema
               (m/explain value)
               :errors
               first))

error
; => {:path [:x 0 0]
;     :in [:x 0]
;     :schema :string
;     :value 1}

(get-in value (:in error))
; => 1

(mu/get-in Schema (:path error))
; => :string

注意! 如果你需要可以整齐序列化为 EDN/JSON 的错误消息, 请使用 malli.util/explain-data.

19. 人性化错误消息

可以使用 malli.error/humanize 将解释结果人性化:

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

(-> Address
    (m/explain
      {:id "Lillan"
       :tags #{:artesan "coffee" :garden}
       :address {:street "Ahlmanintie 29"
                 :zip 33100
                 :lonlat [61.4858322, nil]}})
    (me/humanize))
; => {:tags #{["应该是关键词"]}
;     :address {:city ["缺少必需的键"]
;               :lonlat [nil ["应该是 double"]]}}

或者如果你已经有一个 malli 验证异常(例如在 catch 表达式中):

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

(try
  (m/validate Address {:not "an address"})
  (catch Exception e
    (-> e ex-data :data :explain me/humanize)))

20. 自定义错误消息

错误消息可以通过 :error/message:error/fn 属性自定义:

(-> [:map
     [:id int?]
     [:size [:enum {:error/message "应该是: S|M|L"}
             "S" "M" "L"]]
     [:age [:fn {:error/fn (fn [{:keys [value]} _] (str value ", 应该 > 18"))}
            (fn [x] (and (int? x) (> x 18)))]]]
    (m/explain {:size "XL", :age 10})
    (me/humanize
      {:errors (-> me/default-errors
                   (assoc ::m/missing-key {:error/fn (fn [{:keys [in]} _] (str "缺少键 " (last in)))}))}))
; => {:id ["缺少键 :id"]
;     :size ["应该是: S|M|L"]
;     :age ["10, 应该 > 18"]}

消息可以本地化:

(-> [:map
     [:id int?]
     [:size [:enum {:error/message {:en "should be: S|M|L"
                                    :fi "pitäisi olla: S|M|L"}}
             "S" "M" "L"]]
     [:age [:fn {:error/fn {:en (fn [{:keys [value]} _] (str value ", 应该 > 18"))
                            :fi (fn [{:keys [value]} _] (str value ", pitäisi olla > 18"))}}
            (fn [x] (and (int? x) (> x 18)))]]]
    (m/explain {:size "XL", :age 10})
    (me/humanize
      {:locale :fi
       :errors (-> me/default-errors
                   (assoc-in ['int? :error-message :fi] "pitäisi olla numero")
                   (assoc ::m/missing-key {:error/fn {:en (fn [{:keys [in]} _] (str "缺少键 " (last in)))
                                                      :fi (fn [{:keys [in]} _] (str "puuttuu avain " (last in)))}}))}))
; => {:id ["puuttuu avain :id"]
;     :size ["pitäisi olla: S|M|L"]
;     :age ["10, pitäisi olla > 18"]}

顶级人性化的 map 错误位于 :malli/error:

(-> [:and [:map
           [:password string?]
           [:password2 string?]]
     [:fn {:error/message "密码不匹配"}
       (fn [{:keys [password password2]}]
         (= password password2))]]
    (m/explain {:password "secret"
                :password2 "faarao"})
    (me/humanize))
; => {:malli/error ["密码不匹配"]}

错误可以通过 :error/path 属性定向:

(-> [:and [:map
           [:password string?]
           [:password2 string?]]
     [:fn {:error/message "密码不匹配"
           :error/path [:password2]}
       (fn [{:keys [password password2]}]
         (= password password2))]]
    (m/explain {:password "secret"
                :password2 "faarao"})
    (me/humanize))
; => {:password2 ["密码不匹配"]}

默认情况下, 仅使用直接错误的模式属性:

(-> [:map
     [:foo {:error/message "条目失败"} :int]] ;; 这里, :int 失败, 没有错误属性
    (m/explain {:foo "1"})
    (me/humanize))
; => {:foo ["应该是整数"]}

通过自定义 :resolve 来从父模式查找人性化错误(BETA, 可能会改变):

(-> [:map
     [:foo {:error/message "条目失败"} :int]]
    (m/explain {:foo "1"})
    (me/humanize {:resolve me/-resolve-root-error}))
; => {:foo ["条目失败"]}

21. 拼写检查

对于封闭的模式, 可以通过以下方式检查键的拼写:

(-> [:map [:address [:map [:street string?]]]]
    (mu/closed-schema)
    (m/explain
      {:name "Lie-mi"
       :address {:streetz "Hämeenkatu 14"}})
    (me/with-spell-checking)
    (me/humanize))
; => {:address {:street ["缺少必需的键"]
;            :streetz ["应该拼写为 :street"]}
;     :name ["不允许的键"]}

22. 错误中的值

仅获取错误中的值部分:

(-> Address
    (m/explain
     {:id "Lillan"
      :tags #{:artesan "coffee" :garden "ground"}
      :address {:street "Ahlmanintie 29"
                :zip 33100
                :lonlat [61.4858322, "23.7832851,17"]}})
    (me/error-value))
; => {:tags #{"coffee" "ground"}
;     :address {:lonlat [nil "23.7832851,17"]}}

屏蔽无关部分:

(-> Address
    (m/explain
     {:id "Lillan"
      :tags #{:artesan "coffee" :garden "ground"}
      :address {:street "Ahlmanintie 29"
                :zip 33100
                :lonlat [61.4858322, "23.7832851,17"]}})
    (me/error-value {::me/mask-valid-values '...}))
; => {:id ...
;     :tags #{"coffee" "ground" ...}
;     :address {:street ...
;               :zip ...
;               :lonlat [... "23.7832851,17"]}}

## 美化错误

有两种方式获取美化的错误:

23. 开发模式

启动开发模式:

((requiring-resolve 'malli.dev/start!))

现在, 任何通过 malli.core/-fail! 抛出的异常都会被捕获并在抛出之前进行美化. 美化是可扩展的, 使用 virhe.

美化的 Coercion:

pretty-coerce.png

自定义异常(使用默认布局):

bats-in-the-attic.png

美化打印由 malli.dev.virhe/-format 多方法支持, 默认使用 (-> exception (ex-data) :data) 作为调度键. 作为后备, 使用异常类或其子类, 例如以下方法将处理所有 java.sql.SQLException 及其父异常:

(require '[malli.dev.virhe :as v])

(defmethod v/-format java.sql.SQLException [e _ printer]
  {:title "抛出的异常"
   :body [:group
          (v/-block "SQL 异常" (v/-color :string (ex-message e) printer) printer) :break :break
          (v/-block "更多信息: " (v/-link "https://cljdoc.org/d/metosin/malli/CURRENT" printer) printer)]})

23.1. pretty/explain

对于开发时的美化错误打印, 尝试 malli.dev.pretty/explain

pretty-explain.png

24. 值转换

(require '[malli.transform :as mt])

使用 Transformer 实例, 通过 m/decodem/encode 进行双向模式驱动的值转换.

默认的 Transformer 包括:

名称 描述
mt/string-transformer 在字符串和 EDN 之间转换
mt/json-transformer 在 JSON 和 EDN 之间转换
mt/strip-extra-keys-transformer 从 maps 中删除额外的键
mt/default-value-transformer 应用来自模式属性的默认值
mt/key-transformer 转换 map 键
mt/collection-transformer 集合之间的转换(例如 set -> vector)

注意: 包含的 Transformer 是尽力而为的, 即它们不会在坏输入上抛出错误, 而只是将输入值保持不变. 你应该确保你的模式验证捕获这些未转换的值. 自定义 Transformer 应遵循相同的习惯用法.

简单用法:

(m/decode int? "42" mt/string-transformer)
; => 42

(m/encode int? 42 mt/string-transformer)
; => "42"

为了提高性能, 预计算转换使用 m/decoderm/encoder:

(def decode (m/decoder int? mt/string-transformer))

(decode "42")
; => 42

(def encode (m/encoder int? mt/string-transformer))

(encode 42)
; => "42"

24.1. 强制转换

对于同时解码和验证结果(在错误时抛出异常), 使用 m/coercem/coercer:

(m/coerce :int "42" mt/string-transformer)
; => 42

((m/coercer :int mt/string-transformer) "42")
; => 42

(m/coerce :int "invalid" mt/string-transformer)
; => 抛出 :malli.core/invalid-input 异常 {:value "invalid", :schema :int, :explain {:schema :int, :value "invalid", :errors ({:path [], :in [], :schema :int, :value "invalid"})}}

转换可以在没有 Transformer 的情况下应用, 仅进行验证:

(m/coerce :int 42)
; => 42

(m/coerce :int "42")
; => 抛出 :malli.core/invalid-input 异常 {:value "42", :schema :int, :explain {:schema :int, :value "42", :errors ({:path [], :in [], :schema :int, :value "42"})}}

使用续接风格进行无异常转换:

(m/coerce :int "fail" nil (partial prn "success:") (partial prn "error:"))
; => 打印 "error:" {:value "fail", :schema :int, :explain ...}

24.2. 高级转换

转换是递归的:

(m/decode
  Address
  {:id "Lillan",
   :tags ["coffee" "artesan" "garden"],
   :address {:street "Ahlmanintie 29"
             :city "Tampere"
             :zip 33100
             :lonlat [61.4858322 23.7854658]}}
  mt/json-transformer)
; => {:id "Lillan",
;     :tags #{:coffee :artesan :garden},
;     :address {:street "Ahlmanintie 29"
;               :city "Tampere"
;               :zip 33100
;               :lonlat [61.4858322 23.7854658]}}

转换 map 键:

(m/encode
  Address
  {:id "Lillan",
   :tags ["coffee" "artesan" "garden"],
   :address {:street "Ahlmanintie 29"
             :city "Tampere"
             :zip 33100
             :lonlat [61.4858322 23.7854658]}}
  (mt/key-transformer {:encode name}))
; => {"id" "Lillan",
;     "tags" ["coffee" "artesan" "garden"],
;     "address" {"street" "Ahlmanintie 29"
;                "city" "Tampere"
;                "zip" 33100
;                "lonlat" [61.4858322 23.7854658]}}

转换同质的 :enum:=~(支持自动类型检测 ~:keyword, :symbol, :int:double):

(m/decode [:enum :kikka :kukka] "kukka" mt/string-transformer)
; => :kukka

可以将 Transformers 组合使用 mt/transformer:

(def strict-json-transformer
  (mt/transformer
    mt/strip-extra-keys-transformer
    mt/json-transformer))

(m/decode
  Address
  {:id "Lillan",
   :EVIL "LYN"
   :tags ["coffee" "artesan" "garden"],
   :address {:street "Ahlmanintie 29"
             :DARK "ORKO"
             :city "Tampere"
             :zip 33100
             :lonlat [61.4858322 23.7854658]}}
  strict-json-transformer)
; => {:id "Lillan",
;     :tags #{:coffee :artesan :garden},
;     :address {:street "Ahlmanintie 29"
;               :city "Tampere"
;               :zip 33100
;               :lonlat [61.4858322 23.7854658]}}

模式属性可以用于覆盖默认转换:

(m/decode
  [string? {:decode/string clojure.string/upper-case}]
  "kerran" mt/string-transformer)
; => "KERRAN"

这也可以这样做:

(m/decode
  [string? {:decode {:string clojure.string/upper-case}}]
  "kerran" mt/string-transformer)
; => "KERRAN"

作为拦截器的解码器和编码器(具有 :enter:leave 阶段):

(m/decode
  [string? {:decode/string {:enter clojure.string/upper-case}}]
  "kerran" mt/string-transformer)
; => "KERRAN"
(m/decode
  [string? {:decode/string {:enter #(str "olipa_" %)
                            :leave #(str % "_avaruus")}}]
  "kerran" mt/string-transformer)
; => "olipa_kerran_avaruus"

要访问模式(以及选项), 使用 :compile:

(m/decode
  [int? {:math/multiplier 10
         :decode/math {:compile (fn [schema _]
                                  (let [multiplier (:math/multiplier (m/properties schema))]
                                    (fn [x] (* x multiplier))))}}]
  12
  (mt/transformer {:name :math}))
; => 120

疯狂使用:

(m/decode
  [:map
   {:decode/math {:enter #(update % :x inc)
                  :leave #(update % :x (partial * 2))}}
   [:x [int? {:decode/math {:enter (partial + 2)
                            :leave (partial * 3)}}]]]
  {:x 1}
  (mt/transformer {:name :math}))
; => {:x 24}

:and 从左到右累积转换的值.

(m/decode
  [:and
   [:map
    [:name string?]
    [:description string?]]
   [:and
    [:fn (fn [{:keys [name description]}] (not (empty? name)))]
    [:fn (fn [{:keys [name description]}] (not (empty? description)))]]]
  {:name "kikka", :description "kukka"}
  (mt/transformer mt/default-value-transformer))
; => {:name "kikka", :description "kukka"}

:or 使用第一个成功的模式进行转换, 按顺序.

(m/decode
  [:or
   [:map [:name string?]]
   [:map [:name int?]]]
  {:name "kikka"}
  (mt/transformer mt/default-value-transformer))
; => {:name "kikka"}

(m/decode
  [:or
   :map
   [:map [:name string?]]]
  {:name "kikka"}
  (mt/transformer mt/default-value-transformer))
; => {:name "kikka"}

代理模式如 :merge:union 类似于 m/deref.

(m/decode
  [:merge
   [:map [:name string?]]
   [:map [:description string?]]]
  {:name "kikka", :description "kukka"})
; => {:name "kikka", :description "kukka"}

(m/decode
  [:union
   [:map [:name string?]]
   [:map [:description string?]]]
  {:name "kikka"})
; => {:name "kikka"}

(m/decode
  [:union
   [:map [:name string?]]
   [:map [:description string?]]]
  {:description "kukka"})
; => {:description "kukka"}

25. 持久化模式

将模式写入和读取为 EDN, 无需 eval.

以下示例需要 SCIcherry 作为外部依赖, 因为它包含一个(引用的)函数定义. 参见 [可序列化函数](#serializable-functions).

(require '[malli.edn :as edn])

(-> [:and
     [:map
      [:x :int]
      [:y :int]]
     [:fn '(fn [{:keys [x y]}] (> x y))]]
    (edn/write-string)
    (doto prn) ; => "[:and [:map [:x :int] [:y :int]] [:fn (fn [{:keys [x y]}] (> x y))]]"
    (edn/read-string)
    (doto (-> (m/validate {:x 0, :y 1}) prn)) ; => false
    (doto (-> (m/validate {:x 2, :y 1}) prn))) ; => true
; => [:and
;      [:map
;       [:x :int]
;       [:y :int]]
;      [:fn (fn [{:keys [x y]}] (> x y))]]

26. 多模式

使用 :multi 模式和 :dispatch 属性进行封闭调度:

(m/validate
  [:multi {:dispatch :type}
   [:sized [:map [:type keyword?] [:size int?]]]
   [:human [:map [:type keyword?] [:name string?] [:address [:map [:country keyword?]]]]]]
  {:type :sized, :size 10})
; => true

使用 ::m/default 分支:

(def valid?
  (m/validator
    [:multi {:dispatch :type}
     ["object" [:map-of :keyword :string]]
     [::m/default :string]]))

(valid? {:type "object", :key "1", :value "100"})
; => true

(valid? "SUCCESS!")
; => true

(valid? :failure)
; => false

任何函数都可以用作 :dispatch:

(m/validate
  [:multi {:dispatch first}
   [:sized [:tuple keyword? [:map [:size int?]]]]
   [:human [:tuple keyword? [:map [:name string?] [:address [:map [:country keyword?]]]]]]]
  [:human {:name "seppo", :address {:country :sweden}}])
; => true

:dispatch 值应在实际值之前被解码:

(m/decode
  [:multi {:dispatch :type
           :decode/string #(update % :type keyword)}
   [:sized [:map [:type [:= :sized]] [:size int?]]]
   [:human [:map [:type [:= :human]] [:name string?] [:address [:map [:country keyword?]]]]]]
  {:type "human"
   :name "Tiina"
   :age "98"
   :address {:country "finland"
             :street "this is an extra key"}}
  (mt/transformer mt/strip-extra-keys-transformer mt/string-transformer))
; => {:type :human
;     :name "Tiina"
;     :address {:country :finland}}

27. 递归模式

要创建递归模式, 引入 [本地注册表](#local-registry) 并在注册表中使用 :ref 包装所有递归位置. 现在你可以在模式体中引用递归模式.

例如, 这里是一个使用 :schema 的递归模式, 表示单链表的正整数:

(m/validate
  [:schema {:registry {::cons [:maybe [:tuple pos-int? [:ref ::cons]]]}}
   ::cons]
  [16 [64 [26 [1 [13 nil]]]]])
; => true

没有 :ref 关键字, Malli 会急切地展开模式, 直到堆栈溢出错误:

(m/validate
  [:schema {:registry {::cons [:maybe [:tuple pos-int? ::cons]]}}
   ::cons]
  [16 [64 [26 [1 [13 nil]]]]])
; 堆栈溢出错误

实际上, 你只需要在递归位置使用 :ref. 然而, 最好在所有递归引用位置使用 :ref 以确保生成器行为良好:

;; 注意:
[:schema {:registry {::cons [:maybe [:tuple pos-int? [:ref ::cons]]]}}
 ::cons]
;; 生成器与 "展开的" 模式相同
[:maybe [:tuple pos-int? [:schema {:registry {::cons [:maybe [:tuple pos-int? [:ref ::cons]]]}} ::cons]]]
;; 
[:schema {:registry {::cons [:maybe [:tuple pos-int? [:ref ::cons]]]}}
 [:ref ::cons]]
;; 直接对应于以下生成器:
(gen/recursive-gen
  (fn [rec] (gen/one-of [(gen/return nil) (gen/tuple rec)]))
  (gen/return nil))

28. 值生成

模式可以用来生成值:

(require '[malli.generator :as mg])

;; 随机
(mg/generate keyword?)
; => :?

;; 使用种子
(mg/generate [:enum "a" "b" "c"] {:seed 42})
;; => "a"

;; 使用种子和大小
(mg/generate pos-int? {:seed 10, :size 100})
;; => 55740

;; 正则表达式也有效(仅在 clj 和 [com.gfredericks/test.chuck "0.2.10"+] 可用)
(mg/generate
  [:re #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$"]
  {:seed 42, :size 10})
; => "CaR@MavCk70OHiX.yZ"

;; :gen/return (注意, 不是验证)
(mg/generate
 [:and {:gen/return 42} :int])
; => 42

;; :gen/elements (注意, 不是验证)
(mg/generate
  [:and {:gen/elements ["kikka" "kukka" "kakka"]} string?]
  {:seed 10})
; => "kikka"

;; :gen/fmap
(mg/generate
  [:and {:gen/fmap (partial str "kikka_")} string?]
  {:seed 10, :size 10})
;; => "kikka_WT3K0yax2"

;; 可移植的 :gen/fmap(需要 ~org.babashka/sci~ 依赖才能工作)
(mg/generate
  [:and {:gen/fmap '(partial str "kikka_")} string?]
  {:seed 10, :size 10})
;; => "kikka_nWT3K0ya7"

;; :gen/schema
(mg/generate
  [:any {:gen/schema [:int {:min 10, :max 20}]}]
  {:seed 10})
; => 19

;; :gen/min 和 :gen/max 对数字和集合有效
(mg/generate
  [:vector {:gen/min 4, :gen/max 4} :int]
  {:seed 1})
; => [-8522515 -1433 -1 1]

;; :gen/infinite? 和 :gen/NaN? 对 :double 有效
(mg/generate
  [:double {:gen/infinite? true, :gen/NaN? true}]
  {:seed 1})
; => ##Inf

生成的值是有效的:

(mg/generate Address {:seed 123, :size 4})
; => {:id "H7",
;     :tags #{:v?.w.t6!.QJYk-/-?s*4
;             :_7U
;             :QdG/Xi8J
;             :*Q-.p*8*/n-J9u}
;     :address {:street "V9s"
;               :city ""
;               :zip 3
;               :lonlat [-2.75 -0.625]}}

28.1. :and 生成

:and 模式的生成器通过从第一个子模式生成值, 然后过滤掉不满足整个 :and 模式的值.

为了获得最可靠的结果, 将最有可能生成整个模式有效值的模式放在 :and 模式的第一个子模式.

;; 不好: :string 不太可能生成满足模式的值
(mg/generate [:and :string [:enum "a" "b" "c"]] {:seed 42})
; => 执行错误
; 无法在 100 次尝试后满足 such-that 谓词.
;; 好: ~:enum~ 的每个值都是字符串
(mg/generate [:and [:enum "a" "b" "c"] :string] {:seed 42})
; => "a"

你可能需要自定义第一个 :and 子模式的生成器以提高生成有效值的机会.

例如, 一个非空异质向量的模式可以通过结合 :catvector? 进行验证, 但由于 :cat 生成序列, 我们需要使用 :gen/fmap 使其生成向量:

;; 生成一个以关键词开头的非空向量
(mg/generate [:and [:cat {:gen/fmap vec}
                    :keyword [:* :any]]
                  vector?]
            {:size 1
             :seed 2})
; => [:.+ [1]]

29. 推断模式

F# 类型提供者 的启发:

(require '[malli.provider :as mp])

(def samples
  [{:id "Lillan"
    :tags #{:artesan :coffee :hotel}
    :address {:street "Ahlmanintie 29"
              :city "Tampere"
              :zip 33100
              :lonlat [61.4858322, 23.7854658]}}
   {:id "Huber",
    :description "Beefy place"
    :tags #{:beef :wine :beer}
    :address {:street "Aleksis Kiven katu 13"
              :city "Tampere"
              :zip 33200
              :lonlat [61.4963599 23.7604916]}}])

(mp/provide samples)
;[:map
; [:id :string]
; [:tags [:set :keyword]]
; [:address
;  [:map
;   [:street :string]
;   [:city :string]
;   [:zip :int]
;   [:lonlat [:vector :double]]]]
; [:description {:optional true} :string]]

所有样本都符合推断的模式:

(every? (partial m/validate (mp/provide samples)) samples)
; => true

为了提高性能, 使用 mp/provider:

(require '[criterium.core :as p])

;; 5ms
(p/bench (mp/provide samples))

;; 500µs (10倍)
(let [provider (mp/provider)]
  (p/bench (provider samples)))

29.1. :map-of 推断

默认情况下, :map-of 不被推断:

(mp/provide
 [{"1" [1]}
  {"2" [1 2]}
  {"3" [1 2 3]}])
; => [:map
;  ["1" {:optional true} [:vector :int]]
;  ["2" {:optional true} [:vector :int]]
;  ["3" {:optional true} [:vector :int]]]

使用 ::mp/map-of-threshold 选项:

(mp/provide
 [{"1" [1]}
  {"2" [1 2]}
  {"3" [1 2 3]}]
 {:::mp/map-of-threshold 3})
; => [:map-of :string [:vector :int]]

样本数据可以用 ::mp/hint 类型提示:

(mp/provide
 [^{::mp/hint :map-of}
  {:a {:b 1, :c 2}
   :b {:b 2, :c 1}
   :c {:b 3}
   :d nil}])
; => [:map-of
;  :keyword
;  [:maybe [:map
;           [:b :int]
;           [:c :int]]]]

29.2. :tuple 推断

默认情况下, :tuple 不被推断:

(mp/provide
 [[1 "kikka" true]
  [2 "kukka" true]
  [3 "kakka" true]])
; => [:vector :some]

使用 ::mp/tuple-threshold 选项:

(mp/provide
 [[1 "kikka" true]
  [2 "kukka" true]
  [3 "kakka" false]]
 {:::mp/tuple-threshold 3})
; => [:tuple :int :string :boolean]

样本数据可以用 ::mp/hint 类型提示:

(mp/provide
 [^{::mp/hint :tuple}
  [1 "kikka" true]
  ["2" "kukka" true]])
; => [:tuple :some string? boolean?]

### 值解码在推断中的应用

默认情况下, 不对(叶子)值应用解码:

(mp/provide
 [{:id "caa71a26-5fe1-11ec-bf63-0242ac130002"}
 {:id "8aadbf5e-5fe3-11ec-bf63-0242ac130002"}])
; => [:map [:id string?]]

通过 ::mp/value-decoders 选项添加自定义解码:

(mp/provide
 [{:id "caa71a26-5fe1-11ec-bf63-0242ac130002"
  :time "2021-01-01T00:00:00Z"}
 {:id "8aadbf5e-5fe3-11ec-bf63-0242ac130002"
  :time "2022-01-01T00:00:00Z"}]
 {::mp/value-decoders {:string {:uuid mt/-string->uuid
                                'inst? mt/-string->date}}})
; => [:map [:id :uuid] [:time inst?]]

30. 描述

你可以调用 describe 获取模式的英文描述:

(require '[malli.experimental.describe :as med])

(med/describe [:map {:closed true}
               [:x :boolean]
               [:y :int]])
;; => "map where {:x -> <boolean>, :y -> <int>} with no other keys"

31. 链接(以及感谢)

32. Alpha 版本

Malli 的公共 API 在 [pre-alpha](https://github.com/metosin/malli/issues/207) 和 alpha 版本中已经相当稳定, 我们尽量不破坏现有功能. 然而, 库仍在发展, 像 [值解构](https://github.com/metosin/malli/issues/241) 可能 会影响公共 API, 并 很可能 影响库的扩展者, 例如需要为自定义模式实现新的协议方法.

所有更改(无论是否破坏性)都将在 [CHANGELOG](CHANGELOG.md) 中记录, 并在需要时提供迁移指南和路径.

API 层和稳定性:

  • 公共 API: 公共变量, 名称不以 - 开头, 例如 malli.core/validate. 库中最稳定的部分, alpha 版本中不应有(大)变动.
  • 扩展 API: 公共变量, 名称以 - 开头, 例如 malli.core/-collection-schema. 不需要基本用例, 可能在 alpha 期间发展, 详细信息请参见 [CHANGELOG](CHANGELOG.md).
  • 实验性: 位于 malli.experimental 命名空间中的内容, 代码可能会被移动到单独的支持库, 但你总是可以将旧实现复制到项目中, 因此可以放心使用.
  • 私有 API: 私有变量和 malli.impl 命名空间, 一切皆有可能.

33. 开发

Malli 欢迎贡献. 在提交 PR 之前, 请先为其打开一个 issue.

33.1. 添加新模式类型

要添加一个新的模式类型, 例如 :float, 你应添加以下内容:

  • malli.core 中定义模式 + 测试
  • 默认编码器/解码器映射到 malli.transform + 测试
  • JSON Schema 映射到 malli.json-schema + 测试
  • 生成器映射到 malli.generator + 测试
  • 可选: 向 malli.provider 添加推断器 + 测试
  • 更新 README.md

33.1.1. 运行测试

我们使用 [Kaocha](https://github.com/lambdaisland/kaocha) 和 [cljs-test-runner](https://github.com/Olical/cljs-test-runner) 作为测试运行器. 在运行测试之前, 你需要安装 NPM 依赖.

#+endsrcbash npm install ./bin/kaocha ./bin/node #+endsrc

### 本地安装

#+endsrcbash clj -Mjar clj -Minstall #+endsrc

33.1.2. ClojureScript 的包大小

使用默认注册表(37KB+ Gzipped)

# 没有 sci
npx shadow-cljs run shadow.cljs.build-report app /tmp/report.html

# 使用 sci
npx shadow-cljs run shadow.cljs.build-report app-sci /tmp/report.html

# 使用 cherry
npx shadow-cljs run shadow.cljs.build-report app-cherry /tmp/report.html

使用最小注册表(2.4KB+ Gzipped)

# 没有 sci
npx shadow-cljs run shadow.cljs.build-report app2 /tmp/report.html

# 使用 sci
npx shadow-cljs run shadow.cljs.build-report app2-sci /tmp/report.html

# 使用 cherry
npx shadow-cljs run shadow.cljs.build-report app2-cherry /tmp/report.html

33.1.3. 格式化代码

clojure-lsp format
clojure-lsp clean-ns

33.1.4. 检查生成的代码

npx shadow-cljs release app --pseudo-names

33.1.5. 在 GraalVM 上测试

没有 sci(11Mb)

./bin/native-image demo
./demo '[:set :keyword]' '["kikka" "kukka"]'

有 sci(18Mb):

./bin/native-image demosci
./demosci '[:fn (fn [x] (and (int? x) (> x 10)))]]' '12'

33.1.6. Babashka

从版本 0.8.9 开始, Malli 与 [babashka](https://babashka.org/) 兼容, 这是一个用于脚本的本地, 快速启动的 Clojure 解释器.

你可以将 malli 添加到 bb.edn:

{:deps {metosin/malli {:mvn/version "0.9.0"}}}

或直接在 babashka 脚本中:

(ns bb-malli
  (:require [babashka.deps :as deps]))

(deps/add-deps '{:deps {metosin/malli {:mvn/version "0.9.0"}}})

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

(prn (malli/validate [:map [:a [:int]]] {:a 1}))
(prn (malli/explain [:map [:a [:int]]] {:a "foo"}))

33.1.7. 第三方库

  • Aave, Clojure 代码检查工具.
  • Gungnir, 用于 Clojure 数据映射的高级数据驱动数据库库.
  • Regal, 皇家化的正则表达式.
  • Reitit, 一个快速的数据驱动路由器, 适用于 Clojure/Script.
  • wasm, 符合规范的 WebAssembly 编译器和反编译器.
  • malli-instrument, 模仿 clojure.spec.alpha API 的 Malli 仪器.
  • Snoop, 使用 Malli 模式的函数仪器化.
  • malli-key-relations, 关于 map 键的关系模式.
  • malli-cli, 命令行处理.
  • malapropism, 基于 malli 的配置库.
  • muotti, 带有 malli 支持的基于图的值转换库.
  • malli-select, Malli 的 spec2 选择(当你只需要部分时的羊群 🐑).

33.2. 许可证

版权所有 © 2019-2022 Metosin Oy 及贡献者.

根据 Eclipse 公共许可证 2.0 版本的条款提供, 参见 LICENSE.

Author: 青岛红创翻译

Created: 2025-06-10 Tue 09:25