どうも、「iOS Advent Calendar 2011」5日目担当のfaultierです。つい最近使ったのでNSURLProtocolネタで。
NSURLProtocolって何?
Foundationフレームワークで最初から扱えるプロトコルはhttp、https、ftp、fileの4つ。これ以外のプロトコルでの通信をNSURLConnectionやNSURLDownloadなどで扱う場合や、特定のリクエストに限って特別な処理をしたい場合などに、NSURLProtocolを継承して登録することで使えるようになる。ちなみに、他のアプリからopenURLしたときにアプリを起動させるカスタムURLスキームとはまた別なので注意。こちらはアプリ内でURL Loading Systemを使うときにだけ影響するもの。
使い方
最低限必要なのは、+canInitWithRequest:、+canonicalRequestForRequest:、-startLoading、-stopLoadingの4つ。
まずはこんな感じで、どんなリクエストのときにそのURLProtocolをが処理するのかを決める。
+ (BOOL)canInitWithRequest:(NSURLRequest *)request { return [[[request URL] scheme] isEqualToString:@"udon"]; }
ここでYESを返すとこのクラスがインスタンス化されて通信処理に進む。NOの場合は他に登録されているURLProtocolのこのメソッドが登録時の逆順に呼ばれて行く。この場合はudonというスキーム、例えばudon://marukame/bukkake/coolみたいなURLへのリクエストの時に処理をすることになる。別にこれは独自のスキームである必要ではなく、httpやfileなどをフックすることもできるし、特定のホストや特殊なヘッダが付いている時だけ処理するようなこともできる。
次は+canonicalRequestForRequest:。リクエストをcanonicalな形に変える必要がある場合はここで弄るのだけど、特に何もすることが無ければrequestをそのまま返してやればいい。
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { return request; }
その後実際の通信が始まり、-startLoadingが呼ばれる。NSURLProtocolのオブジェクトは、id<NSURLProtocolClient>のclientと、NSURLRequestのrequestというプロパティを持っているので、requestからどんなリソースが必要なのかを判断して、clientに対してデータを返してやる、という形で通信の中継をする。NSURLProtocolClientのメソッドは大体NSURLConnectionのdelegateと対応しているので、NSURLConnectionでの通信を実装したことがあれば分かるはず。NSURLConnection側でキャンセルされたときはstopLoadingが呼ばれる。簡単な例としては以下のようになる。
- (void)startLoading { // 本来非同期で通信するのだけど、 // この例では単に文字列データを返すだけなので、 // その場で返してしまう NSData *data = [@"うどんが食べたい" dataUsingEncoding:NSUTF8StringEncoding]; NSDictionary *headers = [NSDictionary dictionaryWithObjectsAndKeys: @"text/plain", @"Content-Type", [NSString stringWithFormat:@"%d", [data length]], @"Content-Length", nil]; NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:[self.request URL] statusCode:200 HTTPVersion:@"1.1" headerFields:headers]; // NSURLResponseのオブジェクトを返し、 [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowedInMemoryOnly]; // データを渡し、 [self.client URLProtocol:self didLoadData:data]; // 通信を終了。 // 通信失敗の場合は -URLProtocol:didFailWithError:を呼ぶ。 [self.client URLProtocolDidFinishLoading:self]; } - (void)stopLoading { // この例ではキャンセルしようが無い… // 内部で別なNSURLConnectionを使っていたり、 // NSOperationQueueやGCDなどで非同期処理をしている場合、 // ここでキャンセルの処理を実行する。 }
これだけだとudonスキームはまだURL Loading Systemに登録されていないので、適宜NSURLProtocolの+registerClass:、+unregisterClass:呼んであげることで使えるようになる。その独自スキームを使うUIViewControllerとか、あるいはもうクラスのloadメソッドで登録してしまうとか。
+ (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ [NSURLProtocol registerClass:[self class]]; }); }
使いどころ
それどういうときに使うの?http以外のプロトコルとかそんな実装しないよ?と思うかもしれないけど、意外と使い道がある。UIWebView等での通信は裏側でNSURLConnectionを使っているので、WebView内での通信をフックしてアプリ側で弄ったりできるのだ。例えばCoreDataにあるデータをJSON形式にして返すようにしておくとか、JSからのトリガーでアプリ内のバックグラウンド処理を走らせるとか、画像をファイルキャッシュしておいてオフラインでも表示するとか、複数の異なる形式のWebAPIをプロキシして同じ形で扱えるようにしたり、ということも簡単にできる。
また、逆に「特定のリクエストに限って特別な処理をする」ではなく「特定のリクエストはスルーするけど他はブロックする」のような使い方もできる。最近(というには結構前からだけど)話題になったのは、iPhoneのWebViewにこういう脆弱性がある、という話。
何故デバイスのアドレス帳や着信履歴みたいなのの生のデータにJSからアクセスできる必要があるのか良く分からないし、おかしな仕様だと思うのでバージョンアップで塞がれるだろうとは思うけど、少なくともiOS5.0まではこの問題は残っているので、「アプリ内でUIWebViewにloadHTMLStringさせて、外部から取得したHTMLをfile://等のスキームで表示させている」ような場合には対処する必要がある。簡単な対策としてはきちんとbaseURLを設定しておけばいいのだけど、そうすると今度はCSS、JS、画像などのバンドル内部に持っているリソースが使えなくなる。「外部から取得したHTMLをアプリ内のUIWebViewで表示したいけど、アプリ内の安全なリソースにはアクセスさせたい」場合に、NSURLProtocolで通信をフックする仕組みが使える。
前者の記事だと、NSURLProtocolのサブクラスでバンドル内のファイルを開いて、そのデータを返してしまう、というやり方をしている。また、後者の記事やPhoneGapの実装では、NSURLProtocolの「登録時の逆順に対応できるかどうかチェックする」「YESを返せばそこで止まり、NOを返すと次のNSURLProtocolのチェックに進む」という性質を利用して、「ホワイトリストに載ってないリクエストの場合は、canInitWithRequest:でYESを返した上でエラーなり空レスポンスなりを返し、ホワイトリストに載っている場合はNOを返して通常のhttpやfileプロトコルとして処理させる」という方法でサンドボックス外のリソースにアクセスさせないようにしている。
まとめ
- NSURLProtocolを使えばさくっと独自スキームを定義できるよ
- WebViewみたいに細かく制御しづらいものの内部の通信もフックできるよ
- セキュリティ面でも意味があるよ
という感じなので、是非試してみてください。