Фабрики в Swift

Проблема разделяемых состояний и ее решение с помощью фабрик.
26 октября 2017225057Андрей Никифоров62105

Здравствуйте!

Продолжаю переводить полезные посты. В последнее время была куча статей о 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)
   }
}

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

Выводы

Фабрики — действительно полезная вещь для разделения кода, в контексте состояния и разделения ответственности. Разделяемое состояние легко избегается созданием новых экземпляров, и фабрики — отличный способ инкапсулировать их создание.

Мы еще вернемся к фабрикам в будущих постах, и посмотрим, какие еще проблемы можно решить с их помощью.

Новые комментарии