在开发时,如果每个ORM操作都要写隔离条件,除了效率低
外,更重要的是不安全
,如果少写或错写了WHERE
租户条件,那么会暴露
或修改到其它租户的数据,这是非常危险的,我们希望在ORM底层中实现自动sharding和添加租户条件,下面我们以entgo这个Go的ORM为例。
目录
- ORM底层 根据租户 隔离 增删改查
- ORM底层 根据租户 sharding库
根据租户 隔离 增删改查

我们还需要将请求SaaS标识值写到context中,ORM层才可以拿到值做自动处理
SELECT
中添加条件
使用Intercept
拦截查询语句,自动追加where 租户隔离字段={租户}
,由于存在像系统配置这类共用表,因此,拦截后需要知道哪些表有租户隔离字段,没有的就不能加where隔离条件,这里我们可以利于生成的migrate.Tables
,我们简单处理下就可以判断了:
go
// 获取各表存在字段隔离的map
// key为表名,value为true表示有租户隔离字段
func getTableInfoMap() make(map[string]bool) {
// 这里自行做一个sync.Once,提升效率
m := make(map[string]bool)
for _, v := range migrate.Tables {
for _, column := range v.Columns {
// 假设隔离字段名为saas_id
if column.Name == "saas_id" {
m[v.Name] = true
}
}
}
}
go
client.Intercept(intercept.Func(func(ctx context.Context, q intercept.Query) error {
// 判断是否人为手动强行关闭隔离条件,如一些管理平台需要看所有租户的信息,需要强行关闭,ctx中的key可以统一声明,这里为了演示,使用写死的字符串
if _, isOk := ctx.Value("close_saas_field"); isOk {
return nil
}
q.WhereP(func(s *sql.Selector) {
// 判断表是否存在租户隔离
tableSaasMap := getTableInfoMap()
if _,isOk := tableSaasMap[s.TableName()]; !isOk {
return
}
// 判断是否已经设置过隔离字段了,此方法是自己声明的
if HaveCondition(s) {
return
}
// 如果ctx里面没有租户值,直接报错
saasId, isOk := ctx.Value("saas_id");
if !isOk {
panic("entgo拦截时,ctx中缺少租户标识")
}
// 设置条件
exprField := fmt.Sprintf("%s.%s=?", s.TableName(), "saas_id")
s.Where(sql.ExprP(exprField, saasId))
})
return nil
}))
封闭 是否设置过隔离 的判断方法
go
// 是否设置过隔离条件了
func HaveCondition(s *sql.Selector) bool {
predicate := s.P()
if predicate == nil {
return false
}
conditionStr, _ := predicate.Query()
// 有`号的包裹
fieldEsc := "`" + field + "`"
if strings.Contains(conditionStr, field.ToQueryString()) {
return true
}
// 兼容有库名的写法
if strings.Contains(conditionStr, "."+field) {
return true
}
return false
}
INSERT 自动写上租户标识
go
// 通过是否实现了此接口来判断hook操作的表是否有saas标识字段人
type SaasSetter interface {
SetSaasID(i int)
}
// 处理Insert
client.Use(func(next ent.Mutator) ent.Mutator {
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
// 判断是否包含 saas_id 字段
setter, haveSaas := m.(SaasSetter)
if !haveSaas {
// 不包含saas隔离字段,不处理
return next.Mutate(ctx, m)
}
// 如果是创建,则需要强行带上隔离字段
if m.Op().Is(ent.OpCreate) {
// 判断是否已经设置过了隔离字段值
if setVal, isSet := m.Field("saas_id"); isSet {
val, ok := setVal.(int)
if ok && val > 0 {
return next.Mutate(ctx, m)
}
}
// 获取ctx中存的saas_id字段并设置值
saasId := ctx.Value("saas_id")
setter.SetSaasId(saasId)
return next.Mutate(ctx, m)
}
})
})
UPDATE 和 DELETE 添加条件
go
// 通过断言此方法实现,来调用设置条件
type WherePSetter interface {
WhereP(ps ...func(*sql.Selector))
}
// 处理UPDATE 和 DELETE
client.Use(func(next ent.Mutator) ent.Mutator {
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
// 仅拦截处理UPDATE 和 DELETE
if !m.Op().Is(ent.OpDelete) && !m.Op().Is(ent.OpUpdate) {
return next.Mutate(ctx, m)
}
//强行带上隔离字段
if whereSetter, haveWhere := m.(WherePSetter); haveWhere {
whereSetter.WhereP(func(s *sql.Selector) {
// 判断表是否存在租户隔离字段
tableSaasMap := getTableInfoMap()
if _,isOk := tableSaasMap[s.TableName()]; !isOk {
return
}
// 判断是否已经设置过隔离字段了
if HaveCondition(s) {
return
}
// 如果ctx里面没有租户值,直接报错
saasId, isOk := ctx.Value("saas_id");
if !isOk {
panic("entgo拦截hook时,ctx中缺少租户标识")
}
// 设置条件
exprField := fmt.Sprintf("%s.%s=?", s.TableName(), "saas_id")
s.Where(sql.ExprP(exprField, saasId))
})
}
})
})
根据租户 sharding库
- 配置里面增加连接
yaml
sharding:
- source: root:lansexiongdi@tcp(192.168.6.193:3307)/saas_mall_official?parseTime=True&charset=utf8mb4&loc=Asia%2FShanghai
debug: true
poolSize: 10
saasId: # 表示 [1,1] 范围的租户用此连接
min: 1
max: 1
- source: root:lansexiongdi@tcp(192.168.6.193:3307)/saas_cloud_1?parseTime=True&charset=utf8mb4
debug: true
poolSize: 10
saasId: # 表示 [2,4] 范围的租户用此连接
min: 2
max: 4
- source: root:lansexiongdi@tcp(192.168.6.193:3307)/saas_cloud_2?charset=utf8mb4
debug: true
poolSize: 10
saasId: # 表示 [5,∞] 范围的租户用此连接
min: 5
max: 0
- 创建连接时,将对应的值入map
go
// key为saasId,value为连接
var saasClientMap = make(map[int]*ent.Client)
// 省略连接与map的代码...
- 在获取连接时,通过ctx中的saasId值,到第2步中的map中取连接
总结
本文以为entgo
为例,其它ORM框架也有类似的处理,主要是强调必须在ORM底层兜底
,防止数据泄漏和变更到其租户的数据,当然也能大幅提升开发效率
。