2

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

 1 year ago
source link: https://juejin.cn/post/7172114918471106573
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

2022年12月01日 09:49 ·  阅读 769

我们来自字节跳动飞书商业应用研发部(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信息进行读取和推断,这里不再过多赘述

宽表与逻辑结构映射

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

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

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

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

image.png

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK