You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于Scala Cats Validated的复杂ADT验证技术问询

用Cats Validated处理嵌套case类与跨字段验证的方案

作为Scala新手,你选Cats Validated来做fail slow的验证完全是个正确的选择!它天生就是为收集所有验证错误设计的,不管是嵌套case类还是跨字段验证场景,都能完美适配。下面给你一步步拆解解决方案:

1. 嵌套case类的验证:层层递进,组合验证函数

嵌套结构的验证核心是先验证最底层的case类,再向上组合。每个层级的验证函数都返回ValidatedNel[你的错误类型, 目标类],这样上层验证可以直接复用下层的验证逻辑,同时自动收集所有层级的错误。

先定义错误类型(用密封特质统一管理更规范):

import cats.data.ValidatedNel
import cats.implicits._

sealed trait ValidationError
case class InvalidStreet(msg: String) extends ValidationError
case class InvalidLandMark(msg: String) extends ValidationError
case class InvalidUserName(msg: String) extends ValidationError
// 可根据需求扩展更多错误类型

然后写每个嵌套类的验证函数:

// 验证最底层的LandMark
def validateLandMark(landMark: LandMark): ValidatedNel[ValidationError, LandMark] = {
  val wellKnownCheck = if (landMark.wellKnown && landMark.street.isEmpty) 
    InvalidLandMark("Well-known landmark must have a street name").invalidNel
  else landMark.validNel

  val streetCheck = if (landMark.street.length < 3)
    InvalidLandMark("Street name must be at least 3 characters").invalidNel
  else landMark.validNel

  // 组合多个验证,收集所有错误
  (wellKnownCheck, streetCheck).mapN((_, _) => landMark)
}

// 验证Address:包含List[LandMark]的批量验证
def validateAddress(address: Address): ValidatedNel[ValidationError, Address] = {
  val streetCheck = if (address.street.isEmpty)
    InvalidStreet("Street cannot be empty").invalidNel
  else address.validNel

  // 遍历List[LandMark],验证每个元素并收集所有错误
  val landMarksCheck = address.lds.traverse(validateLandMark)

  // 组合街道和地标验证结果
  (streetCheck, landMarksCheck).mapN(Address)
}

// 验证最上层的User
def validateUser(user: User): ValidatedNel[ValidationError, User] = {
  val nameCheck = if (user.name.length < 2)
    InvalidUserName("User name must be at least 2 characters").invalidNel
  else user.validNel

  // 验证List[Address],收集所有地址的错误
  val addressesCheck = user.adds.traverse(validateAddress)

  (nameCheck, addressesCheck).mapN(User)
}

这里的traverse是处理集合验证的关键,它会自动把每个元素的验证错误合并到同一个NonEmptyList里,不会因单个元素失败就终止验证,完美契合fail slow策略。

2. 跨字段验证:先过单字段,再做全局检查

跨字段验证需要拿到所有字段的有效值后再判断,这时候可以先用mapN完成所有单个字段的验证,得到完整的case类实例,再用andThen追加跨字段检查逻辑——这样既保留前面所有单字段错误,又能新增跨字段错误。

比如你提到的firstName和username编辑距离验证:

// 先定义单个字段的验证函数
def validateUserName(username: String): ValidatedNel[ValidationError, String] = {
  if (username.length < 4) InvalidUserName("Username must be at least 4 characters").invalidNel
  else username.validNel
}

def validateFirstName(firstName: String): ValidatedNel[ValidationError, String] = {
  if (firstName.isEmpty) InvalidUserName("First name cannot be empty").invalidNel
  else firstName.validNel
}

// 完整的注册信息验证:单字段+跨字段
def validateRegistration(data: RegistrationData): ValidatedNel[ValidationError, RegistrationData] = {
  // 第一步:验证所有单个字段,收集所有错误
  val baseValidation = (
    validateUserName(data.username),
    validatePassword(data.password), // 假设你已实现该函数
    validateFirstName(data.firstName),
    validateLastName(data.lastName), // 假设你已实现该函数
    validateAge(data.age) // 假设你已实现该函数
  ).mapN(RegistrationData)

  // 第二步:追加跨字段验证,检查编辑距离
  baseValidation.andThen { validData =>
    // 这里简化编辑距离实现,实际可复用第三方库(比如commons-text)
    def levenshteinDistance(a: String, b: String): Int = 
      Math.abs(a.length - b.length)

    if (levenshteinDistance(validData.firstName, validData.username) > 3) {
      InvalidUserName("First name and username are too different (Levenshtein distance > 3)").invalidNel
    } else {
      validData.validNel
    }
  }
}

andThen的逻辑是:只有前面的单字段验证完全通过(无错误)时,才执行跨字段检查;如果前面已有错误,会直接保留原错误列表,不执行后续逻辑——这完全符合fail slow的需求:先收集所有单字段问题,再做全局规则校验。

3. 关于fail slow策略的必要性

必须强调:收集所有验证失败项是用户输入场景的最佳实践。想象一下,如果用户提交表单后只收到第一个错误(比如“用户名太短”),修正后又收到“密码不符合规则”,再提交又收到“年龄无效”——这种体验非常糟糕。而Cats Validated的ValidatedNel(非空列表)正好能一次性返回所有错误,让用户一次性修正,大幅提升体验。

所以你的思路完全正确,Applicative Functors(Cats Validated)就是为这种场景量身打造的。

内容的提问来源于stack exchange,提问作者advocateofnone

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.11 08:37:55