Фабрики в Swift
Здравствуйте!
Продолжаю переводить полезные посты. В последнее время была куча статей о JavaScript и HTML/CSS, так что вот вам неплохая статья об использовании фабрик в Swift.
Оригинал статьи: «Using the factory pattern to avoid shared state in Swift».
Разделяемое состояние — действительно частый источник багов во многих приложениях. Это случается, когда вы случайно или намеренно допускаете наличие в своей архитектуре компонентов, обращающихся к одному и тому же изменяемому состоянию. Проблемы и баги обычно возникают из-за неправильной обработки изменений такого состояния в системе.
Давайте посмотрим, как можно избежать разделяемого состояния в большинстве ситуаций, используя фабрики для создания явно разделенных экземпляров, пользующихся собственными состояниями.
Проблема
Скажем, наше приложение содержит класс Request, который используется для запросов к бекенду. Его реализация выглядит примерно так:
class Request { enum State { case pending case ongoing case completed(Result) } let url: URL let parameters: [String : String] fileprivate(set) var state = State.pending init(url: URL, parameters: [String : String] = [:]) { self.url = url self.parameters = parameters } }
Еще у нас есть класс DataLoader, в который передается Request для его выполнения, примерно так:
dataLoader.perform(request) { result in // Handle result }
Итак, в чем проблема? Поскольку Request содержит не только информацию о том, где и когда запрос должен быть выполнен, а еще и состояние запроса, мы легко можем получить разделение состояния. Разработчики, не знакомые с конкретной реализацией Request, могут допускать, что это значение простого типа, которое может быть переиспользовано, как тут:
class TodoListViewController: UIViewController { private let request = Request(url: .todoList) private let dataLoader = DataLoader() func loadItems() { dataLoader.perform(request) { [weak self] result in self?.render(result) } } }
С таким кодом мы можем легко получить неопределенную ситуацию, когда loadItems вызван несколько раз, прежде чем все запросы закончатся. Например, механизм поиска или pull-to-refresh с легкостью может создавать кучу запросов. Так как все запросы выполняются, используя один и тот же экземпляр, мы продолжим обнулять состояние, оставляя DataLoader в недоумении.
Один путь решения проблемы состоит в автоматической отмене всех предыдущих запросов при создании нового. Это решит проблему, но создаст кучу новых и в итоге сделает работу с API сложнее.
Фабричные методы
Вместо этого давайте используем другую технику для решения описанной проблемы, применив фабрики, чтобы избежать связывания состояния запроса с самим изначальным запросом. Такое разделение обычно требуется, чтобы избежать разделяемого состояния, и это хорошая практика в целом.
Итак, как нам изменить Request, чтобы использовать фабрику? Мы начнем с объявления нового типа StatefulRequest, наследуемого от Request, и переместим состояние в него, примерно так:
// Our Request class remains the same, minus the statefulness class Request { let url: URL let parameters: [String : String] init(url: URL, parameters: [String : String] = [:]) { self.url = url self.parameters = parameters } } // We introduce a stateful type, which is private to our networking code private class StatefulRequest: Request { enum State { case pending case ongoing case completed(Result) } var state = State.pending }
Теперь добавим фабричный метод в Request, который создаст для нас StatefulRequest-версию переданного запроса:
private extension Request {
func makeStateful() -> StatefulRequest {
return StatefulRequest(url: url, parameters: parameters)
}
}
И наконец, когда DataLoader начнет выполнять запрос, мы сделаем так, чтобы он создавал новый StatefulRequest всякий раз:
class DataLoader { func perform(_ request: Request) { perform(request.makeStateful()) } private func perform(_ request: StatefulRequest) { // Actually perform the request ... } }
Созданием нового экземпляра для каждого нового запроса мы устранили все возможности появления разделяемого состояния 👍.
Стандартный подход
В качестве похожего примера — подход, который применяется при обходе коллекций в Swift. Вместо общего состояния итератора, например хранения указания на текущий элемент, Iterator хранит отдельное состояние для каждой отдельной итерации. Так что если вы напишете что-то вроде этого:
for book in books {
...
}
Под капотом Swift вызовет books.makeIterator(), который вернет итератор, соответствующий типу коллекции. Мы рассмотрим коллекции и итераторы в одном из следующих постов.
Фабрики
Теперь давайте посмотрим на другую ситуацию, в которой применимы фабрики для избавления от разделяемого состояния. Речь пойдет о фабричных типах.
Скажем, мы пишем приложение о фильмах, в котором пользователи могут смотреть списки фильмов по категориям или по рекомендациям. У нас есть контроллеры для каждого случая, которые используют синглтон MovieLoader для запросов к бекенду:
class CategoryViewController: UIViewController { // We paginate our view using section indexes, so that we // don't have to load all data at once func loadMovies(atSectionIndex sectionIndex: Int) { MovieLoader.shared.loadMovies(in: category, sectionIndex: sectionIndex) { [weak self] result in self?.render(result) } } }
Использование синглтона таким образом не выглядит опасным сперва, и это распространенная практика. Но когда мы начнем переключаться между экранами приложения быстрее, чем отрабатывают запросы, мы попадем в непростую ситуацию. У нас скопится длинная очередь незавершенных запросов, которые сильно замедлят работу приложения, особенно в условиях плохого соединения с сетью.
По сути тут мы имеем ту же проблему с разделяемым состоянием.
Для решения проблемы нам стоит использовать отдельный экземпляр MovieLoader для каждого контроллера. Таким образом мы сможем отменять запросы при уходе с соответствующего экрана, и очередь запросов не будет переполняться:
class MovieLoader { deinit { cancelAllRequests() } }
Однако мы не хотим руками создавать новый экземпляр MovieLoader каждый раз при загрузке контроллера. У нас есть вещи типа кэша, сессий и всего такого, что нужно переносить между контроллерами. Это звучит грязно, давайте лучше используем фабрику!
class MovieLoaderFactory { private let cache: Cache private let session: URLSession // We can have the factory contain references to underlying dependencies, // so that we don't have to expose those details to each view controller init(cache: Cache, session: URLSession) { self.cache = cache self.session = session } func makeLoader() -> MovieLoader { return MovieLoader(cache: cache, session: session) } }
Теперь мы инициализируем каждый контроллер с MovieLoaderFactory, и как только ему понадобится загрузчик, он создаст его, используя фабрику. Вот так:
class CategoryViewController: UIViewController { private let loaderFactory: MovieLoaderFactory private lazy var loader: MovieLoader = self.loaderFactory.makeLoader() init(loaderFactory: MovieLoaderFactory) { self.loaderFactory = loaderFactory super.init(nibName: nil, bundle: nil) } private func openRecommendations(forMovie movie: Movie) { let viewController = RecommendationsViewController( movie: movie, loaderFactory: loaderFactory ) navigationController?.pushViewController(viewController, animated: true) } }
Как вы можете видеть, большое преимущество использования фабрики в том, что мы можем передать фабрику в любой последующий контроллер. Мы избежали разделяемого состояния, и не внесли лишнего усложнения в код 🎉.
Выводы
Фабрики — действительно полезная вещь для разделения кода, в контексте состояния и разделения ответственности. Разделяемое состояние легко избегается созданием новых экземпляров, и фабрики — отличный способ инкапсулировать их создание.
Мы еще вернемся к фабрикам в будущих постах, и посмотрим, какие еще проблемы можно решить с их помощью. Напоследок рекомендуем посмотреть вебинар по введению в язык Swift.