Heroku に Sinatra でゴニョゴニョした時の備忘録
GW に親父に頼まれてこんなのを作ったときの備忘録
- 複数のサイトからデータをスクレイピングして tsv ファイルに書き出し
- tsv ファイルをまとめて zip
- これらを Web アプリとして提供
本当は親父の PC ローカルで完結させたかったんだけど、Windows だし、環境構築がダルいしってことで、Ruby で Sinatra で Heroku に設置するかんじになりました。
僕のバックグラウンドとしてはこの程度
- iOS & Objective-C しか知らない
- Ruby は細々と書き捨てなスクリプトは書いたことがある
- JavaScript は大昔に Sleipnir のスクリプトを書いたことがある
ということで
- Ruby でそれなりのコード量を書くのははじめて
- Web アプリ的なのははじめて
- Heroku を使うのも Hello World 以外で使うのははじめて
という感じだったので大分屈折した感じで間違ったことを書いているとは思います。
スクレイピング
基本的に Nokogiri でやりました。このライブラリは割とよく使っているので慣れてはいました。DOM の指定はなんとなく css セレクタを使ってます。Chrome とかの Web Inspector とかだと Copy XPath とかあるからそっちの方が楽だったかもとか思ったりもしますがまあ勉強になるのでいいかなぁと。
ハマったのはあるサイトの pre タグ内に "<1234>" みたいな文字があったのですが Nokogiri だとこの文字列が欠落してしまうことがありました。Hpricot を使ってみたらどうなるかなぁと思ったら、inner_text メソッドを呼び出すと Hpricot 内部で落ちてしまって万事休す。かと思いましたが、以下のようにして open して取ってくるときにバイナリで取ってくるようにしたらなんとか期待通りの動作になりました。
Hpricot(open(url, "r:binary").read.toutf8)
Sinatra
Background Job
構成としては以下のようなシンプルな画面です。
- スクレイピング開始用のボタン
- 生成できた zip ファイルをダウンロードするボタン
問題だったのはスクレイピング開始周りです。当然ながらスクレイピングは時間がかかります。同期的に処理をしてしまうと timeout してしまってどうにもなりません。iOS 的なコードでいうところのこんな感じの処理を書きたくなりました。
- (id)startScraping { self.cleanUp; dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); dispatch_async(queue, ^(void) { [self scrapeWithURL:url]; }); return response; }
スクレイピング自体はバックグラウンドに回してしまってとりあえずはレスポンスを返しておくイメージです。調べてみると Heroku で Sinatra を動かす場合 EventMachine の中で動いているらしいので
EM::defer do doHeavyTask() end
とすると Block 内部は別スレッドで実行できるようです。EM:defer は実行ブロックと完了ブロックを渡せるので iOS でやっていたようなノリでできます。
iOS で inline block を書くときはこんな感じで書けますが、
id(^completionBlock)(void) = ^(void) { return @"test"; };
Ruby だとこんな感じで書けるんですね。
callback = proc do "" end
HTML
最初は erb を使っていましたが、
- 構造は同じ
- ただし、具体的な文字等はバラツキあり
- 上記を複数回繰り返し出力したい
ということを実現する方法がよくわからなかったのですが、ググっていると haml を使って自分のやりたいことと似ていることをしているサンプルを見つけたのでやってみようと思ったところ実際にできたので途中から haml に変更しました。
CSS
よく分からないので Twitter Bootstrap を使って適当にサンプルから HTML をコピペしました。
まとめ
初体験でしたが、GW のアソビとしてはなかなか楽しかったです。iOS で学んだものが多少なりとも活きている気がしたのがうれしかったです。課題としてはスクレイピングが終わったらページの書き換えを出来るようにしたいです。誰かやり方教えてください。
複数の非同期処理を前の処理結果を受けとりつつ数珠つなぎに直列に書きたかった時に参考にしたコード
以前
@xcatsan さんのこちらの記事をさっきみてて思い出したので備忘録として。
以前、非同期で実行される処理の塊を直列に順序を決めて実行したいというシチュエーションがありました。そのときにぱっと浮かんだのは昨日の Cocoa 勉強会関西で@yashigani が発表した内容 にも登場した NSOperation
と NSOperationQueue
でした。「非同期で実行される処理の塊を直列に順序を決めて実行」だけならそれでも良かったんですが、その処理の塊の結果を次の処理の塊に受け渡す必要がありました。さらにその処理結果によって次以降に実行する処理の塊も振り分けるとか…。基本的に非同期処理のコールバックは Blocks を使うことは僕の中できまっていたので Blocks の入れ子とか地獄なのは分かっていたのでどうにかしたかった。
やりたかったこと要求されてたこと
- UI から1つのタスク(大)が依頼される
- そのタスク(大)は一個のメソッドとして実装され、 completionBlock でその結果を UI に返される
- タスク(大)は実は複数のタスク(中)を数珠つなぎにしたもの
- たまにタスク(大)はタスク(中)の実行状況に合わせてタスク(中)を追加したりする
- 実はタスク(中)もタスク(小)の集合だったりする
参考にしたライブラリ
前振りが無駄に長かったですがそのときに参考にしたライブラリを列挙します。
STDeferred
今回の要求を聞いて最初に思い浮かんだのがこのライブラリ。処理結果を次の結果に受け渡しています。 1つのメソッドに Blocks を数珠つなぎになる感じだったのでそのまま使うことはなかったですが、受け渡しかたの参考にさせていただきました。
Sequencer
冒頭にあげた @xcatsan さんのこちらの記事でも紹介されています。参照先に書かれているとおりシンプルだったので参考になりました。STDeferred よりも書き方がシンプルに見えて好みでした。
AAMCommandKit
Serial 実行する Command とか参考にさせてもらいました。
結局こんな実装に
コードは諸事情で出せないのですが、うろ覚えの実装はこんな感じだったです。
NSOperation
とNSOperationQueue
っぽいものを自作- オレオレ Operation クラスには前の処理結果を取り出すプロパティと自分の処理結果を入れるプロパティを設けた
- オレオレ Queue は突っ込まれた Operation を実行し、キューイング時に、実行済みの Operation の処理結果を取り出して次の Operation の previousResult 的なプロパティに突っ込んでから次の Operation を実行
- オレオレ Queue には Delegate が実装してあり Operation をキューイングする時に前の Operation と次の Operation を垣間見れてフックしたりもできる
- Operation 内部では execute されたときに previousResult を見つつその内容によって実行内容を調整
- 例えば前の処理がエラーだったら、そのエラーを自身の結果として詰め込むとか。つまり、1, 2, 3, 4 と処理が並べてあっても、もし 2 でこけてたらそのまま 2 がコケたよという結果をそのまま伝搬することで最終的に結果をコールバックされた呼び出し元(タスク(大)の呼び出し元)では誰がコケたのかわかる。
- Queue には全 Operation の実行が完了したことを伝える Delegate を実装
懺悔
参考にしたライブラリを複雑そうだ思ったりもした割に、自分の実装の方がはるかに複雑です。ごめんなさい。
継承とかも入ってたので複雑度マックスです。ごめんなさい。
全体像の把握がツライです。ごめんなさい。
でも、そのプロジェクト内では、新規タスクの追加や仕様変更にはそこそこ強かったのでこれはこれでよかったかなと思ってます。
次回似たような要求があったときにはもっと整理した形で実装したいと思っています。
最近よくやってる Blocks の使い方
Blocks 怖いような、便利なような。そんな物ですがみなさんはどんな感じに使っていますか?
僕は最近こんな感じのメソッドを実装して使ってます。
UINavigationController *nvc = [DetailViewController navigationBasedViewControllerWithConfigurationBlock:^(DetailViewController *viewController) { NSDate *object = _objects[indexPath.row]; viewController.detailItem = object; }]; [self.navigationController presentViewController:nvc animated:YES completion:NULL];
特に凄く便利かというとそうでもないと思うんですけど、典型的なモーダルな ViewController を表示させるコードってこんな感じだと思います。
DetailViewController *vc = [[DetailViewController alloc] init]; NSDate *object = _objects[indexPath.row]; vc.detailItem = object; UINavigationController *nvc = [[UINavigationController alloc] initWithRootViewController:vc]; [self.navigationController presentViewController:nvc animated:YES completion:NULL];
要するにこんなことをしてるんですよね。
- 表示させたい ViewController クラスを生成
- 表示させたい ViewController クラスに何か値をセット
- NavigationController を生成
- 表示
いろいろとタイプすることが多いのをちょっとだけ省略したくてこうしてます。
実装はこんな感じです
+ (UINavigationController *)navigationBasedViewControllerWithConfigurationBlock:(void(^)(id viewController))configurationBlock { UIViewController *vc = [[[self class] alloc] init]; if (configurationBlock) { configurationBlock(vc); } return [[UINavigationController alloc] initWithRootViewController:vc]; }
引数で受けた Block は同期的な感じで使うのでコピーしてないので循環参照を気にする必要もないですね。 この例では UIViewController の生成を init にしちゃってますがそのあたりは実際の実装との相談でバリエーションを持たせてもいいかもしれないです。 Block に渡す引数の型は id 型にしておけば、呼び出し側で任意のクラス型に書き換えられるので、ブロック内でキャストする必要もないです。 カテゴリメソッドとして実装するとある程度汎用的に使えます。
思いつきで進化版
もっと発展させて、さっき思いつきで実装したカテゴリメソッドはこんな感じで使います。
__weak typeof(self) weakSelf = self; UINavigationController *nvc = [DetailViewController cs_navigationBasedViewControllerWithConfigurationBlock:^(DetailViewController *viewController) { NSDate *object = _objects[indexPath.row]; viewController.detailItem = object; } wantsDismiss:^(DetailViewController *viewController) { [weakSelf.navigationController dismissViewControllerAnimated:YES completion:^{ // do something NSLog(@"%s dismissViewControllerAnimated: %@", __PRETTY_FUNCTION__, weakSelf); }]; }];
昔、師匠に教えてもらったんですけど「モーダルを閉じるのはモーダルを出した側がやる方がいい」と。Apple のサンプルでも Delegate 経由でモーダルを出した側からモーダルを閉じるようにしているのがあります。あれって実装するのが面倒くさくてついついモーダル側で自身を閉じてしまいがちでした。このカテゴリメソッドではモーダル側の例えば cancel ボタンなりを押されたときにインスタンス生成時に渡された Block を呼ぶことで呼び出し側に閉じるか否かを委譲させています。さすがに今回は引数として受けた wantsDismiss Blcok は Copy して保持しているので循環参照対策として weak な self を使っています。実装はこんな感じです。
#import "UIViewController+CSAdditions.h" #import <objc/runtime.h> static char kUIViewControllerCSAdditionsKey; @implementation UIViewController (CSAdditions) + (UINavigationController *)cs_navigationBasedViewControllerWithConfigurationBlock:(void(^)(id viewController))configurationBlock { UIViewController *vc = [[[self class] alloc] init]; if (configurationBlock) { configurationBlock(vc); } return [[UINavigationController alloc] initWithRootViewController:vc]; } + (UINavigationController *)cs_navigationBasedViewControllerWithConfigurationBlock:(void(^)(id viewController))configurationBlock wantsDismiss:(void(^)(id viewController))callbackBlock { UIViewController *vc = [[[self class] alloc] init]; if (configurationBlock) { configurationBlock(vc); } if (callbackBlock) { [vc cs_setWantsDismisCallbackBlock:callbackBlock]; } return [[UINavigationController alloc] initWithRootViewController:vc]; } - (void)cs_setWantsDismisCallbackBlock:(void(^)(id viewController))callbackBlock { objc_setAssociatedObject(self, &kUIViewControllerCSAdditionsKey, callbackBlock, OBJC_ASSOCIATION_COPY_NONATOMIC); } - (void(^)(id viewController))cs_wantsDismisCallbackBlock; { return (void(^)(id viewController))objc_getAssociatedObject(self, &kUIViewControllerCSAdditionsKey); }
// DetailViewController 側の使用例 // DetailViewController の leftBarButtonItem に設置した Cancel ボタンを押したときに呼ばれるメソッド - (void)wantsDismis:(id)sender { /// 登録されている Block を呼び出して引数として自身をわたす [self cs_wantsDismisCallbackBlock](self); }
associatedObject を使って wantsDismisCallbackBlock を保持するようにしています。保持した wantsDismisCallbackBlock の解放をどうしようかと思いましたが、明示的に解放しなくても自分自身が解放されるときにいっしょに解放してくれるようだったので特に何もしていません。
Blocks ってやっぱり便利
とはいえ、Blocks は万能ではないので traditional な delegate パターンや target-action パターンなんかもうまく活用していきたいですね。
簡単なバージョン番号の比較方法
最近、頑張って比較してるのにバグっている見たコードをみました。簡単な比較方法を知らない人もいるのかもと思って書いてみます。
たまに求められるバージョン番号比較
何らかの理由でバージョン番号の比較をしたいときってありますよね?○○バージョン以上なら△△するとか。求められる内容によって
- Class オブジェクトが生成できるかどうか?
- respondsToSelector: で期待しているセレクターが呼べるか?
とか方法はいろいろありますが、今回は文字列での比較の話です。
文字列ベースで比較
文字列ベースでのバージョン番号の比較ってどうしてますか?
integerValue とか floatValue とかで数値化して比較してますか?最近見たコードでもそのような比較をしてました。でも、バージョン番号って 「5.1」とかなら単純に数字にしてしまって比較できますが、「5.1.1」とかだと少数でもないですよね?バラして数値化して比較することもできますがちょっと面倒ですし、バグりそうです。実際に僕が見たコードはバグってました。
NSNumericSearch
こんなときは NSString の
- (NSComparisonResult)compare:(NSString *)aString options:(NSStringCompareOptions)mask
を使うと簡単です。
compare:options: の option 引数で NSNumericSearch を指定すると数字を含んだ文字列を数字として評価して結果を返してくれます。これは Mac の Finder でのファイル名ソートにも使われています。 "file_1.txt", "file_5.txt", "file_10.txt" とかってファイルをソートするとちゃんと 1 の 後には 5, 10 と続きますよね?OSとかによっては 1 の後が 10, 5 ってソートされて残念な気分になったことがあります。
具体例 1
NSComparisonResult result = [@"4.1.1" compare:@"3" options:NSNumericSearch]; // result is "NSOrderedDescending"
上記の場合は Descending なので
"4.1.1" > "3"
ということです。
具体例 2
NSComparisonResult result = [@"5.1.1" compare:@"6.0" options:NSNumericSearch]; // result is "NSOrderedAscending"
つまり
"5.1.1" < "6.0"
具体例 3
NSComparisonResult result = [@"6.1" compare:@"6.1" options:NSNumericSearch]; // result is "NSOrderedSame"
つまり
"6.1" = "6.1"
ですね。
具体例 4
こんな変な比較も人間的な感覚にマッチした比較結果を返してくれます。
NSComparisonResult result = [@"4.0.0.1.0.0" compare:@"4.0.0.0" options:NSNumericSearch]; // result is "NSOrderedDescending"
具体例 5
僕はこんな感じのコードを UIDevice のカテゴリメソッドで生やしています。
+ (BOOL)cs_isOSVersionGreaterThanOr:(NSString *)version { NSString *currentVersion = [[self currentDevice] systemVersion]; NSComparisonResult result = [version compare:currentVersion options:NSNumericSearch]; return (result == NSOrderedAscending || result == NSOrderedSame) ? YES : NO; }
注意点
- 引数に渡す文字列が nil の場合には戻り値が不定(Reference より)
- レシーバ側が nil の場合は 0 が返るので結果が NSOrderdSame になる
- [@"6" compare:@"6.0" option:NSNumericSearch] の結果が NSOrderedAscending を返してしまう(2013/02/25 追記)
注意点が増えました 3番目の問題は @norio_nomura さんに指摘していただきました。これは地味にうざい挙動です。やらかしてしまいそうです。
そつなく
compare:options: は Foundation.framework で結構古くから実装されていますし Mac OS でも実績があるはずの枯れたものですのでバグがある可能性はあまり考えられません。注意点に上げたポイントさえ気をつければヘタに文字列を分割して数値化して比較するコードを手で書くよりも簡単で無難ですのでどんどん使いましょう。
Placeholder 付き UITextView
UITextView って UITextField みたいに Placeholder がないんですね。つい先日まで気づきませんでした。必要とするようなシチュエーションがないとかそのような UI が iOS 的にナシなのかなぁと思っていたらカレンダーアプリのイベント追加画面で Apple 自身が実装してました。しょうがないので作りました。良かったら使ってみてください。CSTextView から CSNPlaceholderTextView に名称を変更しています。
CSNPlaceholderTextView
特徴
- 挙動は Calendar.app の イベント追加画面 (
EKEventEditViewController
)のメモの部分に似せている - placeholder の表示位置は Caret の位置に自動調整
- placeholder の font は textView 側の
font
プロパティに連動 - UITextView のリプレイスとして使えるように UITextViewDelegate を汚染しない
ということで、Placeholder として使っている Label の textColor は UITextField の placeholder プロパティの説明にあるように
The placeholder string is drawn using a 70% grey color.
な色にしてあったり、UITextViewDelegate を汚染しないように UITextiView が準拠している UITextInput プロトコルのメソッドをオーバーライドするようにして表示位置や出し入れを調整しています。
ハマったのはplaceholder の表示の On/Off を
- (CGRect)caretRectForPosition:(UITextPosition *)position
だけでやっていましたが、入力済みのテキストを Cut してから Undo Cut して Undo Typing したあとに placeholder が消えないことでした。
- (UITextRange *)selectedTextRange
メソッドはこの問題の動作でも呼ばれることがわかったのでここでも On/Off を行うことで回避しました。ただ、この方法が UITextInput とかのテキスト入力系 の振る舞いとして適切かどうかはちょっとわかりません。
追記
「text プロパティでテキストを代入すると placeholder が消えない」というバグがあったので修正しておきました。
CocoaPods 対応
pod 'CSNPlaceholderTextView', '~> 0.0'
iOS6以降でのMapアプリの起動方法
はじめに
ここでは、アプリ内部で使用する Map 機能(MapKit)のことは特に言及せず、アプリから外部の Map アプリ(標準 Map.app と GoogleMap.app)の起動について書きます。
現状
標準 Map.app と GoogleMap.app
iOS 6 以前までは純正の Map.app は内部では Google が使用されていました。iOS 6 からは Apple 独自に切り替えられました。2013/02/15 時点では改善をされてきてはいるものの以前の Google ベースの Map.app と比べると内容的に見劣りする状況です。そこで Google から 3rd Party アプリとして GoogleMap.app がリリースされました。このアプリは標準インストールされていたころの Map.app よりもパワーアップしていて評判の高いものになっています。
ニーズ
こうなるとやはりニーズとしては以下のようになります。
- 使いやすい GoogleMap.app を呼び出して使いたい
- GoogleMap.app をインストールしていないユーザー向けにも考慮して標準 Map.app も使いたい
対応方法
GoogleMap.app を開く挙動に関して
- iOS バージョン問わず
- GoogleMap.app がインストールされているかを canOpenURL: で確認し、GoogleMap.app で今まで同じような挙動をさせることができました。
iOS 標準 Map.app を開く挙動に関して
- iOS 6 以前の場合
- Map.app は内部実装が Google のものなので昔と同じ挙動(既存の実装)で Map.app を開く
- iOS 6 以降の場合
- 比較的単純なパラメータなら iOS 6 以前と同じでも問題はないが一部以前まで使えていたパラメータが使えないものもある
- iOS 6 から追加された
MKMapItem
クラスの機能を使ってより細かいオプションを設定して iOS 標準 Map.app (Apple) を開く
コード例
iOS の標準 Map.app を開く
/** MKMapItem が使えるか確認し、使える場合はそれを利用(事実上 iOS 6 以前か以降かの判定) 使えない場合(事実上 iOS 6.x 以前)の場合は昔からの挙動 */ Class itemClass = [MKMapItem class]; if (itemClass) { /// MKPlacemark を作る CLLocationCoordinate2D coordinate = CLLocationCoordinate2DMake(self.place.latitude, self.place.longitude); MKPlacemark *placemark = [[MKPlacemark alloc] initWithCoordinate:coordinate addressDictionary:self.place.addressDictionary]; /// MKPlacemark から MKMapItem を作る MKMapItem *item = [[MKMapItem alloc] initWithPlacemark:placemark]; item.name = self.place.name; /// Apple Map.app に渡すオプションを準備 /// Span を指定して Map 表示時の拡大率を調整 MKCoordinateRegion region = MKCoordinateRegionMakeWithDistance(coordinate, 250, 250); MKCoordinateSpan span = region.span; /// Apple Map.app を開く BOOL result = [item openInMapsWithLaunchOptions:@{ MKLaunchOptionsMapSpanKey : [NSValue valueWithMKCoordinateSpan:span], MKLaunchOptionsMapCenterKey : [NSValue valueWithMKCoordinate:coordinate] }]; if (result == NO) { UIAlertView *alert = [[UIAlertView alloc] initWithTitle:nil message:@"Apple Map.app を開けませんでした" delegate:nil cancelButtonTitle:@"閉じる" otherButtonTitles:nil]; [alert show]; } } else { NSString *url = [NSString stringWithFormat:@"http://maps.apple.com/?ll=%f,%f&q=%@", self.place.latitude, self.place.longitude, self.place.escapedName]; NSURL *URL = [NSURL URLWithString:url]; [[UIApplication sharedApplication] openURL:URL]; }
MKMapItem
の openInMapsWithLaunchOptions:
を使うとある程度細かい指定を Apple Map.app に対して渡せます。openInMapsWithLaunchOptions:
で span をしていすると表示された時の大きさ(ズーム具合)を調整できます。MKCoordinateSpan
の値は文系の私にはよく分からない感じです。実際の利用シーン的には「任意の地点を軸に 500m くらいの範囲で表示したい」というのが多いと思います。このメートルで指定するには球体である地球を考慮してうんぬんかんぬんと私にとってはよく分からない計算をしないといけないです。簡単に済ませる方法としては MKCoordinateRegionMakeWithDistance
関数を使って得られる MKCoordinateRegion
から MKCoordinateSpan
を取得するのが簡単でわかりやすいです。
Google Map.app を開く
こちらを参考に URL Scheme を使って Google Map.app を開く。
NSString *url = [NSString stringWithFormat:@"googlemaps://?q=%f,%f(%@)", self.place.latitude, self.place.longitude, self.place.escapedName]; NSURL *URL = [NSURL URLWithString:url]; if ([[UIApplication sharedApplication] canOpenURL:URL]) { [[UIApplication sharedApplication] openURL:URL]; } else { UIAlertView *alert = [[UIAlertView alloc] initWithTitle:nil message:@"Google Map.app がインストールされていません" delegate:nil cancelButtonTitle:@"閉じる" otherButtonTitles:nil]; [alert show]; }
サンプルコード
ここにサンプルコードをおいておきます。