|
1 | | -# API、缓存与对象 <Badge type="warning" text="已完成,尚未搬运" /> |
| 1 | +# API、缓存与实体类 |
| 2 | + |
| 3 | +当我们看 Fudan Kit 包的代码结构的时候,目录结构是比较清晰的,每个目录代表一项服务。如校车对应 `Bus`,教务对应 `Course` 等。每个服务都有一个 |
| 4 | + |
| 5 | +展开这些目录时,会发现有很多代码文件,被命名为 `*API, *Entity, *Store`,如 `BusAPI.swift`, `BusEntity.swift`, `BusStore.swift`。这三个文件分别代表了 API、实体类和缓存。 |
| 6 | + |
| 7 | +它们的关系很直接:API 层负责封装校园网站上提供的各类信息接口,Store 层负责缓存这些信息并向上层业务提供,Entity 负责建模业务中涉及到的各项实体。 |
| 8 | + |
| 9 | +## API 层和解析数据常用的工具箱 |
| 10 | + |
| 11 | +API 层的核心任务是从校园网站上获取数据,并将其解析为我们需要的格式。这些 API 方法被放置在一个 `enum` 中作为命名空间。以下例说明: |
| 12 | + |
| 13 | +```swift |
| 14 | +enum ReservationAPI { |
| 15 | + static func getPlaygrounds() async throws -> [Playground] |
| 16 | + static func getReservations(playground: Playground, date: Date) async throws -> [Reservation] |
| 17 | +} |
| 18 | +``` |
| 19 | + |
| 20 | +调用时,调用 `ReservationAPI.getPlaygrounds()` 即可获取所有可预约的场馆。 |
| 21 | + |
| 22 | +API 层需要解析复杂的 HTML 或 JSON 数据,并将其转换为 Entity 中结构明确的 `struct` 并呈现给用户。 |
| 23 | + |
| 24 | +:::info |
| 25 | +这部分功能的开发没有固定的模式,需要具体查看对应页面的结构。这个过程中需要频繁用到浏览器开发者工具或者抓包软件,和比较丰富的经验。毕竟我们没有学校官方提供的数据接口。 |
| 26 | +::: |
| 27 | + |
| 28 | +### 网络请求 |
| 29 | + |
| 30 | +:::tip |
| 31 | +如果没有 Swift 网络标准库的基础,可以先阅读 [网络基本 API](/dev/danxi-kit/api) 一节。 |
| 32 | +::: |
| 33 | + |
| 34 | +我们为校园服务设置了专门的 `URLSession`: |
| 35 | + |
| 36 | +```swift |
| 37 | +extension URLSession { |
| 38 | + static let campusSession = URLSession(configuration: .default) |
| 39 | +} |
| 40 | +``` |
| 41 | + |
| 42 | +并封装了两个常用的构造 `URLRequest` 的方法: |
| 43 | + |
| 44 | +```swift |
| 45 | +func constructRequest(_ url: URL, payload: Data? = nil, method: String? = nil) -> URLRequest |
| 46 | +func constructFormRequest(_ url: URL, method: String = "POST", form: [String: String]) -> URLRequest |
| 47 | +``` |
| 48 | + |
| 49 | +这些方法会设置请求的 `User-Agent` 字段,后者还会自动将字典转换为 URL-encoded form,用起来比较方便。 |
| 50 | + |
| 51 | +### HTML 解析 |
| 52 | + |
| 53 | +对于有写爬虫经验的同学,Python 中的 `BeautifulSoup` 应该不陌生。在 Swift 中,我们使用 `SwiftSoup` 来解析 HTML。这里建议读一读它们的官方 [入门文档](https://scinfu.github.io/SwiftSoup/),还是比较简短的。 |
| 54 | + |
| 55 | +下面我们来看一个使用 SwiftSoup 解析 HTML 的例子: |
| 56 | + |
| 57 | +```swift |
| 58 | +let html = """ |
| 59 | +<html><body> |
| 60 | +<p class='message'>SwiftSoup is powerful!</p> |
| 61 | +<p class='message'>Parsing HTML in Swift</p> |
| 62 | +</body></html> |
| 63 | +""" |
| 64 | + |
| 65 | +let document: Document = try SwiftSoup.parse(html) |
| 66 | +let messages: Elements = try document.select("p.message") |
| 67 | +for message in messages { |
| 68 | + // message: Element |
| 69 | + let text: String = try message.text() |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +这里我们为了让大家理解,明确标注了各个变量的类型。可以看到 SwiftSoup 中的核心概念包括 `Document`、`Elements` 和 `Element`。我们可以用 CSS 选择器等各种方式检索元素。实际应用中,CSS 选择器通常已经足够好用了。 |
| 74 | + |
| 75 | +为了减少重复代码,我们封装了几个常用方法,可以应付多数情况: |
| 76 | + |
| 77 | +```swift |
| 78 | +func existHTMLElement(_ data: Data, selector: String) -> Bool |
| 79 | +func decodeHTMLDocument(_ data: Data) throws -> Document |
| 80 | +func decodeHTMLElement(_ data: Data, selector: String) throws -> Element |
| 81 | +func decodeHTMLElementList(_ data: Data, selector: String) throws -> Elements |
| 82 | +``` |
| 83 | + |
| 84 | +它们的名字足够直接,因此就不进一步解释了。 |
| 85 | + |
| 86 | +### JSON 解析 |
| 87 | + |
| 88 | +Swift 内置的 `Codable` 协议可以帮助我们解析 JSON 数据,但是它对类型要求严格,不太适用于复杂的 JSON 结构。因此我们使用 `SwiftyJSON` 来解析 JSON 数据。 |
| 89 | + |
| 90 | +`SwiftyJSON` 的使用方法也很简单,我们可以直接将 `Data` 转换为 `JSON` 对象,然后使用下标访问: |
| 91 | + |
| 92 | +```swift |
| 93 | +let json = try JSON(data: data) |
| 94 | +json["message"].string |
| 95 | +json["code"].int |
| 96 | +json["subtype"].rawData() |
| 97 | +// ... |
| 98 | +``` |
| 99 | + |
| 100 | +学校提供的大部分 JSON API 有以下格式: |
| 101 | + |
| 102 | +```json |
| 103 | +{ |
| 104 | + "e": 0, |
| 105 | + "m": "error message", |
| 106 | + "d": { |
| 107 | + // actual data |
| 108 | + } |
| 109 | +} |
| 110 | +``` |
| 111 | + |
| 112 | +其中 `e` 代表错误码(0即无错误),`m` 代表错误信息,`d` 代表数据。我们封装了以下函数来处理。如果检测到错误会直接抛出。 |
| 113 | + |
| 114 | +```swift |
| 115 | +func unwrapJSON(_ data: Data) throws -> JSON |
| 116 | +``` |
| 117 | + |
| 118 | +## Store |
| 119 | + |
| 120 | +Store 层负责将收到的数据缓存起来,避免重复请求。部分 Store 层的数据是存储在磁盘上的,其他则只在内存中存储。 |
| 121 | + |
| 122 | +Store 层通常有以下方法(具体名字可能有出入): |
| 123 | +- `getCachedData`:获取缓存的数据 |
| 124 | +- `getRefreshedData`:获取最新的数据,并刷新缓存 |
| 125 | +- `clearCache`:清除缓存(在用户退出登录时调用) |
| 126 | + |
| 127 | +:::warning |
| 128 | +如果存在 Store 层,UI 层不应该绕过 Store 层直接调用 API 层的数据。 |
| 129 | +::: |
| 130 | + |
| 131 | +## Entity |
| 132 | + |
| 133 | +Entity 结尾的文件会提供该服务中所有涉及到的实体。通常建议先阅读这个文件,它可以帮助你理清这个服务模块的业务逻辑和核心概念。 |
| 134 | + |
| 135 | +以场馆预约 `ReservationEntity` 为例: |
| 136 | + |
| 137 | +```swift |
| 138 | +struct Playground: Identifiable, Codable, Hashable { |
| 139 | + let id: String |
| 140 | + let name: String |
| 141 | + let campus: String |
| 142 | + let category: String |
| 143 | +} |
| 144 | + |
| 145 | +struct Reservation: Identifiable, Codable { |
| 146 | + let id: UUID |
| 147 | + let name: String |
| 148 | + let begin, end: Date |
| 149 | + let reserved, total: Int |
| 150 | +} |
| 151 | +``` |
| 152 | + |
| 153 | +其中 `Playground` 代表一个可预约的场馆,而 `Reservation` 代表一个可预约的时间段。 |
| 154 | + |
| 155 | +:::info |
| 156 | +有时服务器返回的信息结构很乱,为了方便处理,我们会在 API 层定义一些辅助结构体,用于解析服务器返回的数据,并将其转换为 Entity 层的实体。 |
| 157 | + |
| 158 | +当服务器返回的数据结构需要进一步处理时,如将 `String` 转换为 `Date`、将部分字段重新命名等,我们会在 API 层进行处理,而不是在 Entity 层。我们会将此类结构用 Response 作为命名后缀。参见以下例子: |
| 159 | + |
| 160 | +```swift |
| 161 | +// In BusAPI: |
| 162 | +struct ScheduleResponse: Codable { |
| 163 | + let id: String |
| 164 | + let start: String |
| 165 | + let end: String |
| 166 | + let stime: String |
| 167 | + let etime: String |
| 168 | + let arrow: String |
| 169 | + let holiday: String |
| 170 | +} |
| 171 | + |
| 172 | +// In BusEntity |
| 173 | +struct Schedule: Identifiable, Codable { |
| 174 | + let id: Int |
| 175 | + let time: Date |
| 176 | + let start, end: String |
| 177 | + let holiday: Bool |
| 178 | + let bidirectional: Bool |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +还有一个场景,是需要将多个服务器返回的数据结构合并为一个。我们同样在 API 层进行处理,以 Builder 作为命名后缀。参见以下例子: |
| 183 | + |
| 184 | +```swift |
| 185 | +struct CourseBuilder { |
| 186 | + let name, code, teacher, location: String |
| 187 | + let weekday: Int |
| 188 | + var start, end: Int |
| 189 | + var onWeeks: [Int] = [] |
| 190 | + |
| 191 | + func build() -> Course { |
| 192 | + Course(id: UUID(), name: name, code: code, teacher: teacher, location: location, weekday: weekday - 1, start: start, end: end, onWeeks: onWeeks) |
| 193 | + } |
| 194 | +} |
| 195 | +``` |
| 196 | + |
| 197 | +这个例子中,如果有同一个课程跨越几个时间段,如高等数学课在周四第2和第3节课都要上课,那么服务器会返回2节课,我们要将它合并为同一节课,就可以调用 `courseBuilder.build()`。 |
| 198 | + |
| 199 | + |
| 200 | +::: |
0 commit comments