9
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

テコテックAdvent Calendar 2018

Day 20

【Swift】 Alamofireをやめて、自力でAPIConnectorを実装した話

Last updated at Posted at 2018-12-19

今回はiOSのライブラリでも特に有名で、利用者も多いであろうAlamofireを使わず、自力でAPIConnectorを作成していきたいと思います。

何故Alamofireを使わないのか

iOS開発で多くの人が使っているであろうAlamofireですが、便利な反面、多機能すぎてすべての機能を使いこなさなければならない場面は限られてくると思います。
またコード量も膨大であるため、いざ何か起こったときの対応にも時間がかかりがちなのも個人的にはマイナスポイントです。
というわけで、今回はオーソドックスにJSONを返すAPIとの通信を前提に脱Alamofireを目指して頑張りたいと思います

開発環境は以下の通りです。

  • macOS Mojave
  • XCode 10.1(Swift4.2)

実装してみた

1. HTTPメソッド

HTTP通信を行うとき、HTTPメソッドを指定するわけですが、Alamofireでは9種類定義されています。備えあれば、と言いますがやはり使う機会は少ないので、基本的にはCRUD(Create, Read, Update, Delete)に必要な4種類で大丈夫でしょう。
今回は、GET、POST、PUT、DELETEの4種類を定義します。

HTTPMethod.swift
enum HTTPMethod: String {
    case get     = "GET"
    case post    = "POST"
    case put     = "PUT"
    case delete  = "DELETE"
}

2. プロパティ

続いて、APIConnectorのプロパティについて考えていきます。とりあえず必須となるものは、

  • HTTPメソッド
  • URL文字列
  • ヘッダー
  • パラメーター(今回はkey, valueともに文字列の想定)

あたりでしょうか。場合によっては、

  • タイムアウトの時間
  • キャッシュポリシー

も必要になってくると思うので、そのあたりを定義することにします。

APIConnector.swift
import UIKit
import Security
import HTTPMethod

class APIConnector: NSObject {
    var method: HTTPMethod { get { return .get } }
    var header: [String: String] = [:]
    var parameters: [String: String] = [:]
    var timeoutInterval: TimeInterval = 60.0
    var cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
    var urlString: String = ""
}

methodプロパティですが、今回は常にgetを返すようになっています。これを継承先のクラスで変更する場合はプロパティをoverrideして使用していきます。

CustomAPIConnector.swift
class CustomAPIConnector: APIConnector {
    override var method: HTTPMethod { get { return .post } }
}

のような感じです。1回1回設定してもいいですが、基本的にプロジェクトで通信を行う場合、主として使用するメソッドが決まっている場合が多いので、設定する手間を省きます。この辺りは好みかもしれません。

3. リクエストの生成

次は実際に送信するリクエストを生成します。この部分についてはAlamofireのメソッドを参考に簡略化して作成しています。

APIConnector.swift

class APIConnector: NSObject {
    /** 中略 */

    /// リクエストパラメータを生成した後、URLRequestを生成する
    ///
    /// - Returns: URLRequest
    private func createRequest() -> URLRequest? {
        var request: URLRequest?
        guard let url = URL(string: urlString) else { return request}
        
        if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), parameters.count != 0 {
            let percentEncodedQuery = (urlComponents.percentEncodedQuery.map { $0 + "&" } ?? "") + createQuery()
            urlComponents.percentEncodedQuery = percentEncodedQuery
            
            if method == .get {
                if let encodedUrl = urlComponents.url {
                    request = URLRequest(url: encodedUrl)
                }
            } else {
                request = URLRequest(url: url)
                request?.httpBody = percentEncodedQuery.data(using: .utf8)
            }
        } else {
            request = URLRequest(url: url)
        }
        
        return request
    }
    
    /// クエリーを生成する
    private func createQuery() -> String {
        var components: [(String, String)] = []
        
        for key in parameters.keys.sorted(by: <) {
            let value = parameters[key]!
            components += escapedQuery(fromKey: key, value: value)
        }
        return components.map { "\($0)=\($1)" }.joined(separator: "&")
    }
    
    /// エスケープされた(key, value)を返す
    /// 今回はkey, valueともに文字列の想定なので、型の判定は必要ないが、valueをAny型にした場合Alamofireでやっているように型の判定が必要となる
    private func escapedQuery(fromKey key: String, value: String) -> [(String, String)] {
        var components: [(String, String)] = []
        components.append((escape(key), escape("\(value)")))
        return components
    }
    
    /// クエリーとして送信出来るよう文字列を変換する
    /// RFC3986で規定されている形にする
    private func escape(_ string: String) -> String {
        var characterSet = CharacterSet.alphanumerics
        characterSet.insert(charactersIn: "-._~")
        return string.addingPercentEncoding(withAllowedCharacters: characterSet) ?? string
    }
}

これで createRequest() を呼ぶことでリクエストが生成できます。

4. 通信処理

では、先程作ったリクエストを利用して実際に通信を行う部分を実装していきます。

APIConnector.swift
class APIConnector: NSObject {
    /** 中略 */
    /// 通信を途中でキャンセル出来るように、URLSessionDataTaskをプロパティとして保持しておく
    private var task: URLSessionDataTask?
    /** 中略 */

    /// メソッド内でリクエストを生成して通信を開始する
    func startConnection() {
        guard var request = createRequest() else {
            connectionFailed(error: nil)
            return
        }
        request.timeoutInterval = timeoutInterval
        request.cachePolicy = cachePolicy
        
        for (headerField, headerValue) in header {
            request.setValue(headerValue, forHTTPHeaderField: headerField)
        }
        
        request.httpMethod = method.rawValue
        
        task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let data = data, let response = response {
                self.connectionSuccess(data: data, response: response)
            } else {
                self.connectionFailed(error: error)
            }
        }
        task?.resume()
    }

    /// 通信成功時の処理(HTTPステータスに関わらず、レスポンスが取得できた場合)
    /// 今回はJSON形式で値が返ってくる事を想定
    ///
    /// - Parameters:
    ///   - data: 通信で取得したData
    ///   - response: 通信で取得したResponse
    func connectionSuccess(data: Data, response: URLResponse) {
        print(response)
        do {
            let json = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments)
            print(json)
        } catch {
            print("Serialize Error")
        }
    }
    
    /// 通信失敗時の処理(純粋にレスポンスやDataが返ってこなかった場合)
    ///
    /// - Parameter error: Error
    func connectionFailed(error: Error?) {
        if let error = error { print("error code :", (error as NSError).code) }
        print(error ?? "Error")
    }

    /// 通信をキャンセルする
    func connectionCancel() {
        task?.cancel()
    }
}

これで、APIConnectorを生成し、必要な値を設定した後で startConnection() を呼ぶことで通信を開始し、返り値のJSONを表示するところまで完成しました。あとは実際に、作成したAPIConnectorを使ってAPI通信を行いたいと思います。

通信してみた

では、実際に通信を行っていきます。今回は
https://httpbin.org
にgetとpostでデータを送りたいと思います。

APIConnectorを継承したカスタムのAPIConnectorを作成します。今回はGET用とPOST用の両方を作成します。
まずはGET用

GETAPIConnector.swift
class GETAPIConnector: APIConnector {
    
    override init() {
        super.init()
        
        urlString = "https://httpbin.org/get"
        parameters = ["parameterTestKey": "!*'();:@&=+$,/?%#[]-._~ "]
    }
}

次にPOST用

POSTAPIConnector.swift
class POSTAPIConnector: APIConnector {
    override var method: HTTPMethod { return .post }
    
    override init() {
        super.init()
        
        urlString = "https://httpbin.org/post"
        header = ["Content-Type": "application/json"]
        parameters = ["parameterTestKey": "!*'();:@&=+$,/?%#[]-._~ "]
    }
}

どちらのAPIConnectorもinit()をoverrideすることにより、生成した時点で必要な情報が設定された状態になっています。

続いて、ViewControllerを作り、ボタンタップ時に通信をするような簡単な処理を作成します

ViewController.swift
class ViewController: UIViewController {

    @IBAction func actionConnect(_ sender: Any) {
        let getConnector = GETAPIConnector()
        getConnector.startConnection()

        let postConnector = POSTAPIConnector()
        postConnector.startConnection()
    }
}

後は、実際に通信を行うだけです。
結果はこちら。まずはGETの場合

<NSHTTPURLResponse: 0x600003d4e240> { URL: https://httpbin.org/get?parameterTestKey=%21%2A%27%28%29%3B%3A%40%26%3D%2B%24%2C%2F%3F%25%23%5B%5D-._~%20 } { Status Code: 200, Headers {
    "Access-Control-Allow-Credentials" =     (
        true
    );
    "Access-Control-Allow-Origin" =     (
        "*"
    );
    Connection =     (
        "keep-alive"
    );
    "Content-Length" =     (
        444
    );
    "Content-Type" =     (
        "application/json"
    );
    Date =     (
        "Mon, 17 Dec 2018 23:15:32 GMT"
    );
    Server =     (
        "gunicorn/19.9.0"
    );
    Via =     (
        "1.1 vegur"
    );
} }
{
    args =     {
        parameterTestKey = "!*'();:@&=+$,/?%#[]-._~ ";
    };
    headers =     {
        Accept = "*/*";
        "Accept-Encoding" = "br, gzip, deflate";
        "Accept-Language" = "en-us";
        Connection = close;
        Host = "httpbin.org";
        "User-Agent" = "testHTTP/1 CFNetwork/975.0.3 Darwin/18.2.0";
    };
    origin = "IPが表示されます";
    url = "https://httpbin.org/get?parameterTestKey=!*'()%3B%3A%40%26%3D%2B%24%2C%2F%3F%25%23[]-._~ ";
}

続いてPOSTの場合

<NSHTTPURLResponse: 0x600003d43820> { URL: https://httpbin.org/post } { Status Code: 200, Headers {
    "Access-Control-Allow-Credentials" =     (
        true
    );
    "Access-Control-Allow-Origin" =     (
        "*"
    );
    Connection =     (
        "keep-alive"
    );
    "Content-Length" =     (
        539
    );
    "Content-Type" =     (
        "application/json"
    );
    Date =     (
        "Mon, 17 Dec 2018 23:15:32 GMT"
    );
    Server =     (
        "gunicorn/19.9.0"
    );
    Via =     (
        "1.1 vegur"
    );
} }
{
    args =     {
    };
    data = "parameterTestKey=%21%2A%27%28%29%3B%3A%40%26%3D%2B%24%2C%2F%3F%25%23%5B%5D-._~%20";
    files =     {
    };
    form =     {
    };
    headers =     {
        Accept = "*/*";
        "Accept-Encoding" = "br, gzip, deflate";
        "Accept-Language" = "en-us";
        Connection = close;
        "Content-Length" = 81;
        "Content-Type" = "application/json";
        Host = "httpbin.org";
        "User-Agent" = "testHTTP/1 CFNetwork/975.0.3 Darwin/18.2.0";
    };
    json = "<null>";
    origin = "IPが表示されます";
    url = "https://httpbin.org/post";
}

以上になります。しっかりとパーセントエンコードも機能していますね

まとめ

ひとまず簡単な通信であれば、Alamofireを使用せずHTTP通信を実装することが出来ました。実際にプロジェクト等で使用していく場合はもう少し考慮が必要になってくるかと思いますが、その場合は追々修正していく形でいきたいとおもいます。

参考URL

Alamofire
https://github.com/Alamofire/Alamofire

9
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?