一から勉強させてください

最下級エンジニアが日々の学びをアウトプットしていくだけのブログです

Grape + Rails4.2のAPI開発でエンドポイントとエンティティのテストについて考える

最近、Grape + Rails (v4.2.6)で REST な API を開発する機会があったのですが、今回はその開発の中で悩んだ、「Grape のエンドポイントとエンティティのテスト」について書きたいと思います。

前提として Grape ののエンティティは grape-entity、テストフレームワークrspecを採用しているものとします。また API の format は JSON とします。

API の実装

例として、ユーザーがひたすら無意味に投稿しまくるだけのシンプルなアプリケーションを考えてみる。

モデル

# User
class User < ActiveRecord::Base
  has_many :posts, inverse_of: :user

  validates :name, presence: true
end

# Post
class Post < ActiveRecord::Base
  belongs_to :user, inverse_of: :posts

  validates :user, presence: true
  validates :text, presence: true

  scope :latest, -> { order created_at: :desc }
end

エンティティ

# User
module API
  module V1
    module Entities
      class User < Grape::Entity
        expose :id
        expose :name
      end
    end
  end
end

# Post
module API
  module V1
    module Entities
      class Post < Grape::Entity
        expose :id
        expose :text
        expose :user, using: Entities::User
        expose :created_at
        expose :updated_at
      end
    end
  end
end

エンドポイント

# /api/v1/postsでPostの一覧が返ることを想定,
module API
  module V1
    class Posts < Grape::API
      resources :posts do
        desc 'Return all posts' do
          success API::V1::Entities::Post
        end
        get do
          posts = Post.includes(:user).latest
          present(posts, with: API::V1::Entities::Post)
        end
      end
    end
  end
end
# /api/v1/postsのレスポンスのサンプル
[
  {
    "id": 2,
    "text": "ひゃっはーー",
    "user": {
      "id": 1,
      "name": "Tom"
    },
    "created_at": "2016-08-09T07:22:35.000Z",
    "updated_at": "2016-08-09T07:22:35.000Z"
  },
  {
    "id": 1,
    "text": "うぇーーい",
    "user": {
      "id": 2,
      "name": "Bob"
    },
    "created_at": "2016-08-08T07:22:35.000Z",
    "updated_at": "2016-08-08T07:22:35.000Z"
  }
]

API のテスト

方針

  • Grape ではモデルとエンティティを指定してpresentメソッドを呼ぶといい感じに body(今回は JSON)を返してくれる
  • このpresentメソッドをテスト環境で使えれば、各エンティティ、エンドポイントで返却されるであろう JSON を組み立ててテストできそう
  • JSON のテストはjson_spec使えばよさそう
  • Rspectype: :apiを定義して、API 専用ヘルパーメソッドを読み込んでやればよさそう
  • このあたりを解読すればいけそう

API 用ヘルパー

# rails_helperで読み込んでおく
module API
  module TestHelpers
    include Rack::Test::Methods
    include Grape::DSL::InsideRoute

    private

    def app
      Rails.application
    end

    def request
      last_request
    end

    def response
      last_response
    end

    def render_api_response *args
      # Grape::DSL::InsideRouteモジュールをincludeしているのでpresentメソッドをそのまま使える
      present(*args).to_json.tap do
        # presentメソッドを呼ぶとbodyがインスタンス変数に入るようになっているが、テストでは返却されるJSONがわかればよいだけなので@bodyは常にnilにしておく
        @body = nil
      end
    end

    # Override
    def entity_representation_for entity_class, object, options
      # presentメソッドの中で呼ばれるメソッド
      # 実際はここでenvをセットするような処理が入っているが、Rack::Testのenvメソッドと名前があたってArgumentErrorが発生するので無視する
      entity_class.represent object, options
    end
  end
end

RSpec.configure do |config|
  config.include API::TestHelpers, type: :api
end

エンティティのテスト

require 'rails_helper'

RSpec.describe API::V1::Entities::Post, type: :api do
  describe 'fields' do
    subject { render_api_response(post, with: described_class) }

    let(:user) { create(:user) } # FactoryGirlが入っている体でおねがいします
    let(:post) { create(:post, user: user) }
    let(:expected_user) { render_api_response(user, with: API::V1::Entities::User) }

    it do
      is_expected.to be_json_eql(post.id).at_path 'id'
      is_expected.to be_json_eql(post.text.to_json).at_path 'text'
      is_expected.to be_json_eql(post.created_at.to_json).at_path 'created_at'
      is_expected.to be_json_eql(post.updated_at.to_json).at_path 'updated_at'
      is_expected.to be_json_eql(expected_user).at_path 'user'
    end
  end
end

エンドポイントのテスト

require 'rails_helper'

RSpec.describe API::V1::Posts, type: :api do
  describe 'index' do
    before { send_request }

    let(:send_request) { get "/api/v1/posts", params.merge(format: :json) }
    let(:params) { {} }
    let(:user) { create(:user) } # FactoryGirlが入っている体でおねがいします
    let!(:posts) { create_list :post, rand(2..4), user: user }
    let(:search_result) { Post.latest }
    let(:expected) { render_api_response(search_result, with: API::V1::Entities::Post) }

    it do
      expect(response).to be_successful
      expect(response.body).to be_json_eql expected
    end
  end
end

まとめ

Grape を使ったのは初めてだったのでベストプラクティスはわからないですが、今回のテスト方針はまあまあ機能したので個人的には悪くなかったんじゃないかなーと思っています。本当は OAuth2 認可が必要なエンドポイントのテストとかもあって、そのあたりのことも書きたかったのですが、それはまた次の機会に。

RubyでネストしたHashをflatな1次元のHashに変換する

{
  :foo => "bar",
  :hello => {
    :world => "Hello World",
    :bro => "What's up dude?",
  },
  :a => {
    :b => {
      :c => "d"
    }
  }
}

みたいなネストした Hash があったとして、これを

{
  :foo => "bar",
  :"hello.world" => "Hello World",
  :"hello.bro" => "What's up dude?",
  :"a.b.c" => "d"
}

にしたい時がきっとあると思います。

今回それを実現するためのメソッドをStack Overflow から丸パクリしたので書いたので、メモ。

こんな感じ。引数にネストした Hash を渡すと目的の Hash が返ってくるはず。

def flatten_hash_from hash
  hash.each_with_object({}) do |(key, value), memo|
    next flatten_hash_from(value).each do |k, v|
      memo["#{key}.#{k}".intern] = v
    end if value.is_a? Hash
    memo[key] = value
  end
end

each_with_objectを使う。初期値{}をセットして、これに欲しい情報をどんどん突っ込んでいって目的の Hash を完成させる。injectがブロックの最後に評価した値がmemoにセットされていくのに対して、each_with_objectは常に最初に渡したこのオブジェクトだけを見ていてくれるのでわかりやすい。そして flat になるまでひたすら再帰処理をする。

実際に上述のネストした Hash に対して適用した場合を考えて、処理の流れを追ってみる。

  1. まずkey = :foo, value = "bar"。これはvalueが Hash ではないのでmemo{ :foo => "bar" }となって次へ。

  2. 次にkey = :hello, value = { :world => "Hello World", :bro => "What's up dude?" }。これはvalueが Hash なので、このvalueに対してさらにflatten_hash_fromメソッドが呼ばれる。

  3. 再帰処理の中のmemoは新たな{}であり、1 のmemoとは異なるので注意。まずkey = :world, value = "Hello World"。これはvalueが Hash ではないので、memo{ :world => "Hello World" }となる。key = :broについても同様なので、最終的にmemo{ :world => "Hello World", :bro => "What's up dude?" }となり、これが 2 のflatten_hash_from(value)に返る。

  4. 3 からさらにeach処理へ。ここでkey = :helloになっているので、memo["#{key}.#{k}".intern] = vとすることで、memokeyhello.world, hello.broのようになる。最終的にこの処理の後のmemo{ :foo => "bar", :"hello.world" => "Hello World", :"hello.bro" => "What's up dude?" }になる。

  5. 次にkey = :a, value = { :b => { :c => "d" } }。これもvalueが Hash なので再帰処理へ。

  6. key = :b, value = { :c => "d" }なのでさらに再帰処理へ。

  7. key = :c, value = "d"なので { :c => "d" }が 6 のflatten_hash_fromに返る。

  8. { :"b.c" => "d" }となり、5 のflatten_hash_fromに返る。

  9. key = :aの階層に戻ってきてeachするので{:"a.b.c" => "d" }が新たにmemoに追加される。その結果、memo{ :foo => "bar", :"hello.world" => "Hello World", :"hello.bro" => "What's up dude?", :"a.b.c" => "d" }となるので終了。

まとめ

再帰処理はちゃんと処理の流れを理解して使っていきたい。

参考

Golangで画像をアップロードして表示するだけのアプリをつくってみた

完全に出遅れた感満載だけど、最近 Golang の勉強を始めました。

ちょうど昔、node.js の勉強で画像をアップロードして表示するだけのアプリをつくったことがあったので、今回はそれの Golang 版をつくってみました。

成果物はこちら

初期表示

とりあえず/にアクセスしたら画像をアップロードするためのフォームと送信ボタンを表示できるようにしてみる。

Golang は標準でテンプレート機能をサポートしているっぽかったのでそれを使ってみる。 templates/index.htmlを用意して以下のようにする。

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>{{ .Title |html }}</title>
  </head>
  <body>
    <form action="/upload" enctype="multipart/form-data" method="post">
      <input type="file" name="upload" id="upload" multiple="multiple">
      <input type="submit" value="Upload file" />
    </form>
  </body>
</html>

完全にただの HTML だったので、テンプレ感を出すために{{ .Title }}の部分にタイトルの情報を渡して表示するようにしてみた。たぶんこんなの必要ない。

次にapp.goを用意して、次のようにしてみる。(一部抜粋)

var templates = template.Must(template.ParseFiles("templates/index.html"))

func IndexHandler(w http.ResponseWriter, r *http.Request) {
    data := map[string]interface{}{"Title": "index"}
    renderTemplate(w, "index", data)
}

func renderTemplate(w http.ResponseWriter, tmpl string, data interface{}) {
    if err := templates.ExecuteTemplate(w, tmpl+".html", data); err != nil {
        log.Fatalln("Unable to execute template.")
    }
}

func main() {
    http.HandleFunc("/", IndexHandler)
    http.ListenAndServe(":8888", nil)
}
  1. templatesを定義してこの中で必要なテンプレートファイルをパースしておく。別ファイルに定義したテンプレートを読み込むにはtemplate.ParseFilesを使う。template.Mustはバリデーションチェックで、指定したテンプレートのエラーがnilじゃない場合にpanicを呼ぶっぽい。templatestype Templateのポインタ。

  2. renderTemplateメソッドを定義して、その中でtemplates.ExecuteTemplateを呼んで該当するテンプレートを表示。エラーがあった場合はlog.Fatallnを呼ぶ。ここに処理が入った場合はexit(1)で異常終了する。

  3. http.HandleFuncfunc HandleFunc(pattern string, handler func(ResponseWriter, *Request))となっていて、パターンとハンドラメソッドを受け取るようになっているので/のリクエストでIndexHandlerを実行するようにする。

  4. http.ListenAndServe8888ポートでサーバ起動。

ひとまずこんな感じでgo run app.goしたらlocalhost:8888index.htmlの内容が表示できた。

画像をアップロード

次に画像をアップロードする処理をUploadHandlerで書いていく。

func UploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != "POST" {
        http.Error(w, "Allowed POST method only", http.StatusMethodNotAllowed)
        return
    }

    err := r.ParseMultipartForm(32 << 20) // maxMemory
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    file, _, err := r.FormFile("upload")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer file.Close()

    f, err := os.Create("/tmp/test.jpg")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer f.Close()

    io.Copy(f, file)
    http.Redirect(w, r, "/show", http.StatusFound)
}

func main() {
    http.HandleFunc("/", IndexHandler)
    http.HandleFunc("/upload", UploadHandler)
    http.ListenAndServe(":8888", nil)
}
  1. まずUploadHandlerで受け付けるのはPOSTだけなのでGETなどが来た場合は405 Method Not Allowedエラーを返す。

  2. multipart/form-dataなファイルのアップロード処理をするためにはまずr.ParseMultipartFormを呼ぶ必要がある。これのインターフェースはfunc (r *Request) ParseMultipartForm(maxMemory int64) errorとなっていて、アップロードするファイルはmaxMemoryのサイズのメモリに保存される。もしファイルのサイズがmaxMemoryを超えた場合、残った部分はシステムのテンポラリファイルに保存される。

  3. エラーハンドリングはとりあえずエラーが起きたらhttp.StatusInternalServerErrorを返すようにする。

  4. r.FormFileで、2 で用意したファイルハンドルを取得することができる。今回は HTML の Form のほうでuploadという名前でデータを送信しているのでその名前で取得する。また、defer file.Close()を呼んでおくことでUploadHandlerの処理を抜ける前に必ずファイルのクローズ処理が行われる事が保証できる。defer便利。

  5. os.Create("/tmp/test.jpg")/tmp以下にtest.jpgというファイルを作ります(雑につくったので JPG しか対応していません)。このos.Createの他にos.OpenFileを使っている例も見受けられたけど、Createは内部でOpenFileを使っていたのでやってることは同じっぽい。これもdefer f.Close()でちゃんと閉じる。

  6. io.Copyでフォームからアップロードされたデータが/tmp/test.jpgとして保存される。

  7. /showにリダイレクトして、/tmp/test.jpgの内容を表示する

アップロードした画像を表示

アップロードして保存した画像を表示する。

func ShowHandler(w http.ResponseWriter, r *http.Request) {
    file, err := os.Open("/tmp/test.jpg")
    defer file.Close()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    img, _, err := image.Decode(file)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    writeImageWithTemplate(w, "show", &img)
}

func writeImageWithTemplate(w http.ResponseWriter, tmpl string, img *image.Image) {
    buffer := new(bytes.Buffer)
    if err := jpeg.Encode(buffer, *img, nil); err != nil {
        log.Fatalln("Unable to encode image.")
    }
    str := base64.StdEncoding.EncodeToString(buffer.Bytes())
    data := map[string]interface{}{"Title": tmpl, "Image": str}
    renderTemplate(w, tmpl, data)
}

func main() {
    http.HandleFunc("/", IndexHandler)
    http.HandleFunc("/upload", UploadHandler)
    http.HandleFunc("/show", ShowHandler)
    http.ListenAndServe(":8888", nil)
}
  1. まずos.Openで先ほどの画像を取り出す。Openも内部でOpenFileを使っている。こちらもクローズ処理を忘れないようにする。

  2. image.Decodeで取り出したファイルをデコードする。

  3. writeImageWithTemplateというメソッドを定義。こちらに取り出した Image を渡して、いい感じにテンプレートに出力するよう試みる。

  4. new(bytes.Buffer)で処理を行うためのメモリ領域を確保する。そしてjpeg.Encodeにそれと*imgを渡してエンコード処理。

  5. 4 でできあがったものを base64 形式にしてstringに変換。それをテンプレートに渡す。data := map[string]interface{}{"Title": tmpl, "Image": str}は Key がstringValueinterface{}にしているけど、これは Swift でAnyObjectを渡すみたいなノリなんだろうなと思っている。

これで画像が埋め込まれた形でテンプレートがちゃんと表示できた!

まとめ

Go は標準パッケージが充実しているなーと思った。今まで触ってきた言語と結構違っていてなかなか慣れないけど、楽しい。

次はゴルーチンとかチャネルとか絡めたものを作りたいなー

参考

Swiftで抽象クラスを実装する

Swift では protocol がサポートされていて、protocol extension もできるようになりましたが、抽象クラスは 2015 年 11 月現在サポートされていません。

僕は抽象クラスがほしいなーって思うときがたまにあって、例えばtypealiasを使用している protocol を何かの変数の型に指定しようとすると、protocol can only be used as a generic constraint...みたいなエラーが出るんですよね。抽象的に実装したいのにできねえ...ってなります。

そういった現状ですが、前回の記事でも使用した RxSwift で抽象メソッドをトリッキーに実装しているのを見て感動したので、真似して抽象クラスをつくってみました。

AbstractClass の実装

protocol AbstractClass: class {}

extension AbstractClass {
    func abstract<T>() -> T {
        customFatalError("Must be overridden")
        let dummyValue: T? = nil
        return dummyValue!
    }
}
private func customFatalError(message: String) {
    fatalError(message)
}
  1. まず空の protocol をAbstractClassとして定義します。

  2. つぎに protocol extension でジェネリクスを使用したabstractメソッドを定義します。

  3. 普通にfatalErrorを呼ぶとその後の行が実行されないという旨の警告が出るので、プライベートメソッドとして内部でfatalErrorを呼ぶだけのcustomFatalErrorメソッドを定義します。

  4. 3 で用意したメソッドを呼んで override が必要な旨とともにfatalErrorを出しつつ、ダミーの Optional 値を返すことでコンパイルが無理矢理通るようにします。これでfunc foo() -> Int { return self.abstract() }のように何かの型を返したい抽象メソッドを定義する場合でもコンパイルエラーにはなりません。

実際の使用例はちょっと適当ですが、以下のようなイメージです。

class Employee: AbstractClass {

    let name: String
    private let salary: Int

    init(name: String, salary: Int) {
        self.name = name
        self.salary = salary
    }

    func work() {
        return self.abstract()
    }

    func getSalary() -> Int {
        return self.salary
    }

}

class Engineer: Employee {

    override func work() {
        print("Writing awesome code!!")
    }

}

AbstractClassに準拠しているので「このクラスは抽象クラスだ」ってぱっと見でわかるし、「self.abstract()を返しているメソッドは抽象メソッドなんだな」ってなんとなくわかります。

そしてclass Foo<T>: AbstractClass {}みたいなジェネリクス付きの抽象クラスを定義をして、変数の型にFoo<String>などと指定しても前述の protocol のようなエラーは出ません。

ただ Java とかだと抽象クラスをインスタンス化しようとするとコンパイルエラーになると思いますが、これはあくまで抽象クラスもどきなのでインスタンス化しようとしてもコンパイルエラーにはなりませんのでご注意を。。

RxSwift の抽象メソッドの実装がさらに進化してた

上記の実装は 1~2 ヶ月前に見て真似したものだったのですが、最近 RxSwift を見なおしてみると、@noreturnを使うことでより簡潔になっていました。 @noreturnは呼び出し元に戻ってこない旨を表す属性で例えばfatalError

@noreturn public func fatalError(@autoclosure message: () -> String = default, file: StaticString = default, line: UInt = default)

のように@noreturnなメソッドとして定義されています。これを使えばより簡潔に書けそうです。

以下が書きなおしたバージョン。

protocol AbstractClass: class {}

extension AbstractClass {
    @noreturn func abstract() {
        fatalError("Must be overridden")
    }
}
class Employee: AbstractClass {

    let name: String
    private let salary: Int

    init(name: String, salary: Int) {
        self.name = name
        self.salary = salary
    }

    func work() {
        // return self.abstract()
        self.abstract()
    }

    func getSalary() -> Int {
        return self.salary
    }

}

class Engineer: Employee {

    override func work() {
        print("Writing awesome code!!")
    }

}

まとめ

Swift は抽象クラスをサポートしないのかなー。

参考

RxSwiftを使ってGitHubのおすすめユーザーを表示するアプリをつくってみた

ひさしぶりの投稿です。気づけば iOS の開発を始めて半年近く経ち、ようやく半人前になってきたかもしれません。

最近は流行りのリアクティブプログラミングに興味津々なので、今回はその勉強がてらRxSwiftを使って GitHub のおすすめユーザーを表示するアプリを作ってみました。その内容をざっくり書いてみたいと思います。

ちなみにこのアプリはこちらの記事の中で RxJS で作られているものを参考にしており、今回作るのはその iOS 版になります。リアクティブプログラミングについても同記事をかなり参考にしております。

開発環境は Xcode7(Swift2.0)で iOS8 以上をターゲットにしております。

コードはこちら。(本記事に載せているコードはリファクタリングなどで修正されている可能性もあるので、差異がある場合はなるべく GitHub のコードを参考にしてください)

ffmpegで雑に mov→gif 変換した紹介デモも乗せておきます。画質悪っ!しかも 1 件取得失敗してるやつおるやんけ…

WhoToFollowDemo

リアクティブプログラミングのざっくりしたイメージ

  1. Observable(ストリームと同義?)を作る。時間順に並んだ進行中のイベントの列のことらしい。よく見るマーブルダイアグラムのやつ。

  2. map, filterなど Observable に対して使用できる関数をかましてデータを加工する。

  3. Observer を作って、Observable を subscribe して返ってくる値に対して処理する。

この流れに沿って良い感じにコードを書いていきたいです。

API クライアント

GitHubAPI を叩いて User 情報を取得したいので、まずは API クライアントを作成しました。 ちなみにライブラリは CocoaPods ですべて導入済とします。 叩く想定の APIhttps://api.github.com/users?since=◯◯です。

class GithubAPIClient: {

    // MARK: - Properties

    let scheme = "https"
    let host: String = "api.github.com"

    private let manager = Alamofire.Manager.sharedInstance

    static let sharedInstance: GithubAPIClient = GithubAPIClient()


    // MARK: - Initializers

    private init() {}


    // MARK: - Instance methods

    func request(method: Alamofire.Method = .GET, path: String, params: [String : String] = [:]) -> Observable<AnyObject!> {
        let request = self.manager.request(method, self.buildPath(path), parameters: params).request

        if let request = request  {
            return self.manager.session.rx_JSON(request)
        } else {
            fatalError("Invalid request")
        }
    }


    // MARK: - Private methods

    private func buildPath(path: String) -> NSURL {
        let trimmedPath = path.hasPrefix("/") ? path.substringFromIndex(path.startIndex.successor()) : path
        return NSURL(scheme: self.scheme, host: self.host, path: "/" + trimmedPath)!
    }

}

通信は Alamofire を使用して GitHub のユーザー一覧を取得する API を叩きます。そしてここで Observable を返したいので、requestメソッドの返り値はObservable<AnyObject!>です。

RxCocoa のNSURLSession+Rx.swiftの中で、通信で返ってきた JSON データを Observable にして返すrx_JSONというメソッドがあるので、それをありがたく使用しています。

User モデル

API から返ってくるlogin, avatar_url, html_urlを使用して、名前、アバター画像の表示、プロフィールページへの遷移を実現できるようにします。

あとは先ほどの API クライアントを使ってユーザー一覧の Fetch、JSON の Parse 機能を実装します。

class User {

    // MARK: - Properties

    let name: String
    let url: String
    let avatarUrl: String

    static let apiClient = GithubAPIClient.sharedInstance


    // MARK: - Initializers

    init(name: String, url: String, avatarUrl: String) {
        self.name = name
        self.url = url
        self.avatarUrl = avatarUrl
    }


    // MARK: - Static methods

    static func fetch() -> Observable<[User]> {
        let randomOffset = String(arc4random_uniform(500))

        return self.apiClient.request(path: "users", params: ["since": randomOffset])
            .observeOn(Dependencies.sharedInstance.backgroundScheduler)
            .map { json in
                guard let json = json as? [AnyObject] else { fatalError("Cast failed") }
                return self.parseJSON(json)
            }.observeOn(Dependencies.sharedInstance.mainScheduler)
    }

    static func parseJSON(json: [AnyObject]) -> [User] {
        return json.map { result in
            guard let name = result["login"] as? String else { fatalError("Parse error") }
            guard let url = result["html_url"] as? String else { fatalError("Parse error") }
            guard let avatarUrl = result["avatar_url"] as? String else { fatalError("Parse error") }
            return User(name: name, url: url, avatarUrl: avatarUrl)
        }
    }

}

ObserveOnというメソッドにスケジューラーなるものを渡すことでメインスレッド、バックグラウンドスレッドなど処理を実行するスレッドを切り替えられます。重い処理は裏で勝手にやっとけよと。RxSwift の Examples でも同じようにしていたのでおそらく使い方は間違っていないかと。。

ちなみにSubscribeOnというメソッドもあるみたいです。使ったこと無いので違いをあまり理解していないですが、ObserveOnはそれを呼び出した後の処理を行うスレッドを変更するのに対し、SubscribeOnはそれを呼び出す前(Observable の作成そのもの?)の処理も指定のスレッドにできるみたいなイメージでいます。(参考)

というわけでここでの処理の流れは、

  1. ランダムなオフセットをつくって、URL を組み立てて API を叩く

  2. バックグラウンドスレッドでデータ加工(map)スタート

  3. Observable<AnyObject!>が返ってくる想定なのでそいつに対してParseJSON

  4. parseJSON内では引数に渡ってくるのデータはユーザー情報の一覧という想定なので、ほしい情報を抜き出してUserインスタンスの一覧を返す

  5. map内で無事にparseJSONできたら返ってくるのはObservable<[User]>

  6. メインスレッドに戻って、あとはこいつを Observer が subscribe すれば OK

という感じです。

ユーザー一覧の表示

TableViewController をつくって、ユーザーの一覧をいい感じに表示できるようにします。

コードが長いので、関係ありそうなとこだけ抜粋します。

self.refreshControl!.rx_controlEvents(.ValueChanged).startWith({ print("Start loading...") }())
  .flatMap {
      return User.fetch()
  }.subscribeNext { [unowned self] result in
      self.users = result
      self.refreshControl!.endRefreshing()
  }.addDisposableTo(self.disposeBag)

いよいよ Observable を Subscribe していきます。まずは初期表示時と pull to refresh した時にデータを良い感じに表示できるようにしたいです。

とりあえず pull to refresh するというイベントを起点に考えてみました。

  1. pull to refresh されたというイベントを Observable にしたいのでrx_controlEventsメソッドを使用

  2. pull to refresh されたタイミングでUser.fetch()を呼び出す

  3. subscribeNext(AnonymousObserverという匿名の Observer をつくって該当の Observable を勝手に subscribe してくれるやつ)内で返ってきたユーザー一覧を TableViewController 自身のusers(型は[User])にセット

  4. お作法的にdisposeBagをつくっておいてaddDisposableToを呼んでおけば不要になったタイミングで勝手に subscribe やめてくれる(はず。。)

ただこれだと pull to refresh しないとデータを Fetch できないので初回何も表示されず、かなりツラくなります。

なので、startWithで初回の pull to refresh を無理矢理発火させて初回も Fetch できるようにしました。

rx_controlEventsの返り値はControlEvent<Void>となっており、ControlEvent

public struct ControlEvent<PropertyType> : ControlEventType {
    public typealias E = PropertyType

    let source: Observable<PropertyType>

    init(source: Observable<PropertyType>) {
        self.source = source
    }

    /**
    Subscribes an observer to control events.

    - parameter observer: Observer to subscribe to events.
    - returns: Disposable object that can be used to unsubscribe the observer from receiving control events.
    */
    public func subscribe<O : ObserverType where O.E == E>(observer: O) -> Disposable {
        return self.source.subscribe(observer)
    }

    /**
    - returns: `Observable` interface.
    */
    public func asObservable() -> Observable<E> {
        return self.source
    }

    /**
    - returns: `ControlEvent` interface.
    */
    public func asControlEvent() -> ControlEvent<E> {
        return self
    }
}

またstartWithについては

extension ObservableType {

    /**
    Prepends a sequence of values to an observable sequence.

    - parameter elements: Elements to prepend to the specified sequence.
    - returns: The source sequence prepended with the specified values.
    */
    public func startWith(elements: E ...)
        -> Observable<E> {
        return StartWith(source: self.asObservable(), elements: elements)
    }
}

となっています。

これらのコードからrx_controlEventssourceObservable<Void>となりそうなので、startWith({ print("Start loading...") }())としてクロージャを強引に実行したらうまく Fetch してくれました。

あとmapではなくflatMapを使っていますが、mapだとsubscribeNextで返ってくるのがRxSwift.ObserveOnSerialDispatchQueue<Swift.Array<User>>のような意味不明なものになります。

今回は pull to refresh のイベントを起点にしており、そこでObservable<Void>が返ってきているので、入れ子の Observable を良い感じにマージしてくれるflatMapを使わないと欲しい値が手に入らないのではないかと思われます。(参考)

RxSwift.ObserveOnSerialDispatchQueue<Swift.Array<User>>に関してはまだちょっとコードを追えていないです。。

取得したユーザー一覧をもとにセルを作成・表示

正直 ViewModel 必要ない感満載なのですが、一応 ViewModel を介してUserモデルからセルを表示してみました。画像表示には SDWebImage を使用しています。

class UserTableViewCellModel {

    // MARK: - Properties

    let model: Variable<User>
    let name: Observable<String>
    let avatarUrl: Observable<NSURL>

    let disposeBag = DisposeBag()


    // MARK: - Initializers

    init(model: User) {
        self.model = Variable(model)
        self.name = self.model.map { return $0.name }
        self.avatarUrl = self.model.map { return NSURL(string: $0.avatarUrl)! }
    }

}
class UserTableViewCell: UITableViewCell {

    // MARK: - Properties

    let disposeBag = DisposeBag()

    var viewModel: UserTableViewCellModel? {
        didSet {
            guard let vModel = self.viewModel else { return }
            vModel.name.bindTo(self.nameLabel.rx_text).addDisposableTo(self.disposeBag)
            vModel.avatarUrl.subscribeNext {
                self.avatarImageView.sd_setImageWithURL($0, placeholderImage: UIImage(named: "DefaultImage.png"))
            }.addDisposableTo(self.disposeBag)
        }
    }

    static let rowHeight: CGFloat = 80


    // MARK: - IBOutlets

    @IBOutlet weak var avatarView: UIView!
    @IBOutlet weak var avatarImageView: UIImageView!
    @IBOutlet weak var nameLabel: UILabel!


    // MARK: - Lifecycles

    override func awakeFromNib() {
        super.awakeFromNib()
        // Initialization code
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        self.avatarImageView.frame = self.avatarView.bounds
    }

}
  1. Controller から渡ってくるUserモデルを初期値として Observable を作成したかったので、Variable<User>を定義

  2. 1 の Variable からnameavatarUrlの Observable をそれぞれ作成

  3. View 側で ViewModel の上記の Observable たちを subscribe してnameavatarUrlをセット

Variable については

Variables represent some observable state. Variable without containing value can't exist because initializer requires initial value.

です(だんだん雑になってきた。。)

vModel.name.bindTo(self.nameLabel.rx_text)の部分はvModel.name.subscribeNext { self.nameLabel.text = $0 }と同じようなことを簡易的に書いている感じでしょうか。ラベルのテキストなど、ただ値をセットするだけのときによく使いそうです。

ちなみにこれは Rx ではなく SDWebImage の話ですが、デフォルト画像をセットしてない場合、スクロールしないと画像表示してくれないんですね。。地味にちょっとハマりました。(参考)

あと更に余談ですが、ImageView の Constraint をちゃんと設定しているはずなのに ImageView の位置が右に若干ずれる事件が発生しました。 これも同じようなことで困っている人がいて解決しましたが、これまた地味にハマりました。(参考)

ユーザーの削除(別ユーザーの表示)

iOS でよくあるスワイプで削除ボタンが出るやつを使ってユーザーを削除したら別のユーザーを表示してあげるようにします。

self.tableView.rx_itemDeleted.subscribeNext { [unowned self] indexPath in
    var data = self.users
    let nextIndex = Int(arc4random_uniform(18) + 11) // Select from remaining users
    let prevData = data[indexPath.row]

    data[indexPath.item] = data[nextIndex]
    data[nextIndex] = prevData
    self.users = data
}.addDisposableTo(self.disposeBag)

ユーザーが削除された時のイベントの Observable はrx_itemDeletedで取れるので、そいつを subscribe してやります。

実は API を叩いた時にユーザーは 30 人返ってきているのですが、アプリ上でははじめの 10 人しか出していない(元の参考にしたアプリは 3 人だったのですが、TableView で 3 人は寂しかったので 10 件)ので、余っている人の中から適当に選んで入れ替えて表示しています。この辺はかなり雑なのでバグとかあるかもしれません。。

ユーザーのプロフィールページへ遷移

最後にセルを選択したら該当のユーザーの GitHub プロフィールページに遷移できるようにします。iOS9 からSFSafariViewControllerが使えるので iOS9 の場合はそれを使うようにしました。

self.tableView.rx_itemSelected.subscribeNext { [unowned self] indexPath in
  guard let user = self.userForIndexPath(indexPath) else { return }
  guard let url = NSURL(string: user.url) else { return }

  if #available(iOS 9.0, *) {
      let safari = SFSafariViewController(URL: url)
      safari.delegate = self
      self.presentViewController(safari, animated: true, completion: nil)
  } else {
      UIApplication.sharedApplication().openURL(url)
  }

  self.tableView.deselectRowAtIndexPath(indexPath, animated: false)
}.addDisposableTo(self.disposeBag)

削除時と同様にrx_itemSelectedから Observable を作れるので、それを subscribe してユーザーのプロフィールページを SafariView または Safari に遷移して表示するようにしています。

あとセルの選択状態は残らないようにdeselectRowAtIndexPathを呼んでいます。

一般的にはセル選択時の挙動ってtableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath)で書くと思うんですが、それをrx_itemSelectedで書くのって本当に良いんですかね?削除時の挙動も。。アップルに逆らいまくっている気がして妙な罪悪感を感じます。

まとめ

以上、RxSwift を使ってリアクティブっぽくアプリをつくってみました。Rx 関係のライブラリは機能が豊富すぎて難しくてかなりツラいですが、うまく動くと最高に気持ちいいです。実装も楽しくて妙な中毒性があります。

今回のアプリの元となった記事でも

現代のアプリは、高度にインタラクティブな体験をユーザーに与えるために、多数のリアルタイムイベントを扱っている。我々はこれを適切に取り扱うツールを探しており、リアクティブプログラミングがその答えなのだ。

と書いてありますし、ぜひ使いこなしたいもんです。

まだまだ理解しきれていない部分が多いのでおかしな点などがありましたら、僕が傷つかない程度に優しくご指摘ください。GitHubへの Issue や PR も大歓迎でございます。

参考