把「Go靜態分析」放進你的工具箱

語言: CN / TW / HK

背景

提到靜態分析,大家第一反應可能是Sonar、FindBugs等靜態程式碼檢查工具,它通過對原始碼的分析,不實際執行程式碼,檢查程式碼是否符合編碼規範,發現原始碼中潛在的漏洞。這當中主要通過靜態編譯相關技術,其實大家在日常的業務程式碼開發過程中藉助IDE,使用的程式碼格式化、查詢某方法的定義/呼叫等功能也是基於這套技術實現的。

本文期望擴充靜態分析的定義,泛指所有通過靜態編譯相關技術實現的工具,不侷限於上面提到的通用的,開箱即用的功能。掌握靜態分析的原理,基於特定場景,可以組裝屬於自己的工具,提升開發效率,把「Go靜態分析」放進你的工具箱。

Golang在 https://github.com/golang/tools 提供了相關工具和程式碼包,非常清晰的將靜態編譯的各個過程和中間結果暴露了出來,讓我們可以方便的定義自己的工具。

下面帶大家一起瀏覽一下Golang靜態編譯的基礎知識,更加深入的瞭解靜態編譯的整個過程,並通過幾個例子看看具體能做什麼新工具。

Go編譯過程

和很多其他靜態語言類似,Golang編譯的過程如下:

暫時無法在飛書文件外展示此內容

下面將展開幾個主要步驟的細節,主要目的讓大家對於該步驟的概念,和中間結果具備哪些資訊量、資訊組織形式有一個大致的感知。

詞法分析

詞法分析將原始碼的字元序列轉換為單詞(Token)序列的過程,不同的語言定義了不同的關鍵字和詞法規則。

下面用最簡單的hello world舉例,第一部分是原始碼,第二部分是轉化後的Token。

package main import "fmt" func main() { fmt.Println("Hello, world!") }

1:1 package "package" 1:9 IDENT "main" 1:13 ; "\n" 2:1 import "import" 2:8 STRING ""fmt"" 2:13 ; "\n" 3:1 func "func" 3:6 IDENT "main" 3:10 ( "" 3:11 ) "" 3:13 { "" 4:3 IDENT "fmt" 4:6 . "" 4:7 IDENT "Println" 4:14 ( "" 4:15 STRING ""Hello, world!"" 4:30 ) "" 4:31 ; "\n" 5:1 } "" 5:2 ; "\n" 5:3 EOF ""

Token展示了3列資訊

  1. token position,詞對應原始碼的位置(行:列)
  2. token,詞型別
  3. literal string,某些詞型別具體的字元

在這個例子中可以看到,分詞結果由下面三類組成:

  • 關鍵字(package、import、func)
  • 字面量(沒有引號的單詞為IDENT,有引號的單詞為STRING)
  • 操作符(各種括號)

這裡可以看到Golang的分號規則是在詞法分析階段完成的,自動在行尾添加了分號token。

也就出現了下面有趣的現象 ``` // 詞法分析通過,main()後面會多一個分號token,也就是上面例子中第10行後面多一個分號token // 語法分析不通過,因為多的這個分號token破壞語法結構 func main() { fmt.Println("Hello, world!") }

// 詞法分析通過,詞法分析結果與上面例子完全一致,因為正括號token後面的換行不會補分號token // 語法分析通過 func main() { fmt.Println("Hello, world!") } ```

下面展示完整的Golang SDK中對於詞型別的定義:

``` // The list of tokens. const ( // Special tokens ILLEGAL Token = iota EOF COMMENT

// 第1組,字面量 literal_beg // Identifiers and basic type literals // (these tokens stand for classes of literals) IDENT // main INT // 12345 FLOAT // 123.45 IMAG // 123.45i CHAR // 'a' STRING // "abc" literal_end

// 第2組,操作符 operator_beg // Operators and delimiters ADD // + SUB // - MUL // * QUO // / REM // % // ... operator_end

// 第3組,關鍵字 keyword_beg // Keywords BREAK CASE CHAN CONST CONTINUE // ... keyword_end ) ```

語法分析

語法分析是將單詞(Token)序列通過語法規則轉化為語法樹AST(Abstract Syntax Tree)的過程,AST本質上是一個樹形結構的物件,由下面三類基本節點組成:

  • Decl,宣告
    • GenDecl,型別宣告(import,constant,type,變數)
    • FuncDecl,函式宣告
  • Stmt,語句
    • IfStmt、ForStmt、ReturnStmt,流程控制語句
    • BlockStmt,程式碼塊
    • ExprStmt,表示式語句
  • Expr,表示式
    • BinaryExpr,二元表示式(X、操作符、Y)
    • CallExpr,呼叫函式

下面還是hello world的例子,展示語法分析的結果 // 語法樹以檔案為根 *ast.File { // 包宣告 . Package: 1:1 . Name: *ast.Ident { //... } // 內容宣告 . Decls: []ast.Decl (len = 2) { // 1. import宣告 . . 0: *ast.GenDecl { . . . Tok: import . . . // ... . . } // 2. main函式宣告 . . 1: *ast.FuncDecl { . . . Name: *ast.Ident { . . . . Name: "main" . . . . // ... . . . } . . . Type: *ast.FuncType { // ... } // main函式體宣告 . . . Body: *ast.BlockStmt { . . . . List: []ast.Stmt (len = 1) { // 第1句宣告 . . . . . 0: *ast.ExprStmt { // 呼叫語句 . . . . . . X: *ast.CallExpr { . . . . . . . Fun: *ast.SelectorExpr { . . . . . . . . X: *ast.Ident { . . . . . . . . . Name: "fmt" . . . . . . . . } . . . . . . . . Sel: *ast.Ident { . . . . . . . . . Name: "Println" . . . . . . . . } . . . . . . . } // 呼叫語句引數 . . . . . . . Args: []ast.Expr (len = 1) { // 第1個引數 . . . . . . . . 0: *ast.BasicLit { . . . . . . . . . Kind: STRING . . . . . . . . . Value: ""Hello, world!"" . . . . . . . . } . . . . . . . } . . . . . . } . . . . . } . . . . } . . . } . . } . } }

可以看到

  • 一個檔案是一個AST樹,本質是在一個Package下的一組內容宣告的集合,一項內容宣告可以是一個import外部包、一個常量定義、一個函式定義等。
  • 一個函式定義FuncDecl,本質是一組Stmt語句的集合,所以FuncDecl的Body由一個BlockStmt語句塊組成。一個語句可以是if、for等條件控制語句,或一個ExprStmt表示式語句。
  • 一個表示式ExprStmt,本質是一個BinaryExpr二元表示式,用於賦值;或一個CallExpr用於函式呼叫。

總結來說,Decl(宣告)Stmt(語句)Expr(表示式)是一個逐級包含的關係,共同組成了AST樹。

SSA

Go 1.7開始,Go將原來的IR(Intermediate Representation,中間程式碼)轉換成SSA(Static Single Assignment,靜態單賦值)形式的IR,可以引入更多優化。具體細節和原理更多涉及編譯期優化,這裡就不過多展開,只介紹一下SSA的概念和過程中間變數。

SSA的結果,可以理解為每個賦值得到唯一的變數名。當 x 重新分配了另一個值時,將建立一個新名稱 x_1。比如下面例子:

SSA前 x = 1 y = 7 // do stuff with x and y x = y y = func() // do more stuff with x and y SSA後 x = 1 y = 7 // do stuff with x and y x_1 = y y_1 = func() // do more stuff with x_1 and y_1 在概念上SSA這一步也是輸出語法分析的結果,對於我們來說更多有用的內容是因為Golang/Tools工具鏈在SSA之後的結果做了基本的分析和匯聚,大部分的三方工具也是基於SSA的結果開始分析的。

我們這裡主要看一下對於一個函式定義Function包含哪些資訊。 ``` type Function struct { name string object types.Object // a declared types.Func or one of its wrappers method types.Selection // info about provenance of synthetic methods Signature *types.Signature pos token.Pos

Synthetic string        // provenance of synthetic function; "" for true source functions
syntax    ast.Node      // *ast.Func{Decl,Lit}; replaced with simple ast.Node after build, unless debug mode
parent    *Function     // enclosing function if anon; nil if global
Pkg       *Package      // enclosing package; nil for shared funcs (wrappers and error.Error)
Prog      *Program      // enclosing program
Params    []*Parameter  // function parameters; for methods, includes receiver
FreeVars  []*FreeVar    // free variables whose values must be supplied by closure
Locals    []*Alloc      // local variables of this function
Blocks    []*BasicBlock // basic blocks of the function; nil => external
Recover   *BasicBlock   // optional; control transfers here after recovered panic
AnonFuncs []*Function   // anonymous functions directly beneath this one
referrers []Instruction // referring instructions (iff Parent() != nil)

} ```

一些常見的Function內容都彙總在這個結構體中

  • name(名稱)
  • Params(入參)
  • Signature(函式簽名,函式入參和返回值)
  • Blocks(函式具體語句)
  • Recover(Recover具體語句)等

上面的method指golang中的成員函式,是一種特殊的function,所以對應Params比程式碼中多一個引數,多的第一個引數是receiver(具體的型別例項)。

工具包

最後整理介紹一下Golang SDK提供的工具包,方便大家查詢對應的介面和使用

  • go/scanner,詞法分析
  • go/token,token定義
  • go/parser,語法分析
  • go/ast,AST結構定義
  • golang.org/x/tools/go/packages,一組包檢查和分析
  • golang.org/x/tools/go/ssa,SSA分析
  • golang.org/x/tools/go/callgraph,呼叫關係演算法和工具
  • golang.org/x/tools/go/analysis,靜態分析工具

可以看到,對於原始碼的分析程度逐漸加深,從單一檔案的詞法資訊(scanner、token)、到單一檔案的語法資訊(parser、ast)、最後多個檔案互相引用和呼叫的資訊(ssa、packages、callgraph、analysis)。在我們使用編譯資訊的時候,可以選擇需要的解析程度和中間變數開始。

常用工具原理解析

通過上面對編譯過程的拆解和講解,大家對於一些基本概念和中間變數已經有一定的瞭解,現在通過幾個Go官方工具中具體的應用場景,一起看看可以用這些資訊製作哪些工具和它們的基本原理。

gofmt

gofmt是Golang官方提供的工具,用於程式碼格式化,統一程式碼風格。實現非常簡潔,並沒有提供非常複雜的程式碼樣式模版可供定製,唯一可定製的是用tab或空格格式化縮排。

它的基本原理是通過解析為AST後反寫格式化實現的,具體程式碼詳見Go SDK/src/cmd/gofmt,大致流程如下:

UML 圖.jpg

  1. 第一步原始碼 -> AST,通過go/parser完成
  2. 第二步AST Rewrite,主要目的是做一些簡單的程式碼優化,主要下面3個步驟:
    1. rewrite by rule,根據自定義的規則重寫AST,格式pattern -> replacement
      • 例如 foo -> bar,將編譯的所有foo變數重新命名為bar
    2. SortImports,排序import
    3. simplify,根據Go內部規則重寫AST
      • 例如 s[a:len(s)] -> s[a:],將多餘的len(s)操作去掉
  3. 第三步AST Printer,通過go/printer完成,按照每個node不同的型別(Decl、Stmt、Expr)遞迴完成輸出

下面展示一下AST Printer的主流程,原始碼中按照👇引導讀者下鑽閱讀: ``` // 入口函式,傳入AST根節點的node,型別為ast.File節點 func (p printer) printNode(node interface{}) error { // ... // format node switch n := node.(type) { // ... case ast.File: p.file(n) // 👇 // ... default: goto unsupported }

return nil }

// 進入ast.File的輸出函式,這裡print包資訊,剩下交給declList函式 func (p printer) file(src ast.File) { p.setComment(src.Doc) p.print(src.Pos(), token.PACKAGE, blank) // 輸出package關鍵字 p.expr(src.Name) // 輸出包名,一個型別為ast.Ident表示式 p.declList(src.Decls) // 輸出所有定義列表,一組型別為ast.Decl宣告 // 👇 p.print(newline) }

// 進入ast.Decl列表的輸出函式,定位每一個宣告開始,剩下交給decl函式 func (p *printer) declList(list []ast.Decl) { tok := token.ILLEGAL for _, d := range list { // 根據一定條件判定輸出換行 p.linebreak(p.lineFor(d.Pos()), min, ignore, tok == token.FUNC && p.numLines(d) > 1) // 輸出單個ast.Decl p.decl(d) // 👇 } }

// 進入ast.Decl的輸出函式,例子中只有一個main函式 func (p printer) decl(decl ast.Decl) { switch d := decl.(type) { case ast.BadDecl: p.print(d.Pos(), "BadDecl") case ast.GenDecl: p.genDecl(d) case ast.FuncDecl: p.funcDecl(d) // 👇 default: panic("unreachable") } }

// 進入ast.FuncDecl的輸出函式,輸出func關鍵字,函式簽名(入參和出參),剩下交給funcBody輸出函式體 func (p printer) funcDecl(d ast.FuncDecl) { p.setComment(d.Doc) p.print(d.Pos(), token.FUNC, blank) // We have to save startCol only after emitting FUNC; otherwise it can be on a // different line (all whitespace preceding the FUNC is emitted only when the // FUNC is emitted). startCol := p.out.Column - len("func ") if d.Recv != nil { p.parameters(d.Recv, false) // method: print receiver p.print(blank) } p.expr(d.Name) p.signature(d.Type) // signature = 函式簽名 p.funcBody(p.distanceFrom(d.Pos(), startCol), vtab, d.Body) // 👇 }

// 進入funcBody的輸出函式 func (p printer) funcBody(headerSize int, sep whiteSpace, b ast.BlockStmt) { // ... p.block(b, 1) // 👇 }

// 進入ast.BlockStmt的輸出函式 func (p printer) block(b ast.BlockStmt, nindent int) { p.print(b.Lbrace, token.LBRACE) // 輸出左花括號 p.stmtList(b.List, nindent, true) // 迴圈print包含的各個stmt p.linebreak(p.lineFor(b.Rbrace), 1, ignore, true) p.print(b.Rbrace, token.RBRACE) // 輸出右花括號 } ```

剩餘的程式碼不再展開,上面的過程已經把Decl的輸出邏輯表達清楚,剩餘的進入Stmt、Expr的部分結構類似,具體細節有興趣同學可以自行閱讀原始碼。

可以看到實現的邏輯與語法分析部分講解的Decl(宣告)Stmt(語句)Expr(表示式)逐級包含的關係完全一致,附加了相關的語法規則符號,換行。

lint

Go vet、golangci-lint是Golang常用的程式碼靜態檢查工具。它的實現原理都是基於SSA的go/analysis分析框架實現相關的邏輯。

這裡以golangci-lint中的bodyclose檢查為例,這個檢查項主要檢查HTTP response body使用後是否呼叫過close方法,避免未關閉的情況。

下面可以看到常用原始碼使用如下,獲取一個resp,使用完成之後呼叫resp.Body.Close()關閉流 resp, err := http.Get("http://example.com/") // ... resp.Body.Close()

利用了SSA靜態單賦值的特點,判斷每一個引用是否呼叫了close方法或傳遞給下一個引用。

基本邏輯是遍歷所有引用了Response的包中的所有指令Instruction,分為下面三種情況

  1. 直接使用,對應下面原始碼的ssa.FieldAddr
    • http.Get獲取的resp變數,不經過第二次賦值,不產生第二個SSA的變數,判斷是否呼叫了Close方法
  2. 指標引用,對應下面原始碼的ssa.Store
    • 更常見的情況,我們一般在defer func中呼叫resp.Body.Close(),保證close函式一定可以執行
    • 形如變數被某個閉包函式引用,會產生一個ssa.Store的引用,我們需要判斷被引用後是否呼叫了Close方法
  3. 函式呼叫,對應下面原始碼的ssa.Call
    • resp獲取後,如果被用於引數傳遞給了其他函式,會產生一個ssa.Call的引用,那就遞迴到對應的函式中進行判斷,可能直接使用 或 指標引用

下面一起通過原始碼具體看一下如何實現一個lint的檢查,具體原始碼位於 https://github.com/timakin/bodyclose

``` const ( Doc = "bodyclose checks whether HTTP response body is closed successfully"

nethttpPath = "net/http"
closeMethod = "Close"

)

// 這裡介紹主要流程,過濾掉具體細節 func (r runner) run(pass *analysis.Pass) (interface{}, error) { // 查詢所有引用了net/http包,且使用了Response的 r.resObj = analysisutil.LookupFromImports(pass.Pkg.Imports(), nethttpPath, "Response") // 收集相關引用物件和型別資訊 // 。。。

// 迴圈所有檢查函式
for _, f := range funcs {
    // 如果函式返回不是http.Response跳過
    // ...
    // 未跳過的,遍歷具體語句,檢視是否open之後,沒有close
    for _, b := range f.Blocks {
        for i := range b.Instrs {
            pos := b.Instrs[i].Pos()
            if r.isopen(b, i) {
                pass.Reportf(pos, "response body must be closed")
            }
        }
    }
}

}

func (r runner) isopen(b ssa.BasicBlock, i int) bool { // 判斷一些前置引用,省略 // ... for _, resRef := range resRefs { switch resRef := resRef.(type) { case ssa.Store: // Call in Closure function if len(resRef.Addr.Referrers()) == 0 { return true }

  for _, aref := range *resRef.Addr.Referrers() {
     if c, ok := aref.(*ssa.MakeClosure); ok {
        f := c.Fn.(*ssa.Function)
        if r.noImportedNetHTTP(f) {
           // skip this
           return false
        }
        called := r.isClosureCalled(c)

        return r.calledInFunc(f, called)
     }

  }

case ssa.Call: // Indirect function call if f, ok := resRef.Call.Value.(ssa.Function); ok { for _, b := range f.Blocks { for i := range b.Instrs { return r.isopen(b, i) } } } case *ssa.FieldAddr: // Normal reference to response entity if resRef.Referrers() == nil { return true }

  bRefs := *resRef.Referrers()

  for _, bRef := range bRefs {
     bOp, ok := r.getBodyOp(bRef)
     if !ok {
        continue
     }
     if len(*bOp.Referrers()) == 0 {
        return true
     }
     ccalls := *bOp.Referrers()
     for _, ccall := range ccalls {
        if r.isCloseCall(ccall) {
           return false
        }
     }
  }

} }

return true

} ```

場景實踐

除了一些通用的場景,一些日常開發過程中遇到的問題,也可以方便的通過靜態分析的工具鏈快速實現,下面通過兩個例子簡單說明。

函式特徵查詢

  • 問題定義

在一次oncall的過程中,發現一個依賴方的RPC方法BatchGetUserInfoByEmail出現問題。

RPC方法定義入參為Email slice,返回為使用者資訊slice,介面約定兩個slice保持大小、順序一致。後續業務程式碼也是按照這個假設進行的開發,並沒有做相關的校驗。

這次問題因為依賴方調整了相關邏輯,在一組email中有重複資料的時候,會返回去重後的使用者資訊,破壞了之前的假設,導致一些問題。

這個case處理完成後,想通過入參和出參都包含slice的特徵排查一下是否存在其他隱患的可能,因為沒有特定的關鍵字,原始碼文字查詢的方式不可用,但可以通過SSA相關工具鏈快速實現這樣的查詢。

  • 實現思路

根據我們需要使用的特徵,選擇剛好足夠的解析層面工具會更便於我們使用,這次需求的特徵全部來自函式簽名,所以不用使用多檔案分析的工具包,使用單檔案ssautil工具包就可以滿足我們的需求。

基本的實現步驟如下:

  1. 載入專案原始碼,編譯
  2. 遍歷所有專案中的Function函式
  3. 判斷函式簽名中關於入參和出參的特徵,輸出函式的原始碼位置

下面通過原始碼詳細講解: dir := "專案程式碼下載的本地路徑" // 1. 載入專案程式碼所有的package名稱 initial, _ := packages.Load(&packages.Config{ Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedImports | packages.NeedTypes | packages.NeedTypesSizes | packages.NeedSyntax | packages.NeedTypesInfo, Tests: false, Dir: dir, ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) { // 忽略測試檔案 if strings.HasSuffix(filename, "_test.go") { return nil, nil } return parser.ParseFile(fset, filename, src, parser.ParseComments) }, }, dir+"/...") // 2. 基於指定的package名稱,建立SSA專案(包含所有引用的包) prog, pkgs := ssautil.AllPackages(initial, 0) for _, p := range pkgs { if p != nil { // 編譯 p.Build() } } // 3. 查詢SSA專案所有Function allFuncs := ssautil.AllFunctions(prog) // 遍歷所有Function,查詢需要特徵 for f := range allFuncs { if f.Pkg != nil { // 這裡只演示查詢入參包含Slice的程式碼 find := false for _, p := range f.Params { if typ, ok := p.Type().(*types.Slice); ok { find = true } } // ... } }

跨專案呼叫鏈路分析

  • 問題定義

開源專案中實現呼叫鏈路分析的工具已經有很多,可以方便的對專案中的呼叫鏈路實現分析、視覺化輸出等功能,例如go callgraph、go-callvis。基本原理是分析每一個FuncDecl,忽略IfStmt、ForStmt等流程控制語句,只提取ExprStmt中的CallExpr。得到每一個Function裡呼叫的下游Function,形成一個呼叫鏈路。

但在基於微服務搭建的實際業務專案中,會遇到兩個問題:

  1. 呼叫鏈路分析需要一個入口函式,作為分析的起點,常見的通用工具一般使用main函式作為起點,但一般微服務請求通過RPC handler作為入口函式
  2. 微服務架構下,通常一組服務共同提供一個業務服務,一個功能需要呼叫多個下游服務和中介軟體才能完成,無法直接通過靜態分析結果將多個微服務的呼叫鏈路串聯起來。

  3. 實現思路

基於上面介紹過的ssautil的能力,將多個專案RPC呼叫連線起來其實是很簡單的一件事情,每一個Function的Blocks已經形成了本方法的AST,基於RPC框架自動生成的client程式碼,做一個簡單的手工對映,把RPC client和RPC server的Function做一個連結,就可以形成完整的AST。

基本的實現步驟如下:

  1. 載入多個專案原始碼,編譯
  2. 按照基於RPC工具生成handler為函式分析入口,開始分析
  3. 根據包名對於不同的外部包進行打標,忽略Go SDK、log包等通用包,只分析中介軟體或下游的函式
  4. 根據SDP生成的client程式碼,將RPC client到RPC server的對映關係自動生成

ssautil解析的部分就不重複介紹,與「函式特徵查詢」處的程式碼類似,下面展示構建callgraph的程式碼: ``` func doBuildCallGraph(fullName string, funcMap map[string]ssa.Function, level int) { if f, ok := funcMap[fullName]; ok { // 迴圈每一個程式碼塊 for , b := range f.Blocks { // 迴圈每一個操作 for , instr := range b.Instrs { // 只看呼叫操作 if site, ok := instr.(ssa.CallInstruction); ok { call := site.Common() if callFunc, ok2 := call.Value.(ssa.Function); ok2 { fullName2 := GetFullName(callFunc) // 組裝CallGraphTree,這裡省略

              // 通過手工對映,將RPC client到RPC server做name轉換
              if newFullName, ok3 := rpcJump(fullName2); ok3 {
                  fullName2 = newFullName
              }

              // 遞迴下一級Function
              doBuildCallGraph(fullName2, funcMap, level+1)
           }
        }
     }
  }

} } ``` 跨專案呼叫鏈路有什麼作用呢?

  1. 例如目前系統對應的mongo出現故障,對於某個核心介面是否受到影響?或者影響多少介面?傳統的方法基於整理的文件或開發人員的經驗,利用跨專案呼叫鏈路可以快速給出基於目前程式碼給出結論,主要看收集的CallGraphTree中是否包含mongo相關的SDK。
  2. 例如在依賴包檢查提示升級某個依賴包版本時候,影響多少功能?可以通過分析前後兩個版本程式碼變動影響的函式,確定有多少我確實使用的函式有變更,已經向上影響我的多少業務方法,針對性的進行迴歸測試。

總結

以上就是Golang靜態分析這一塊目前總結的內容,包含基本概念、常用工具的應用、日常工作中的應用。當然可能應用的地方遠遠不止上面提到的部分。工欲善其事,必先利其器,通過本文的講解大家已經看到靜態分析工具強大的能力,期望可以幫助大家擴充一些視角,更好的將程式碼本身作為工具,解決日常開發中的問題。

加入我們

我們來自位元組跳動飛書商業應用研發部(Lark Business Applications),目前我們在北京、深圳、上海、武漢、杭州、成都、廣州、三亞都設立了辦公區域。我們關注的產品領域主要在企業經驗管理軟體上,包括飛書 OKR、飛書績效、飛書招聘、飛書人事等 HCM 領域系統,也包括飛書審批、OA、法務、財務、採購、差旅與報銷等系統。歡迎各位加入我們。

掃碼發現職位&投遞簡歷

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