咱们来自字节跳动飞书商业运用研发部(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