1. 概述

在开发中,实现密码验证机制以确保用户设置强密码是一个常见任务。

本文将介绍如何在 Kotlin 中实现一个灵活、可扩展且健壮的密码验证机制。

2. 问题背景

一个强密码通常要求至少一定长度,并包含大小写字母、数字、特殊字符等组合。

乍一看,密码验证似乎很简单。一些开发者可能会使用正则表达式来处理,例如:

val pattern = "... 一个复杂的正则表达式 ...".toRegex()

if (password.length < minLength || !password.matches(pattern)) {
    // 密码不合法
}

虽然这可能有效,但每当密码规则发生变化时都需要修改正则表达式,维护成本高。

因此,我们的目标是构建一个符合 Kotlin 风格、灵活、可扩展且有效的密码验证机制。

我们以如下规则为例:

const val ERR_LEN = "密码至少包含8个字符!"
const val ERR_WHITESPACE = "密码不能包含空格!"
const val ERR_DIGIT = "密码必须包含至少一个数字!"
const val ERR_UPPER = "密码必须包含至少一个大写字母!"
const val ERR_SPECIAL = "密码必须包含至少一个特殊字符,如:_%-=+#@"

此外,我们准备了如下测试用例:

val tooShort = "a1A-"
val noDigit = "_+Addabc"
val withSpace = "abcd A#1#"
val noUpper = "1234abc#"
val noSpecial = "1abcdABCD"
val okPwd = "1234abc#ABC"

接下来,我们逐步构建验证机制。

3. 创建验证函数

我们可以通过分别检查每条规则来实现灵活的验证逻辑。首先,我们构建一个验证函数:

fun validatePassword(pwd: String) = runCatching {
    require(pwd.length >= 8) { ERR_LEN }
    require(pwd.none { it.isWhitespace() }) { ERR_WHITESPACE }
    require(pwd.any { it.isDigit() }) { ERR_DIGIT }
    require(pwd.any { it.isUpperCase() }) { ERR_UPPER }
    require(pwd.any { !it.isLetterOrDigit() }) { ERR_SPECIAL }
}

该函数使用 runCatching 包裹多个 require 调用,require 在条件不满足时抛出 IllegalArgumentException,并附带错误信息。

通过 runCatching 返回的 Result 对象,我们可以判断验证是否成功,并在失败时获取错误信息。

测试代码如下:

validatePassword(tooShort).apply {
    assertTrue { isFailure }
    assertEquals(ERR_LEN, exceptionOrNull()?.message)
}

validatePassword(okPwd).apply {
    assertTrue { isSuccess }
}

这种方式比正则表达式更易维护,也便于扩展。

4. 创建 Password 内联类

4.1 扩展 String 类的方法

我们可以将每条规则拆分成 String 的扩展函数,方便组合使用:

fun String.longerThan8() = require(length >= 8) { ERR_LEN }
fun String.withoutWhitespace() = require(none { it.isWhitespace() }) { ERR_WHITESPACE }
fun String.hasDigit() = require(any { it.isDigit() }) { ERR_DIGIT }
fun String.hasUppercase() = require(any { it.isUpperCase() }) { ERR_UPPER }
fun String.hasSpecialChar() = require(any { !it.isLetterOrDigit() }) { ERR_SPECIAL }

然后我们可以将这些方法作为函数引用组合使用:

val checks = listOf(
    String::longerThan8,
    String::withoutWhitespace,
    String::hasDigit,
    String::hasUppercase,
    String::hasSpecialChar,
)

验证时使用:

runCatching { checks.forEach { it(noDigit) } }.apply {
    assertTrue { isFailure }
    assertEquals(ERR_DIGIT, exceptionOrNull()?.message)
}

但这种方式会污染 String 类,可能导致其他开发者误解。

4.2 使用内联类封装验证逻辑

Kotlin 的内联类可以避免污染 String 类,同时提供类型安全的封装。我们创建 Password 内联类如下:

@JvmInline
value class Password(val pwd: String) : CharSequence by pwd {
    fun longerThan8Rule() = require(pwd.length >= 8) { ERR_LEN }
    fun withoutWhitespaceRule() = require(pwd.none { it.isWhitespace() }) { ERR_WHITESPACE }
    fun hasDigitRule() = require(pwd.any { it.isDigit() }) { ERR_DIGIT }
    fun hasUppercaseRule() = require(pwd.any { it.isUpperCase() }) { ERR_UPPER }
    fun hasSpecialCharRule() = require(pwd.any { !it.isLetterOrDigit() }) { ERR_SPECIAL }

    infix fun checkWith(rules: List<KFunction1<Password, Unit>>) = runCatching { rules.forEach { it(this) } }
}

使用方式如下:

val rules = listOf(
    Password::hasDigitRule,
    Password::longerThan8Rule,
    Password::withoutWhitespaceRule,
    Password::hasSpecialCharRule,
    Password::hasUppercaseRule
)

(Password(tooShort) checkWith rules).apply {
    assertTrue { isFailure }
    assertEquals(ERR_LEN, exceptionOrNull()?.message)
}

✅ 优势:不污染 String 类,支持灵活扩展规则。

❌ 缺点:只能通过 Password 类型调用。

4.3 支持管理员密码规则

我们还可以为管理员密码添加额外规则,例如:

fun Password.withoutAdminRule() = require("admin" !in pwd.lowercase()) { "管理员密码不能包含 'admin'" }
fun Password.longerThan10Rule() = require(pwd.length >= 10) { "管理员密码必须大于10个字符!" }

val adminRules = listOf(
    Password::hasDigitRule,
    Password::withoutWhitespaceRule,
    Password::hasSpecialCharRule,
    Password::hasUppercaseRule,
    Password::withoutAdminRule,
    Password::longerThan10Rule
)

测试:

(Password("1234adX%@") checkWith adminRules).apply {
    assertTrue { isFailure }
    assertEquals("管理员密码必须大于10个字符!", exceptionOrNull()?.message)
}

5. 收集所有验证错误信息

目前我们的验证逻辑在遇到第一个失败规则时就会终止。但有时我们希望返回所有验证失败的错误信息。

为此,我们可以在 Password 类中添加 validateWith 方法:

infix fun validateWith(rules: List<KFunction1<Password, Unit>>) = runCatching {
    val message = rules.mapNotNull { 
        runCatching { it(this) }.exceptionOrNull()?.message 
    }.joinToString(separator = "\n")
    require(message.isEmpty()) { message }
}

测试:

val onlyOneSpace = " " // 触发多个错误
(Password(onlyOneSpace) validateWith rules).apply {
    assertTrue { isFailure }
    assertEquals(
        setOf(ERR_LEN, ERR_WHITESPACE, ERR_DIGIT, ERR_UPPER), 
        exceptionOrNull()?.message?.split("\n")?.toSet()
    )
}

这样,我们就能一次性返回所有错误信息,提升用户体验。

6. 总结

我们首先使用 runCatchingrequire 构建了一个灵活的密码验证函数,相比正则表达式更易维护。

接着,我们将规则拆分为 String 扩展函数,提高了灵活性,但也带来了类污染问题。

为了解决这个问题,我们使用 Kotlin 的内联类 Password 封装验证逻辑,既保持了灵活性,又避免了类污染。

最后,我们进一步扩展了验证机制,使其支持返回所有验证失败的错误信息。

所有代码示例可在 GitHub 上找到。


原始标题:Password Validation in Kotlin