这篇文章会对Rails中的分页做个简单的介绍。利用Kaminari能够很简单的实现分页功能,然而它并不是万能的,有一些场景它无法很好地满足业务需求。针对这种场景我会在本篇提出一个简单的“权宜之计”。

简单分页

在Rails项目里面我们一般会采用Kaminari来实现分页功能。得益于Rails那优秀的生态,我们几乎不用费什么力气就能实现分页。

User.page(params[:page]).per(params[:per_page])

假设参数中page为10, per_page为2,它会直接利用了数据库的数据分割机制,每一页的数据为两条,然后获取第10页的数据。转换成数据库查询语句大概是这样子

SELECT "users".* FROM "users" LIMIT 2 OFFSET 9

除此之外Kaminari还会为查询结果提供常见的元数据,比如当前页数,总页数,数据总数等

> @users = User.page(1).per(2)

> @users.current_page # 当前页
=> 1

> @users.limit_value # 每页面包含数据
=> 2

> @users.total_pages # 总页数
=> 22

> @users.total_count # 总条数
=> 44

利用Kaminari提供的工具函数,编写有分页功能的API接口是如此地简单

简单的含分页API实现

# app/controllers/api/users_controller.rb

module Api
  class UsersController < ApplicationController
    def index
      @users = User.page(params[:page]).per(params[:per_page])
    end
  end
end
# app/views/api/users/index.json.rabl

child @users => :data do
  attributes :id, :nickname, :openid
end

child(:meta) do
  node(:total_pages) { @users.total_pages }
  node(:current_page) { @users.current_page }
  node(:total_count) { @users.total_count }
end

我是用了rabl来做JSON的模板引擎,这里只是贪方便,你也可以直接把JSON结构写在动作的render函数里面。

麻烦点的分页

上面提到的分页是比较简单的,就是直接套用了Kaminari提供的工具方法,这些工具方法是跟模型(Model)以及Rails查询结果本身挂钩的

> User.class # 模型本身
=> Class

> User.page(1).class # 模型本身可以调用`page`方法
=> User::ActiveRecord_Relation
> User.page(1).per(2) # Rails的查询结果可以调用`per`方法
=> User::ActiveRecord_Relation

注意: pageper方法都是Kaminari提供的。

这里为什么要强调Rails的查询结果呢?那是为了要跟数据库的查询结果做区分。其实在一些较为复杂的场景下,Rails本身所提供的方法就不太够用了,这种时候则需要自己写一些复杂的SQL。我们可以利用find_by_sql方法又或者是ActiveRecord::Base.connection.execute来执行相关的SQL语句

> User.find_by_sql('select * from users').class
=> Array

> User.all.class
=> User::ActiveRecord_Relation

> ActiveRecord::Base.connection.execute('select * from users').class
=> PG::Result

其实三者都是执行了类似的SQL语句,但是结果会封装到不同的数据结构里面。而Kaminari的工具方法只能应用在Rails的ORM模型(比如User)或者查询结果中(比如User::ActiveRecord_Relation),对其他两种并不生效

> User.find_by_sql('select * from users').page(1)
  User Load (0.6ms)  select * from users
Traceback (most recent call last):
        1: from (irb):20
NoMethodError (undefined method `page' for #<Array:0x0000000009bd0278>)

> ActiveRecord::Base.connection.execute('select * from users').page(1)
  (0.5ms)  select * from users
Traceback (most recent call last):
        2: from (irb):20
        1: from (irb):21:in `rescue in irb_binding'
NoMethodError (undefined method `page' for #<PG::Result:0x0000000009e09648>)

如果我像针对另外两种查询结果进行分页那咋整呢?Kaminari提供了比较方便的函数Kaminari.paginate_array,可以直接对数组来分页,所以针对上面的结果我们其实可以这样去分页

> results = Kaminari.paginate_array(User.find_by_sql('select * from users'))
  User Load (0.5ms)  select * from users
=> [#<User id: 48, nickname: "User#4", avatar: nil, mobile: "13744205053", openid: "0a18267e4917...

> results.page(1).per(2)
=> [#<User id: 48, nickname: "User#4", avatar: nil, mobile: "13744205053" ....

> results.page(2).per(2)
=> [#<User id: 47, nickname: "User#3", avatar: nil, mobile: "13744206757" ....

> results.total_count
=> 44



> results = Kaminari.paginate_array(ActiveRecord::Base.connection.execute('select * from users').to_a)
   (0.7ms)  select * from users
=> [{"id"=>48, "nickname"=>"User#4", "avatar"=>nil, "mobile"=>"13744205053", "openid"=>"0a18267e...

> results.page(1).per(2)
=> [{"id"=>48, "nickname"=>"User#4", "avatar"=>nil, "mobile"=>"13744205053", ...

> results.page(2).per(2)
=> [{"id"=>47, "nickname"=>"User#3", "avatar"=>nil, "mobile"=>"13744206757", ...

> results.total_count
=> 44

可见,只要简单地把PG::Result的数据用to_a转换成数组类型就能够进行分页。而且两者获得的结果集是类似的,最大的不同在于,通过User.find_by_sql获得的结果是一个User对象组成的数组。而通过ActiveRecord::Base.connection.execute获得的结果是一个PG::Result,经过to_a处理之后得到的是Hash对象组成的数组,他们包含的数据几乎一样但是就可操作性来讲还是User对象更好一些

> result_from_hash = ActiveRecord::Base.connection.execute('select * from users').to_a.first # Hash对象
   (0.5ms)  select * from users
=> {"id"=>48, "nickname"=>"User#4", "avatar"=>nil, "mobile"=>"...

> result_from_hash.id
Traceback (most recent call last):
        1: from (irb):3
NoMethodError (undefined method `id' for #<Hash:0x00000000032622a0>)
> result_from_hash['id']
=> 48


> result_from_user = User.find_by_sql('select * from users').first # User对象
  User Load (0.6ms)  select * from users
=> #<User id: 48, nickname: "User#4", avatar: nil, mobile: "13...
irb(main):006:0> result_from_user.id
=> 48

Hash对象要获取字段数据得通过Hash#[]方法,而User对象直接能够以函数调用的模式xx.xx去获取。

遗留问题

在一些较为复杂的场景下,Rails的ORM提供的查询往往不太够用,在这种场景下要手写SQL语句(我这里为了方便举例所以采用了较为简单的查询,真实的业务会复杂许多)。而在这种情况下Kaminari提供的工具方法已经不能直接使用了,我们可以通过Kaminari.paginate_array来对查询结果进行分页。

然而这种方法有很大的问题它必须要把数据全部查询出来之后,再对结果进行分页。利用这种方式得到的结果也会拥有page, per, total_count这些工具函数,代码的写法基本一致,这对于数据量不大或者程序员想偷懒的场景已然够用了。如果数据量到了一定的级别,这种做法就没法满足了,我们不太可能从数据库直接获取出1000甚至2000条数据,然后对这几千条数据进行分页,对于接口来说这种数据量的获取绝对会拖慢接口的响应速度。这个问题我们留在下一个篇章解决。

尾声

这篇文章简单介绍了一下Rails里面做分页的方式,利用工具库Kaminari并依赖Rails的ORM,我们能够很简单地写出分页的代码。然而有些场景下我们不得不用原生的SQL语句来解决业务上的查询问题,这种场景下就无法直接依赖Kaminari提供的工具函数。我们后面使用它所提供的Kaminari.paginate_array对结果进行封装,还是可以实现类似的分页效果的,然而这只是“权益之计”。从长远来看,要应对数据量巨大的场景,还是不得不借助数据库的分页机制,下一篇文章(进阶篇)将会详细谈谈面临复杂场景,不得不在Rails中手写SQL语句来查询的时候,如何利用数据库的分页机制,实现适应性更好的分页功能