使用 Haskell 进行可组合数据验证

2021-07-27 23:58:33

最近,一位客户要求我们为他们开发新的规则引擎。该系统充当高度可配置仪表板的后端,非技术用户可以在其中定义系统特定组件行为的业务规则。部署后,该系统必须处理大量生产工作负载,因此性能也是一个关键考虑因素。可配置 - 应为最终用户提供切换以从固定类型的规则中获得所需的行为。快速 - 该系统应该能够每秒处理数百个验证请求。健壮 - 系统不应该宕机,并且应该很容易即时修改。声明性 - 产品经理应该能够查看规则代码并对规则的作用有相当准确的理解。为了满足上述要求,我们决定编写一个小型嵌入式领域特定语言(eDSL)来支持编写声明式验证规则。本文将展示生产中使用的实际语言的简化版本。我们将使用浅嵌入,也称为单态最终无标签编码。我们必须问自己的第一个问题不是我们想要执行什么操作,而是我们的问题或领域的本质是什么,这通常称为指称设计。

在我们的例子中,这相对简单——验证是从输入值到它是否有效的映射。将其放入代码中:以此为基础,我们可以开始编写帮助我们构建这些验证规则的函数,对于我们的基本规则,我们将使用尾随“_”的约定来避免名称冲突。一个简单的例子是等式规则: eq_ :: Eq a => a -> ValidationRule a eq_ ruleValue = ValidationRule $ \actual -> actual == ruleValue 在上面, ruleValue 是嵌入在 ValidationRule 中的值,而 actual 是此规则将在验证操作期间尝试验证的值。如何构建和配置此规则的一个示例是: 我们已经看到规则由静态和动态部分组成。规则的形状由函数 eq_ 静态确定,但值(在我们的示例中为 5)可以是任何运行时值。 eq_ 不是我们可能想要的唯一规则。让我们制定一些更基本的规则: lt_, gt_ :: Ord a => a -> ValidationRule a gt_ ruleValue = ValidationRule $ \actual -> actual > ruleValue lt_ ruleValue = ValidationRule $ \actual -> actual < ruleValue 有了这个,我们的eDSL 为我们提供了一种将值与某物进行比较的方法,但仅此一项并不是很有用。每次出现新的业务用例时,我们都可以制定定制规则,但我们需要的是建立更大验证的能力。在布尔逻辑中,我们有两个主要的合取和析取运算,也称为 AND 和 OR。让我们写下它们:

and_, or_ :: ValidationRule a -> ValidationRule a -> ValidationRule a and_ rule1 rule2 = ValidationRule $ \actual -> validate rule1 actual && validate rule2 actual or_ rule1 rule2 = ValidationRule $ \actual -> validate rule1 actual || validate rule2 actual not_ :: ValidationRule a -> ValidationRule a not_ rule = ValidationRule $ \actual -> not $ validate rule actual 我们可以组合到目前为止编写的规则来创建新的组合子。让我们创建大于或等于运算符(在大多数语言中为 >=): geq_ :: Ord a => a -> ValidationRule a geq_ value = gt_ value `or_` eq_ value 目前,仍然有一个关键的限制我们的语言。所有这些规则都需要属于同一类型,我们无法更改该类型。通常,当我们看到看起来像 fa 的东西并且想要 fb 时,我们会在 Functor 类型类上找到 fmap。让我们尝试为我们的类型编写它: instance Functor ValidationRule where fmap :: (a -> b) -> ValidationRule a -> ValidationRule b fmap f rule = ValidationRule $ \actual -> validate rule ????我们遇到了一个问题,我们有一个函数 a -> b 和一个 ValidationRule a 并且想要一个 ValidationRule b。这意味着当我们尝试构建新的验证规则时,我们有一个值 actual :: b 但验证规则需要 a 类型的东西。如果我们有一个函数 b -> a 我们可以实现这一点。然而,f 与我们需要的正好相反。看起来我们想要一些类似于反向函子的东西:

notQuiteFmap :: (b -> a) -> ValidationRule a -> ValidationRule b notQuiteFmap f rule = ValidationRule $ \actual -> validate rule (f actual) 这个函数有一个名字,contramap,它属于也称为逆变函子作为协函。 Contravariant 的文档定义了两者之间的区别:在 Haskell 中,人们可以将 Functor 视为包含或产生值,而逆变函子是可以被视为消费值的函子。实际上,使用的示例是 Predicate a,它与我们的类型 ValidationRule a 完全相同。当你能找到一个预先存在的类型来验证你的方法时,总是很好的。提供的示例(检查帐户余额是否透支)很好地演示了如何使用 contramap,因此让我们实现它。首先我们设置我们账户的数据类型和我们的否定规则,它是一个整数: data Account = Account { accountBalance :: Integer , accountName :: Text , accountOwner :: Text } negative_ :: ValidationRule Integer negative_ = lt_ 0 我们希望能够验证一个帐户,但我们只有一个适用于 Integer 的规则,我们不想编写大量一次性规则。这是我们可以应用 contramap 功能的地方:

这相当简洁,那是什么意思?用简单的英语,如果 accountBalance(我们使用 contramap 的字段的名称)是negative_(我们 contramap 的规则),我们可以说 Account(正在验证的类型)被透支(函数的名称)。我们可以想象这在更大的验证中使用: accountOwnedBy :: Text -> ValidationRule Account accountOwnedBy owner = contramap accountOwner $ eq_ownerdrawingAllowed :: ValidationRule AccountdrawingAllowed = accountOwnedBy "Alice" `and_` (not_ overdrawn) 所以我们现在可以制定相当复杂的验证规则,这些规则几乎像英语一样逐字阅读。 “如果该帐户由 Alice 所有并且没有透支,则允许对该帐户进行提款。”这很酷,但我们开始看到 API 磨损的人体工程学。看上面的例子,我们知道是否允许提款,但我们不知道为什么不允许提款。例如,失败可能是由于帐户透支而发生的,但也可能是因为帐户的所有者是 Bob 而不是 Alice。让我们稍微修改一下我们的语义域,以帮助我们跟踪失败的原因。为了跟踪这一点,我们将定义一个 Validation 数据类型,而不是使用 Bool 作为我们的返回值: type ErrMsg = Text data Validation err = Success |失败 err 类型 ValidationResult = Validation [ ErrMsg] 成功 :: ValidationResult 成功 = 成功失败 :: ErrMsg -> ValidationResult 失败 errMsg = 失败 [errMsg] newtype ValidationRule a = ValidationRule { validate :: a -> ValidationResult } 我们将需要现在重写我们的核心函数以使用新的表示。我们只会重写其中的一些。其余的留给读者作为练习。

eq_ :: ( Show a, Eq a) => a -> ValidationRule a eq_ value = ValidationRule $ \actual -> if actual == value then success else failure (Text.pack $ "Expected " <> show actual <> "等于 " <> 显示值) and_ :: ValidationRule a -> ValidationRule a -> ValidationRule a and_ rule1 rule2 = ValidationRule $ \actual -> case (validate rule1 actual, validate rule2 actual) of ( Failure e1, Failure e2) - > 失败 (e1 <> e2) ( 失败 e1, _) -> 失败 e1 (_, 失败 e2) -> 失败 e2 ( 成功, 成功) -> 成功 or_ :: ValidationRule a -> ValidationRule a -> ValidationRule a or_ rule1 rule2 = ValidationRule $ \actual -> case (validate rule1 actual, validate rule2 actual) of ( Failure e1, Failure e2) -> Failure (e1 <> e2) ( Success, _) -> Success (_, Success) - > 成功 现在我们可以运行我们的验证功能,如果有失败,我们将知道验证失败的原因。最好的部分是,我们实际上不必更改顶级规则的编写方式(尽管我们必须重新编译,因为我们没有充分利用多态最终无标记方法)。由于只有我们的原语知道 ValidationResult,因此在此更改期间我们不必更新任何更复杂的业务规则。在这篇文章中,我们通过创建 eDSL 解决了可配置验证的问题,该 eDSL 允许我们通过组合原始规则来创建复杂的业务规则。使用这种方法验证输入的性能非常好,因为在组合函数时几乎没有解释性开销,就像我们在这里所做的那样。我们在这里展示的 eDSL 只比我们为客户构建的稍微简单一点,并且清楚地说明了核心思想。像这样的语言可以根据需要继续发展,通常不需要重写现有的规则,正如我们上面看到的。我们在这篇文章中没有涉及的一个方面是向规则添加上下文,这使我们能够看到我们的原始规则之一相对于更大的验证失败的地方(例如,我们期望“Alice”但在“Bob”的上下文中得到了“Bob”)验证帐户所有者)。 Ben Levy 和 Christian Charukiewicz 是 Foxhound Systems 的合伙人和首席软件工程师。在 Foxhound Systems,我们专注于构建快速可靠的定制软件。您是否正在寻求有关您正在做的事情的帮助?请通过 [email protected] 与我们联系。