Skip to content

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 模块负责统一管理所有朋友圈的数据,由MomentsRepoMomentsRepoType所组成。

其中MomentsRepoType是用于定义接口的协议,实现的逻辑都在遵循了该协议的MomentsRepo结构体里面。 当MomentsRepo需要访问网络数据时,就需要使用到 Networking 模块的组件。

在朋友圈功能里面,MomentsRepo使用了GetMomentsByUserIDSessionType来获取朋友圈信息,并使用了UpdateMomentLikeSessionType来更新点赞信息。

GetMomentsByUserIDSessionTypeUpdateMomentLikeSessionType是 Networking 模块里的两个协议, 它们的实现类型分别是GetMomentsByUserIDSessionUpdateMomentLikeSession结构体。其中,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类型,它也有类型为Datadata属性。在Data类型下还包含了类型为MomentsDetailsgetMomentsDetailsByUserID属性。

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对象。最重要的数据是类型为Parametersparameters属性。我们通过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,在它里面包含userDetailsmoments两个属性。在开发过程中我们往往要经常调试 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对象里面包含了userDetailsmoments属性,具体的 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。它也包含了userDetailsmoments两个属性,但我们没办法从 JSON 中看出来,所幸 GraphQL 为我们提供了 Schema ,它可以描述各个数据的具体类型。

下面是MomentsDetails及其子类型的 Schema 定义。其中MomentsDetails包含了userDetailsmoments两个属性 ,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,而momentsMoment类型的数组。下面是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,userDetailstype等属性。其中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其实是DecodableEncodable两个协议合体,一个类型遵循了Codable表示该类型同时遵循DecodableEncodable两个协议。如下图所示,因为 BFF 返回的是 JSON 数据,我们可以使用JSONDecoder把 JSON 数据解码成 Swift 的 Model 类型,反过来,我们可以使用JSONEncoder把 Swift 的 Model 编码成 JSON 数据。

在 Swift 4 之前,我们需要使用JSONSerialization来反序列化 JSON 数据,然后把每一个属性单独转换成所需的类型。后来出现 SwiftyJSON 等库,帮我们减轻了一部分 JSON 转型工作,但还是需要大量手工编码来完成映射。

Swift 4 以后,出现了Codable协议,我们只需要把所定义的 Model 类型遵守该协议,Swift 在调用JSONDecoderdecode方法时就能自动完成转型。这样既能减少编写代码的数量,还能获得原生的性能。以下是APISession里面转换 JSON 到 Model 类型的代码:

swift
let model = try JSONDecoder().decode(ReponseType.self, from: data)

我们只需要把转换的 Model 类型告诉decode方法即可。为了处理转换失败的情况,我们使用了try语句。当转型失败时,它会返回nil,使得我们的程序不会崩溃。

这里有一个技巧,假如你在开发中转型失败了,可以把 Model 定义的一部分属性先注释起来,找出引起转型失败的那个属性;然后,通过 GraphQL Schema 来检查该属性的数据类型,并判断该属性能否为空,最后根据 Schema 的定义来修改转型失败的属性。

总结

至此,我们就有了一个开发网络模块的模板,下面我来总结一下开发网络模块的具体流程。

  1. 根据 BFF 返回的 JSON 数据以及 GraphQL 的 Schema ,定义 Model 的数据类型,请记住所有类型都需要遵循Codable协议。

  2. 定义一个网络请求的协议,并提供一个请求的方法,该方法需要接收请求所需的所有参数,并返回包含 Model 类型的 Observable 序列。这样上层模块就能使用响应式编程的方式来处理网络请求的结果了。

  3. 遵循上述的协议并实现一个网络请求的结构体。在该结构体里定义一个遵循了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