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

от автора

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

Задача

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

REST запросы

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

import Foundation  public class NetworkManager {  // MARK: - Properties  let session: URLSession let decoder: JSONDecoder  // MARK: - Lifecycle  public init(session: URLSession, decoder: JSONDecoder) { self.session = session self.decoder = decoder }  // 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 decoder.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 } } } 

DataLoader

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

import Foundation  public final class DataLoader: NetworkManager { @discardableResult public func dataRequest<T: Decodable>(url: UrlCreatable,   method: HTTPMethod,   headers: [HTTPHeader] = [],   parameters: Request.Parameters? = nil) async throws -> T { let data = try await dataRequest( url: url, method: method, headers: headers, parameters: parameters )  return try decoder.decode(T.self, from: data) }  @discardableResult public func dataRequest(url: UrlCreatable, method: HTTPMethod, headers: [HTTPHeader] = [], parameters: Request.Parameters? = nil) async throws -> Data { let url = try url.create()  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) } } 

UrlCreatable — это интерфейс для создания URL, котрый реализуют типы String и URL.

import Foundation  public protocol UrlCreatable { func create() throws -> URL }  extension String: UrlCreatable { public func create() throws -> URL { if let url = URL(string: self) { return url } else { throw "Cannot create url" } } }  extension URL: UrlCreatable { public func create() throws -> URL { return self } } 

С HTTPMethod и HTTPHeader тоже все понятно — это также стандартный набор констант, используемых в запросе, отправляемом на сервер (URLRequest).

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) }() } 
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") } 

А вот создание самого запроса программировать уже несколько сложнее, так как нужно сериализовать параметры либо в query строку, либо в httpBody.

import AceExtensions 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 = parameters { try encode(parameters: parameters) } }  // MARK: - Methods  private func encode(parameters: Parameters) throws { switch parameters.configuration.destination { case .query(let arrayEncoding, let boolEncoding): try appendQuery(parameters: parameters, arrayEncoding: arrayEncoding, boolEncoding: boolEncoding) case .httpBody: try addHttpBody(parameters: parameters) } }  private func appendQuery(parameters: Parameters,  arrayEncoding: Parameters.Configuration.ArrayEncoding,  boolEncoding: Parameters.Configuration.BoolEncoding) throws { guard let dictionary = parameters.object as? [String: Any] else { throw "Query parameters is not [String: Any] dictionary" }  guard let url = urlRequest.url,   var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { throw "Cannot create url components from url request" }  for (name, value) in dictionary { let items = createQueryItems( name: name, value: value, arrayEncoding: arrayEncoding, boolEncoding: boolEncoding )  var queryItems = components.queryItems ?? [] queryItems.append(contentsOf: items) components.queryItems = queryItems }  if let url = components.url { urlRequest.url = url }  if parameters.configuration.updateHeaders { var headers = urlRequest.allHTTPHeaderFields ?? [:] headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8" urlRequest.allHTTPHeaderFields = headers } }  private func addHttpBody(parameters: Parameters) throws { if let data = parameters.object as? Data { urlRequest.httpBody = data } else if JSONSerialization.isValidJSONObject(parameters.object) { let data = try JSONSerialization.data(withJSONObject: parameters.object, options: .fragmentsAllowed) urlRequest.httpBody = data }  if parameters.configuration.updateHeaders { var headers = urlRequest.allHTTPHeaderFields ?? [:] headers["Content-Type"] = "application/json; charset=utf-8" urlRequest.allHTTPHeaderFields = headers } }  private func createQueryItems(name: String,   value: Any,   arrayEncoding: Parameters.Configuration.ArrayEncoding,   boolEncoding: Parameters.Configuration.BoolEncoding) -> [URLQueryItem] { switch value { case let boolean as Bool: let value = encodeQuery(boolean: boolean, encoding: boolEncoding) let queryItem = URLQueryItem(name: name, value: value) return [queryItem] case let number as NSNumber: let queryItem = URLQueryItem(name: name, value: number.stringValue) return [queryItem] case let array as [Any]: let name = encodeQuery(array: name, encoding: arrayEncoding) let queryItems = array.flatMap { value in createQueryItems(name: name, value: value, arrayEncoding: arrayEncoding, boolEncoding: boolEncoding) } return queryItems case let dictionary as [String: Any]: let queryItems: [[URLQueryItem]] = dictionary.map { key, value in let name = name + "[\(key)]" return createQueryItems(name: name, value: value, arrayEncoding: arrayEncoding, boolEncoding: boolEncoding) } return queryItems.flatMap { $0 } default: let queryItem = URLQueryItem(name: name, value: "\(value)") return [queryItem] } }  private func encodeQuery(array name: String, encoding: Parameters.Configuration.ArrayEncoding) -> String { switch encoding { case .brackets: return name + "[]" case .noBrackets: return name } }  func encodeQuery(boolean value: Bool, encoding: Parameters.Configuration.BoolEncoding) -> String { switch encoding { case .numeric: return (value as NSNumber).stringValue case .literal: return String(value) } } } 

Может показаться, что работа с параметрами запроса несколько запутана, но на деле это набор ключ: значение, сериализованные по определенным правилам, заданным в структуре Parameters.

import Foundation  public extension Request { struct Parameters {  // MARK: - Types  public struct Configuration { public enum Destination { case query(arrayEncoding: ArrayEncoding, boolEncoding: BoolEncoding) case httpBody }  public enum ArrayEncoding { case brackets case noBrackets }  public enum BoolEncoding { case numeric case literal }  public var destination: Destination public var updateHeaders: Bool  public static let `defaultQuery` = Configuration( destination: .query(arrayEncoding: .brackets, boolEncoding: .literal), updateHeaders: true )  public static let `defaultJson` = Configuration( destination: .httpBody, updateHeaders: true ) }  // MARK: - Properties  public let object: Any public let configuration: Configuration  // MARK: - Lifecycle  public init(object: [String: Any], configuration: Configuration = .defaultQuery) { self.object = object self.configuration = configuration }  public init<T: Encodable>(object: T, configuration: Configuration = .defaultJson) throws { self.object = try JSONEncoder().encode(object) self.configuration = configuration }  public init(object: Data, configuration: Configuration) throws { self.object = object self.configuration = configuration } } }  // MARK: - ExpressibleByDictionaryLiteral  extension Request.Parameters: ExpressibleByDictionaryLiteral { public init(dictionaryLiteral elements: (String, Any)...) { let dictionary = Dictionary(uniqueKeysWithValues: elements) let object = (dictionary as [String: Any?]).compactMapValues { $0 } self.init(object: object, configuration: .defaultQuery) } } 

На этом ядро NetworkManager готово. В очередной раз обращаю внимание, что ядро не зависит от UI фреймворков, а значит легче поддерживается.

FileLoader

Имея такое ядро невероятно просто добавить новый модуль — FileLoader для загрузки файлов.

import Foundation  public final class FileLoader: NetworkManager {  public init(session: URLSession) { super.init(session: session, decoder: JSONDecoder()) }  @discardableResult public func downloadRequest(url: UrlCreatable, method: HTTPMethod, headers: [HTTPHeader] = [], parameters: Request.Parameters? = nil) async throws -> URL { let url = try url.create()  let request = try Request( url: url, method: method, headers: headers, parameters: parameters )  let (fileUrl, response) = try await session.download(for: request.urlRequest) return try handle(response: response, content: fileUrl) } } 

ImageLoader

С загрузкой изображений все несколько сложнее, поскольку необходимо добавить логику кэширования и создания preview. Но, очень важно, чтобы эта логика выполнялась в потоке, параллельном main, иначе возможны тормоза в работе UI. По этой причине так называемый ImageLoader является actor, поскольку будет использоваться Task данного actor, а не MainActor. Также, поскольку уже идет работа с изображениями, добавлена зависимость от UI фреймворка — SwiftUI.

import SwiftUI  public actor ImageLoader {  // MARK: - Properties  private let fileLoader: FileLoader private let storage = ImagesStorage()  // MARK: - Lifecycle  public init(session: URLSession) { fileLoader = FileLoader(session: session) }  // MARK: - Methods  public func loadImage(at url: URL) async -> UIImage? { if let image = storage.loadObject(for: url) { return image } else { let image: UIImage? = try? await downloadImage(url: url) storage.save(object: image, for: url) return image } }  public func createThumb(of image: UIImage?, size: CGSize) async -> UIImage? { guard let image = image else { return nil }  let ratio = image.size.width / image.size.height let width: CGFloat let height: CGFloat  if image.size.width > image.size.height { width = size.width height = width / ratio } else { height = size.height width = ratio * height }  let size = CGSize(width: width, height: height) return await image.byPreparingThumbnail(ofSize: size) }  public func fileUrl(forImageAt url: URL) async -> URL? { return storage.fileUrl(for: url) }  @discardableResult public func downloadImage(url: UrlCreatable,   method: HTTPMethod = .get,   headers: [HTTPHeader] = [],   parameters: Request.Parameters? = nil) async throws -> Image? { let uiImage: UIImage? = try await downloadImage( url: url, method: method, headers: headers, parameters: parameters )  if let uiImage = uiImage { return Image(uiImage: uiImage) } else { return nil } }  @discardableResult public func downloadImage(url: UrlCreatable,   method: HTTPMethod = .get,   headers: [HTTPHeader] = [],   parameters: Request.Parameters? = nil) async throws -> UIImage? { let fileUrl = try await fileLoader.downloadRequest( url: url, method: method, headers: headers, parameters: parameters )  return UIImage(contentsOfFile: fileUrl.path) } } 

Если с созданием preview все понятно — достаточно вызвать метод byPreparingThumbnail(ofSize:), то с логикой кэширования нужно разобраться. Ведь использовать NSCache<KeyType, ObjectType> ошибочно, так как изображение и так будет сохранено в ОЗУ, при наполнении модели. А вот для того, чтобы изображения были кэшированы в постоянную память, требуется некоторый ImagesStorage объект, сохраняющий в постоянную память и загружающий из неё.

import Foundation  public protocol NetworkCache { associatedtype Item func save(object: Item?, for requestUrl: URL) func loadObject(for requestUrl: URL) -> Item? func hasCache(for requestUrl: URL) -> Bool } 
import UIKit  public final class ImagesStorage: CachedStorage, NetworkCache {  // MARK: - Methods  public func save(object: UIImage?, for requestUrl: URL) { let data = object?.pngData() let url = fileUrl(for: requestUrl) save(data: data, at: url) }  public func loadObject(for requestUrl: URL) -> UIImage? { let url = fileUrl(for: requestUrl)  if let data = loadData(at: url) { return UIImage(data: data) } else { return nil } }  public func hasCache(for requestUrl: URL) -> Bool { let url = fileUrl(for: requestUrl) return hasCache(at: url) }  public func fileUrl(for requestUrl: URL) -> URL { let url = cachesUrl() let className = String(describing: Self.self) let hash = (requestUrl.absoluteString as NSString).hash  return url .appendingPathComponent(className) .appendingPathComponent(String(hash)) .appendingPathComponent(requestUrl.lastPathComponent) } }  
import Foundation  public class CachedStorage {  // MARK: - Properties  private let fileManager: FileManager = .default  // MARK: - Methods  public func save(data: Data?, at url: URL) { let directoryUrl = url.deletingLastPathComponent()  if !directoryExistsAtPath(directoryUrl.path) { createCacheDirectory(at: directoryUrl) }  fileManager.createFile(atPath: url.path, contents: data, attributes: nil) }  public func loadData(at url: URL) -> Data? { return try? Data(contentsOf: url) }  public func hasCache(at url: URL) -> Bool { return fileManager.fileExists(atPath: url.path) }  private func directoryExistsAtPath(_ path: String) -> Bool { var isDirectory = ObjCBool(true) let exists = fileManager.fileExists(atPath: path, isDirectory: &isDirectory) return exists && isDirectory.boolValue }  private func createCacheDirectory(at url: URL) { do { try fileManager.createDirectory(at: url, withIntermediateDirectories: true) } catch { assertionFailure("Cache directory not created: \(error.localizedDescription)") } }  public func cachesUrl() -> URL { let urls = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)  if let url = urls.first { return url } else { return fileManager.temporaryDirectory } } } 

Внимательного читателя может удивить использование hash свойства NSString вместо стандартного md5 hash. В этом месте разработчик позволил себе сделать паузу и выпить чашечку хорошего кофе, после чего запрограммировал наиболее простое решение.

Этот объект унаследован от CachedStorage, который сохраняет файлы в постоянную память и загружает из неё и реализует NetworkCache интерфейс.

Пример использования

Сначала необходимо сконфигурировать URLSession.

import Foundation  extension URLSession { static let `default`: URLSession = { let configuration = URLSessionConfiguration.default configuration.waitsForConnectivity = true configuration.timeoutIntervalForResource = configuration.timeoutIntervalForRequest return URLSession(configuration: configuration) }()  static let background: URLSession = { let configuration = URLSessionConfiguration.background(withIdentifier: "background-session") return URLSession(configuration: configuration) }() } 

Далее объявить несколько вспомогательных методов, которые удовлетворяют требованиям проекта.

extension DataLoader { convenience init(session: URLSession = .default) { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601  self.init(session: session, decoder: decoder) }  private func baseUrl() throws -> URL { guard let host = Bundle.main.object(forInfoDictionaryKey: "Host") as? String else { throw "Host variable is not secified" }  guard let url = URL(string: host) else { throw "Cannot create url" }  return url.appendingPathComponent("swagger") }  private var accessToken: String { let secureSettings = SecureSettings() return secureSettings.accessToken ?? "" }  @discardableResult public func dataRequest(path: String, method: HTTPMethod, headers: [HTTPHeader] = [], parameters: Request.Parameters? = nil) async throws -> Data { let url = try baseUrl().appendingPathComponent(path)  return try await dataRequest( url: url, method: method, headers: headers, parameters: parameters ) }  @discardableResult public func dataRequest<T: Decodable>(path: String,   method: HTTPMethod,   headers: [HTTPHeader] = [],   parameters: Request.Parameters? = nil) async throws -> T { let url = try baseUrl().appendingPathComponent(path)  return try await dataRequest( url: url, method: method, headers: headers, parameters: parameters ) } }

И, наконец, объявить REST методы.

extension DataLoader {  func login(userName: String, password: String) async throws -> LoginResponse { return try await dataRequest( path: "auth/login", method: .get, parameters: ["userName": username, "password": password] ) }  }

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

class TestViewController: UIViewController {   func loadPreviews(of news: [NewsViewModel]) async {     await withTaskGroup(of: Void.self) { group in       for news in news {         group.addTask { [weak self] in           await self?.loadImage(of: news)         }       }     }   }    private func loadImage(of news: NewsViewModel) async {     guard let url = news.underlyingModel.preview else {       return     }      if let image = await imageLoader.loadImage(at: url) {       news.preview = Image(uiImage: image)     }   }  }

Заключение

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

  1. URLSession

  2. URLRequest

  3. URLResponse

  4. Alamofire


ссылка на оригинал статьи https://habr.com/ru/post/676092/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *