これは 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さんにコメント頂き、URLComponentsとURLQueryItemを使ったパラメーターのセット方法に修正しました。
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は URLResponse
を HTTPURLResponse
にダウンキャストすることで取得できます。
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によるエラー処理は一考の余地があると思ったので、後日考えてみたいと思います。