Регулярно возникают задачи проверять, что пользователь вводит в поля и сообщать ему если он что-то сделал не правильно.
Ничего в этом сложного нет, напишем парочку регулярных выражений
так
const val SNILS_PATTERN = "[0-9]{3}-[0-9]{3}-[0-9]{3}\\s[0-9]{2}"
и так
const val SPEC_SYMBOLS = "—−–„““”‘’„”«»" const val UPPER_RUS_LETTERS = "А-ЯЁЙ" const val LOWER_RUS_LETTERS = "а-яёй" const val RUS_LETTERS = "$UPPER_RUS_LETTERS$LOWER_RUS_LETTERS" const val RUS_NAME_PATTERN = "[${RUS_LETTERS}IVX0-9\\-`'.()\\s]*" const val RUS_NAME_PATTERN_WITH_COMMA = "[${RUS_LETTERS}IVX0-9\\-`'.,()\\s]*" const val LATIN_LETTERS = "A-Za-z"
еще добавим маски
const val MASK_MOBILE_PHONE = "+7 [000] [000]-[00]-[00]" const val SNILS_MASK = "[000]-[000]-[000] [00]"
и будет норм…
Если вы тоже так считаете, то дальше можно не читать
Мы пойдём другим путём….
Теоретическая часть
Для обработки текста введенного в поле будем применять такую концепцию.Значение введенное в поле проходит последовательно три этапа:
-
фильтрация – удаляем все недопустимые символы
-
валидация – проверяем соответствует ли значение определенным правилам
-
форматирование – форматируем значение для вывода
Проверим как это работает, например, на поле для ввода СНИЛС.
СНИЛС — это
-
11 цифр
-
две последние — контрольная сумма
-
при выводе они форматируются вот так ХХХ-ХХХ-ХХХ ХХ

в поле значение 123-45, пользователь нажимает цифру 7
-
фильтрация – т.к. СИНЛС это только цифры то удаляем все не цифровые символы. получаем 123457. передаем его на валидацию
-
валидация – СНИЛС это 11 цифр значит значение не валидное. передаем дальше «Error(123457)»
-
форматирование – после форматирования получим «Error(123-457)»
после всех этих манипуляций, отображаем в поле 123-457. Отображать ошибку или нет для этого поля решает «логика отображения». В данном случае, пока фокус в поле ввода, ошибку не отображаем.
Теперь перейдём к написанию кода
Этап 1. Фильтрация
Создадим интерфейс Filter
. У него один метод filter: String -> String
interface Filter { fun filter(data: String): String }
И чтобы не возвращаться, сразу сделаем тривиальную реализацию фильтра. Эта реализация возвращает данные без изменения.
object SimpleFilter : Filter { override fun filter(data: String) = data }
и тут в голову приходит мысль, скорее всего нужно иметь возможность соединять фильтры в цепочку.для этого сделаем ComplexFilter
open class ComplexFilter private constructor(private val filters: List<Filter>) : Filter { override fun filter(data: String): String = filters.fold(data) { res, filter -> filter.filter(res) } companion object { fun build(filters: List<Filter>): ComplexFilter { return ComplexFilter(filters) } } }
И сразу напишем DSL для построения фильтров
class ComplexFilterBuilder { private val filters: MutableList<Filter> = mutableListOf() fun build(): ComplexFilter { return ComplexFilter.build(filters) } operator fun Filter.unaryPlus(): ComplexFilterBuilder { filters.add(this) return this@ComplexFilterBuilder } } fun filter(lambda: ComplexFilterBuilder.() -> ComplexFilterBuilder): ComplexFilter { return ComplexFilterBuilder().lambda().build() }
теперь фильтр для поля СНИЛС будет выглядеть вот так
val snilsFilter = filter { +FilterOnlyDigits +FilterMaxLength(11) }
Напишем несколько фильтров
/** * Удаляет из строки все символы пробела */ object FilterSpacesSymbols: Filter { override fun filter(data: String): String = data.filterNot { it.isWhitespace() } } /** * Удаляет из строки все символы из кирииллицы */ object FilterNonLatinsSymbols: Filter { override fun filter(data: String): String = data.filterNot { it.isCyrillic() } } /** * Удаляет из строки указанные символы */ class FilterSymbols(private val filteredSymbols: String): Filter { override fun filter(data: String): String = data.filterNot { it in filteredSymbols } } /** * Оставляет строку длиной не более maxLength символов */ object FilterMaxLength(private val maxLength: Int) : Filter { override fun filter(data: String): String = date.take(maxLength) } /** * Оставляет в строке только цифры */ object FilterOnlyDigits : Filter { override fun filter(data: String): String = data.filter { it.isDigit() } }
И теперь проверим, что snilsFilter
ведёт себя правильно
тесты для snilsFilter
class SnilsFilterTest : FunSpec({ context("Snils filter") { val snilsFilter = filter { +FilterOnlyDigits +FilterMaxLength(11) } withData( listOf( " " to "", "sd fasdf as fsd a fas asd f" to "", "s6d84f65sd46s5d4f" to "684654654", "123-456-789" to "123456789", "123-456-789 11" to "12345678911", "123-456" to "123456", "123-456-789-123-456-789" to "12345678912", "123456789123456789" to "12345678912", ) ) { (data, res) -> snilsFilter.filter(data) should be(res) } } })
c фильтрами закончили, теперь перейдем к валидаторам
Этап 2. Валидация
С валидацией чуть сложнее.
Создадим интерфейс Validator.
Для него нужен метод, который принимает String
возвращает ValidationResult
(результат валидации)
interface Validator { fun validate(data: String): ValidationResult }
ValidationResult
это sealed класс.
-
У него может быть два варианта
Valid
иError
-
Valid
иError
содержать строку с данными -
Error
дополнительно содержит список ошибок :List<ValidationError>
-
ValidationError
— базовый интерфейс для ошибок валидации
interface ValidationError sealed class ValidationResult { abstract val data: String abstract fun isValid(): Boolean class Valid(override val data: String) : ValidationResult() { override fun isValid(): Boolean = true } class Error(override val data: String, val errors: List<ValidationError>) : ValidationResult() { override fun isValid(): Boolean = false } companion object { fun valid(value: String): ValidationResult = Valid(value) fun invalid(value: String, errors: List<ValidationError>): ValidationResult { assert(errors.isNotEmpty()) return Error(value, errors) } fun invalid(value: String, error: ValidationError): ValidationResult { return Error(value, listOf(error)) } } } fun String.asValid() : ValidationResult = ValidationResult.valid(this)
Тривиальный валидатор будет выглядеть так. Он всегда считает данные валидными
class SimpleValidator: Validator { override fun validate(data: String): ValidationResult = data.asValid() }
и снова надо соединять валидаторы в цепочки, т.е. прогонять строку через несколько валидаций для этого
open class ComplexValidator private constructor(private val validators: List<Validator>) : Validator { override fun validate(data: String) = validators.fold(valid(data)) { res, validator -> res.andThen(validator) } companion object { fun build(validators: List<Validator>): ComplexValidator { return ComplexValidator(validators) } } }
для того чтобы последовательно применять валидации добавим несколько методов в ValidationResult
fun bind(anotherValidationFunction: (String) -> ValidationResult): ValidationResult { return when (this) { is Error -> { when(val res = anotherValidationFunction(data)) { is Error -> invalid(res.data, this.errors + res.errors) is Valid -> invalid(res.data, this.errors) } } is Valid -> anotherValidationFunction(data) } } fun andThen(anotherValidator: Validator): ValidationResult = bind { str: String -> anotherValidator.validate(str) }
И DSL для построения валидаторов
class ComplexValidatorBuilder() { private val validators: MutableList<Validator> = mutableListOf() fun build(): ComplexValidator { return ComplexValidator.build(validators) } operator fun Validator.unaryPlus(): ComplexValidatorBuilder { validators.add(this) return this@ComplexValidatorBuilder } } fun validator(lambda: ComplexValidatorBuilder.() -> ComplexValidatorBuilder): ComplexValidator { return ComplexValidatorBuilder().lambda().build() }
Допишем недостающие валидаторы
object OnlyDigitsValidationError: ValidationError object OnlyDigitsValidator: Validator { override fun validate(data: String): ValidationResult = if(data.all { it.isDigit() }) valid(data) else invalid(data, OnlyDigitsValidationError) } object ExactLengthValidationError : ValidationError object ExactLengthValidator(val exactLength: Int) : Validator { override fun validate(data: String): ValidationResult = if (data.length == exactLength) valid(data) else invalid(data, ExactLengthValidationError) } object SnilsCheckSumValidatorError : ValidationError object SnilsCheckSumValidator : Validator { override fun validate(data: String): ValidationResult { if (data.length != 11) return ValidationResult.invalid(data, SnilsCheckSumValidatorError) try { val part1 = data.substring(0, 9) val part2 = data.substring(9, 11) val checkSum = part1 .reversed() .mapIndexed { index, c -> c.digitToInt() * (index + 1) } .sum() .mod(101) .toString() .padStart(2, '0') .takeLast(2) return if (checkSum == part2) ValidationResult.valid(data) else ValidationResult.invalid(data, SnilsCheckSumValidatorError) } catch (e: Exception) { return ValidationResult.invalid(data, SnilsCheckSumValidatorError) } } }
Теперь можно написать валидатор для СНИЛС
val snilsValidator = validator { +ExactLengthValidator(11) // 11 символов +OnlyDigitsValidator // только цифры +SnilsCheckSumValidator // проверка контрольной суммы СНИЛС }
И тесты для snilsValidator
class SnilsValidatorTest: FunSpec( { context("valid snils") { withData( listOf( "11223344595", "12345678964", "98765432183", "11111111145", "45645645617", "91919191943", "77777777712", "95195195147") ){ data -> snilsValidator.validate(data) should beValid() } } context("wrong snils") { withData( listOf( "777777777777777777" to SnilsCheckSumValidatorError, "77777777713" to SnilsCheckSumValidatorError, "7777777" to ExactLengthValidationError, "7sdfsdf7" to OnlyDigitsValidationError, "" to ExactLengthValidationError ) ) { (data, error) -> with(snilsValidator.validate(data)) { this shouldNot beValid() (this as ValidationResult.Error).errors shouldContain error } } } })
Этап 3. Форматирование
Создадим интерфейс Formatter
interface Formatter { fun format(data: String): String }
Тривиальный форматтер будет такой. Он всегда возвращает данные без форматирования
class SimpleFormatter: Formatter { override fun format(data: String) = data }
Немного поразмыслив, приходим к выводу что, скорее всего не надо выполнять несколько форматирований, для одного поля. Поэтому ComplexFormatter
не будем делать.
напишем форматтер для СНИЛСа
class SnilsFormatter: Formatter { override fun format(data: String): String = buildString { data.forEachIndexed { index, c -> when (index) { 0, 1, 2 -> append(c) 3 -> append('-').append(c) 4, 5 -> append(c) 6 -> append('-').append(c) 7, 8, -> append(c) 9 -> append(' ').append(c) 10 -> append(c) else -> append(c) } } } }
тесты для SnilsFormatter
class SnilsFormatterTest : FunSpec({ context("Snils formatter") { withData( listOf( "asdfgh" to "asd-fgh", "" to "", "1" to "1", "12" to "12", "123" to "123", "1234" to "123-4", "12345" to "123-45", "123456" to "123-456", "1234567" to "123-456-7", "12345678" to "123-456-78", "123456789" to "123-456-789", "1234567891" to "123-456-789 1", "12345678900" to "123-456-789 00", "123456789111111111" to "123-456-789 11", ) ) { (data, res) -> SnilsFormatter.format(data) should be(res) } } })
Соберём всё вместе.
добавим такую сущность FormField
-
у нее есть список фильтров
-
список валидаторов
-
форматтер (по умолчанию —
SimpleFormatter
) -
поле может быть обязательным или не обязательным
class FormField private constructor( private val filters: List<Filter> = emptyList(), private val validators: List<Validator> = emptyList(), private val formatter: Formatter = SimpleFormatter(), val isOptional: Boolean = false )
у FormField
всего один метод process
Алгоритм у него простой:
-
входящую строку прогоняет через фильтры
-
если поле не обязательное и полученное значение пустое, то возвращаем
ValidationResult.Valid
-
то что осталось, прогоняем через валидаторы,
-
в полученном
ValidationResult
форматирует текст.
fun process(data: String): ValidationResult { val filtered = filters.fold(data) { res, filter -> filter.filter(res) } return if (filtered.isEmpty() && isOptional) { filtered.asValid() } else { validators .fold(ValidationResult.valid(filtered)) { res, validator -> res.andThen(validator) } .map { formatter.format(it) } } }
Для построения FormField
напишем метод build
. Для того чтобы обрабатывать «обязательность» поля в этом методе, добавляем в список валидаторов NotEmptyValidator
companion object { fun build( filters: List<Filter>, validators: List<Validator>, formatter: Formatter, isOptional: Boolean ): FormField = if (isOptional == true) FormField(filters, validators, formatter, true) else FormField(filters, listOf(NotEmptyValidator) + validators, formatter, false) }
добавим билдер для DSL.
И окончательный результат будет такой
class FormField private constructor( private val filters: List<Filter> = emptyList(), private val validators: List<Validator> = emptyList(), private val formatter: Formatter = SimpleFormatter, val isOptional: Boolean = false ) { fun process(data: String): ValidationResult { val filtered = filters.fold(data) { res, filter -> filter.filter(res) } return if (filtered.isEmpty() && isOptional) { filtered.asValid() } else { validators .fold(ValidationResult.valid(filtered)) { res, validator -> res.andThen(validator) } .map { formatter.format(it) } } } companion object { fun build( filters: List<Filter>, validators: List<Validator>, formatter: Formatter, isOptional: Boolean ): FormField = if (isOptional == true) FormField(filters, validators, formatter, true) else FormField(filters, listOf(NotEmptyValidator) + validators, formatter, false) } } class FieldBuilder(private val isOptional: Boolean = false) { private val filters: MutableList<Filter> = mutableListOf() private val validators: MutableList<Validator> = mutableListOf() private var formatter: Formatter = SimpleFormatter fun build(): FormField { return FormField.build(filters, validators, formatter, isOptional) } operator fun Filter.unaryPlus(): FieldBuilder { filters.add(this) return this@FieldBuilder } operator fun Validator.unaryPlus(): FieldBuilder { validators.add(this) return this@FieldBuilder } operator fun Formatter.unaryPlus(): FieldBuilder { formatter = this return this@FieldBuilder } } /** * возвращает обязательное поле * Example * val f = formField { * +FilterLength(10) * +SnilsValidator() * +SnilsFormatter() * } */ fun formField(lambda: FieldBuilder.() -> FieldBuilder): FormField { return FieldBuilder().lambda().build() } /** * возвращает не обязательное поле. т.е. если значение в поле пустое, * то валидация не происходит и поле считается валидным, * если поле не пустое, то проводятся валидации */ fun optionalFormField(lambda: FieldBuilder.() -> FieldBuilder): FormField { return FieldBuilder(isOptional = true).lambda().build() }
Теперь поле для ввода СНИЛС можно описать так
val snilsField = formField { +snilsFilter +snilsValidator +SnilsFormatter() }
или так
val snilsField = formField { +OnlyDigitsFilter() +MaxLengthFilter(11) +ExactLengthValidator(11) +OnlyDigitsValidator() +SnilsCheckSumValidator() +SnilsFormatter() }
тесты для snilsField
class SnilsFormFieldTest : FunSpec({ context("required snils field") { val f = formField { +FilterOnlyDigits +FilterMaxLength(11) +OnlyDigitsValidator +ExactLengthValidator(11) +SnilsCheckSumValidator +SnilsFormatter } test("empty string") { with(f.process("")) { this shouldNot beValid() (this as ValidationResult.Error) shouldNot beValid() } } test("spaces") { with(f.process(" ")) { this shouldNot beValid() (this as ValidationResult.Error).errors shouldContain NotEmptyValidationError this.errors shouldContain ExactLengthValidationError this.errors shouldContain SnilsCheckSumValidatorError } } test("123-456d") { with(f.process("123-456d")) { this shouldNot beValid() (this as ValidationResult.Error).errors shouldContain ExactLengthValidationError this.errors shouldContain SnilsCheckSumValidatorError this.data should be("123-456") } } test("123-456-789 64") { with(f.process("123-456-789 64")) { this should beValid() this.data should be("123-456-789 64") } } } context("optional snils field") { val f = optionalFormField { +FilterOnlyDigits +FilterMaxLength(11) +OnlyDigitsValidator +SnilsCheckSumValidator +SnilsFormatter } test("empty string") { with(f.process("")) { this should beValid() this.data should be("") } } test("spaces") { with(f.process(" ")) { this should beValid() this.data should be("") } } test("some not digit symbols") { with(f.process("asdfasdfasdf")) { this should beValid() this.data should be("") } } test("123-456d") { with(f.process("123-456d")) { this shouldNot beValid() (this as ValidationResult.Error).errors shouldContain SnilsCheckSumValidatorError this.data should be("123-456") } } test("123-456-789 64") { with(f.process("123-456-789 64")) { this should beValid() this.data should be("123-456-789 64") } } } })
Внимательный читатель задаст вопрос, «Есть OnlyDigitstFilter
и OnlyDigitsValidator
. Выглядит это как что-то избыточное и повторяющееся…тоже самое и с длиной FilterMaxLength
и ExactLengthValidator
«

Краткий ответ — это принцип Single Responsibility доведенный до абсолюта.
Фильтры и валидаторы это разные сущности (фильтры занимаются фильтрацией, а валидаторы занимаются валидацией ), они разделены. И они обе нужны.
В данном случае OnlyDigitsValidator
будет всегда возвращать Valid
, т.к. OnlyDigitsFilter
удалил все не цифровые символы. Но для сохранения целостной картины я оставляю OnlyDigitsValidator
.
ExactLengthValidator
позволяет определить какая именно ошибка случилась. т.е. если введено 5 символов из необходимых 11-ти, то в списке ошибок будет ExactLengthValidationError
.
Можно использовать «оптимизированный» вариант поля снилс
val f = optionalFormField { +FilterOnlyDigits +FilterMaxLength(11) +SnilsCheckSumValidator +SnilsFormatter }
но я предпочитаю полный вариант
val snilsField = formField { +OnlyDigitsFilter() +MaxLengthFilter(11) +ExactLengthValidator(11) +OnlyDigitsValidator() +SnilsCheckSumValidator() +SnilsFormatter() }
Возможны случаи, когда не надо удалять из введенной строки неправильные символы, но при этом надо выводить сообщение об ошибке (далее, если будут следующие части, будет такой пример.) — для этого также необходимо разделение фильтров и валидаторов.
И еще один вопрос «Зачем прогонять все валидации, ведь достаточно будет прекращать проверку сразу после первой неудачной валидации».
Рассмотрим такой пример — поле для составления пароля. Это поле требует много валидаторов на различные условия (содержит цифры, содержит спецсимволы, содержит заглавные буквы и т.д.) и после того как пользователь ввел пароль (или по мере ввода) надо по каждому условию выводить или не выводить ошибку. Для этого поля надо прогонять все валидации. В итоге для универсальности, я решил, что надо в любом случае прогонять все валидации.
Что в итоге получили
-
все поведение поля описано в одном методе
-
описание, отчасти похоже на ТЗ которое пишет аналитик.
-
поведение поля можно полностью протестировать с помощью Unit-тестов
Итак, с одиночным полем ввода понятно. В следующих частях соберем эти поля в форму и прикрутим их к андроиду…
А пока можно рассмотреть ещё один пример
Представим, есть такое поле, «дата рождения».
К значению в этом поле следующие требования
-
значение должно быть правильной датой
-
дата должна быть меньше текущей
-
дата отображается в формате dd.mm.yyyy
-
разрешено вводить только цифровые символы
-
если введена не верная дата, то выводим сообщение об ошибке
-
если введена дата больше текущей, то выводим сообщение об ошибке
Приступим
Сначала создадим метод dateBeforeField(maxDate: LocalDate)
, который возвращает FormField
с ограничением по максимальной дате.
И метод birthDateField()
который уже возращает поле для ввода дня рождения
fun dateBeforeField(maxDate: LocalDate) = formField { +FilterOnlyDigits +FilterMaxLength(8) +ExactLengthValidator(8) +DateValidator() +DateBeforeValidator(maxDate) +DateFormatter } fun birthDateField() = dateBeforeField(currentDate()) fun currentDate() = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
Необходимые фильтры уже написаны, Написать DateFormatter
не сложно
DateValidator
DateValidator
— проверяет, что введена правильная (существующая) дата. Написать его совсем не сложно (хорошо, что есть kotlinx.datetime
) Строка после фильтров приходит в виде DDMMYYYY
, для этого сделаем DDMMYYYYformat
— нужный нам формат даты, остальное очевидно…
val DDMMYYYYformat = LocalDate.Format { dayOfMonth() monthNumber() year() } class DateValidator(val format: DateTimeFormat<LocalDate> = DDMMYYYYformat) : Validator { override fun validate(data: String): ValidationResult = try { LocalDate.parse(data, format) data.asValid() } catch (e: Exception) { ValidationResult.invalid(data, DateValidationError) } }
DateBeforeValidator
Тут тоже ничего сложного, Дополнительно потребуется параметр includeBorder
— включать или не включать границу в разрешенные значения
object DateBeforeValidationError : ValidationError class DateBeforeValidator( val maxDate: LocalDate, val includeBorder: Boolean = false, val format: DateTimeFormat<LocalDate> = DDMMYYYYformat ) : Validator { override fun validate(data: String): ValidationResult = try { val localDate = LocalDate.parse(data, format) when { localDate < maxDate -> data.asValid() localDate == maxDate && includeBorder -> data.asValid() else -> ValidationResult.invalid(data, DateBeforeValidationError) } } catch (e: Exception) { ValidationResult.invalid(data, DateBeforeValidationError) } }
и окончательно проверим, что поле ведет себя как предполагается (не получается вставить спойлер в спойлер)
class BirthDayFieldTest : FunSpec({ val maxDate = LocalDate(2024,10,10) context("required birthday field") { val f = dateBeforeField(maxDate) "".let { test("$it must be invalid") { with(f.process(it)) { this shouldNot beValid() (this as ValidationResult.Error) shouldNot beValid() } } } " ".let { test("$it must be invalid") { with(f.process(it)) { this shouldNot beValid() (this as ValidationResult.Error).errors shouldContain NotEmptyValidationError this.errors shouldContain ExactLengthValidationError this.errors shouldContain DateValidationError } } } "sadfasdf".let { test("$it must be invalid") { with(f.process(it)) { this shouldNot beValid() (this as ValidationResult.Error).errors shouldContain NotEmptyValidationError this.errors shouldContain ExactLengthValidationError this.errors shouldContain DateValidationError } } } "1234".let { test("$it must be invalid") { with(f.process(it)) { this shouldNot beValid() (this as ValidationResult.Error).errors shouldContain ExactLengthValidationError this.errors shouldContain DateValidationError this.data should be("12.34") } } } context("valid dates") { listOf("12.12.2002", "29.02.2024", "09.10.2024", "01.01.2024").forEach { test("$it must be valid") { with(f.process(it)) { this should beValid() this.data should be(it) } } } } context("dates after max date") { listOf("12.12.2025", "28.02.2025", "10.10.2024", "10.10.2025").forEach { test("$it must be invalid") { with(f.process(it)) { this shouldNot beValid() (this as ValidationResult.Error).errors shouldContain DateBeforeValidationError } } } } } context("optional birthday field") { val f = optionalFormField { +FilterOnlyDigits +FilterMaxLength(8) +DateValidator() +DateBeforeValidator(maxDate = maxDate) +DateFormatter } "".let { test("$it must be valid") { with(f.process(it)) { this should beValid() } } } " ".let { test("$it must be valid") { with(f.process(it)) { this should beValid() } } } "sadfasdf".let { test("$it must be valid") { with(f.process(it)) { this should beValid() } } } } })
Пока я писал тесты (а вы их смотрели), появилась мысль, что возможно описать это поле по-другому
Изменим фильтры — разрешим вводить не только цифры но и точки, изменим формат с которым работают валидаторы и тогда не нужен будет форматтер.
val format = LocalDate.Format { dayOfMonth() char('.') monthNumber() char('.') year() } formField { +FilterOnlyDigitsAndDots +FilterMaxLength(10) +ExactLengthValidator(10) +DateValidator(format = format) +DateBeforeValidator(maxDate, format = format) }
Принципиально ничего не меняется, только теперь пользователь сам должен вводить точки в нужных местах , а в первом варианте за него это делал форматтер. И у пользователя появилось гораздо больше возможностей для ввода неправильного значения, например .........
.
Поэтому я выбираю начальный вариант..
На сегодня, точно всё.
До следующей части.

ссылка на оригинал статьи https://habr.com/ru/articles/897064/
Добавить комментарий