(原文地址:https://medium.freecodecamp.org/swift-networking-with-siesta-5b5e7089bd8f)
今天我跟大家分享一下我的 iOS 網路庫新歡,名字叫做 Siesta。“她有啥特殊的?為啥我不直接用 Almofire?”你也許會問。事實上,你仍然可以把 Alamofire 和 Siesta 一起使用!它是客戶端之上的網路抽象層。
和 Moya 不同,Siesta 不會隱藏 HTTP。這種中間狀態,是我使用 Siesta 構建 REST API 的理由。
通過資源為中心而不是請求為中心的設計,Siesta 提供一個全域性的符合 RESTful 的可被觀察的模型。
這意味著什麼?一些非必要的網路和反序列化操作被大量減少,檢視控制器和網路請求之間的關係被解耦。此外,它的響應解析十分透明,開箱即用。
這篇教程裡,我將展示給你如何通過使用 Siesta,讓你的網路處理程式碼變得更加 Swiftly。
初始化
從 Cocoapods 安裝:
pod 'Siesta', '~> 1.0'
複製程式碼
為了演示本教程,我將編寫一個簡單的 CRUD 應用程式配合 REST API 和 我部署到 HeroKu 上基於 JWT 的驗證。
首先,建立一個名為 AwesomeAPI.swift
的檔案。
定義基本的 API 配置:
import Siesta
let baseURL = "https://jwt-api-siesta.herokuapp.com"
let AwesomeAPI = _AwesomeAPI()
class _AwesomeAPI {
// MARK: - Configuration
private let service = Service(
baseURL: baseURL,
standardTransformers: [.text, .image]
)
fileprivate init() {
// –––––– Global configuration ––––––
#if DEBUG
LogCategory.enabled = [.network]
#endif
}
// MARK: - Resource Accessors
func ping() -> Resource {
return service.resource("/ping")
}
}
複製程式碼
我們在此定義了全域性使用的單例 API 物件。我們配置服務的地址,還有standardTransforms
(定義型別的轉換標準),它提供了對文字型別、圖片型別響應的解析。然後我們開啟了 debug 模式,在除錯 API 時這很有用。最後,我們定義了 resource accessor
(資源訪問)。一個訪問我們API 的方法返回一個我們在 ViewController 中使用的資源物件。
從資源物件中訪問網路並讀取資料,我們需要在 ViewController 中建立一個觀察者:
import Siesta
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
AwesomeAPI.ping().addObserver(self)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
AwesomeAPI.ping().loadIfNeeded()
}
}
extension ViewController: ResourceObserver {
func resourceChanged(_ resource: Resource, event: ResourceEvent) {
if let text = resource.latestData?.text {
print(text)
}
}
}
複製程式碼
我們給ping
返回的資源新增了一個觀察者,並定義好了代理,當資源的狀態改變時,代理會被呼叫。當收到新資料和被資源被新增時,資源的狀態都會改變。
Siesta 支援對請求初始化和配置進行解耦,所以在請求資源的時候,不用擔心過多關於請求具體的細節。
比如,你無需擔心loadIfNeeded
被呼叫的太頻繁,Siesta 允許你在指定時間內忽略重複的請求。預設時間是30秒,值可配置。
現在如果你執行程式,你可能將看到類似這樣的輸出:
Siesta:network │ GET https://jwt-api-siesta.herokuapp.com/ping
Siesta:network │ Response: 200 ← GET https://jwt-api-siesta.herokuapp.com/ping
pong
複製程式碼
轉換器
讓我們再做點有意思的。定義一些轉換器可以實現自動解析原始 JSON 資料到一個模型物件。
/status
返回:
{
"text": "ok"
}
複製程式碼
我們使用 JSONDecoder
在後臺對 JSON 進行解析,這是一個在 Swift 4 的新加入的。
首先,我們新增轉換器:
fileprivate init() {
...
let jsonDecoder = JSONDecoder()
// –––––– Mapping from specific paths to models ––––––
service.configureTransformer("/status") {
try jsonDecoder.decode([String: String].self, from: $0.content)
}
}
// MARK: - Resource Accessors
func status() -> Resource {
return service.resource("/status")
}
複製程式碼
[String: String]
意味著我們期待在我們的 JSON 響應物件中,返回一個 string-to-string 對映的字典。
然後我們對 ViewController 中觀察方法進行更新。
func resourceChanged(_ resource: Resource, event: ResourceEvent) {
if let status: [String: String] = resource.typedContent() {
print("\(status)")
}
}
複製程式碼
你可能注意到了,解析一個 JSON 我們使用 typedContent()
,它返回一個可選值,解包後使用。注意我們需要明確提供資料型別([String: String]),這裡的資料型別不能被推倒出來。同樣的,對 /ping
的呼叫修改如下:
if let text: String = resource.typedContent() {
print(text)
}
複製程式碼
驗證
在我們的 API 中,我們有兩個需要驗證許可權的介面:incomes
和expenses
。他們需要認證許可權,所以我們需要先獲得 JWT token。我們來增加認證方法。這裡沒有采用增加一個方法去返回帶有認證資訊的資源,而是把驗證資訊增加到每個請求中。
首先,增加一個屬性,它將儲存JWT token用於驗證。
private var authToken: String? {
didSet {
service.invalidateConfiguration()
guard let token = authToken else { return }
let jwt = try? JWTDecode.decode(jwt: token)
tokenExpiryDate = jwt?.expiresAt
}
}
複製程式碼
這個屬性被賦值的時候,我們將當前的配置作廢掉,這樣做是必須的,當下一次資源(resource)被獲取的時候,請求的頭會被重新整理。剛剛配置的最新的 token 會被放到 HTTP 頭中。
還需要考慮將 token 儲存到鑰匙串而不是 NSUserDefaults
或者其他不安全的儲存方式。我們這裡使用 JWTDecode 來解析 JWT token 和過期時間。
接下來,我們想在 token 過期的時候自動重新整理。更成熟的設計是提供有一個專門重新整理 token 的介面,呼叫它去重新整理 token。在我們的例子中,我們考慮一個簡化的實現,只是重新傳送一次登入請求。
下面是傳送登入請求並得到 token 的程式碼:
@discardableResult func login(_ email: String, _ password: String, onSuccess: @escaping () -> Void, onFailure: @escaping (String) -> Void) -> Request {
let request = service.resource("/login")
.request(.post, json: ["email": email, "password": password])
.onSuccess { entity in
guard let json: [String: String] = entity.typedContent() else {
onFailure("JSON parsing error")
return
}
guard let token = json["jwt"] else {
onFailure("JWT token missing")
return
}
self.authToken = token
onSuccess()
}
.onFailure { (error) in
onFailure(error.userMessage)
}
return request
}
複製程式碼
我們傳送一個攜帶使用者驗證資訊的 POST 請求給/login
。在onSuccess
和onFailure
兩個方法中處理返回資訊,如果驗證成功,則儲存起來。
最後,我們來實現在過期之前更新使用者驗證資訊。使用計時器來實現:
private var refreshTimer: Timer?
public private(set) var tokenExpiryDate: Date? {
didSet {
guard let tokenExpiryDate = tokenExpiryDate else { return }
let timeToExpire = tokenExpiryDate.timeIntervalSinceNow
// try to refresh JWT token before the expiration time
let timeToRefresh = Date(timeIntervalSinceNow: timeToExpire * 0.9)
refreshTimer = Timer.scheduledTimer(withTimeInterval: timeToRefresh.timeIntervalSinceNow, repeats: false) { _ in
AwesomeAPI.login("test", "test", onSuccess: {}, onFailure: { _ in })
}
}
}
複製程式碼
我們測試介面的驗證資訊為test
和test
,AwesomeAPI.login()
很容易整合進 ViewController。解析登入請求返回的資訊,同樣需要定義一個轉換器:
service.configureTransformer("/login", requestMethods: [.post]) {
try jsonDecoder.decode([String: String].self, from: $0.content)
}
複製程式碼
呼叫 API 的時候需要我們將 JWT token 資訊放在 Authorization HTTP 頭中。為了達到這個目的,我們增加一項配置:
service.configure("**") {
if let authToken = self.authToken {
$0.headers["Authorization"] = "Bearer \(authToken)"
}
}
複製程式碼
現在我們的請求已經被認證了,接著嘗試去請求一些需要認證的資源,比如/expenses
。這個斷點返回一個陣列,成員結構包含以下欄位:
{
"amount": -50.0,
"created_at": "2017-12-07T16:00:52.988245",
"description": "pizza",
"type": "TransactionType.EXPENSE"
}
複製程式碼
我們建立一個模型來儲存返回值的這種格式。增加一個名為Expense
的類。接下來使用JSONDecoder
,從 Codable 繼承:
import Foundation
struct Expense: Decodable {
let amount: Float
let createdAt: Date
let description: String
let type: String
enum CodingKeys: String, CodingKey {
case amount
case createdAt = "created_at"
case description
case type
}
}
複製程式碼
CodingKeys
列舉允許我們對映返回的 JSON 欄位名到剛剛建立的結構體的屬性名。這裡對映了日期欄位(createdAt
)。因為我們的自定義了日期格式,我們還需要通過JSONDecoder.dateDecodingStrategy
來進行配置。
let jsonDecoder = JSONDecoder()
let jsonDateFormatter = DateFormatter()
jsonDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.A"
jsonDecoder.dateDecodingStrategy = .formatted(jsonDateFormatter)
複製程式碼
最後,建立這個類的轉換器:
service.configureTransformer("/expenses") {
try jsonDecoder.decode([Expense].self, from: $0.content)
}
複製程式碼
我們期待得到 Expense 陣列,通過[Expense]
定義。
參考剛才的定義,我們增加一個expenses()
資源訪問器,然後我們可以呼叫需要驗證資訊的資源:
import Siesta
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
AwesomeAPI.expenses().addObserver(self)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
AwesomeAPI.login("test", "test", onSuccess: {
AwesomeAPI.expenses().loadIfNeeded()
}, onFailure: { error in
print(error)
})
}
}
extension ViewController: ResourceObserver {
func resourceChanged(_ resource: Resource, event: ResourceEvent) {
if let expenses: [Expense] = resource.typedContent() {
print(expenses)
}
}
}
複製程式碼
最後一件事
最後我想討論一下認證資訊過期之後的一些實踐。配合 Siesta,我們能自動執行認證以及重試因為認證失敗的請求。
增加配置:
service.configure("**") {
// Retry requests on auth failure
$0.decorateRequests {
self.refreshTokenOnAuthFailure(request: $1)
}
}
複製程式碼
將請求串聯起來,然後帶著新 token 再次呼叫。
func refreshAuth(_ username: String, _ password: String) -> Request {
return self.login(username, password, onSuccess: {
}, onFailure: { error in
})
}
func refreshTokenOnAuthFailure(request: Siesta.Request) -> Request {
return request.chained {
guard case .failure(let error) = $0.response, // Did request fail…
error.httpStatusCode == 401 else { // …because of expired token?
return .useThisResponse // If not, use the response we got.
}
return .passTo(
self.refreshAuth("test", "test").chained { // If so, first request a new token, then:
if case .failure = $0.response { // If token request failed…
return .useThisResponse // …report that error.
} else {
return .passTo(request.repeated()) // We have a new token! Repeat the original request.
}
}
)
}
}
複製程式碼
最後,專案地址奉上:https://github.com/nderkach/AwesomeAPI
Happy hacking!