「飞书绩效」宽表SQL自动生成逻辑浅析

9,887 阅读7分钟

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

本文作者:飞书商业应用研发部 唐玄昭

欢迎大家关注飞书技术,每周定期更新飞书技术团队技术干货内容,想看什么内容,欢迎大家评论区留言~

背景

飞书绩效系统中,不同租户、绩效评估周期中,评估的内容和数量都可以自由配置,因此我们无法使用统一的表结构来支持这样的场景。

为了解决这个问题,飞书绩效采用宽表对用户的数据进行存储,并开发了一套用于生成宽表SQL的基础库(database库),来将宽表数据映射到业务逻辑中,实现了逻辑结构与物理结果的解耦。

主要内容

  1. 飞书绩效的database库如何完成宽表和业务逻辑映射的
  2. gorm库的插件机制是如何支持database完成上述操作的

处理流程

流程图.jpg

上图给出了项目启动后,一次请求调用的大致的数据获取逻辑

全部流程由三个模块组成,其中database模块承担了最核心的sql 语言生成、db数据到 结构化数据的转化过程

关键算法

基于GORM 插件机制的逻辑封装

注:本文基于gorm v1版本进行说明

为了避免业务层过多关注底层的逻辑,即逻辑到物理结构的转化,database包充分利用了gorm提供的Plugin能力,实现了以下能力:

  • 业务逻辑到物理表结构的转化
  • 数据库原始数据组装成为业务数据

整个的生命周期如下图所示

流程图 (1).jpg

GORM开放能力的实现

gorm的每一次数据库操作,都是一个callback顺序执行的过程。无论是核心的查询逻辑,还是打点、日志这些的非核心逻辑,都是通过callback的方式执行的

下面用图示的方式给出了一次gorm操作的流程,从图中我们可以看到,除了初始化数据库连接外,gorm的所有操作都是围绕着callback执行的

流程图 (2).jpg

以查询函数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

  1. 如果before已经排过序,则当前callback被放入到before的后一个;否则当前callback被放到最后一个,然后递归对before进行排序
  2. 如果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信息进行读取和推断,这里不再过多赘述

宽表与逻辑结构映射

由于每个周期的绩效评估指标、流程和环节都不完全相同,因此我们没有一个通用的结构去描述这种多样的模型

因此我们定义了以下的模型来满足多租户多周期的需求

UML 图.jpg

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()实现了构建方法,调用该方法可以将这棵树通过递归的方式,组装成目标结果

UML 图 (1).jpg

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{}里面的各个数据写入用户传入的逻辑结构中

加入我们

扫码发现职位 & 投递简历:

image.png

官网投递:job.toutiao.com/s/FyL7DRg