
最近は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)
このクライアントを使って書いたMacアプリは現在申請中でレビュー待ち。というか1回リジェクトされてしまった。
そもそもなぜMacアプリを書く必要があるのか、そしてそもそもなぜAWSではなくAzureなのか、ということに答える必要があるかもしれないけど、今回はその点はスルーすることにする。
MS Azure Storage
(ここ、間違っていたらごめんなさい)
MS Azure StorageにはBlob、Queue、Table、Fileという4つのサービスの総称で、AWSと対応させると大体こんな感じになっている。
| Azure Storage | AWS | 用途 |
|---|---|---|
| Blob | S3 | ファイル |
| Queue | SQS | メッセージング |
| Table | DynamoDB | NoSQL |
| File | (ない?) | ファイル共有 |
今回書いているAPIクライアントはこれら4つのサービスのうちBlobとQueueに対応しようと考えていて、実際主要なAPIはある程度実装できたと思っている。
ちなみにAzure StorageのAPIリファレンスはこちら。
ストレージ サービス REST API リファレンス | Windows Azure のテクニカル ドキュメント ライブラリ
設計
Swift力不足のためAPIクライアントをSwiftでいい感じに書くにはどうしたらいいのか全く検討がつかなかった。そこで「Swift API クライアント」などと適当にググって調べらとてもいい記事を発見し、記事やサンプルアプリケーションを見て良い感じだなと思ったのでほぼそのままの設計で実装することにした。この記事に感謝します。
設計方針はこの3つだそう。
目標としたのは以下の3つの条件を満たすことです。
- レスポンスはモデルオブジェクトとして受け取る (便利)
- 個々のリクエスト/レスポンスの定義は1箇所で済ます (変更しやすくしたい)
- リクエストオブジェクトはAPIクライアントから分離させたい
この記事を読んでいただければ僕のライブラリでやっていることも全部わかると思うが、一応簡単に設計について触れておく。
クライアントの主要コード
クライアントの主要メソッドcallの擬似コードを載せてみる。
1public class Client {
2 public func call<T: Request>(request: T, handler: (Response<T.Response>) -> Void) {
3 // ① 成功時の処理
4 let success = { (task: NSURLSessionDataTask!, responseObject: AnyObject!) -> Void in
5 let statusCode = (task.response as? NSHTTPURLResponse)?.statusCode
6 switch (statusCode, request.convertResponseObject(responseObject)) {
7 case (.Some(200..<300), .Some(let response)):
8 handler(Response(response))
9 default:
10 let userInfo = [NSLocalizedDescriptionKey: "unresolved error occurred."]
11 let error = NSError(domain: "WebAPIErrorDomain", code: 0, userInfo: userInfo)
12 handler(Response(error))
13 }
14 }
15
16 // ② 失敗時の処理
17 let failure = { (task: NSURLSessionDataTask!, error: NSError!) -> Void in
18 handler(Response(error))
19 }
20
21 // ③ リクエスト
22 let manager = AFHTTPSessionManager()
23 let url = scheme + "://" + host() + request.path()
24 manager.responseSerializer = AFHTTPResponseSerializer()
25 manager.responseSerializer.acceptableContentTypes = request.responseTypes()
26 manager.GET(url, parameters: nil, success: success, failure: failure)
27 }
28}
③の部分は単にAFNetworkingの呼び出しなのでここでは特に触れない。通信のライブラリとしてはAFNetworkingを選択した。同じ作者が書いたSwift版のライブラリ、AlamofireのほうがSwiftらしく書けるのかもしれないけれど、一度も使ったことがなかったので今回はパスした。いずれ書き換えてみてもいいかもしれないと思っている。
次は短い②の部分だけど、これは単にエラーオブジェクトをcallに渡ってきたハンドラに返している。
最後の①はレスポンスが正常に返ってきたときの処理で、ステータスコードとレスポンスから正しくモデルオブジェクトに変換できたかどうかで処理を分岐している。
全体を見ると、個々のリクエストに必要な情報(メソッド、パラメータ、HTTPレスポンスから得たいモデルオブジェクトなど)はリクエストオブジェクトから取り出して使うようになっている。そのためクライアントのコードは全てのリクエストに共通の処理だけ書いておけば良い。
レスポンス
クライアントのcallメソッドに渡すハンドラにはResponse<T>が渡ってくるようになっている。このResponseは値付きenum(というのかなんというのか…?)になっていて、成功時はリクエストオブジェクトが持つレスポンスの型に対応したモデルオブジェクトが、失敗時にはNSErrorを持っていることになる。
1public class Wrapper<T> {
2 public let value: T
3
4 init(_ value: T) {
5 self.value = value
6 }
7}
8
9public enum Response<T> {
10 case Success(Wrapper<T>)
11 case Failure(Wrapper<NSError>)
12
13 init(_ value: T) {
14 self = .Success(Wrapper(value))
15 }
16
17 init(_ error: NSError) {
18 self = .Failure(Wrapper(error))
19 }
20}
クライアントの呼び出し側はこのようになる。response: Response<T>でSwitchして成功時、失敗時の処理をしてあげる感じになる。
1client.call(AzureQueue.ListQueuesRequest(), handler: { response in
2 switch response {
3 case .Success(let wrapper):
4 println(wrapper.value) // AzureQueue.ListQueuesRequest.Response
5 case .Failure(let wrapper):
6 println(wrapper.value) // NSError
7 }
8})
リクエストオブジェクト
さっき書いたようにリクエストオブジェクトには個々のHTTPリクエストで必要な情報を個別に定義して、クライアントから使えるようにしてある。
参考にした記事では
- パス
- メソッド(GET,POSTなど)
- モデルオブジェクトへの変換
- モデルオブジェクトへの型
などが書かれていたが、Azure Storageのクライアントではさらに、
- リクエストBody
- 追加のHTTPヘッダー(BodyのContent-Lengthなど)
- HTTPレスポンスのContent−Type
あたりを追加している。
1public class ListQueuesRequest: Request {
2 public let method = "GET"
3
4 public typealias Response = Collection<Queue>
5
6 public init() {}
7
8 public func path() -> String {
9 return "/?comp=list"
10 }
11
12 public func body() -> NSData? {
13 return nil
14 }
15
16 public func additionalHeaders() -> [String : String] {
17 return [:]
18 }
19
20 public func convertResponseObject(object: AnyObject?) -> Response? {
21 return ResponseUtility.responseItems(object, keyPath: "Queues.Queue")
22 }
23
24 public func responseTypes() -> Set<String>? {
25 return ["application/xml"]
26 }
27}
Promise版の呼び出し
ここまで書いたことでAPIクライアントとしての機能は大体果たせるようになった。
ところでJavascriptなんかではよくあるように、非同期処理を待ってから次の非同期処理を書こうとするととても書きにくいという問題が、Objective-CやSwiftでAPIクライアントを書くときにも現れる。これはまあ放っておいてもいいのだけど使うときに便利なほうがいいと思ったので試しに対応してみることにした。
SwiftでもJavascriptのPromise的なアプローチを使えることができると知っていたので調べてみたところ次のようなライブラリが候補に挙がった。
決め手はなんだったかよく覚えていないが、上に挙げた3つのライブラリを全て試してみて最終的にBrightFuturesを採用してみた。
実装の際に参考にしたリンクはこちら。
BrightFutures版のクライアント
1public class Client {
2 public func future<T: Request>(request: T) -> Future<T.Response, NSError> {
3 let promise = Promise<T.Response, NSError>()
4
5 // ① 成功時の処理
6 let success = { (task: NSURLSessionDataTask!, responseObject: AnyObject!) -> Void in
7 let statusCode = (task.response as? NSHTTPURLResponse)?.statusCode
8 switch (statusCode, request.convertResponseObject(responseObject)) {
9 case (.Some(200..<300), .Some(let response)):
10 promise.success(response)
11 default:
12 let userInfo = [NSLocalizedDescriptionKey: "unresolved error occurred."]
13 let error = NSError(domain: "WebAPIErrorDomain", code: 0, userInfo: userInfo)
14 promise.failure(error)
15 }
16 }
17
18 // ② 失敗時の処理
19 let failure = { (task: NSURLSessionDataTask!, error: NSError!) -> Void in
20 promise.failure(error)
21 }
22
23 // ③ リクエスト
24 let manager = AFHTTPSessionManager()
25 let url = scheme + "://" + host() + request.path()
26 manager.responseSerializer = AFHTTPResponseSerializer()
27 manager.responseSerializer.acceptableContentTypes = request.responseTypes()
28 manager.GET(url, parameters: nil, success: success, failure: failure)
29
30 // ④ Futureオブジェクトを返す
31 return promise.future
32 }
33}
通常版と変わったのはcallメソッドにハンドラを渡さなくなり、代わりにFutureというオブジェクトを返すようになったことで、レスポンスが返ってきたときにはハンドラにモデルオブジェクトやエラーを渡す代わりにPromiseオブジェクトのsuccessやfailureメソッドを呼ぶようになっている。
BrightFutures版を使う側のコード
例えば、
- Queueの一覧を取得した後で、
- Queueを新しく生成し、
- 次にそのQueueを削除する
というAPI呼び出し(意味はないが)をしたいとき、通常版ではこのようになる(ひどすぎる…)。
1func onError(error: NSError) {
2 println(error)
3}
4
5func normal() {
6 let req1 = AzureQueue.ListQueuesRequest()
7 queueClient.call(req1, handler: { response in
8 switch response {
9 case .Success(let wrapper):
10 let req2 = AzureQueue.CreateQueueRequest(queue: "brandnewqueue")
11 self.queueClient.call(req2, handler: { response in
12 switch response {
13 case .Success(let wrapper):
14 let req3 = AzureQueue.DeleteQueueRequest(queue: "brandnewqueue")
15 self.queueClient.call(req3, handler: { response in
16 switch response {
17 case .Success(let wrapper):
18 println("Success!!")
19 case .Failure(let wrapper):
20 self.onError(wrapper.value)
21 }
22 })
23 case .Failure(let wrapper):
24 self.onError(wrapper.value)
25 }
26 })
27 case .Failure(let wrapper):
28 self.onError(wrapper.value)
29 }
30 })
31}
BrightFutures版では煩雑さは残るものの幾分かマシに書けるようになる。
1func promise() {
2 let req1 = AzureQueue.ListQueuesRequest()
3 queueClient.future(req1).flatMap { response -> Future<AzureQueue.CreateQueueRequest.Response, NSError> in
4 let req = AzureQueue.CreateQueueRequest(queue: "brandnewqueue")
5 return self.queueClient.future(req)
6 }.flatMap { response -> Future<AzureQueue.DeleteQueueRequest.Response, NSError> in
7 let req = AzureQueue.DeleteQueueRequest(queue: "brandnewqueue")
8 return self.queueClient.future(req)
9 }.onSuccess { response in
10 println(response)
11 }.onFailure { error in
12 println(error)
13 }
14}
煩雑さの原因になっている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環境でテストを動かすよ、ということのようだった。
そういうわけでSwift版でも同じようにAzure環境でテストを動かすコードを書くことになった。テストコードを書くにあたってBrightFutures版が非常に役に立った。これをしてそれをして、最後にあれをしたらこうなっている、というコードを通常版で書こうとしていたらだいぶ辛いことになっていたと思う。
コード
もう1回貼っておきます。
https://github.com/pi-chan/AzureStorageApiClient
宣伝
途中に出てきた表は自作のMacアプリで作りました。日本語入力で若干不具合があるけどMarkdownで表を書くときにはWebにある表生成ツール(Markdown Tables generator)よりも便利なのでぜひどうぞ。
Table2Text (Markdown, CSV)
カテゴリ: 開発ツール, ユーティリティ
