PIYO - Tech & Life -

SwiftでMicrosoft Azure StorageのAPIクライアントを書きました(書いてます)

API Azure iOS Mac Swift

最近はSwift版のMicrosoft Azure StorageのAPIクライアントを書いている。

https://github.com/pi-chan/AzureStorageApiClient

Microsoft Azure Storageを管理するようなMacまたはiOSのアプリを書きたくてSwiftかObjective-Cで書かれたAPIクライアントライブラリを探したのだけれど、現時点ではそのようなものはないということがわかったのでSwiftを勉強しつつ自分で書くことにした(ここにそう書かれている→Microsoft Azure Storage Client Library for C++ v1.0.0 (General Availability) - Microsoft Azure Storage Team Blog - Site Home - MSDN Blogs - Microsoft Azure Storage Team Blog - Site Home - MSDN Blogs”))

このクライアントを使って書いたMacアプリは現在申請中でレビュー待ち。というか1回リジェクトされてしまった。

そもそもなぜMacアプリを書く必要があるのか、そしてそもそもなぜAWSではなくAzureなのか、ということに答える必要があるかもしれないけど、今回はその点はスルーすることにする。

MS Azure Storage

(ここ、間違っていたらごめんなさい)

MS Azure StorageにはBlob、Queue、Table、Fileという4つのサービスの総称で、AWSと対応させると大体こんな感じになっている。

Azure StorageAWS用途
BlobS3ファイル
QueueSQSメッセージング
TableDynamoDBNoSQL
File(ない?)ファイル共有

今回書いているAPIクライアントはこれら4つのサービスのうちBlobとQueueに対応しようと考えていて、実際主要なAPIはある程度実装できたと思っている。

ちなみにAzure StorageのAPIリファレンスはこちら。

ストレージ サービス REST API リファレンス | Windows Azure のテクニカル ドキュメント ライブラリ

設計

Swift力不足のためAPIクライアントをSwiftでいい感じに書くにはどうしたらいいのか全く検討がつかなかった。そこで「Swift API クライアント」などと適当にググって調べらとてもいい記事を発見し、記事やサンプルアプリケーションを見て良い感じだなと思ったのでほぼそのままの設計で実装することにした。この記事に感謝します。

堅牢で使いやすいAPIクライアントをSwiftで実装したい

設計方針はこの3つだそう。

目標としたのは以下の3つの条件を満たすことです。

  • レスポンスはモデルオブジェクトとして受け取る (便利)
  • 個々のリクエスト/レスポンスの定義は1箇所で済ます (変更しやすくしたい)
  • リクエストオブジェクトはAPIクライアントから分離させたい

この記事を読んでいただければ僕のライブラリでやっていることも全部わかると思うが、一応簡単に設計について触れておく。

クライアントの主要コード

クライアントの主要メソッドcallの擬似コードを載せてみる。

public class Client {
    public func call<T: Request>(request: T, handler: (Response<T.Response>) -> Void) {
        // ① 成功時の処理
        let success = { (task: NSURLSessionDataTask!, responseObject: AnyObject!) -> Void in
            let statusCode = (task.response as? NSHTTPURLResponse)?.statusCode
            switch (statusCode, request.convertResponseObject(responseObject)) {
            case (.Some(200..<300), .Some(let response)):
                handler(Response(response))
            default:
                let userInfo = [NSLocalizedDescriptionKey: "unresolved error occurred."]
                let error = NSError(domain: "WebAPIErrorDomain", code: 0, userInfo: userInfo)
                handler(Response(error))
            }
        }

        // ② 失敗時の処理
        let failure = { (task: NSURLSessionDataTask!, error: NSError!) -> Void in
            handler(Response(error))
        }

        // ③ リクエスト
        let manager = AFHTTPSessionManager()
        let url = scheme + "://" + host() + request.path()
        manager.responseSerializer = AFHTTPResponseSerializer()
        manager.responseSerializer.acceptableContentTypes = request.responseTypes()
        manager.GET(url, parameters: nil, success: success, failure: failure)
    }
}

③の部分は単にAFNetworkingの呼び出しなのでここでは特に触れない。通信のライブラリとしてはAFNetworkingを選択した。同じ作者が書いたSwift版のライブラリ、AlamofireのほうがSwiftらしく書けるのかもしれないけれど、一度も使ったことがなかったので今回はパスした。いずれ書き換えてみてもいいかもしれないと思っている。

次は短い②の部分だけど、これは単にエラーオブジェクトをcallに渡ってきたハンドラに返している。

最後の①はレスポンスが正常に返ってきたときの処理で、ステータスコードとレスポンスから正しくモデルオブジェクトに変換できたかどうかで処理を分岐している。

全体を見ると、個々のリクエストに必要な情報(メソッド、パラメータ、HTTPレスポンスから得たいモデルオブジェクトなど)はリクエストオブジェクトから取り出して使うようになっている。そのためクライアントのコードは全てのリクエストに共通の処理だけ書いておけば良い。

レスポンス

クライアントのcallメソッドに渡すハンドラにはResponse<T>が渡ってくるようになっている。このResponseは値付きenum(というのかなんというのか…?)になっていて、成功時はリクエストオブジェクトが持つレスポンスの型に対応したモデルオブジェクトが、失敗時にはNSErrorを持っていることになる。

public class Wrapper<T> {
    public let value: T

    init(_ value: T) {
        self.value = value
    }
}

public enum Response<T> {
    case Success(Wrapper<T>)
    case Failure(Wrapper<NSError>)

    init(_ value: T) {
        self = .Success(Wrapper(value))
    }

    init(_ error: NSError) {
        self = .Failure(Wrapper(error))
    }
}

クライアントの呼び出し側はこのようになる。response: Response<T>でSwitchして成功時、失敗時の処理をしてあげる感じになる。

client.call(AzureQueue.ListQueuesRequest(), handler: { response in
    switch response {
    case .Success(let wrapper):
        println(wrapper.value) // AzureQueue.ListQueuesRequest.Response
    case .Failure(let wrapper):
        println(wrapper.value) // NSError
    }
})

リクエストオブジェクト

さっき書いたようにリクエストオブジェクトには個々のHTTPリクエストで必要な情報を個別に定義して、クライアントから使えるようにしてある。

参考にした記事では

  • パス
  • メソッド(GET,POSTなど)
  • モデルオブジェクトへの変換
  • モデルオブジェクトへの型

などが書かれていたが、Azure Storageのクライアントではさらに、

  • リクエストBody
  • 追加のHTTPヘッダー(BodyのContent-Lengthなど)
  • HTTPレスポンスのContent−Type

あたりを追加している。

public class ListQueuesRequest:  Request {
    public let method = "GET"

    public typealias Response = Collection<Queue>

    public init() {}

    public func path() -> String {
        return "/?comp=list"
    }

    public func body() -> NSData? {
        return nil
    }

    public func additionalHeaders() -> [String : String] {
        return [:]
    }

    public func convertResponseObject(object: AnyObject?) -> Response? {
        return ResponseUtility.responseItems(object, keyPath: "Queues.Queue")
    }

    public func responseTypes() -> Set<String>? {
        return ["application/xml"]
    }
}

Promise版の呼び出し

ここまで書いたことでAPIクライアントとしての機能は大体果たせるようになった。

ところでJavascriptなんかではよくあるように、非同期処理を待ってから次の非同期処理を書こうとするととても書きにくいという問題が、Objective-CやSwiftでAPIクライアントを書くときにも現れる。これはまあ放っておいてもいいのだけど使うときに便利なほうがいいと思ったので試しに対応してみることにした。

SwiftでもJavascriptのPromise的なアプローチを使えることができると知っていたので調べてみたところ次のようなライブラリが候補に挙がった。

決め手はなんだったかよく覚えていないが、上に挙げた3つのライブラリを全て試してみて最終的にBrightFuturesを採用してみた。

実装の際に参考にしたリンクはこちら。

BrightFutures版のクライアント

public class Client {
    public func future<T: Request>(request: T) -> Future<T.Response, NSError> {
        let promise = Promise<T.Response, NSError>()

        // ① 成功時の処理
        let success = { (task: NSURLSessionDataTask!, responseObject: AnyObject!) -> Void in
            let statusCode = (task.response as? NSHTTPURLResponse)?.statusCode
            switch (statusCode, request.convertResponseObject(responseObject)) {
            case (.Some(200..<300), .Some(let response)):
                promise.success(response)
            default:
                let userInfo = [NSLocalizedDescriptionKey: "unresolved error occurred."]
                let error = NSError(domain: "WebAPIErrorDomain", code: 0, userInfo: userInfo)
                promise.failure(error)
            }
        }

        // ② 失敗時の処理
        let failure = { (task: NSURLSessionDataTask!, error: NSError!) -> Void in
            promise.failure(error)
        }

        // ③ リクエスト
        let manager = AFHTTPSessionManager()
        let url = scheme + "://" + host() + request.path()
        manager.responseSerializer = AFHTTPResponseSerializer()
        manager.responseSerializer.acceptableContentTypes = request.responseTypes()
        manager.GET(url, parameters: nil, success: success, failure: failure)

        // ④ Futureオブジェクトを返す
        return promise.future
    }
}

通常版と変わったのはcallメソッドにハンドラを渡さなくなり、代わりにFutureというオブジェクトを返すようになったことで、レスポンスが返ってきたときにはハンドラにモデルオブジェクトやエラーを渡す代わりにPromiseオブジェクトのsuccessfailureメソッドを呼ぶようになっている。

BrightFutures版を使う側のコード

例えば、

  1. Queueの一覧を取得した後で、
  2. Queueを新しく生成し、
  3. 次にそのQueueを削除する

というAPI呼び出し(意味はないが)をしたいとき、通常版ではこのようになる(ひどすぎる…)。

func onError(error: NSError) {
    println(error)
}

func normal() {
    let req1 = AzureQueue.ListQueuesRequest()
    queueClient.call(req1, handler: { response in
        switch response {
        case .Success(let wrapper):
            let req2 = AzureQueue.CreateQueueRequest(queue: "brandnewqueue")
            self.queueClient.call(req2, handler: { response in
                switch response {
                case .Success(let wrapper):
                    let req3 = AzureQueue.DeleteQueueRequest(queue: "brandnewqueue")
                    self.queueClient.call(req3, handler: { response in
                        switch response {
                        case .Success(let wrapper):
                            println("Success!!")
                        case .Failure(let wrapper):
                            self.onError(wrapper.value)
                        }
                    })
                case .Failure(let wrapper):
                    self.onError(wrapper.value)
                }
            })
        case .Failure(let wrapper):
            self.onError(wrapper.value)
        }
    })
}

BrightFutures版では煩雑さは残るものの幾分かマシに書けるようになる。

func promise() {
    let req1 = AzureQueue.ListQueuesRequest()
    queueClient.future(req1).flatMap { response -> Future<AzureQueue.CreateQueueRequest.Response, NSError> in
        let req = AzureQueue.CreateQueueRequest(queue: "brandnewqueue")
        return self.queueClient.future(req)
    }.flatMap { response -> Future<AzureQueue.DeleteQueueRequest.Response, NSError> in
        let req = AzureQueue.DeleteQueueRequest(queue: "brandnewqueue")
        return self.queueClient.future(req)
    }.onSuccess { response in
        println(response)
    }.onFailure { error in
        println(error)
    }
}

煩雑さの原因になっているflatMap { response -> Future<AzureQueue.CreateQueueRequest.Response, NSError> inのようなクロージャの型の部分だが、これを省略してしまうと現状のXcode6.4ではambiguousとか言われてコンパイルできなかった。PromiseKitなどでも同じように見えるエラーに出会ったのでこのあたりはSwiftやXcodeの進化が必要なのかな。

とにかく、これでAPI呼び出しを順番にしたいという要求にも一応答えられるようになった。

あとPromise系のライブラリはそれぞれ進化が速いらしい。すぐに色々変わってしまうかもしれない。

テスト

プロジェクトをCocoaPodsのpod lib createで生成したらデフォルトでQuickというテストライブラリが入ってきたのでそのままこれを使った。Quickでのテストは書いたことがなかったが参考になるコード(後述)が見つかったのであまり苦労することはなかった。

テストはできるだけ書いておきたいと思ったものの、APIクライアントはAPIサーバーあっての話なのでテストはどうしようか迷って色々試した結果、今のように実際のAzure環境を使ったテストに落ち着いた。

Nocillaでスタブ?

最初はNocillaというライブラリを使ってスタブしてテストを書こうと思ったが、マッチポンプ的あまり意味がない気がしたのでこれは却下することにした。

なお、Nocillaを使おうとして色々調べているときに見つけたサンプルコードがQuickでテストを書くときにも訳にたったので紹介しておく。

Azure Storage Emulator

次に試そうとしたのはWindowで動かすことができるAzure Storage Emulatorを使うことだ。ざっくり言うとAPIサーバーのエミュレータをローカルに立てられるというものらしい。ということはこれをMacに入れたVMWareとかで動かしておけばテストに使えるんじゃねーか(少なくともローカルでは)と考え色々準備してみた。

ところがクライアントの接続先にVMWareのIPを指定してもどうにもうまくいかない。3時間近く奮闘したものの何も得られなかったのでこの作戦も却下することに。

実環境でテストする

ここでようやく他の言語のクライアントではどうしてるんだろうということに気がついたので、Railsで使ったことがあるRuby版のクライアントを見てみることにした。

Azure/azure-sdk-for-ruby

これのテストコードを見ると環境変数にストレージのアカウントやアクセスキーが設定されているときだけテストを動かせるようになっていた。つまり本物のAzure環境でテストを動かすよ、ということのようだった。

そういうわけでSwift版でも同じようにAzure環境でテストを動かすコードを書くことになった。テストコードを書くにあたってBrightFutures版が非常に役に立った。これをしてそれをして、最後にあれをしたらこうなっている、というコードを通常版で書こうとしていたらだいぶ辛いことになっていたと思う。

コード

もう1回貼っておきます。

https://github.com/pi-chan/AzureStorageApiClient

宣伝

途中に出てきた表は自作のMacアプリで作りました。日本語入力で若干不具合があるけどMarkdownで表を書くときにはWebにある表生成ツール(Markdown Tables generator)よりも便利なのでぜひどうぞ。

Table2Text (Markdown, CSV)
カテゴリ: 開発ツール, ユーティリティ