Думаю, каждый из нас сталкивался с задачей валидации данных в приложениях. Например, при регистрации пользователя нужно убедиться что 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

Есть вероятность, что некоторые комбинации валидаторов (например, валидация 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, который отправляет сообщения каждый раз, когда меняется wrappedValue.
В результате сообщения об ошибках валидации меняются по мере заполнения пользователем формы.

В процессе тестирования данного решения был выявлен один баг. Валидация происходит при каждом изменении свойства вне зависимости от наличия подписчиков у этого события. С одной стороны, это никак не влияет на результат, но с другой, в случае, когда нам не нужна валидация в реальном времени, будут выполняться ненужные действия. Правильно будет выполнять валидацию и отправлять сообщения только если есть хоть один подписчик. В результате код был переделан с учетом данного требования
@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.
