Представьте, что вам необходимо сохранить идентификатор пользователя в UserDefaults. Каким будет первый шаг?


Обычно дело начинается с добавления константы для ключа и проверок на его уникальность. Это актуально и для большинства других хранилищ типа "ключ-значение". И последствия примитивного дизайна таких хранилищ не ограничиваются ключами, интерфейс в виде бессистемного набора методов приводит к целому ряду возможных проблем:


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

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


Протокол хранилища


Итак, первое, что нам необходимо — это протокол для самого хранилища, который поможет абстрагироваться от его типа. Следовательно у нас будет единый интерфейс для работы и c UserDefaults, и со связкой ключей (Keychain), и с каким-либо другим хранилищем типа «ключ-значение». Выглядит этот протокол довольно просто:


protocol KeyValueStorage {
    func value<T: Codable>(forKey key: String) -> T?
    func setValue<T: Codable>(_ value: T?, forKey key: String)
}

Так любое хранилище, соответствующее протоколу KeyValueStorage, должно реализовать два generic-метода: геттер и сеттер значений по ключу в виде строки. При этом сами значения соответствуют протоколу Codable, что позволяет хранить экземпляры типов, имеющих универсальное представление (например, JSON или PropertyList).


Стандартные реализации хранилищ данных не поддерживают тип Codable для значений, например, тот же UserDefaults. Поэтому такое разграничение типа — это побочный бонус, позволяющий хранить и примитивы Swift (числа, строки и т.д.), и целые структуры данных, сохраняя простым интерфейс самого хранилища.


Реализация протокола


Есть два пути для реализации протокола KeyValueStorage:


  • Подписать существующее хранилище под протокол и добавить необходимые методы:

extension UserDefaults: KeyValueStorage {
    func value<T: Codable>(forKey key: String) -> T? {
        // Реализация метода
    }

    func setValue<T: Codable>(_ value: T?, forKey key: String) {
        // Реализация метода
    }
}

  • Обернуть хранилище в отдельный тип, скрыв его поля для внешнего использования:

class PersistentStorage: KeyValueStorage {
    private let userDefaults: UserDefaults

    let suiteName: String?
    let keyPrefix: String

    init?(suiteName: String? = nil, keyPrefix: String = "") {
        guard let userDefaults = UserDefaults(suiteName: suiteName) else {
            return nil
        }

        self.userDefaults = userDefaults
        self.suiteName = suiteName
        self.keyPrefix = keyPrefix
    }

    func value<T: Codable>(forKey key: String) -> T? {
        // Реализация метода
    }

    func setValue<T: Codable>(_ value: T?, forKey key: String) {
        // Реализация метода
    }
}

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


Для реализации самих методов value(forKey:) и setValue(:forKey:) важно предусмотреть совместимость данных. Это необходимо, чтобы значения, сохраненные стандартными средствами UserDefaults, можно было извлечь методами из KeyValueStorage, и наоборот.


Полный пример готового к использованию класса PersistentStorage доступен по ссылке.


Контейнер значения


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


class KeyValueContainer<T: Codable> {
    let storage: KeyValueStorage
    let key: String

    var value: T? {
        get {
            storage.value(forKey: key)
        }

        set {
            storage.setValue(newValue, forKey: key)
        }
    }

    init(storage: KeyValueStorage, key: String) {
        self.storage = storage
        self.key = key
    }
}

Тип значения для контейнера ограничен протоколом Codable точно так же, как и в методах самого хранилища, поэтому вычисляемое свойство value просто проксирует ему вызовы с фиксированным ключом и типом значения.


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


Пример реализации контейнера со значением по умолчанию
class KeyValueContainer<T: Codable> {
    let storage: KeyValueStorage
    let key: String
    let defaultValue: T?

    var value: T? {
        get {
            storage.value(forKey: key) ?? defaultValue
        }

        set {
            storage.setValue(newValue, forKey: key)
        }
    }

    init(storage: KeyValueStorage, key: String, defaultValue: T? = nil) {
        self.storage = storage
        self.key = key
        self.defaultValue = defaultValue
    }
}

Контейнер решает первые две наши проблемы — фиксирует ключ и тип для считываемых и записываемых данных. Так любую попытку записать значение некорректного типа пресечет компилятор еще на этапе сборки:


func doSomething(with container: KeyValueContainer<Int>) {
    container.value = "Text" // Ошибка компиляции
}

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


Уникальность ключей


Чтобы решить проблему коллизии, в качестве ключа используем имя вычисляемого свойства, в котором будет создаваться сам контейнер. Для этого необходимо добавить простое расширение к нашему протоколу KeyValueStorage:


extension KeyValueStorage {
    func makeContainer<T: Codable>(key: String = #function) -> KeyValueContainer<T> {
        KeyValueContainer(storage: self, key: key)
    }
}

Так во все реализации протокола хранилища добавится generic-метод, возвращающий контейнер с указанным ключом. Особый интерес в этом методе представляет параметр key, который по умолчанию имеет значение, равное специальному выражению #function (документация). Это означает, что на этапе сборки вместо литерала #function подставится имя объявления, из которого был вызван метод makeContainer(key:).


Данная конструкция позволяет объявлять контейнеры в расширениях хранилищ, и их ключами будут имена вычисляемых свойств, если метод makeContainer() в них вызван без параметра key:


extension PersistentStorage {
    var foobar: KeyValueContainer<Int> {
        makeContainer()
    }
}

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



Подводя итог


Контейнеры для значений решают все упомянутые проблемы интерфейса хранилищ и применимы не только к UserDefaults. Любое хранилище значений по ключу достаточно подписать (обернуть) под протокол KeyValueStorage с соответствующей реализацией, и его уже можно использовать для создания безопасных контейнеров.


Отдельно стоит отметить удобство добавления и использования таких контейнеров, все ограничивается простыми и понятными конструкциями:


extension PersistentStorage {
    // Объявление контейнера
    var foobar: KeyValueContainer<Int> {
        makeContainer()
    }
}

// Чтение значения
let foobar = storageInstance.foobar.value

// Запись значения
storageInstance.foobar.value = 123

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


На этом все. Буду рад обратной связи в комментариях. Пока!