Heroku に Sinatra でゴニョゴニョした時の備忘録

GW に親父に頼まれてこんなのを作ったときの備忘録

  • 複数のサイトからデータをスクレイピングして tsv ファイルに書き出し
  • tsv ファイルをまとめて zip
  • これらを Web アプリとして提供

本当は親父の PC ローカルで完結させたかったんだけど、Windows だし、環境構築がダルいしってことで、RubySinatra で 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 が発表した内容 にも登場した NSOperationNSOperationQueue でした。「非同期で実行される処理の塊を直列に順序を決めて実行」だけならそれでも良かったんですが、その処理の塊の結果を次の処理の塊に受け渡す必要がありました。さらにその処理結果によって次以降に実行する処理の塊も振り分けるとか…。基本的に非同期処理のコールバックは Blocks を使うことは僕の中できまっていたので Blocks の入れ子とか地獄なのは分かっていたのでどうにかしたかった。

やりたかったこと要求されてたこと

  • UI から1つのタスク(大)が依頼される
  • そのタスク(大)は一個のメソッドとして実装され、 completionBlock でその結果を UI に返される
  • タスク(大)は実は複数のタスク(中)を数珠つなぎにしたもの
  • たまにタスク(大)はタスク(中)の実行状況に合わせてタスク(中)を追加したりする
  • 実はタスク(中)もタスク(小)の集合だったりする

参考にしたライブラリ

前振りが無駄に長かったですがそのときに参考にしたライブラリを列挙します。

STDeferred

今回の要求を聞いて最初に思い浮かんだのがこのライブラリ。処理結果を次の結果に受け渡しています。 1つのメソッドに Blocks を数珠つなぎになる感じだったのでそのまま使うことはなかったですが、受け渡しかたの参考にさせていただきました。

Sequencer

冒頭にあげた @xcatsan さんのこちらの記事でも紹介されています。参照先に書かれているとおりシンプルだったので参考になりました。STDeferred よりも書き方がシンプルに見えて好みでした。

AAMCommandKit

Serial 実行する Command とか参考にさせてもらいました。

結局こんな実装に

コードは諸事情で出せないのですが、うろ覚えの実装はこんな感じだったです。

  • NSOperationNSOperationQueue っぽいものを自作
  • オレオレ 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

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'

Mountain Lion (Mac OS X 10.8) でマウスカーソルのサイズを標準機能で変える方法

プレゼン等をする際に通常使っているマウスカーソルの大きさでは小さく見えにくい場合があります。調べてみると Mac の標準機能でできるようですが、Mountain Lion (Mac OS X 10.8) から設定場所がちょっと変わったのでメモ。

設定場所

Mac のシステム環境設定のアクセシビリティの中の

f:id:griffin-stewie:20130217222405p:plain

"ディスプレイ" の中に "カーソルサイズ" という項目があります。

f:id:griffin-stewie:20130217222403p:plain

これでカーソルサイズが特大になります。

カーソルサイズを最大にすると細かい物のクリックがしにくいのでプレゼン時のデモ等ではお気をつけください。

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];
    }

MKMapItemopenInMapsWithLaunchOptions: を使うとある程度細かい指定を 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];
    }

サンプルコード

ここにサンプルコードをおいておきます。