Skip to content

旦夕社区的 API 封装 ​

这篇文档主要是在说树洞、蛋壳等旦夕自营社区服务中,如何对接后端的 API 接口。

Swift 标准库中进行 HTTP 请求的基本 API ​

说明

这部分属于基础知识性的内容,和旦夕项目没有直接的关系,如果熟悉 Swift 标准库可以直接跳到下一节。

Swift 的标准库(Foundation)中内置有对 HTTP 请求的支持,封装较少且功能完善,因此我们优先考虑使用 Foundation 提供的 API 来进行 HTTP 请求。目前没有必要引入第三方网络框架。

下面简单介绍一下使用 Foundation 进行 HTTP 请求的流程,这部分在 Foundation 中被称为 URL Loading System。在这之前,读者应该熟悉 URL 的组成、HTTP 协议、Cookie 等基本概念。

一个 HTTP 请求的基本流程 ​

要进行一个 HTTP 请求,基本的流程是:

  • 构建出请求的 URL。 有些简单的 URL 可以直接敲字符串敲出来, 但是有些复杂的 URL 有很多请求参数,请求参数里含有空格等特殊字符的时候还要进行转义,这种 URL 应该用程序来处理。下面分别是一个简单和复杂的 URL 例子。
text
https://forum.fduhole.com/api/divisions

https://forum.fduhole.com/api/search?query=Hello%20World&category=technology&page=2&sort=desc&filter=recent5&lang=zh-CN&date_from=2023-01-01&date_to=2023-12-31&limit=50
  • 构建出 HTTP 请求。一个完整的 HTTP 请求不止包含 URL,还包含 HTTP 方法、HTTP Header、HTTP Body 等部分。
  • 发送 HTTP 请求。
  • 接收并处理 HTTP 响应。 HTTP 响应包含返回码,Header 和 Body 几个部分。

如何用代码实现一个 HTTP 请求 ​

接下来我们会用一个在树洞里发帖的例子来帮助大家熟悉 Swift 里 URL Loading System 中的各种概念。

说明

这个例子里的树洞 API 是虚构的,真正的树洞 API 请去看 API 文档。

构建请求 URL ​

首先,我们要构造请求的 URL。我们发帖是必须提供一个分区的,分区的 ID 会写在 URL 参数里。

swift
let divisionId: Int // 这个变量是分区的 ID

var components = URLComponents(string: "https://forum.fduhole.com/api/post")!
components.queryItems = [
  URLQueryItem(name: "divisionId", value: divisionId)
]
let url = components.url!

可以看到,URLComponents 是一个“半成品” URL,它经过一个组装过程后变成真的 URL。

提示

对于简单的、不需要构建过程的 URL,也可以直接用字符串构建,比如

swift
let url = URL(string: "https://forum.fduhole.com/api/divisions")

构建 HTTP 请求 ​

我们已经提供了分区的 ID 参数,接下来该提供发帖内容了。由于发帖内容可能很长,因此不适合放在 URL 参数里,通常会把它放到 HTTP Body 里。

swift
let content: String // 这个变量是发帖的内容
let token: String // 这个变量是鉴别身份用的 Token

let data = content.data(using: String.Encoding.utf8)!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = data

这里涉及到的概念是 URLRequest。

发送请求并获取响应 ​

swift
let (data, response) = try await URLSession.shared.data(for: request)
let httpResponse = response as! HTTPURLResponse
if httpResponse.statusCode != 200 {
	throw HTTPError()
}
// 下面可以继续使用 data

可以看到,发送请求后会返回两样东西,一个是 Data, 另一个是 URLResponse。

因为历史遗留问题,必须把 URLResponse 类型转换成 HTTPURLResponse 才能用。

HTTPURLResponse 相当于包含了响应中 HTTP Header 的信息,如 status code 和所有的 header field。

Data 就是返回的 body 了。要进一步使用 Data 需要将它解码,这会在另一个文档里讲。

旦夕对 HTTP 请求进行的封装 ​

可以看到,上述的流程总共需要十几行代码,这还没算上我们编码和解码 JSON 的部分,以及一些异常处理的部分。而我们有几十个 API,全都重复相似的代码没有必要。

requestWithData ​

我们把上述的代码封装了一下,做成了这个函数:

swift
func requestWithData(_ path: String, base: URL, protected: Bool = true, params: [String: String]? = nil, payload: [String: Any]? = nil, method: String? = nil) async throws -> Data

这个函数在代码里对各个参数的注释已经写得很明白了,我们这里再重复一遍:

  • path 请求的路径,附加到传入的 base URL 后构成完整的 URL,比如 "/courses"。

  • base 基础 URL,目前包括树洞、蛋壳和 auth 三个。

  • protected 控制是否加上 JWT 来认证用户身份。

  • params URL 里的 Query 参数。

  • payload 请求体,以字典传入,会自动编码成 JSON。

  • method HTTP 方法。如果没有传入则根据有无 payload 选择 GET 或 POST。

requestWithResponse ​

我们基本不需要裸的 Data,而是需要进行一个 JSON 解码处理,把它解码成 Hole, Floor, Tag 等数据对象。requestWithResponse 就是干这个的,它用泛型的方式来获取到需要什么类型,并调用 JSONDecoder 完成解码。

swift
func requestWithResponse<T: Decodable>(...) async throws -> T

方法里的参数和之前是一样的。

requestWithoutResponse ​

有些方法其实不需要服务器返回的数据,只需要知道成功执行了就好。但是如果函数提供了一个返回值,那么直接忽略它会导致编译警告,导致你不得不写这种逆天代码:

swift
_ = try await requestWithData(...)

为了大家的身心健康,我们封装了一个没有返回值的包装方法:

swift
func requestWithoutResponse(...) async throws

项目里是怎么写的? ​

项目中 API 相关代码的结构 ​

旦夕项目里,网络请求基本按照后端的微服务分类,大概分为以下几类:

  • 树洞服务,即 ForumAPI
  • 蛋壳服务,即 CurriculumAPI
  • 账户服务,即 GeneralAPI

每个 API 服务都是一个 enum,它在这里基本起到 namespace 的作用,防止函数之间的名字冲突。

swift
// 一个 API Collection 的例子
enum ForumAPI {
  static func listDivisions() async throws ... 
  static func deleteFloor() async throws ...
}

API 案例分析 ​

我们拿一些真实的代码来说明一下。

获取所有树洞里的分区(Division):

swift
public static func getDivisions() async throws -> [Division] {
    return try await requestWithResponse("/divisions", base: forumURL)
}

获取一个洞里的一些楼层,这里使用了 URL Query:

swift
public static func listFloorsInHole(holeId: Int, startFloor: Int, size: Int? = nil) async throws -> [Floor] {
    var params = ["offset": "\(startFloor)", "order_by": "id"]
    if let size {
        params["size"] = String(size)
    }
    return try await requestWithResponse("/holes/\(holeId)/floors", base: forumURL, params: params)
}

发布一条回复,这里使用了 HTTP Body:

swift
public static func createFloor(content: String, holeId: Int, specialTag: String = "") async throws -> Floor {
    let payload = ["content": content, "special_tag": specialTag]
    return try await requestWithResponse("/holes/\(holeId)/floors", base: forumURL, payload: payload)
}

进行登录操作。用户没有登录时显然没有 Token,所以这个 API 需要把 protected 设置成 false:

swift
public static func login(email: String, password: String) async throws -> Token {
    let payload = ["email": email, "password": password]
    return try await requestWithResponse("/login", base: authURL, protected: false, payload: payload)
}