Services and Managers

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

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

Первое – это нужно разделять понятие сервисов и менеджеров. Под понятием сервисов мы должны относить вещи которые работают не с бизнес-логикой нашего приложения. Например: работа с http протоколом (одно из самых повседневных задач), работа с базой данных, работе с блютусом, работа с микрофоном, работа с face id, работа с аудио, работа с фильтрами для изображений и т/д. То есть, те вещи которые могут существовать отдельно.

Менеджеры – это классы, которые взаимодействуют с сервисами. Они и являются мостом между нашими контролерами и сервисами.

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

Возьмем к примеру класс по работе с http. (Надеюсь у вас эта логика не реализована в контролере 🙂 ) На моей практике, это самое больное место почти каждого приложения, когда смешивают понятие сервиса и менеджера в единое целое. Обычно такой класс называется APIClient, WebClient, NetworkManager, WebAPI, который содержит ряд функций для создания запроса и вызова.

Выглядят они по-разному, кто у кого подсмотрел 🙂


    func login(with email: String, and password: String, completion: @escaping (User?, ApiError?) -> Void) {
        Alamofire.request("http://example.com/api/user")
            .responseData { response in
                debugPrint(response)
                switch response.result {
                case .success:
                    let jsonDate = try? JSONSerialization.jsonObject(with: response.data!, options: [])
                    print(jsonDate)
                    let jsonDecoder = JSONDecoder()
                    guard
                        let json = response.result.value,
                        let user = try? jsonDecoder.decode(User.self, from: json)
                        else { return }
                    completion(user, nil)
                case .failure:
                    let jsonDecoder = JSONDecoder()
                    guard let json = response.data, let error = try? jsonDecoder.decode(ApiError.self, from: json) else {
                        completion(nil, nil)
                        return
                    }
                    completion(nil, error)
                }
        }
    }

В этом примере используется вспомогательная библиотека Alamofire, которая в принципе работает как сервис. На первый взгляд выглядит правильно, и большинство разработчиков именно так и строят архитектуру http запросов. Но я вынужден вас разочаровать. Этот подход не является совсем правильным. Во-первых, первая проблема это бизнес-логика, которая тесно начинает быть завязана на результате запроса. Второе, это тесная связка с использованием сторонней библиотеки. А что если завтра нам нужно будет использовать не Alamofire в связи с нарушением авторских прав? Да, такой ход событий мало вероятный, но он имеет место. Чтоб использовать другую библиотеку нам прийдется переписать весь класс, а у нас к примеру там уже 50+ запросов. Это конечно возможно, но это время, которое заказчик нам не оплатит, так как не он виноват в вашем выборе архитектуры. Далее проблема с логированием запросов, отправка ошибок на сторонний сервис. Да черт возьми, банальная проверка на наличие соединения с интернетом в каждом методе. Список можно продолжать далее. Про то, что функция в результате может не вернуть ничего я промолчу. 🙂

Таким способом, организовывать архитектуру мы просто не можем, не в начальных стадиях, не при рефакторинге кода. Хочу признаться я пока не столкнулся с этим всем, сам так писал код. 🙂 Это касается не только работы с http запросом, а и всех других вещей где мы взаимодействуем со сторонними ресурсами.

Еще один пример, это работа с базой данных. Представьте себе, что проекту уже больше года, и вы совместили понятия сервиса и менеджера на примере выше, с классом который отвечает за хранение и извлечение данных с базой данных построенных стронем решением как Realm. Все вроде хорошо, но тут при определенных обстоятельствах нам нужно заменить Realm на фреймоврк из коробки Core Data или еще другой. Скажу честно, это сделать практически уже не реально. Можно, но очень с большой кровью. Допустить ошибку, будет очень просто. А вот в случае изначального разделения на сервис/менеджер, нам будет просто переписать/заменить сервис не затрагивая бизнес-логику приложения. Да, там еще предстоит сделать миграцию, но это уже другая история. 🙂

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

Пример из базового класса http сервиса


class BaseService {
    
    static func doRequestTo(url: URL,
                            parameters: Dictionary? = nil,
                            success: @escaping (Data) -> (),
                            failure: @escaping (Error) -> ())  {
        
        guard isInternetAvailable() else {
            failure(RequestError.internetNotAvailable)
            return
        }
        
        print("Request: \(url.absoluteString.removingPercentEncoding ?? "INVALID REQUEST")\nParameters: \(parameters as AnyObject)")
        
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "POST"
        
        if let parameters = parameters, let jsonData = try? JSONSerialization.data(withJSONObject: parameters) {
            urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
            urlRequest.httpBody = jsonData
        }
        
        UIApplication.shared.isNetworkActivityIndicatorVisible = true
        URLSession.shared.dataTask(with: urlRequest,
                                   completionHandler:
            { (data, response, error) -> Void in
                DispatchQueue.main.async {
                    UIApplication.shared.isNetworkActivityIndicatorVisible = false
                    if let error = error {
                        if (error as NSError).code == -1004 {
                            failure(ResponseError.serverNotAvailable)
                        } else {
                            failure(ResponseError.unknownError)
                        }
                    } else {
                        if let data = data {
                            if let prettyString = String(data: data, encoding: .utf8)?.clean() {
                                print("Response: \(prettyString)")
                            } else {
                                print("Response: incorrect data")
                            }
                            success(data)
                        } else {
                            failure(ResponseError.invalidData)
                        }
                    }
                }
        }).resume()
    }
}

Пример из сервиса авторизации


class AuthService: BaseService {
    
    static func login(login: String,
                      password: String,
                      success: @escaping (LoginResponse) -> (),
                      failure: @escaping (Error) -> ()) {
        
        guard let url = createURL(for: .OAUTH_LOGIN) else {
            failure(RequestError.invalidURLParameters)
            return
        }
        
        doRequestTo(url: url,
                    parameters: ["login": login,
                                 "password": password],
                    mapTo: RawLoginResponse.self,
                    success:
            { result in
                if let response = result.response {
                    success(response)
                } else {
                    if let _ = result.errorCode, let msg = result.msg {
                        failure(ResponseServerError.MSG(msg))
                    } else {
                        failure(ResponseError.invalidData)
                    }
                }
        }) { error in
            failure(error)
        }
    }
}

Смысл в том, чтоб все запросы которые вы делаете в вашем приложении проходили через одну функцию (в примере это doRequest). Реализация может быть абсолютно разной, так как под каждый сервер нужно разные настройки.

В будущем мы можем спокойно использовать эти классы, в других наших проектах, с помощью банального копи-паста класса. И это здорово 🙂

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


class UserManager {
static let shared = UserManager()
    // var token: String?
    
    func login(login: String,
               password: String,
               success: @escaping () -> (),
               failure: @escaping (Error) -> ()) {
        
        AuthService.login(login: login,
                          password: password.sha256(),
                          success:
            { user in
            
            // do something with user
            // self.token = user.token
                
            success()
        }) { error in
            failure(error)
        }
    }
}

Цепочка должна выглядеть следующем образом. Контроллер не должен знать как данные формируются для запроса, допустим авторизации (ему главное только передать логин и пароль из текстового поля) в результате он должен отреагировать на положительный результат, открыть нам главное окно приложения, или при отрицательном результате к примеру вывести ошибку. Всё! Это его роль и больше ничего. Далее менеджер должен сформировать параметры для этого запроса, например нам нужно зашифровать пароль sha-256, и вызвать функцию из сервиса. Также он не должен знать как это делать, кто отвечает уже за сам запрос, библиотека Alamofire или класс URLSession. Возможно у нас есть дополнительные ключи в header, без которых наш сервер не будет возвращать данные, об этом тоже менеджеру не нужно ничего знать, так как это дополнительные настройки сервиса. Его задача сформировать запрос и передать дальше. А дальше дело за сервисом, он просто делает запрос, и банально возвращает ответ, или как в примере выше он может дополнительно распарсить уже в готовый объект используя дженерики. Менеджер передает их в менеджер по работе с базой данных для сохранения к примеру или присваивает токен для текущий сессии, и возвращает в контроллер ответ, успешный ли был процесс авторизации или нет.


UserManager.shared.login(login: login,
                         password: password,
                         success:
    {
        
       // do something
        
}) { error in
    if let e = error as? ResponseServerError, case .MSG(let m) = e {
        self.alert(message: m)
    }
    if let e = error as? RequestError {
        self.alert(message: e.rawValue, title: "Error")
    }
}

Да, возможно на первый взгляд это кажется немного сложным. Так как, зачем столько действий, чтоб авторизовать пользователя, но если в нужный момент вам нужно добавить дополнительный слой, вы это сделаете очень просто.

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

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

Поделиться

Оставить комментарий