Swift中的HTTP(三) 请求体 Request Bodies

532 阅读6分钟

HTTP简介

HTTP基础结构

HTTP请求体

HTTP 加载请求

HTTP 模拟测试

HTTP 链式加载器

HTTP 动态修改请求

HTTP 请求选项

HTTP 重置

HTTP 取消

HTTP 限流

HTTP 重试

HTTP 基础鉴权

HTTP 自动鉴权设置

HTTP 自动鉴权

HTTP 复合加载器

HTTP 头脑风暴

HTTP 总结

在进行HTTPRequest请求发送前,我们稍稍改进一下我们的结构体,最后,我们将会以下面的信息输出:

public struct HTTPRequest {
    private var urlComponents = URLComponents()
    public var method: HTTPMethod = .get
    public var headers: [String: String] = [:]
    public var body: Data?
}

在本节中,我们将着重讨论一下body属性,并对其进行改造。

通用化body

在HTTP简介那一节,我们了解到,一个请求体是原始二进制数据,但是,在与 Web API 通信时,这些数据有多种标准格式,例如 JSON 和表单提交。

我们可以将其概括为一种“给我们数据的东西”的形式,而不是要求此代码的客户手动构造其提交数据的二进制表示。

由于我们不打算对用于构造数据的算法施加任何限制,因此通过协议而不是具体类型来定义此功能是有意义的:

public protocol HTTPBody { }

接下来,我们需要一种方法从其中一个值中获取Data,并在出现问题时选择性地报告错误:

public protocol HTTPBody { 
    func encode() throws -> Data 
}

我们可以在这一点上停下来,但还有另外两条信息值得拥有:

public protocol HTTPBody { 
    var isEmpty: Bool { get }
    var additionalHeaders: [String: String] { get } 
    func encode() throws -> Data 
}

如果我们能快速知道一个body是空的,那么我们就可以省去尝试检索任何编码数据和处理错误或空数据值的麻烦。 此外,某些类型的正文与请求中的header结合使用。 例如,当我们将值编码为 JSON 时,我们希望有一种方法可以自动指定 Content-Type: application/json 的header,而无需在请求中手动指定它。 为此,我们将允许这些类型声明额外的header,这些标头将作为最终请求的一部分结束。 为了进一步简化采用,我们可以为这些提供默认实现:

extension HTTPBody {
    public var isEmpty: Bool { return false }
    public var additionalHeaders: [String: String] { return [:] }
}

最后,我们可以将我们的类型更新到这个新的协议中

public struct HTTPRequest {
    private var urlComponents = URLComponents()
    public var method: HTTPMethod = .get
    public var headers: [String: String] = [:]
    public var body: HTTPBody?
}

空请求体 EmptyBody

最简单的HTTPBody是”无体“。有了这个协议,定义一个空请求体也是很方便的。

public struct EmptyBody: HTTPBody {
    public let isEmpty = true

    public init() { }
    public func encode() throws -> Data { Data() }
}

我们甚至可以将其设置为默认的主体值,从而完全消除对该属性的可选性的需要:

public struct HTTPRequest {
    private var urlComponents = URLComponents()
    public var method: HTTPMethod = .get
    public var headers: [String: String] = [:]
    public var body: HTTPBody = EmptyBody()
}

数据体 DataBody

下一个明显要实现的主体类型是返回给定的任何Data值的主体。 这将用于我们不一定有 HTTPBody 实现但也许我们已经有Data值本身要发送的情况。

具体实现如下:

public struct DataBody: HTTPBody {    
    private let data: Data
    
    public var isEmpty: Bool { data.isEmpty }
    public var additionalHeaders: [String: String]
    
    public init(_ data: Data, additionalHeaders: [String: String] = [:]) {
        self.data = data
        self.additionalHeaders = additionalHeaders
    }
    
    public func encode() throws -> Data { data }    
}

有了这个,我们可以很轻松的将一个Data值封装进HTTPBody里:

let otherData: Data = ...
var request = HTTPRequest()
request.body = DataBody(otherData)

JSON体 JSONBody

在发送网络请求时,将值编码为 JSON 是一项非常常见的任务。 制作一个 HTTPBody 来为我们处理这个现在很容易:

public struct JSONBody: HTTPBody {
    public let isEmpty: Bool = false
    public var additionalHeaders = [
        "Content-Type": "application/json; charset=utf-8"
    ]
    
    private let encode: () throws -> Data
    
    public init<T: Encodable>(_ value: T, encoder: JSONEncoder = JSONEncoder()) {
        self.encode = { try encoder.encode(value) }
    }
    
    public func encode() throws -> Data { return try encode() }
}

首先,我们假设我们得到的任何值都会至少产生一些结果,因为即使是空字符串也会编码为非空 JSON 值。 因此,isEmpty = false

接下来,大多数服务器在接收 JSON 正文时需要 application/jsonContent-Type,因此我们假设这是常见情况,并在 additionalHeaders 中默认该值。 但是,我们会将该属性保留为 var,以防万一出现客户不希望这样的情况。

对于编码,我们需要接受一些通用值(要编码的东西),但最好不要让整个结构对编码类型通用。 我们可以通过将类型的泛型参数限制为初始化器来避免类型的泛型参数,然后在闭包中捕获泛型值。

我们还需要一种方法来提供自定义 JSONEncoder,以便客户有机会摆弄诸如 .keyEncodingStrategy 之类的东西。 但是,我们将提供一个默认编码器来简化使用。

最后,encode() 方法本身只是调用我们创建的闭包,它捕获通用值并通过 JSONEncoder 执行它。

其中一个的使用方法如下:

struct PagingParameters: Encodable {
    let page: Int
    let number: Int
}
let parameters = PagingParameters(page: 0, number: 10)

var request = HTTPRequest()
request.body = JSONBody(parameters)

这样,正文将自动编码为 {"page":0,"number":10},我们的最终请求将具有正确的 Content-Type 标头。

表单 FormBody

我们将在本文中看到的最后一种主体是表示基本表单提交的body。 当我们专门讨论多部分表单上传时,我们将保存文件上传以备将来使用。

表单提交正文最终为粗略的 URL 编码键值对,例如 name=Arthur&age=42

我们将从与我们的 HTTPBody 实现相同的基本结构开始:

public struct FormBody: HTTPBody {
    public var isEmpty: Bool { values.isEmpty }
    public let additionalHeaders = [
        "Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
    ]
    
    private let values: [URLQueryItem]
    
    public init(_ values: [URLQueryItem]) {
        self.values = values
    }
    
    public init(_ values: [String: String]) {
        let queryItems = values.map { URLQueryItem(name: $0.key, value: $0.value) }
        self.init(queryItems)
    }
    
    public func encode() throws -> Data {
        let pieces = values.map { /* TODO */ }
        let bodyString = pieces.joined(separator: "&")
        return Data(bodyString.utf8)
    }
}

和以前一样,我们有一个自定义的 Content-Type 标头来应用于请求。 我们还公开了几个初始化器,以便客户端可以以对他们有意义的方式描述这些值。 我们还删除了大部分 encode() 方法,省略了 URLQueryItem 值的实际编码。

不幸的是,对名称和值进行编码有点模棱两可。 如果你仔细阅读关于表单提交的古老规范,你会看到提到“换行规范化”和将空格编码为 + 的内容。 我们可以努力挖掘并找出这些东西的含义,但在实践中,Web 服务器往往可以很好地处理任何百分比编码的内容,甚至是空格。 我们将走捷径并假设这是真的。 我们还将全面假设字母数字字符在名称和值中是可以的,并且其他所有内容都应该被编码:

private func urlEncode(_ string: String) -> String {
    let allowedCharacters = CharacterSet.alphanumerics
    return string.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? ""
}

使用 = 字符组合名称和值:

private func urlEncode(_ queryItem: URLQueryItem) -> String {
    let name = urlEncode(queryItem.name)
    let value = urlEncode(queryItem.value ?? "")
    return "(name)=(value)"
}

有了这个,我们可以解决 /* TODO */ 评论:

public struct FormBody: HTTPBody {
    public var isEmpty: Bool { values.isEmpty }
    public let additionalHeaders = [
        "Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
    ]
    
    private let values: [URLQueryItem]
    
    public init(_ values: [URLQueryItem]) {
        self.values = values
    }
    
    public init(_ values: [String: String]) {
        let queryItems = values.map { URLQueryItem(name: $0.key, value: $0.value) }
        self.init(queryItems)
    }
    
    public func encode() throws -> Data {
        let pieces = values.map(self.urlEncode)
        let bodyString = pieces.joined(separator: "&")
        return Data(bodyString.utf8)
    }

    private func urlEncode(_ queryItem: URLQueryItem) -> String {
        let name = urlEncode(queryItem.name)
        let value = urlEncode(queryItem.value ?? "")
        return "(name)=(value)"
    }

    private func urlEncode(_ string: String) -> String {
        let allowedCharacters = CharacterSet.alphanumerics
        return string.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? ""
    }
}

和以前一样,使用它变得很简单:

var request = HTTPRequest()
request.body = FormBody(["greeting": "Hello, ", "target": "🌎"])
// the body is encoded as:
// greeting=Hello%2C%20&target=%F0%9F%8C%8E

其他Body Other Bodies

您可以在 HTTP 请求中发送的正文格式多种多样。 我已经提到过,我们将来会更仔细地研究多部分请求,但是这种 HTTPBody 方法几乎适用于您会遇到的每一种请求体。

在下一篇文章中,我们将描述 HTTP 请求加载抽象层并使用 URLSession 实现它。