Appearance
18网络层架构:如何设计网络访问与JSON数据解析?
为了存取服务器上的数据,并与其他用户进行通信,几乎所有的 iOS App 都会访问后台 API 。目前流行的后台 API 设计有几种方案: RESTful、gRPC、GraphQL 和 WebSocket。其中,gRPC 使用 Protobuf 进行数据传输, GraphQL 和 RESTful 往往使用 JSON 进行传输。
为了把访问后台 API 的网络传输细节给屏蔽掉,并为上层模块提供统一的访问接口,我们在架构 App 的时候,往往会把网络访问封装成一个独立的 Networking 模块。像我们的 Moments App 也不例外,它的这个模块负责访问 BFF,同时把返回的 JSON 数据进行解码。所以,这一讲,我主要介绍下 Networking 模块的架构设计与实现,以及如何使用 Swift 的 Codable 来解码返回的 JSON 数据。
Networking 模块架构
下图是朋友圈功能 Networking 模块的具体架构。
从上面的图可以看到,作为 Networking 模块的使用者,Repository 模块位于 Networking 模块的上层。在朋友圈功能里面, Repository 模块负责统一管理所有朋友圈的数据,由MomentsRepo
和MomentsRepoType
所组成。
其中MomentsRepoType
是用于定义接口的协议,实现的逻辑都在遵循了该协议的MomentsRepo
结构体里面。 当MomentsRepo
需要访问网络数据时,就需要使用到 Networking 模块的组件。
在朋友圈功能里面,MomentsRepo
使用了GetMomentsByUserIDSessionType
来获取朋友圈信息,并使用了UpdateMomentLikeSessionType
来更新点赞信息。
GetMomentsByUserIDSessionType
和UpdateMomentLikeSessionType
是 Networking 模块里的两个协议, 它们的实现类型分别是GetMomentsByUserIDSession
和UpdateMomentLikeSession
结构体。其中,GetMomentsByUserIDSession
通过访问 BFF 来读取朋友圈信息,而UpdateMomentLikeSession
通过 BFF 来更新点赞信息。当 BFF 返回时,它们都会使用JSONDecoder
来把返回的 JSON 数据解码成名为MomentsDetails
的 Model 数据。
那为什么MomentsRepo
依赖GetMomentsByUserIDSessionType
协议而不是GetMomentsByUserIDSession
结构体?因为这样能使MomentsRepo
依赖于抽象的接口,而不是具体实现,在 Swift 中,这种模式叫作面对协议编程(Protocol Oriented Programming)。使用了这种模式以后,我们可以很灵活地替换具体的实现类型,提高架构的可扩展性和灵活性。
目前,我们把访问 GraphQL 的技术细节封装在GetMomentsByUserIDSession
里面。假如以后需要把后台改成 gRPC API,在 Moments App 中可以实现另一个结构体来遵循GetMomentsByUserIDSessionType
协议,比如命名为GetMomentsByUserIDSessionGRPC
,然后把所有访问的 gRPC 的操作都封装在里面(如下图所示),这样我们在不改变MomentsRepo
的情况下就支持了新的网络 API。
Networking 模块实现
有了架构设计以后我们一起看看 Networking 模块的实现。首先,我会先介绍下底层 HTTP 网络通信模块,然后以 Moments App 朋友圈信息的网络请求为例,为你介绍下怎样开发一个网络请求模块,以及解码 JSON 返回数据。
底层 HTTP 网络通信模块
为了方便访问支持 RESTFul 和 GraphQL 的 API, 在 Moments App 中,我们开发了一个底层 HTTP 网络通信模块,该模块把所有 HTTP 请求封装起来,核心是APISession
协议。下面是它的定义。
swift
protocol APISession {
associatedtype ReponseType: Codable
func post(_ path: String, parameters: Parameters?, headers: HTTPHeaders) -> Observable<ReponseType>
}
APISession
定义了post(_ path: String, parameters: Parameters?, headers: HTTPHeaders) -> Observable<ReponseType>
方法来发起 HTTP POST 请求,然后返回Observable<ReponseType>
。有了 Observable 序列,我们就能把网络返回数据引进到以 RxSwift 所连接的 MVVM 框架中。
你可能问,为什么Observable
存放的是ReponseType
类型呢?由于APISession
并不知道每一个网络请求返回数据的具体类型,因此使用associatedtype
来定义ReponseType
,以迫使所有遵循它的实现类型都必须指定ReponseType
的具体数据类型。
例如在GetMomentsByUserIDSession
里面的Session
结构体,我们使用typealias
来指定ReponseType
的具体类型为Response
,其代码示例如下。
swift
typealias ReponseType = Response
为了方便共享 HTTP 网络请求的功能,我们为APISession
定义了协议扩展,并给post(_ path: String, parameters: Parameters?, headers: HTTPHeaders) -> Observable<ReponseType>
方法提供默认的实现。具体代码示例如下。
swift
extension APISession {
func post(_ path: String, headers: HTTPHeaders = [:], parameters: Parameters? = nil) -> Observable<ReponseType> {
return request(path, method: .post, headers: headers, parameters: parameters, encoding: JSONEncoding.default)
}
}
为了提高代码的可重用性,我们定义了名叫request(_ path: String, method: HTTPMethod, headers: HTTPHeaders, parameters: Parameters?, encoding: ParameterEncoding) -> Observable<ReponseType>
的私有方法,来支持 HTTP 的其他 Method,代码示例如下。
swift
private func request(_ path: String, method: HTTPMethod, headers: HTTPHeaders, parameters: Parameters?, encoding: ParameterEncoding) -> Observable<ReponseType> {
let url = baseUrl.appendingPathComponent(path)
let allHeaders = HTTPHeaders(defaultHeaders.dictionary.merging(headers.dictionary) { $1 })
return Observable.create { observer -> Disposable in
let queue = DispatchQueue(label: "moments.app.api", qos: .background, attributes: .concurrent)
let request = AF.request(url, method: method, parameters: parameters, encoding: encoding, headers: allHeaders, interceptor: nil, requestModifier: nil)
.validate()
.responseJSON(queue: queue) { response in
// 处理返回的 JSON 数据
}
return Disposables.create {
request.cancel()
}
}
}
有了request()
方法,我们就可以支持不同的 HTTP Method 了。如果需要支持 HTTP GET 请求的时候,只需把HTTPMethod.get
传递给该方法就可以了。
request()
方法的核心逻辑是怎么样的呢?在该方法里面,我们首先使用Observable.create()
方法来创建一个 Observable 序列并返回给调用者,然后在create()
方法的封包里使用 Alamofire 的request()
方法发起网络请求。为了不阻挡 UI 的响应,我们把该请求安排到后台队列中执行。当我们得到返回的 JSON 以后,会使用下面的代码进行处理。
swift
switch response.result {
case .success:
guard let data = response.data else {
// if no error provided by Alamofire return .noData error instead.
observer.onError(response.error ?? APISessionError.noData)
return
}
do {
let model = try JSONDecoder().decode(ReponseType.self, from: data)
observer.onNext(model)
observer.onCompleted()
} catch {
observer.onError(error)
}
case .failure(let error):
if let statusCode = response.response?.statusCode {
observer.onError(APISessionError.networkError(error: error, statusCode: statusCode))
} else {
observer.onError(error)
}
}
其逻辑是,当网络请求成功了,就把返回的 JSON 数据通过JSONDecoder
解码成ReponseType
类型,并通过onNext
方法发送到 Observable 序列中,接着调用onCompleted
方法来关闭数据流;如果发生网络错误,就通过onError
方法来发送错误事件。
请求朋友圈信息模块
有了底层 HTTP 网络通信模块以后,我们来看看怎样开发一个网络请求模块。
在 Moments App 中,为了分离责任和方便管理,我们为每一个网络请求都定义了一个协议以及对应的实现结构体。在我们的例子中,它们分别是GetMomentsByUserIDSessionType
协议和GetMomentsByUserIDSession
结构体。
其中GetMomentsByUserIDSessionType
协议的定义如下。
swift
protocol GetMomentsByUserIDSessionType {
func getMoments(userID: String) -> Observable<MomentsDetails>
}
该协议只定义了一个getMoments(userID: String) -> Observable<MomentsDetails>
方法来提供访问朋友圈信息的接口。因为每个用户的朋友圈信息都不一样,我们需要把用户 ID 传递给该方法,并返回包含了MomentsDetails
的 Observable 序列。
接下来看看GetMomentsByUserIDSession
结构体的实现。因为GetMomentsByUserIDSession
遵循了etMomentsByUserIDSessionType
协议,因此必须实现来自该协议的getMoments(userID: String) -> Observable<MomentsDetails>
方法。具体实现如下所示。
swift
func getMoments(userID: String) -> Observable<MomentsDetails> {
let session = Session(userID: userID)
return sessionHandler(session).map {
$0.data.getMomentsDetailsByUserID }
}
该方法通过sessionHandler
来获取网络请求的结果。其中sessionHandler
是一个封包,它接收了类型为Session
的入口参数,我们可以在init
方法里面看到sessionHandler
的具体实现,如下所示:
swift
init(togglesDataStore: TogglesDataStoreType = InternalTogglesDataStore.shared, sessionHandler: @escaping (Session) -> Observable<Response> = {
$0.post($0.path, headers: $0.headers, parameters: $0.parameters)
}) {
self.togglesDataStore = togglesDataStore
self.sessionHandler = sessionHandler
}
其中$0
表示入口参数Session
的对象, 由于Session
遵循了APISession
协议,它可以直接调用APISession
的扩展方法post
来发起 HTTP POST 请求,并获取类型为Response
的返回值。
那返回值Response
的类型是怎样定义出来的呢?其实它的定义来自 BFF 返回值 JSON 的数据结构,该 JSON 包含了data
属性,data
下有一个getMomentsDetailsByUserID
属性,具体数据结构如下。
javascript
{
"data": {
"getMomentsDetailsByUserID": {
// MomentsDetails object
"userDetails": {...},
"moments": [...]
}
}
}
我们可以根据 JSON 的数据结构来定义 Swift 的Response
类型,它也有类型为Data
的data
属性。在Data
类型下还包含了类型为MomentsDetails
的getMomentsDetailsByUserID
属性。
swift
struct Response: Codable {
let data: Data
struct Data: Codable {
let getMomentsDetailsByUserID: MomentsDetails
}
}
为了把 Observable 序列的类型从Response
转换成MomentsDetails
类型,我们在getMoments
方法里调用了转换操作符map { $0.data.getMomentsDetailsByUserID }
从Response
里抽出getMomentsDetailsByUserID
进行返回。
接着我们看看Session
结构体的具体实现。 该结构体负责准备 GraphQL 请求的数据,这些数据包括 URL 路径、HTTP 头和参数。URL 路径比较简单,是一个值为/graphql
的常量。HTTP 头也是一个默认的HTTPHeaders
对象。最重要的数据是类型为Parameters
的parameters
属性。我们通过init
方法来看看该属性是怎样进行初始化的。它的实现代码如下所示。
swift
init(userID: String) {
let variables: [AnyHashable: Encodable] = ["userID": userID]
parameters = ["query": Self.query,
"variables": variables]
}
首先我们把传递进来的userID
存放到类型为[AnyHashable: Encodable]
的variables
变量里面,然后把它与query
属性一同赋值给parameters
。
那么query
是怎样来的呢?因为所有的 GraphQL 的请求都需要发送 Query,在朋友圈信息请求的例子也不例外,query
属性就是用于定义要发送的 Query 的,其定义如下。
swift
private static let query = """
query getMomentsDetailsByUserID($userID: ID!) {
getMomentsDetailsByUserID(userID: $userID) {
userDetails {
id
name
avatar
backgroundImage
}
moments {
id
userDetails {
name
avatar
}
type
title
photos
createdDate
}
}
}
"""
}
在该 Query 定义中,我们定义了类型为ID!
的入口参数$userID
,同时定义了返回值的数据结构,例如返回getMomentsDetailsByUserID
,在它里面包含userDetails
和moments
两个属性。在开发过程中我们往往要经常调试 Query,你可以使用 GraphiQL 工具来进行调试。你可以在 Moments App 的 BFF来尝试调试上面的 Query,执行效果如下。
在此,我们已经讲完Session
的实现了,有了 URL 路径,HTTP 头和参数。sessionHandler
就可以使用它来发起 HTTP POST 请求。具体调用如下所示。
swift
session.post(session.path, headers: session.headers, parameters: session.parameters)
解码 JSON 返回数据
当我们从 BFF 取得 JSON 返回数据的时候,需要把它解析为 Swift Model 来引入 MVVM 架构里面。那怎样才能把 JSON 数据解码成 Model 类型MomentsDetails
呢?
这要从返回 JSON 的数据结构入手。JSON 返回结果是由上面的 Query 定义所决定的,在getMomentsDetailsByUserID
对象里面包含了userDetails
和moments
属性,具体的 JSON 如下。
javascript
{
"userDetails": {
"id": "0",
"name": "Jake Lin",
"avatar": "https://avatar-url",
"backgroundImage": "https://background-image-url"
},
"moments": [
{
"id": "0",
"userDetails": {
"name": "Taylor Swift",
"avatar": "https://another-avatar-url"
},
"type": "PHOTOS",
"title": null,
"photos": [
"https://photo-url"
],
"createdDate": "1615899003"
}
]
}
有了 JSON 数据结构,我们就可以定义一个 Swift 的 Model 来进行映射,例如把该 Model 命名为MomentsDetails
。它也包含了userDetails
和moments
两个属性,但我们没办法从 JSON 中看出来,所幸 GraphQL 为我们提供了 Schema ,它可以描述各个数据的具体类型。
下面是MomentsDetails
及其子类型的 Schema 定义。其中MomentsDetails
包含了userDetails
和moments
两个属性 ,userDetails
为非空的UserDetails
类型。而moments
的类型是包含非空的Moment
数组,同样地,该数组自己也不能为空。具体定义如下所示。
typescript
type MomentsDetails {
userDetails: UserDetails!
moments: [Moment!]!
}
type Moment {
id: ID!
userDetails: UserDetails!
type: MomentType!
title: String
url: String
photos: [String!]!
createdDate: String!
}
type UserDetails {
id: ID!
name: String!
avatar: String!
backgroundImage: String!
}
enum MomentType {
URL
PHOTOS
}
有了上面的 GraphQL Schema,加上 JSON 数据结构,我们可以完成MomentsDetails
的映射。
swift
struct MomentsDetails: Codable {
let userDetails: UserDetails
let moments: [Moment]
}
具体做法是把 GraphQL 中的type
映射成struct
,然后每个属性都使用let
来定义成常量。在 GraphQL 中,!
符合表示非空类型,因此在 Swift 中也使用非空类型。在我们的例子中userDetails
属性的类型为非空的UserDetails
,而moments
是Moment
类型的数组。下面是UserDetails
类型的定义,它有id
,name
等属性。
swift
struct UserDetails: Codable {
let id: String
let name: String
let avatar: String
let backgroundImage: String
}
接着我们看看Moment
类型定义。
swift
struct Moment: Codable {
let id: String
let userDetails: MomentUserDetails
let type: MomentType
let title: String?
let url: String?
let photos: [String]
let createdDate: String
struct MomentUserDetails: Codable {
let name: String
let avatar: String
}
enum MomentType: String, Codable {
case url = "URL"
case photos = "PHOTOS"
}
}
Moment
类型包含了id
,title
,userDetails
和type
等属性。其中title
在 GraphQL 中 Schema 里面没有定义为!
,表示这个属性可能为空,当我们映射成 Swift 类型时使用了?
来表示这个属性是可空类型(Optional)。userDetails
属性的类型是一个嵌套类型MomentUserDetails
,我推荐把所有的子类型都内嵌到父类型里面,这样能把所有的类型定义统一封装在MomentsDetails
里面,访问的时候就有命名空间。
最后我们看一下type
属性,它在 GraphQL 里的定义是一个枚举。我们把它映射为类型是MomentType
的一个枚举。由于 GraphQL 会通过字符串来传输enum
,当我们在 Swift 中映射成枚举类型时,需要把该enum
定义为字符串类型,并为每一个case
都指定需要映射的字符串值。例如我们给url
指定为"URL"
。
为了让 Swift 帮我们进行自动的解码与编码,我们把所有所有类型都遵守了Codable
协议,下面是Codable
协议的定义。
swift
public typealias Codable = Decodable & Encodable
Codable
其实是Decodable
和Encodable
两个协议合体,一个类型遵循了Codable
表示该类型同时遵循Decodable
和Encodable
两个协议。如下图所示,因为 BFF 返回的是 JSON 数据,我们可以使用JSONDecoder
把 JSON 数据解码成 Swift 的 Model 类型,反过来,我们可以使用JSONEncoder
把 Swift 的 Model 编码成 JSON 数据。
在 Swift 4 之前,我们需要使用JSONSerialization
来反序列化 JSON 数据,然后把每一个属性单独转换成所需的类型。后来出现 SwiftyJSON 等库,帮我们减轻了一部分 JSON 转型工作,但还是需要大量手工编码来完成映射。
Swift 4 以后,出现了Codable
协议,我们只需要把所定义的 Model 类型遵守该协议,Swift 在调用JSONDecoder
的decode
方法时就能自动完成转型。这样既能减少编写代码的数量,还能获得原生的性能。以下是APISession
里面转换 JSON 到 Model 类型的代码:
swift
let model = try JSONDecoder().decode(ReponseType.self, from: data)
我们只需要把转换的 Model 类型告诉decode
方法即可。为了处理转换失败的情况,我们使用了try
语句。当转型失败时,它会返回nil
,使得我们的程序不会崩溃。
这里有一个技巧,假如你在开发中转型失败了,可以把 Model 定义的一部分属性先注释起来,找出引起转型失败的那个属性;然后,通过 GraphQL Schema 来检查该属性的数据类型,并判断该属性能否为空,最后根据 Schema 的定义来修改转型失败的属性。
总结
至此,我们就有了一个开发网络模块的模板,下面我来总结一下开发网络模块的具体流程。
根据 BFF 返回的 JSON 数据以及 GraphQL 的 Schema ,定义 Model 的数据类型,请记住所有类型都需要遵循
Codable
协议。定义一个网络请求的协议,并提供一个请求的方法,该方法需要接收请求所需的所有参数,并返回包含 Model 类型的 Observable 序列。这样上层模块就能使用响应式编程的方式来处理网络请求的结果了。
遵循上述的协议并实现一个网络请求的结构体。在该结构体里定义一个遵循了
APISession
协议的Session
结构体,并在Session
结构体内定义发送给 GraphQL 的query
属性,我们可以通过 GraphiQL 工具来测试 Query 的定义。
思考题:
请问在你们项目中是如何解析网络返回的 JSON 数据呢?能否分享一下经验。
可以把你的答案写得留言区哦,下一讲我将介绍如何使用仓库模式设计数据存储层。
源码地址:
底层 HTTP 网络通信模块:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Foundations/Networking
请求朋友圈信息模块:https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Features/Moments/Networking