普通视图

发现新文章,点击刷新页面。
昨天以前掘金专栏-庄周晓梦

使用Combine来实现一个网络请求

作者 庄周晓梦
2023年2月13日 13:36

通过上一篇《iOS响应式编程-Combine简介》的阅读,我们对Combine的大致应用有了一个基本的了解,接下来,我们看看Combine都能用在哪里。

首先是网络请求,因为网络请求有着天然的异步性,这一点完美的契合了Combine的特性-处理异步事件,所以,我们完全可以用Combine来写一个网络请求库,从而告别之前那种无穷无尽的回调方式。彻底告别callback,这是我们的学习目的。开始吧!!!

在编码之前,我们先列一下网络层的几个关键组成部分

  • Endpoint:一个简单的struct,里面包含path和queryItem属性。通过对其进行扩展,我们可以很容易的创建一个base URL,来定义具体的endpoint和header。
  • Codable模型:一个可以通过JSON进行编解码的模型
  • 网络控制器:负责直接对接URLSessionDataTask,并且解码一个Codable模型的的数据结构。例如,具备一个get()方法用以返回一个包含某一模型的publisher
  • 逻辑控制器:对来自View层的由网络控制器完成的业务进行抽象,并且提供简单的方法对模型进行序列化。例如,有一个getUser()方法,这个方法调用了一个网络控制器的必调方法,并且返回了一个users数组。

Codable 模型

接下来,我们将从API里获取用户列表,JSON格式如下:

{
    "data": [
        {
            "id": "0F8JIqi4zwvb77FGz6Wt",
            "title": "mr",
            "firstName": "Heinz-Georg",
            "lastName": "Fiedler",
            "email": "heinz-georg.fiedler@example.com",
            "picture": "https://randomuser.me/api/portraits/men/81.jpg"
        },
        {
            "id": "0P6E1d4nr0L1ntW8cjGU",
            "title": "miss",
            "firstName": "Katie",
            "lastName": "Hughes",
            "email": "katie.hughes@example.com",
            "picture": "https://randomuser.me/api/portraits/women/74.jpg"
        }
    ]
}

所以,我们创建一个如下的模型:

struct Users: Codable, CustomStringConvertible {
    let data: [User]?
}

struct User: Codable, CustomStringConvertible {
    let id: String?
    let title: String?
    let firstName: String?
    let lastName: String?
    let email: String?
    let picture: String?
}

需要注意的是这里我们同样遵循了CustomStringConvertible协议用以对我们的对象进行debug。我们用下面这个简单的扩展来减轻每个struct定义description属性的负担:

extension CustomStringConvertible where Self: Codable {
    var description: String {
        var description = "\n \(type(of: self)) \n"
        let selfMirror = Mirror(reflecting: self)
        for child in selfMirror.children {
            if let propertyName = child.label {
                description += "\(propertyName): \(child.value)\n"
            }
        }
        return description
    }
}

模型建立完成之后,开始其他部分的代码编写吧

EndPoint

定义一个Endpoint struct来匹配指定的API需求:

struct Endpoint {
    var path: String
    var queryItems: [URLQueryItem] = []
}

现在,我们需要创建一个扩展,在这个扩展里,我们对API的URL进行构建,而且也要定义包含APPID的headers属性(范例API特有的属性):

extension Endpoint {
    var url: URL {
        var components = URLComponents()
        components.scheme = "https"
        components.host = "dummyapi.io"
        components.path = "/data/api" + path
        components.queryItems = queryItems
        
        guard let url = components.url else {
            preconditionFailure("Invalid URL components: \(components)")
        }
        
        return url
    }
    
    var headers: [String: Any] {
        return [
            "app-id": "YOUR APP ID HERE"
        ]
    }
}

定义一些Endpoints

extension Endpoint {
    static var users: Self {
        return Endpoint(path: "/user")
    }
    
    static func users(count: Int) -> Self {
        return Endpoint(path: "/user",
                        queryItems: [
                            URLQueryItem(name: "limit",
                                         value: "\(count)")
            ]
        )
    }
    
    static func user(id: String) -> Self {
        return Endpoint(path: "/user/\(id)")
    }
}

我们只用第一个举例。后面两个将在后续用以展示便捷的添加参数和设置路径。

NetworkController

首先,定义一个协议:NetworkControllerProtocol

protocol NetworkControllerProtocol: class {
    typealias Headers = [String: Any]
    
    func get<T>(type: T.Type,
                url: URL,
                headers: Headers
    ) -> AnyPublisher<T, Error> where T: Decodable
}

可以看出,有一个方法来对单个Codable模型进行序列化

接下来,对这个协议进行具体实现

final class NetworkController: NetworkControllerProtocol {
    
    func get<T: Decodable>(type: T.Type,
                           url: URL,
                           headers: Headers
    ) -> AnyPublisher<T, Error> {
        
        var urlRequest = URLRequest(url: url)
        
        headers.forEach { (key, value) in
            if let value = value as? String {
                urlRequest.setValue(value, forHTTPHeaderField: key)
            }
        }
        
        return URLSession.shared.dataTaskPublisher(for: urlRequest)
            .map(\.data)
            .decode(type: T.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
    
}

对此总结以下四点:

  • 创建并实现了一个URLSessionDataTask
  • 如果没有错误发生,获取data
  • 对模型对象进行Decode操作
  • 返回一个要么包含一个模型对象,要么包含一个错误的publisher

LogicController

同样,也是实现一个协议:UsersLogicControllerProtocol

protocol UsersLogicControllerProtocol: class {
    var networkController: NetworkControllerProtocol { get }

    func getUsers() -> AnyPublisher<Users, Error>
    func getUsers(count: Int) -> AnyPublisher<Users, Error>
    func getUser(id: String) -> AnyPublisher<User, Error>
}

在这里,我们对NetworkController产生了依赖,并定义了三个方法:

  • getUsers():默认对20个User进行序列化
  • getUser(count: Int):指定一个具体数量的Users进行序列化
  • getUser(id: String):对指定id的User进行序列化
final class UsersLogicController: UsersLogicControllerProtocol {
    
    let networkController: NetworkControllerProtocol
    
    init(networkController: NetworkControllerProtocol) {
        self.networkController = networkController
    }
    
    func getUsers() -> AnyPublisher<Users, Error> {
        let endpoint = Endpoint.users
        
        return networkController.get(type: Users.self,
                                     url: endpoint.url,
                                     headers: endpoint.headers)
    }
    
    func getUsers(count: Int) -> AnyPublisher<Users, Error> {
        let endpoint = Endpoint.users(count: count)
        
        return networkController.get(type: Users.self,
                                     url: endpoint.url,
                                     headers: endpoint.headers)
    }
    
    func getUser(id: String) -> AnyPublisher<User, Error> {
        let endpoint = Endpoint.user(id: id)
        
        return networkController.get(type: User.self,
                                     url: endpoint.url,
                                     headers: endpoint.headers)
    }
    
}

可以看到,我们的业务直接对接NetworkControlelr的方法。通过标识符final,用来表明,该类不可继承。 最后,来看看其他部分

如何使用

初始化一个NetworkController和UserLogicController

let networkController = NetworkController()

let usersLogicController = UsersLogicController(
  networkController: networkController
)

创建一个suscriptions属性来存储以后的订阅:

let networkController = NetworkController()

let usersLogicController = UsersLogicController(
  networkController: networkController
)

var subscriptions = Set<AnyCancellable>()

现在我们可以通过下面的方式来获取users

usersLogicController.getUsers()
    .sink(receiveCompletion: { (completion) in
        switch completion {
        case let .failure(error):
            print("Couldn't get users: \(error)")
        case .finished: break
        }
    }) { users in
        print(users)
    }
    .store(in: &subscriptions)

结果如下:

image.png

iOS响应式编程Combine——简介

作者 庄周晓梦
2023年2月10日 23:04

Combine

通过绑定事件处理(event-progressing)操作符来自定义处理异步事件

总览

Combine框架提供了一种声明式的Swift API, 用来随时处理各种值。这些值可以被当做各种各样的异步事件。Combine声明了发布者来公开那些随时可能变化的值。并且订阅者接收这些来自发布者的值。

  • Publisher 协议声明了一种可以随时传递一系列值的类型。发布者有operaters(操作符)根据从上游发布者收到的值采取行动并重新发布它们。
  • 在发布者的链条终端,会有一个 Subscriber 根据它所接收到的元素来做出反应。发布者只在订阅者明确要求时才发布值。这使你的订阅者代码可以控制从与其连接的发布者那里接收事件的速度。

多种Foundation类型都是通过发布者来公开他们的功能,包括 TimerNotificationCenterURLSessionCombine 同时也为符合KVO(键值观察)的所有属性提供了内置发布者。

你可以绑定多个发布者的输出,并使其交互。例如,你可以订阅一个输入框(textField)的发布者来进行更新操作,用其文本值来执行一段网络请求。你后续也可以用其他发布者来执行请求的响应来更新APP。

采用了 Combine 之后,通过集中事件处理和排查问题的技术像嵌套闭包以及基于会话的回调来使你的代码将会变得更加易读和维护。

引子

接下来我们来看一个关于《利用Combine来接收和处理事件》的讨论。

概述

Combine 提供了一种声明式的途径来让你的APP处理各种事件。跟以前执行多个代理回调或者completionHandler闭包相比,亦可以为一个给定事件源创建一个执行链。链的每一部分都是由处理从上一步接收到的指定行为和元素的操作符组成的。

想象一下有一个App需要根据一个textField的text来适配一个tableView和collectionView,在APPKit中,每在textfield中键入一个字符,都会产生一个Notification,你可以用Combine来对这个Notification进行订阅。在收到通知后,你可以用操作符来改变事件传递的内容和计时,也可以用最后的结果来更新APP的用户界面。

连接Publisher和Subscriber

为了能用Combine来接收textfield的通知,访问NotificationCenter实例并调用他的publisher(for: object:)方法。这个调用方法携带通知名和资源对象,并且会返回一个产生通知元素的发布者。

let pub = NotificationCenter.default.publisher(for: NSControl.textDidChangeNotification, object: filterField)

Subscriber来接收来自发布者的元素。订阅者定义了一个关联类型,Input, 来声明他所接收的类型。订阅者同样也会声明一个类型,Output,来声明他产生的元素的类型。发布者和订阅者都会定义一种类型,Failure,来代表他们产生或者接收的错误。想要将订阅者对接到发布者,Output必须和Input必须相匹配,同样Failure类型也要相匹配.

Combine提供了两种内嵌的订阅者,这些订阅者会自动将属于他们的发布者的output和failure。

  • sink(receiveCompletion:receiveValue)携带两个闭包。第一个闭包在接收到Subcribers.Completion时执行,这个回调是一个代表着publisher正常结束或者发生错误。第二个闭包在他接收到来自publisher的元素时执行。
  • assign(to:on:)会立即分发每个从一个既定对象的属性收到的元素,通过key-path来标示这个属性。

举个例子,你可以用sink订阅者来在发布者完成时/每次收到元素时进行打印:

let sub = NotificationCenter.default
.publisher(for: NSControl.textDidChangeNotification, object: filterField)
.sink(receiveCompletion: { print ($0) }, receiveValue: { print ($0) })

sink(receiveCompletion:receiveValue)assign(to:on:)两者都从他们的publisher那里请求了大量的元素,这些元素的数量是不受限的。想要控制接收元素的频率,可以通过实现Subscriber协议来创建你自己的订阅者。

通过Operators来改变Output类型

前述的sink订阅者都是在receive闭包中执行他的业务。如果需要通过接收到的元素或者需要维持两者的调用来执行大量自定义业务,这一点就很烦人了。Combine的先进之处就在于他提供了大量的操作符来处理自定义事件传递。

比如, NotificationCenter.Publisher.Output,在接收比如来自textfield的字符串时,它并不是一个非常方便的类型。因为publisher的output本质上来讲是一个随时的元素序列, Combine提供了序列修改操作符,比如 map(_:), flatMap(maxPublishers:_:), 和reduce(_:_:)。这些操作符与他们在Swift标准库中的对应体相似。

你可以添加一个map(_:)操作符,这个操作符返回一个不同类型,来改变这个发布者的output。在这个案例中,你可以获取一个NSTextField类型的通知对象,然后获取这个textField的stringValue

let sub = NotificationCenter.default .publisher(for: NSControl.textDidChangeNotification, object: filterField) .map( { ($0.object as! NSTextField).stringValue } ) .sink(receiveCompletion: { print ($0) }, receiveValue: { print ($0) })

在发布者链产生了你想要的类型之后,用assign(to:on:)取代sink(receiveCompletion:receiveValue:)。下面的例子懈怠了一个从发布者链接收到的字符串,并且将他们分发给一个自定义ViewModel对象的filterString:

let sub = NotificationCenter.default .publisher(for: NSControl.textDidChangeNotification, object: filterField) .map( { ($0.object as! NSTextField).stringValue } ) .assign(to: \MyViewModel.filterString, on: myViewModel)

自定义发布者和操作符

您可以使用一个运算符来扩展Publisher实例,该运算符执行否则需要手动编码的操作。以下是可以使用运算符来改进此事件处理链的三种方法:

  • 您可以使用filter(_:)运算符来忽略特定长度的输入或拒绝非字母数字字符,而不是使用键入到文本字段中的任何字符串来更新视图模型。

  • 如果过滤操作很耗时(例如,如果它查询一个大型数据库),您可能需要等待用户停止键入。为此,debouce(for:scheduler:options:)运算符允许您设置发布者发出事件之前必须经过的最短时间段。RunLoop类提供了以秒或毫秒为单位指定时间延迟的便利。

  • 如果结果更新了UI,则可以通过调用receive(on:options:)方法将回调传递给主线程。通过将RunLoop类提供的Scheduler实例指定为第一个参数,可以告诉Combine在主运行循环上调用订阅者。

声明如下:

let sub = NotificationCenter.default .publisher(for: NSControl.textDidChangeNotification, object: filterField) .map( { ($0.object as! NSTextField).stringValue } ) .filter( { $0.unicodeScalars.allSatisfy({CharacterSet.alphanumerics.contains($0)}) } ) .debounce(for: .milliseconds(500), scheduler: RunLoop.main) .receive(on: RunLoop.main) .assign(to:\MyViewModel.filterString, on: myViewModel)

在需要时取消发布

发布者持续发出元素,直到正常结束或失败。如果你不想再订阅发布者,一你一取消订阅。订阅者类型可以通过sink(receiveCompletion:receiveValue:) assign(to:on:) 来执行Cancellable协议,这个协议提供了取消方法:

sub?.cancel()

如果你创建了一个自定义的Subscriber,发布者会在你第一次订阅他的时候发送一个 Subscription对象。存储这个订阅,并在要结束发布的时候调用cancel()方法。当你创建了一个自定义的订阅者,你应该执行Cancellable协议,并且让cancel()方法来实现存储的订阅。

TCP/UDP介绍及区别

作者 庄周晓梦
2023年2月7日 15:50

传输控制协议(TCP)驱动着可靠数据的传输。相较之下,用户数据包协议(UDP)优先于速度和效率,这一点对网络操作至关重要。

TCP和UDP协议是互联网的功能支柱,能将不同类型的的数据从一个网络资源传输给目标。TCP更可靠一点,同时,UDP优先于速度和效率。这篇文章将为您解释这两种协议的工作原理及两者之间的10大区别。

什么是TCP

TCP是一种面向连接的通讯协议,此协议允许计算设备和应用通过网络发送数据并核对其传递,是国际互联网重要支柱的一种形式。

image.png

TCP 依附于三次握手(同步/同步确认/最终确认)

通讯编程和计算设备利用TCP协议来交换网络之间的消息。这个协议的任务是携带一个穿梭于互联网之间的包,并保证互联网数据之间的消息/数据的成功传递。

在可以发送数据之前,客户端和服务端必须建立一个连接。一旦连接建立,服务端必须实时地动态监听客户端的请求。TCP协议是基于连接的,所以当数据在接收方和发送方之间传递的时候,他创建并维持两者之间的连接。因此,任何穿梭于网络的的信息都能保证准确无误的到达。

因为这一点,TCP是网络协议中最为广泛使用的一种协议。

下列几点是TCP最关键的几个特性:

  • 客户端确认来自服务端的数据
  • 超时后,服务端尝试重新传递没有被传递的数据
  • 在拥挤的互联网中,TCP延迟数据传递
  • TCP使用三次握手来查验数据传输错误

但是,TCP是一种本能可靠的协议,这些反馈机制也会导致更大的开销。这就意味着他会消耗大量的宽带来满足你的系统。大多数线上应用都使用的是UDP协议结合TCP来解决这个问题。

什么是UDP

UDP是一种面向消息的传输协议,这个协议允许计算设备和应用通过网络发送数据,但是并不需要对其传递进行核对,这完美契合于实时传输及广播系统

image.png

UDP能够连续不断地进行数据传输(即响应),而且并不需要确认连接。

跟TCP一样,UDP的目的也是收发消息,所以UDP的功能与TCP类似。UDP的特别之处在于:它不是基于连接的。在这种情况下,“无连接”指的是在通讯发生之前没有连接建立。

另外,它并不能保证来自服务端的数据包,这一点通常意义上可以被理解为“阅后即焚”协议。因为他并不关心客户端是否收到了数据。

大多数情况下,UDP要比TCP快,因为它不需要确认包的传递,但是TCP会确认。

UDP协议不适用于发送电子邮件/查看网页或者下载文件。但是它主要更倾向于事实应用,例如广播或者多任务网络传输。UDP的关键特征如下:

  • 它适用于容忍数据包丢失的带宽密集型应用程序。
  • 更少的数据传输延迟
  • 一次可以发送大量的包
  • 有可能会丢失数据

两者的10大区别

TCP在以下几点上异于UDP

image.pngTCP和UDP在组织上的关键区别

1. TCP是面向连接的,但是UDP是无连接的

因为TCP是面向连接的协议,他依赖于处于被动开放状态的服务器。一个被动开放的服务器监听每一个尝试连接他的客户端。客户端必须先和服务器连接,然后才能发送和接收数据。连接通过三次握手建立。客户端发送一个同步请求,服务端回发一个确认,然后客户端返回一个同步确认作为响应。 相较而言,UDP是一种无连接协议。这种数据传输需要一个网络终端来发送一个IT信号,并且不需要核验接收者是否可用或者能够收到这个信号。消息一旦发出,就不需要过多考虑接受者,也不需要考虑目的地。无连接传输协议可能会少量丢包。但是,这对于接收客户端来说并不总是那么明显,例如,在视频通话期间。

2. TCP 利用比 UDP 更多的错误检查机制

TCP使用三种不同机制来核验错误,并保证数据在传输时的完整性。这就使得它高度可靠,TCP通过以下方式进行错误核验:

  • 超时后阻止连接: 连接有一个指定的超时时间。如果服务端或者客户端没有在这个时间段内收到确认消息,连接将被关闭,并且必须在你可以传输数据之前重新建立链接。
  • 包含一个头部检查和区域: 数据包头部包含一个16比特的值,也就是我们所说的检查和区域。每个数据节点,TCP都会包含一个检查和区域,用来在传输过程中评估完整性
  • 发送和接收确认: 当一个连接建立了以后,或者数据发送之后,服务器就会散射出一个确认或者一个ACK消息。客户端收到这个确认并且通过添加一个ACK消息值来回发他的消息。

这三点保证了正确的数据流在通过TCP传递的时候的不会出现任何丢失和出错。差异点在于,UDP只基于通过使用校验和校验错误来执行。

  1. TCP在特定序列中发送数据,而 UDP 协议没有固定的顺序:

为了确定需要将数据段交给哪个应用程序进程,TCP 使用端口号。 此外,它通过使用序列号与远程主机同步。 发送和接收的每个数据段都带有序列号。 这允许系统跟踪数据传输的特定顺序,从而保持所需的顺序。

UDP 不遵循排序机制。 数据包以无固定顺序独立发送,并在接收方应用程序中缝合在一起。 请记住,它们将按照接收顺序重新拼接在一起——也就是说,协议无法判断哪些数据包应该先到,以及它们是否以错误的顺序接收。 应用程序将错误地接收数据包。 UDP 还会丢弃任何它无法处理的数据包。

  1. UDP要比TCP更快更高效

尽管 UDP 存在内在缺陷,但 UDP 如此受欢迎的关键原因之一是它的速度和效率。 用户数据报协议不需要建立连接就可以开始发送数据包。 因此,它节省了打开服务器并将其置于“被动打开”侦听状态通常所需的时间。 它允许数据传输更快地开始,而不会延迟或延长延迟时间。 也不需要按顺序放置数据包或发送和接收确认,从而节省了时间。

除了延迟之外,UDP 在带宽方面也更高效。 一旦数据从服务器传输到客户端,TCP 就会参与许多错误检查机制、确认过程和排序措施,这些都会占用大量带宽。 相比之下,UDP 可以快速地将数据流从一个计算位置获取到另一个计算位置,而无需进行大量检查和平衡。 这使得它适用于低性能网络、移动设备和其他可能无法轻易获得资源的连接条件。

传输控制协议比 UDP 更慢并且更耗费资源。 如果数据序列被破坏,TCP 将重新启动连接,要求服务器发送和接收确认,建立三向握手等。UDP 简单地丢弃丢失或损坏的数据包,然后继续下一个 一,使其效率显着提高。

  1. 与 UDP 不同的是,TCP 不能用于多播或广播服务

TCP 是真正的端到端连接。 这意味着在一个通信端点和另一个端点之间建立连接,并采取准确的记录保存措施来跟踪发送的数据包和字节。 同步和确认消息将前一条消息的值加 1,使它们易于跟踪和追踪。 数据包标头还包含排序段,以保持数据流有序。 这些使其成为点对点传输系统的理想选择,而不是将数据广播到多个端点的场景。

在多播或广播场景中,服务器以多个接收者为目标。 它在不等待确认或任何交付确认的情况下中继数据,这正是 UDP 的工作方式。 UDP 的核心架构使其非常适合向整组端点(或子网)广播数据包,无论它们处于“被动打开”还是“侦听”状态。 在这种情况下,数据传输不会将特定的网络主机指定为目的地,而是将一组主机作为目标。

  1. TCP利用流量控制,但是UDP却没有

流量控制是一种机制,服务器通过这种机制首先检查接收方的容量,以了解它可以接受多少数据以及以什么速度接受。 传输控制协议通过滑动窗口的方式实现流量控制。 接收方允许发送方发送数据,直到滑动窗口中的窗口已满。 一旦发生这种情况,发送方必须等到接收方澄清有更大的窗口可用。

TCP 利用流量控制信息来校准数据传输的速度。 根据接收方主机,传输控制协议可以调整数据包传输的速度,避免接收方不堪重负。 然而,这也意味着服务器将在发送每个数据包之前等待流量控制信息,从而使其速度变慢且效率降低。

UDP 不使用任何流量控制技术。 它以最适合原始服务器的速度发送数据,因此,强大的服务器可能会用多个连续的数据流轰炸接收设备。 组织可以部署路由器来干预 UDP 数据流,并通过流量监管策略校准数据包发送的速度。 当UDP发送数据速度过快,接收方不堪重负时,它会直接丢弃接收方无法接受的数据包。

  1. UDP 不控制拥塞,而 TCP 实现拥塞避免算法

在流量控制中,TCP 根据接收方的接受窗口大小调整数据传输。 在这里,TCP 考虑了网络基础设施的容量。 除了接收者之外,网络还决定了数据传输的快慢程度。 因此,将传输速度校准到网络可接受的水平至关重要。 TCP 通过拥塞避免算法和策略实现这一点。

加性增加/乘性减少 (AIMD) 是使用的关键算法之一。 它结合了拥塞窗口的线性增长和指数减少来防止网络流量积累一个高度复杂但有效的过程。 TCP会等待拥塞的网络通道畅通后再恢复传输,保证数据包不丢失。

事实上,拥塞控制是 TCP 消耗如此多计算资源的主要原因。 传输控制协议可能使用 10 种以上的拥塞避免机制,具体取决于网络配置。 这可以包括 TCP Tahoe(在发生数据丢失时重新建立慢启动连接)、TCP Reno(为拥塞后恢复启动快速重传)和其他几个。

相比之下,UDP 没有办法控制网络拥塞。 如果路径上的流量过多,UDP 将丢弃排队等候的数据包并发送剩余的数据包。 组织可以使用专门配置的路由器来保存丢失的数据包,但这种功能并不是用户数据报协议所固有的。

  1. TCP要比UDP更可靠

两种协议各有利弊,TCP最大的优势在于可靠性高。 这可以归因于:

  • 传输控制协议是基于连接的。 它只会将数据发送给正在侦听它的客户端。
  • 它使用三向握手系统在数据传输一致的同时保持连接。 如果连接中断,传输也会停止,不会有数据包丢失。
  • TCP 使用排序机制以正确的顺序发送数据。 这意味着通过此协议发送的图像、网页、数据文件和其他信息类型将以未损坏的状态到达。
  • TCP 提供数据将被传送的保证。 它对收到的每个数据包都获得一个确认,只有在客户端发送 ACK 消息后才发送下一个数据包。
  • TCP 使用流量和拥塞控制机制来确保数据不会丢失、损坏、复制或乱序传送。

相反,用户数据报协议本身并不可靠。 其架构旨在连续向一个或多个接收客户端发送数据包,而无需等待“侦听”状态或确认。 在具有挑战性的网络条件下,TCP 和 UDP 可能会导致数据包丢失。 不同的是,TCP 会识别丢失并识别丢失的数据包以重新传输信息。 UDP 无法判断数据包是否在传输过程中丢失,丢失了哪些数据包,或者如何重新发送它们。 这使得 UDP 不太可靠,尽管它更有效。

使用UDP协议的应用程序必须单独配置可靠性机制。 例如,可以单独配置数据传输的超时时间,如果在规定的时间内没有收到接收方的信号,则主动切断UDP协议。

  1. TCP标头与UDP标头不同

任何通信协议都允许以字节串形式交换信息。 这些“位串”包含多个字段,每个字段包含一些与特定协议相关的信息。 位串有两部分:标头和有效载荷。 有效负载包含消息的主体,而标头用于标识和支持通信协议的操作。 TCP 和 UDP 数据传输利用两种不同的标头。

首先,TCP 使用可变长度的标头来支持更复杂的数据传输,而不会影响可靠性。 标头可以有 20 到 60 个字节之间的任何位置。 相比之下,UDP 有一个固定长度的头部,速度快,效率高,但通用性差。 UDP 标头只能有八个字节。

TCP 和 UDP 标头(即它们的字段)也不同。 TCP 标头包含序列号、校验和、ACK 号、控制位、滑动窗口信息、源端口、目标端口等的指定字段。 相比之下,UDP 标头更短更简单,因为它们仅包含校验和、源端口、目标端口和一些其他元素的字段。

  1. UDP适用于现场实时数据传输,TCP不支持

尽管其本质上不可靠,但 UDP 仍然是在线操作的主要方式。 这是因为它非常适合实时数据传输,丢失几个数据包无关紧要。

例如,在网络游戏中,丢失的数据包只会跳过几帧,并可能导致玩家失去几分。 用户数据报协议会继续发送后续的数据包,用户可以继续播放。 但是,如果丢失了单个数据包,TCP 会注意到这一点。 它将重新启动连接并重新传输数据,这将冻结游戏。 在这种情况下,传输控制协议会对用户体验产生负面影响。

TCP 最适合数据完整性比传输速度更重要的用例。 它将确保文件和网页完好无损地到达,甚至有助于实时分析和内容交付网络,在这些网络中,丢失的数据包会篡改结果。 相比之下,UDP更适合媒体传输,比如:

  • 视频通话:UDP 可以支持每秒 30 帧或更高刷新率的视频。 数据传输速度非常快,丢几个包也不会影响用户体验。
  • 在线游戏:TCP 的许多清单和平衡将显著影响游戏体验。 没有完美的网络条件,帧会经常冻结,如果使用 TCP,连接会重新启动。 这就是推荐 UDP 的原因。

附加

传输控制协议和用户数据报协议以它们自己的方式提供帮助。 前者可靠,后者高效。 大多数网络和连接解决方案利用 TCP 和 UDP 来提供功能齐全的互联网体验。 通过了解 TCP 和 UDP 之间的主要区别,组织可以根据手头的用例正确配置网络并为最佳连接铺平道路。

Swift中的HTTP(十八) 总结

作者 庄周晓梦
2023年2月6日 20:45

HTTP简介

HTTP基础结构

HTTP请求体

HTTP 加载请求

HTTP 模拟测试

HTTP 链式加载器

HTTP 动态修改请求

HTTP 请求选项

HTTP 重置

HTTP 取消

HTTP 限流

HTTP 重试

HTTP 基础鉴权

HTTP 自动鉴权设置

HTTP 自动鉴权

HTTP 复合加载器

HTTP 头脑风暴

HTTP 总结

在这个系列的过程中,我们从一个简单的想法开始,并将它带到了一些非常迷人的地方。 我们开始的想法是可以将网络层抽象为“我发送此请求,最终我得到响应”的想法。

在阅读 Rob Napier 关于协议协议的博客文章后,我开始研究这种方法。 在其中,他指出我们似乎误解了 Dave Abrahams Crusty 在 WWDC 2015 上提出的开创性的“面向协议编程”的想法。在谈到网络时,我们尤其忽略了这一点,Rob 的后续帖子进一步探讨了这个想法 .

我希望您在本系列博文中已经意识到的一件事是,在本系列中我从未谈论过 Codable。 本系列中没有任何内容是通用的(除了使指定请求主体变得容易的小例外)。 没有提到反序列化或 JSON 或解码响应或任何东西。 这是非常刻意的。

HTTP 的要点很简单:您发送一个 HTTP 请求(我们看到它具有非常明确的结构),您会返回一个 HTTP 响应(它具有类似的明确定义的结构)。 没有机会介绍泛型,因为我们不是在处理通用算法。

所以这引出了一个问题:泛型从何而来? 我如何在这个框架中使用我很棒的 Codable 类型? 答案是:下一层抽象。

Hello, Codable!

我们的 HTTP 堆栈处理具体的输入类型 (HTTPRequest) 和具体的输出类型 (HTTPResponse)。 没有地方放通用的东西。 我们在某些时候需要泛型,因为我们想使用我们漂亮的 Codable 结构,但它们不属于 HTTP 通信层。

因此,我们将 HTTPLoader 链包裹在一个可以处理泛型的新层中。 我称之为“连接”层,它看起来像这样:

public class Connection {

    private let loader: HTTPLoader

    public init() {
        self.loader = ...
    }

    public func request(_ request: ..., completion: ...) {
        // TODO: create an HTTPRequest
        // TODO: interpret the HTTPResponse
    }

}

为了以通用方式解释响应,这就是我们需要泛型的地方,因为这是我们需要使其适用于许多不同类型的算法。 因此,我们将定义一个通常包装 HTTPRequest 并可以解释 HTTPResponse 的类型:

public struct Request<Response> {
    public let underlyingRequest: HTTPRequest
    public let decode: (HTTPResponse) throws -> Response

    public init(underlyingRequest: HTTPRequest, decode: @escaping (HTTPResponse) throws -> Response) {
        self.underlyingRequest = underlyingRequest
        self.decode = decode
    }
}

当我们知道 Response 是 Decodable 时,我们还可以提供一些方便的方法:

extension Request where Response: Decodable {

    // request a value that's decoded using a JSON decoder
    public init(underlyingRequest: HTTPRequest) {
        self.init(underlyingRequest: underlyingRequest, decoder: JSONDecoder())
    }
    
    // request a value that's decoded using the specified decoder
    // requires: import Combine
    public init<D: TopLevelDecoder>(underlyingRequest: HTTPRequest, decoder: D) where D.Input == Data {
        self.init(underlyingRequest: underlyingRequest,
                  decode: { try decoder.decode(Response.self, from: $0.body) })
    }

}

有了这个,我们就有了一种方法来封装“发送这个 HTTPRequest 应该产生一个我可以使用这个闭包解码的值”的想法。 我们现在可以实现我们之前删除的请求方法:

public class Connection { 
    ...

    public func request<ResponseType>(_ request: Request<ResponseType>, completion: @escaping (Result<ResponseType, Error>) -> Void) {
        let task = HTTPTask(request: request.underlyingRequest, completion: { result in
            switch result {
                case .success(let response):

                    do {
                        let response = try request.decode(httpResponse: response)
                        completion(.success(response))
                    } catch {
                        // something when wrong while deserializing
                        completion(.failure(error))
                    }

                case .failure(let error):
                    // something went wrong during transmission (couldn't connect, dropped connection, etc)
                    completion(.failure(error))
            }
        })
        loader.load(task)
    }
}

使用条件化扩展,我们可以简化 Request 构造:

extension Request where Response == Person {
    static func person(_ id: Int) -> Request<Response> {
        return Request(personID: id)
    }

    init(personID: Int) {
        let request = HTTPRequest(path: "/api/person/(personID)/")

        // because Person: Decodable, this will use the initializer that automatically provides a JSONDecoder to interpret the response
        self.init(underlyingRequest: request)
    }
}

// usage:
// automatically infers `Request<Person>` based on the initializer/static method
connection.request(Request(personID: 1)) { ... }

// or:
connection.request(.person(1)) { ... }

这里有一些重要的事情在起作用:

  • 请记住,即使是 404 Not Found 响应也是成功的响应。 这是我们从服务器返回的响应! 解释该响应是客户端问题。 所以默认情况下,我们可以盲目地尝试反序列化任何响应,因为每个 HTTPResponse 都是“成功”的响应。 这意味着处理 404 Not Found 或 304 Not Modified 响应取决于客户端。
  • 通过使每个请求解码响应,我们提供了个性化/特定于请求的反序列化逻辑的机会。 如果解码失败,一个请求可能会查找 JSON 响应中编码的错误,而另一个请求可能只是满足于抛出 DecodingError。
  • 由于每个 Request 使用闭包进行解码,我们可以在闭包中捕获特定于域和上下文的值,以帮助特定请求的解码过程!
  • 我们不仅限于 JSON 反序列化。 一些请求可能反序列化为 JSON; 其他人可能会使用 XMLDecoder 或自定义的东西反序列化。 每个请求都有机会根据自己的意愿解码响应。
  • 对 Request 的条件扩展意味着我们有一个漂亮且富有表现力的 API connection.request(.person(42)) { ... }

Hello, Combine!

此连接层还可以轻松地与 Combine 集成。 我们可以在 Connection 上提供一个方法来公开发送请求并返回一个符合 Publisher 的类型,以在发布者链中使用或作为 ObservableObject 的一部分,甚至在 SwiftUI 中使用 .onReceive() 修饰符:

import Combine

extension Connection {

    // Future<...> is a Combine-provided type that conforms to the Publisher protocol
    public func publisher<ResponseType>(for request: Request<ResponseType>) -> Future<ResponseType, Error> {
        return Future { promise in
            self.request(request, completion: promise)
        }
    }

    // This provides a "materialized" publisher, needed by SwiftUI's View.onReceive(...) modifier
    public func publisher<ResponseType>(for request: Request<ResponseType>) -> Future<Result<ResponseType, Error>, Never> {
        return Future { promise in
            self.request(request, completion: { promise(.success($0)) }
        }
    }

}

结论

我们终于走到了尽头! 我希望您喜欢这个系列,并希望它能为您打开思路,迎接新的可能性。 我希望你能从中学到一些东西:

  • HTTP 并不可怕、复杂。 从本质上讲,它真的非常简单。 它是一种用于发送请求的简单的基于文本的格式,也是一种用于获取响应的简单格式。 我们可以轻松地在 Swift 中对其进行建模。
  • 将 HTTP 抽象为高级“请求/响应”模型允许我们做一些非常酷的事情,如果我们在 HTTP 森林中查看所有特定于 URLSession 的树时,这些事情将很难实现。
  • 我们可以有蛋糕也可以吃吃蛋糕! 无论您使用的是 UIKit/AppKit 还是 SwiftUI 或其他任何东西,这种网络模型都能很好地工作。
  • 通过认识到我们不需要泛型或协议,我们避免了代码的过度复杂化。 加载程序链的每个部分都是离散的、可组合的,并且易于单独测试。 在使用它时,我们永远不必处理那些可怕的“关联类型或自身”错误。
  • 无论您使用何种编程语言和平台,这种方法的原理都适用。 本系列的主题是“如何思考问题”。 谢谢阅读!

Swift中的HTTP(十七) 头脑风暴

作者 庄周晓梦
2023年2月6日 20:40

HTTP简介

HTTP基础结构

HTTP请求体

HTTP 加载请求

HTTP 模拟测试

HTTP 链式加载器

HTTP 动态修改请求

HTTP 请求选项

HTTP 重置

HTTP 取消

HTTP 限流

HTTP 重试

HTTP 基础鉴权

HTTP 自动鉴权设置

HTTP 自动鉴权

HTTP 复合加载器

HTTP 头脑风暴

HTTP 总结

我原计划在不同的加载器上发布更多的帖子,你可以使用这个架构构建,但是为了“完成”这个系列,我决定放弃一个帖子每个加载器,而是强调要点 其中一些。

OpenID

我们已经了解了如何实现通过 OAuth 2 流授权请求的加载程序,但在其之上存在一个抽象,称为 OpenID。 使用 OAuth 加载程序,我们需要指定登录 url、刷新令牌的 url 等内容。 OpenID 允许身份提供者通过发送包含所有这些 url(以及协议的其他特性)的清单来抽象化这一点。

如果我们想自己实现 OpenID,我们需要在我们的状态机中有一个初步状态来首先获取这个清单,然后将其用作后续状态逻辑的基础。 或者,我们可以将 OpenID 的现有实现(例如官方实现)包装在自定义 HTTPLoader 子类中,并允许该库执行复杂的逻辑。 在这种情况下,我们的 HTTPLoader 子类将充当库提供的 API 与 HTTP 加载程序链所需的 API 之间的适配器。

缓存

从概念上讲,缓存加载器应该相对简单易懂。 当一个请求进入这个加载器时,它会检查请求(可能还有一个 HTTPRequestOption 来指示是否允许缓存)并查看它是否与任何已保存的响应(在内存中、磁盘上等)相匹配。 如果存在这样的响应,则加载程序返回该响应,而不是将请求进一步发送到链中。

如果响应不存在,它会继续执行典型的请求,但也会插入另一个完成处理程序,以便它可以捕获响应,并且(如果满足正确的条件)将其持久化以用于未来的请求。

重复备份

重复备份类似于缓存,因为当一个请求进来时,加载器会查看它是否与一个已经在进行中的请求相似。 如果是,则新请求被搁置,当原始请求得到响应时,该响应被复制到第二个请求。

重定向

有几种方法可以处理重定向请求。

默认情况下,URLSession 将遵循重定向,除非您特别覆盖 URLSessionTaskDelegate 上的 willPerformHTTPRedirection 委托方法。 因此,您可以这样做,然后有条件地允许根据您创建的特定 HTTPRequestOption 对请求进行重定向。

或者,您可以无条件地拒绝 URLSession 级别的重定向,然后有一个单独的 RedirectionFollowingLoader 接收传入请求,复制它们,并将重复项发送到链中。 当副本返回时,加载器检查响应并查看它是否是重定向响应。 如果是,那么它会为重定向构造一个新请求,并将其发回。

一旦加载程序返回非重定向响应,它就会使用该响应作为对原始请求的响应并将其发回。 您将需要一些逻辑来检测重定向循环并打破它们,但这里的关键思想是发送请求的副本,以便您有机会在决定如何处理之前检查响应。

证书固定

原则上,证书固定应该看起来像任何其他 HTTPLoader:一个请求进来,在它被发送到下一个请求之前,目标服务器的证书根据作为 HTTPRequestOption 附加到请求的证书进行验证。

实际上,这有点困难,因为证书仅在 URLSessionLoader 中协商到远程服务器的连接时可用。 因此,此处的操作过程是不使用单独的 CertificatePinningLoader,而是向 HTTPRequest 提供 CertificateValidator 值,如果加载程序需要执行某些证书验证(类似于 Alamofire 的 ServerTrustEvaluating 协议),则可以使用该值。

然后我们的 URLSessionLoader 需要更新为使用委托,并实现委托方法来处理 URLAuthenticationChallenge,然后在收到 .serverTrust 质询时为请求查询该选项。

点对点

点对点加载器很有趣,因为它源于这样一种认识,即 HTTPLoader 的合同没有说明响应来自哪个设备。 我们已经看到了加载程序的示例,这些加载程序将返回虚假响应(用于模拟)或重用响应(缓存和重复数据删除)。 P2P 加载器可以决定将请求发送到另一台设备,并允许该设备提供响应。

这可以通过多种技术来实现,从 MultipeerConnectivity 到蓝牙或直接套接字连接。 这里的可能性非常大。

敏锐的观察者还会意识到我们早先创建的 URLSessionLoader 属于此类。 这是一个加载程序,可以将生成请求的责任“卸载”到另一个设备。 它恰好是一个同时也是 HTTP 服务器的设备,但我们的加载堆栈并不需要直接知道这一点。

流式响应

该框架不能很好地工作的一个领域是流式响应。 这很明显:我们已经围绕这样的期望构建了一切,即离散且有限的请求具有离散且有限的响应。 流媒体打破了这种期望。 我们可以在上传中做一个流式主体,因为发送该流的过程是发送我们单个离散请求的一部分。

我们可以处理一些类型的流媒体主体,例如文件下载。 对于这些,我们想提供一个 OutputStream(或类似的)来表示“把你得到的任何字节放回这里”; 默认情况下,这可能是内存中数据值的流。 这将允许我们将响应直接流式传输到文件,而不是通过内存中的数据值。

对于实时视频流,我们可以提供一个 OutputStream 将数据通过管道传输到 AVSession 中。 但是,为了使这项工作有效,我们将明确放弃“单一请求,单一响应”的一些语义。 我们还需要非常小心我们如何实现请求复制(例如重定向加载程序需要)。

结论

我们可以用这个框架做很多事情。 有些事情有些复杂,需要解决/使用系统 API 的特定实现细节(例如证书固定、流式响应等)。 总的来说,这种将网络建模为“发送请求并最终获得响应”的方法使我们能够构建极其灵活、可组合和可定制的网络堆栈。

在下一篇(也可能是最后一篇)博文中,我们将缩小范围以查看我们创建的框架的高级概述,并了解它如何适应其他 Swift 技术。

Swift中的HTTP(十六)复合加载器

作者 庄周晓梦
2023年2月6日 20:35

HTTP简介

HTTP基础结构

HTTP请求体

HTTP 加载请求

HTTP 模拟测试

HTTP 链式加载器

HTTP 动态修改请求

HTTP 请求选项

HTTP 重置

HTTP 取消

HTTP 限流

HTTP 重试

HTTP 基础鉴权

HTTP 自动鉴权设置

HTTP 自动鉴权

HTTP 复合加载器

HTTP 头脑风暴

HTTP 总结

到目前为止,我们已经构建了两个不同的加载器来处理身份验证,并且可以想象我们想要构建更多来支持其他加载器。 如果我们可以将所有“身份验证”逻辑封装到一个加载程序中,那不是很好吗?

我们将通过创建一个复合加载器来做到这一点。

设置

这个加载器在结构上与我们制作的其他加载器相似:大部分工作将发生在 load(task:) 方法中,我们也需要在某些时候实现 reset(with:) 。

public class Auth: HTTPLoader {

    private let basic: BasicAuth = ...
    private let oauth: OAuth = ...

    public override func load(task: HTTPTask) {
        if /* should load with basic auth */ {
            basic.load(task: task)
        } else if /* should load with oauth */ {
            oauth.load(task: task)
        } else {
            super.load(task: task)
        }
    }

    public override func reset(with group: DispatchGroup) {
        basic.reset(with: group)
        oauth.reset(with: group)
        super.reset(with: group)
    }

}

这看起来不错,对吧? 好吧,这不是😀。 这种方法有几个问题需要我们解决:

  • 我们需要一种方法来确定哪个加载器应该加载特定请求。

  • reset(with:) 方法存在严重的逻辑缺陷。

为请求选择加载器

给定传入的 HTTPTask,我们需要一种方法来确定应该如何对其进行身份验证。 或者换句话说,每个请求都需要告诉我们它想要什么。 幸运的是,我们已经有办法指定每个请求的选项!

public struct AuthenticationMethod: Hashable, HTTPRequestOption {
    // by default, requests are not authenticated
    public static let defaultOptionValue: AuthenticationMethod? = nil

    public static let basic = AuthenticationMethod(rawValue: "basic")
    public static let oauth = AuthenticationMethod(rawValue: "oauth")

    public let rawValue: String

    public init(rawValue: String) { self.rawValue = rawValue }
}

extension HTTPRequest {    
    public var authenticationMethod: AuthenticationMethod? {
        get { self[option: AuthenticationMethod.self] }
        set { self[option: AuthenticationMethod.self] = newValue }
    }
}

我选择使用 AuthenticationMethod 类型的结构来创建它,以便客户端可以创建自己的 AuthenticationMethods 以对应于他们自己的自定义身份验证加载器; 枚举会使这变得非常困难。 我们还可以更新加载器以允许客户端传递他们想要使用的身份验证加载器的种类,方法是创建一个 Dictionary<AuthenticationMethod, HTTPLoader> 将身份验证方法关联到特定的加载器:

public class Auth: HTTPLoader {

    private let subloaders: [AuthenticationMethod: HTTPLoader]

    public init(loaders: [AuthenticationMethod: HTTPLoader]) {
        self.subloaders = loaders
        super.init()
    }

    public override func load(task: HTTPTask) {
        if let method = task.request.authenticationMethod {
            // the request wants authentication

            if let loader = subloaders[method] {
                // we know which loader to use
                loader.load(task: task)
            } else {
                // we don't know which loader to use
                task.fail(.cannotAuthenticate)
            }
        } else {
            // no authentication; immediately pass it on
            super.load(task: task)
        }
    }

    public override func reset(with group: DispatchGroup) {
        subloaders.values.forEach { $0.reset(with: group) }
        super.reset(with: group)
    }

}

这样我们的 Auth loader 不仅支持内置的身份验证方法(BasicAuth 和 OAuth); 它还支持框架客户端制作的任何类型的 AuthenticationMethod 和自定义身份验证加载程序。 这种方法的另一个巨大优势是 Auth 加载器不需要拦截底层 BasicAuth 或 OAuth 加载器的任何委托方法。 由于这些加载器现在是在外部创建的,创建者可以将委托(用于读取/写入凭据等)设置为它想要的任何对象。 如果 Auth 加载器自己创建了这些值,我们将需要一种方法来注入委托或拦截委托方法调用,然后再通过不同的委托协议转发它们。

重置比赛

reset(with:) 方法有一个有趣的问题。 让我们看一下 nextLoader 值是如何用这种复合加载器配置的:

image.png

我们的内部加载器需要一个 .nextLoader 值,因为它们需要一种方法将修改后的任务向下传递到链中,以便最终通过网络传输。 然而,如果我们直接将它们发送到 Auth 加载器的 .nextLoader,那么我们最终会遇到这样一种情况,即下一个加载器将在一次重置尝试中多次调用其 reset(with:) 方法。 我们可以想象这种情况的发生:

Auth 收到重置调用并开始重置之前的加载程序。 它指示 Auth 重置 Auth 指示每个子加载器重置,并指示其下一个加载器重置 每个子加载器开始重置并指示其下一个加载器重置,但 Auth 已经告诉该加载器重置 因此,我们需要拦截来自内部加载器的所有这些重置调用,并阻止它们在链中传播。 幸运的是,我们已经有一个加载程序可以做到这一点! 回到第 9 部分,我们构建了一个 ResetGuard 加载器,它正是这样做的。

从广义上讲,Auth 加载器需要确保每个子加载器的 .nextLoader 都指向一个私有的 ResetGuard 加载器。 然后它需要拦截设置自己的 nextLoader 的调用,而不是使它成为 ResetGuard 的下一个加载器。 通过这种方式,Auth 加载器在整个加载链中注入一个新的 ResetGuard 加载器实例。

我会把这个留给你去实施。

结论

总的来说,构建复合加载器是一项概念上直接的任务。 关于我们需要处理的 nextLoader 有一个有趣的边缘案例,但是一旦我们理解了这个问题,解决方案就会出现我们已经构建的组件。

在下一篇文章中,我们将研究一种不同类型的复合加载器来支持 OAuth 的变体:OpenID。

Swift中的HTTP(十五) 自动鉴权

作者 庄周晓梦
2023年2月6日 20:30

HTTP简介

HTTP基础结构

HTTP请求体

HTTP 加载请求

HTTP 模拟测试

HTTP 链式加载器

HTTP 动态修改请求

HTTP 请求选项

HTTP 重置

HTTP 取消

HTTP 限流

HTTP 重试

HTTP 基础鉴权

HTTP 自动鉴权设置

HTTP 自动鉴权

HTTP 复合加载器

HTTP 头脑风暴

HTTP 总结

上一篇文章介绍了 OAuth 流程的基础知识:我们如何检查令牌、我们如何要求用户登录、我们如何刷新令牌等等。 在这篇文章中,我们将采用该状态机并将其集成到 HTTPLoader 子类中。

加载器

我们已经为授权流程定义了状态机,但我们需要另一个简单的状态机来描述加载器将如何与之交互。 让我们考虑一下我们的加载器在被要求加载任务时可能处于的各种“状态”:

  • 空闲(什么都没有发生,或者状态机失败)→我们应该启动状态机并开始运行授权流程
  • 授权(状态机正在运行)→我们应该让任务等待状态机完成
  • 授权(我们有有效的凭据)→加载任务
  • 授权(我们的凭证已过期)→ 我们需要刷新令牌 如果我们仔细观察一下,我们会发现“空闲”状态实际上与“授权 + 过期令牌”状态是一回事:在任何一种情况下,我们都需要启动状态机,以便 我们可以获得新的令牌(回想一下,状态机已经具有刷新过期令牌的逻辑)。 考虑到这一点,让我们存根我们的加载器:
public class OAuth: HTTPLoader {

    private var stateMachine: OAuthStateMachine?
    private var credentials: OAuthCredentials?
    private var pendingTasks = Array<HTTPTask>()

    public override func load(task: HTTPTask) {
        // TODO: make everything threadsafe

        if stateMachine != nil {
            // "AUTHORIZING" state
            // we are running the state machine; load this task later
            self.enqueueTask(task)

        } else if let tokens = credentials {
            // we are not running the state machine
            // we have tokens, but they might be expired
            if tokens.expired == true {
                // "AUTHORIZED+EXPIRED" state
                // we need new tokens
                self.enqueueTask(task)
                self.runStateMachine()
            } else {
                // "AUTHORIZED+VALID" state
                // we have valid tokens!
                self.authorizeTask(task, with: tokens)
                super.load(task: task)
            }

        } else {
            // "IDLE" state
            // we are not running the state machine, but we also do not have tokens
            self.enqueueTask(task)
            self.runStateMachine()
        }
    }

}

我们可以看到 if 语句中编码的四种可能状态。 我们遗漏了一些部分,所以让我们看一下:

public class OAuth: HTTPLoader {
    ... // the stuff above

    private func authorizeTask(_ task: HTTPTask, with credentials: OAuthCredentials) {
        // TODO: create the "Authorization" header value
        // TODO: set the header value on the task
    }

    private func enqueueTask(_ task: HTTPTask) {
        self.pendingTasks.append(task)
        // TODO: how should we react if the task is cancelled while it's pending?
    }

    private func runStateMachine() {
        self.stateMachine = OAuthStateMachine(...)
        self.stateMachine?.delegate = self
        self.stateMachine?.run()
    }
}

extension OAuth: OAuthStateMachineDelegate {

    // TODO: the OAuth loader itself needs a delegate for some of these to work

    func stateMachine(_ machine: OAuthStateMachine, wantsPersistedCredentials: @escaping (OAuthCredentials?) -> Void) {
        // The state machine is asking if we have any credentials
        // TODO: if self.credentials != nil, use those
        // TODO: if self.credentials == nil, ask a delegate
    }

    func stateMachine(_ machine: OAuthStateMachine, persistCredentials: OAuthCredentials?) {
        // The state machine has found new tokens for us to save (nil = delete tokens)
        // TODO: save them to self.credentials
        // TODO: also pass them on to our delegate
    }

    func stateMachine(_ machine: OAuthStateMachine, displayLoginURL: URL, completion: @escaping (URL?) -> Void) {
        // The state machine needs us to display a login UI
        // TODO: pass this on to our delegate
    }

    func stateMachine(_ machine: OAuthStateMachine, displayLogoutURL: URL, completion: @escaping () -> Void) {
        // The state machine needs us to display a logout UI
        // This happens when the loader is reset. Some OAuth flows need to display a webpage to clear cookies from the browser session
        // However, this is not always necessary. For example, an ephemeral ASWebAuthenticationSession does not need this
        // TODO: pass this on to our delegate
    }

    func stateMachine(_ machine: OAuthStateMachine, didFinishWithResult result: Result<OAuthCredentials, Error>) {
        // The state machine has finished its authorization flow

        // TODO: if the result is a success
        //       - save the credentials to self.credentials (we should already have gotten the "persistCredentials" callback)
        //       - apply these credentials to everything in self.pendingTasks
        // 
        // TODO: if the result is a failure
        //       - fail all the pending tasks as "cannot authenticate" and use the error as the "underlyingError"

        self.stateMachine = nil
    }

}

大多数对状态机的反应都涉及将信息转发给另一个代表。 这是因为我们的加载器(正确!)不知道如何显示登录/注销 UI,我们的加载器也不知道凭据如何保存或保存在何处。 这是应该的。 显示 UI 和持久化信息与我们的加载器“验证请求”的任务无关。

重置

除了 TODO: 分散在我们代码周围的项目之外,我们缺少的最后一个主要难题是“重置”逻辑。 乍一看,我们可能会认为是这样的:

public func reset(with group: DispatchGroup) {
    self.stateMachine?.reset(with: group)
    super.reset(with: group)
}

正如上一篇文章中所讨论的,状态机中的每个状态都可以被 reset() 调用中断,这就是发生这种情况的方式。 因此,如果我们的机器当前正在运行,这就是我们可以中断它的方式。

……但是如果它没有运行呢? 如果我们已经通过身份验证并拥有有效令牌,然后我们收到对 reset() 的调用怎么办? (这实际上是常见的情况,因为“重置”在很大程度上类似于“注销”,通常只有在身份验证成功时才会发生)

在这种情况下,我们需要修改我们的状态机。 回想一下我们上次描述这个 OAuth 流程:

image.png

此流程中没有任何内容可处理“注销”场景。 我们需要稍微修改一下,以便我们也有办法使令牌无效。 此注销状态已在之前的“注意事项”部分中列出。 包含它后,状态流程图现在大致如下所示:

image.png

关于这件事需要注意的两点是:

  • 从所有先前状态到新“注销”状态的虚线表示在状态机运行时通过调用 reset() 来“中断”该状态

  • 新的“注销”状态是状态机的可能入口点。 也就是说,我们可以在这个状态下启动机器。 我将把“注销”状态的实现留给你,但它需要做一些事情:

  • 它需要构造 URL 来显示“注销”页面以显示给用户(之前提到的从浏览器会话中清除 cookie 的页面)

  • 它需要联系服务器并告诉他们凭据已被撤销

  • 它需要通知其委托人清除任何持久化的凭据 有了这个,我们的 OAuth 加载器应该可以完全正常工作:

public func reset(with group: DispatchGroup) {
    if let currentMachine = self.stateMachine {
       // we are currently authorizing; interrupt the flow
       currentMachine.reset(with: group)
    } else {
        // TODO: you'll want to pass the "group" into the machine here
        self.stateMachine = OAuthStateMachine(...)
        self.stateMachine?.delegate = self

        // "running" the state machine after we gave it the DispatchGroup should start it in the LogOut state
        self.stateMachine?.run()
    }
    super.reset(with: group)
}

结论

我希望这两篇文章说明 OAuth 不必是这么可怕的东西。 我们有状态机来授权(或取消授权)用户,它有六种可能的状态。 这不是很多,我们可以把它记在脑子里。 同样,加载程序本身只有少数几种可能的状态,具体取决于状态机的情况。 通过将各自的逻辑封装在不同的抽象层中,我们能够将整体复杂性保持在相当低的水平。 我们机器的每个状态子类都是直截了当的; 我们的 StateMachine 类中几乎没有代码; 甚至我们的 OAuth 加载程序也只有几十行。

但由此,我们最终得到了一个功能齐全的 OAuth 流程:

  • 我们保证一次只运行一个 OAuth 授权 UI
  • 我们允许客户显示他们想要的 OAuth UI
  • 我们允许客户以他们想要的方式保留令牌
  • 我们允许中断授权
  • 我们允许通过重置取消授权 太棒了!

在下一篇文章中,我们将把 BasicAuth 和 OAuth 加载器组合成一个复合身份验证加载器。

Swift中的HTTP(十四) 自动鉴权设置

作者 庄周晓梦
2023年2月6日 20:25

HTTP简介

HTTP基础结构

HTTP请求体

HTTP 加载请求

HTTP 模拟测试

HTTP 链式加载器

HTTP 动态修改请求

HTTP 请求选项

HTTP 重置

HTTP 取消

HTTP 限流

HTTP 重试

HTTP 基础鉴权

HTTP 自动鉴权设置

HTTP 自动鉴权

HTTP 复合加载器

HTTP 头脑风暴

HTTP 总结

虽然基本访问身份验证适用于“基本”情况,但现在更常见的是使用某种形式的 OAuth。 与 Basic 身份验证相比,OAuth 有一些有趣的优势,例如:

该应用永远无法访问用户的用户名和密码 用户可以在不影响应用程序访问的情况下更改用户名或密码 用户可以远程撤销应用程序的访问权限 这些优势是以增加复杂性为代价的,而这正是我们将在本文中探讨的复杂性。

自动鉴权流程

基本(无错误)OAuth 流程如下所示:

image.png

当我们决定开始身份验证时,我们首先检查是否有任何已保存的令牌。 这里有三种可能的结果:

  • 我们有凭据,而且还没有过期
  • 我们有凭据,但它们已过期
  • 我们没有凭据

第一种情况很简单。 如果我们有未过期的凭据,那么我们不需要做任何事情。 第二种情况也很容易。 如果我们有过期的凭据,我们可以将它们发送到身份验证服务器以获取“新”版本的令牌(假设用户没有撤销访问权限),然后我们就完成了。

如果我们没有任何凭据,则需要要求用户登录。这是通过构建登录网页的 URL,然后将该页面显示给用户来完成的。 用户登录网页,显示该页面的服务器验证凭据的正确性。 假设用户名和密码正确,网页将重定向到一个新的 URL,浏览器会拦截该 URL 并将其重定向到应用程序。

此重定向 URL 具有由服务器生成的特殊代码,应用程序可使用该代码获取访问凭据。 因此,有了代码,应用程序现在转身询问服务器:“鉴于此代码,我需要授权令牌”。 服务器用令牌响应,过程完成。

OAuth 流程定义非常明确,是状态机的一个很好的例子。 有几件具体的事情要做,并且它们完成的顺序是严格定义的,并且只允许该顺序。

如果我们要在 Swift 中实现这个流程,一种常见的方法可能是使用某种 State 枚举,就像我们在 Basic Authentication 帖子中看到的那样。 但是,考虑到状态的数量和允许的非常明确的流程,我认为有必要采用更正式的方法。

自动鉴权状态机

首先,我们将定义一个代表整个过程的“状态机”类:

class OAuthStateMachine {
    func run() { }
}

接下来,我们将定义一个代表上图中“圆”的 OAuthState 类,并为状态机提供一个状态。 该状态将有一个 enter() 方法,当我们“进入”该状态时将调用该方法,它应该开始执行其逻辑:

class OAuthState {
    func enter() { }
}

class OAuthStateMachine {
    private var currentState: OAuthState!
}

一个状态需要一种方法来告诉机器它何时准备好继续前进,所以它需要一个对机器的引用,以及一种移动状态的方法:

class OAuthState {
    unowned var machine: OAuthStateMachine!
    func enter() { }
}

class OAuthStateMachine {
    private var currentState: OAuthState!

    func move(to newState: OAuthState) {
        currentState?.machine = nil
        newState.machine = self
        currentState = newState
        currentState.enter()
    }
}

现在我们可以定义对应于我们的图表的状态:

class GetSavedCredentials: OAuthState { }
class LogIn: OAuthState { }
class GetTokens: OAuthState { }
class RefreshTokens: OAuthState { }
class Done: OAuthState { }

对于其中的每一个,我们都需要实现它们的 enter() 方法。 让我们看看每一个。

检索凭证

GetSavedCredentials 状态是当我们需要转身并询问应用程序是否在钥匙串(或其他安全存储位置)中为我们保存了任何凭据时。 在基本访问帖子中,我们通过委托完成了此操作。 我们将在这里采用类似的方法。

class GetSavedCredentials: OAuthState {
    override func enter() {
        // we need to ask someone if there are any save credentials
        // let's assume the state machine itself has a delegate we can ask

        let delegate = machine.delegate
        DispatchQueue.main.async {
            // it's always polite to invoke delegate methods on the main thread
            delegate.stateMachine(self.machine, wantsPersistedCredentials: { credentials in
                // this closure will be called with either the credentials that were saved, or "nil"
                self.processCredentials(credentials)
            })
        }
    }

    private func processCredentials(_ credentials: OAuthCredentials?) {
        let nextState: OAuthState
        if let credentials = credentials, credentials.expired == false {
            // we got credentials and they're not expired
            nextState = Done(credentials: credentials)
        } else if let credentials = credentials {
            // we got credentials but they are expired
            nextState = RefreshTokens(credentials: credentials)
        } else {
            // we did not get credentials
            nextState = LogIn()
        }
        machine.move(to: nextState)
    }
}

这真的就是它的全部。 当我们“进入”状态时,我们询问代理是否有任何已保存的凭据。 在某个时候它会返回给我们,于是我们检查结果并决定下一步去哪里。

刷新Tokens

我们从身份验证服务器获得的令牌包含两部分:“刷新”令牌和“访问”令牌。 访问令牌是我们用来对每个请求进行身份验证的东西,它的生命周期往往很短。 它的有效期从几分钟到几天不等。

在某个时候,它会“过期”(这个过期日期包含在我们作为令牌的一部分获得的数据中)。 发生这种情况时,我们使用另一个令牌(“刷新”令牌)向服务器请求新的访问令牌。 这是 OAuth 尝试为用户提供尽可能多的控制权的方式之一。 当用户撤销对应用程序的访问时,它不仅会使访问令牌失效,还会使刷新令牌失效。 这意味着应用程序不能只获取新令牌并仍然保持访问权限,而是完全失去访问权限。

RefreshTokens 状态使用此“刷新令牌”来获取新凭据。 假设 OAuthStateMachine 有一个 HTTPLoader,我们可以使用它来请求新的凭据(例如,这可能是整个 OAuth 加载程序的 .nextLoader)。

class RefreshTokens: OAuthState {
    let credentials: OAuthCredentials
    override func enter() {
        var request = HTTPRequest()
        // TODO: construct the request to point to our OAuth server
        request.body = FormBody([
            URLQueryItem(name: "client_id", value: "my_apps_client_id"),
            URLQueryItem(name: "client_secret", value: "my_apps_client_secret"),
            URLQueryItem(name: "grant_type", value: "refresh_token"),
            URLQueryItem(name: "refresh_token", value: credentials.refreshToken)
        ])

        machine.loader.load(request: request, completion: { result in
            self.processResult(result)
        })
    }

    private func processResult(_ result: HTTPResult) {
        let nextState: OAuthState
        switch result {
            case .failure(let error):
                // TODO: do we give up here? Or maybe we could ask the user to log in?
                nextState = Done(credentials: nil)

            case .success(let response):
                // this could be any response, including a "401 Unauthorized" response

                if let credentials = OAuthCredentials(response: response) {
                    // TODO: notify the delegate that we have new credentials to save
                    nextState = Done(credentials: credentials)
                } else {
                    // TODO: do we give up here? Or maybe we could ask the user to log in?
                    nextState = Done(credentials: nil)
                }
        }

        machine.move(to: nextState)
    }
}

鉴于我们现有的 OAuthCredentials,我们使用其中的 .refreshToken 向服务器请求新的访问令牌。 如果我们得到它,我们可以告诉委托人保存它并移动到“完成”状态。 如果出现问题,那么我们可以放弃(在没有凭据的情况下转到“完成”),或者我们可以直接进入“登录”状态并要求用户再次登录。 这个特定的选择是我们的,做出这个改变是实例化一个 LogIn 实例而不是 Done 实例的问题。

登录

如果我们未能获得有效的访问令牌(无论是没有保存还是无法理解响应),我们可能需要要求用户登录。此状态将与以下一样简单 GetSavedCredentials 状态:

class LogIn: OAuthState {
    let state = UUID()

    override func enter() {
        var loginURL = URLComponents()
        // construct a URL according to the specification for the server. This will likely be something like this:
        loginURL.scheme = "https"
        loginURL.host = "example.com"
        loginURL.path = "/oauth/login"
        loginURL.queryItems = [
            URLQueryItem(name: "client_id", value: "my_apps_client_id"),
            URLQueryItem(name: "response_type", value: "code"),
            URLQueryItem(name: "scope", value: "space separated list of permissions I want"),
            URLQueryItem(name: "state", value: state.uuidString)
        ]

        let url = loginURL.url! // this should always succeed
        // TODO: what if in some alternate reality this fails. Then what?

        DispatchQueue.main.async {
            let delegate = self.machine.delegate
            delegate.stateMachine(self.machine, displayLoginURL: url, completion: { callbackURL in
                self.processCallbackURL(callbackURL)
            })
        }
    }

    private func processCallbackURL(_ url: URL?) {
        let nextState: OAuthState
        // TODO: if url is nil, then the user cancelled the login process → Done(credentials: nil)
        // TODO: if we got a url but its "state" query item doesn't match self.state, the app called the wrong callback → Done(credentials: nil)
        // TODO: if we see a "code" query item in the URL → GetTokens(code: code)
        machine.move(to: nextState)
    }
}

这在概念上是一个非常简单的状态。 与 GetSavedCredentials 一样,这是一种我们“等待应用程序做某事,当它完成后会告诉我们”的状态。

“应用程序做某事”的那部分可以是几件不同的事情。 这个状态所做的就是给应用程序一个 URL,应用程序需要以某种方式使用它来让用户登录。这可以通过 WKWebView(不推荐),弹出到 Safari(可能会破坏),或者使用 ASWebAuthenticationSession(可能是最好的体验)。

显示 WKWebView 很容易,但它会让用户面临风险,因为应用程序可能会看到用户在这样的 Web 视图中输入的内容。 因此,您不应该将这些用于敏感场景,例如登录。如果您选择使用其中之一(您不应该这样做),您将使用 Web 视图的 WKNavigationDelegate 来查看用户何时完成以及服务器何时尝试 将流程重定向回应用程序。 您将拦截重定向的 URL,并使用该 URL 调用提供给状态机委托方法的回调。 当然,如果用户决定取消,你会用 nil 调用回调。 但是,不要使用这种方法。

如果您决定将用户弹出到 Safari,您将使用 UIApplication(或 NSWorkspace)打开 URL。 该应用程序还需要将回调保存在某处。 在 Safari 中,服务器将重定向到应用程序注册处理的 URL(通过其 Info.plist),此时应用程序将再次激活,您的 application(_:open:options:) 委托方法将是 调用,然后将 URL 传回回调。 当然,使用这种方法,您无法知道用户是否已取消。

最好的方法是使用 ASWebAuthenticationSession 在您的应用程序中显示安全浏览器,同时在会话结束时提供回调。 例如,如果您的应用程序中有一个 UIWindowSceneDelegate,您可以将其用作呈现上下文:

extension MySceneDelegate: ASWebAuthenticationPresentationContextProviding {

    // this is the method that would get called by way of the state machine delegate method
    func displayLoginSession(_ url: URL, completion: @escaping (URL?) -> Void) {
        self.authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: Bundle.main.bundleIdentifier!, completionHandler: { [weak self] url, error in
            self?.authSession = nil
            completion(url)
        })
        self.authSession?.prefersEphemeralWebBrowserSession = true
        self.authSession?.presentationContextProvider = self
        self.authSession?.start()
    }
    
    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        // which window are we presenting the session in? the scene's window!
        return window!
    }
}

由于我们已经将“登录所需的 UI”与“整个身份验证过程”分离,我们实际上最终在整个体验中获得了相当大的灵活性,并且可以让应用程序开发者为他们的应用程序选择最佳体验,而无需 我们(作为图书馆作者)必须为他们做出决定。

获取Tokens

我不会在这里列出完整的状态,但这在概念上与 RefreshTokens 状态相同。 在这种状态下,我们将收到的代码作为登录回调 URL 的一部分,并将其与其他所需的位(例如我们的客户端 ID 和客户端密码)一起发送到服务器。 假设一切都检查完毕,我们将取回一个不错的新刷新令牌和有效访问令牌,我们可以要求应用程序为我们保存它们,然后转到我们最终的“完成”状态。

Done

正如我们到目前为止所见,可以使用一组有效的凭据或 nil(表示出现问题)调用 Done 状态。 当我们进入这个状态时,它需要以某种方式向机器发出信号表明整个过程已经完成(可能是 OAuthStateMachine 上的另一个新方法)并且机器可以获取 Done 状态获得的任何值并将其返回给 调用状态机的库。

两大坑

我从这个状态机中遗漏了两个明显的遗漏。

我遗漏的第一件事,就像我在所有实现中遗漏的一样,是线程安全的概念。 我把它排除在外是因为它是相当多的样板代码,我的目标是这些帖子的可读性,而不是“完全正确”。

另一件事是由问题提示的:如果我们在这个状态机的中间并且我们被要求重置()整个加载链会发生什么? 为了适应这一点,每个状态可能还需要有一个 reset() 方法,它可以用来执行任何清理(例如取消网络请求),然后立即转移到新的 LogOut 状态。 LogOut 状态将负责告诉委托人保存一组新的凭据(nil,意思是“删除你拥有的”),可能会显示注销网页,使服务器的凭据过期等等。 为简洁起见,我将其省略,但如果您最终实施 OAuth,则明智的做法是考虑这种情况。

总结

这是我们为我们的库实现 OAuth 加载程序所需的整体设置。 在下一篇文章中,我们将使用此 OAuthStateMachine 自动获取和刷新令牌以用于经过身份验证的请求。

Swift中的HTTP(十三) 基础鉴权

作者 庄周晓梦
2023年2月6日 20:14

HTTP简介

HTTP基础结构

HTTP请求体

HTTP 加载请求

HTTP 模拟测试

HTTP 链式加载器

HTTP 动态修改请求

HTTP 请求选项

HTTP 重置

HTTP 取消

HTTP 限流

HTTP 重试

HTTP 基础鉴权

HTTP 自动鉴权设置

HTTP 自动鉴权

HTTP 复合加载器

HTTP 头脑风暴

HTTP 总结

对 web api 的 HTTP 请求通常需要有某种凭据。 最简单的身份验证类型是基本访问身份验证,在这篇文章中,我们将把这个功能添加到我们的库中。

当我们阅读基本身份验证的规范(或维基百科文章)时,我们看到它只是添加了一个授权标头,以及一个 base64 编码的用户名和密码。 添加标头值是我们之前见过的,因此我们的 BasicAuth 加载器在原则上与我们的 ApplyEnvironment 加载器相似。

配置

我们需要的第一件事是一个结构来表示用户名和密码:

public struct BasicCredentials: Hashable, Codable {
    public let username: String
    public let password: String

    public init(username: String, password: String) { ... }
}

我们还需要一个 HTTPRequestOption 以便单个请求可以指定唯一的凭据:

extension BasicCredentials: HTTPRequestOption {
    public static let defaultOptionValue: BasicCredentials? = nil
}

extension HTTPRequest {
    public var basicCredentials: BasicCredentials? {
        get { self[option: BasicCredentials.self] }
        set { self[option: BasicCredentials.self] = newValue }
    }
}

顺便说一句,让我们转向加载器。

加载器

加载器的行为很简单:当请求进来时,加载器会查看它是否指定了自定义凭据。 如果是这样,它将它们转换为正确的授权标头并将请求发送到链中。 如果没有,它会将任何本地存储的凭据应用于请求,然后继续发送。 如果它没有任何凭据,它会在检索一些请求时暂停传入请求。

让我们把它存根:

public protocol BasicAuthDelegate: AnyObject {
    func basicAuth(_ loader: BasicAuth, retrieveCredentials callback: @escaping (BasicCredentials?) -> Void)
}

public class BasicAuth: HTTPLoader {
    public weak var delegate: BasicAuthDelegate?

    private var credentials: BasicCredentials?
    private var pendingTasks = Array<HTTPTask>()

    public override func load(task: HTTPTask) {
        if let customCredentials = task.request.basicCredentials {
            self.apply(customCredentials, to: task)
        } else if let mainCredentials = credentials {
            self.apply(mainCredentials, to: task)
        } else {
            self.pendingTasks.append(task)
            // TODO: ask delegate for credentials
        }
    }

    private func apply(_ credentials: BasicCredentials, to task: HTTPTask) {
        let joined = credentials.username + ":" + credentials.password
        let data = Data(joined.utf8)
        let encoded = data.base64EncodedString()
        let header = "Basic (encoded)"
        task.request[header: "Authorization"] = header
    }
}

这是基本的实现,但不太正确。 例如,如果同时收到许多请求,我们最终会多次向委托人询问凭据。 我们需要在管理某些状态方面做得更好:

public class BasicAuth: HTTPLoader {
    public weak var delegate: BasicAuthDelegate?

    private enum State {
        case idle
        case retrievingCredentials(Array<HTTPTask>)
        case authorized(BasicCredentials)
    }

    private var state = State.idle

    public override func load(task: HTTPTask) {
        if let customCredentials = task.request.basicCredentials {
            self.apply(customCredentials, to: task)
            super.load(task: task)
            return
        } 

        // TODO: make this threadsafe
        switch state {
            case .idle:
                // we need to ask for credentials
                self.state = .retrievingCredentials([task])
                self.retrieveCredentials()

            case .retrievingCredentials(let others):
                // we are currently asking for credentials and waiting for the delegate
                self.state = .retrievingCredentials(others + [task])

            case .authorized(let credentials):
                // we have credentials
                self.apply(credentials, to: task)
                super.load(task: task)
        }
    }

    private func retrieveCredentials() {
        if let d = delegate {
            // we've got a delegate! Ask it for credentials
            // these credentials could come from storage (eg, the Keychain), from a UI ("log in" page), or somewhere else
            // that decision is entirely up to the delegate
            DispatchQueue.main.async {
                d.basicAuth(self, retrieveCredentials: { self.processCredentials($0) })
            }
        } else {
            // we don't have a delegate. Assume "nil" credentials
            self.processCredentials(nil)
        }
    }

    private func processCredentials(_ retrieved: BasicCredentials?) {
        // TODO: make this threadsafe

        guard case .retrievingCredentials(let pending) = state else {
            // we got credentials, but weren't waiting for them; do nothing
            // this could happen if we were "reset()" while waiting for the delegate
            return
        }

        if let credentials = retrieved {
            state = .authorized(credentials)
            for task in pending {
                self.apply(credentials, to: task)
                super.load(task: task)
            }
        } else {
            // we asked for credentials but didn't get any
            // all of these tasks will fail
            state = .idle
            pending.forEach { $0.fail(.cannotAuthenticate) }
        }
    }

    private func apply(_ credentials: BasicCredentials, to task: HTTPTask) {
        ...
    }
}

这看起来好多了,但它仍然缺少一些关键的东西,我将留给你来实现: 我们需要注意一个任务在挂起时被取消(即 .retrievingCredentials 状态) 这些都不是线程安全的 我们需要 reset(with:) 逻辑来使挂起的任务失败并返回到 .idle 状态 还有一些其他场景值得考虑:

  • 此加载程序假定请求将始终经过身份验证。 您将如何更改它以允许请求完全绕过身份验证,因为他们不需要它?

  • 在 URLComponents 上有一种方法可以指定用户和密码。 由于 HTTPRequest 在后台使用 URLComponents,您如何更改此加载器以在那里查找可能的授权信息,而不是(或除此之外)basicCredentials 请求选项? 你认为这应该是 API 吗? 为什么或者为什么不?

  • 此加载程序不会检测请求何时未通过身份验证,例如返回 401 Unauthorized 或 403 Forbidden 的响应。 这个装载机应该尝试检测吗? 为什么或者为什么不?

正如我们所见,基本身份验证归结为向我们的传出请求简单添加一个标头。 通过延迟传入请求,我们可以转身询问应用程序的另一部分(通过代理)是否有任何凭证供我们使用。

这种模式将在下一篇文章中继续为我们服务,届时我们将研究通过 OAuth 2.0 进行身份验证的更复杂的场景。

Swift中的HTTP(十二) 重试

作者 庄周晓梦
2023年2月6日 20:10

HTTP简介

HTTP基础结构

HTTP请求体

HTTP 加载请求

HTTP 模拟测试

HTTP 链式加载器

HTTP 动态修改请求

HTTP 请求选项

HTTP 重置

HTTP 取消

HTTP 限流

HTTP 重试

HTTP 基础鉴权

HTTP 自动鉴权设置

HTTP 自动鉴权

HTTP 复合加载器

HTTP 头脑风暴

HTTP 总结

如果收到的响应与客户端所寻找的不完全一致,大多数网络库都能够自动再次发送请求。 让我们也把它添加到我们的库中。

配置

回忆一下我们的 HTTPLoader 的 API:

open class HTTPLoader {

    /// Load an HTTPTask that will eventually call its completion handler
    func load(task: HTTPTask)

    /// Reset the loader to its initial configuration
    func reset(with group: DispatchGroup)
}

请记住,这定义了一种加载任务的方式和一种“重新开始”的方式。 加载任务具有请求最终将返回响应的合同。

API 合同中没有任何内容表明请求只能加载一次。 您可以(并且我们将)拥有一个加载程序,多次执行单个请求,然后选择最佳响应传回。 这就是我们的装载机要做的。

重试请求与每个单独的请求密切相关,因此我们将首先创建每个请求的选项值来指定单个请求的行为方式。

重试选项

重试请求的决定归结为一个问题:“鉴于此响应,我是否应该重试该请求?如果是,我应该等待多长时间才能重试?” 我们可以将其表示为协议:

public protocol HTTPRetryStrategy {
    func retryDelay(for result: HTTPResult) -> TimeInterval?
}

我们的重试“策略”需要检查我们从上次请求调用中获得的 HTTPResult,然后返回一个 TimeInterval(以秒为单位的时间长度)等待再次发送请求。 返回 nil 表示“不重试”,返回 0 表示“立即重试”。

我们可以立即想象出几种不同的策略,例如立即重试、始终等待相同的时间或等待呈指数增长的时间长度:

public struct Backoff: HTTPRetryStrategy {
    public static func immediately(maximumNumberOfAttempts: Int) -> Backoff
    public static func constant(delay: TimeInterval, maximumNumberOfAttempts: Int) -> Backoff
    public static func exponential(delay: TimeInterval, maximumNumberOfAttempts: Int) -> Backoff
}

由于这是一个协议,我们也可以想象一个自定义实现来提供更动态的实现。 例如,Twitter API 说:

• 对于速率受限的查询(那些返回 HTTP 429 状态代码的查询),您必须检查 x-rate-limit-reset 标头并仅在指示的时间或之后重试。

• 对于导致 HTTP 503 服务不可用状态代码的查询,您必须检查 retry-after 标头并仅在指示的时间后重试。

许多 HTTP 服务提供类似的选项。 如果您发送太多请求太快或信息尚不可用,它们通常会在响应标头中指示您应该等待多长时间才能重试。 因此,您可以编写一个自定义重试策略来实现您所针对的 API 的特定行为:

struct TwitterRetryStrategy: HTTPRetryStrategy {
    func retryDelay(for result: HTTPResult) -> TimeInterval? {
        // TODO: are there other scenarios to consider?
        guard let response = result.response else { return nil }

        switch response.statusCode {

            case 429: 
                // look for the header that tells us when our limit resets
                guard let retryHeader = response.headers["x-rate-limit-reset"] else { return nil }
                guard let resetTime = TimeInterval(retryHeader) else { return nil }
                let resetDate = Date(timeIntervalSince1970: resetTime)
                let timeToWait = resetDate.timeIntervalSinceNow()
                guard timeToWait >= 0 else { return nil }
                return timeToWait

            case 503:
                // look for the header that tells us how long to wait
                guard let retryHeader = response.headers["retry-after"] else { return nil }
                return TimeInterval(retryHeader)

            default:
                return nil
        }
    }
}

定义了这些策略后,我们需要一个正式的 HTTPRequestOption 类型来声明它可以附加到请求中:

public enum RetryOption: HTTPRequestOption {
    // by default, HTTPRequests do not have a retry strategy, and therefore do not get retried
    public static var defaultOptionValue: HTTPRetryStrategy? { nil }
}

extension HTTPRequest {    
    public var retryStrategy: HTTPRetryStrategy? {
        get { self[option: RetryOption.self] }
        set { self[option: RetryOption.self] = newValue }
    }
}

加载器

我们创建的用于处理此问题的加载程序将是迄今为止我们最复杂的加载程序。 我个人的实现大约有 200 行代码,太长了,无法在本文中完整列出。 不过,我会突出显示它的关键部分。

  • 所有通过 load(task:) 方法接收的 HTTPTasks 在被传递到链中的下一个加载器之前被复制。 这是因为每个任务只应执行一次,因此请求的多次调用将需要多个任务。
  • 我们需要一种方法来记住哪个“重复”任务对应于原始任务。
  • 我们需要一种方法来保留所有等待重试的任务的列表,以及它们希望开始的时间。
  • 因此,我们需要某种类似计时器的机制来跟踪“下一个任务何时开始”。
  • 取消会有点棘手,因为原始任务将被取消,但我们需要一种方法来查看发生的情况并将取消命令转发给任何重复项。 -不要忘记重置 考虑到所有这些,我的实现大致如下所示:
// TODO: make all of this thread-safe
public class Retry: HTTPLoader {
    // the original tasks as received by the load(task:) method
    private var originalTasks = Dictionary<UUID, HTTPTask>()

    // the times at which specific tasks should be re-attempted
    private var pendingTasks = Dictionary<UUID, Date>()

    // the currently-executing duplicates
    private var executingAttempts = Dictionary<UUID, HTTPTask>()

    // the timer for notifying when it's time to try another attempt
    private var timer: Timer?
    
    public override func load(task: HTTPTask) {
        let taskID = task.id
        // we need to know when the original task is cancelled
        task.addCancelHandler { [weak self] in
            self?.cleanupFromCancel(taskID: taskID)
        }
        
        attempt(task)
    }
    
    /// Immediately attempt to load a duplicate of the task
    private func attempt(_ task: HTTPTask) {
        // overview: duplicate this task and 
        // 1. Create a new HTTPTask that invokes handleResult(_:for:) when done
        // 2. Save this information into the originalTasks and executingAttempts dictionaries

        let taskID = task.id        
        let thisAttempt = HTTPTask(request: task.request, completion: { [weak self] result in
            self?.handleResult(result, for: taskID)
        })
        
        originalTasks[taskID] = task
        executingAttempts[taskID] = thisAttempt
        
        super.load(task: thisAttempt)
    }
    
    private func cleanupFromCancel(taskID: UUID) {
        // when a task is cancelled:
        // - the original task is removed
        // - any executing attempt must be cancelled
        // - any pending task must be removed AND explicitly failed
        //   - this is a task that was stopped at this level, therefore
        //     this loader is responsible for completing it

        // TODO: implement this
    }
    
    private func handleResult(_ result: HTTPResult, for taskID: UUID) {
        // schedule the original task for retrying, if necessary
        // otherwise, manually complete the original task with the result

        executingAttempts.removeValue(forKey: taskID)
        guard let originalTask = originalTasks.removeValue(forKey: taskID) else { return }
            
        if let delay = retryDelay(for: originalTask, basedOn: result) {
            pendingTasks[taskID] = Date(timeIntervalSinceNow: delay)
            rescheduleTimer()
        } else {
            originalTask.complete(with: result)
        }
    }
    
    private func retryDelay(for task: HTTPTask, basedOn result: HTTPResult) -> TimeInterval? {
        // we do not retry tasks that were cancelled or stopped because we're resetting
        // TODO: return nil if the result indicates the task was cancelled
        // TODO: return nil if the result indicates the task failed because of `.resetInProgress`
        
        let strategy = task.request.retryStrategy
        guard let delay = strategy?.retryDelay(for: result) else { return nil }
        return max(delay, 0) // don't return a negative delay
    }
    
    private func rescheduleTimer() {
        // TODO: look through `pendingTasks` find the task that will be retried soonest
        // TODO: schedule the timer to fire at that time and call `fireTimer()`
    }
    
    private func fireTimer() {
        // TODO: get the tasks that should've started executing by now and attempt them
        // TODO: reschedule the timer
    }
    
    public override func reset(with group: DispatchGroup) {
        // This loader is done resetting when all its tasks are done executing

        for task in originalTasks.values {
            group.enter()
            task.addCompletionHandler { group.leave() }
        }
        
        super.reset(with: group)
    }
}

这个粗略的轮廓说明了“自动重试”加载程序的原理。 随着请求的到来,它们被保存到一边,重复项被转发到链下。 当复制完成时,加载器检查响应并弄清楚它应该如何处理它。 如果请求的重试策略表明它应该再试一次,那么它会将任务排队等待未来的日期。 如果不是,它会获取重复请求的结果并假装它一直是原始响应。

Retry 加载器是我们创建的第一个加载器,它在链中的位置会影响链的整体行为。 让我们考虑一个场景,我们有两个加载器:一个 Retry 加载器和一个 Throttle 加载器:

let throttle = Throttle()
throttle.maximumNumberOfRequests = 1

let retry = Retry()

现在假设我们要执行两个任务,taskA 和 taskB,我们还假设 taskA 将在最终失败之前最多重试 3 次,而 taskB 将成功。

let taskA: HTTPTask = ...
let taskB: HTTPTask = ...

let chain1 = throttle --> retry --> ...
let chain2 = retry --> throttle --> ... 

如果节流加载器放在重试加载器之前,那么“最大 1 个请求”的限制会在请求重试之前发生。 因此,如果 chain1 加载 taskA,然后加载 taskB,则执行顺序将始终为:A(尝试 1)、A(尝试 2)、A(尝试 3)、B。如果 taskA 的尝试之间存在较大延迟,则 taskB 可以 在尝试之前等待很长时间。

另一方面,如果 chain2 加载 taskA,然后加载 taskB,则执行顺序是不确定的。 可能是 A(尝试 1)、B、A(尝试 2)、A(尝试 3),B 有机会更快地执行。

“正确”的顺序完全取决于你想要的行为,但我建议节流可能是链中的最终加载器之一,这样链就不会无意中让传入的请求挨饿。

在下一篇文章中,我们将首先了解使用基本访问身份验证的身份验证。

❌
❌