「飛書績效」寬表SQL自動生成邏輯淺析

語言: CN / TW / HK

我們來自位元組跳動飛書商業應用研發部(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的基礎上的

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資訊進行讀取和推斷,這裡不再過多贅述

寬表與邏輯結構對映

由於每個週期的績效評估指標、流程和環節都不完全相同,因此我們沒有一個通用的結構去描述這種多樣的模型

因此我們定義了以下的模型來滿足多租戶多週期的需求

UML 圖.jpg

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()實現了構建方法,呼叫該方法可以將這棵樹通過遞迴的方式,組裝成目標結果

UML 圖 (1).jpg

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{}裡面的各個資料寫入使用者傳入的邏輯結構中

加入我們

掃碼發現職位 & 投遞簡歷:

image.png

官網投遞:job.toutiao.com/s/FyL7DRg