.key, value: String(describing:

Como isolar a lógica de interação cliente-servidor em aplicativos iOS

Hoje em dia, a maioria dos aplicativos móveis depende muito das interações cliente-servidor. Isso não apenas significa que eles podem descarregar a maior parte de suas tarefas pesadas para servidores de back-end, mas também permite que esses aplicativos móveis ofereçam todos os tipos de recursos e funcionalidades que só podem ser disponibilizados pela Internet.

Os servidores back-end geralmente são projetados para oferecer seus serviços por meio de APIs RESTful . Para aplicativos mais simples, muitas vezes nos sentimos tentados a criar um código espaguete; misturar o código que invoca a API com o resto da lógica do aplicativo. No entanto, à medida que os aplicativos se tornam complexos e lidam com mais e mais APIs, pode se tornar um incômodo interagir com essas APIs de maneira não estruturada e não planejada.

Mantenha o código do seu aplicativo iOS livre de confusão com um módulo de rede de cliente REST bem projetado.



Mantenha o código do seu aplicativo iOS livre de confusão com um módulo de rede de cliente REST bem projetado. Tweet

Este artigo discute uma abordagem arquitetônica para a construção de um módulo de rede do cliente REST limpo para aplicativos iOS que permite manter toda a lógica de interação cliente-servidor isolada do restante do código do aplicativo.

Aplicativos cliente-servidor

Uma interação cliente-servidor típica se parece com isto:

  1. Um usuário realiza alguma ação (por exemplo, tocar em algum botão ou realizar algum outro gesto na tela).
  2. O aplicativo prepara e envia uma solicitação HTTP / REST em resposta à ação do usuário.
  3. O servidor processa a solicitação e responde de acordo com o aplicativo.
  4. O aplicativo recebe a resposta e atualiza a interface do usuário com base nela.

À primeira vista, o processo geral pode parecer simples, mas temos que pensar nos detalhes.

Mesmo supondo que uma API do servidor de back-end funcione conforme anunciado (que é não sempre o caso!), muitas vezes pode ser mal projetado, tornando-o ineficiente ou até mesmo difícil de usar. Um incômodo comum é que todas as chamadas para a API exigem que o chamador forneça redundantemente as mesmas informações (por exemplo, como os dados da solicitação são formatados, um token de acesso que o servidor pode usar para identificar o usuário conectado no momento e assim por diante).

Os aplicativos móveis também podem precisar utilizar vários servidores back-end simultaneamente para diferentes fins. Um servidor pode, por exemplo, ser dedicado à autenticação do usuário, enquanto outro trata apenas da coleta de dados analíticos.

Além disso, um cliente REST típico precisará fazer muito mais do que apenas invocar APIs remotas. A capacidade de cancelar solicitações pendentes ou uma abordagem limpa e gerenciável para lidar com erros são exemplos de funcionalidade que precisam ser incorporados a qualquer aplicativo móvel robusto.

Uma Visão Geral da Arquitetura

O núcleo do nosso cliente REST será construído com base nos seguintes componentes:

É assim que cada um desses componentes interagirá entre si:

As setas de 1 a 10 na imagem acima mostram uma sequência ideal de operações entre o aplicativo que invoca um serviço e o serviço que eventualmente retorna os dados solicitados como um objeto modelo. Cada componente nesse fluxo tem uma função específica garantindo separação de preocupações dentro do módulo.

Implementação

Implementaremos nosso cliente REST como parte de nosso aplicativo de rede social imaginário, no qual carregaremos uma lista dos amigos do usuário conectado no momento. Assumiremos que nosso servidor remoto usa JSON para respostas.

Vamos começar implementando nossos modelos e analisadores.

De JSON bruto para objetos de modelo

Nosso primeiro modelo, User, define a estrutura da informação para qualquer usuário da rede social. Para manter as coisas simples, incluiremos apenas campos que são absolutamente necessários para este tutorial (em um aplicativo real, a estrutura normalmente teria muito mais propriedades).

struct User { var id: String var email: String? var name: String? }

Uma vez que receberemos todos os dados do usuário do servidor back-end por meio de sua API, precisamos de uma maneira de analise a resposta da API em um User válido | objeto. Para fazer isso, adicionaremos um construtor a User que aceita um objeto JSON analisado (Dictionary) como parâmetro. Definiremos nosso objeto JSON como um tipo de alias:

typealias JSON = [String: Any]

Em seguida, adicionaremos a função construtora ao nosso User estrutura da seguinte forma:

extension User { init?(json: JSON) { guard let id = json['id'] as? String else { return nil } self.id = id self.email = json['email'] as? String self.name = json['name'] as? String } }

Para preservar o construtor padrão original de User, adicionamos o construtor por meio de uma extensão no User tipo.

Em seguida, para criar um User objeto de uma resposta bruta da API, precisamos realizar as duas etapas a seguir:

// Transform raw JSON data to parsed JSON object using JSONSerializer (part of standard library) let userObject = (try? JSONSerialization.jsonObject(with: data, options: [])) as? JSON // Create an instance of `User` structure from parsed JSON object let user = userObject.flatMap(User.init)

Tratamento simplificado de erros

Definiremos um tipo para representar os diferentes erros que podem ocorrer ao tentar interagir com os servidores back-end. Podemos dividir todos esses erros em três categorias básicas:

Podemos definir nossos objetos de erro como um tipo de enumeração. E já que estamos nisso, é uma boa ideia fazer nosso ServiceError tipo em conformidade com o Error protocolo . Isso nos permitirá usar e manipular esses valores de erro usando mecanismos padrão fornecidos pelo Swift (como usar throw para lançar um erro).

enum ServiceError: Error { case noInternetConnection case custom(String) case other }

Ao contrário de noInternetConnection e other erros, o erro personalizado tem um valor associado a ele. Isso nos permitirá usar a resposta de erro do servidor como um valor associado para o próprio erro, fornecendo assim mais contexto ao erro.

Agora, vamos adicionar um errorDescription propriedade para o ServiceError enumartion para tornar os erros mais descritivos. Adicionaremos mensagens codificadas para noInternetConnection e other erros e use o valor associado como a mensagem para custom erros.

extension ServiceError: LocalizedError { var errorDescription: String? { switch self { case .noInternetConnection: return 'No Internet connection' case .other: return 'Something went wrong' case .custom(let message): return message } } }

Há apenas mais uma coisa que precisamos implementar em nosso ServiceError enumeração. No caso de um custom erro, precisamos transformar os dados JSON do servidor em um objeto de erro. Para fazer isso, usamos a mesma abordagem que usamos no caso dos modelos:

extension ServiceError { init(json: JSON) { if let message = json['message'] as? String { self = .custom(message) } else { self = .other } } }

Reduzindo a lacuna entre o aplicativo e o servidor back-end

O componente cliente será um intermediário entre o aplicativo e o servidor backend. É um componente crítico que definirá como o aplicativo e o servidor se comunicarão, mas não saberá nada sobre os modelos de dados e suas estruturas. O cliente será responsável por invocar URLs específicos com parâmetros fornecidos e retornar dados JSON de entrada analisados ​​como objetos JSON.

enum RequestMethod: String { case get = 'GET' case post = 'POST' case put = 'PUT' case delete = 'DELETE' } final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // TODO: Add implementation } }

Vamos examinar o que está acontecendo no código acima ...

Primeiro, declaramos um tipo de enumeração, RequestMethod, que descreve quatro métodos HTTP comuns. Estes estão entre os métodos usados ​​nas APIs REST.

O WebClient classe contém o baseURL propriedade que será usada para resolver todos os URLs relativos que ele recebe. Caso nosso aplicativo precise interagir com vários servidores, podemos criar várias instâncias de WebClient cada um com um valor diferente para baseURL.

O cliente tem um único método load, que leva um caminho relativo a baseURL como um parâmetro, método de solicitação, parâmetros de solicitação e fechamento de conclusão. O fechamento de conclusão é chamado com o JSON analisado e ServiceError como parâmetros. Por enquanto, o método acima carece de implementação, que veremos em breve.

Antes de implementar o load , precisamos criar um método URL de todas as informações disponíveis para o método. Vamos estender o URL classe para este propósito:

extension URL { init(baseUrl: String, path: String, params: JSON, method: RequestMethod) { var components = URLComponents(string: baseUrl)! components.path += path switch method { case .get, .delete: components.queryItems = params.map { URLQueryItem(name: $0.key, value: String(describing: $0.value)) } default: break } self = components.url! } }

Aqui, simplesmente adicionamos o caminho para a URL base. Para os métodos GET e DELETE HTTP, também adicionamos os parâmetros de consulta à string do URL.

Em seguida, precisamos ser capazes de criar instâncias de URLRequest de determinados parâmetros. Para fazer isso, faremos algo semelhante ao que fizemos para URL:

extension URLRequest { init(baseUrl: String, path: String, method: RequestMethod, params: JSON) { let url = URL(baseUrl: baseUrl, path: path, params: params, method: method) self.init(url: url) httpMethod = method.rawValue setValue('application/json', forHTTPHeaderField: 'Accept') setValue('application/json', forHTTPHeaderField: 'Content-Type') switch method { case .post, .put: httpBody = try! JSONSerialization.data(withJSONObject: params, options: []) default: break } } }

Aqui, primeiro criamos um URL usando o construtor da extensão. Em seguida, inicializamos uma instância de URLRequest com este URL, defina alguns cabeçalhos HTTP conforme necessário e, em caso de métodos POST ou PUT HTTP, adicione parâmetros ao corpo da solicitação.

Agora que cobrimos todos os pré-requisitos, podemos implementar o load método:

final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // Checking internet connection availability if !Reachability.isConnectedToNetwork() { completion(nil, ServiceError.noInternetConnection) return nil } // Adding common parameters var parameters = params if let token = KeychainWrapper.itemForKey('application_token') { parameters['token'] = token } // Creating the URLRequest object let request = URLRequest(baseUrl: baseUrl, path: path, method: method, params: params) // Sending request to the server. let task = URLSession.shared.dataTask(with: request) { data, response, error in // Parsing incoming data var object: Any? = nil if let data = data { object = try? JSONSerialization.jsonObject(with: data, options: []) } if let httpResponse = response as? HTTPURLResponse, (200..<300) ~= httpResponse.statusCode { completion(object, nil) } else { let error = (object as? JSON).flatMap(ServiceError.init) ?? ServiceError.other completion(nil, error) } } task.resume() return task } }

O load método acima executa as seguintes etapas:

  1. Verifique a disponibilidade de conexão à Internet. Se a conectividade com a Internet não estiver disponível, chamamos o encerramento da conclusão imediatamente com noInternetConnection erro como parâmetro. (Nota: Reachability no código é uma classe personalizada, que usa uma das abordagens comuns para verificar a conexão com a Internet.)
  2. Adicione parâmetros comuns. . Isso pode incluir parâmetros comuns, como um token de aplicativo ou ID de usuário.
  3. Crie o URLRequest objeto, usando o construtor da extensão.
  4. Envie a solicitação ao servidor. Usamos o URLSession objeto para enviar dados ao servidor.
  5. Analise os dados recebidos. Quando o servidor responde, primeiro analisamos a carga útil de resposta em um objeto JSON usando JSONSerialization. Em seguida, verificamos o código de status da resposta. Se for um código de sucesso (ou seja, no intervalo entre 200 e 299), chamamos o fechamento de conclusão com o objeto JSON. Caso contrário, transformamos o objeto JSON em um ServiceError objeto e chamar o encerramento de conclusão com esse objeto de erro.

Definição de serviços para operações logicamente vinculadas

No caso de nossa aplicação, precisamos de um serviço que lide com tarefas relacionadas a amigos de um usuário. Para isso, criamos um FriendsService classe. Idealmente, uma classe como essa será responsável por operações como obter uma lista de amigos, adicionar um novo amigo, remover um amigo, agrupar alguns amigos em uma categoria, etc. Para simplificar neste tutorial, implementaremos apenas um método :

final class FriendsService { private let client = WebClient(baseUrl: 'https://your_server_host/api/v1') @discardableResult func loadFriends(forUser user: User, completion: @escaping ([User]?, ServiceError?) -> ()) -> URLSessionDataTask? { let params: JSON = ['user_id': user.id] return client.load(path: '/friends', method: .get, params: params) { result, error in let dictionaries = result as? [JSON] completion(dictionaries?.flatMap(User.init), error) } } }

O FriendsService classe contém um client propriedade do tipo WebClient. Ele é inicializado com a URL base do servidor remoto que se encarrega de gerenciar os amigos. Conforme mencionado anteriormente, em outras classes de serviço, podemos ter uma instância diferente de WebClient inicializado com um URL diferente, se necessário.

No caso de um aplicativo que funciona com apenas um servidor, o WebClient classe pode receber um construtor que inicializa com o URL desse servidor:

final class WebClient { // ... init() { self.baseUrl = 'https://your_server_base_url' } // ... }

O loadFriends método, quando chamado, prepara todos os parâmetros necessários e usa a instância de FriendService de WebClient para fazer uma solicitação de API. Depois de receber a resposta do servidor por meio de WebClient, ele transforma o objeto JSON em User modela e chama o fechamento de conclusão com eles como parâmetro.

Um uso típico de FriendService pode ser parecido com o seguinte:

let friendsTask: URLSessionDataTask! let activityIndicator: UIActivityIndicatorView! var friends: [User] = [] func friendsButtonTapped() { friendsTask?.cancel() //Cancel previous loading task. activityIndicator.startAnimating() //Show loading indicator friendsTask = FriendsService().loadFriends(forUser: currentUser) {[weak self] friends, error in DispatchQueue.main.async { self?.activityIndicator.stopAnimating() //Stop loading indicators if let error = error { print(error.localizedDescription) //Handle service error } else if let friends = friends { self?.friends = friends //Update friends property self?.updateUI() //Update user interface } } } }

No exemplo acima, estamos assumindo que a função friendsButtonTapped é invocado sempre que o usuário toca em um botão destinado a mostrar uma lista de seus amigos na rede. Também mantemos uma referência à tarefa no friendsTask para que possamos cancelar a solicitação a qualquer momento chamando friendsTask?.cancel().

Isso nos permite ter um maior controle do ciclo de vida das solicitações pendentes, permitindo-nos encerrá-las quando determinarmos que se tornaram irrelevantes.

Conclusão

Neste artigo, compartilhei uma arquitetura simples de um módulo de rede para seu aplicativo iOS que é trivial de implementar e pode ser adaptada às necessidades de rede intrincadas da maioria dos aplicativos iOS. No entanto, a principal conclusão disso é que um cliente REST devidamente projetado e os componentes que o acompanham - que são isolados do resto da lógica do aplicativo - podem ajudar a manter o código de interação cliente-servidor do aplicativo simples, mesmo quando o próprio aplicativo se torna cada vez mais complexo .

Espero que este artigo seja útil para construir seu próximo aplicativo iOS. Você pode encontrar o código-fonte deste módulo de rede no GitHub . Verifique o código, bifurque-o, altere-o, brinque com ele.

Se você achar alguma outra arquitetura mais preferível para você e seu projeto, por favor, compartilhe os detalhes na seção de comentários abaixo.

Relacionado: Simplificando o uso da API RESTful e a persistência de dados no iOS com Mantle e Realm .value)) } default: break } self = components.url! } }

Aqui, simplesmente adicionamos o caminho para a URL base. Para os métodos GET e DELETE HTTP, também adicionamos os parâmetros de consulta à string do URL.

Em seguida, precisamos ser capazes de criar instâncias de URLRequest de determinados parâmetros. Para fazer isso, faremos algo semelhante ao que fizemos para URL:

extension URLRequest { init(baseUrl: String, path: String, method: RequestMethod, params: JSON) { let url = URL(baseUrl: baseUrl, path: path, params: params, method: method) self.init(url: url) httpMethod = method.rawValue setValue('application/json', forHTTPHeaderField: 'Accept') setValue('application/json', forHTTPHeaderField: 'Content-Type') switch method { case .post, .put: httpBody = try! JSONSerialization.data(withJSONObject: params, options: []) default: break } } }

Aqui, primeiro criamos um URL usando o construtor da extensão. Em seguida, inicializamos uma instância de URLRequest com este URL, defina alguns cabeçalhos HTTP conforme necessário e, em caso de métodos POST ou PUT HTTP, adicione parâmetros ao corpo da solicitação.

Agora que cobrimos todos os pré-requisitos, podemos implementar o load método:

final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // Checking internet connection availability if !Reachability.isConnectedToNetwork() { completion(nil, ServiceError.noInternetConnection) return nil } // Adding common parameters var parameters = params if let token = KeychainWrapper.itemForKey('application_token') { parameters['token'] = token } // Creating the URLRequest object let request = URLRequest(baseUrl: baseUrl, path: path, method: method, params: params) // Sending request to the server. let task = URLSession.shared.dataTask(with: request) { data, response, error in // Parsing incoming data var object: Any? = nil if let data = data { object = try? JSONSerialization.jsonObject(with: data, options: []) } if let httpResponse = response as? HTTPURLResponse, (200..<300) ~= httpResponse.statusCode { completion(object, nil) } else { let error = (object as? JSON).flatMap(ServiceError.init) ?? ServiceError.other completion(nil, error) } } task.resume() return task } }

O load método acima executa as seguintes etapas:

  1. Verifique a disponibilidade de conexão à Internet. Se a conectividade com a Internet não estiver disponível, chamamos o encerramento da conclusão imediatamente com noInternetConnection erro como parâmetro. (Nota: Reachability no código é uma classe personalizada, que usa uma das abordagens comuns para verificar a conexão com a Internet.)
  2. Adicione parâmetros comuns. . Isso pode incluir parâmetros comuns, como um token de aplicativo ou ID de usuário.
  3. Crie o URLRequest objeto, usando o construtor da extensão.
  4. Envie a solicitação ao servidor. Usamos o URLSession objeto para enviar dados ao servidor.
  5. Analise os dados recebidos. Quando o servidor responde, primeiro analisamos a carga útil de resposta em um objeto JSON usando JSONSerialization. Em seguida, verificamos o código de status da resposta. Se for um código de sucesso (ou seja, no intervalo entre 200 e 299), chamamos o fechamento de conclusão com o objeto JSON. Caso contrário, transformamos o objeto JSON em um ServiceError objeto e chamar o encerramento de conclusão com esse objeto de erro.

Definição de serviços para operações logicamente vinculadas

No caso de nossa aplicação, precisamos de um serviço que lide com tarefas relacionadas a amigos de um usuário. Para isso, criamos um FriendsService classe. Idealmente, uma classe como essa será responsável por operações como obter uma lista de amigos, adicionar um novo amigo, remover um amigo, agrupar alguns amigos em uma categoria, etc. Para simplificar neste tutorial, implementaremos apenas um método :

final class FriendsService { private let client = WebClient(baseUrl: 'https://your_server_host/api/v1') @discardableResult func loadFriends(forUser user: User, completion: @escaping ([User]?, ServiceError?) -> ()) -> URLSessionDataTask? { let params: JSON = ['user_id': user.id] return client.load(path: '/friends', method: .get, params: params) { result, error in let dictionaries = result as? [JSON] completion(dictionaries?.flatMap(User.init), error) } } }

O FriendsService classe contém um client propriedade do tipo WebClient. Ele é inicializado com a URL base do servidor remoto que se encarrega de gerenciar os amigos. Conforme mencionado anteriormente, em outras classes de serviço, podemos ter uma instância diferente de WebClient inicializado com um URL diferente, se necessário.

No caso de um aplicativo que funciona com apenas um servidor, o WebClient classe pode receber um construtor que inicializa com o URL desse servidor:

final class WebClient { // ... init() { self.baseUrl = 'https://your_server_base_url' } // ... }

O loadFriends método, quando chamado, prepara todos os parâmetros necessários e usa a instância de FriendService de WebClient para fazer uma solicitação de API. Depois de receber a resposta do servidor por meio de WebClient, ele transforma o objeto JSON em User modela e chama o fechamento de conclusão com eles como parâmetro.

Um uso típico de FriendService pode ser parecido com o seguinte:

let friendsTask: URLSessionDataTask! let activityIndicator: UIActivityIndicatorView! var friends: [User] = [] func friendsButtonTapped() { friendsTask?.cancel() //Cancel previous loading task. activityIndicator.startAnimating() //Show loading indicator friendsTask = FriendsService().loadFriends(forUser: currentUser) {[weak self] friends, error in DispatchQueue.main.async { self?.activityIndicator.stopAnimating() //Stop loading indicators if let error = error { print(error.localizedDescription) //Handle service error } else if let friends = friends { self?.friends = friends //Update friends property self?.updateUI() //Update user interface } } } }

No exemplo acima, estamos assumindo que a função friendsButtonTapped é invocado sempre que o usuário toca em um botão destinado a mostrar uma lista de seus amigos na rede. Também mantemos uma referência à tarefa no friendsTask para que possamos cancelar a solicitação a qualquer momento chamando friendsTask?.cancel().

Isso nos permite ter um maior controle do ciclo de vida das solicitações pendentes, permitindo-nos encerrá-las quando determinarmos que se tornaram irrelevantes.

Conclusão

Neste artigo, compartilhei uma arquitetura simples de um módulo de rede para seu aplicativo iOS que é trivial de implementar e pode ser adaptada às necessidades de rede intrincadas da maioria dos aplicativos iOS. No entanto, a principal conclusão disso é que um cliente REST devidamente projetado e os componentes que o acompanham - que são isolados do resto da lógica do aplicativo - podem ajudar a manter o código de interação cliente-servidor do aplicativo simples, mesmo quando o próprio aplicativo se torna cada vez mais complexo .

Espero que este artigo seja útil para construir seu próximo aplicativo iOS. Você pode encontrar o código-fonte deste módulo de rede no GitHub . Verifique o código, bifurque-o, altere-o, brinque com ele.

Se você achar alguma outra arquitetura mais preferível para você e seu projeto, por favor, compartilhe os detalhes na seção de comentários abaixo.

Relacionado: Simplificando o uso da API RESTful e a persistência de dados no iOS com Mantle e Realm