對於 CloudWeGo kitex 生成工具的原始碼分析
theme: juejin
大家好,前兩天我在網上怎麼也搜尋也搜不到 關於 Kitex 的解析文章,基本只是介紹 bytedance 出了個 kitex 框架之類的一模一樣的無效資訊,我感覺很難受
為什麼發在掘金呢,因為這是我在 google 的時候有時會出現在我頁面的有用網站,baidu 實在是不行。
以下內容為我對於 kitex 中 程式碼生成檔案的解析說明
1. 我認為在解析原始碼的時候最好遵循以下幾個原則
- 要有紮實的語言基礎知識
- 熟練的使用搜素引擎, baidu 不行!
- 遵循由淺入深,由表及裡的原則, 不要一口吃個大胖子,直接失去學習的興趣
- 擁有較為完善的英語水平,因為大多開源專案都是面向國際的,所以一般選用英文作為註釋,看不懂這是我們的問題,肯定不是開發人員的問題啊
2. 開始分析 main.go
由文件提示可知,kitex 工具檔案是在專案的 github.com/cloudwego/kitex/tool/cmd 目錄中
.
└── 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() 方法中
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 不到一個月,並且寫的匆忙,所以難免有些紕漏,望原諒