巨大レコードのページネーション用gemを作った

Railsにおいて,kaminariwill_paginateのようなページネーションライブラリは非常に強力だ. というか,あまりページネーションというものを意識せずとも使えてしまうので,便利だ.

ページネーション時のカウントクエリが重い

便利ではあるのだが,すべての場合において無敵なわけではない. 複雑なJOIN を繰り返すことによりインデックスが効かなくなった状態で,レコード数が大量にあるような場合.そんなときに,これらのページネーションを使っていると,重くなる場合がある.

ページネーションライブラリは,ページの下部にページネーションのビューを表示している. そして,ここにはラストページの番号が書かれている.

f:id:h3poteto:20151219205701p:plain

この数字は,ページネーションライブラリ内部でカウントクエリを発行して,条件に合致するレコード数をカウントしている.

複雑で重いクエリの場合,このカウントクエリが非常に重くなってしまう場合がある.

大量のレコードがある場合のページネーション

gemにした

このような場合のページネーションを上手いこと解決してくれるgemを作った.

github.com

方針

そもそもそのページ数,正確である必要は?

カウントクエリが重くなるような状態では,たいていの場合はレコード数が多い.例えば4000ページくらいあったとしよう.果たして1ページ目を訪れる人のどのくらいが,最終ページにいくだろうか? そして最終ページにいくとき,そのページ数が正確である必要性はあるのだろうか?

これは俺の個人的意見なのだが,ページ数もレコード件数も,そこまで正確である必要はないと思っている.だいたい有効桁数3桁くらいまで出ていれば,その先はなんであろうと変わらない. そもそも天下のgoogleだって,検索件数を正確には出していないじゃないですか.

f:id:h3poteto:20151219205638p:plain

どう実現するか

  1. 本当に一番最初に1ページ目を表示したときに,ページ数をCOUNT クエリ発行してカウントする
  2. その値を(クエリをKeyとして)Redisに保存しておく
  3. ラストページの値は,最初にカウントした値を元に,多めに見積もって適当にceil して出しておく
  4. アクセスされたとき,もしレコード件数が0件だったら,もう一度COUNT クエリを発行して正確なラストページの値を算出し,ラストのレコードを表示する
  5. そのとき,Redisに保存されている値と違う値になっていたら,Redisの値を更新する

使い方

READMEに書いてあることとほとんど同じですが.

Redisの設定

config/initalizers/redis.rb みたいなファイルを作ってください.

GuessPaging::RedisClient.setup do |config|
  config.redis_host = '127.0.0.1'
  config.redis_port = 6379
end

Controllers

class RecordsController < ApplicationController
  def search
    @guess = GuessPaging::Paginate.new(
      query: Record.where(category_id: params[:category_id].to_i),
      per_page: 10,
      essential: 3)
    @guess.guess(
      page_params: params[:page]
    )
  end
end

per_page はわかると思います.ちなみにデフォルトで10が設定されているので,省略可能なオプションです. essential は,ページ数を適当に丸めて表示するときの有効桁数です.この例で言うと,ラストが4561ページだったら,4570と表示されることになります.この値も,デフォルト値として3が設定されているので,省略可能なオプションになります.

Views

<% @guess.records.each do |r| %>
  <%= r.hogehoge %>
<% end %>
<%= paging(@guess) %>

元のActiveRecord オブジェクトはrecordsというインスタンス変数に格納されています.ここから取り出せば,今までと同じように使えます.

Assets

app/assets/stylesheets/application.css に以下の行を追加します.

//= require 'guess_paging'

これでこんな感じのビューデザインになります.

f:id:h3poteto:20151219205735p:plain

Helpers

  • ページネーションのビューを表示する

    <%= paging(@guess) %>

  • 現在のページ番号

    @guess.current_page

  • ラストページの番号(概数)

    @guess.max_page

  • レコード件数(概算)

    @guess.count

速くなるよ

あまり複雑なDBを即席で作れなかったので,適当に10万件くらいの軽いレコードなので,そこまで大きな差は出ていませんが.

  • kaminari f:id:h3poteto:20151219205819p:plain f:id:h3poteto:20151219205825p:plain

  • guess_paging f:id:h3poteto:20151219205833p:plain f:id:h3poteto:20151219205841p:plain

できていないこと

  • localeによるビューの変更 kaminari のようにlocaleの設定でページネーションビューの表示文字を変更することについては,対応していません.

  • デザイン変更 これは使う側で適当なcssを作ってもらえばもちろん上書きできます.デフォルトだと,//= require 'guess_paging' したものがそのまま適応されます.

思うところ

今回のgemはActiveRecord の拡張として作ってはいません.どうもページネーションの機能がActiveRecord に拡張されて入るというのは,違う気がしていてい,ページネーションはページネーションのオブジェクトを生成するように作りました. ただ,これにもちょっとだけ不便なところがあって,ActiveRecord の拡張として作ると,呼び出された時に初めてDBアクセスしてARのオブジェクトが生成されます. なので,コントローラでクエリを書いておくと,ビューで呼び出すときに初めてDBアクセスされますよね.そのため,kaminari のようなページネーションでは,ページネーション用のカウントクエリはビューで呼び出した際に初めてクエリが呼ばれます.

それに対して,guess_paging は,ARで書いたクエリを一度GuessPaging に渡して,そこでオブジェクト化しているので,guessメソッドを読んだ時点でARのオブジェクト生成が走り,必要なクエリが呼ばれます. なるほど,ARの拡張として作るというのにはそういう意味があるんですね.

もうひとつ考えたこととしては,おそらくこのgemってレコード件数が少ないページにはあんまり使い勝手が良くないんですよね. だって,勝手にページ数保持するじゃないですか,正確じゃないでしょ. これを10ページとかでやられたら,むしろ邪魔なわけですよ.

そうなると kaminariwill_paginate と同居するってことは十分に考えられて,その際にARの拡張にしているとメソッド名被りそうだな……と. であるならオブジェクトからして別の方が取り回しが楽な予感がしました.

S3+CloudFrontだけでリダイレクト設定を作る

サービスをリニューアルするときに、ドメインも新しく作りなおしていて、どうしてもリダイレクトしておきたい場合があります。でも、そのリダイレクトってサービスの仕様ではないし、それなりに時間経ったら不要になるだろうから消したい。

そんなことのために、本体サービスのnginxにリダイレクト設定を書いたり、リダイレクトのためだけにEC2を立てるのは非常にだるい。

というわけで手抜きをして、S3でリダイレクト設定を書いてみる。

StaticWebHostでリダイレクト

例:old.hoge.comをnew.fuga.comにリダイレクトする場合

  1. old.hoge.comというバケットをS3で作る
  2. バケットの設定からStaticWebHostで、new.fuga.comへのリダイレクトを設定する f:id:h3poteto:20200326144948p:plain

  3. Endpointをメモっておく

この段階でEndpointにアクセスするとリダイレクトされるのが確認できると思う。

httpsを含まないような場合には、このままこのEndpointをRoute53に登録してやれば良い。 ただ、httpsを含む場合は、このままではhttps経由のアクセスがリダイレクトされないので、以下の作業が必要になる。

httpsを含む場合にはCloudFrontを経由する

httpsを含む場合には、CloudFront経由でS3のEndpointにアクセスさせて、そのCloudFrontに適切な証明書を設定してやれば、httpsのままリダイレクトされる。

  1. CloudFrontを新規作成する
  2. Originにはバケット名ではなくS3のEndpointを入れる f:id:h3poteto:20200326145005p:plain

  3. 証明書を設定する(CloudFront用の証明書しかリストアップされないので、ない場合は必要な証明書をCloudFront用に登録し直す)

  4. S3のold.hoge.comのPermissionに、Everyone: Listを追加する f:id:h3poteto:20200326145019p:plain

Route53に登録する

前述のhttpsを使わないパターンの場合は、S3のEndpointを直接、httpsを使う場合はCloudFrontのEndpointをCNAMEに登録してやる。 f:id:h3poteto:20200326145030p:plain

というところまでやると、無事リダイレクトされた。

golangでrecoverしたときの戻り値

この記事は Crowdworks Advent Calendar 2015 13日目の記事になります. クラウドワークスの業務では全然goを使っていないけど,goの話をします.

panicとrecover

goには例外がないと言われているし,そもそもあんまり panic を使う機会はないと思う.もし頻繁に panic していたら,おそらくその panic の使い方,goではあまり推奨されることじゃないので「本当にそれpanic必要なの?」と疑ったほうがいい. golangのpanicは例外ではないのか?

そして,さらにはrecover というものがあり,こいつは panic が起きた時の処理をハンドリングできる.

func hoge() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Printf("recoverd!\n")
            // Do something
        }
    }    
    panic("hogehoge")
}

としておくと,hoge()panic に陥らない.

できれば,あまり panic を使わないで欲しいし,ましてや recover なんてもっと使わないでほしい. recover については以下の記事が詳しい. panicはともかくrecoverに使いどころはほとんどない

わかっていても使わなきゃいけない場合もある

直近で困ったのは,sql パッケージを使った時のトランザクションだった. トランザクションなので,panic が発生した場合は Rollback してもらわないと困る.

func hoge(tx *sql.Tx) {
    defer func() {
        if err := recover(); err != nil {
            tx.Rollback()
        }
    }()
    // Do something
    tx.Exec(…)
}

[Go言語] database/sqlパッケージを使ってみた

もちろん,SQLの実行結果としてエラーを受け取れる場合は多々ある.

err := tx.QueryRow("SELECT name FROM users WHERE id = ?;", id).Scan(&name)
if err != nil {
    tx.Rollback()
}

その場合は,ちゃんと Rollback を呼びだせばいいのだけれど,その他のコードで panic が呼ばれる可能性は否定できない.

そういった場合でもちゃんとトランザクションロールバックしてほしい.というわけでどうしても recover せざるを得ない.

recoverしたときの戻り値どうなるの?

戻り値を宣言しているにもかかわらず,return しなかった場合には,ふつうコンパイラに怒られる.では,recover したときは return しなくていいのだろうか? 一応コンパイラからは怒られていない.では、一体どんな値が返ってくるんだろうか?

boolの戻り値

以下のように関数を定義していたとする.

func hoge() bool {
    defer func() {
        if err := recover(); err != nil {
            fmt.Printf("recoverd!\n")
        }
    }()
    // Do something
    return true
}

このとき,panicrecover されたらこの関数の戻り値はどうなるのか? やってみた.

package main

import (
    "fmt"
)

func main() {
    fmt.Printf("rescue return: %+v\n", rescue())  // => rescue return: false
}

func rescue() bool {
    defer func() {
        if err := recover(); err != nil {
            fmt.Printf("panic: %+v\n", err)  // => panic: error
        }
    }()

    panic("error")

    return true
}

へー,false になるらしい.

intの場合は?

func main() {
    fmt.Printf("rescue return: %+v\n", rescue())  // => rescue return: 0
}

func rescue() int {
    defer func() {
        if err := recover(); err != nil {
            fmt.Printf("panic: %+v\n", err)  // => panic: error
        }
    }()

    panic("error")

    return 10
}

つまり定義時の値を返している

上記のような例の場合,処理の流れ的にどこにも return する場所がないまま,panic / recover になってしまっている. そのときにどうやら,その型で定義したときの値を返している様子.

var i int    // => 0
var s string // => ""
var b bool   // => false

というわけで,recover で拾う場合はこのことを覚えておきましょう.何も考えずにrecover していると,うっかり正常に終了しているように見えるような値を返しているかもしれない.

そして例外的に使っているからこそ,できるだけ何が起こったかは把握しておいた方が良くて,recover した時にログくらい出しておかないとわからなくなる.

recover時に戻り値を指定する

defer 内で return しようとすると……

func main() {
    fmt.Printf("rescue return: %+v\n", rescue())  // => rescue return: false
}

func rescue() bool {
    defer func() bool {
        if err := recover(); err != nil {
            fmt.Printf("panic: %+v\n", err)    // => panic: error
            return true
        }
        return true
    }()
    return true
}

特に考慮はしてくれないらしい.というか,defer で呼び出した関数の戻り値は,親の関数の戻り値と何ら関係ないのか……. さすがに以下のようなことはできない

func rescue() bool {
    defer return func() bool {    // => syntax error: unexpected return
        if err := recover(); err != nil {
            fmt.Printf("panic: %+v\n", err)
            return true
        }
        return true
    }()
    return true
}

いや,そもそも deferreturn を呼ぶなんて無理か…….

ドキュメントを探すと,やり方はあるらしい.

func main() {
    fmt.Printf("rescue return: %+v\n", rescue())  // => rescue return: true
}

func rescue() (b bool) {
    defer func() {
        if err := recover(); err != nil {
            fmt.Printf("panic: %+v\n", err)
            b = true
        }
    }()

    panic("error")    // => panic: error

    return true
}

Defer, Panic, and Recover

Deferred functions may read and assign to the returning function's named return values.

なるほど,return はできないけど,return の値を書き換えることはできると…….

returnの書き換えを使うパターン

先のトランザクションの例で,例えば以下のようにちゃんと error を返すコードを書いていたとする.

func hoge(tx *sql.Tx) (bool, error) {
    defer func() {
        if err := recover(); err != nil {
            tx.Rollback()
        }
    }()
    // Do something
    tx.Exec(…)
    return true, nil
}

この場合,recover されると,errornil で返さる.エラーが起こっているにもかかわらず,以下のようなエラーチェックをしていると(goではすごく一般的なエラーチェックコード),呼び出し元でうまいことエラーチェックできない可能性がある.

func main() {
    result, err := hoge(tx)
    if err != nil {
        log.Fatal("error: ", err.Error())
    }
    // Do something
}

こんなコードを書いていたら,全然ハンドリングされないかもしれない. なので,error はやはりきっちり返したい.というようなときに,先ほどのreturn値の書き換えを使うしかなくなる.

func hoge(tx *sql.Tx) (r bool, e error) {
    defer func() {
        if err := recover(); err != nil {
            tx.Rollback()
            r = false
            e = errors.New("unexpected error!")
        }
    }()
    // Do something
    tx.Exec(…)
    return true, nil
}

っていうかできるだけrecover使わないで書こう

上記のような例は仕方ないにしても,できる限り,recover することや,その戻り値をアテにすることは避けたほうが良い.できることなら,goの一般的な関数らしく,戻り値にerror を含めて,何か起こったらpanic するのではなくerrors.New() した方がいい.

というのは,どこで聞いても言われることらしい. http://stackoverflow.com/questions/19934641/golang-returning-from-defer

golangのpanicは例外ではないのか?

goには例外がないと言われている.確かに,よく見かける,exception とかはないんだけど,あれ? panic があるじゃん?

では panic は例外ではないのか?

我々は、処理構造を制御するためのtry-catch-finally形式の例外処理機構によって、コードが入り組んでしまうと考えています。しかも、ファイルを開けないといった、ごく一般的なエラーをさも特別なエラーであるかのように扱わせる傾向があります。

Go言語では、異なるアプローチを取りました。Go言語では戻り値として複数の値が返せるので、一般的なエラーハンドリングの場合、戻り値といっしょにエラー情報を返すことができます。エラー型は標準化されており、Goの他の機能と相まってエラーハンドリングがすっきりしたものとなります。これは、他の言語と大きく異なる点です。

それとは別に、Go言語にはエラーシグナルの発行機構と、本当に例外的な状況から回復する機構があります。エラーの発生によって、関数のコールスタックの巻き戻りが開始する中でのみ、このエラー回復メカニズムは実行可能です。このメカニズムは、大きな障害をハンドリングするのにも充分な上に、処理構造に特別な制御を行う必要もありません。また上手に使えば、エラーハンドリングのコードが読みやすくもなります。

http://golang.jp/go_faq

つまり,try-catch-finally形式の例外ではない.

冒頭でも述べたように、Goは一般的な例外処理への使用を推奨しないものの、例外処理用の専用構文を備えている。一般的な例外にはあくまで戻り値を使う。

https://ja.wikipedia.org/wiki/Go_%28%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%E8%A8%80%E8%AA%9E%29#.E4.BE.8B.E5.A4.96.E5.87.A6.E7.90.86

なるほど.ということは,「例外が存在しない」わけではなくて「一般的なtry-catchの中で発生させているような使い勝手の良いexceptionがない」というべきなのか.

厳密に言えば,panic defer recoverpanic を拾えるわけだ.ただし,ブロックのようなものを作れないので,あくまで関数全体に対して,defer recover することにはなるのだが. ただ,その機構を いつものexception みたいに使うなよ っていう意味を込めての「例外がない」なのか.

とはいえ,実現したいことは理解できる.

一般的な,try-catch内で発生させる exception については,「それ本当に例外なのか?」というのと「読みにくい」に尽きる. そこに関しては納得できる.

ただ,例外を使わずこまめに error を返していくと,こういう意見にも納得できるわけで…….

if err != nil によるエラーハンドリングを繰り返し繰り返し書かなくてはならないことがあります。 Go Blogにはif err != nil { return err }のパターンはあまり出現しない(once per page or two)と書かれていますが、 プログラムのタイプによっては(例えばいろいろな外部リソースや外部ライブラリをつなぐようなコード)かなりの頻度で if err != nil を書かざるを得ないことがあるような気がします。

http://www.yunabe.jp/docs/golang_pitfall.html#section-4

さらに,この error を書くパターンだと,スタックトレースを見るために発生源の情報を error に追記する必要があり…….

これでエラーがどこで発生したのかが分かるようになります。ただこういうことしてると実は例外で良かったんじゃないかという気分にもなりますが。

http://www.yunabe.jp/docs/golang_pitfall.html#return-nil-err--error

なので,結局割といっぱいこのパターンを書かなきゃいけないとは思うんですよね.

ただ,最初の思惑どおり,やっぱり例外として既に別処理に回されたりしていることはないので,上から順番に処理されていくという意味で読みやすくはあります.

watchfiyがnfsマウントしたファイルの変更を検知してくれないとき

browserify + watchify + gulp

browserify + gulpでビルドするときに,よく差分ビルドをするためにwatchifyを使うことを推奨している記事は結構多い.そういう環境でビルドする方法は こちら あたりを参考にしてもらえると嬉しい.

これが,watchifyを入れておかないと,ファイルの変更を検知してくれないので,毎回自分でビルドコマンドを叩かなきゃいけないし,差分ビルドにならないので遅い.そんな開発環境は嫌だ.というわけで,gulpのビルドタスクにwatchifyを組み込んでおくことは,本当にいろんなところで紹介されている.

nfsマウントしたディスク上のファイル変更が検知されない

前述のような例に沿って同じようなgulpタスクを作ってみた.ただ,特に理由はないのだけれど,vagrantで動かしていた.

jsのソースファイルは,vagrantのshared folderを使って,ホストOS側の適当なディレクトリに放り込んでいる.vagrantはそれをnfsマウントして利用していた. この場合,jsのソースファイルの編集はホストOS側で行う.しかし,サーバはvagrant側で動かしているのでjsのビルドもvagrant側で行ってほしい.

そうすると,vagrant側のgulpはwatchifyを動かしていても,ファイル変更を検知してくれなかった.

これは,watchifyに poll オプションを与える必要がある.

var w = watchify(b, {
  poll: true
});

http://stackoverflow.com/a/30743525/4545174

これでホストOS側で変更したファイルの変更検知を,watchifyが行ってくれるので差分ビルドが走るようになった.

ちなみに遅い

ビルドが遅いのではなくて,変更検知にラグがある…….これはnfsマウントしているから仕方ないのか?

同じことを思う人はいたらしい.

Watchify polling very slow on Vagrant Machine

こういう記事,あまり出回っていないので,多分近年のフロントエンド開発をする人はvagrantとか使わないのかな……. *1

*1:ここに脚注を書きます