Why build a new wheel
just for fun!
Ha ha, in fact, I still recommend you to use in the real project of network library, such as Moya/Alamofire AFNetworking, after all, the function is strong enough, proven, code, don’t want to say faults may be slightly bloated, not convenient to use with the SDK, And for the latter two generally need secondary encapsulation. This time, the goal is to create a network library that is light enough and powerful enough.
Let’s start implementing a simple and powerful network library directly based on the system URLSession.
The target
What I want to achieve is this:
let client = HTTPClient(session: .shared)
let req = HTTPBinPostRequest(foo: "bar")
client.send(req) { (result) in
switch result {
case .success(let response):
print(response)
case .failure(let error):
print(error)
}
}
Copy the code
It needs to be minimal, pass only the necessary variable parameters, and be completely type safe, all types are determined, and no judgment is required for final processing.
abstract
What objects are involved in making a network request? Nothing more than requests, responses, data-processing operations, and objects that connect those things.
A request, in fact, is an interface, at least the request address, request method, request parameters, parameter type, it is a complete request. When the client and the back-end agree on an interface, in addition to the value of the parameter uncertainty, other (including return structure) are generally will not change, the other parameters is also just the interface itself, implementation should be within the request, and should not by the caller to tell the caller calls the only need to describe this interface.
public protocol Request {
associatedtype Response: Decodable
var url: URL { get }
var method: HTTPMethod { get }
var parameters: [String: Any] { get }
var contentType: ContentType { get}}Copy the code
The request has been abstracted out, but this is just for higher level use. At the bottom level we have to convert it to URLRequest to actually request it, so:
public extension Request{
func buildRequest(a) throws -> URLRequest {
let req = URLRequest(url: url)
// There are various assignments to req, various ifelse, which can be easily written in noodle code
return request
}
}
Copy the code
We need to modify the basic URLRequest in various ways, such as the assignment request method, various header fields, query fields, and request body. Obviously, if you write all the logic into buildRequest, it’s going to be complicated. We need to abstract out a unified interface for handling URLRequest modifications.
public protocol RequestAdapter {
func adapted(_ request: URLRequest) throws -> URLRequest
}
Copy the code
Now buildRequest will be simple enough:
func buildRequest(a) throws -> URLRequest {
let req = URLRequest(url: url)
let request = try adapters.reduce(req) { try $1.adapted($0)}return request
}
Copy the code
Now that we have URLRequest, it’s time to send it to URLSession:
public struct HTTPClient {
public func send<Req: Request>(_ request: Req,
desicions: [Decision]? = nil,
handler: @escaping (Result<Req.Response, Error>) -> Void) - >URLSessionDataTask? {
let urlRequest: URLRequest
do {
urlRequest = try request.buildRequest()
} catch {
handler(.failure(error))
return nil
}
let task = session.dataTask(with: urlRequest) { (data, response, error) in
// Check whether there is data and response, whether response is valid, and parse data
}
task.resume()
return task
}
}
Copy the code
Obviously, there are many possibilities for the response and data, and the code here will get more complex. As above, we need to abstract out the behavior of processing the response data:
public protocol Decision: AnyObject {
// Whether the decision should be made and whether the response data meets the conditions for the decision to be executed
func shouldApply<Req: Request>(request: Req, data: Data, response: HTTPURLResponse) -> Bool
func apply<Req: Request>(request: Req,
data: Data,
response: HTTPURLResponse,
done: @escaping (DecisionAction<Req>) -> Void)}Copy the code
There may be more than one processing of the response data for a request, and we need to process it sequentially, so we also need to know the status of that processing or the next action:
public enum DecisionAction<Req: Request> {
case continueWith(Data.HTTPURLResponse)
case restartWith([Decision])
case error(Error)
case done(Req.Response)}Copy the code
Then you can handle it correctly:
let task = session.dataTask(with: urlRequest) { (data, response, error) in
guard let data = data else {
handler(.failure(error ?? ResponseError.nilData))
return
}
guard let response = response as? HTTPURLResponse else {
handler(.failure(ResponseError.nonHTTPResponse))
return
}
self.handleDecision(request, data: data, response: response, decisions: desicions ?? request.decisions,
handler: handler)
}
Copy the code
func handleDecision<Req: Request>(_ request: Req,
data: Data,
response: HTTPURLResponse,
decisions: [Decision],
handler: @escaping (Result<Req.Response, Error>) -> Void) {
guard! decisions.isEmptyelse {
fatalError("No decision left but did not reach a stop")}var decisions = decisions
let first = decisions.removeFirst()
guard first.shouldApply(request: request, data: data, response: response) else {
handleDecision(request, data: data, response: response, decisions: decisions, handler: handler)
return
}
first.apply(request: request, data: data, response: response) { (action) in
switch action {
case let .continueWith(data, response):
self.handleDecision(request, data: data, response: response, decisions: decisions, handler: handler)
case .restartWith(let decisions):
self.send(request, desicions: decisions, handler: handler)
case .error(let error):
handler(.failure(error))
case .done(let value):
handler(.success(value))
}
}
}
Copy the code
implementation
After the abstraction, let’s start to realize the actual scene.
If you need to make a POST request, the request body should be JSON:
struct JSONRequestDataAdapter: RequestAdapter {
let data: [String: Any]
func adapted(_ request: URLRequest) throws -> URLRequest {
var request = request
request.httpBody = try JSONSerialization.data(withJSONObject: data, options: [])
return request
}
}
Copy the code
If it’s a form request:
struct URLFormRequestDataAdapter: RequestAdapter {
let data: [String: Any]
func adapted(_ request: URLRequest) throws -> URLRequest {
var request = request
request.httpBody =
data.map({ (pair) -> String in
"\(pair.key)=\(pair.value)"
})
.joined(separator: "&").data(using: .utf8)
return request
}
}
Copy the code
When the response data is returned, retry if statusCode is incorrect:
public class RetryDecision: Decision {
let count: Int
public init(count: Int) {
self.count = count
}
public func shouldApply<Req>(request: Req, data: Data, response: HTTPURLResponse) -> Bool where Req : Request {
let isStatusCodeValid = (200..<300).contains(response.statusCode)
return! isStatusCodeValid &&count > 0
}
public func apply<Req>(request: Req, data: Data, response: HTTPURLResponse, done: @escaping (DecisionAction<Req>) -> Void) where Req : Request {
let nextRetry = RetryDecision(count: count - 1)
let newDecisions = request.decisions.replacing(self, with: nextRetry)
done(.restartWith(newDecisions))
}
}
Copy the code
Real data parsing operations:
public class ParseResultDecision: Decision {
public func shouldApply<Req>(request: Req, data: Data, response: HTTPURLResponse) -> Bool where Req : Request {
return true
}
public func apply<Req>(request: Req, data: Data, response: HTTPURLResponse, done: (DecisionAction<Req>) -> Void) where Req : Request {
do {
let value = try JSONDecoder().decode(Req.Response.self, from: data)
done(.done(value))
} catch {
done(.error(error))
}
}
}
Copy the code
use
Now I’ll define a real request to try out this power:
struct HTTPBinPostRequest: Request {
typealias Response = HTTPBinPostResponse
var url: URL = URL(string: "https://httpbin.org/post")!
var method: HTTPMethod=.POST
var contentType: ContentType = .json
var parameters: [String : Any] {
return ["foo": foo]
}
let foo: String
var decisions: [Decision] {
return [RetryDecision(count: 2),
BadResponseStatusCodeDecision(valid: 200..<300),
DataMappingDecision(condition: { (data) -> Bool in
return data.isEmpty
}, transform: { (data) -> Data in
"{}".data(using: .utf8)!
}),
ParseResultDecision()]}}struct HTTPBinPostResponse: Codable {
struct Form: Codable { let foo: String? }
let form: Form
let json: Form
}
Copy the code
Obviously, we’ve achieved what we set out to do. Nice!
extension
If the interface requires token authentication, we just need to add a TokenAdapter
struct TokenAdapter: RequestAdapter {
let token: String?
init(token: String?). {self.token = token
}
func adapted(_ request: URLRequest) throws -> URLRequest {
guard let token = token else {
return request
}
var request = request
request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
return request
}
}
Copy the code
public extension Request{
var adapters: [RequestAdapter] {
return [TokenAdapter(token: "token"),method.adapter,
RequestContentAdapter(method: method, content: parameters, contentType: contentType)]
}
}
Copy the code
Each request will carry a token.
Let’s look at one more common scenario that implements a Decision.
You may have experienced a similar problem on your client: a nice JSON structure returns null on the back end, which then defaults to NSNull, and you may crash on your cidcidr or cidCIDr for an exception.
public class DataMappingDecision: Decision {
let condition: (Data) - >Bool
let transform: (Data) - >Data
public init(condition: @escaping (Data) - >Bool, transform: @escaping (Data) - >Data) {
self.condition = condition
self.transform = transform
}
public func shouldApply<Req>(request: Req, data: Data, response: HTTPURLResponse) -> Bool where Req : Request {
return condition(data)
}
public func apply<Req>(request: Req, data: Data, response: HTTPURLResponse, done: (DecisionAction<Req>) -> Void) where Req : Request {
done(.continueWith(transform(data), response))
}
}
Copy the code
Interested students may have noticed that HTTPBinPostRequest is already used above:
DataMappingDecision(condition: { (data) -> Bool in
return data.isEmpty
}, transform: { (data) -> Data in
"{}".data(using: .utf8)!
})
Copy the code
The DataMappingDecision can also be used to mock data to facilitate upfront client development.
conclusion
The whole implementation of the single responsibility, interface isolation, open and close principle and protocol oriented, the use of command mode, adapter mode, combination mode and appearance mode, flexible and extensible, the main method is pure function, can be tested, when defining the interface is declarative combination.
The complete code
Reference: wang wei’s speech on iPlayground
LineSDK source