Swift
AdventCalendar2016
SwiftDay 16

iOSでライブラリに頼らず、URLSessionを使ってHTTP通信する

More than 1 year has passed since last update.

これは Swift Advent Calendar 2016 16日目の投稿です。
昨日15日目は@_ha1fさんのPhotos.frameworkのサンプルを読むでした。

TL;DR

Multipart頻発したり、大きなデータのダウンロードがメインのアプリではなく、単純なGet, Postのみなら、URLSessionで問題ない

背景

今までiOSでの通信処理といえば、当たり前にAFNetworkingかAlamofire (その他のライブラリも触ったことはありますが) を使っており、お恥ずかしながら、標準APIでの処理を書いたことがありませんでした。「Swift 通信」で検索すると1番目にAlamofireを使った記事がヒットすることからも、自分のような人は意外と多いのではないでしょうか?

また、mono0926さんのiOS 10が正式リリースされた今、そろそろ既存アプリのiOS 8サポートは切っても良いだろうかという考察Alamofire 4.0がiOS 9以上のみサポートになったの項や@yimajoさんの今から新規でiOSアプリを書き始めるなら。2016年冬Alamofireの項を読んで、確かに「あんなに巨大で全体を把握しづらいライブラリを安易に使っていていいのか?」、「そもそも最初からライブラリありきで思考停止してるのでは?」と思いました。

そこで今回は標準で提供されているAPIで通信を行なってみて、本当に通信ライブラリが必要なのかを考えてみました。
@codelynxさんのURLSessionDownloadTask でちょっと大きめなデータでも寝ている間にダウンロードと一部被っている感じもありますが、ご容赦ください。

APIのサンプルはhttps://httpbin.orgをお借りしました。

Playgroundで通信する

今回は簡単に検証するためにPlaygroundを使用します。

import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

初めて知りました。(参考: http://dev.classmethod.jp/smartphone/iphone/swift-3-playground-urlsession/)

Get

import Foundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true


class URLSessionGetClient {

  func get(url urlString: String, queryItems: [URLQueryItem]? = nil) {
    var compnents = URLComponents(string: urlString)
    compnents?.queryItems = queryItems
    let url = compnents?.url
    let task = URLSession.shared.dataTask(with: url!) { data, response, error in
      if let data = data, let response = response {
        print(response)
        do {
          let json = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments)
          print(json)
        } catch {
          print("Serialize Error")
        }
      } else {
        print(error ?? "Error")
      }
    }

    task.resume()
  }

}

let urlSessionGetClient = URLSessionGetClient()
let queryItems = [URLQueryItem(name: "a", value: "foo"),
                  URLQueryItem(name: "b", value: "1234")]
urlSessionGetClient.get(url: "https://httpbin.org/get", queryItems: queryItems)

APIのURLとリクエストするパラメータを?a=xxx&b=yyy&c=zzzと連結していくだけと、非常に単純です。
parametersStringの辺りはきちんとPatternMatchを行う等の改善の余地がありますが。
※2017/07/11追記
@sawat1203さんにコメント頂き、URLComponentsURLQueryItemを使ったパラメーターのセット方法に修正しました。
Getのメソッドの引数に [URLQueryItem] 渡すようにすると、簡潔に書くことができますね!

Getの場合はURLSessionで十分簡潔に書くことができました。
URLSession.shared ではなく、let session = URLSession(configuration: URLSessionConfiguration)を使う場合は、sessionが強参照で保持されるので、completionHandlerの内部でsession.invalidateAndCancel() or session.finishTasksAndInvalidate()として、適宜解放する必要があります。

ちなみにURLConnection (iOS 9でdeprecated) で書くとこんな感じ
class URLConnectionClient {

    func get(url urlString: String) -> Void {
        let url = URL(string: urlString)
        let request = URLRequest(url: url!)
        let queue = OperationQueue.main
        NSURLConnection.sendAsynchronousRequest(request, queue: queue) { response, data, error in
            if let response = response, let data = data {
                print(response)
                do {
                    let json = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments)
                    print(json)
                } catch {
                    print("")
                }
            } else {
                print(error ?? "")
            }
        }
    }

}

let urlConnectionClient = URLConnectionClient()
urlConnectionClient.get(url: "https://httpbin.org/get")

responseのHandlerの引数の順番が、(URLResponse?, Data?, Error?) -> (Data?, URLResponse?, Error?) に変更になってますね。

Post

import Foundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

class URLSessionPostClient {

    func post(url urlString: String, parameters: [String: Any]) {
        let url = URL(string: urlString)
        var request = URLRequest(url: url!)
        request.httpMethod = "POST"

        let parametersString: String = parameters.enumerated().reduce("?") { (input, tuple) -> String in
            switch tuple.element.value {
            case let int as Int: return input + tuple.element.key + "=" + String(int) + (parameters.count - 1 > tuple.offset ? "&" : "")
            case let string as String: return input + tuple.element.key + "=" + string + (parameters.count - 1 > tuple.offset ? "&" : "")
            default: return input
            }
        }

        request.httpBody = parametersString.data(using: String.Encoding.utf8)
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let data = data, let response = response {
                print(response)
                do {
                    let json = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments)
                    print(json)
                } catch {
                    print("Serialize Error")
                }
            } else {
                print(error ?? "Error")
            }
        }
        task.resume()
    }

}

let urlSessionPostClient = URLSessionPostClient()
urlSessionPostClient.post(url: "https://httpbin.org/post", parameters: ["a": "foo", "b": 1234])

Postの場合はパラメータがnilの状況が考えられなかったので、Optionalを外しました。
パラメータはエンコードして、httpBodyに入れます。
PostもGetの時とほぼ変わらずに書けたように思います。

Multipart

こいつが厄介です。今回はユーザーID, トークン, 画像(jpeg)の送信を想定しました。

import UIKit
import Foundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

class URLSessionMulitipartClient {

    func mulipartPost(url urlString: String, parameters: [String: Any]) {
        let url = URL(string: urlString)
        var request = URLRequest(url: url!)
        request.httpMethod = "POST"

        let uniqueId = ProcessInfo.processInfo.globallyUniqueString
        let boundary = "---------------------------\(uniqueId)"

        // Headerの設定
        request.addValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

        // Bodyの設定
        var body = Data()
        var bodyText = String()

        for element in parameters {
            switch element.value {
            case let image as UIImage:
                let imageData = UIImageJPEGRepresentation(image, 1.0)
                bodyText += "--\(boundary)\r\n"
                bodyText += "Content-Disposition: form-data; name=\"\(element.key)\"; filename=\"\(element.key).jpg\"\r\n"
                bodyText += "Content-Type: image/jpeg\r\n\r\n"

                body.append(bodyText.data(using: String.Encoding.utf8)!)
                body.append(imageData!)
            case let int as Int:
                bodyText = String()
                bodyText += "--\(boundary)\r\n"
                bodyText += "Content-Disposition: form-data; name=\"\(element.key)\";\r\n"
                bodyText += "\r\n"

                body.append(bodyText.data(using: String.Encoding.utf8)!)
                body.append(String(int).data(using: String.Encoding.utf8)!)
            case let string as String:
                bodyText += "--\(boundary)\r\n"
                bodyText += "Content-Disposition: form-data; name=\"\(element.key)\";\r\n"
                bodyText += "\r\n"

                body.append(bodyText.data(using: String.Encoding.utf8)!)
                body.append(string.data(using: String.Encoding.utf8)!)
            default:
                break
            }
        }

        // Footerの設定
        var footerText = String()
        footerText += "\r\n"
        footerText += "\r\n--\(boundary)--\r\n"

        body.append(footerText.data(using: String.Encoding.utf8)!)

        request.httpBody = body

        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let data = data, let response = response {
                print(response)
                do {
                    let json = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments)
                    print(json)
                } catch {
                    print("Serialize Error")
                }
            } else {
                print(error ?? "Error")
            }
        }

        task.resume()
    }

}

let urlSessionMultipartClient = URLSessionMultipartClient()
let parameters = ["sample": #imageLiteral(resourceName: "fox"), "userId": 1234, "accessToken": "xxxxxxxxxxxxxxx"] as [String : Any] // 画像は適宜Playgroundへ
urlSessionMultipartClient.mulipartPost(url: "https://httpbin.org/post", parameters: parameters)

フォームデータを形成する部分は入り組んでいますが、読めなくもない...
画像の拡張子はjpegを想定しましたが、APIが要求するフォーマットが複数出てくるとparameters をDictionaryにすることが難しくなりますね。HttpBodyを表現するための型を自作することになるかと思います。 (実際Alamofireの場合は、MultipartFormDataという型が使用されています。)
久々に Webを支える技術 を引っ張り出しました。

Download

大きなデータのダウンロードには、今までの dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Swift.Void) -> URLSessionDataTask ではなく、 downloadTask(with url: URL) -> URLSessionDownloadTask を使用します。

import UIKit
import Foundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

class URLSessionDownloadImageClient: NSObject, URLSessionDownloadDelegate {

    func downloadImage(url urlString: String) -> Void {

        let url = URL(string: urlString)!
        let configuration = URLSessionConfiguration.background(withIdentifier: "backgroundSessionConfiguration")
        let session = URLSession(configuration: configuration,
                                 delegate: self,
                                 delegateQueue: OperationQueue.main)
        let task = session.downloadTask(with: url)
        task.resume()
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        do {
            let data = try Data(contentsOf: location)
            let image = UIImage(data: data)
        } catch {
            print("Serialize Error")
        }
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {

        session.invalidateAndCancel()

        if let error = error {
            print(error)
            return
        }
    }

}

let urlSessionDownloadImageClient = URLSessionDownloadImageClient()
urlSessionDownloadImageClient.downloadImage(url: "https://httpbin.org/image/png")

URLSessionDownloadTask でちょっと大きめなデータでも寝ている間にダウンロード@codelynxさんも仰っていますが、Closureでハンドル出来ないため、実際に使用する際はさらに複雑になるかと思います。

HTTP Status Code

HTTP通信をする以上、HTTP Status Codeのハンドリングもする必要が出て来るかと思います。
Status Codeは URLResponseHTTPURLResponse にダウンキャストすることで取得できます。

import Foundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

class URLSessionClient {

    func get() {
        let url = URL(string: "https://httpbin.org/get")
        let task = URLSession.shared.dataTask(with: url!) { data, response, error in
            if let response = response {
                let statusCode = (response as! HTTPURLResponse).statusCode
                print(statusCode)
            }
        }

        task.resume()
    }

}

let urlSessionClient = URLSessionClient()
urlSessionClient.get()

Status Code取得後のエラー処理は自前で行います。
きちんとしたエラー処理は、HTTP通信のみならず、永遠の悩みな気がしますが...

まとめ

  • Get: URLSessionで問題なし
  • Post: URLSessionで問題なし
  • Multipart: URLSessionでは複数パターン対応が大変になるが、できないこともない
  • Download: 多くのダウンロードが発生すると辛くなってくる
  • HTTP Status Code: 取得は容易にできるが、その後のエラー処理は自前で行う必要がある。

単純なGet, PostのみのAPI通信しか行わないのであれば、ライブラリを使う必要はないと思いました。
MultipartとDownloadは頻発しないのであれば、URLSessionで対応できそうです。(DownloadはAlamofireでも悩みましたが。)
HTTP Status Codeによるエラー処理は一考の余地があると思ったので、後日考えてみたいと思います。