Валидация данных в iOS приложениях

от автора

Думаю, каждый из нас сталкивался с задачей валидации данных в приложениях. Например, при регистрации пользователя нужно убедиться что email имеет правильный формат, а пароль удовлетворяет требованиям безопасности и так далее. Можно привести массу примеров, но все в итоге сводится к одной задаче — валидация данных перед отправкой формы.

Работая над очередным проектом, я задумался над тем, чтобы создать универсальное решение, а не писать методы валидации для каждого экрана с формой отдельно. В Swift 5.1 появилась аннотация @propertyWrapper, и я подумал, что было бы удобно иметь синтаксис наподобие следующего:

@Validated([validator1, validator2, ...]) var email: String? = nil  let errors =  $email.errors //массив ошибок валидации

Валидаторы

Первым делом нужно было определиться с тем, как будут выглядеть и работать валидаторы. В рамках моего проекта мне было необходимо проверить валидность данных и вывести соответствующие сообщения об ошибках. Необходимости в более сложной обработке ошибок не было, поэтому реализация валидатора получилась следующая:

struct ValidationError: LocalizedError {     var message: String     public var errorDescription: String? {         message     } }  protocol Validator {     associatedtype ValueType     var errorMessage: String { get }     func isValid(value: ValueType?) -> Bool }  extension Validator {     func validate(value: ValueType?) throws {         if !isValid(value: value) {             throw ValidationError(message: errorMessage)         }     } }

Тут все просто. Валидатор содержит сообщение об ошибке, с помощью которого создается ValidationError, если валидация не пройдена. Это упрощает обработку ошибок, поскольку все валидаторы возвращают один и тот же тип ошибки, но с разными сообщениями. В качестве примера можно привести код валидатора, проверяющего строку на соответствие регулярному выражению:

struct RegexValidator: Validator {     public var errorMessage: String     private var regex: String     public init(regex: String, errorMessage: String) {         self.regex = regex         self.errorMessage = errorMessage     }     public func isValid(value: String?) -> Bool {         guard let v = value else { return false }                 let predicate = NSPredicate(format: "SELF MATCHES %@", regex)         return predicate.evaluate(with: v)     } }

Данная реализация содержит одну известную многим проблему. Поскольку протокол Validator содержит associatedtype, то мы не можем создать переменную типа

var validators:[Validator] //Protocol 'Validator' can only be used as a generic constraint because it has Self or associated type requirements

Для решения данной проблемы используем стандартный подход, а именно, создание структуры AnyValidator.

private class ValidatorBox<T>: Validator {     var errorMessage: String {         fatalError()     }      func isValid(value: T?) -> Bool {         fatalError()     } }  private class ValidatorBoxHelper<T, V:Validator>: ValidatorBox<T> where V.ValueType == T {     private let validator: V      init(validator: V) {         self.validator = validator     }      override var errorMessage: String {         validator.errorMessage     }      override func isValid(value: T?) -> Bool {         validator.isValid(value: value)     } }  struct AnyValidator<T>: Validator {     private let validator: ValidatorBox<T>      public init<V: Validator>(validator: V) where V.ValueType == T {         self.validator = ValidatorBoxHelper(validator: validator)     }      public var errorMessage: String {         validator.errorMessage     }      public func isValid(value: T?) -> Bool {         validator.isValid(value: value)     } }

Думаю, тут нечего комментировать. Это стандартный подход для решения проблемы описанной выше. Также было бы полезно добавить расширение для протокола Validator, позволяющее создавать AnyValidator объект.

extension Validator {     var validator: AnyValidator<ValueType> {         AnyValidator(validator: self)     } }

Property wrapper

С валидаторами разобрались, можно переходить непосредственно к реализации обертки @Validated.

@propertyWrapper class Validated<Value> {        private var validators: [AnyValidator<Value>]     var wrappedValue: Value?      init(wrappedValue value: Value?, _ validators: [AnyValidator<Value>]) {         wrappedValue = value         self.validators = validators     }      var projectedValue: Validated<Value> {         self     }      public var errors: [ValidationError] {         var errors: [ValidationError] = []         validators.forEach {             do {                 try $0.validate(value: wrappedValue)             }             catch {                 errors.append(error as! ValidationError)             }         }         return errors     } }

В цели данной статьи не входит разбор того как работают обертки propertyWrapper и какой синтаксис они используют. Если вам еще не удалось с ними познакомиться, то советую прочитать мою другую статью How to Approach Wrappers for Swift Properties(English).

Данная реализация позволяет нам объявлять свойства, требующие валидации следующим образом:

@Validated([ NotEmptyValidator(errorMessage: "Email can't be empty").validator, RegexValidator(regex:"[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-za-z]{2,64} ", errorMessage:"Email has wrong format").validator ]) var email: String? = nil

И получать массив ошибок валидации в любой момент времени следующим образом

let errors = $email.errors

image
Есть вероятность, что некоторые комбинации валидаторов (например, валидация email) будут встречаться в приложении на нескольких экранах. Для того, чтобы избежать копирования кода, можно в таких случаях создавать отдельный wrapper, унаследованный от Validated.

@propertyWrapper final class Email: Validated<String> {     override var wrappedValue: String? {         get {             super.wrappedValue         }         set {             super.wrappedValue = newValue         }     }      override var projectedValue: Validated<String> {         super.projectedValue     }      init(wrappedValue value: String?) {         let notEmptyValidator = NotEmptyValidator(errorMessage: "Email can’t be empty")         let regexValidator = RegexValidator(regex:"[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-za-z]{2,64} ", errorMessage:"Email has wrong format").validator         super.init(wrappedValue: value, [notEmptyValidator, regexValidator])     } }  @Email var email: String? = nil

К сожалению, на данный момент аннотация @propertyWrapper обязывает переопределять wrappedValue и projectedValue, в противном случае мы получим ошибку компиляции. Выглядит это как баг реализации, так что возможно, что в будущих версиях Swift это будет исправлено.

Добавляем Reactive

Вместе с iOS13 пришел нативный фреймворк для реактивного программирования Combine. И я подумал, что может быть полезным иметь следующий синтаксис:

let cancellable = $email             .publisher             .map { $0.map { $0.localizedDescription }.joined(separator: ", ") }             .receive(on: RunLoop.main)             .assign(to: \.text, on: emailErrorLabel)

Это позволит обновлять информацию об ошибках валидации в режиме реального времени (после каждого введенного символа). Первоначальная реализация этой идеи выглядела следующим образом:

@propertyWrapper class Validated<Value> {      private var _subject: Any!      @available(iOS 13.0, *)     private var subject: PassthroughSubject<[ValidationError], Never> {         return _subject as! PassthroughSubject<[ValidationError], Never>     }      open var wrappedValue: Value? {         didSet {             if #available(iOS 13.0, *) {                 subject.send(errors)             }         }     }      public init(wrappedValue value: Value?, _ validators: [AnyValidator<Value>]) {         wrappedValue = value         self.validators = validators         if #available(iOS 13.0, *) {             _subject = PassthroughSubject<[ValidationError], Never>()         }     }      @available(iOS 13.0, *)     public var publisher: AnyPublisher<[ValidationError], Never> {         subject.eraseToAnyPublisher()     } // The rest of the code }

Из-за того, что stored property не может быть помечено аннотацией @available, пришлось применить work around со свойствами _subject и subject. В остальном все должно быть предельно понятным. Создается объект PassthroughObject, который отправляет сообщения каждый раз, когда меняется warppedValue.

В результате сообщения об ошибках валидации меняются по мере заполнения пользователем формы.
image
В процессе тестирования данного решения был выявлен один баг. Валидация происходит при каждом изменении свойства вне зависимости от наличия подписчиков у этого события. С одной стороны, это никак не влияет на результат, но с другой, в случае, когда нам не нужна валидация в реальном времени, будут выполняться ненужные действия. Правильно будет выполнять валидацию и отправлять сообщения только если есть хоть один подписчик. В результате код был переделан с учетом данного требования

@propertyWrapper class Validated<Value> {      private var _subject: Any!      @available(iOS 13.0, *)     private var subject: Publishers.HandleEvents<PassthroughSubject<[ValidationError], Never>> {         return _subject as! Publishers.HandleEvents<PassthroughSubject<[ValidationError], Never>>     }      private var subscribed: Bool = false      open var wrappedValue: Value? {         didSet {             if #available(iOS 13.0, *) {                 if subscribed {                     subject.upstream.send(errors)                 }             }         }     }      public init(wrappedValue value: Value?, _ validators: [AnyValidator<Value>]) {         wrappedValue = value         self.validators = validators         if #available(iOS 13.0, *) {             _subject = PassthroughSubject<[ValidationError], Never>()                 .handleEvents(receiveSubscription: {[weak self] _ in                 self?.subscribed = true             })         }     }      // The rest of the code }

В результате я получил относительно универсальное решение для валидации данных в приложении. Возможно, оно не решает некоторые задачи, например, более сложную обработку ошибок, чем простой вывод сообщения, но для простой валидации введенных пользователем данных оно подходит. Ознакомиться с полным решением вы можете на GitHub.

ссылка на оригинал статьи https://habr.com/ru/post/485092/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *