1

把「Go静态分析」放进你的工具箱

 1 year ago
source link: https://juejin.cn/post/7114851198762483748
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

背景

提到静态分析,大家第一反应可能是Sonar、FindBugs等静态代码检查工具,它通过对源码的分析,不实际运行代码,检查代码是否符合编码规范,发现源码中潜在的漏洞。这当中主要通过静态编译相关技术,其实大家在日常的业务代码开发过程中借助IDE,使用的代码格式化、查找某方法的定义/调用等功能也是基于这套技术实现的。

本文期望扩充静态分析的定义,泛指所有通过静态编译相关技术实现的工具,不局限于上面提到的通用的,开箱即用的功能。掌握静态分析的原理,基于特定场景,可以组装属于自己的工具,提升开发效率,把「Go静态分析」放进你的工具箱。

Golang在 github.com/golang/tool… 提供了相关工具和代码包,非常清晰的将静态编译的各个过程和中间结果暴露了出来,让我们可以方便的定义自己的工具。

下面带大家一起浏览一下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树。

Go 1.7开始,Go将原来的IR(Intermediate Representation,中间代码)转换成SSA(Static Single Assignment,静态单赋值)形式的IR,可以引入更多优化。具体细节和原理更多涉及编译期优化,这里就不过多展开,只介绍一下SSA的概念和过程中间变量。

SSA的结果,可以理解为每个赋值得到唯一的变量名。当 x 重新分配了另一个值时,将创建一个新名称 x_1。比如下面例子:

x = 1
y = 7
// do stuff with x and y
x = y
y = func()
// do more stuff with x and y
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,大致流程如下:

  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(表达式)逐级包含的关系完全一致,附加了相关的语法规则符号,换行。

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的检查,具体源码位于 github.com/timakin/bod…

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. 微服务架构下,通常一组服务共同提供一个业务服务,一个功能需要调用多个下游服务和中间件才能完成,无法直接通过静态分析结果将多个微服务的调用链路串联起来。

基于上面介绍过的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、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历

67b7409da3d340ad91b3de3a53f8ba7c~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp

官网投递:job.toutiao.com/s/FyL7DRg


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK