- 返回值
- 引入返回值定制方案并编写公共方法;
- 优化之前模块的返回值信息的定制;
- 修改之前的测试并通过测试。
- 分页
- 引入分页功能并编写公共方法;
- 优化之前模块的代码;
- 编写分页测试并完成测试。
进入新的一章,最重要的事儿就是切换新分支开发。
$ git checkout -b chapter07
我们已经完成了用户和商铺模块的开发,完成了吗?确切地说是已经完成了一部分。
前面的开发过程中至少还有两个问题需要我们认真研究,第一个是返回值的字段和格式,第二个是分页。
首先我们来聊聊返回值的相关问题。
对于前后端分离开发而言,API的返回值十分发重要,因为返回值不仅仅携带了数据这么简单,好的返回值的数据结构应该是 统一风格的
,结构清晰的
,字段安全的
,节省资源的
。Restful风格的API通常返回的是JSON
类型的数据结构,我们也不例外,所以我们的目标就是要创建 统一风格的
,结构清晰的
,字段安全的
,节省资源的
JSON 数据结构。在前面的章节中,我们使用了自定义的转换方法,在具体的实现中我们“笨拙”的手动对数据格式进行了拼装,例如下面的代码:
class Api::V1::ShopsController < ApplicationController
def show
@data = set_response_data(@shop)
render json: {error_code:0, data:@data, message:'ok'}, status: 200
end
private
#......
def set_response_data shop
retrun {} unless shop.present?
{
id: shop.id,
name: shop.name,
products_count: shop.products_count,
orders_count: shop.orders_count,
created_at: shop.created_at,
owner:{
id: shop.user.id,
email: shop.user.email
}
}
end
end
以上代码是上一章商铺模块商铺详情展示的主体代码和返回值data部分拼装数据的方法。其实这种手动拼装的方法从灵活度上个人感觉还是不错的,也能统一风格,结构也很清晰,而且字段都是经过我们筛选的,也能保证安全。然而,最重要的问题是我们需要编写大量的这种侵入在控制器内的代码,当然我们可以使用编程技巧尝试解决这个问题,但是这其实是个公共需求,实际上是有解决方案的。
不过在引出解决方案前,我们先就上面我们提到的 “好的返回值的数据结构” 简单分析下原因:
-
统一风格
所有返回值的数据结构基本分为两类:请求成功的返回值,请求失败的返回值。无论是成功的还是失败的返回值,无论是哪一个API的返回值其基本结构风格都应该是统一的,包括参数名,参数数量,参数位置。统一的风格能够让项目前后端配合的更和谐。
-
结构清晰
返回值的数据应该是结构化的,每一部分的含义不能有歧义和混淆。从数据结构上就能明确体现数据之间的关系,是包含还是属于,是单数还是列表,都应该从结构上明确体现。和上面一样,结构清晰的返回值能够让项目前后端配合的更和谐。
-
字段安全
首先我们要明确返回值通常是数据库中存储的数据,然而不是所有的数据库字段都适合被返回给客户端,尤其是一些敏感字段,比如用户表中的存储密码信息的字段和其值,我们一定不能查询出来返回给客户端。还有一些字段通常情况下是没有必要返回的字段,比如创建时间字段。只返回必要的、没有安全隐患的字段是保证我们数据安全的必要条件!
-
节省资源
返回值是要通过网络传输给客户端的,所以我们应该尽量让返回值的体积变小,在前面三个约定的前提下,这部分实际上是可操作性最强的!也是我们引入现有方法比较重要的原因。
好了,下面就让我们引出本章的 “返回值担当“ -- jsonapi-serializer
!
首先我们先简单了解下 jsonapi-serializer
是什么。
jsonapi-serializer
是一个符合 JSON:API 规范的 Ruby 对象编译器,实际上就是能够很方便的把 Ruby 对象构造成特定格式的 json 数据。这里的特定格式类似于下面例子:
{
"data": {
"type": "user",
"id": "1",
"attributes": {
"email": "[email protected]"
},
"relationships": {
"products": {
"data": [
{ "type": "product", "id": "1" },
]
}
}
},
"include": [{
"type": "product",
"id": "1",
"attributes": {
"name": "iphone",
}
}],
"links": {
"self": "http://example.com/products/1",
"next": "http://example.com/products/2",
"last": "http://example.com/products/10"
}
}
- 支持通过定义序列化文件来自定义返回字段
- 支持 互相 引用
- 支持
belongs_to
,has_many
和has_one
- 支持缓存
-
序列化对象到hash
MovieSerializer.new(模型对象[, 可选参数]).serializable_hash
-
序列化对象到 Json
MovieSerializer.new(模型对象[, 可选参数]).serializable_hash.to_json
Gemfile
gem 'jsonapi-serializer'
$ bundle
通过以上命令我们就可以使用 jsonapi-serializer
了。
思路分析
首先要使用 jsonapi-serializer
提供的命令生成用户序列化的定义文件;
然后就可以改造用户控制器中的代码;
最后我们需要修改并通过测试。
-
生成用户序列化文件: 用户信息只返回email即可
$ rails generate serializer User email Running via Spring preloader in process 4089 create app/serializers/user_serializer.rb
我们可以查看
app/serializers/user_serializer.rb
文件class UserSerializer include JSONAPI::Serializer attributes :email end
其中的
attributes
就是标示我们要返回当前对象的那些字段, 这里我们需要也返回role
字段,所以我们手动添加上:# app/serializers/user_serializer.rb class UserSerializer include JSONAPI::Serializer attributes :email, :role end
-
修改用户控制器代码
app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController def index @users = User.offset(@page).limit(@per_page) render json:serializer_user(@users), status: 200 end def show render json: serializer_user(@user), status: 200 end def create @user = User.new(user_params) if @user.save render json: serializer_user(@user), status: 201 else render json: {error_code:500, message:@user.errors}, status: 201 end end def update if @user.update(user_params) render json: serializer_user(@user), status: 202 else render json: {error_code:500, message:@user.errors}, status: 202 end end private # ...... def serializer_user(user, error_code=0, message='ok') user_hash = UserSerializer.new(user).serializable_hash user_hash['error_code'] = error_code user_hash['message'] = message return user_hash end end
以上代码主要是定义了私有方法
serializer_user
, 然后在每个处理方法中调用该方法处理用户实例模型。现在如果访问
/api/v1/users
可以获得下列数据信息:{ "error_code": 0, "data": [ { "id": "1", "type": "user", "attributes": { "email": "[email protected]", "role": 1 } }, { "id": "2", "type": "user", "attributes": { "email": "[email protected]", "role": 1 } } ], "message": "ok" }
-
修改测试
如果现在直接运行测试,会报错,那是因为我们的返回值的格式变化了,我们可以修改我们的测试:
require "test_helper" class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest test "show_success: should show user" do get api_v1_user_path(@user), as: :json json_response = JSON.parse(self.response.body) # 验证状态码 assert_response 200 # 验证返回数据 assert_equal @user.email, json_response['data']['attributes']['email'] end end
这个容易修改,因为我们的返回值出现了变动,所以只修改测试中的
show_success: should show user
即可。现在测试
$ rails test Finished in 2.116465s, 15.1196 runs/s, 18.8994 assertions/s. 32 runs, 40 assertions, 0 failures, 0 errors, 0 skips
思路分析
首先要使用 jsonapi-serializer
提供的命令生成商铺序列化的定义文件;
然后就可以改造商户控制器中的代码;
最后我们需要修改并通过测试。
-
生成商铺序列化文件:
$ rails generate serializer Shop Running via Spring preloader in process 6384 create app/serializers/shop_serializer.rb
我们可以查看
app/serializers/shop_serializer.rb
文件class ShopSerializer include JSONAPI::Serializer attributes end
这里我们需要设定需要返回的字段:
# app/serializers/shop_serializer.rb class ShopSerializer include JSONAPI::Serializer attributes :name, :products_count, :orders_count belongs_to :user end
现在就体现出了
jsonapi-serializer
的优势 -
修改商铺控制器代码
app/controllers/api/v1/shops_controller.rb
class Api::V1::ShopsController < ApplicationController # ... ... def index @shops = Shop.offset(@page).limit(@per_page) render json: serializer_shop(@shops), status: 200 end def show render json: serializer_shop(@shop), status: 200 end def create @user = current_user @shop = Shop.new(shop_params) @shop.user = @user @shop.transaction do @user.role = 2 if @shop.save! && @user.save! render json: serializer_shop(@shop), status: 201 else render json: {error_code:500, message:@shop.errors}, status: 201 end end end def update @user = current_user if @user.shop.update(shop_params) render json: serializer_shop(@shop), status: 201 else render json: {error_code:500, message:@shop.errors}, status: 201 end end private def serializer_shop(shop, error_code=0, message='ok') options = { include: [:user] } shop_hash = ShopSerializer.new(shop, options).serializable_hash shop_hash['error_code'] = error_code shop_hash['message'] = message return shop_hash end # def set_response_data shop # retrun {} unless shop.present? # { # id: shop.id, # name: shop.name, # products_count: shop.products_count, # orders_count: shop.orders_count, # created_at: shop.created_at, # owner:{ # id: shop.user.id, # email: shop.user.email # } # } # end end
以上代码主要是定义了私有方法
serializer_shop
, 然后在每个处理方法中调用该方法处理商铺实例模型,此时我们可以删除掉方法set_response_data
。需要注意的是:在 方法中我们添加了
options
参数,并把它当做ShopSerializer.new
的第二个参数,用以实现返回值包含具体的商户信息。现在如果访问
/api/v1/shops
可以获得下列数据信息:{ "data": [ { "id": "1", "type": "shop", "attributes": { "name": "test1", "products_count": 0, "orders_count": 0 }, "relationships": { "user": { "data": { "id": "1", "type": "user" } } } } ], "included": [ { "id": "1", "type": "user", "attributes": { "email": "[email protected]", "role": 1 } } ], "error_code": 0, "message": "ok" }
-
修改测试
如果现在直接运行测试,会报错,那是因为我们的返回值的格式变化了,我们可以修改我们的测试:
require "test_helper" class Api::V1::ShopsControllerTest < ActionDispatch::IntegrationTest test "show_success: should show shop" do get api_v1_shop_path(@shop), as: :json json_response = JSON.parse(self.response.body) # 验证状态码 assert_response 200 # 验证返回数据 assert_equal @shop.name, json_response['data']['attributes']['name'] end end
这个容易修改,因为我们的返回值出现了变动,所以只修改测试中的
show_success: should show shop
即可。现在测试
$ rails test Finished in 2.134784s, 14.9898 runs/s, 18.7373 assertions/s. 32 runs, 40 assertions, 0 failures, 0 errors, 0 skips
$ git add .
$ git commit -m "add jsonapi-serializer"
我们在已经开发的用户和商铺模块中使用了简单的自己写的分页,但是分页实际上还需要有很多其它的需求,比如总数,上页,下页,最后一页等。我们本节将引入一个分页工具 will_paginate
来帮助我们实现更多复杂的操作。
will_paginate
是一个强大的与Rails深度集成的分页库。当然还有很多其它的实现方案,也可以使用。
- 使用简单
- 功能强大
- 与Rails深度集成
-
paginate(page: 第几页, per_page: 每一页显示数量)
-
page(第几页)
-
per_page(每一页显示数)
-
total_pages # 总页数
-
previous_page # 上一页
-
next_page # 下一页
更多api请查阅官方文档
-
模型内配置
# Post 模型 app/models/post.rb class Post self.per_page = 10 end
-
全局配置
# app/models/application_record.rb WillPaginate.per_page = 10
Gemfile
gem 'will_paginate'
$ bundle
通过以上命令我们就可以使用 will_paginate
了。
will_paginate
的使用很简单,可以在模型对象上像使用模型的类方法一样直接使用即可,例如:
User.page(current_page).per_page(per_page)
app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
# ......
WillPaginate.per_page = 20
end
我们这里设置默认值为20,这就相当于以后调用分页会自动把每页设定为显示20条。
分页肯定是多出用到,而且我们这里一般是在控制器中,其实我们在前面已经自己定义了 app/controllers/concerns/paginable.rb
文件,下面我们直接修改它就可以:
module Paginable
protected
def _to_i(param, default_no = 1)
param && param&.to_i > 0 ? param&.to_i : default_no.to_i
end
# def set_page
# @page = _to_i(params[:page], 1)
# @page = set_per_page * (@page - 1)
# end
# def set_per_page
# @per_page = _to_i(params[:per_page], 10)
# end
def current_page
_to_i(params[:page], 1)
end
def per_page
_to_i(params[:per_page], 10)
end
def get_links_serializer_options links_paths, collection
{
links: {
first: send(links_paths, page: 1),
last: send(links_paths, page: collection.total_pages),
prev: send(links_paths, page: collection.previous_page),
next: send(links_paths, page: collection.next_page),
}
}
end
end
重点说明下 get_links_serializer_options
方法,它的出现是为了在返回值中添加相关的分页信息,这个需要作为我们在上面引入的分页方法的第二个参数。
分页基本上要在所有的控制器含有列表的地方使用,所以我们在控制器的全局引入分页的公共方法。
app/controllers/application_controller.rb
class ApplicationController < ActionController::API
# ... ...
include Paginable
# ... ...
end
其实我们之前已经引入过了,如果没有引入,需要在这里引入一下。
在用户模块中,我们在用户列表的地方使用到了分页,所以我们需要的就是修改 users#index
方法:
app/controllers/api/v1/users_controller.rb
def index
@users = User.page(current_page).per_page(per_page)
options = get_links_serializer_options 'api_v1_users_path', @users
render json: serializer_user(@users,0,'ok',options), status: 200
end
上面的代码,我们传入了options参数,是因为我们也要将分页信息加入到返回的信息中,所以我们需要修改 users#serializer_user
:
def serializer_user(user, error_code=0, message='ok', options={})
user_hash = UserSerializer.new(user, options).serializable_hash
user_hash['error_code'] = error_code
user_hash['message'] = message
return user_hash
end
现在,users_controller.rb
整体文件变为:
class Api::V1::UsersController < ApplicationController
# before_action :set_per_page, only: [:index]
# before_action :set_page, only: [:index]
before_action :set_user, only: [:show, :update, :destroy]
# before_action :check_login?, expect: [:destroy]
before_action :check_admin, only: [:index, :destroy]
before_action :check_admin_or_owner, only: [:update]
def index
@users = User.page(current_page).per_page(per_page)
options = get_links_serializer_options 'api_v1_users_path', @users
render json: serializer_user(@users,0,'ok',options), status: 200
end
def show
render json: serializer_user(@user), status: 200
end
def create
@user = User.new(user_params)
if @user.save
render json: serializer_user(@user), status: 201
else
render json: {error_code:500, message:@user.errors}, status: 201
end
end
def update
if @user.update(user_params)
render json: serializer_user(@user), status: 202
else
render json: {error_code:500, message:@user.errors}, status: 202
end
end
def destroy
@user.destroy
render json: {error_code:0, message:'ok'}, status: 204
end
private
def set_user
@user = User.find_by_id params[:id].to_i
@user = @user || {}
end
def user_params
params.require(:user).permit(:name, :email, :password)
end
def is_admin?
current_user&.role == 0
end
def check_admin
head 403 unless is_admin?
end
def is_owner?
@user.id == current_user&.id
end
def check_admin_or_owner
head 403 unless is_admin? || is_owner?
end
def serializer_user(user, error_code=0, message='ok', options={})
user_hash = UserSerializer.new(user, options).serializable_hash
user_hash['error_code'] = error_code
user_hash['message'] = message
return user_hash
end
end
你会发现,有两行代码被我注释掉了:
# before_action :set_per_page, only: [:index]
# before_action :set_page, only: [:index]
这是之前我们自己实现分页所用的代码!现在可以删除掉。
现在访问 api/v1/users
获取的数据格式是这样的:
{
"data": [
{
"id": "1",
"type": "user",
"attributes": {
"email": "[email protected]",
"role": 1
}
},
{
"id": "2",
"type": "user",
"attributes": {
"email": "[email protected]",
"role": 1
}
},
{
"id": "3",
"type": "user",
"attributes": {
"email": "[email protected]",
"role": 1
}
},
{
"id": "4",
"type": "user",
"attributes": {
"email": "[email protected]",
"role": 0
}
}
],
"links": {
"first": "/api/v1/users?page=1",
"last": "/api/v1/users?page=1",
"prev": "/api/v1/users",
"next": "/api/v1/users"
},
"error_code": 0,
"message": "ok"
}
我们可以为返回值的分页标签添加测试,由于分页实在用户列表中,所以测试可以在用户列表的测试中完成:
test "index_success: should show users" do
get api_v1_users_path,
# 新增
headers: { Authorization: JsonWebToken.encode(user_id: @user.id) },
as: :json
assert_response 200
# 测试分页
json_response = JSON.parse(response.body, symbolize_names:true)
assert_not_nil json_response.dig(:links, :first)
assert_not_nil json_response.dig(:links, :last)
assert_not_nil json_response.dig(:links, :prev)
assert_not_nil json_response.dig(:links, :next)
end
在这里,我们引入了一个新的语法 json_response.dig
, 这是使用Hash#dig方法。它是一种Ruby方法,允许您在嵌套Hash中检索元素,避免在元素不存在时出现错误。
现在运行测试:
$ rails test
Finished in 2.329378s, 13.7376 runs/s, 18.8892 assertions/s.
32 runs, 44 assertions, 0 failures, 0 errors, 0 skips
在商铺模块中,我们在商铺列表的地方使用到了分页,所以我们需要的就是修改 shops#index
方法:
app/controllers/api/v1/shops_controller.rb
def index
@shops = Shop.page(current_page).per_page(per_page)
option = get_links_serializer_options 'api_v1_shops_path', @shops
render json: serializer_shop(@shops, 0, 'ok', option), status: 200
end
上面的代码,我们传入了options参数,是因为我们也要将分页信息加入到返回的信息中,所以我们需要修改 shops#serializer_shop
:
def serializer_shop(shop, error_code=0, message='ok', option = {})
options = { include: [:user] }
options = options.merge(option)
shop_hash = ShopSerializer.new(shop, options).serializable_hash
shop_hash['error_code'] = error_code
shop_hash['message'] = message
return shop_hash
end
现在,shops_controller.rb
整体文件变为:
class Api::V1::ShopsController < ApplicationController
# before_action :set_per_page, only: [:index]
# before_action :set_page, only: [:index]
before_action :set_shop, only: [:show, :update, :destroy]
before_action :check_login, only: [:create, :update, :destroy]
before_action :check_owner, only: [:update, :destroy]
def index
@shops = Shop.page(current_page).per_page(per_page)
option = get_links_serializer_options 'api_v1_shops_path', @shops
render json: serializer_shop(@shops, 0, 'ok', option), status: 200
end
def show
render json: serializer_shop(@shop), status: 200
end
def create
@user = current_user
@shop = Shop.new(shop_params)
@shop.user = @user
@shop.transaction do
@user.role = 2
if @shop.save! && @user.save!
render json: serializer_shop(@shop), status: 201
else
render json: {error_code:500, message:@shop.errors}, status: 201
end
end
end
def update
@user = current_user
if @user.shop.update(shop_params)
render json: serializer_shop(@shop), status: 201
else
render json: {error_code:500, message:@shop.errors}, status: 201
end
end
def destroy
@shop.destroy
head 204
end
private
def set_shop
@shop = Shop.includes(:user).find_by_id params[:id]
@shop = @shop || {}
end
def shop_params
params.require(:shop).permit(:name, :products_count, :orders_count)
end
def check_login
head 401 unless current_user
end
def check_owner
head 403 unless current_user.id == @shop.user.id
end
def serializer_shop(shop, error_code=0, message='ok', option = {})
options = { include: [:user] }
options = options.merge(option)
shop_hash = ShopSerializer.new(shop, options).serializable_hash
shop_hash['error_code'] = error_code
shop_hash['message'] = message
return shop_hash
end
end
现在访问 api/v1/shops
, 返回值:
{
"data": [
{
"id": "1",
"type": "shop",
"attributes": {
"name": "test1",
"products_count": 0,
"orders_count": 0
},
"relationships": {
"user": {
"data": {
"id": "1",
"type": "user"
}
}
}
}
],
"included": [
{
"id": "1",
"type": "user",
"attributes": {
"email": "[email protected]",
"role": 1
}
}
],
"links": {
"first": "/api/v1/shops?page=1",
"last": "/api/v1/shops?page=1",
"prev": "/api/v1/shops",
"next": "/api/v1/shops"
},
"error_code": 0,
"message": "ok"
}
我们可以为返回值的分页标签添加测试,由于分页实在商铺列表中,所以测试可以在商铺列表的测试中完成:
test "index_success: should show shops" do
get api_v1_shops_path, as: :json
assert_response 200
# 测试分页
json_response = JSON.parse(response.body, symbolize_names:true)
assert_not_nil json_response.dig(:links, :first)
assert_not_nil json_response.dig(:links, :last)
assert_not_nil json_response.dig(:links, :prev)
assert_not_nil json_response.dig(:links, :next)
end
现在运行测试:
$ rails test
Finished in 2.218713s, 14.4228 runs/s, 21.6342 assertions/s.
32 runs, 48 assertions, 0 failures, 0 errors, 0 skips
$ git add .
$ git commit -m "git add will_paginate"
$ git checkout master
$ git merge chapter07
我们本章引入了返回值定制和分页系统,可以使我们的工作更顺畅!下一节,我们将继续开发我们的简约商城系统的一个很重要的模块:商品模块!大家加油!