「飛書績效」寬表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的基礎上的
Kotlin
// 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完成各種操作
Go
// 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的結構
```Go 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進行排序 1. 如果after已經排過序,則當前callback被放到after的前一個;否則將after的before設成當前callback,然後遞歸對after進行排序
Go
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的結構,該結構包含了數據庫操作的上下文信息
Go
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
```Go 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)、映射關係組裝起來,得到了以下結構
```Go 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的方式進行表示
Go
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語句
寬表數據寫入結構體中
```Go 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{}裏面的各個數據寫入用户傳入的邏輯結構中
加入我們
掃碼發現職位 & 投遞簡歷:
- 解鎖抖音世界盃的畫質優化實踐
- Kafka 架構、核心機制和場景解讀
- 頭條穩定性治理:ARC 環境中對 Objective-C 對象賦值的 Crash 隱患
- 字節跳動模型大規模部署實戰
- 「飛書績效」寬表SQL自動生成邏輯淺析
- Mybatis源碼主流程分析
- 推薦系統的Bias
- 抖音 Android 基礎技術大揭祕!| 字節跳動技術沙龍第十期
- 基於序列標註模型的主動學習實踐
- 加密技術科普
- 二維碼掃描優化
- 前端監控系列4 | SDK 體積與性能優化實踐
- 特效側用户體驗優化實戰 —— 包體積篇
- 深入理解 Android Studio Sync 流程
- 選擇 Go 還是 Rust?CloudWeGo-Volo 基於 Rust 語言的探索實踐
- 初探自然語言預訓練技術演進之路
- 高性能 RPC 框架 CloudWeGo-Kitex 內外統一的開源實踐
- 開源 1 週年突破 1w Star - CloudWeGo 開源社區實踐分享
- Go 語言官方依賴注入工具 Wire 使用指北
- prompt 綜述