基于Scala Cats Validated的复杂ADT验证技术问询
作为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

