对于 CloudWeGo kitex 生成工具的源码分析

1,476 阅读4分钟

大家好,前两天我在网上怎么也搜索也搜不到 关于 Kitex 的解析文章,基本只是介绍 bytedance 出了个 kitex 框架之类的一模一样的无效信息,我感觉很难受

为什么发在掘金呢,因为这是我在 google 的时候有时会出现在我页面的有用网站,baidu 实在是不行。

以下内容为我对于 kitex 中 代码生成文件的解析说明

Kitex 文档官网

1. 我认为在解析源码的时候最好遵循以下几个原则

  1. 要有扎实的语言基础知识
  2. 熟练的使用搜素引擎, baidu 不行!
  3. 遵循由浅入深由表及里的原则, 不要一口吃个大胖子,直接失去学习的兴趣
  4. 拥有较为完善的英语水平,因为大多开源项目都是面向国际的,所以一般选用英文作为注释,看不懂这是我们的问题,肯定不是开发人员的问题啊

2. 开始分析 main.go

由文档提示可知,kitex 工具文件是在项目的 github.com/cloudwego/kitex/tool/cmd 目录中

image-20220520224653636.png

 .
 └── kitex
     ├── args.go
     └── main.go
  • main.go 完成命令行的执行逻辑
  • args.go 主要用于解析命令行参数

下面从 main.go 开始分析, 以下是主要逻辑

 // 添加 version 参数
 func init() {
   ...
 }
 // 执行主体 ...
 func main() {
   ...
 }
 // 指定 IDL 文件的generator tool path
 func lookupTool(idlType string) string {
   ...
 }
 // 形成 执行kitex 生成代码的命令
 func buildCmd(a *arguments, out io.Writer) *exec.Cmd {
   ...
 }

然后我们从 func main() 进行分析, 以下为基本逻辑

 func main() {
   // run as a plugin
   // 决定使用哪种 插件
   switch filepath.Base(os.Args[0]) {
   // thrift-gen-kitex
   case thriftgo.PluginName:
     os.Exit(thriftgo.Run())
   // protoc-gen-kitex  
   case protoc.PluginName:
     os.Exit(protoc.Run())
   }
   
   //TODO: 分析 命令行参数 
   args.parseArgs()
   
   out := new(bytes.Buffer)
   // 返回了生成了的例如 protoc-gen-kitex 的可执行文件cmd
   cmd := buildCmd(&args, out)
   // run cmd
   err := cmd.Run()
   if err != nil {
     if args.Use != "" {
       out := strings.TrimSpace(out.String())
       if strings.HasSuffix(out, thriftgo.TheUseOptionMessage) {
         os.Exit(0)
       }
     }
     os.Exit(1)
   }
 }

再然后我们进入 args.parseArgs() 中分析

 func (a *arguments) parseArgs() {
     // 设置flags
     f := a.buildFlags()
     // 分析 flag
     if err := f.Parse(os.Args[1:]); err != nil {
       log.Warn(os.Stderr, err)
       os.Exit(2)
     }
     // 将参数赋值给配置
     log.Verbose = a.Verbose
     // 检查 从外添加的参数
     for _, e := range a.extends {
       e.check(a)
     }
     // 检查...
     a.checkIDL(f.Args())
     a.checkServiceName()
     a.checkPath()
 }

我们可以发现 kitex/tool/cmd/kitex/args.go 中的 buildFlag(),使用了golang/src/flag 库,这是由 golang 官方支持实现命令行的库,以上代码使用命令行中的第一个参数作为一个 flag,第二个参数为flag使用出现 error的处理方法

 func (a *arguments) buildFlags() *flag.FlagSet {
   f := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
   ...
 }

函数中类似的方法较多, 我们只举例一个

func (f *FlagSet) BoolVar(p *bool, name string, value bool, usage string)

它实了现参数的绑定

 func (a *arguments) buildFlags() *flag.FlagSet {
   f := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
   // 设置子命令
   f.BoolVar(&a.NoFastAPI, "no-fast-api", false,
     "Generate codes without injecting fast method.")
   ...
 }
 ​
 type arguments struct {
   generator.Config
   // 额外添加的 flag
   extends []*extraFlag
 }

被绑定的参数

 package generator
 ​
 type Config struct {
   Verbose         bool
   GenerateMain    bool // whether stuff in the main package should be generated
   GenerateInvoker bool // generate main.go with invoker when main package generate
   Version         string
   NoFastAPI       bool
   ModuleName      string
   ServiceName     string
   Use             string
   IDLType         string
   Includes        util.StringSlice
   ThriftOptions   util.StringSlice
   ProtobufOptions util.StringSlice
   IDL             string // the IDL file passed on the command line
   OutputPath      string // the output path for main pkg and kitex_gen
   PackagePrefix   string
   CombineService  bool // combine services to one service
   CopyIDL         bool
   ThriftPlugins   util.StringSlice
   Features        []feature
 }

然后再从 kitex 中的代码生成工具命令入手

这是官方文档中的示例

 kitex -module "your_module_name" -service a.b.c hello.thrift

其中 hello.thrift 参数由于没有形成键值对,所以属于 non-flag , 由 buildFlags 中的 a.checkIDL(f.Args()) 进行读取

 func (a *arguments) buildFlags() *flag.FlagSet {
   f := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
   ...
   // 检查使用哪种 IDL 语言
   a.CheckIDL(f.Args())
 }

我们再深入看看 f.Args 的源码, 从注释知晓 Args() 读取的为 non-flag 的参数,由此通过 CheckIDL() 便可以判断使用了哪种 IDL 语言

 package flag
 ​
 // Args returns the non-flag arguments.
 func (f *FlagSet) Args() []string { return f.args }

3. -module 为什么有时候可以可有可无 ?

官网中还有一个有意思的说明, 当前目录是在 $GOPATH/src 下的一个目录,那么可以不指定 -module,这部分的逻辑在 args.go 中的 checkPath() 方法中

image-20220520232852175.png

 func (a *arguments) checkPath() {
   // go 的路径
   pathToGo, err := exec.LookPath("go")
   ...
   // 获取 gopath/src
   gosrc := filepath.Join(util.GetGOPATH(), "src")
   gosrc, err = filepath.Abs(gosrc)
   ...
   curpath, err := filepath.Abs(".")
   // 是不是存在gopath/src 中 
   if strings.HasPrefix(curpath, gosrc) {
     if a.PackagePrefix, err = filepath.Rel(gosrc, curpath); err != nil {
       log.Warn("Get GOPATH/src relpath failed:", err.Error())
       os.Exit(1)
     }
     a.PackagePrefix = filepath.Join(a.PackagePrefix, generator.KitexGenPath)
   } else {
     if a.ModuleName == "" {
       log.Warn("Outside of $GOPATH. Please specify a module name with the '-module' flag.")
       os.Exit(1)
     }
   }
   // 重点
   if a.ModuleName != "" {
     module, path, ok := util.SearchGoMod(curpath)
     if ok {
       // go.mod exists
       if module != a.ModuleName {
         log.Warnf("The module name given by the '-module' option ('%s') is not consist with the name defined in go.mod ('%s' from %s)\n",
           a.ModuleName, module, path)
         os.Exit(1)
       }
       if a.PackagePrefix, err = filepath.Rel(path, curpath); err != nil {
         log.Warn("Get package prefix failed:", err.Error())
         os.Exit(1)
       }
       a.PackagePrefix = filepath.Join(a.ModuleName, a.PackagePrefix, generator.KitexGenPath)
     } else {
       if err = initGoMod(pathToGo, a.ModuleName); err != nil {
         log.Warn("Init go mod failed:", err.Error())
         os.Exit(1)
       }
       a.PackagePrefix = filepath.Join(a.ModuleName, generator.KitexGenPath)
     }
   }
 ​
   if a.Use != "" {
     a.PackagePrefix = a.Use
   }
   a.OutputPath = curpath
 }

从以上代码为什么 GOPATH/src 中可以不使用 -module, 因为 $GOPATH/src 中是有go.mod 目录的,所以 -module 其实基本是属于必须的参数,如果没有看到src目录,大家可以自行搜索一下原因,通过自己的思考得到答案是很有意思的.

4. 继续分析main.go

看完了上面的分析我们再转回 main.go, 从 init() 可知该函数添加了version 参数, 我感觉个人可以通过此对kitex 进行侵入性小的个人定制

 func init() {
   var queryVersion bool
   args.addExtraFlag(&extraFlag{
     apply: func(f *flag.FlagSet) {
       f.BoolVar(&queryVersion, "version", false,
         "Show the version of kitex")
     },
     check: func(a *arguments) {
       if queryVersion {
         println(a.Version)
         os.Exit(0)
       }
     },
   })
 }

从下可知 buildCmd 是一个重要的方法,我们下来开始解析

 func main() {
   ...
   out := new(bytes.Buffer)
   // 返回了生成了的例如 protoc-gen-kitex 的可执行文件cmd
   cmd := buildCmd(&args, out)
   // run cmd
   err := cmd.Run()
   if err != nil {
     if args.Use != "" {
       out := strings.TrimSpace(out.String())
       if strings.HasSuffix(out, thriftgo.TheUseOptionMessage) {
         os.Exit(0)
       }
     }
     os.Exit(1)
   }
 }

从代码可知该函数 使用了exec.Cmd{} 这个 golang 原生方法,这个我觉得大家可以自己点进源码看看, 学习的时候毕竟是要思考的嘛

 func buildCmd(a *arguments, out io.Writer) *exec.Cmd {
   // Pack 的作用是将配置信息解析成key=value的格式
   // eg:  IDL=thrift,Version=1.2
   kas := strings.Join(a.Config.Pack(), ",")
   cmd := &exec.Cmd{
     // 指定 IDL 文件的generator tool path
     Path:   lookupTool(a.IDLType),
     Stdin:  os.Stdin,
     Stdout: &teeWriter{out, os.Stdout},
     Stderr: &teeWriter{out, os.Stderr},
   }
   
   if a.IDLType == "thrift" {
     cmd.Args = append(cmd.Args, "thriftgo")
     for _, inc := range a.Includes {
       cmd.Args = append(cmd.Args, "-i", inc)
     }
     a.ThriftOptions = append(a.ThriftOptions, "package_prefix="+a.PackagePrefix)
     gas := "go:" + strings.Join(a.ThriftOptions, ",")
     if a.Verbose {
       cmd.Args = append(cmd.Args, "-v")
     }
     if a.Use == "" {
       cmd.Args = append(cmd.Args, "-r")
     }
     cmd.Args = append(cmd.Args,
       //  generator.KitexGenPath = kitex_gen
       "-o", generator.KitexGenPath,
       "-g", gas,
       "-p", "kitex:"+kas,
     )
     for _, p := range a.ThriftPlugins {
       cmd.Args = append(cmd.Args, "-p", p)
     }
     cmd.Args = append(cmd.Args, a.IDL)
   } else {
     a.ThriftOptions = a.ThriftOptions[:0]
     // "protobuf"
     cmd.Args = append(cmd.Args, "protoc")
     for _, inc := range a.Includes {
       cmd.Args = append(cmd.Args, "-I", inc)
     }
     outPath := filepath.Join(".", generator.KitexGenPath)
     if a.Use == "" {
       os.MkdirAll(outPath, 0o755)
     } else {
       outPath = "."
     }
     cmd.Args = append(cmd.Args,
       "--kitex_out="+outPath,
       "--kitex_opt="+kas,
       a.IDL,
     )
   }
   log.Info(strings.ReplaceAll(strings.Join(cmd.Args, " "), kas, fmt.Sprintf("%q", kas)))
   return cmd
 }

这是我大致分析lookupTook 方法的注释

 func lookupTool(idlType string) string {
   // 返回此进程可执行路径名
   exe, err := os.Executable()
   if err != nil {
     log.Warn("Failed to detect current executable:", err.Error())
     os.Exit(1)
   }
   
   // 找出可执行文件名 eg: kitex
   dir := filepath.Dir(exe)
   // 拼接path eg: kitex protoc-gen-kitex
   pgk := filepath.Join(dir, protoc.PluginName)
   tgk := filepath.Join(dir, thriftgo.PluginName)
   
   link(exe, pgk)
   link(exe, tgk)
   
   tool := "thriftgo"
   if idlType == "protobuf" {
     tool = "protoc"
   }
   // 寻找 PATH 中的指定可执行文件
   // e.g: /usr/local/bin/protoc-gen-kitex
   path, err := exec.LookPath(tool)
   if err != nil {
     log.Warnf("Failed to find %q from $PATH: %s. Try $GOPATH/bin/%s instead\n", path, err.Error(), tool)
     path = filepath.Join(util.GetGOPATH(), "bin", tool)
   }
   return path
 }

由此 只要在 kitex 此目录执行 go build 命令,再放入 GOPATH 下,kitex 的执行文件就生效了

5. 总结

我这次解析源码主要是因为 cloudwego 开源不久,我乍看之下只推出了 kitex RPC 框架 和 Netpoll 网络库, 网络上好像也没有什么解析,看到字节跳动的 CSG 所以抽空写了一下,希望对乐意学习的同学有帮助。

由于我本人也只是接触 golang 不到一个月,并且写的匆忙,所以难免有些纰漏,望原谅