Как стать автором
Обновить

Swift. Менеджер сетевых запросов. DataLoader

Время на прочтение11 мин
Количество просмотров3.7K
Xcode
Xcode

По крайней мере в последнее десятилетие, количество приложений, которым требуется доступ в интернет, неимоверно возросло. Причем для большинства проектов требуется только выполнение REST запросов и загрузка изображений, с созданием preview. В связи с чем, необходим своего рода менеджер сетевых запросов для загрузки данных из сети. Далее будет представлен NetworkManager, с помощью которого выполняются REST запросы.

Задача

Необходимо разработать менеджер сетевых запросов для выполнения REST запросов. При этом необходимо, чтобы была возможность обрабатывать появление/отсутствие соединения и конфигурировать кэширование. Решение должно использовать новую систему concurrency, представленную в iOS 13.

Решение

Ниже представлен базовый класс, в котором расположена логика по обработке ответа сервера (URLResponse), от которого будут наследоваться конкретные загрузчики (модули), в том числе DataLoader и который зависит только от Foundation framework.

import Foundation

public class NetworkManager {
	
	// MARK: - Properties
	
	let session: URLSession
	
	// MARK: - Lifecycle
	
	public init(session: URLSession) {
		self.session = session
	}
	
	// MARK: - Methods
	
	func handle<T>(response: URLResponse, content: T) throws -> T {
		guard let response = response as? HTTPURLResponse else {
			throw "Unknown response received"
		}
		
		guard let httpStatusCode = HttpStatusCode(rawValue: response.statusCode) else {
			throw "Unknown http status code"
		}
		
		if httpStatusCode.isSuccessStatusCode {
			return content
		} else if let content = content as? Data {
			throw try JSONDecoder().decode(Request.Error.self, from: content)
		} else {
			throw Request.Error(code: response.statusCode)
		}
	}
}

Не стоит удивляться наличию перечисления HTTPStatusCode, поскольку это достаточно стандартный набор констант, возвращаемых серверами в URLResponse.

import Foundation

public enum HttpStatusCode: Int {
	case unknown = -1
	
	// Informational response
	case `continue` = 100
	case switchingProtocols = 101
	case processing = 102
	case earlyHints = 103
	
	// Success
	case ok = 200
	case created = 201
	case accepted = 202
	case nonAuthoritativeInformation = 203
	case noContent = 204
	case resetContent = 205
	case partialContent = 206
	case multiStatus = 207
	case alreadyReported = 208
	case used = 226
	
	// Redirection
	case multipleChoices = 300
	case movedPermanently = 301
	case found = 302
	case seeOther = 303
	case notModified = 304
	case useProxy = 305
	case switchProxy = 306
	case temporaryRedirect = 307
	case permanentRedirect = 308
	
	// Client errors
	case badRequest = 400
	case unauthorized = 401
	case paymentRequired = 402
	case forbidden = 403
	case notFound = 404
	case methodNotAllowed = 405
	case notAcceptable = 406
	case proxyAuthenticationRequired = 407
	case requestTimeout = 408
	case conflict = 409
	case gone = 410
	case lengthRequired = 411
	case preconditionFailed = 412
	case payloadTooLarge = 413
	case urlTooLong = 414
	case unsupportedMediaType = 415
	case rangeNotSatisfiable = 416
	case expectationFailed = 417
	case teapot = 418
	case misdirectedRequest = 421
	case unprocessableEntity = 422
	case locked = 423
	case failedDependency = 424
	case tooEarly = 425
	case upgradeRequired = 426
	case preconditionRequired = 428
	case tooManyRequests = 429
	case requestHeaderFieldsTooLarge = 431
	case unavailableForLegalReasons = 451
	
	// Server errors
	case internalServerError = 500
	case notImplemented = 501
	case badGateway = 502
	case serviceUnavailable = 503
	case gatewayTimeout = 504
	case httpVersionNotSupported = 505
	case variantAlsoNegotiates = 506
	case insufficientStorage = 507
	case loopDetected = 508
	case notExtended = 510
	case networkAuthenticationRequired = 511
	
	public var isSuccessStatusCode: Bool {
		switch self.rawValue {
		case 200..<300:
			return true
		default:
			return false
		}
	}
}

Также Request.Error - наиболее тривиальная структура, которая содержит ответ сервера в случае возникновения ошибки.

import Foundation

extension Request {
	public struct Error: LocalizedError, Codable {
		public var code: Int
		public var error: String?
		
		public var statusCode: HttpStatusCode? {
			return HttpStatusCode(rawValue: code)
		}
		
		public var errorDescription: String? {
			return error
		}
	}
}

DataLoader наиболее часто используется. Поскольку с его помощью выполняются REST запросы, то предпочтительно, чтобы данный модуль еще и выполнял сериализацию/десериализацию запросов.

import Foundation

public final class DataLoader: NetworkManager {
	@discardableResult
	public func dataRequest<T: Decodable>(url: URL,
										  method: HTTPMethod,
										  headers: [HTTPHeader] = [],
										  parameters: Request.Parameters? = nil,
										  decoder: JSONDecoder? = nil) async throws -> T {
		let data = try await dataRequest(
			url: url,
			method: method,
			headers: headers,
			parameters: parameters
		)
		
		let decoder = decoder ?? JSONDecoder()
		return try decoder.decode(T.self, from: data)
	}
	
	@discardableResult
	public func dataRequest(url: URL,
							method: HTTPMethod,
							headers: [HTTPHeader] = [],
							parameters: Request.Parameters? = nil) async throws -> Data {
		let request = try Request(
			url: url,
			method: method,
			headers: headers,
			parameters: parameters
		)
		
		let (data, response) = try await session.data(for: request.urlRequest)
		return try handle(response: response, content: data)
	}
}

HTTPMethod - очередной объект содержащий константы, которые определяют метод запроса. Поскольку методы могут быть и нестандартными, для возможности расширения в качестве контейнера была выбрана структура вместо перечисления.

import Foundation

public struct HTTPMethod: RawRepresentable, Equatable, Hashable {
	public let rawValue: String
	
	public init(rawValue: String) {
		self.rawValue = rawValue
	}
}

public extension HTTPMethod {
	static let connect = HTTPMethod(rawValue: "CONNECT")
	static let delete = HTTPMethod(rawValue: "DELETE")
	static let get = HTTPMethod(rawValue: "GET")
	static let head = HTTPMethod(rawValue: "HEAD")
	static let options = HTTPMethod(rawValue: "OPTIONS")
	static let patch = HTTPMethod(rawValue: "PATCH")
	static let post = HTTPMethod(rawValue: "POST")
	static let put = HTTPMethod(rawValue: "PUT")
	static let trace = HTTPMethod(rawValue: "TRACE")
}

HTTPHeader - объект, который содержит информацию о header запроса. Также реализован в виде структуры, а не перечисления, поскольку необходима возможность расширения нестандартными значениями.

import Foundation

public struct HTTPHeader: Hashable {
	public let name: String
	public let value: String
	
	public init(name: String, value: String) {
		self.name = name
		self.value = value
	}
}

extension HTTPHeader: CustomStringConvertible {
	public var description: String {
		"\(name): \(value)"
	}
}

public extension HTTPHeader {
	static func accept(_ value: String) -> HTTPHeader {
		HTTPHeader(name: "Accept", value: value)
	}
	
	static func acceptCharset(_ value: String) -> HTTPHeader {
		HTTPHeader(name: "Accept-Charset", value: value)
	}
	
	static func acceptLanguage(_ value: String) -> HTTPHeader {
		HTTPHeader(name: "Accept-Language", value: value)
	}
	
	static func acceptEncoding(_ value: String) -> HTTPHeader {
		HTTPHeader(name: "Accept-Encoding", value: value)
	}
	
	static func authorization(username: String, password: String) -> HTTPHeader {
		let credential = Data("\(username):\(password)".utf8).base64EncodedString()
		return authorization("Basic \(credential)")
	}
	
	static func authorization(bearerToken: String) -> HTTPHeader {
		authorization("Bearer \(bearerToken)")
	}
	
	static func authorization(_ value: String) -> HTTPHeader {
		HTTPHeader(name: "Authorization", value: value)
	}
	
	static func contentDisposition(_ value: String) -> HTTPHeader {
		HTTPHeader(name: "Content-Disposition", value: value)
	}
	
	static func contentType(_ value: String) -> HTTPHeader {
		HTTPHeader(name: "Content-Type", value: value)
	}
	
	static func contentLength(_ value: String) -> HTTPHeader {
		HTTPHeader(name: "Content-Length", value: value)
	}
	
	static func userAgent(_ value: String) -> HTTPHeader {
		HTTPHeader(name: "User-Agent", value: value)
	}
}

public extension HTTPHeader {
	static func qualityEncoded(_ encodings: [String]) -> String {
		return encodings.enumerated().map { index, encoding in
			let quality = 1.0 - (Double(index) * 0.1)
			return "\(encoding);q=\(quality)"
		}
		.joined(separator: ", ")
	}
	
	static let defaultAcceptEncoding: HTTPHeader = {
		let encodings = ["br", "gzip", "deflate"]
		let value = qualityEncoded(encodings)
		return .acceptEncoding(value)
	}()

	static let defaultAcceptLanguage: HTTPHeader = {
		let encodings = Array(Locale.preferredLanguages.prefix(6))
		let value = qualityEncoded(encodings)
		return .acceptLanguage(value)
	}()

	static let defaultUserAgent: HTTPHeader = {
		let info = Bundle.main.infoDictionary
		let executable = (info?["CFBundleExecutable"] as? String) ??
			(ProcessInfo.processInfo.arguments.first?.split(separator: "/").last.map(String.init)) ??
			"Unknown"
		let bundle = info?["CFBundleIdentifier"] as? String ?? "Unknown"
		let appVersion = info?["CFBundleShortVersionString"] as? String ?? "Unknown"
		let appBuild = info?["CFBundleVersion"] as? String ?? "Unknown"

		let osNameVersion: String = {
			let version = ProcessInfo.processInfo.operatingSystemVersion
			let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
			let osName: String = {
				#if os(iOS)
				#if targetEnvironment(macCatalyst)
				return "macOS(Catalyst)"
				#else
				return "iOS"
				#endif
				#elseif os(watchOS)
				return "watchOS"
				#elseif os(tvOS)
				return "tvOS"
				#elseif os(macOS)
				return "macOS"
				#elseif os(Linux)
				return "Linux"
				#elseif os(Windows)
				return "Windows"
				#else
				return "Unknown"
				#endif
			}()

			return "\(osName) \(versionString)"
		}()
		
		let userAgent = "\(executable)/\(appVersion) (\(bundle); build:\(appBuild); \(osNameVersion))"
		return .userAgent(userAgent)
	}()
}

С параметрами запроса все несколько сложнее, так как они могут передаваться как в query строке, так и в body запроса. Поэтому структура Parameters конфигурирует то, как параметры будут передаваться, в то время как логика по формированию query или body расположена в объекте Query<T>.

import Foundation

public extension Request {
	struct Parameters {
		
		// MARK: - Types
		
		public enum Destination {
			case query
			case body
		}
		
		// MARK: - Properties
		
		let destination: Destination
		let object: Any
		
		// MARK: - Methods
		
		public static func query(_ query: Query<String>, encoding: QueryEncoding? = nil) -> Parameters {
			let encoding = encoding ?? QueryEncoding()
			let object = (query, encoding)
			return Parameters(destination: .query, object: object)
		}
		
		public static func body<T: Encodable>(_ object: T, encoder: JSONEncoder? = nil) throws -> Parameters {
			let encoder = encoder ?? JSONEncoder()
			let data = try encoder.encode(object)
			return Parameters(destination: .body, object: data)
		}
		
		public static func body(_ data: Data) -> Parameters {
			return Parameters(destination: .body, object: data)
		}
	}
}

Более подробно про структуру Query<T> рассказано в Swift. Сериализация параметров запроса, поэтому ниже представлен только source code объекта.

import Foundation

public extension Request {
	struct Query<Key: Encodable> {
		
		// MARK: - Types
		
		public struct Parameter<K, V> {
			public var key: K
			public var value: V
		}
		
		// MARK: - Properties
		
		private var elements: [Element]
		
		// MARK: - Lifecycle
		
		init<S: Sequence>(uniqueKeysWithValues elements: S) where S.Element == (Key, Value) {
			self.elements = elements.map(Parameter.init)
		}
		
		// MARK: - Methods
		
		public subscript(key: Key) -> Value? where Key: Equatable {
			get { elements.first { $0.key == key }?.value }
			set {
				if let index = elements.firstIndex(where: { $0.key == key }) {
					if let newValue {
						elements[index].value = newValue
					} else {
						elements.remove(at: index)
					}
				} else {
					if let newValue {
						elements.append(Element(key: key, value: newValue))
					}
				}
			}
		}
	}
}

// MARK: - Extensions

extension Request.Query: ExpressibleByDictionaryLiteral {
	public typealias Value = any Encodable
	
	public init(dictionaryLiteral elements: (Key, Value)...) {
		self.elements = elements.map(Parameter.init)
	}
}

extension Request.Query: RangeReplaceableCollection {
	public init() {
		self.elements = []
	}
}

extension Request.Query: Sequence {
	public typealias Iterator = IndexingIterator<Array<Element>>

	public func makeIterator() -> Iterator {
		return elements.makeIterator()
	}
}

extension Request.Query: Collection {
	public typealias Element = Parameter<Key, Value>
	public typealias Index = Int

	public var startIndex: Index {
		return elements.startIndex
	}

	public var endIndex: Int {
		return elements.endIndex
	}

	public subscript(position: Int) -> Element {
		return elements[position]
	}

	public func index(after i: Int) -> Int {
		return elements.index(after: i)
	}
}

extension Request.Query where Key == String {
	public func encode(to url: URL, encoding: QueryEncoding) -> URL? {
		var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
		components?.queryItems = elements.flatMap { encodeQueryItem(element: $0, encoding: encoding) }
		return components?.url
	}
	
	private func encodeQueryItem(element: Element, encoding: QueryEncoding) -> [URLQueryItem] {
		encodeQueryItem(name: element.key, value: element.value, encoding: encoding)
	}
	
	private func encodeQueryItem(name: String, value: Any, encoding: QueryEncoding) -> [URLQueryItem] {
		switch value {
		case let boolean as Bool:
			let queryItem = encodeBool(name: name, value: boolean, encoding: encoding)
			return [queryItem]
		case let number as NSNumber:
			let queryItem = encodeNumber(name: name, value: number)
			return [queryItem]
		case let array as [Any]:
			let queryItems = encodeArray(name: name, value: array, encoding: encoding)
			return queryItems
		case let dictionary as [String: Any]:
			let queryItems = encodeDictionary(name: name, value: dictionary, encoding: encoding)
			return queryItems
		default:
			let queryItem = URLQueryItem(name: name, value: "\(value)")
			return [queryItem]
		}
	}
	
	private func encodeBool(name: String, value: Bool, encoding: QueryEncoding) -> URLQueryItem {
		let stringValue: String
		
		switch encoding.bool {
		case .numeric:
			stringValue = (value as NSNumber).stringValue
		case .literal:
			stringValue = String(value)
		}
		
		return URLQueryItem(name: name, value: stringValue)
	}
	
	private func encodeNumber(name: String, value: NSNumber) -> URLQueryItem {
		let stringValue = value.stringValue
		return URLQueryItem(name: name, value: stringValue)
	}
	
	private func encodeArray(name: String, value: [Any], encoding: QueryEncoding) -> [URLQueryItem] {
		switch encoding.array {
		case .enclosingBrackets:
			return value.flatMap { encodeQueryItem(name: name + "[]", value: $0, encoding: encoding) }
		case .surroundingBrackets:
			let value = value
				.flatMap { encodeQueryItem(name: name, value: $0, encoding: encoding) }
				.compactMap { $0.value }
				.map { "\"\($0)\"" }
				.joined(separator: ",")
			
			let queryItem = URLQueryItem(name: name, value: "[\(value)]")
			return [queryItem]
		case .noBrackets:
			return value.flatMap { encodeQueryItem(name: name, value: $0, encoding: encoding) }
		}
	}
	
	private func encodeDictionary(name: String, value: [String: Any], encoding: QueryEncoding) -> [URLQueryItem] {
		return value
			.map { encodeQueryItem(name: name + "[\($0)]", value: $1, encoding: encoding) }
			.flatMap { $0 }
	}
}

extension Request.Query: Encodable {
	public func encode(to encoder: Encoder) throws {
		if Key.self is CodingKeyRepresentable.Type {
			var container = encoder.container(keyedBy: QueryCodingKey.self)
			
			for element in elements {
				guard let key = element.key as? CodingKeyRepresentable else {
					continue
				}
				
				let codingKey = QueryCodingKey(key: key)
				try container.encode(element.value, forKey: codingKey)
			}
		} else {
			var container = encoder.unkeyedContainer()
			
			for element in elements {
				try container.encode(element.key)
				try container.encode(element.value)
			}
		}
	}
}

QueryCodingKey - одна из зависимостей Query<T> структуры, с помощью которой присходит энкодинг any Encodable значения.

import Foundation

struct QueryCodingKey: CodingKey {
	let stringValue: String
	let intValue: Int?
	
	init(stringValue: String) {
		self.stringValue = stringValue
		self.intValue = Int(stringValue)
	}
	
	init(intValue: Int) {
		self.stringValue = "\(intValue)"
		self.intValue = intValue
	}
	
	init(key: CodingKeyRepresentable) {
		self.stringValue = key.codingKey.stringValue
		self.intValue = key.codingKey.intValue
	}
}

QueryEncoding - еще одна зависимость Query<T>, которая конфигурирует серилизацию query строки.

import Foundation

public struct QueryEncoding {
	public enum ArrayEncoding {
		case enclosingBrackets
		case surroundingBrackets
		case noBrackets
	}
	
	public enum BoolEncoding {
		case numeric
		case literal
	}
	
	public var array: ArrayEncoding
	public var bool: BoolEncoding
	
	public init(array: QueryEncoding.ArrayEncoding = .enclosingBrackets, bool: QueryEncoding.BoolEncoding = .literal) {
		self.array = array
		self.bool = bool
	}
}

И, наконец сам запрос. Является ничем иным, как оберткой над объектом URLRequest.

import Foundation

public final class Request {
	
	// MARK: - Properties
	
	private(set) var urlRequest: URLRequest
	
	// MARK: - Lifecycle
	
	init(url: URL,
		 method: HTTPMethod,
		 headers: [HTTPHeader],
		 parameters: Parameters?) throws {
		urlRequest = URLRequest(url: url)
		urlRequest.httpMethod = method.rawValue
		urlRequest.allHTTPHeaderFields = headers.reduce(into: [:]) { $0[$1.name] = $1.value }
		
		if let parameters {
			switch parameters.destination {
			case .query:
				guard let (query, encoding) = parameters.object as? (Query<String>, QueryEncoding) else {
					throw "Cannot resolve query object"
				}
				
				urlRequest.url = query.encode(to: url, encoding: encoding)
			case .body:
				guard let data = parameters.object as? Data else {
					throw "Cannot resolve data object"
				}
				
				urlRequest.httpBody = data
			}
		}
	}
}

На этом ядро NetworkManager сформировано и готово к использованию.

Использование

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

extension DataLoader {
  func updateAccount(id: Int, name: String) async throws {
		let url = try createUrl(host: .staging, path: "api/v1/account/\(id)")
		let accessToken = try accessToken(for: .staging)
		
		let headers: [HTTPHeader] = [
			.authorization("Bearer \(accessToken)")
		]
		
		let parameters: Request.Query = [
			"name": name
		]
		
		try await dataRequest(
			url: url,
			method: .put,
			headers: headers,
			parameters: .query(parameters)
		)
	}
}

Реализация методов createUrl(host:path:) и accessToken(for:) выходит за рамки данной статьи, поэтому рассматриваться не будет.

Заключение

Вполне возможно, что Apple представят своего конкурента Alamofire. Тем более, что Alamofire имеет недостатки и в качестве примера стоит привести спагетти код и зависимость от других библиотек. В любом случае, наличие под рукой NetworkManager, реализованного с оглядкой на абстракцию, инкапсуляцию и наследование позволяет разработчику на мгновение откинуться в кресле и подумать над решением уже другой задачи.

  1. Оригинал статьи

  2. URLSession

  3. URLRequest

  4. URLResponse

  5. Alamofire

Теги:
Хабы:
Всего голосов 5: ↑4 и ↓1+3
Комментарии2

Публикации

Истории

Работа

Swift разработчик
25 вакансий
iOS разработчик
22 вакансии

Ближайшие события

19 сентября
CDI Conf 2024
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн