「飛書績效」寬表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