24 次发布
0.5.2 | 2024 年 1 月 28 日 |
---|---|
0.5.1 | 2023 年 11 月 15 日 |
0.5.0 | 2022 年 11 月 24 日 |
0.4.0 | 2022 年 7 月 12 日 |
0.1.3 | 2019 年 11 月 28 日 |
#2 in #complex
433 每月下载量
用于 2 crates
40KB
654 行
semval
一个轻量级且无偏见的库,用于在 Rust 中进行语义验证,具有最小的依赖性。
没有任何宏魔法,至少现在还没有。
TL;DR 如果你需要在运行时验证复杂的数据结构,那么这个 crate 可能可以帮助你用语义验证来丰富你的领域模型。
动机
如何递归地验证复杂的数据结构,收集所有违规行为,并最终报告或评估这些发现?在将外部数据馈送到进一步的处理阶段之前,在运行时验证外部数据对于避免不一致性至关重要,更不用说防止物理损害。
示例
用例
假设你正在创建一个用于管理餐厅 预订 的网络服务。客户可以预订特定的开始时间和客人数。作为 联系方式,他们需要留下他们的 电话号码 或 电子邮件地址,至少需要提供其中之一。
创建新预订的 JSON 请求体可能如下所示
{
"start": "2019-07-30T18:00:00Z",
"number_of_guests": 4,
"customer": {
"name": "slowtec GmbH",
"contact_data": {
"phone": "+49 711 500 716 72",
"email": "[email protected]"
}
}
}
领域模型
让我们专注于联系方式。在 Rust 中对应的类型安全数据模型可能看起来像这样
struct PhoneNumber(String);
struct EmailAddress(String);
struct ContactData {
pub phone: Option<PhoneNumber>,
pub email: Option<EmailAddress>,
}
在这个例子中,电话号码和电子邮件地址仍然由字符串表示,但被包裹在只有一个成员的元组结构体中。这种常用的 元组结构体 模式在编译时建立类型安全,并使我们能够为这些类型添加 行为。
业务规则
我们的预订业务要求只有在以下所有条件都满足时,才能接受联系方式实体
- 电子邮件地址有效
- 电话号码有效
- 电子邮件地址、电话号码或两者都存在
验证
让我们为 预订 用例开发一个软件设计。它应该能够根据我们的业务要求验证领域实体。
我们将仅关注 联系方式 实体以简化问题。这足以推断基本原理。完整的代码可以在仓库中提供的示例文件 reservation.rs 中找到。
无效性
验证可能有哪些结果?如果验证成功,我们就完成了,处理将继续,就像什么都没发生一样,也就是说,验证通常是一个幂等操作。如果验证失败,我们希望以某种方式理解它为什么失败,以解决冲突或修复不一致。最后,我们可能需要将任何未解决的问题反馈给调用者。
验证失败的原因是用无效性来表达的。无效性基本上是某些验证条件的逆。
联系数据的无效性变体有
- 电子邮件地址无效
- 电话号码无效
- 电子邮件地址和电话号码都缺失
请注意,不同的无效性变体可能同时适用,例如,同一个实体的电子邮件地址和电话号码可能都无效。
结果
我们已经意识到验证成功的结果基本上是无的。在Rust中,这个无由单元类型()
表示。
任何无效性都会导致验证失败。这意味着我们在检测到第一个无效性时应该尽早失败并终止验证吗?不一定。考虑与直接用户交互的表单验证用例。如果用户提交包含多个无效或缺失字段的表单,我们应该报告所有这些字段以减少不成功重试和往返的次数。
这导致我们对验证结果有一个初步的定义
type NaiveValidationResult = Result<(), Vec<Invalidity>>
我们将在稍后对其进行细化。
上下文
验证是一个递归操作,需要遍历深度嵌套的数据结构。这种遍历的当前状态定义了具有某种抽象级别的验证上下文。
在ContactData
级别,如果存在,我们需要递归验证电话号码和电子邮件地址。这些下属验证在较低的抽象级别上执行,不知道高级别的上下文。
此外,我们检查这两个成员是否都缺失,然后拒绝作为不完整的ContactData
。这是当前级别唯一实际实现且不使用递归的验证。
让我们使用求和类型将所有可能的变体编码在Rust中
enum PhoneNumberInvalidity {
...lower abstraction level...
}
enum EmailAddressInvalidity {
...lower abstraction level...
}
enum ContactDataInvalidity {
Phone(PhoneNumberInvalidity),
Email(EmailAddressInvalidity),
Incomplete,
}
请注意,每个验证结果仅指代一个Invalidity
类型。通过将较低级别上下文的Invalidity
类型包裹在下属变体中,实现了从较低级别上下文到验证结果的递归嵌套。这些变体的名称通常类似于当前上下文中的角色名称。
结果 ...继续
在初步考虑的基础上,我们现在能够最终确定通用验证结果的定义
struct ValidationContext<V: Invalidity> {
...implementation details...
}
type ValidationResult<V: Invalidity> = Result<(), ValidationContext<V>>
ValidationContext
负责以关联的Invalidity
类型的多个变体形式收集验证结果。每个项目代表违反某些验证条件的行为,即已检测到的单个无效性。无效性如何收集的具体实现是隐藏的。
行为
我们通过实现通用Validate
特质来增强我们的领域实体
pub trait Validate {
type Invalidity: Invalidity;
fn validate(&self) -> ValidationResult<Self::Invalidity>;
}
关联类型Invalidity
通常定义为对应领域实体的伴随类型,就像我们上面看到的。不要被同名特质的限制所迷惑,它只是一个别名,代表Any + Debug
。
假设我们的复合实体 ContactData
的所有组件已经实现了此特性,实现过程就变得简单直接。
impl Validate for ContactData {
type Invalidity = ContactDataInvalidity;
fn validate(&self) -> ValidationResult<Self::Invalidity> {
ValidationContext::new()
.validate_with(&self.email, ContactDataInvalidity::EmailAddress)
.validate_with(&self.phone, ContactDataInvalidity::PhoneNumber)
.invalidate_if(
// Either email or phone must be present
self.email.is_none() && self.phone.is_none(),
ContactDataInvalidity::Incomplete,
)
.into()
}
}
验证函数首先创建一个新的空上下文。然后,它通过递归收集子验证的结果以及执行自己的验证规则。最后,它将上下文转换成结果,以便将其返回给调用者。
流畅接口 已被证明在大多数用例中非常有用且易于阅读,即使更复杂的验证可能需要在某些点上打断控制流。
推论
我们将业务需求的验证规则转换成了几行综合的代码。此代码与相应的领域实体相关联,并且只需要考虑一个抽象级别。递归组合使我们能够验证复杂的数据结构,并追踪验证失败的根源。
验证代码与基础设施组件独立,是包含在系统 功能核心 中的理想候选。通过简单的单元测试,我们可以验证验证是否按预期工作,并可靠地保护我们免受接受无效数据的影响。
不要做的事情
我们未涉及
- 如何通过将它们定义为 标记变体 来增强
Invalidity
类型,以包含额外的、与上下文相关的数据,以及 - 如何路由和解释验证结果。
这两个问题的答案相互依赖,需要特定于用例的解决方案,并且不受此库的任何限制。
许可证
根据Mozilla公共许可证2.0(MPL-2.0)授权(请参阅 MPL-2.0.txt 或 https://www.mozilla.org/MPL/2.0/)。
此版权许可的权限取决于提供授权文件的源代码及其修改的文件(在特定情况下,GNU许可证之一)。必须保留版权和许可通知。贡献者提供专利权的明确授予。但是,使用授权作品的大型作品可以根据不同的条款分发,并且不需要添加在大型作品中添加的文件。
贡献
您有意提交给工作以供包含的任何贡献都应根据Mozilla公共许可证2.0(MPL-2.0)授权。
必须在每个文件的顶部添加以下标题,并带有相应的 SPDX简短标识符
// SPDX-License-Identifier: MPL-2.0
依赖项
~74KB