【山竹记账后端】2.RESTful API 与路由、分页实现


大纲链接 §

[toc]


1. REST风格是什么

REST 是什么?

  • Representational State Transfer
  • 一种网络软件 架构风格
    • 不是标准、不是协议、不是接口,只是一种风格
    • Roy于2000年在自己博士论文中提到此术语
    • Roy曾参与撰写HTTP规格文档

怎么做?

  1. 资源为中心,一旦此为名字一般就是资源,如果为动词就不是资源:用户、创建用户
  2. 充分利用 HTTP现有功能,如动词、状态码、头部字段
  3. Github API 就比较符合 REST

参考


REST风格举例

请求1:创建 item

1
2
3
4
POST /api/v1/items
Content-Type: application/json
消息体 {"amount": 99, "kind": "income"}
响应 {"resource": {...},} 或者 {"errors": {...}}
  • 注意货币的最小单位,避免浮点数的精度问题,99

请求2:创建 item

1
2
3
POST /api/v1/items
Content-Type: application/x-www-form-urlencoded
消息体 amount-99&kind=income

请求3:更新 item

1
2
3
PATCH /api/v1/items/1
Content-Type: application/json
消息体 {"amount": 11, "kind": "expense"}

反风格:

1
POST /api/v1/modify_items?id=1
  • 反方观点:全用 POST 多省事儿
  • 我方观点:自己想路径,多费事儿

请求4:删除 item

1
DELETE /api/v1/items/1

反风格:

1
POST /api/v1/remove_items?id=1
  • 反方观点:POST 多省事儿
  • 我方观点:有 DELETE 不用非要自己想,多费事儿

请求5:获取 一个或多个 item

1
2
3
4
5
6
7
8
9
GET /api/v1/items/1
GET /api/v1/items?page=1&per_page=10
GET /api/v1/user/2/items
GET /api/v1/items?user_id=2
GET /api/v1/items?tags_id[]=1&tag_id[]=2
GET /api/v1/items?tags_id=1,2
GET /api/v1/items?sort_by[]=id+asc&sort_by[]=name+desc
GET /api/v1/items?keyword=hi
GET /api/v1/items/search/hi
  • 单条或多条记录
    • 单条 GET /api/v1/items/1 省略了 id
    • 多条 GET /api/v1/items?page=1&per_page=10 获取分页数据
    • 按用户获取通过传id获取多条 GET /api/v1/user/2/items?... 从属关系,资源嵌套
    • 无资源嵌套,拍平 GET /api/v1/items?user_id=2
  • 数组
    • 属于两个tag的记录 GET /api/v1/items?tags_id[]=1&tag_id[]=2
    • 表示资源数组 tags_id[],后端框架自动拼接,比如 rails
    • 其他框架的反风格:GET /api/v1/items?tags_id=1,2
    • 如果 url 太长就不得不改为 POST
  • 排序,二重排序 GET /api/v1/items?sort_by[]=id+asc&sort_by[]=name+desc
    • + 就是空格
    • 排序条件相同则按创建时间排序
  • 搜索
    • GET /api/v1/items?keyword=hi
    • 反风格 GET /api/v1/items/search/hi

REST风格总结

  1. 尽量以资源为中心:url里的items就是资源
  2. 尽量使用HTTP现有功能:其实响应头里也可以包含内容,但目前的例子都没有用到
  3. 可以适当违反规则:比如 /api/v1/items/search/hi

人话版REST风格总结

  • 看见 路径 就知道请求什么东西
  • 看见 动词 就知道是什么操作
  • 看见 状态码 就知道结果是什么
    • 200 - 成功
    • 201 - 创建成功
    • 400 - 其他所有错误,详细原因可以放在 body
    • 404 - 未找到
    • 403 - 没有权限
    • 401 - 未登录
    • 422 - 无法处理的实体,参数有问题
    • 402 - 需付费
    • 412 - 不满足前提条件,流程中常用
    • 429 - 请求太频繁

为什么不喜欢REST

需求:批量创建 items

  • POST /api/v1/items 只能创建一个 item 返回一个结果

可以适当改造 REST

  • POST /api/v1/items/batch
  • 请求消息体 [{"amount": 1}, {"amount": -2}, {"amount": 3}]
  • 响应内容 {"resources: [{...}, null, {...}]"}, {"errors": [null, {...}, null]}
  • 更多批处理
    • POST /api/v1/items/batch
    • DELETE /api/v1/items/batch
    • UPDATE /api/v1/items/batch

符合 REST 风格的 API 就叫 RESTful API


2. API概要设计

  • 使用 Rails 提供的强大工具

2.1 发送验证码

sms_code

  • 资源: validation_codes
  • 动作: 只有一个 create POST
  • 状态码: 200 | 201 | 422 | 429成功 | 创建成功 | 请求参数验证错误 | 请求太频繁

2.2 登入登出

  • 资源: session (注意没有 s,单点登录)
  • 动作: create | destroy POST | DELETE
  • 状态码: 200 | 422

2.3 当前用户

  • 资源: me
  • 动作: show GET
  • 状态码: 200 | 429

2.4 记账数据

  • 资源: items
  • 动作: create | update | show | index | destroy
    • update 对应 PATCH 表示部分字段更新
    • show 对应 GET /items/:id 用来表示一条记账记录
    • index 对应 GET /items?since=2026-01-01&before=2026-02-01
    • destroy 对应 DELETE 表示删除,一般为软删除
  • 状态码: 200 | 201 | 422 | 429

2.5 标签

  • 资源: tags
  • 动作: create | update | show | index | destroy
  • 状态码: 200 | 201 | 422 | 429

2.6 打标签* 暂不实现

记录用户和标签的关系

  • 资源: taggings (动词的名词形式)
  • 动作: create | index | destroy
  • 状态码: 200 | 201 | 422 | 429

API 概要设计已完成

  • 接下来有一些细节要注意

3. 开始实现-路由 api

手动添加路由 config/routes.rb

1
2
3
4
Rails.application.routes.draw do
  post "/validation_codes", to: "validationCodes#create"
  #...
end

对比使用 namespaces 简略写路径,自动生成路由

1
2
3
4
5
6
7
8
9
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      # 写在这里都是以 /api/v1 开头的路由,自动生成资源对应的6个方法
      resources :validation_codes
    end
  end

end
  • namespace 自动添加前缀(命名空间)
  • 运行命令 bin/rails routes 查看路由:URI Pattern 对应 Controller#Action
    • GET /api/v1/validation_codes(.:format) 对应 api/v1/validation_codes#index
    • POST /api/v1/validation_codes(.:format) 对应 api/v1/validation_codes#create
    • GET /api/v1/validation_codes/:id(.:format) 对应 api/v1/validation_codes#show
    • PATCH /api/v1/validation_codes/:id(.:format) 对应 api/v1/validation_codes#update
    • DELETE /api/v1/validation_codes/:id(.:format) 对应 api/v1/validation_codes#destroy
  • resources :xxx 自动生成资源对应的6个方法
  • 手动实现这些api对应的方法

rails routes


配置路由缺省方法

1
2
3
4
5
6
7
8
9
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      # 写在这里都是以 /api/v1 开头的路由,仅生成配置的方法
      resources :validation_codes, only: [:create]
    end
  end

end
  • resources :validation_codes, only: [:create] 仅生成配置的路由、方法

配置好其他所有路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :validation_codes, only: [:create]
      resources :session, only: [:create, :destroy]
      resources :me, only: [:show]
      resources :items, exclude: [:put]
      resources :tags, exclude: [:put]
    end
  end

end

rails routes all

具体对应方法实现先暂时不写


参考文档


3. 实现 validation_codescreate 的路由

创建数据表

创建表

  • 运行命令 bin/rails g model ValidationCode email:string kind:integer used_at:datetime

    • 注意 ValidationCode 没有 s

      1
      2
      3
      4
      5
      
      bin/rails g model ValidationCode email:string kind:integer used_at:datetime
      
      #      invoke  active_record
      #      create    db/migrate/20260909153656_create_validation_codes.rb
      #      create    app/models/validation_code.rb

db/migrate/20260909153656_create_validation_codes.rb 修改部分配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class CreateValidationCodes < ActiveRecord::Migration[7.2]
  def change
    create_table :validation_codes do |t|
      t.string :email
      t.integer :kind, default: 1, null: false
      t.datetime :used_at

      t.timestamps
    end
  end
end

迁移数据库

  • 运行命令 bin/rails db:migrate

    1
    2
    3
    4
    
    == 20260909153656 CreateValidationCodes: migrating ============================
    -- create_table(:validation_codes)
    -> 0.0273s
    == 20260909153656 CreateValidationCodes: migrated (0.0274s) ===================

反悔迁移数据库

由于 validation_codescode 字段未添加,需要撤销迁移

  • 运行命令 bin/rails db:rollback 撤销迁移

    1
    2
    3
    4
    
    == 20260909153656 CreateValidationCodes: reverting ============================
    -- drop_table(:validation_codes)
    -> 0.0048s
    == 20260909153656 CreateValidationCodes: reverted (0.0082s) ===================

再次修改 db/migrate/20260909153656_create_validation_codes.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class CreateValidationCodes < ActiveRecord::Migration[7.2]
  def change
    create_table :validation_codes do |t|
      t.string :email
      t.integer :kind, default: 1, null: false
      t.string :code, limit: 100
      t.datetime :used_at

      t.timestamps
    end
  end
end
  • 再次运行 bin/rails db:migrate

    1
    2
    3
    4
    
    == 20260909153656 CreateValidationCodes: migrating ============================
    -- create_table(:validation_codes)
    -> 0.0135s
    == 20260909153656 CreateValidationCodes: migrated (0.0136s) ===================

创建 Controller

  • 运行命令 bin/rails g controller validation_codes create

    1
    2
    
      create  app/controllers/validation_codes_controller.rb
       route  get "validation_codes/create"
  • 删除自动添加生成的路由

修改前缀 添加目录 api/v1

  • app/controllers 中添加目录 api/v1
  • 将生成的 validation_codes_controller.rb 移到 app/controllers/api/v1 目录下
  • 修改 validation_codes_controller.rb 中类名,添加命名空间前缀 Api::V1::ValidationCodesController

    • 注意命名空间需要添加两个冒号隔开 Api::V1::

      1
      2
      3
      4
      5
      
      class Api::V1::ValidationCodesController < ApplicationController
      def create
      head 201
      end
      end
  • 启动服务 bin/rails s

  • 查看是否可以访问到 /api/v1/validation_codes

    • 使用命令 curl -X POST http://127.0.0.1:3000/api/v1/validation_codes -v
    • -v 查看返回结果中状态码

      1
      
      < HTTP/1.1 201 Created
  • 修改 head 202 再运行 curl -X POST http://127.0.0.1:3000/api/v1/validation_codes -v

  • 查看返回结果中状态码


5. 实现 items 的分页

实现分页的两种方案

  1. 使用 pageper_page 参数,见 kaminaripagy
  2. 使用 start_idlimit 参数,需要 id 是自增数字

创建 ItemsController

  • 运行命令 bin/rails g controller Api::V1::Items index create

    • 注意有两个冒号 ::ruby 用来区分命名空间层级的符号

      1
      
      # create  app/controllers/api/v1/items_controller.rb

删除自动生成的路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
 route  namespace :api do
    namespace :v1 do
    end
  end

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      #get "items/index"
      #post "items/create"
      # /api/v1
      resources :validation_codes, only: [:create]
      resources :session, only: [:create, :destroy]
      resources :me, only: [:show]
      resources :items, exclude: [:put]
      resources :tags, exclude: [:put]
    end
  end

end

创建 ItemsModel

  • 运行命令 bin/rails g model item user_id:integer amount:integer notes:text happened_at:datetime
    • textstring 的区别就是对应数据库中 string 短字符和 varchar 长字符
  • 补充字段 重新运行命令 bin/rails g model item user_id:integer amount:integer notes:text tags_id:integer happened_at:datetime --force

    • 添加 --force 会删除之前创建的,在重新创建当前的同名 model

      1
      2
      3
      4
      5
      6
      7
      8
      
      #      invoke  active_record
      #      create    db/migrate/20260910062438_create_items.rb
      #      create    app/models/item.rb
      
      #      invoke  active_record
      #      remove    db/migrate/20260910062438_create_items.rb
      #      create    db/migrate/20260910063034_create_items.rb
      #   identical    app/models/item.rb

创建的空文件 app/models/item.rb 先不管

1
2
class Item < ApplicationRecord
end

修改创建的文件 db/migrate/20260910063034_create_items.rb 中字段类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class CreateItems < ActiveRecord::Migration[7.2]
  def change
    create_table :items do |t|
      t.bigint :user_id
      t.integer :amount
      t.text :note
      t.bigint :tags_id, array: true
      t.datetime :happened_at

      t.timestamps
    end
  end
end
  • user_id 暂时不做外键
    • 做测试时,外键需要把相关所有表的信息都填好
    • 数据库层面,暂时只需要做指引就好了
  • tags_id 为数组,需要加上 array: true
    • PostgrsSql 支持数组类型

数据迁移

  • 运行命令 bin/rails db:migrate

    1
    2
    3
    4
    
    == 20260910063034 CreateItems: migrating ======================================
    -- create_table(:items)
    -> 0.0212s
    == 20260910063034 CreateItems: migrated (0.0213s) =============================
  • 右键刷新数据库插件 mangosteen_dev 查看是否添加了 items


尝试实现 ItemsController

实现 app/controllers/api/v1/items_controller.rbcreate 方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Api::V1::ItemsController < ApplicationController
  def index
  end

  def create
    item = Item.new amount: 1
    if item.save
      render json: { resource: item }
    else
      render json: { error: item.errors }
    end
  end
end
  • 使用 curl -X POST 创建:curl -X POST http://127.0.0.1:3000/api/v1/items -v
    • 子字符串查询 终端输入 curl -X POST,然后按上,会自动查询补全上一次相近的命令

查看返回

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
*   Trying 127.0.0.1:3000...
* Connected to 127.0.0.1 (127.0.0.1) port 3000 (#0)
> POST /api/v1/items HTTP/1.1
> Host: 127.0.0.1:3000
> User-Agent: curl/8.0.1
> Accept: */*
> 
< HTTP/1.1 200 OK
< x-frame-options: SAMEORIGIN
< x-xss-protection: 0
< x-content-type-options: nosniff
< x-permitted-cross-domain-policies: none
< referrer-policy: strict-origin-when-cross-origin
< content-type: application/json; charset=utf-8
< vary: Accept
< etag: W/"e72fe408864ee29d2d86ac4b85b287c1"
< cache-control: max-age=0, private, must-revalidate
< x-request-id: 0e5f7d1f-60aa-4ecb-a633-da62a24ea9bf
< x-runtime: 0.095545
< server-timing: start_processing.action_controller;dur=0.01, sql.active_record;dur=12.01, start_transaction.active_record;dur=0.01, transaction.active_record;dur=6.03, process_action.action_controller;dur=40.04
< content-length: 173
< 
* Connection #0 to host 127.0.0.1 left intact
{"resource":{"id":1,"user_id":null,"amount":1,"note":null,"tags_id":null,"happened_at":null,"created_at":"2026-09-10T07:20:54.496Z","updated_at":"2026-09-10T07:20:54.496Z"}}#                                                                                                                                                              

手动创建了21个(后续会在测试中写自动创建逻辑) curl -X POST http://127.0.0.1:3000/api/v1/items -v 实现 app/controllers/api/v1/items_controller.rbindex 方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Api::V1::ItemsController < ApplicationController
  def index
    p " Item.all-----------------------------------"
    p  Item.all
    render json: { resource:  Item.all }
  end

  def create
    #...
  end
end
  • 先尝试打印 Item,发请求 curl GET http://127.0.0.1:3000/api/v1/items -v

修改 app/controllers/api/v1/items_controller.rb

1
2
3
4
5
6
7
8
9
class Api::V1::ItemsController < ApplicationController
  def index
    Item.page(1)
  end

  def create
    #...
  end
end
  • 使用 curl http://127.0.0.1:3000/api/v1/items -v 报错 < HTTP/1.1 500 Internal Server Error
  • 使用浏览器访问接口 http://127.0.0.1:3000/api/v1/items 查看报错页面
  • Rails 识别用户使用浏览器请求,返回一个html报错页面;识别用户使用 curl 请求则返回一个 json

NoMethodError_Items_Cli NoMethodError_Items


使用第三方库kaminari实现 ItemsController

使用 pageper_page 参数,使用第三方库 kaminaripagy

  • gem 'kaminari' 写到 Gemfile
  • 或者直接 gem install kaminari

Gemfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
source "https://rubygems.org"

gem "rails", "~> 7.2.3", ">= 7.2.3.2"
gem "pg", "~> 1.1"
gem "puma", ">= 5.0"

gem "tzinfo-data", platforms: %i[ mswin mswin64 mingw x64_mingw jruby ]
gem "bootsnap", require: false
# gem 'kaminari' # 安装最新版
gem 'kaminari', '~> 1.2.2' # 安装特定兼容版本

group :development, :test do
  gem "debug", platforms: %i[ mri mswin mswin64 mingw x64_mingw ], require: "debug/prelude"
  gem "brakeman", require: false
  gem "rubocop-rails-omakase", require: false
end
  • 运行命令 bundle install 或者简写形式 bundle 安装依赖
    • 已默认配置好国内镜像源,加速安装依赖
    • 已默认开启详细模式 bundle --verbose
  • 安装成功后,重启服务 bin/rails s
  • 安装最新版 gem 'kaminari'
  • 安装特定兼容版本 gem 'kaminari', '~> 1.2.2'
  • 一般不用指定版本号, Gemfile.lock 自动锁版本
  • 此项目依赖都安装在全局环境,项目本地目录没有依赖
    • 可以使用 gem env 查看 INSTALLATION DIRECTORY 对应的路径下
    • 使用 ls /usr/local/rvm/gems/ruby-3.1.2/gems 查看所有依赖
    • 这个目录已在镜像中持久化

参考


再次修改 app/controllers/api/v1/items_controller.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Api::V1::ItemsController < ApplicationController
  def index
    item = Item.page(1)
    render json: { resource: item }
  end

  def create
    #...
  end
end
  • 发起请求 curl GET http://127.0.0.1:3000/api/v1/items -v 查看结果
  • kaminari 默认配置每页 25 项数据,可以自定义配置
    • 运行命令配置 bin/rails g kaminari:config 添加全局配置文件
    • 自动生成 create config/initializers/kaminari_config.rb 可以自行编辑配置

kaminari 初始化配置 config/initializers/kaminari_config.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# frozen_string_literal: true

Kaminari.configure do |config|
  config.default_per_page = 10
  # config.max_per_page = nil
  config.max_per_page = 100
  # config.window = 4
  # config.outer_window = 0
  # config.left = 0
  # config.right = 0
  # config.page_method_name = :page
  # config.param_name = :page
  # config.max_pages = nil
  # config.params_on_first_page = false
end
  • 一般配置都放在 config/initializers/ 目录下
  • 需要再次重启服务 bin/rails s
  • 发起请求 curl GET http://127.0.0.1:3000/api/v1/items -v 查看结果
  • 成功请求第一页的前十个数据

请求第 n 页,获取参数 params[:page],修改 app/controllers/api/v1/items_controller.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Api::V1::ItemsController < ApplicationController
  def index
    # item = Item.page(params[:page]).per(100)
    item = Item.page params[:page]
    render json: { resource: item }
  end

  def create
    #...
  end
end
  • 使用 Item.page params[:page] 获取查询参数
  • 请求 curl GET http://127.0.0.1:3000/api/v1/items -v 查询参数空,kaminari 默认判断为 1
  • 请求第二页 curl GET http://127.0.0.1:3000/api/v1/items\?page\=2 -v
    • 注意命令行中会自动添加转译符号反斜杠 \
  • 请求第三页 curl GET http://127.0.0.1:3000/api/v1/items\?page\=3 -v
  • 超过允许最大页码返回 {"resource":[]}
  • 自定义每页100个 item = Item.page(params[:page]).per(100)
    • params[:page] 查询参数空,或者不合法的值,kaminari 默认判断为 1

分页获取总条数,修改 app/controllers/api/v1/items_controller.rb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Api::V1::ItemsController < ApplicationController
  def index
    # item = Item.page(params[:page]).per(100)
    item = Item.page params[:page]
    render json: {
      resource: item,
      pager: {
        page: params[:page],
        per_page: 100,
        count: Item.count
      }
    }
  end

  def create
    #...
  end
end
  • rails 适合不需要考虑太多细节,比如性能问题,优先思考清楚后端的流程、步骤

参考


对比使用 pagy 分页

kaminari 的性能堪忧,虽然写法较 pagy 友好,更傻瓜式,容易理解上手,本项目还是使用此库

  • 注重性能可以以后更换此库

参考


区别于 社交媒体网站所采用的分页流派:信息流(Feed 流)分页

区别于 例如传统博客页面使用 pageper_page 参数,见 kaminaripagy 库 信息流(Feed 流)分页 使用 start_idlimit 参数,需要 id 是自增数字,或者时间戳(用 start_time

  • 下拉更新页面,永远都是获取第一页
  • 第一页数据变更特别快,下拉后原来的第一页可能已经变为 第1000页了
  • 最新下拉获取 使用 start_id 的最新一条,一次拉取 limit
  • 无需任何库实现,直接写查询语句
    • item = Item.where(""id > ?", params[:start_id]).limit(20)

6. $3


7. $3


8. $3


·未完待续·

参考文章

相关文章


  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名