Думаю, каждый из нас сталкивался с задачей валидации данных в приложениях. Например, при регистрации пользователя нужно убедиться что 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.
varton86
Спасибо за статью.
А почему используете .receive(on: RunLoop.main) вместо .receive(on: DispatchQueue.main)?
AlexanderGaidukov Автор
Спасибо за отзыв. Если честно, затрудняюсь ответить в чем разница между этими двумя вариантами, поскольку пока не было времени глубоко изучить Combine. Вариант с RunLoop.main взял из Документации