分享 在有多个模型关联操作的情况下,使用 Callbacks 保证数据的一致性

netqyq · 发布于 2017年02月28日 · 最后由 netqyq 回复于 2017年03月01日 · 632 次阅读
E40b21

背景

通过callback关联的2个model,rails会自动把所有的数据库操作语句封装为一个事务。如果整个事务执行过程中有异常抛出,事务将自动回滚,从而保证了关联模型的数据一致性。

举例

在电商系统中,有2个模型,Order和OrderItem。其中Order模型用来存储订单信息,OrderItem存储订单项。这2个表都有一个orderID字段,通过一个生成的orderID来进行关联, 注意这里的orderID并不是ActiveRecord自动维护的order.id或order_item.id,而是后生成的并且同时插入这2个表中,也就是说关联关系并没有用到原来的id字段。所以不能通过has_many关联。

数据库schema如下:

create_table "order_items", force: :cascade do |t|
  t.string   "orderID"
  t.integer  "product_id"
  t.decimal  "price",      precision: 10, scale: 2
  t.decimal  "amount",     precision: 10, scale: 2
  t.datetime "created_at",                          null: false
  t.datetime "updated_at",                          null: false
  t.integer  "user_id"
  t.index ["product_id"], name: "index_order_items_on_product_id", using: :btree
end

create_table "orders", force: :cascade do |t|
  t.integer  "user_id"
  t.string   "orderID"
  t.text     "message"
  t.datetime "created_at",                             null: false
  t.datetime "updated_at",                             null: false
  t.string   "fixed_address"
  t.decimal  "total_price",   precision: 10, scale: 2
  t.index ["user_id"], name: "index_orders_on_user_id", using: :btree
end

业务逻辑是:

  1. 如果创建了订单,则需要将购物车中具体购买的物品添加到order_items表中。
  2. 如果删除订单,则需要同时删除在order_items表中具有和orderID相同的项目。

使用Callback实现关联操作如下:

class Order < ApplicationRecord
  belongs_to :user
  belongs_to :address

  # 新建order后新建order_items
  after_create :create_order_items

  # 如果删除订单,则需要同时删除在order_items表中具有和orderID相同的项目。
  after_destroy :destroy_all_same_orderID

  protected
    def create_order_items
      current_user = self.user
      current_user.carts.each do |cart|
        OrderItem.new(user_id: current_user.id, orderID: self.orderID, product_id: cart.product_id, price: cart.product.price, amount: cart.amount).save!
      end
      # raise "crash for test"
      Cart.where(user_id: current_user.id).destroy_all
    end

    def destroy_all_same_orderID
      # raise "error"

      OrderItem.where(orderID: self.orderID).destroy_all
    end
end

结论

这样在orders_controller.rb中的create操作,只进行order新建相关的操作,没有任何order_items相关的操作。如果不使用callback,在controller中操作两个模型的话,代码会不整洁,最主要是数据一直性无法保证。 另外,好的做法是将callback方法放在private或protected中,以避免对外公开。

共收到 15 条回复
13587

感谢你的分享

这里有一些问题我想指出来,希望可以帮到大家

  1. OrderID建议加个index, 能加快query的速度
  2. after_destroy :destroy_all_same_orderID 没有必要, 你只需要在Order里加一个OrderItem的关联,然后设置destroy的关系就可以了

    class Order < ApplicationRecord
      has_many :order_items, dependent: :destroy
    end
    
    class OrderItem < ApplicationRecord
      belongs_to :order, foreign_key: "OrderID"
    end
    
  3. 读取数据的时候不需要加self ,只有在写数据的时候需要

  4. after_create :create_order_items 里的OrderItem的创建没有检测到底成功了没有,如果出错了会导致OrderItem的遗失。而且因为你没有用bang method, 所以出错了也不会有异常抛出,也就不会有回滚了

我的一些建议

  1. 不要用callbacks, 可以网上搜一下rails callbacks hell,就能了解到callbacks带来的问题,而这些问题会随着业务逻辑的复杂而越来越难修正
  2. 不要把业务逻辑放在model里。业务逻辑可以放在service objects里, keep them isolated, easily testable, reusable, SRP
E40b21

非常感谢@mengqing 的指点。 第1点,加index是个不错的主意! 第2点. 这里并不是has_many的关系,orderID是生成后插入的,2张表都有的,相同的。 其他几点我再考虑下,怎么调整。

E40b21

对于第4点,测试一下回滚。

在each循环里面,抛出异常,测试是否能回滚。

def create_order_items
      current_user = self.user
      current_user.carts.each do |cart|
        OrderItem.new(user_id: current_user.id, orderID: self.orderID, product_id: cart.product_id, price: cart.product.price, amount: cart.amount).save
        # 抛出异常
        raise "crash for test"
      end
      # raise "crash for test"
      Cart.where(user_id: current_user.id).destroy_all
    end

日志如下,可以看到,已经ROLLBACK

Started POST "/orders" for ::1 at 2017-02-28 21:28:36 +0800
Processing by OrdersController#create as HTML
  Parameters: {"address"=>"8", "pay_method"=>"alipay"}
  User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 ORDER BY "users"."id" ASC LIMIT $2  [["id", 4], ["LIMIT", 1]]
  Cart Load (0.6ms)  SELECT "carts".* FROM "carts" WHERE "carts"."user_id" = $1  [["user_id", 4]]
  Product Load (0.2ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = $1 LIMIT $2  [["id", 4], ["LIMIT", 1]]
  Address Load (0.2ms)  SELECT  "addresses".* FROM "addresses" WHERE "addresses"."id" = $1 LIMIT $2  [["id", 8], ["LIMIT", 1]]
   (0.1ms)  BEGIN
  SQL (0.4ms)  INSERT INTO "orders" ("user_id", "orderID", "created_at", "updated_at", "fixed_address", "total_price") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id"  [["user_id", 4], ["orderID", "1488288516220"], ["created_at", 2017-02-28 13:28:36 UTC], ["updated_at", 2017-02-28 13:28:36 UTC], ["fixed_address", "李明 17030006000 北京 北京 海淀区 青松国际 36203"], ["total_price", #<BigDecimal:7f98c1e6e680,'0.25E1',18(27)>]]
  User Load (0.4ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 4], ["LIMIT", 1]]
  Cart Load (0.3ms)  SELECT "carts".* FROM "carts" WHERE "carts"."user_id" = $1  [["user_id", 4]]
  Product Load (0.2ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = $1 LIMIT $2  [["id", 4], ["LIMIT", 1]]
  SQL (0.5ms)  INSERT INTO "order_items" ("orderID", "product_id", "price", "amount", "created_at", "updated_at", "user_id") VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING "id"  [["orderID", "1488288516220"], ["product_id", 4], ["price", #<BigDecimal:7f98c2152d60,'0.25E1',18(27)>], ["amount", #<BigDecimal:7f98c2152950,'0.1E1',9(18)>], ["created_at", 2017-02-28 13:28:36 UTC], ["updated_at", 2017-02-28 13:28:36 UTC], ["user_id", 4]]
   (0.2ms)  ROLLBACK
Completed 500 Internal Server Error in 43ms (ActiveRecord: 3.5ms)

结果是能够回滚,通过界面测试也没问题,订单和购物车都还是原样不变,只是我这里还没有加异常判断。

13587

你这里回滚了是因为你手动抛出了异常,save命令是不会抛出异常的,只会返回true或者false。如果你需要抛出异常就要用save!

E40b21

@mengqing 这个明白了,多谢!

Any exception that is not ActiveRecord::Rollback or ActiveRecord::RecordInvalid will be re-raised by Rails after the callback chain is halted. Raising an exception other than ActiveRecord::Rollback or ActiveRecord::RecordInvalid may break code that does not expect methods like save and update_attributes (which normally try to return true or false) to raise an exception.

332

我的经验教训,订单和订单项的创建是主业务逻辑,不建议放到 callbacks 中,放到 callbacks 里代码不直观,可读性差很多。 至于你提到的作为一个事务处理,很多处理的方法,比如:

  • 1 最简单的方法建立 Order 和 OrderItem 关联,order has_many order_items ,通过 order.order_items.build(...) 构造 order_items,order.save! 是放在一个数据库事务中处理。
  • 2 另外一种方法显式使用 Order. transaction {}

而 destory 使用 关联的 dependent: :destory 选项处理最简单。

E40b21

@vincent , @mengqing 改造了一下,如果使用ActiveRecord::Base.transaction的话

将create逻辑放到了controller中核心代码如下:

def create
    ActiveRecord::Base.transaction do
      @order = Order.new(user_id: current_user.id, orderID: @orderID , message:params[:message], fixed_address: Address.find(address_id).full_address, total_price: @total)
      @order_saved = @order.save!
      current_user.carts.each do |cart|
        OrderItem.new(user_id: current_user.id, orderID: @orderID, product_id: cart.product_id, price: cart.product.price, amount: cart.amount).save!
        # raise "crash for test"
      end
      #raise "crash for test"
      Cart.where(user_id: current_user.id).destroy_all
    end
end

将delete逻辑放到了controller中核心代码如下:

def destroy
    ActiveRecord::Base.transaction do
      OrderItem.where(orderID: @order.orderID).destroy_all
      @order.destroy!
    end
end
26949

一楼说的挺详细了,你可以看下service的用法。 你controller加事物,反模式了。 至于删除,楼上也指出了,关联一下就好了。不需要额外的加一个事物了呀

26054

以前遇到过关于 build 的问题,如果关联的表的 外键设置了必输验证,那么保存会失败。之后就很少用build 的方式了, transaction就挺好的感觉

332
class Order < ApplicationRecord
  has_many :order_items, dependent: :destroy
end

class OrderItem < ApplicationRecord
  belongs_to :order, foreign_key: "OrderID"
end
order = Order.new(user_id: current_user.id, orderID: @orderID , message:params[:message], fixed_address: Address.find(address_id).full_address, total_price: @total)

current_user.carts.each do |cart|
    order.order_items.build(user_id: current_user.id, product_id: cart.product.id, price: cart.product.price, amount: cart.amount)
end
# 保存作为一次事务处理
order.save!
# order 删除会级联删除 order_items
order.destroy
E40b21
332vincent 回复

感谢。

如果按照您这样 create order 和 create order_items解决了, 还有最后一步逻辑,下单后,清空购物车。

Cart.where(user_id: current_user.id).destroy_all

这个应该包含在事务中?这个逻辑该放在哪里呢?

E40b21
26949didme 回复

嗯,controller加ActiveRecord::Base.transaction do,觉得有些不对味。

E40b21
26054themadeknight 回复

这里的order_item应该不会让用户直接操作,所有没有验证的问题。失败是正常,如果保存失败,能保证回滚和处理异常就好。创建之前都要执行validate的。

332
E40b21netqyq 回复

这个可以直接用 ActiveRecord::Base.transaction 把它们包在一个事务里,到底是放在 controller 还是抽离到一个 service 看个人喜好了。

E40b21

@vincent 好的,非常感谢您的建议!

E40b21 netqyq 关闭了讨论 03月03日 11:57
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册