当前位置: 首页 > 科技观察

在Go中编写工具的终极指南

时间:2023-03-19 10:29:57 科技观察

我之前构建了一个工具来让生活更轻松。该工具名为:gomodifytags,它会根据字段名称自动填充结构体的标签字段。下面是一个例子:(Ausageexampleusinggomodifytagsinvim-go)使用这样的工具可以很容易地管理一个结构体的多个字段。该工具还可以添加和删除标签、管理标签选项(如omitempty)、定义转换规则(snake_case、camelCase等)等等。但是这个工具是如何工作的呢?它在底层使用了哪些Go包?像这样的问题有很多需要回答。这是一篇很长的博客文章,解释了如何编写这样的工具以及如何构建它的每个细节。它包含许多独特的细节、提示和技巧,以及一些未知的Go位。喝杯咖啡,开始深入挖掘!首先列出这个工具需要完成的功能:需要阅读源文件,理解并能够解析Go文件。它需要找到相关的结构。找到结构后,需要获取其字段名称。它需要根据字段名称更新结构标签(根据转换规则,即:snake_case)它需要能够用这些更改更新文件,或者能够以可接受的方式输出更改让我们先看结构标签的定义是什么,然后我们将了解所有部分以及它们如何组合在一起以构建此工具。结构体的标记值(其内容,例如`json:"foo"`)不是官方标准的一部分,但是,有一个非官方规范使用reflect包定义其格式,stdlib也采用该规范(例如编码/json)包。通过reflect.StructTag类型定义:结构体标签的定义比较简洁,不易理解。定义可以细分如下:struct标签是一个字符串(字符串类型)struct标签的Key是一个不带引号的字符串struct标签的值是一个带引号的字符串struct标签的键和值由冒号(:)分隔。用冒号分隔的键和对应的值称为“键值对”。结构标签可以包含多个键值对(可选)。键值对由空格分隔。可选设置不是定义的一部分。像encoding/json这样的包将值解析为逗号分隔的列表。值中第一个逗号之后的任何内容都是可选设置的一部分,例如:“foo,omitempty,string”。其中value有一个名为"foo"的名称和一个可选设置["omitempty","string"]由于结构标记是一个字符串,因此需要用双引号或反引号括起来。并且因为值也需要用引号括起来,结构标签通常用反引号括起来。上述规则的概述如下:(结构标签的定义有很多隐含的细节)理解了什么是结构标签后,您就可以根据需要修改结构标签。问题来了,我们如何轻松解析所做的更改?幸运的是,reflect.StructTag包含一个可以解析结构标记并返回特定键值的方法。一个例子如下:),tag.Get("species"))}输出:如果键不存在,bluegopher返回一个空字符串。这非常有用,但是,它有一些额外的注释,因此不适合我们,因为我们需要更大的灵活性。它们是:它无法检测标签是否格式错误(即:键被引用,值未被引用等)它不知道选项的语义它无法遍历现有标签或返回它们。我们必须知道要修改哪些标签。如果我不知道它的名字怎么办?无法修改现有标签。我们无法重建新的结构标签。为了改善这一点,我编写了一个自定义的Go包,它修复了上述所有问题,并提供了一个API,可以轻松修改结构标签的各个方面。这个包称为structtag,可从github.com/fatih/structtag获得。这个包允许我们以一种简洁的方式解析和修改标签。这是一个完整的工作示例,复制/粘贴并自己尝试:packagemainimport("fmt""github.com/fatih/structtag")funcmain(){tag:=`json:"foo,omitempty,string"xml:"foo"`//parsethetagtags,err:=structtag.Parse(string(tag))iferr!=nil{panic(err)}//iterateoveralltagsfor_,t:=rangetags.Tags(){fmt.Printf("tag:%+v\n",t)}//getasingletagjsonTag,err:=tags.Get("json")iferr!=nil{panic(err)}//changeexistingtagjsonTag.Name="foo_bar"jsonTag.Options=niltags.Set(jsonTag)//addnewtagtags.Set(&structtag.Tag{Key:"hcl",Name:"foo",Options:[]string{"squash"},})//printthetagsfmt.Println(tags)//输出:json:"foo_bar"xml:"foo"hcl:"foo,squash"}既然我们知道如何解析一个结构标签,并修改它或创建一个新标签,是时候修改一个有效的Go源文件了。在上面的示例中,标签已经存在,但是如何从现有的Go结构中获取它们呢?简短回答:通过AST。AST(抽象语法树,AbstractSyntaxTree)允许我们从源代码中检索每个单独的标识符(节点)。下面你可以看到结构类型的AST(简化版):(结构的基本Goast.Node表示)在这棵树中,我们可以检索和操作每个标识符,每个字符串和每个括号等。这些都表示为AST节点。例如,我们可以通过替换代表它的节点中的名称,将字段名称从“Foo”更改为“Bar”。同样的逻辑也适用于结构标签。要获得GoAST,我们需要解析源文件并将其转换为AST。事实上,两者都是一步处理的。为此,我们将使用go/parser包来解析文件以获得(整个文件的)AST,然后使用go/ast包遍历整个树(我们也可以手动完成,但是那是另一篇博客文章的主题)。你可以在下面的代码中看到一个完整的例子:"foo\"`}"fset:=token.NewFileSet()file,err:=parser.ParseFile(fset,"demo",src,parser.ParseComments)iferr!=nil{panic(err)}ast.Inspect(file,func(xast.Node)bool{s,ok:=x.(*ast.StructType)if!ok{returntrue}for_,field:=ranges.Fields.List{fmt.Printf("字段:%s\n",field.Names[0].Name)fmt.Printf("Tag:%s\n",field.Tag.Value)}returnfalse})}以上代码输出结果如下:Field:FooTag:`json:"foo"`上面的代码做了以下事情:我们定义了一个有效的Go包的实例,它只包含一个结构。我们使用go/parser包来解析这个字符串。解析器包还可以从磁盘读取文件(或整个包)。解析后,我们保存我们的节点(分配给变量文件)并查找由*ast.StructType定义的AST节点(参见AST图像以供参考)。遍历树是通过ast.Inspect()函数完成的。它遍历所有节点,直到收到错误值。这非常方便,因为它不需要了解每个节点。我们打印结构的字段名称和结构标签。我们现在可以完成两件重要的事情,首先,我们知道如何解析Go源文件并检索其中结构的标签(通过go/parser)。其次,我们知道如何解析Gostruct标签并根据需要修改它们(通过github.com/fatih/structtag)。现在我们有了这些,我们可以使用这两个重要的代码片段开始构建我们的工具(名为gomodifytags)。该工具应按顺序执行以下操作:获取配置以识别我们要修改的结构根据配置查找和修改结构输出结果由于gomodifytags主要由编辑器执行,我们打算通过CLI标志传递配置信息.第二步包括多个步骤,如解析文件、找到正确的结构,然后修改结构(通过修改AST)。***,我们将以原始Go源文件或某种自定义协议(如JSON,稍后会详细介绍)的形??式输出结果。以下是经过简化后的gomodifytags的主要功能:让我们从详细解释每个步骤开始。为简单起见,我将尝试以提取的形式解释重要部分。虽然一切都是一样的,但一旦您完成这篇博文,您将能够在没有任何指导的情况下浏览整个源代码(您将在本指南的***找到所有资源)让我们从***开始吧第一步学习如何获取配置。下面是我们的配置文件,其中包含所有的必需信息typeconfigstruct{//firstsection-input&outputfilestringmodifiedio.Readeroutputstringwritebool//secondsection-structselectionoffsetintstructNamestringlinestringstart,endint//thirdsection-structmodificationremove[]stringadd[]stringoverridebooltransformstringsortboolclearboolstringrebotclemot[]三个主要部分:***部分包含有关如何读取以及读取哪个文件的配置。这可以是本地文件系统上的文件名,也可以是直接来自标准输入的数据(主要用于编辑器)。它还设置了结果的输出方式(Go源文件或JSON),以及我们是否应该覆盖文件而不是输出到标准输出。第二部分定义如何选择结构及其字段。有多种方法可以做到这一点。我们可以通过其偏移量(光标位置)、结构名称、单行(仅指定字段)或一系列行来定义它。***,我们总是需要获取起始行号。例如,在下面的示例中,您可以看到一个示例,其中我们按名称选择结构,然后提取起始行号,以便我们可以选择正确的字段:而编辑器***使用字节偏移量。例如下面你可以看到我们的光标就在“端口”字段名称之后,从那里我们可以很容易地得到起始行号:config配置中的第三部分实际上是一对一到我们的structtagpackage映射.它基本上允许我们在读取字段后将配置传递给structtag包。如您所知,structtag包允许我们解析结构标记并在各个部分对其进行修改。但是,它不会覆盖或更新结构的字段值。我们如何获得配置?我们只是使用flag包,为配置中的每个字段创建一个flag,并为它们赋值。例如:flagFile:=flag.String("file","","Filenametobeparsed")cfg:=&config{file:*flagFile,}我们对配置中的每个字段执行相同的操作。有关完整列表,请参阅gomodifytag当前主分支上的标志定义。一旦我们有了配置,我们就可以做一些基本的验证:funcmain(){cfg:=config{...}err:=cfg.validate()iferr!=nil{log.Fatalln(err)}//continueparsing}//validate验证配置是否有效func(c*config)validate()error{ifc.file==""{returnerrors.New("nofileispassed")}ifc.line==""&&c.offset==0&&c.structName==""{returnerrors.New("-line,-offsetor-structisnotpassed")}ifc.line!=""&&c.offset!=0||c.line!=""&&c.structName!=""||c.offset!=0&&c.structName!=""{returnerrors.New("-line,-offsetor-struct不能一起使用.pickone")}if(c.add==nil||len(c.add)==0)&&(c.addOptions==nil||len(c.addOptions)==0)&&!c.clear&&!c.clearOption&&(c.removeOptions==nil||len(c.removeOptions)==0)&&(c.remove==nil||len(c.remove)==0){returnerrors.New("oneof"+"[-add-tags,-add-options,-remove-tags,-删除选项,-清除-tags,-clear-options]"+"shouldbedefined")}returnil}将代码的验证部分放到一个函数中,让测试更简单现在我们知道如何获取配置并验证它,让我们继续解析thefile:一开始我们讨论了如何解析一个文件,这里我们解析config结构体中的方法,其实所有的方法都是config结构体的一部分:funcmain(){cfg:=config{}node,err:=cfg.parse()iferr!=nil{returnerr}//continuefindstructselection...}func(c*config)parse()(ast.Node,error){c.fset=token.NewFileSet()varcontentsinterface{}ifc.modified!=nil{archive,err:=buildutil.ParseOverlayArchive(c.modified)iferr!=nil{returnnil,fmt.Errorf("failedtoparse-modifiedarchive:%v",err)}fc,ok:=archive[c.file]if!ok{returnil,fmt.Errorf("找不到%sinarchive",c.file)}contents=fc}returnparser.ParseFile(c.fset,c.file,contents,parser.ParseComments)}parse函数只做了一件事。解析源代码并返回一个ast.Node。这很简单,如果我们只传递文件,在这种情况下我们使用parser.ParseFile()函数。请注意token.NewFileSet(),它创建一个类型*token.FileSet。我们将它存储在c.fset中,但也传递给parser.ParseFile()函数。为什么?因为fileset是为每个文件独立存储每个节点的位置信息。这对以后获取ast.Node的准确信息很有帮助(注意ast.Node使用了一个紧凑的位置信息,叫做token.Pos,要获取更多的信息,需要通过token.FileSet.Position()函数得到一个token.Position,里面包含了更多的信息)让我们继续。如果源文件通过stdin传递,它会变得更加有趣。config.modified字段是一个io.Reader以便于测试,但我们实际上通过stdin传递它。我们如何检测是否需要从标准输入读取?我们询问用户是否想通过stdin传递一些东西。在这种情况下,此工具的用户需要传递--modified标志(这是一个布尔标志)。如果用户传递此标志,我们只需将stdin分配给c.modified:再次执行上面的parse()函数,您会看到我们检查是否分配了.modified字段,因为stdin是一个任意数据流,我们需要能够根据给定的协议解析它。在这种情况下,我们假设它包含以下内容:文件名后跟换行符(十进制)文件大小后跟文件的换行符内容因为我们知道文件大小,所以我们可以毫无问题地解析该文件的内容。任何大于给定文件大小的部分,我们就停止解析。这种方法也被其他几个工具(如guru、gogetdoc等)使用,对编辑器非常有用。因为这样可以让编辑器通过修改后的文件内容,而不用将其保存到文件系统中。因此它被命名为“修改”。现在我们有了Node,让我们继续下一步“找结构”:在我们的main函数中,我们将使用上一步解析的ast.Node来调用findSelection()函数:funcmain(){//...parsefileandgetast.Nodestart,end,err:=cfg.findSelection(node)iferr!=nil{returnerr}//continuerewritingthenodewiththestart&endposition}cfg.findSelection()函数会根据配置文件和方式返回指定结构我们选择结构结构的开始和结束位置。它遍历给定的Node并返回其起始位置(类似于上面配置部分中解释的内容):(检索步骤遍历所有节点,直到找到*ast.StructType并将其返回到.)