「飞书绩效」宽表SQL自动生成逻辑浅析
我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。
本文作者:飞书商业应用研发部 唐玄昭
欢迎大家关注飞书技术,每周定期更新飞书技术团队技术干货内容,想看什么内容,欢迎大家评论区留言~
背景
飞书绩效系统中,不同租户、绩效评估周期中,评估的内容和数量都可以自由配置,因此我们无法使用统一的表结构来支持这样的场景。
为了解决这个问题,飞书绩效采用宽表对用户的数据进行存储,并开发了一套用于生成宽表SQL的基础库(database库),来将宽表数据映射到业务逻辑中,实现了逻辑结构与物理结果的解耦。
主要内容
- 飞书绩效的database库如何完成宽表和业务逻辑映射的
- gorm库的插件机制是如何支持database完成上述操作的
处理流程
上图给出了项目启动后,一次请求调用的大致的数据获取逻辑
全部流程由三个模块组成,其中database模块承担了最核心的sql 语言生成、db数据到 结构化数据的转化过程
关键算法
基于GORM 插件机制的逻辑封装
注:本文基于gorm v1版本进行说明
为了避免业务层过多关注底层的逻辑,即逻辑到物理结构的转化,database包充分利用了gorm提供的Plugin能力,实现了以下能力:
- 业务逻辑到物理表结构的转化
- 数据库原始数据组装成为业务数据
整个的生命周期如下图所示
GORM开放能力的实现
gorm的每一次数据库操作,都是一个callback顺序执行的过程。无论是核心的查询逻辑,还是打点、日志这些的非核心逻辑,都是通过callback的方式执行的
下面用图示的方式给出了一次gorm操作的流程,从图中我们可以看到,除了初始化数据库连接外,gorm的所有操作都是围绕着callback执行的
以查询函数Find的逻辑实现为例,我们可以看到,函数的核心十分简短,主要就是构建数据查询的上下文,以及调用事先注册的callback。这也印证了上面的说法,所有的gorm操作都是建立在callback的基础上的
// Find find records that match given conditions
func (s *DB) Find(out interface{}, where ...interface{}) *DB {
return s.NewScope(out).inlineCondition(where...).callCallbacks(s.parent.callbacks.queries).db
}
为了做到开箱即用,gorm提供了一系列通用的callback,并默认将这些callback注入到每一次数据库操作中,这使得我们即使不懂得如何编写一个callback,也可以使用gorm完成各种操作
// Define callbacks for querying
func init() {
DefaultCallback.Query().Register("gorm:query", queryCallback)
DefaultCallback.Query().Register("gorm:preload", preloadCallback)
DefaultCallback.Query().Register("gorm:after_query", afterQueryCallback)
}
Callback的有序执行
上面讲了,gorm的执行是通过callback的有序执行实现的,而为了实现这个有序执行,gorm设计了以下的callback的结构
type CallbackProcessor struct {
logger logger
name string // current callback's name
before string // register current callback before a callback
after string // register current callback after a callback
replace bool // replace callbacks with same name
remove bool // delete callbacks with same name
kind string // callback type: create, update, delete, query, row_query
processor *func(scope *Scope) // callback handler
parent *Callback
}
// Before insert a new callback before callback `callbackName`, refer `Callbacks.Create`
func (cp *CallbackProcessor) Before(callbackName string) *CallbackProcessor {
cp.before = callbackName
return cp
}
其中before和after就是用来控制callback的执行顺序的,在注册时,如果指定了当前callback的前序或者后置依赖,那么在执行前,则会按照给定的顺序进行排序,并基于排序结果顺序执行
简易排序流程说明: 对于每一个callback
- 如果before已经排过序,则当前callback被放入到before的后一个;否则当前callback被放到最后一个,然后递归对before进行排序
- 如果after已经排过序,则当前callback被放到after的前一个;否则将after的before设成当前callback,然后递归对after进行排序
func (scope *Scope) callCallbacks(funcs []*func(s *Scope)) *Scope {
defer func() {
if err := recover(); err != nil {
if db, ok := scope.db.db.(sqlTx); ok {
db.Rollback()
}
panic(err)
}
}()
for _, f := range funcs {
(*f)(scope)
if scope.skipLeft {
break
}
}
return scope
}
Callback上下文信息的构建
在执行callback时,需要传入名为Scope的结构,该结构包含了数据库操作的上下文信息
type Scope struct {
Search *search
Value interface{}
SQL string
SQLVars []interface{}
db *DB
instanceID string
primaryKeyField *Field
skipLeft bool
fields *[]*Field
selectAttrs *[]string
}
下面给出几个常见函数对于Scope里面变量的操作,从这几个例子可以看到,部分DB操作只是修改了Scope的信息,部分DB操作则是执行了callback
func (s *DB) First(out interface{}, where ...interface{}) *DB {
newScope := s.NewScope(out)
newScope.Search.Limit(1)
return newScope.Set("gorm:order_by_primary_key", "ASC").
inlineCondition(where...).callCallbacks(s.parent.callbacks.queries).db
}
func (s *DB) Exec(sql string, values ...interface{}) *DB {
scope := s.NewScope(nil)
generatedSQL := scope.buildCondition(map[string]interface{}{"query": sql, "args": values}, true)
generatedSQL = strings.TrimSuffix(strings.TrimPrefix(generatedSQL, "("), ")")
scope.Raw(generatedSQL)
return scope.Exec().db
}
// Where return a new relation, filter records with given conditions, accepts `map`, `struct` or `string` as conditions, refer http://jinzhu.github.io/gorm/crud.html#query
func (s *DB) Where(query interface{}, args ...interface{}) *DB {
return s.clone().search.Where(query, args...).db
}
另外,对于fields、selectAttrs等字段,则是基于用户传入的数据结构解析得来,具体的解析过程无非是基于反射,对字段名、tag信息进行读取和推断,这里不再过多赘述
宽表与逻辑结构映射
由于每个周期的绩效评估指标、流程和环节都不完全相同,因此我们没有一个通用的结构去描述这种多样的模型
因此我们定义了以下的模型来满足多租户多周期的需求
RootStatics定义了数据的结构,FieldMapping表定义了每个字段对应宽表的具体列,Data表包含A、B、C等列
基于周期、租户信息,我们可以得到某个字段在宽表中存储哪一列,将逻辑字段(RootStatistics)、映射关系组装起来,得到了以下结构
type model struct {
name string
tableName string
fields []Field
nameMap map[string][]int
columnMap map[string][]int
}
type Field struct {
Name string
Column string
Type reflect.Type
Index []int
StructTag reflect.StructTag
Tags map[string]string
ModelName string
TableName string
// Tags
IsPrimaryKey bool
AutoIncrement bool
HasDefault bool
Collation string
// Mapping
MapName string
MapKey string
}
生成的model结构会被塞入db查询的上下文中,在实际查询时,将逻辑Select语句,基于Model中定义的映射关系,转化成物理的Select语句
逻辑Select结构转物理Select语句
该算法实现了自定义查询语句到数据库真实查询语句的转化,自定义查询语句的结构如下:
type Select struct {
Operators []SelectOperator
Select []Any
From Table
Where Boolean
GroupBy []Any
Having Boolean
OrderBy []Ordered
Limit *Limit
}
基于AST树将自定义查询语句转为SQL语句
将自定义的SQL语言转成mysql理解的SQL语言,这本身是一个编译行为,因此首要需要将自定义的SQL语言表示出来,database库选择使用AST的方式进行表示
type Node interface {
astNode()
Visit(v NodeVisitor) bool
Build(b Builder)
SourceValue() interface{}
SetSourceValue(value interface{})
}
- Visit()实现了这个Node的遍历方法,即对这个AST的所有树节点进行遍历
- Build()实现了构建方法,调用该方法可以将这棵树通过递归的方式,组装成目标结果
SELECT结构到SELECT语句的转化,需要借助AST这一中间状态进行
- 对于使用者传入的SELECT结构,则从根节点出发,不断延展子节点,生成这棵树;
- AST树生成SQL语句时,从根节点Node出发,通过深度优先遍历,可以从子节点获得部分SQL语句,而后在父节点进行加工后,返回上一级,重复这个过程,得到了最终的SELECT语句
宽表数据写入结构体中
for rows.Next() {
scope.DB().RowsAffected++
modelVal := results
if isSlice {
modelVal = reflect.New(modelType).Elem()
}
values := make([]interface{}, len(columns))
for i, fields := range fieldsSlice {
if len(fields) > 0 {
values[i] = reflect.New(fields[0].Type).Interface()
} else {
values[i] = &ignored
}
}
if scope.Err(rows.Scan(values...)) != nil {
return
}
for i, fields := range fieldsSlice {
fieldVal := reflect.ValueOf(values[i]).Elem()
for _, field := range fields {
if scope.Err(writeField(modelVal, field, fieldVal)) != nil {
return
}
}
}
if isSlice {
if isPtr {
modelVal = modelVal.Addr()
}
slice = reflect.Append(slice, modelVal)
}
}
这块的逻辑较为简单,主要就是基于Model的结构信息,将数据库字段写入内存的结构体中。主要分为以下两步:
- 基于rows.Scan()将数据库字段读入interface{}数组中
- 从Model记录的列与字段、字段和类型映射关系中,将interface{}里面的各个数据写入用户传入的逻辑结构中
加入我们
扫码发现职位 & 投递简历:
转载自:https://juejin.cn/post/7172114918471106573