ORM中实现SaaS的数据与库的隔离

接上一篇《SaaS架构与数据隔离的几种方案》

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

目录

  1. ORM底层 根据租户 隔离 增删改查
  2. 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库

  1. 配置里面增加连接
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
  1. 创建连接时,将对应的值入map
go 复制代码
// key为saasId,value为连接
var saasClientMap = make(map[int]*ent.Client)
// 省略连接与map的代码...
  1. 在获取连接时,通过ctx中的saasId值,到第2步中的map中取连接

总结

本文以为entgo为例,其它ORM框架也有类似的处理,主要是强调必须在ORM底层兜底,防止数据泄漏和变更到其租户的数据,当然也能大幅提升开发效率

相关推荐
midsummer_woo40 分钟前
基于spring boot的医院挂号就诊系统(源码+论文)
java·spring boot·后端
Olrookie2 小时前
若依前后端分离版学习笔记(三)——表结构介绍
笔记·后端·mysql
沸腾_罗强2 小时前
Bugs
后端
京茶吉鹿2 小时前
"if else" 堆成山?这招让你的代码优雅起飞!
java·后端
长安不见2 小时前
从 NPE 到高内聚:Spring 构造器注入的真正价值
后端
你我约定有三2 小时前
RabbitMQ--消息丢失问题及解决
java·开发语言·分布式·后端·rabbitmq·ruby
程序视点2 小时前
望言OCR 2025终极评测:免费版VS专业版全方位对比(含免费下载)
前端·后端·github
rannn_1113 小时前
Java学习|黑马笔记|Day23】网络编程、反射、动态代理
java·笔记·后端·学习
一杯科技拿铁3 小时前
Go 的时间包:理解单调时间与挂钟时间
开发语言·后端·golang