预备知识

需要安装的软件

  • protoc
  • golang
  • go 软件包 github.com/lyft/protoc-gen-star

插件调用步骤

protoc,PB编译器,使用一组标志(记录在protoc -h下)进行配置,并将一组文件作为参数交给它。在这种情况下,I标志可以被多次指定,是它在proto文件中用于导入依赖关系的查找路径。默认情况下,官方描述符protos已经被包含在内。

myplugin_out 告诉 protoc 使用 protoc-gen-myplugin protoc-plugin。这些插件会从系统的 PATH 环境变量中自动解析,或者可以用另一个标志明确指定。官方的protoc-plugins (例如,protoc-gen-python) 已经在protoc注册了。该标志的值是特定于特定插件的,但 :…/generated 后缀除外。这个后缀表示protoc将把该包生成的文件放在哪个根目录下(相对于当前工作目录)。然而,这个生成的输出目录不会传播给 protoc-gen-myplugin,所以它需要在标志的左边重复。PG* 通过一个 output_path 参数支持这一点。

protoc 解析传入的 proto 文件,确保它们在语法上是正确的,并加载任何导入的依赖项。它将这些文件和依赖关系转换成描述符 (它们本身就是 PB 消息),并创建一个 CodeGeneratorRequest (又是一个 PB)。protoc 将这个请求序列化,然后执行每个配置的 protoc-plugin,通过 stdin 发送有效载荷。

protoc-gen-myplugin 启动,接收请求的有效载荷,并将其解密。一个基于 PG* 的 protoc-plugin 有两个阶段。首先,PG* 对从 protoc 收到的 CodeGeneratorRequest 进行解密,并为每个文件和其包含的所有实体创建一个完全连接的抽象语法树 (AST)。为这个插件指定的任何参数也会被解析,以便以后使用。

当这一步完成后,PG*就会执行任何注册的模块,把构建的AST交给它。模块可以被写成生成人工制品(例如,文件),或者只是对所提供的图进行某种形式的验证而没有任何其他副作用。模块在针对PB的操作方面提供了极大的灵活性。

一旦所有的模块都被运行,PG*会将任何自定义的工件写入文件系统,或者将生成器特定的工件序列化为CodeGeneratorResponse并将数据发送到其stdout。这整个流程看起来像这样。

foo.proto → protoc → CodeGeneratorRequest → protoc-gen-myplugin → CodeGeneratorResponse → protoc → foo.pb.go

假设插件名称为diy,则需要编译程序为protoc-gen-diy,并将程序加入系统Path变量,通过下面的命令调用插件。

protoc -I . --diy_out=./gen/  xxxx.proto

使用protoc-gen-star包

模块 Modules

PG*模块将被交付一个完整的AST,用于生成目标文件以及所有依赖项。然后,模块可以将文件添加到协议CodeGeneratorResponse或将文件作为组件直接写入磁盘。

PG*提供了一个ModuleBase结构来简化开发模块。开箱即用,它满足Module的接口,只需要创建NameExecute方法。ModuleBase最好用作包装模块实现的匿名嵌入字段。最小模块如下所示:

// ReportModule creates a report of all the target messages generated by the
// protoc run, writing the file into the /tmp directory.
type reportModule struct {
  *pgs.ModuleBase
}

// New configures the module with an instance of ModuleBase
func New() pgs.Module { return &reportModule{&pgs.ModuleBase{}} }

// Name is the identifier used to identify the module. This value is
// automatically attached to the BuildContext associated with the ModuleBase.
func (m *reportModule) Name() string { return "reporter" }

// Execute is passed the target files as well as its dependencies in the pkgs
// map. The implementation should return a slice of Artifacts that represent
// the files to be generated. In this case, "/tmp/report.txt" will be created
// outside of the normal protoc flow.
func (m *reportModule) Execute(targets map[string]pgs.File, pkgs map[string]pgs.Package) []pgs.Artifact {
  buf := &bytes.Buffer{}

  for _, f := range targets {
    m.Push(f.Name().String()).Debug("reporting")

    fmt.Fprintf(buf, "--- %v ---", f.Name())

    for i, msg := range f.AllMessages() {
      fmt.Fprintf(buf, "%03d. %v\n", i, msg.Name())
    }

    m.Pop()
  }

  m.OverwriteCustomFile(
    "/tmp/report.txt",
    buf.String(),
    0644,
  )

  return m.Artifacts()
}

ModuleBase公开了PG* buildContext实例,该实例已经带有模块的名称。调用PUSH和POP允许将更多信息添加到错误和调试消息中。上面,在记录“报告”调试消息之前,将目标软件包中的每个文件都推到上下文上。

该基础还提供了用于添加或覆盖ProtoC生成和自定义文件的辅助方法。上面的执行方法在 /tmp/report.txt上创建一个自定义文件,指定其应覆盖使用该名称的现有文件。如果它称为AddCustomFile并存在该文件,则不会生成文件(尽管会记录调试消息)。也存在添加生成器文件,附加和注入的类似方法。同样,诸如AddCustomTemplateFile之类的方法允许渲染模板。

在执行所有模块后,返回的工件要么将其放入ProtoC的CodeGenerationResponse有效负载中,要么写入文件系统。出于测试的目的,文件系统已经被抽象化,以便可以通过文件系统initoption向PG* Generator提供自定义的文件(例如内存FS)。

后期处理 Post Processing

模块生成的工件有时需要在写入磁盘或发送对protoc的响应之前进行一些变更。这可能包括针对Go源代码运行gofmt或向所有生成的源文件添加版权标题。为了简化PG*中的此任务,可以使用PostProcessor。最小的PostProcessor实现可能如下所示:

// New returns a PostProcessor that adds a copyright comment to the top
// of all generated files.
func New(owner string) pgs.PostProcessor { return copyrightPostProcessor{owner} }

type copyrightPostProcessor struct {
  owner string
}

// Match returns true only for Custom and Generated files (including templates).
func (cpp copyrightPostProcessor) Match(a pgs.Artifact) bool {
  switch a := a.(type) {
  case pgs.GeneratorFile, pgs.GeneratorTemplateFile,
    pgs.CustomFile, pgs.CustomTemplateFile:
      return true
  default:
      return false
  }
}

// Process attaches the copyright header to the top of the input bytes
func (cpp copyrightPostProcessor) Process(in []byte) (out []byte, err error) {
  cmt := fmt.Sprintf("// Copyright © %d %s. All rights reserved\n",
    time.Now().Year(),
    cpp.owner)

  return append([]byte(cmt), in...), nil
}

copyrightPostProcessor结构通过实现MatchProcess方法来满足PostProcess接口。在PG*接收到所有工件后,每个工件依次交给每个注册处理器的Match方法。在上述情况下,如果文件是目标Artifact类型的一部分,则返回true。如果返回true,则立即使用文件的呈现内容调用Process。这个方法改变输入,如果出现问题,将修改后的值返回out或错误。上面,通知是在输入之前的。

PostProcessors在PG*注册,类似于模块:

g := pgs.Init(pgs.IncludeGo())
g.RegisterModule(some.NewModule())
g.RegisterPostProcessor(copyright.New("PG* Authors"))

Protocol Buffer AST

虽然protoc确保生成proto文件所需的所有依赖关系都以描述符的形式加载进来,但要由protoc-plugins来识别它们之间的关系。为了解决这个问题,PG* 使用构建一个所有装入插件的实体的抽象语法树(AST)。这个AST被提供给每个模块,以方便代码的生成。

Hierarchy 层次结构

由PG*收集器产生的层次结构是完全链接的,从顶级的包开始,一直到消息的每个单独的字段。AST可以用下面的数字图表示。

image-20221031224106501

一个包描述了在同一命名空间内加载的一组文件。正如预期的那样,一个文件代表一个单一的proto文件,它包含任何数量的Message、Enum或Service实体。一个Enum描述了一个基于整数的枚举类型,包含每个单独的EnumValue。一个服务描述了一组RPC方法,这些方法反过来又指代它们的输入和输出消息。

一个消息可以包含其他嵌套的消息和Enum,以及它的每个字段。对于非标量类型,一个字段也可以引用其消息或枚举类型。作为一种实现联合类型的机制,一个Message也可以包含OneOf实体,这些实体引用它的一些字段。

访问者模式 Visitor Pattern

AST的结构可能是相当复杂且不可预测的。同样,模块通常仅与图中的实体的一个子集有关。为了将模块的算法与理解和遍历AST的结构,PG*实现了访问者模式以使两者解耦。实现此接口非常简单,可以大大简化代码生成。

PG*提供了两个基本访问者结构,以简化开发实现。首先,nilvisitor返回一个实例,该实例可在所有实体类型中执行。当AST的某些分支对代码生成不感兴趣的时候,这很有用。例如,如果模块仅与服务有关,则可以将nilvisitor用作匿名字段,并且仅实现所需的接口方法:

// ServiceVisitor logs out each Method's name
type serviceVisitor struct {
  pgs.Visitor
  pgs.DebuggerCommon
}

func New(d pgs.DebuggerCommon) pgs.Visitor {
  return serviceVistor{
    Visitor:        pgs.NilVisitor(),
    DebuggerCommon: d,
  }
}

// Passthrough Packages, Files, and Services. All other methods can be
// ignored since Services can only live in Files and Files can only live in a
// Package.
func (v serviceVisitor) VisitPackage(pgs.Package) (pgs.Visitor, error) { return v, nil }
func (v serviceVisitor) VisitFile(pgs.File) (pgs.Visitor, error)       { return v, nil }
func (v serviceVisitor) VisitService(pgs.Service) (pgs.Visitor, error) { return v, nil }

// VisitMethod logs out ServiceName#MethodName for m.
func (v serviceVisitor) VisitMethod(m pgs.Method) (pgs.Vistitor, error) {
  v.Logf("%v#%v", m.Service().Name(), m.Name())
  return nil, nil
}

如果需要访问深度嵌套的节点,则可以使用PassThroughVisitor。与Nilvisitor不同,顾名思义,此实现通过所有节点,而不是在第一个未实现接口方法上进行短路。将此类型设置为一个匿名字段可能更为复杂,但避免了显式地实现接口的每种方法:

type fieldVisitor struct {
  pgs.Visitor
  pgs.DebuggerCommon
}

func New(d pgs.DebuggerCommon) pgs.Visitor {
  v := &fieldVisitor{DebuggerCommon: d}
  v.Visitor = pgs.PassThroughVisitor(v)
  return v
}

func (v *fieldVisitor) VisitField(f pgs.Field) (pgs.Visitor, error) {
  v.Logf("%v.%v", f.Message().Name(), f.Name())
  return nil, nil
}

通过任何visitor遍历AST都很简单:

v := visitor.New(d)
err := pgs.Walk(v, pkg)

所有实体类型和包都可以传递到Walk中,如果需要,允许启动低于顶级包的访问者。

构建上下文 Build Context

用PG* Generator注册的模块是用BuildContext的实例初始化的,该实例封装了与上下文有关的路径、调试和参数信息。

输出路径 Output Paths

BuildContext的OutputPath方法返回PG*插件的输出目标目录。该路径初始化值为.但指向执行ProtoC的执行目录。可以通过在标志中提供outper_path来覆盖此默认行为。

输出路径可用于使用JoinPath(name ...string)创建工件的文件名,这实际上是filepath.Join(ctx.OutputPath(), name...)的别名。手动跟踪输出路径的相对目录可能很乏味,尤其是在名称是动态的情况下。相反,BuildContext可以通过PushDirPopDir来管理它们。

ctx.OutputPath()                // foo
ctx.JoinPath("fizz", "buzz.go") // foo/fizz/buzz.go

ctx = ctx.PushDir("bar/baz")
ctx.OutputPath()                // foo/bar/baz
ctx.JoinPath("quux.go")         // foo/bar/baz/quux.go

ctx = ctx.PopDir()
ctx.OutputPath()                // foo

ModuleBase包装这些方法以突变其基本的BuildContexts。应该使用这些方法,而不是直接包含的BuildContext上的方法。

调试 Debugging

BuildContext公开了DebuggerCommon接口,该接口提供用于记录,错误检查和断言的实用程序。日志和格式化的LOGF打印消息到OS.Stderr,通常带有模块名称。debug和debugf的表现相同,但仅在通过debugmode或debugenv initoptions启用时打印。

FailFailf立即停止执行Protoc-Plugin,并导致ProtoC通过提供的消息失败。如果输入错误或表达式分别评估为false,则Checkerr和断言也会因提供的消息而失败。

可以通过在buildContext上调用推送和弹出来提供其他上下文前缀。此行为类似于PushDir和PopDir,但仅影响日志消息。ModuleBase包裹这些方法以突变其基本的构建版本。应该使用这些方法,而不是直接包含的buildContext上的方法。

参数 Parameters

BuildContext还提供了从指定的ProtoC标志中对预处理参数的访问。PG*唯一指定的参数是“ output_path”,模块的buildContext利用该参数来指定其输出路径。

pg*允许通过MutateParams InitOption突变参数。通过在此处传递ParamMutator函数,可以在PGG工作流开始之前修改或验证这些KV键值对。

特定语言的子包 Language-Specific Subpackages

虽然是用Go实现的,但PG力求在它能做的事情上与语言无关。因此,除了预先生成的基本描述符类型外,PG对protoc-gen-go(PGG)包没有依赖性。然而,每种语言的protoc-plugin所引入的许多细微差别是可以被概括的。例如,PGG包的命名、导入路径和输出路径是proto包名、go_package文件选项和传递给protoc的参数的复杂互动。虽然PG*的核心API不应该用许多特定语言的方法来重载,但可以提供子包,对参数和实体进行操作以得出适当的结果。

PG*目前实现了pgsgo子包,为针对Go语言的插件提供这些工具。未来的子包计划支持各种语言。

开始编写你的插件

一个简单的插件

延申

参考链接

使用protoc-gen-star 编写的插件

其他