API クライアントを書きつつ Swift らしいコードを考える

こんにちは、技術部モバイル基盤グループの茂呂(@slightair)です。

クックパッドは Garage という RESTful Web API 開発を楽にする Rails のためのライブラリを作り、内部通信やモバイルアプリケーションのためのAPIサーバの開発に利用しています。

過去の Garage の紹介記事はこちらです。

この Garage を使って実装された Web API を iOS アプリから気軽に呼べるように、 Swift で Garage のクライアントを実装してみました。

この記事では、GarageClientSwift の紹介をしつつ、これを作りながら Swift らしいコードってどんなコードなんだろうと考えたことをつらつらと書いていきたいと思います。

Garage

Garage は RESTful Web API 開発のためのライブラリです。OSSとして公開しています。 https://github.com/cookpad/garage

今回はクライアントサイドの話をしたいので Garage 自体の説明は過去の記事にまかせます。

記事で紹介されているサンプル実装を使ってクライアントの開発・動作確認を行います。 手元で動作を確認しながら読みたい場合は、リポジトリからコードをチェックアウトして動かしてください。 https://github.com/taiki45/garage-example

クライアントアプリケーションの動作確認時には、サーバアプリケーションのアクセストークンが必要になるので、過去の記事の手順にしたがって取得してください。

GarageClientSwift

GarageClientSwift はその名の通り、GarageClient の Swift による実装です。 https://github.com/slightair/GarageClientSwift

GarageClientSwift は僕が趣味でなんとなく書いたものなので、クックパッドのアプリでもうバッチリ使っているぜ!…というわけではありません。ただ基本的な機能はそろっているのではないかと思います。

GarageClientSwift は HimotokiAPIKit というSwiftのライブラリに依存しています。 これらのライブラリについては後述します。

GarageClientSwift の使い方

GarageClientSwift は Carthage でプロジェクトに導入できます。 詳しくは README.md を読んでください。 この記事では GarageClientSwift 1.1.0 の実装を使った例を出します。

GarageClientSwift の workspace に Demo.playground を同梱しているので、コードを触りながら動作を確認したければこれを利用できるでしょう。 Demo.playground を動かす際は一度 GarageClient iOS の scheme でビルドしてから playground ファイルを開いてください。

この節で説明するものは、この playground ファイルに記述されているものです。

リソースのモデルを定義する

Web API Client を使うということは、なんらかのリソースを取得したいと考えているはずです。 ここでは User リソースを取得することを考えます。 以下のように User 構造体を定義します。 リソースモデルは Himotoki の Decodable に準拠するようにします。

struct User: Decodable {
    let id: Int
    let name: String
    let email: String

    static func decode(e: Extractor) throws -> User {
        return try User(
            id: e <| "id",
            name: e <| "name",
            email: e <| "email"
        )
    }
}

リクエストを定義する

次にリソースを得るためにどのようなリクエストを投げるか定義します。 /users に GET リクエストを送信してユーザーの一覧を取得しましょう。 このようなリクエストを表現する構造体を定義します。

struct GetUsersRequest: GarageRequestType {
    typealias Resource = [User]

    var method: HTTPMethod {
        return .GET
    }

    var path: String {
        return "/users"
    }

    var queryParameters: [String: AnyObject]? {
        return [
            "per_page": 1,
            "page": 2,
        ]
    }
}

なんとなくやりたいことがわかると思います。 APIKitを知っている人はそのまんまだと感じていると思います。

Garage の設定を定義する

次にGarageアプリケーションへ接続するための情報を用意します。 GarageConfigurationType というプロトコルがあるので、それに準拠する構造体かクラスを定義してそのインスタンスを作ります。ここでは単純にGarageアプリケーションのベースURLとアクセストークンをただ保持している構造体を作りました。実際にはアクセストークンを認可サーバから取得してそれを返してくれるような認証・認可機能を実装したクラスになると思います。

struct Configuration: GarageConfigurationType {
    let endpoint: NSURL
    let accessToken: String
}

let configuration = Configuration(
    endpoint: NSURL(string: "http://localhost:3000")!,
    accessToken: "YOUR ACCESS TOKEN"
)

リクエストを送信する

あとはリクエストを送信するだけです。 GarageClient のインスタンスを作って、sendRequest メソッドでリクエストを送信します。 リクエストのコールバックには Result.Success.Failure が引数に渡されるので結果に応じた処理を記述します。 .Success の場合には、取得したリソースやページングのための件数などの情報を含む GarageResponse 構造体を取得できます。

let garageClient = GarageClient(configuration: configuration)
garageClient.sendRequest(GetUserRequest()) { result in
    switch result {
    case .Success(let response):
        debugPrint(response)

        let users = response.resource
        debugPrint(users)
    case .Failure(let error):
        debugPrint(error)
    }
}

以上が GarageClientSwift を使ったリクエスト送信までの流れです。

Himotoki

Himotoki はJSONをデコードしてモデルにマッピングするためのライブラリです。 https://github.com/ikesyo/Himotoki

この記事では Himotoki 2.0.1 の実装を使った例を出します。

Himotoki を使って以下の様な JSON を User構造体にマッピングするにはこのように記述します。

JSON

{
  "id": 2,
  "name": "bob",
  "email": "bob@example.com"
}

User.swift

struct User: Decodable {
    let id: Int
    let name: String
    let email: String

    static func decode(e: Extractor) throws -> User {
        return try User(
            id: e <| "id",
            name: e <| "name",
            email: e <| "email"
        )
    }
}

e <| "id" のような見慣れない構文が登場しますが、これは Himotoki の Extractor のためのオペレータです。JSONから指定したキーの要素を期待通りの型で取り出すための工夫です。

Himotoki の実装をのぞいてみる

<| はどのような実装になっているのか見てみましょう。

https://github.com/ikesyo/Himotoki/blob/2.0.1/Sources/Operators.swift

infix operator <| { associativity left precedence 150 }

/// - Throws: DecodeError or an arbitrary ErrorType
public func <| <T: Decodable>(e: Extractor, keyPath: KeyPath) throws -> T {
    return try e.value(keyPath)
}

Swift ではオペレータを定義することができるので、その結合の仕方と優先度、処理を定義しています。 <| は Extractor の e.value(keyPath) を呼んでいることがわかりました。

https://github.com/ikesyo/Himotoki/blob/2.0.1/Sources/Extractor.swift

private func _rawValue(keyPath: KeyPath) throws -> AnyJSON? {
    guard isDictionary else {
        throw typeMismatch("Dictionary", actual: rawValue, keyPath: keyPath)
    }

    let components = ArraySlice(keyPath.components)
    return valueFor(components, rawValue)
}

/// - Throws: DecodeError or an arbitrary ErrorType
public func value<T: Decodable>(keyPath: KeyPath) throws -> T {
    guard let rawValue = try _rawValue(keyPath) else {
        throw DecodeError.MissingKeyPath(keyPath)
    }

    do {
        return try T.decodeValue(rawValue)
    } catch let DecodeError.MissingKeyPath(missing) {
        throw DecodeError.MissingKeyPath(keyPath + missing)
    } catch let DecodeError.TypeMismatch(expected, actual, mismatched) {
        throw DecodeError.TypeMismatch(expected: expected, actual: actual, keyPath: keyPath + mismatched)
    }
}

value メソッドはつまり、与えられた keyPath で Dictionary から要素を取り出し、返り値の型の decodeValue を呼びだして値を返しています。TypeConstraints を使って Decodable プロトコルに準拠していることを制限に課しているので、e <| "id"の返り値が Decodable に準拠する型でないといけません。

上記のJSONの "id" 要素は数値なので Int になることを期待します。Int や String のようなよく使う型に対しては Himotoki ですでに Decodable に準拠するための実装が extension で追加されています。 https://github.com/ikesyo/Himotoki/blob/2.0.1/Sources/StandardLib.swift

さて、User 構造体の decode メソッドは以下のように実装していました

static func decode(e: Extractor) throws -> User {
    return try User(
        id: e <| "id",
        name: e <| "name",
        email: e <| "email"
    )
}

User 構造体では、 id は Int、name と email は String と型宣言してあるので、コンパイラが e <| "id"Inte <| "name"String が返ると推論します。型推論がうまく働いてくれるのですっきりした記述になるわけです。

Decodable プロトコルはどのような定義になっているのでしょうか。 https://github.com/ikesyo/Himotoki/blob/2.0.1/Sources/Decodable.swift

static func decode(e: Extractor) throws -> Self を定義していることを要求しています。 これが、先ほど見つけた decodeValue メソッドから呼ばれます。

Swift の Protocol にはデフォルト実装を Protocol extension で追加できます(Swift2 から) なので Decodable に準拠している構造体は decodeValue メソッドを実装していなくてもデフォルトの実装が使われます。

Himotoki の Decodable プロトコルに準拠していれば、JSON から作られたDictionaryを以下のようにしてモデルにマッピングできます。

let user: User? = try? decodeValue(JSON)
let users: [User]? = try? decodeArray(ArrayJSON)

便利ですね! 期待した型とJSONの要素の型が一致しない場合は例外が投げられマッピングに失敗します。

Himotoki は Generics と型推論、Protocol をうまく使った例だと思います。

APIKit

APIKit はリクエストとレスポンスを抽象的に表現できて使いやすいAPIクライアントライブラリです。 https://github.com/ishkawa/APIKit/

リクエストを表す構造体を定義し、それに対応するレスポンスをモデルで受け取れるのが特長です。 リクエストに渡すパラメータの型を明示できます。 リクエスト結果は、成功と失敗のどちらかの状態を表現する Result 型で受け取れます。Result には成功時に目的のオブジェクトを、失敗時にエラー情報を含めることができるので、Optional な変数を用いることなくリクエスト結果を受け取ることができます。

この記事では APIKit 2.0.1 の実装を使った例を出します。

使い方を見てみましょう。 https://github.com/ishkawa/APIKit/blob/2.0.1/Documentation/GettingStarted.md

まずはリクエストを定義します。 サンプルは GitHub API の Ratelimit を取得する API を実行するようです。 RequestType プロトコルに準拠した RateLimitRequest とそのレスポンスを表すモデル RateLimit を定義します。

struct RateLimitRequest: RequestType {
    typealias Response = RateLimit

    var baseURL: NSURL {
        return NSURL(string: "https://api.github.com")!
    }

    var method: HTTPMethod {
        return .GET
    }

    var path: String {
        return "/rate_limit"
    }

    func responseFromObject(object: AnyObject, URLResponse: NSHTTPURLResponse) throws -> Response {
        guard let dictionary = object as? [String: AnyObject],
            let rateLimit = RateLimit(dictionary: dictionary) else {
                throw ResponseError.UnexpectedObject(object)
        }

        return rateLimit
    }
}

struct RateLimit {
    let limit: Int
    let remaining: Int

    init?(dictionary: [String: AnyObject]) {
        guard let limit = dictionary["rate"]?["limit"] as? Int,
            let remaining = dictionary["rate"]?["limit"] as? Int else {
                return nil
        }

        self.limit = limit
        self.remaining = remaining
    }
}

RateLimitRequest 構造体には API の baseURLmethodpath などのリクエストを構築するために必要な情報を記述します。 また、レスポンスをどのようにモデルにマッピングするかを responseFromObject メソッドに記述します。

リクエストの定義ができたらそれを使ってリクエストを投げます。 コールバックには Result<T, Error> が渡されるのでそれに応じた処理を記述します。 .Successの場合はレスポンスをマッピングしたモデルが含まれているので、後は好きなように扱えばよいでしょう。 RateLimitRequestのレスポンスはRateLimitと定義してあるので、resultResult<RateLimit, Error> であり、.Success<RateLimit> が渡されるわけです。なので limitremaining のプロパティにアクセスできます。

let request = RateLimitRequest()

Session.sendRequest(request) { result in
    switch result {
    case .Success(let rateLimit):
        print("limit: \(rateLimit.limit)")
        print("remaining: \(rateLimit.remaining)")

    case .Failure(let error):
        print("error: \(error)")
    }
}

APIKit の実装をのぞいてみる

APIKit の RequestType プロトコルの実装を見てみましょう。 https://github.com/ishkawa/APIKit/blob/2.0.1/Sources/RequestType.swift

RequestType はリクエストを表現する構造体が準拠すべきプロトコルでした。 baseURLmethodpathqueryParametersheaderFields などなど様々なプロパティがありますがほとんどにデフォルト実装が用意されており、オプションのパラメータはリクエストを定義する際に指定したいものだけ実装すれば良いようになっています。

受け取ったレスポンスをパースしたオブジェクトをどのようにモデルにマッピングするかを以下のメソッドに記述します。デフォルトではレスポンスに JSON を期待しています。dataParser プロパティを指定すれば JSON 以外も受け付けることができます。

func responseFromObject(object: AnyObject, URLResponse: NSHTTPURLResponse) throws -> Response

他にも以下の様なメソッドが宣言されています。

func interceptURLRequest(URLRequest: NSMutableURLRequest) throws -> NSMutableURLRequest
func interceptObject(object: AnyObject, URLResponse: NSHTTPURLResponse) throws -> AnyObject

これらのメソッドをリクエストの構造体に実装することで送信する URLRequest に追加の情報を付与したり、レスポンスに応じて独自のエラーを投げてエラーレスポンスを処理することができるようになっています。 これらのメソッドも必要でなければデフォルト実装が利用されるので定義を省略することができます。

APIKit は Swift の Protocol をうまく利用していると思います。

次に Session を見てみましょう。 https://github.com/ishkawa/APIKit/blob/2.0.1/Sources/Session.swift

Singleton の Session オブジェクトを持っているので、通常の利用範囲であればクラスメソッドの Session.sendRequest メソッドを使えば良いようになっていることがわかります。

sendRequest メソッドは TypeConstraints で引数 request の型 RequestRequestType に準拠しているべきと制約を課しています。

public func sendRequest<Request: RequestType>(request: Request,
                                        callbackQueue: CallbackQueue? = nil,
                                              handler: (Result<Request.Response, SessionTaskError>) -> Void = {r in})
                                              -> SessionTaskType? {
...

RequestType には以下の様な記述がありました。

public protocol RequestType {
    /// The response type associated with the request type.
    associatedtype Response
...

これは Protocol の Associated Types という機能で定義するプロトコルに関連する型を指定できるものです。 以下のように RateLimitRequest の Response 型を typealias キーワードで指定することができます。

struct RateLimitRequest: RequestType {
    typealias Response = RateLimit
...

これにより先ほどの sendRequest メソッドの handler 引数にある Result<Request.Response, SessionTaskError> の記述が、RateLimitRequest の場合は Result<RateLimit, SessionTaskError> に定まるわけです。 こうして、リクエストとそれに対応するレスポンスのモデルの型を明示できるようになっています。

このようにして APIKit はリクエストとレスポンスを表現するモデルをわかりやすく定義できるように作られています。 僕のお気に入りのライブラリです。

GarageClientSwift の実装

GarageClientSwift はこれまで説明してきた Himotoki と APIKit を組み合わせて作ったライブラリです。 すでに利用例で見せたように、Himotoki を使った Decodable なリソースのモデルを用意し、APIKit のようにリクエストを表現してリクエストを送信します。

やっていることは APIKit をラップして、Garage アプリケーションの認証に必要なアクセストークンをリクエストに付与したり、Garage のレスポンスに共通で含まれるページング等の情報を持った値を表現する GarageResponse を返すようにしています。

少し工夫したところはリソースの型に User[User] のようにモデルの配列も指定できるようにしたところです。

GarageClient にふたつの sendRequest を定義しています。 https://github.com/slightair/GarageClientSwift/blob/1.1.0/Sources/GarageClient.swift

public func sendRequest<R: GarageRequestType, D: Decodable where R.Resource == D>
    (request: R,
     handler: (Result<GarageResponse<D>, SessionTaskError>) -> Void = { result in })
    -> SessionTaskType? {
        let resourceRequest = RequestBuilder.buildRequest(request, configuration: configuration)
...

public func sendRequest<R: GarageRequestType, D: Decodable where R.Resource: CollectionType, R.Resource.Generator.Element == D>
    (request: R,
     handler: (Result<GarageResponse<[D]>, SessionTaskError>) -> Void = { result in })
    -> SessionTaskType? {
        let resourceRequest = RequestBuilder.buildRequest(request, configuration: configuration)
...

リクエストの ResourceDecodable または Decodable を要素に持つ CollectionType を受け付けています。

RequestBuilder にもふたつの buildRequest を定義しており、それぞれ SingleResourceRequestMultipleResourceRequest を作ります。 https://github.com/slightair/GarageClientSwift/blob/1.1.0/Sources/RequestBuilder.swift

struct RequestBuilder {
    static func buildRequest<R: GarageRequestType, D: Decodable where R.Resource == D>
        (baseRequest: R, configuration: GarageConfigurationType) -> SingleResourceRequest<R, D> {
        return SingleResourceRequest(baseRequest: baseRequest, configuration: configuration)
    }

    static func buildRequest<R: GarageRequestType, D: Decodable where R.Resource: CollectionType, R.Resource.Generator.Element == D>
        (baseRequest: R, configuration: GarageConfigurationType) -> MultipleResourceRequest<R, D> {
        return MultipleResourceRequest(baseRequest: baseRequest, configuration: configuration)
    }
}

SingleResourceRequestMultipleResourceRequest の違いは、中で呼んでいる Himotoki のメソッドが decodeValuedecodeArray かの違いです。 ともに ResourceRequest プロトコルに準拠しており、このプロトコルは APIKit の RequestType を継承しています。 前述した GarageRequestType は APIKit の RequestType 風のプロトコルですが、実際には APIKit の sendRequest に渡す ResourceRequestRequestBuilder が作り GarageRequestType から値を取っていたのでした。 https://github.com/slightair/GarageClientSwift/blob/1.1.0/Sources/GarageRequestType.swift

今回のような範囲では Class の継承ではなく Protocol を使うとすっきりと書けます。 Swift の Protocol は Protocol extension によるデフォルト実装の提供が強力で、継承ができない struct であっても Protocol の組み合わせで拡張していくことができます。 このような Protocol を組み合わせていくプログラミング手法を Apple は Protocol Oriented Programming として提唱しています。

まとめ

GarageClientSwift というライブラリを紹介しつつ、このライブラリの実装に利用した Himotoki、 APIKit と GarageClientSwift 自身の実装を読み、Protocol や Generics を使った実装例の説明をしました。 Swift は新しい言語であり、おもしろい機能や新しいプログラミング手法を提供してくれます。単なる Objective-C の置き換えでアプリケーションを楽に記述するための言語とは捉えずに、 Swift の言語機能を使ってより柔軟で安全なコードを記述して素敵なアプリケーションを作りましょう。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/