iOSとRailsを連携させたアプリの簡単なつくり方

以前「スマホ&Heroku連携!事例に学ぶ無駄のないチーム開発の極意」と題してiPhoneアプリとRailsアプリの連携について書かせて頂いたのですが、どうにも具体的な話ができなかったのが心残りでした。なので、具体的なサンプルを元に、もう少し具体的な話をしてみたいと思います。

ソースコードの置き場所

動作例

動作している様子を動画に録って見ました。

Rails側の見所

Rails側の見所は認証部分だと思います。

ApplicationController
class ApplicationController < ActionController::Base
  protect_from_forgery unless: proc { @basic_auth_token_request }
  before_filter :authenticate_user!

  protected
  def authenticate_with_basic
    return unless params['token'] and params['token'] == "token"

    request_http_basic_authentication unless authenticate_with_http_basic do |email, pass|
      begin
        if sign_in(User.authenticate(email, pass))
          logger.info "* Authenticated by basic auth: #{email}"
          @basic_auth_token_request = true
          return
        end

      rescue ActiveRecord::RecordNotFound => exception
        logger.info "[Authenticated by basic auth error] email: #{email} password: #{!!pass} (#{exception.class}: #{exception.message})"
        respond_to do |format|
          format.html { redirect_to root_path, notice: exception.message }
          format.json { render json: { errors: exception.message }.to_json, status: :unauthorized }
        end
      end
    end
  end
end

パラメータにtoken=tokenが設定されている場合にBASIC認証を行う、と言う感じのコードになっています。BASIC認証で通信する場合はCSRFトークンの要求を回避するためにunless条件をつけています。このコードだけだと実際にアクセスされたときにBASIC認証の要求が有効にならないので、以下のようなコードを各コントローラにつけてあげて下さい。

その他のController
prepend_before_filter :authenticate_with_basic

ApplicationControllerのbefore_filterにつけても、もちろん構いません。

設計についてですが、BASIC認証を行う必要があるのはiOSからのアクセスだけなので、UserAgentを見て判定しても良いかも知れません。とはいえこのあたりをあまり絞り込みすぎてもテストがしづらくなる一方なので、何かしらのパラメータがある場合にBASIC認証モードにする、というのがお手軽かなーと思います。

そもそもBASIC認証なんぞ使わない、という論点もあります。その場合はxAuthを使うのがお手軽でしょう。サーバ側にはOAuth Providerとしてのソースコードが増えることになりますが、Railsなら簡単に書けますね。

後はCookieを保持してセッションを張る方法もありますが、AFNetworkingで書く方法が分かりません……ご存知の方がいたら、教えていただけたらありがたいなーと思います。

iOS側の見所

iOS側ではAFNetworkingというライブラリを使って通信を組み立てます。AFHTTPClientクラスを継承したクラスを作って、お好みでサーバとのインターフェースを作っていくのが大体の方針です。

MLNoteClient.h
- (void)setEmail:(NSString *)email password:(NSString *)password;

- (void)getIndexWhenSuccess:(void (^)(AFHTTPRequestOperation *operation, id responseObject))success
- (void)putNoteWithId:(int)noteId
- (void)createNoteWithTitle:(NSString *)title
- (void)destroyNoteWithId:(int)noteId
#import "AFHTTPClient.h"

@interface MLNoteClient : AFHTTPClient

+ (MLNoteClient *) sharedClient;

// Noteの一覧を取得する
                    failure:(void (^)(int statusCode, NSString *errorString))failure;

// 指定したnoteIdのNoteを更新する
                title:(NSString *)title
                 body:(NSString *)body
              success:(void (^)(AFHTTPRequestOperation *operation, id responseObject))success
              failure:(void (^)(int statusCode, NSString *errorString))failure;

// Noteを新規作成する
                       body:(NSString *)body
                    success:(void (^)(AFHTTPRequestOperation *operation, id responseObject))success
                    failure:(void (^)(int statusCode, NSString *errorString))failure;

// 指定したnoteIdのNoteを削除する
                  success:(void (^)(AFHTTPRequestOperation *operation, id responseObject))success
                  failure:(void (^)(int statusCode, NSString *errorString))failure;

@end

今回はNoteモデルに対するCRUDを作ります。サーバと通信するクライアントはシングルトンにしておくのが良いでしょう。

MLNoteClient.m
- (id) init
- (void)setEmail:(NSString *)email password:(NSString *)password
- (void)getIndexWhenSuccess:(void (^)(AFHTTPRequestOperation *operation, id responseObject))success
- (void)putNoteWithId:(int)noteId
- (void)createNoteWithTitle:(NSString *)title
- (void)destroyNoteWithId:(int)noteId
- (int) statusCodeFromOperation:(AFHTTPRequestOperation *)operation
- (NSString *)errorStringFromOperation:(AFHTTPRequestOperation *)operation
#import "MLNoteClient.h"

#define kBaseUrl @"http://iphone-note.dev/"
#define kToken @"token"

@interface MLNoteClient ()
@end

@implementation MLNoteClient

#pragma mark - Class methods

static MLNoteClient* _sharedClient;
+ (MLNoteClient *) sharedClient
{
    if (!_sharedClient) {
        _sharedClient = [[MLNoteClient alloc] init];
    }
    return _sharedClient;
}

#pragma mark - Instance methods

{
    if (self = [super initWithBaseURL:[NSURL URLWithString:kBaseUrl]]) {
        // initialize code
    }
    return self;
}

{
    [self clearAuthorizationHeader];
    [self setAuthorizationHeaderWithUsername:email password:password];
}

                    failure:(void (^)(int statusCode, NSString *errorString))failure
{
    NSDictionary *params = [NSDictionary dictionaryWithObjectsAndKeys:
                            kToken, @"token",
                            nil];
    [self getPath:@"notes.json"
       parameters:params
          success:success
          failure:^(AFHTTPRequestOperation *operation, NSError *error){
              failure([self statusCodeFromOperation:operation], [self errorStringFromOperation:operation]);
          }];
}

                title:(NSString *)title
                 body:(NSString *)body
              success:(void (^)(AFHTTPRequestOperation *operation, id responseObject))success
              failure:(void (^)(int statusCode, NSString *errorString))failure
{
    NSDictionary *note = [NSDictionary dictionaryWithObjectsAndKeys:
                          title, @"title",
                          body, @"body",
                          nil];
    NSDictionary *params = [NSDictionary dictionaryWithObjectsAndKeys:
                            kToken, @"token",
                            note, @"note",
                            nil];
    [self putPath:[NSString stringWithFormat:@"notes/%d.json", noteId]
       parameters:params
          success:success
          failure:^(AFHTTPRequestOperation *operation, NSError *error){
              failure([self statusCodeFromOperation:operation], [self errorStringFromOperation:operation]);
          }];
}

                       body:(NSString *)body
                    success:(void (^)(AFHTTPRequestOperation *operation, id responseObject))success
                    failure:(void (^)(int statusCode, NSString *errorString))failure
{
    NSDictionary *note = [NSDictionary dictionaryWithObjectsAndKeys:
                          title, @"title",
                          body, @"body",
                          nil];
    NSDictionary *params = [NSDictionary dictionaryWithObjectsAndKeys:
                            kToken, @"token",
                            note, @"note",
                            nil];
    [self postPath:@"notes.json"
       parameters:params
          success:success
          failure:^(AFHTTPRequestOperation *operation, NSError *error){
              failure([self statusCodeFromOperation:operation], [self errorStringFromOperation:operation]);
          }];
}

                  success:(void (^)(AFHTTPRequestOperation *operation, id responseObject))success
                  failure:(void (^)(int statusCode, NSString *errorString))failure
{
    NSDictionary *params = [NSDictionary dictionaryWithObjectsAndKeys:
                            kToken, @"token",
                            nil];
    [self deletePath:[NSString stringWithFormat:@"notes/%d.json", noteId]
        parameters:params
           success:success
           failure:^(AFHTTPRequestOperation *operation, NSError *error){
               failure([self statusCodeFromOperation:operation], [self errorStringFromOperation:operation]);
           }];

}


#pragma mark - Helper methods

{
    return operation.response.statusCode;
}

{
    return [[operation.responseData objectFromJSONData] valueForKey:@"errors"];
}

@end

Railsの例と違って、やたらコードが長いのが玉にキズですね。

BASIC認証

BASIC認証は以下のコードで実現しています。

- (void)setEmail:(NSString *)email password:(NSString *)password
{
    [self clearAuthorizationHeader];
    [self setAuthorizationHeaderWithUsername:email password:password];
}

emailとpasswordを設定した時点で、Authorization Headerに値を設定しています。これで通信を行うときはいつでも認証を通すことになります。

エラーコードが返ってきた時のブロック

AFHTTPClientのメソッドのままだと、失敗時の引数がAFHTTPRequestOperationで何とも使いにくいので、上のコードではステータスコードとサーバ側のエラーメッセージを返すようにしています。

- (void)getIndexWhenSuccess:(void (^)(AFHTTPRequestOperation *operation, id responseObject))success
                    failure:(void (^)(int statusCode, NSString *errorString))failure
{
    NSDictionary *params = [NSDictionary dictionaryWithObjectsAndKeys:
                            kToken, @"token",
                            nil];
    [self getPath:@"notes.json"
       parameters:params
          success:success
          failure:^(AFHTTPRequestOperation *operation, NSError *error){
              failure([self statusCodeFromOperation:operation], [self errorStringFromOperation:operation]);
          }];
}

実際に使っているところ

MLMasterViewController.m
- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];

    //----------------------------------------------------
    // 表示時にサーバ上のデータを読み込む
    [SVProgressHUD showWithStatus:@"Loading" maskType:SVProgressHUDMaskTypeBlack];
    [[MLNoteClient sharedClient] getIndexWhenSuccess:^(AFHTTPRequestOperation *operation, id responseObject){
        _notes = [[NSMutableArray alloc] initWithArray:[responseObject objectFromJSONData]];
        [self.tableView reloadData];
        [SVProgressHUD dismiss];
    } failure:^(int statusCode, NSString *errorString){
        // 認証されていない場合はアカウントの設定画面に飛ばす
        if (statusCode == 401) {
            [self.tabBarController setSelectedIndex:1];
        }
        UIAlertView *alert = [[UIAlertView alloc] initWithTitle:errorString message:nil delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil, nil];
        [alert show];
        [SVProgressHUD dismiss];
    }];
}

ブロックで書けるのが良いですねー。

まとめ

認証さえ何とかなれば、比較的簡単にサーバと連携できることが分かりました。あとはクライアントでデータをキャッシュしたりして、無駄な通信を減らしたりする事で、よりUXを高めることができると思います。

この記事が皆様のご参考になれば幸いです。