Camping是一个用Ruby写的极简Web框架,整个核心代码只有几KB,但它对数据库事务的支持并不简陋。在实际项目中,一个请求往往涉及多条写操作,比如创建订单的同时要扣减库存、写入流水记录,任何一步失败,前面的操作都必须撤销,否则数据库就会留下脏数据。Camping通过过滤器链与事务的结合,提供了Camping::Filters::Around::Transaction::Rollback::OnError这一层机制,让事务回滚和错误处理变得声明式,不用在每个控制器方法里手写begin rescue rollback。这篇文章就来详细拆解它的原理和用法。

一、先理解Ruby数据库事务的基本行为
在Ruby中操作数据库事务,通常离不开DB.transaction或者Sequel、ActiveRecord等ORM提供的事务方法。事务的核心语义很简单:要么全部成功提交,要么全部回滚。以Sequel为例,当一个事务块内部抛出异常,Sequel会自动执行ROLLBACK,然后把异常继续向上抛出,由外层调用者决定如何处理。
require 'sequel'
DB = Sequel.sqlite
DB.create_table :orders do
primary_key :id
String :name
Integer :stock
end
# 基本事务:块内抛异常自动回滚
begin
DB.transaction do
DB[:orders].insert(name: '商品A', stock: 10)
raise '库存不足,触发回滚'
end
rescue => e
puts "捕获异常:#{e.message}"
end
# 此时表中没有数据,说明插入被回滚了
puts DB[:orders].count # 输出 0</code>这段代码展示了事务最原始的行为。问题在于,如果把这种写法直接搬进Camping的控制器,每个action都要写一遍begin rescue,代码重复度很高,而且异常处理的策略(返回什么状态码、记录什么日志)散落在各处,难以统一维护。这就是过滤器机制要解决的问题。
二、Around过滤器在请求生命周期中的位置
Camping的过滤器分为before、after和around三类。before在action之前执行,after在action之后执行,而around则像一个包裹层,把action夹在中间。around过滤器的写法是接收一个block,在block调用之前和之后都可以插入自己的逻辑,这正好是事务需要的结构:在action执行前开启事务,在action正常结束后提交,在action抛出异常时回滚。
理解around的执行顺序很关键。假设注册了两个around过滤器A和B,注册顺序为A、B,那么实际的执行链是:A的前置逻辑 → B的前置逻辑 → action → B的后置逻辑 → A的后置逻辑。事务过滤器应该尽量放在链的外层,这样它管理的范围能覆盖内层的所有操作。
module MyApp
# 一个简单的around过滤器示例,打印执行顺序
around do |&block|
puts "事务开启前的准备"
result = block.call
puts "事务提交后的收尾"
result
end
class Index
def get
"Hello"
end
end
end</code>可以看到,around过滤器里用yield或者block.call执行真正的action,返回值就是action的返回值。如果action内部抛出异常,异常会从block.call处直接向外传播,我们在外层捕获它就能实现回滚。这个执行模型是整个Transaction Rollback机制的基础。
三、Transaction Rollback OnError机制的具体实现思路
所谓Rollback OnError,本质上是把事务块和异常处理封装进around过滤器。它在action开始前调用DB.transaction开启事务,把action的执行放在事务块内,一旦action抛出任何StandardError的子类,就触发数据库回滚,并把错误交给统一的错误处理逻辑,比如记录日志、渲染错误页面、返回500状态码。
下面给出一个完整的可运行实现,包含事务包裹和统一的OnError处理:
require 'camping'
require 'camping/ar'
require 'sequel'
Camping.goes :Shop
module Shop
# 统一的数据库连接
def self.db
@db ||= Sequel.connect('sqlite://shop.db')
end
# around过滤器:事务 + 出错回滚 + 统一错误处理
around do |&block|
begin
self.class.db.transaction do
block.call
end
rescue Sequel::Rollback => e
# 业务代码主动抛出Sequel::Rollback,静默回滚不报错
[409, {'Content-Type' => 'text/plain'}, ['操作已取消']]
rescue StandardError => e
# 未知异常:事务已自动回滚,这里做统一错误响应
warn "事务回滚,错误信息:#{e.class}: #{e.message}"
[500, {'Content-Type' => 'text/plain'}, ['服务器内部错误']]
end
end
class Orders < Camping9::Controllers
# 省略具体action,见下文
end
end</code>这段实现有几个细节值得注意。第一,Sequel::Rollback是一个特殊的异常类型,专门用于在业务逻辑里主动触发回滚,捕获它之后不需要当作真正的错误处理,只需给出业务层面的提示。第二,捕获StandardError时事务已经由Sequel自动回滚了,所以rescue块里不需要再手动调用rollback。第三,过滤器返回的三元素数组(状态码、头部、正文)会直接作为HTTP响应,这就是统一的OnError出口。
在控制器里使用时,代码会变得非常干净,完全看不到事务的痕迹:
module Shop
class CreateOrder
def post
product = Shop.db[:products].where(id: params[:product_id]).first
raise Sequel::Rollback if product[:stock] < params[:count].to_i
Shop.db[:orders].insert(
product_id: product[:id],
count: params[:count].to_i,
created_at: Time.now
)
# 扣减库存
Shop.db[:products].
where(id: product[:id]).
update(stock: product[:stock] - params[:count].to_i)
[302, {'Location' => '/orders'}, []]
end
end
end</code>如果扣减库存这一步因为任何原因抛出异常,前面的订单插入会被整体回滚,不会出现订单存在但库存没扣的脏数据。而这一切都是由外层的around过滤器自动完成的。
四、常见误区与进阶技巧
第一个常见误区是在rescue块里吞掉异常却不做任何处理。有些开发者写了rescue; end就以为万事大吉,实际上如果异常发生在事务块之外(比如过滤器顺序配置错误,事务过滤器没有真正包住action),数据可能已经被部分提交。排查方法是在事务前后打印数据库连接的事务状态,确认事务确实生效。
第二个误区是嵌套事务的处理。Ruby的ORM对嵌套事务通常采用savepoint的方式,内层事务回滚只会回滚到savepoint,外层事务依然可以提交。如果你希望内层失败导致整体失败,必须在内层rescue之后重新raise,让异常继续传播到最外层的事务块,触发完整的ROLLBACK。
Shop.db.transaction do
Shop.db[:orders].insert(name: '订单1')
begin
Shop.db.transaction(savepoint: true) do
Shop.db[:orders].insert(name: '订单2')
raise '内层失败'
end
rescue
# 错误做法:这里如果不重新raise,订单2被回滚但订单1正常提交
# 正确做法:raise,让外层事务也整体回滚
raise
end
end</code>第三个进阶技巧是给错误处理分级。不是所有异常都值得返回500,可以把业务校验错误(如库存不足)映射到422,把数据库连接错误映射到503,把未知异常映射到500并记录完整堆栈。在around过滤器里根据异常类型做一层分发,就能建立起整个应用的统一错误响应规范,前端处理起来也更有依据。
最后补充一点,如果你的Camping项目用的是ActiveRecord风格(camping/ar),把事务过滤器里的db.transaction换成ActiveRecord::Base.transaction即可,整体的around结构和错误处理逻辑完全不变。事务管理的核心思想是一样的:用声明式的包裹层把重复的begin rescue rollback收敛到一处,让业务代码只关心业务本身。