如何使用异步方法构造一个简易的网络请求库

185 阅读7分钟

如何使用异步方法构造一个简易的网络请求库

接下来我们会完成以下的功能:

  1. 异步接收网络数据

  2. 将网络数据进行解析成对应的model

    • 解析成json格式
    • 解析成xml格式(使用XMLParser进行解析)
  1. 存储到数据库 or 展示成列表

具体的代码可以在这个项目中查看: DJGithub

构造一个异步的网络数据请求类

我们查看Apple的文档,会发现苹果在URLSession中提供了一个异步加载网络数据的方法:

/// Convenience method to load data using an URLRequest, creates and resumes an URLSessionDataTask internally.
    ///
    /// - Parameter request: The URLRequest for which to load data.
    /// - Parameter delegate: Task-specific delegate.
    /// - Returns: Data and response.
    public func data(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)
​
/// Convenience method to load data using an URL, creates and resumes an URLSessionDataTask internally.
    ///
    /// - Parameter url: The URL for which to load data.
    /// - Parameter delegate: Task-specific delegate.
    /// - Returns: Data and response.
    public func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)

因此我们只需要定义一个方法,输入对应的request或者URL就可以了,如下

func model<T: DJCodable>(with router: Router, decoder: any Parsable = DJJSONParser<T>()) async throws -> T? {
    guard let request = router.asURLRequest() else { throw DJError.requestError }
    do {
      let (data, response) = try await URLSession.shared.data(for: request)
      if let httpReponse = response as? HTTPURLResponse, httpReponse.statusCode != 200 {
        let dict = ["statusCode": httpReponse.statusCode]
        return try DJDecoder(dict: dict).decode()
      }
      return try await decoder.parse(with: data) as? T
    } catch {
      router.printDebugInfo(with: error)
      throw DJError.dataError
    }
  }
  1. DJCodable:model必须要遵循的协议,这里我试用的是苹果自带的,Codable协议
  1. Router:里面会包含所有的网络请求需要的信息,它也是一个协议,定义如下:
enum HTTPMethod: String {
  case GET
  case POST
  case PUT
  case DELETE
  case PATCH
}
​
protocol Router: URLRequestConvertible {
  var baseURLString: String { get }
  var method: HTTPMethod { get }
  var headers: [String: String] { get }
  var parameters: [String: Any] { get }
  var path: String { get }
}

然后我们定一个遵循这个协议的enum,用来区分不同的request,以请求github相关的方法为例:

enum GithubRouter: Router {
  case userInfo(String) // 获取用户信息
  
   var baseURLString: String {
    return "https://api.github.com/"
  }
  
  var method: HTTPMethod {
    // 这里可以设置不同的request对应的httpmethod
    switch self {
      default: return .GET
    }
  }
  
  var path: String {
    switch self {
      case .userInfo(let userName): return return "users/(userName)"
    }
  }
  
  var headers: [String : String] {
    return [
      "Accept": "application/vnd.github+json"
    ]
  }
  
  var parameters: [String : Any] {
    // 给一些需要在body中携带数据的网络请求准备的
    switch self {
    default: return [:]
    }
  }
  
  func asURLRequest() -> URLRequest? {
    func asURLRequest() -> URLRequest? {
      var queryItems: [String: String] = [:]
​
        // 这里可以设置url后需要携带的参数信息
      switch self {
        default: break
      }
      var request = configURLRequest(with: queryItems)
      // 因为github一些接口需要携带用户的Authorization信息,所以在这里可以设置。
      request?.setValue(ConfigManager.config.authorization, forHTTPHeaderField: "Authorization")
      if !parameters.isEmpty {
        switch method {
        case .POST, .PATCH: request?.httpBody = try? JSONSerialization.data(withJSONObject: parameters)
        default: break
        }
      }
      return request
    }
      var request = configURLRequest(with: queryItems)
      request?.setValue(ConfigManager.config.authorization, forHTTPHeaderField: "Authorization")
      if !parameters.isEmpty {
        switch method {
        case .POST, .PATCH: request?.httpBody = try? JSONSerialization.data(withJSONObject: parameters)
        default: break
        }
      }
      return request
  }
}

最后我们通过组装上面的代码,就可以非常简单的定义一个网络请求了:

// 定义一个用来接收数据的model
struct User: DJCodable {
  var login: String
  var name: String?
  var id: Int
  var bio: String?
  var avatarUrl: String
  var type: String
  var createdAt: String
​
  var followers: Int
  var following: Int
  var publicRepos: Int
  var reposUrl: String
  var followingUrl: String
  var followersUrl: String
  
  var company: String?
  var location: String?
  var email: String?
  var blog: String?
  
  var desc: String {
    return isEmpty(by: self.bio) ? "No description provided." : self.bio!
  }
}
​
let router = GithubRouter.userInfo("dyljqq")
let user: User = try? await APIClient.shared.data(with: router)
print("user: (user)")

如何解析网络数据成对应的model

如果网络数据返回的都是JSON数据,那我们可以自己定义一个JSON解析器,就可以一劳永逸了。如下:

class DJJSONParser<T: DJCodable>: JSONDecoder, Parsable {
  
  typealias DataType = T
  
  override init() {
    super.init()
    self.keyDecodingStrategy = .convertFromSnakeCase
  }
  
  func parse(with data: Data?) async throws -> T? {
    guard let data = data else { return nil }
    do {
      return try self.decode(T.self, from: data)
    } catch {
      throw DJError.parseError("(error)")
    }
  }
}

当然,大家可能注意到了,DJJSONParser这个类,遵循了一个名为Parsable的协议。这个协议非常简单,定义如下:

protocol Parsable {
  
  associatedtype DataType: DJCodable
  
  func parse(with data: Data?) async throws -> DataType?
}

这样如果后面有不同的数据类型,我们都可以构造一个遵循这个协议的解析器。这样,解析网络数据的时候,我们只需要做如下的操作就行了:

return try await decoder.parse(with: data) as? T

那么我们如何解析一个XML数据么?XML的数据相对于JSON数据会复杂一些。我这里选用了苹果自带的XMLParse去做解析。

使用如下:

let parser = XMLParser(data: data)
parser.delegate = self
parser.parse()
​
// delegate中有对数据做对应的处理的委托方法。// 这里我们定义一个类来存储对应的数据
class XMLNode {
  var key: String = ""
  var value: String = ""
  var isEnd = false
  var isRoot = false
  var attributeDict: [String: String] = [:]
  var nodes: [XMLNode] = []
  
  var dict: [String: Any] = [:]
  
  init(with key: String) {
    self.key = key
  }
  
  func parseNodes() {
    for node in nodes {
      if node.nodes.isEmpty {
        if node.value.isEmpty {
          dict[node.key] = node.attributeDict
        } else {
          dict[node.key] = node.value
        }
      } else {
        node.parseNodes()
        if let value = dict[node.key] {
          if let items = value as? [[String: Any]] {
            dict[node.key] = items + [node.dict]
          } else {
            dict[node.key] = [value, node.dict]
          }
        } else {
          dict[node.key] = node.dict
        }
      }
    }
  }
}
​
// 以解析一个简单的xml为例:
// <description>zhangferry,摸鱼周报,iOS,Swift,生活记录</description>
// 开始解析数据 解析出了elementName=description
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
  // <atom:link href="https://zhangferry.com/atom.xml" rel="self" type="application/rss+xml"/>
 // attributes attributeDict: [String : String] 存储的是href="https://zhangferry.com/atom.xml" rel="self" type="application/rss+xml"标签里的值.
    let node = XMLNode(with: elementName)
    if stack.isEmpty {
      node.isRoot = true
    }
    if !attributeDict.isEmpty {
      node.attributeDict = attributeDict
    }
    stack.append(node)
  }
​
  // 解析标签数据内容
// 这个方法会不断调用,直到“zhangferry,摸鱼周报,iOS,Swift,生活记录”数据解析完毕
  func parser(_ parser: XMLParser, foundCharacters string: String) {
    let data = string.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: .newlines)
    if let topNode = stack.last {
      topNode.value = topNode.value + data
    }
  }
​
// 当解析标签结束时
// 解析道</description>时, elementName = description
  func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
    if let topNode = stack.last, topNode.key == elementName {
      let node = stack.removeLast()
      node.isEnd = true
      if node.isRoot {
        rootNode = node
      }
      if let parentNode = stack.last {
        parentNode.nodes.append(node)
      }
    }
  }
  
// 文档解析完成时
  func parserDidEndDocument(_ parser: XMLParser) {
    rootNode?.parseNodes()
    guard var dict = rootNode?.dict else { return }
    /**
    目前已知的xml格式如下:
     format 1:
     <Feed>
            <channel>
                        // data
            </channel>
     </Feed>
     
     format 2:
     <Feed>
      // data
     </Feed>
     */
    if let channel = dict["channel"] as? [String: Any] {
      dict = channel
    }
    do {
      let model = try DJDecoder(dict: dict).decode() as T?
      continuation?.resume(returning: model)
    } catch {
      print("json parse error: (error)")
    }
  }
​
// 这样通过这个方法就可以将上面的信息串联起来了。
func parse(with data: Data?) async throws -> T? {
    guard let data = data else { return nil }
    do {
      return try await withCheckedThrowingContinuation { [weak self] (continuation: CheckedContinuation<T?, Error>) in
        self?.continuation = continuation
        let parser = XMLParser(data: data)
        parser.delegate = self
        parser.parse()
      }
    } catch {
      print("parse error: (error)")
      return nil
    }
  }

以上就构造了一个比较通用的XML解析的类。那么我们怎么使用呢?以摸鱼周报的atom链接为例:

摸鱼周报

func data<T: DJCodable>(with urlString: String, decoder: any Parsable = DJJSONParser<T>()) async throws -> T? {
  do {
    guard let url = URL(string: urlString) else { return nil }
    let (data, _) = try await URLSession.shared.data(from: url)
    return try? await decoder.parse(with: data) as? T
  } catch {
    print("url session debug info:")
    print("fetch error: (urlString), error: (error)")
    print("------------------------")
  }
  return nil
}
​
let urlString = "https://zhangferry.com/atom.xml"
let rssFeedInfo: RssFeedInfo? = try? await data(with: urlString, decoder: DJXMLParser<RssFeedInfo>())
print("rssFeedInfo: (rssFeed.entries)")

存储到数据库 or 展示成列表

定义一个SQLTable的协议,来完成model与数据库中字段的对应:

protocol SQLTable {
    
  static var tableName: String { get }
  static var fields: [String] { get }
  static var uniqueKeys: [String] { get }
  static var fieldsTypeMapping: [String: FieldType] { get }
  static var needFieldId: Bool { get }
  static var selectedFields: [String] { get }
​
  var fieldsValueMapping: [String: Any] { get }
  
  func execute()
      
  static func decode<T: DJCodable>(_ hash: [String: Any]) -> T?
    
}

定义一个名为RssFeedInfo的结构体:

struct RssFeedInfo: DJCodable {
  var title: String
  var updated: String
  var link: String
  var entries: [RssFeed]
  
  var lastBuildDate: String?
  var item: [RssFeed]?
  var entry: [RssFeed]?
  
  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.title = try container.decode(String.self, forKey: .title)
    
    var dateString = ""
    if let updated = try? container.decode(String.self, forKey: .updated) {
      dateString = updated
    } else if let updated = try? container.decode(String.self, forKey: .lastBuildDate) {
      dateString = updated
    }
    
    if !dateString.isEmpty, let date = DateHelper.standard.dateFromRFC822String(dateString) {
      self.updated = DateHelper.standard.dateToString(date)
    } else {
      self.updated = dateString
    }
    
    if let link = try? container.decode(String.self, forKey: .link) {
      self.link = link
    } else {
      self.link = ""
    }
    
    if let entries = try? container.decode([RssFeed].self, forKey: .entry) {
      self.entries = entries
    } else if let entries = try? container.decode([RssFeed].self, forKey: .item) {
      self.entries = entries
    } else {
      self.entries = []
    }
  }
}
​
struct RssFeedLink: DJCodable {
  var href: String?
}
​
struct RssFeed: DJCodable {
  var id: Int?
  var title: String
  var updated: String
  var content: String
  var link: String
  
  var contentEncoded: String?
  var description: String?
  var pubDate: String?
  var summary: String?
​
  var rssFeedLink: RssFeedLink?
  
  var atomId: Int?
  
  enum CodingKeys: String, CodingKey {
    case id, title, updated, content, link, atomId, pubDate,
         contentEncoded = "content:encoded", description, rssFeedLink, summary
  }
  
  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.title = try container.decode(String.self, forKey: .title)
    
    var dateString = ""
    if let updated = try? container.decode(String.self, forKey: .updated) {
      dateString = updated
    } else if let pubDate = try? container.decode(String.self, forKey: .pubDate) {
      dateString = pubDate
    }
    
    if !dateString.isEmpty, let date = DateHelper.standard.dateFromRFC822String(dateString) {
      self.updated = DateHelper.standard.dateToString(date)
    } else {
      self.updated = dateString
    }
    
    if let link = try? container.decode(String.self, forKey: .link) {
      self.link = link
    } else if let rssFeedLink = try? container.decode(RssFeedLink.self, forKey: .rssFeedLink) {
      self.rssFeedLink = rssFeedLink
      self.link = rssFeedLink.href ?? ""
    } else {
      self.link = ""
    }
    
    if let content = try? container.decode(String.self, forKey: .content) {
      self.content = content
    } else if let content = try? container.decode(String.self, forKey: .contentEncoded) {
      self.content = content
    } else if let content = try? container.decode(String.self, forKey: .description) {
      self.content = content
    } else if let content = try? container.decode(String.self, forKey: .summary) {
      self.content = content
    } else {
      self.content = ""
    }
    self.atomId = try? container.decode(Int.self, forKey: .atomId)
    self.id = try? container.decode(Int.self, forKey: .id)
  }
}
​
extension RssFeed: SQLTable {
  static var tableName: String {
    return "rss_feed"
  }
  
  static var fields: [String] {
    return [
      "title", "updated", "content", "link", "atom_id"
    ]
  }
  
  static var fieldsTypeMapping: [String : FieldType] {
    return [
      "title": .text,
      "updated": .text,
      "content": .text,
      "link": .text,
      "atom_id": .bigint,
      "id": .int
    ]
  }
  
  static var selectedFields: [String] {
    return [
      "id", "title", "updated", "content", "link", "atom_id"
    ]
  }
  
  var fieldsValueMapping: [String : Any] {
    return [
      "id": self.id ?? 0,
      "title": self.title,
      "updated": self.updated,
      "content": self.content,
      "link": self.link,
      "atom_id": self.atomId ?? 0
    ]
  }
  
  func update(with rssFeed: RssFeed) {
    guard let rssFeedId = self.id, rssFeedId > 0 else { return }
    let sql = "update (Self.tableName) set title="(rssFeed.title)", content="(rssFeed.content)", updated="(rssFeed.updated)", link="(rssFeed.link)" where id=(rssFeedId)"
    store.execute(.update, sql: sql, type: RssFeed.self)
  }
  
}

然后我们就可以解析不同的rssfeed的数据以及存储到对应数据表中。然后通过数据库or网络请求我们就可以很方便的对数据进行展示了。

PS: 某些Atom源中的数据量非常大,因此可以考虑在APP启动时就进行下载,当然是采用异步下载的方式。可以考虑使用Aync group

的方式。

其他

有任何不懂的地方欢迎给我留言