Comunidade

Minha Primeira Camada de Rede iOS foi Mais Simples do que eu Esperava

Um desenvolvedor Android que se aventurou na criação de uma camada de rede para um aplicativo iOS descobriu que sua abordagem inicialmente planejada foi mais simples do que esperava. Em vez de criar uma abstração complexa como Retrofit, optou por implementar uma única classe genérica chamada NetworkClient que aceita parâmetros HTTP, headers e corpo da requisição, simplificando a camada de rede sem perder funcionalidade.

Compartilhar
Medium

Minha Primeira Camada de Rede iOS foi Mais Simples do que eu Esperava

Pressione enter ou clique para ver a imagem em tamanho completo

Parte 2 da jornada de um desenvolvedor Android para iOS

No final do artigo anterior, eu escrevi que queria explorar endpoints, construção de solicitações e mapeamento de erros a seguir.

Era esse o plano.

Mas quando comecei a implementar a camada de rede real, não criei uma abstração Endpoint em tudo.

Meu instinto de desenvolvedor Android me dizia para construir algo declarativo e estruturado - algo mais próximo do Retrofit.

Uma interface para cada chamada da API.
Um construtor de solicitações.
Interceptadores.
Talvez até mesmo uma camada de transporte separada.

Em vez disso, acabei com um único NetworkClient genérico.

Ele aceita um método HTTP, cabeçalhos, parâmetros de consulta, um corpo opcional e o tipo de resposta esperado.

O resultado foi menor do que a arquitetura que eu inicialmente imaginei.

Para a primeira versão do aplicativo, penso que essa foi a decisão certa.

O que eu realmente precisava

Meu projeto pessoal usa a API TMDB.

O aplicativo carrega filmes, detalhes de filmes, atores, imagens, favoritos e listas de assistir. Também suporta autenticação do TMDB.

As chamadas da API parecem diferentes.

Uma carrega uma página de filmes.
Outra carrega informações sobre um ator.
A autenticação usa solicitações POST com corpos JSON.

Mas por baixo, eu estava repetindo a mesma tarefa:

  1. Crie uma URL.
  2. Adicione parâmetros de consulta.
  3. Construa um URLRequest.
  4. Adicione cabeçalhos de autorização e conteúdo.
  5. Codifique o corpo da solicitação quando necessário.
  6. Execute a solicitação.
  7. Verifique o código de status HTTP.
  8. Decodifique a resposta.
  9. Traduza falhas de rede em erros da aplicação.

Ainda não precisava de uma estrutura para rede.

Precisava de um lugar que lidasse com esses passos consistentemente.

O fluxo real de rede na versão atual do projeto é este:

ViewModel

Repository

Service

NetworkClient

URLSession

No artigo anterior, mostrei uma arquitetura conceitual com uma camada adicional de uso.

Ainda não introduzi tipos separados de uso no meu projeto-pet.

No momento, a maioria delas apenas delega chamadas de ViewModel para repositório sem adicionar comportamento significativo. Posso adicioná-las quando a lógica de negócios se tornar complexa o suficiente para justificar essa fronteira extra.

Por enquanto, a implementação real é mais simples.

Esperava construir algo como o Retrofit

No Android, o Retrofit normalmente me permite descrever uma chamada de API em um lugar:

  • Método HTTP;
  • caminho relativo;
  • parâmetros de consulta;
  • corpo da solicitação;
  • tipo de resposta.

O framework então gera a maioria da maquinaria de solicitação.

Mas eu não tinha uma abstração equivalente no meu projeto, então comecei com um pequeno enum HttpMethod:

enum HttpMethod {
case get(String)
case post(String)

var name: String {
switch self {
case .get:
return "GET"
case .post:
return "POST"
}
}

var path: String {
switch self {
case .get(let path), .post(let path):
return path
}
}
}

O valor associado contém a URL completa:

.get("https://api.themoviedb.org/3/discover/movie")

Isto é muito menos sofisticado do que uma interface Retrofit.

Também é fácil de entender.

Um serviço informa ao cliente qual método HTTP e URL são necessários. O cliente lida com o resto.

Já consigo ver a limitação: a URL completa da TMDB é repetida em vários serviços.

Caso a URL base mude, terei que atualizar várias pastas.

Uma versão futura pode separar a URL base do caminho ou introduzir uma verdadeira abstração de endpoint.

Mas decidi não resolver esse problema futuro antes que o aplicativo realmente precisasse dele.

Um método lida com a maioria das solicitações

A API principal da minha camada de rede é um único método genérico:

func request<T>(
_ httpMethod: HttpMethod,
headers: [HttpHeader] = [],
queryParams: [Param] = [],
body: Encodable? = nil,
timeoutInterval: TimeInterval = 15
) async throws -> T where T: Decodable

O método recebe tudo que é necessário para criar a solicitação:

  • a URL e o método HTTP;
  • cabeçalhos opcionais;
  • parâmetros de consulta;
  • um corpo Encodable opcional;
  • um tempo limite.

O tipo de resposta esperado é inferido do contexto de chamada.

Mantenha HTML no corpo se existir.

func getPerson(id: Int) async throws -> Person {
try await networkClient.request(
.get("https://api.themoviedb.org/3/person/", id),
queryParams: [
Param(
name: "language",
value: iso3166LanguageCode(Locale.current)
)
]
)
}

Porque a função retorna Person, o Swift sabe que a resposta deve ser decodificada como Person.

Gosto de como o site da chamada é pequeno.

Ao mesmo tempo, muito mais informações permanecem visíveis do que com o Retrofit. O serviço ainda sabe a URL e cria manualmente a lista de parâmetros.

Isso é a troca do design atual.

O que uma solicitação real parece

Uma das principais operações no aplicativo carrega uma página de filmes.

O serviço chama o endpoint de descoberta do TMDB:

func fetchMovies(
page: Int,
genre: Genres?
) async throws -> Page {
try await networkClient.request(
.get("https://api.themoviedb.org/3/discover/movie"),
queryParams: [
Param(
name: "include_adult",
value: "false"
),
Param(
name: "language",
value: iso3166LanguageCode(Locale.current)
),
Param(
name: "page",
value: page
),
Param(
name: "sort_by",
value: "primary_release_date.desc"
)
]
)
}

Isso não é um exemplo genérico de tutorial.

O serviço descreve a operação do TMDB.

Não sabe como URLComponents funciona, como a header de autorização é adicionada ou como a resposta é decodificada.

Essa fronteira era a principal coisa que eu queria na primeira versão.

Parâmetros de consulta permaneceram deliberadamente simples

Representei parâmetros de consulta com um pequeno tipo:

struct Param {
let name: String
let value: Any
}

Dentro de NetworkClient, eles se tornam valores URLQueryItem:

if !queryParams.isEmpty {
components?.queryItems = queryParams.map {
URLQueryItem(
name: $0.name,
value: "\($0.value)"
)
}
}

Isso me permite passar strings, inteiros e valores opcionais sem converter tudo manualmente antes de chamar o cliente.

É conveniente.

Ainda não estou certo se é a melhor designação.

Usar Any remove a segurança de tipos e confia na interpolação de strings para produzir um valor de consulta válido.

Eu poderia substituí-lo por String, aceitar diretamente [URLQueryItem] ou introduzir um tipo de valor mais restrito.

Para a aplicação atual, Param mantém as chamadas de serviço legíveis.

Para uma camada de rede maior, eu provavelmente reconsideraria isso.

Cabeçalhos tipados em vez de strings brutos

A aplicação atual precisa apenas de um pequeno conjunto de cabeçalhos:

  • Accept;
  • Authorization;
  • Content-Type

Eu os representei com enums em vez de passar dicionários de strings por toda parte:

enum MediaType: String {
case json = "application/json"
}
enum AuthorizationType {
case bearer(String)

var value: String {
switch self {
case .bearer(let token):
return "Bearer \(token)"
}
}
}
enum HttpHeader {
case accept(MediaType)
case authorization(AuthorizationType)
case contentType(MediaType)

var key: String {
switch self {
case .accept:
return "Accept"
case .authorization:
return "Authorization"
case .contentType:
return "Content-Type"
}
}
var value: String {
switch self {
case .accept(let mediaType),
.contentType(let mediaType):
return mediaType.rawValue
case .authorization(let type):
return type.value
}
}
}

Isso me lembra das pequenas wrappers que eu posso construir ao redor de APIs OkHttp.

Strings brutais funcionariam.

O enum simplesmente torna explícito o conjunto limitado de cabeçalhos usados por este aplicativo e previne diferenças ortográficas entre os serviços.

Não estou tentando modelar todos os possíveis cabeçalhos HTTP.

Somente aqueles que eu realmente preciso.

A autenticação mudou uma pequena decisão de design

Meu projeto pessoal pode fazer solicitações em dois estados de autenticação.

Antes que o usuário faça login, as solicitações usam a chave de API do aplicativo.

Depois da autenticação, algumas solicitações precisam do token de acesso do usuário.

Criei um SessionTokenProvider:

protocol SessionTokenProvider {
func currentBearerToken() -> String
}
final class DefaultSessionTokenProvider:SessionTokenProvider {

private let sessionStore: SessionStore

init(sessionStore: SessionStore) {
self.sessionStore = sessionStore
}
func currentBearerToken() -> String {
sessionStore.load()?.accessToken
?? Environment.apiKey
}
}

A parte importante não é o provedor em si.

A parte importante é quando o cliente pede um token dele.

Os cabeçalhos padrão são calculados para cada solicitação:

private var defaultHeaders: [HttpHeader] {
[
.aceito(.json),
.autorizacao(
.bearer(
sessionTokenProvider
.currentBearerToken()
)
)
]
}

NetworkClient não lê o token uma vez durante a inicialização e mantém para sempre.

A mesma instância do cliente pode primeiro usar o token da aplicação e depois usar o token do usuário autenticado.

Em Android, eu normalmente pensaria sobre essa responsabilidade em termos de um interceptador OkHttp ou de um componente de autenticação.

Na minha implementação em Swift, a solução é atualmente muito menor: um provedor que resolve o token atual enquanto a solicitação está sendo construída.

Escrevi testes especificamente para esse comportamento.

A primeira solicitação usa um token. Então a sessão muda. A segunda solicitação deve usar o novo.

É um pequeno teste, mas protege a aplicação de manter um estado de autenticação desatualizado.

Construindo a solicitação

Dentro NetworkClient, o método primeiro cria a URL:

guard let url = URL(
string: httpMethod.path
) else {
throw ConnectionError.noUrl
}

var components = URLComponents(
url: url,
resolvingAgainstBaseURL: true
)

if !queryParams.isEmpty {
components?.queryItems = queryParams.map {
URLQueryItem(
name: $0.name,
value: "\($0.value)"
)
}
}

guard let url = components?.url else {
throw ConnectionError.badUrl
}

Em seguida, cria um URLRequest:

var request = URLRequest(url: url)
request.httpMethod = httpMethod.name
request.timeoutInterval = timeoutInterval

Cabeçalhos padrão são aplicados primeiro:

for header in defaultHeaders {
request.setValue(
header.value,
forHTTPHeaderField: header.key
)
}

Cabeçalhos específicos da solicitação são adicionados em seguida:

for header in headers {
request.setValue(
header.value,
forHTTPHeaderField: header.key
)
}

Essa ordem é intencional.

A solicitação específica pode substituir um cabeçalho padrão quando necessário.

Também verifico esse comportamento em testes usando um URLProtocol personalizado.

Uma linha que eu já mudaria

Para solicitações POST, a implementação atual aceita um corpo do tipo Encodable:

if let body {
request.httpBody = try? JSONEncoder().encode(body)
}

O código é conciso.

No entanto, também é arriscado.

Ao usar try?, um erro de codificação silenciosamente se torna nil.

A solicitação pode então chegar ao backend sem corpo e a resposta resultante pode parecer um problema do servidor, mesmo que o real falha tenha ocorrido localmente.

No momento em que escrevi essa linha, senti-me conveniente.

Após revisar o fluxo completo, prefiro deixar o erro propagar:

if let body {
request.httpBody = try JSONEncoder().encode(body)
}

Este é exatamente o tipo de decisão que eu queria documentar nesta série.

A primeira versão funciona.

No entanto, ao revisá-la no contexto, percebo que uma conveniência simplificada dificulta a depuração.

Injetar URLSession parecia natural

A solicitação real é executada através de um URLSession injetado:

let (data, response) = try await urlSession.data(for: request)

Poderia ter chamado URLSession.shared diretamente dentro de NetworkClient.

No entanto, Swinject fornece a sessão do exterior.

Depois de anos injetando clientes OkHttp e serviços Retrofit no Android, isso parecia natural.

No ambiente de produção, o aplicativo usa a sessão compartilhada.

In tests, I can provide an ephemeral URLSession configured with a custom URLProtocol.

That lets the tests inspect the generated request and return a controlled response without sending real traffic.

I did not introduce a protocol around NetworkClient in the first version.

For testing the networking code itself, replacing URLSession was enough.

If I later need to test services independently from the client, a NetworkClientProtocol may become useful.

I do not want to add it only because every dependency is “supposed” to have an interface.

I want it to solve a real problem.

The first version worked — until I reviewed the error flow

When I finished the client, I thought the error handling was reasonably clear.

Transport failures were translated through ConnectionErrorMapper.

HTTP status codes produced ApiError values.

The decoder could throw decoding errors.

At least, that was what I intended.

The implementation looked roughly like this:

do {
let (data, response) = try await urlSession.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw ConnectionError.unknown
}

switch httpResponse.statusCode {
case 200...299:
let result: T = try decoder.decode(data)
return result
case 401:
throw ApiError.invalidAPIKey
case 500...503:
throw ApiError.serverError
default:
throw ConnectionError.unknown
}
} catch let error as URLError {
throw connectionErrorMapper.map(error)
} catch {
throw ConnectionError.unknown
}

When I reviewed this code for the article, I noticed a problem.

The final generic catch does not only catch unexpected transport failures.

It also catches errors produced during response validation and decoding.

An ApiError.serverError can become ConnectionError.unknown.

A decoding failure can also become ConnectionError.unknown.

The abstraction was not only hiding implementation details.

It was hiding useful information.

I would fix this by limiting the do-catch scope to the actual URLSession call:

let data: Data
let response: URLResponse

do {
(data, response) = try await urlSession.data(for: request)
} catch let error as URLError {
throw connectionErrorMapper.map(error)
}

Status handling and decoding should happen after that block.

Their original errors can then propagate without being replaced.

This may be the most important thing I discovered while revisiting the implementation.

Not because it required a complex architectural change.

Because it showed how easily an abstraction can become too aggressive and erase information that the rest of the application needs.

Mapeando erros de conexão

Vales brutos URLError são traduzidos por um mapeador separado:

final class ConnectionErrorMapper {
func map(
_ error: URLError
) -> ConnectionError {
switch error.code {
case .notConnectedToInternet:
return .noInternet
case .timedOut:
return .timeout
case .networkConnectionLost:
return .connectionLost
case .cannotFindHost,
.cannotConnectToHost:
return .cannotConnectToHost
case .dataNotAllowed:
return .networkRestricted
default:
return .unknown
}
}
}

Isto mantém a lógica de mapeamento fora do NetworkClient e fornece uma menor quantidade de erros para as camadas superiores entenderem.

A ideia é familiar do Android.

Uma biblioteca de rede de nível baixo pode expor muitos tipos técnicos de falha. O aplicativo geralmente precisa de um modelo de erro mais estável.

Mas o mapeador deve traduzir apenas os erros que realmente entende.

Não deve transformar cada falha na mesma valor genérico.

Isso era o que meu bloco de captura amplo estava fazendo.

Um decodificador para datas do TMDB

O TMDB frequentemente retorna datas neste formato:

yyyy-MM-dd

Em vez de configurar um novo JSONDecoder dentro de cada serviço, criei uma pequena camada de decodificação com uma estratégia de data compartilhada.

Versão simplificada:

final class Decoder {
private let decoder = JSONDecoder()
private let dateFormatter = DateFormatter()

init() {
dateFormatter.locale = Locale(
identifier: "en_US_POSIX"
)
dateFormatter.dateFormat = "yyyy-MM-dd"
decoder.dateDecodingStrategy = .custom {
decoder in
let container =
try decoder.singleValueContainer()
let value =
try container.decode(String.self)
guard !value.isEmpty else {
return Date()
}
guard let date =
self.dateFormatter.date(from: value)
else {
throw DecodingError
.dataCorruptedError(
in: container,
debugDescription:
"Cannot decode date \\(value)"
)
}
return date
}
}
func decode<T: Decodable>(
_ data: Data
) throws -> T {
try decoder.decode(T.self, from: data)
}
}

A centralização do formato de data removeu a configuração repetida dos serviços.

Há ainda uma decisão aqui que não gosto.

Uma string de data vazia se torna Date().

Isto impede o decodificação de falhar, mas também substitui os dados ausentes do servidor pelo horário atual.

O valor resultante parece válido mesmo que não tenha vindo do TMDB.

Um data opcional ou um erro explícito durante a decodificação provavelmente seria mais honesto.

Isto é outra coisa que eu mudaria na próxima versão.

O serviço se concentra no TMDB

O serviço de filmes depende do NetworkClient e descreve operações concretas da API.

A repositória está acima do serviço:

final class MoviesRepositoryImpl: MoviesRepository {

private let service: MoviesServiceProtocol

init(service: MoviesServiceProtocol) {
self.service = service
}

func fetchMovies(
page: Int
) async throws -> Page {
try await service.fetchMovies(
page: page,
genre: nil
)
}

func fetchMovies(
page: Int,
genre: Genres
) async throws -> Page {
try await service.fetchMovies(
page: page,
genre: genre
)
}
}

A repositória não conhece URLs, cabeçalhos ou URLSession.

O ViewModel não comunica diretamente com a rede.

Essa separação é menos interessante do que o cliente genérico em si, mas provavelmente é mais importante para a aplicação.

Detalhes de rede permanecem na camada de dados.

O código da funcionalidade trabalha com operações como “carregar filmes” em vez de “enviar uma solicitação GET para esta URL.”

O que o Android me ajudou a ver

A minha experiência no Android foi útil durante toda essa implementação.

Isso me fez notar códigos de infraestrutura repetidos cedo.

Tornou a injeção de dependência natural.

O empurrou em direção ao tratamento centralizado de erros e efeitos colaterais substituíveis.

Isso também criou uma tentação para construir mais abstrações do que o projeto precisava.

Era esperado endpoints.

Era esperado interceptores.

Era esperado algo que se sentisse como um pequeno Retrofit e OkHttp stack.

A implementação atual não tem nada disso.

Tem apenas um NetworkClient genérico, um provedor de token, um mapeador de erro e um decodificador.

Próximas mudanças

Depois de comparar o artigo com o projeto real, minhas próximas alterações seriam práticas em vez de arquiteturais.

Primeiro, preservaria os erros da API e do decodificador em vez de substituí-los por .unknown.

Segundo, deixaria que os erros de codificação do corpo se propagassem em vez de usar try?.

Terceiro, moveria a URL base da TMDB fora dos serviços individuais.

Quarto, substituiria Param.value: Any por algo mais seguro em termos de tipo.

Quinto, pararia de converter datas vazias do servidor na data atual.

Somente após essas alterações decidiria se o aplicativo realmente precisa de uma abstração separada para pontos de extremidade, construtor de solicitações ou cliente de transporte.

Dividir uma classe em várias classes não melhora automaticamente a arquitetura.

Às vezes, apenas distribui a mesma complexidade por mais arquivos.

O que aprendi com a primeira versão

Quando comecei essa parte do projeto, esperava construir algo familiar do Android.

Talvez uma abstração de ponto de extremidade.
Talvez um construtor de solicitações.
Talvez uma pequena versão do Retrofit.

Acabei com um NetworkClient genérico.

Ele cria solicitações, aplica cabeçalhos, seleciona o token atual, executa URLSession e decodifica o resultado.

Não é perfeito.

A revisão para este artigo já mostrou várias coisas que quero mudar.

Mas isso pode ser a parte mais útil de construir a camada por conta própria.

O Retrofit e o OkHttp geralmente fazem muitas dessas decisões por mim.

No iOS, tive que fazer essas escolhas explicitamente — e às vezes descobrir que minha primeira decisão estava errada.

Minha principal conclusão desta versão é simples:

A primeira abstração não precisa resolver todos os problemas futuros. Ela apenas precisa tornar o problema atual mais claro.

No próximo artigo, eu vou analisar a autenticação: como um token de solicitação TMDB se torna um token de acesso, como a sessão é armazenada no Keychain e como o mesmo NetworkClient muda do token da aplicação para o token do usuário.

Aviso: Este é um projeto educacional independente e não está associado ou endossado pelo TMDB. Este produto usa a API do TMDB, mas não é endossado ou certificado pelo TMDB. Todos os marcas registradas e materiais sob direitos autorais pertencem aos seus respectivos proprietários.

Fonte original

Conteúdo traduzido e adaptado pela redação do Notícias Mobile. Confira também a matéria na fonte original.

Leia a matéria completa