ぴよログ

↓に移転したのでこっちは更新されません、多分。

iOSでUIWebView内のリンクをタップしたらNavigationで画面遷移させる実験

しばらくiOSから離れていてSwiftはほとんど触ったことないのでとりあえずObjective-Cでやりました。このあとSwiftで実装し直そうと思ってるけれど、一旦エントリにします。

いつだったかDHHが既存のWebサイトを活かしてアプリ作るよーみたいなことを言ってたのを覚えていたので、今回は「いや、実際どうやってやるんだろうな」というのを考えてみたわけです。

何をやりたいか

こういうページ遷移がある普通のWebサイトがあるとして、

このページをiOSのUIWebViewで表示するんだけど、リンクをタップしたときに新しいViewControllerを作ってNavigationで遷移するということをやってみます。普通にやるとリンクをタップしたら同一WebView内画面遷移するところを、あたかもiOSのネイティブのように動かしてみようということね。

結論からいうとそれは可能です。↓のアニGIFを見てもらえばリンクを押したときにナビゲーションで次のビューがスタックしてくるのがわかると思います。

こうすることでWebにコンテンツを自由にデプロイしつつ、ユーザー体験はネイティブに近づけられるんじゃないかと。

こんなことはすでに昔からみんながやってたりしたかもしれないけど、最近は端末も速くなっているし、個人的にはWebView+ナビゲーションってあまり見たことがない気がするのでその部分の実験ということで見てもらえると良いです。

Webページの準備

まずはHTML側のソースです。ここで見るべきは、<a>タグについてるcallback-to-iosクラスぐらい。

<!DOCTYPE html>
<html>
  <body>
    <p>1ページ目</p>
    <a href="/secondpage.html" class="callback-to-ios">2ページ目ヘ</a>
  </body>
</html>

続いてJavascriptです。こっちが肝ね。

$ ->
  reportBackToIOS = (href)->
    iframe = $('<iframe />').attr('src', "callback://" + href)
    $('body').append(iframe)
    iframe.remove()
    iframe = null

  $('.callback-to-ios').click ->
    reportBackToIOS($(@).attr("href"))
    false

まずリンクのクリック時にreportBackToIOSを呼び出した上で、falseを返してリンクを辿らないようにしてあります。

reportBackToIOSは名前の通りiOSにリンクがタップされたことを知らせる役割を担っています。iOSのUIWebViewではWebView内でURLの読み込みが発生する直前にコールバックを通るんですが、ここではそれを利用しています。

具体的には、iOSに渡したい情報をURL(の一部)として持ったiframeを作って一旦HTMLのbodyに追加し、すぐに削除していますね。iframeがbodyに追加されたとき、srcアトリビュートで持っているURL(ここでは、'callback://' + 遷移先のURL)をiframe内で開こうとするため、WebViewのコールバックが呼ばれることになるわけです。

iOS側の対応

ViewControllerに適当にUIWebViewを置いてあり、ViewControllerViewControllerの遷移をするSegueがnextという名前で定義されているとします(実際にはViewControllerからViewControllerへのSegueは定義できないので、隠しボタンからViewControllerへのSegueです。)。


#define HOST @"http://localhost:4000"

- (void)viewDidLoad {
    [super viewDidLoad];

    self.webView.delegate = self;
    NSString* urlstring = NULL;
    NSString* path = self.segueOptions.stringValue;
    if(path) {
        urlstring = [NSString stringWithFormat:@"%@%@", HOST, path];
    } else {
        urlstring = [NSString stringWithFormat:@"%@%@", HOST, @"/"];
    }

    NSURL* url = [NSURL URLWithString:urlstring];
    NSURLRequest* urlRequest = [NSURLRequest requestWithURL:url];
    [self.webView loadRequest:urlRequest];
}

肝心のコールバックはこんな感じで実装しておきます。

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
    if ([[[request URL] scheme] isEqualToString:@"callback"]) {
        NSString* href = [[[request URL] absoluteString] substringFromIndex:11];
        [self performSegueWithIdentifier:@"next" options:href];
        return NO;
    }
    return YES;
}

shouldStartLoadWithRequestは新しいリクエストが来た時に通るコールバックで、何もしないときはYESを返すことで読み込みをスタートさせることができるわけですが、今回はcallback://で始まるURLの場合だけはnextという名前のSegueを実行してNOを返すことにします。NOを返せばURLの読み込みは行われないので、callback://****みたいなURLを処理しちゃってエラー、ということも起こりません。

これで最初に紹介したGIFアニメのようなページ遷移ができるようになりました。便利なような、使いどころ難しいようなそんな感じですが、ネイティブっぽく動かすという目的はなんとなく果たせていますね。

参考リンク

reportBackToIOSのところはStackOverflowのこの質問を参考にしました。

ios - How can I reliably detect a link click in UIWebView? - Stack Overflow

markdown_section_numbering gemをアップデート

昔こんなのを作ったわけだけど、本当に少しだけ足りないなと思うところがあったのでアップデートしました。

マークダウンに見出し番号をつけるRuby Gem書いた - PILOG


上の記事で、「Automatorと連携して選択中のテキストを変換する」っていう使い方について書いたのだけれど、それをやるには標準入力を受け取ってgemのクラスに渡して標準出力するっていうスクリプトをわざわざgem外で用意する必要があったわけです。

そのときは気が回っていなかったんだけど、そんな機能はgemに実行ファイルとして含めればいいということにようやく気がついたので今回のアプデに至りました。

markdown_section_numbering | RubyGems.org | your community gem host

バージョン1.1をインストールするとmarkdown-section-numberingというコマンドが使えるようになります。このコマンドにMarkdown形式のテキストを与えると番号付きのマークダウンが標準出力に返ってきます。

中身は単純でこんな感じ。

#!/usr/bin/env ruby

begin
  require 'markdown_section_numbering'
rescue LoadError
  require 'rubygems'
  require 'markdown_section_numbering'
end

$stdout.write MarkdownSectionNumbering.convert($stdin.read)

Effective Ruby

Effective Ruby

Rubyで濁点/半濁点が分離した文字を1文字に統一するには

Macのファイル名周りで発生するのでハマったことがある人もいると思うんだけど、ひらがなやカタカナの濁音や半濁音の文字を表現するのに2文字分使われていることがあったりする。

例えばFinderでぱぴぷぺぽ.txtというファイルを作ってEmacsで開いてみるとこんなふうになる。

Rubyでもこの手のファイル名から文字列を作ったりすると同じようなことが起こる。例えばこんな感じで。

同じ文字列に見えて全く違うってことが起こる。こういう文字列がデータベースに入っていると検索にかからないことがあるし他にも色々困ったことになりそうな感じがする。

なお、この手の話を真面目に語るには僕の知識は足りないので今回はどうやって回避するかを書くだけに留めるけど、軽く触れておくとUnicodeの正規化に関係する現象らしいということがわかった。

Unicode正規化 - Wikipedia

対策

このタイプの文字列が入力として入ってきそうな箇所で正規化の形式を変換しておくことで対処できた。変換にはActiveSupportメソッドを使用した。

normalize (ActiveSupport::Multibyte::Unicode) - APIdock

# 例えばファイル名から文字列をとってくる
name = File.basename(textfile, '.txt') # "は゜ひ゜ふ゜へ゜ほ゜"
name = ActiveSupport::Multibyte::Unicode.normalize(filename, :c) # "ぱぴぷぺぽ"

Azure Storage QueueでBase64関係の例外

バッチ処理のためのメッセージキューとしてMicrosoftのAzure Storage Queueを使っている。C#での使い勝手は言わずもがなだし、Rubyでも最低限のSDKが提供されているのでAWSと比べて使い勝手が悪いということはない。

ただ、Rubyでメッセージを追加してC#でメッセージを取り出すということをやっていたらエンコード関係ではまったのでその部分について今日はメモしておきたい。

例外が発生するケース

Rubyでメッセージ追加

基本的には公式のドキュメントを参考にした。

キュー サービスを使用する方法 (Ruby) | Microsoft Azure

メッセージを追加はRailsアプリケーションから行いたかったので、上のドキュメントを参考にしてこんな風にかいた。

# config/initializer/azure.rb
Azure.configure do |config|
    config.storage_account_name = ENV['AZURE_STORAGE_ACCOUNT']
    config.storage_access_key   = ENV['AZURE_STORAGE_ACCESS_KEY']
end

AZURE_QUEUE_SERVICE = Azure::QueueService.new
AZURE_QUEUE = ENV['AZURE_QUEUE']
AZURE_QUEUE_SERVICE.create_queue(AZURE_QUEUE)
# in controller
AZURE_QUEUE_SERVICE.create_message(AZURE_QUEUE, @model.id.to_s)

C#でメッセージ取り出し

同じく公式ドキュメントを参考にして、メッセージを取り出す部分を書いた。

private void Func()
{
    var sc = new StorageCredentials( Constants.AZURE_STOREGE_ACCOUNT, Constants.AZURE_STOREGE_ACCSESS_KEY );
    var storageAccount = new CloudStorageAccount( sc, true );
    var queueClient = storageAccount.CreateCloudQueueClient();
    var queue = queueClient.GetQueueReference( Constants.AZURE_QUEUE );
    queue.CreateIfNotExists();
    var timeSpan = new System.TimeSpan(1, 0, 0);
    var message = queue.GetMessage( timeSpan );
    messsage.AsString;
}

例外発生

実装はめちゃくちゃ簡単だしこれで動くだろうと思ったのだが、message.AsStringの部分でBase-64 文字配列または文字列の長さが無効です。という例外が発生してしまった。しかもAzure Storage Explorerなどのツールでも同じ例外が起こった。

SDKのソースを見てみたところ、メッセージにはエンコードした文字列かそうでないものかのタイプを持っているらしいが、どうやら今回のケースでは部エンコードされていない文字列であるにもかかわらず、タイプがエンコード済となっていて、想定外のコードを通ることになってしまっていたらしい。

azure-storage-net/CloudQueueMessage.Common.cs at master · Azure/azure-storage-net

/// <summary>
/// Gets the content of the message, as a string.
/// </summary>
/// <value>A string containing the message content.</value>
public string AsString
{
    get
      {
          if (this.MessageType == QueueMessageType.RawString)
              {
                  return this.RawString;
              }
          else
              {
                  byte[] messageData = Convert.FromBase64String(this.RawString);
                  return utf8Encoder.GetString(messageData, 0, messageData.Length);
              }
      }
}

回避策

これもソースをみたらわかったんだけど、RubySDKでエンキューするときにエンコードオプションを指定できるみたいだった。

def create_message(queue_name, message_text, options={})
  query = { }
  unless options.empty?
    query["visibilitytimeout"] = options[:visibility_timeout] if options[:visibility_timeout]
    query["messagettl"] = options[:message_ttl] if options[:message_ttl]
    query["timeout"] = options[:timeout].to_s if options[:timeout]
  end
  uri = messages_uri(queue_name, query)
  body = Serialization.message_to_xml(message_text, options[:encode])
  call(:post, uri, body, {})
  nil
end

options[:encode]の部分!

そういうわけで、最初の実装にオプションを追加してこうしてあげればよかった。

# in controller
AZURE_QUEUE_SERVICE.create_message(AZURE_QUEUE, @model.id.to_s, encode: true)

例外もでなくなりました。めでたし!

ActiveRecordの日付カラムでレコードを絞り込める by_star gem

久々に便利なの見つけた。有名だったりするのかな?

radar/by_star

by_starはモデルの絞り込みに使えるgemで、ActiveRecordとMongoidで使える。

ある期間内のレコードだけを表示したり集計を取ったりというときに使える。自分でも大したコードにはならないんだけど汎用的なものなのでこのgemを使うのが良いでしょう。

githubのREADMEを見れば一目瞭然なのだけど、一応紹介しておく。

Post.by_year(2013)                           # all posts in 2013
Post.before(Date.today)                      # all posts for before today
Post.yesterday                               # all posts in 2013
Post.between_times(Time.zone.now - 3.hours,  # all posts in last 3 hours
                   Time.zone.now)
@post.next                                   # next post after a given post

カラムを指定しない場合はcreated_atが使われる。自分で定義したカラムを使いたいなら次のようにすればOK。

Post.by_year(2013, field: :hogehoge_at) # hogehoge_at という Date/DateTimeなカラムを使用

こういう絞り込みでよくあるのは○年○月のデータ一覧みたいなのだと思うのでそのやりだけ簡単に書いておく。

Post.by_year # 今年のpost
Post.by_month # 今月のpost
Post.by_month(4) # 今年の4月
Post.by_month(4, year: 2012) # 2014年の4月

自前カラムを使うならscopeにしとくと便利だと思う。

class Post < ActiveRecord::Base
  scope :by_year_month, ->(y, m) {
    by_month(m, year: y, field: :hogehoge_at)
  }
end

実装を見てみると最終的にはbetween_times_queryていうメソッドに行き着くっぽかった。

def between_times_query(start, finish, options={})
  start_field = by_star_start_field(options)
  end_field = by_star_end_field(options)
  scope = by_star_scope(options)
  scope = if options[:strict] || start_field == end_field
            scope.where("#{start_field} >= ? AND #{end_field} <= ?", start, finish)
          else
            scope.where("#{end_field} > ? AND #{start_field} < ?", start, finish)
          end
  scope = scope.order(options[:order]) if options[:order]
  scope
end

単機能をいい感じに切り出せてて良さげです。この発想は見習うとこありそう。

Grapeで独自のレスポンスヘッダーを追加する

先日こんなエントリーを書いた。

Grapeを使ったAPIで独自のエラーコードも一緒に返す工夫 - PILOG

その後色々調べているうちに、エラーコードはレスポンスヘッダーに入れたらいいよ~という話を見かけたので、その方針を採るかどうかは別として実現方法を調べてみた。

YappoLogs: 2014年に向けた JSON API の実装の方向性と X-JSON-Status 改め X-API-Status header のご提案YappoLogs: 2014年に向けた JSON API の実装の方向性と X-JSON-Status 改め X-API-Status header のご提案はてなブックマーク - YappoLogs: 2014年に向けた JSON API の実装の方向性と X-JSON-Status 改め X-API-Status header のご提案

調べたところによるとgem、GrapeにはHTTPヘッダーを追加するインターフェースが用意されているらしい。エラー時にヘッダーを追加するには

error! 'Unauthorized', 401, 'X-Error-Detail' => 'Invalid token.'

と、こんな風に書けばいいそうだ。

以前の続きから

先ほどリンクを貼った自分のエントリーではエラーフォーマッターをカスタマイズした上でヘルパーを使ってJSONのレスポンスを返す感じにしていた。

Grapeを使ったAPIで独自のエラーコードも一緒に返す工夫 - PILOG

HTTPヘッダーに項目を追加するためにはヘルパーメソッドをちょちょっと変更してあげるだけで済みそうだ。

before

def my_error!(error)
  error!({message: error.message, code: error.code}, error.status)
end

after

def my_error!(error)
  error!({message: error.message, code: error.code}, error.status,
  "X-API-Status" => error.code.to_s,
  "X-API-Error-Message" => error.message)
end

とまあ、大体こんな感じ。1つ目の引数と内容的にはダブっているし、ヘッダーのフィールド名?っていうのかな?それも適当なのでもうちょっとブラッシュアップする必要がりそうだが、一応レスポンスヘッダーにエラー詳細情報を載せることはできそうだ。

このAPIを呼び出すクライアントを実装するのであればレスポンスヘッダーを解析して、HTTPのStatusとこのX-API-Statusを見て処理を分岐すればいいんだと思う。確かにJSONのパースは不要になるからこちらのほうが(レスポンスBodyにJSONとしてエラー詳細を含めるよりも)いい気がしなくもない。

クライアントの実装も自分でやることになるので、サーバー側の実装はどちらの手段も提供するようにしておいてあとから決めることにしよう。

Grapeを使ったAPIで独自のエラーコードも一緒に返す工夫

RailsでGrapeをっていうgemを使うとちょちょいっとAPIが作れるという話は以前にも書いた。

このときはお試しだったんだけど、今回ちょっと本格的に書くかもという感じになってちゃんとエラー処理とかもしないといけなくなった。それにあたり試行錯誤したことをちょっとメモっておこうと思う。

普通にエラーを返す

以下フォーマットはJSONだとする。

エラーはerror!メソッドによって発生させることができる。error!は例外を使っているようなので呼んだ時点で処理が中断するため、エラー発生後の処理のことは考えなくていい。

class API < Grape::API
  resource :users do
    get '/' do
      error!("Unauthorized! Invalid token.", 401)
    end
  end
end

このAPIからはステータスコード401で次のようなJSONが戻ってくる。

{
  "error" : "Unauthorized! Invalid token."
}

独自のエラーコードも戻したくなる

具体的にどのようなエラーが起こったか、という詳細はエラーメッセージやHTTPのステータスコードだけで判別するのは厳しい。アプリケーション独自のエラーコードを使ってもっと詳細な情報まで返してあげたほうが呼び出し元のクライアントためになると思う。

そのためにGrapeのErrorFormatterというのを使える。文字通りエラー出力のフォーマットをカスタマイズするための物だと思う。

こんなFormatterを定義して、error_formatterに指定してみた。

module ErrorFormatter
  def self.call message, backtrace, options, env
    if message.is_a?(Hash)
      { error: message[:message], code: message[:code] }.to_json
    else
      { error: message }.to_json
    end
  end
end

class API < Grape::API
  error_formatter :json, ErrorFormatter
  resource :users do
    ...
  end
end

1つめの引数messageにはerror!メソッドの第1引数がそのまま渡るので、ここにHashを渡してしまおうというわけ。

使い方はこうなる。

error!({message: "Unauthorized!", code: 123}, 401)

レスポンスはこんな感じになる。

{
  "error" : "Unauthorized!",
  "code" : 123
}

あとはドキュメントとかを整備しておけば123番のエラーはこれだ!みたいなのがより詳細に判別できるようになると思う。

ヘルパーにする

毎度Hashを組み立てるのが面倒だからGrapeのヘルパー機構を使ってもう少し楽に呼び出せる方が良さそう。

helpers do
  def my_error!(message, error_code, status)
    error!({message: message, code: error_code}, status)
  end
end

my_error!("Unauthorized!", 123, 401) # こう呼べる

エラー定義をまとめる

APIのいろいろな箇所でmy_error!(うんぬん)があるのは生じい鬱陶しいんじゃないかと思う。あそこのエラーメッセージちょっと変えたいなと思ってもいろいろなところから探さないといけないし。

ということで、エラー定義を一箇所にまとめる方法を模索している。今のところrails_configgem を使って、環境によらずアプリケーション全体でその設定を使いまわすような感じで作ってみている。

RailsConfigの導入は公式を見れば十分だと思う。

railsconfig/rails_config

導入できたら全体の設定ファイルであるconfig/settings.ymlにいろいろ書いていく。

errors:
  unauthorized_token:
    message: Unauthorized. Invalid token.
    code: 124
    status: 401
  unauthorized_user:
    message: Unauthorized. Invalid user.
    code: 125
    status: 401

RailsConfigにより、上のように書いた内容はアプリケーション内でSettings.errors.unauthorized_token.messageなどとして呼び出せるため、エラー箇所がすっきりするんじゃないかと思う。

最終的にはmy_error!を書き換えていい感じにしてみた。

def my_error!(error)

  error!({message: error.message, code: error.code}, error.status)
end

# 呼び出し
my_error!(Settings.errors.unauthorized_token)

定義も一覧しやすくなったし、呼び出しもすっきりした。まあまあいいところに落ち着いたと思う。

もっといい方法、一般的な方法があったら知りたいです。