咱们来自字节跳动飞书商业运用研发部(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{}里面的各个数据写入用户传入的逻辑结构中
参加咱们
扫码发现职位 & 投递简历:
官网投递:job.toutiao.com/s/FyL7DRg