likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 68

背景

提到静态分析,大家第一反应可能是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树。

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,大致流程如下:

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

  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的检查,具体源码位于 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、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历

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

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

转载自:https://juejin.cn/post/7114851198762483748
评论
请登录