Rails 谈谈 Rails 中的分页 - 简易版

lanzhiheng · March 30, 2021 · Last by charlie_hsieh replied at August 26, 2021 · 1482 hits

这篇文章会对 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 语句来查询的时候,如何利用数据库的分页机制,实现适应性更好的分页功能

Reply to hooopo

我昨天就想回复 pagy 来着…完爆 kaminari

Reply to levi0214

我回头 looklook,见识少,让二位见笑了。😂

Reply to lanzhiheng

不会不会,我也是去年从 kaminari 换成的 pagy,性能好,好定制,源码很短(真的很短)。

@levi0214 pagy 的 pretty url 似乎很难实现?

You need to Sign in before reply, if you don't have an account, please Sign up first.