带有GO的原始插件
#go #grpc #protobuf #protoco

在本文中,我们查看协议缓冲区(Protobufs),并在GO编程的背景下探索Protoc插件的复杂工作。侧重于揭开ProtoC插件及其在代码生成中的作用,我们概述了使用实用片段的插件的内部工作。在整篇文章中,我们将介绍并查看关键概念,例如Protobuf编译器插件API,GO S插件包以及将自定义插件集成到ProtoC编译管道中。因此,如果您发现自己在使用协议缓冲器(就像我一样)时要努力应对复杂情况,或者只是有兴趣利用GO中的自定义ProtoC插件的力量,请加入我的旅程协议缓冲区的表面在一起。

Protoc Plugins With Go

介绍

协议缓冲区(Protobuf)已成为现代软件开发世界中服务序列化和通信的流行选择。 Protobuf凭借其语言不足的性质,效率和易用性,成为了构建数据互换格式的首选解决方案。

Protobufs提供了一组广泛的功能来定义和处理消息,但有时开发人员遇到需要其他自定义的复杂方案。我个人面临的一种情况是,当我试图处理数据上的逻辑和Domian特定约束时。在原始文件或宽松惯例之外,在原始文件中无法强制或什至没有提及的东西。

通过开发自定义的ProtoC插件,我们可以扩展Protobuf编译器(ProtoC)的功能,以自动生成代码,从而使使用原始消息更直观和高效。我们将讨论创建此类插件的各种组件,包括Protobuf编译器插件API,GO S插件包以及将插件集成到Protobuf编译过程中的必要步骤。

免责声明:我倾向于每天犯很多错误,因此,如果您是遇到的众多错误之一,我会很感激您是否会在评论中纠正我。

想象这个问题

以我的拙见,类型安全虽然有时迫使您尊重某些界限,但可能具有很大的价值,尤其是如果您试图构建可扩展,可维护,可理解和易于开发的软件时。当然,这可能是一段时间以后的另一个详细(也许是有争议的)讨论的话题,但是就目前而言,让我们暂时想象这个事实是正确的,我们在同一页面上。

考虑以下消息:

    syntax = "proto3";

    import "google/protobuf/struct.proto";

    message Game {
      google.protobuf.Struct data = 1;
    }

上面的数据字段显然不是类型的安全性,并且可能包含从简单整数到多个复杂嵌套结构的任何内容。序列化为JSON,它可能会变成这样的东西:

    {
      "data": {
        "title": "Elden Ring",
        "description": "Third-person, action, RPG with a focus on combat and exploration",
        "developer": {
          "name": "FromSoftware Inc.",
        }
        "category": "role-playing",
        "number_of_bosses": 18,
      }
    }

让我们说我们检查并分析了数据库中的每个保存的游戏对象,并收集了足够的信息以将此数据结构组织到类似的内容:

    message Game {
      string Title = 1;
      string Description = 2;
      message Developer {
        string Name = 1;
      }
      Developer developer = 3;
      enum Category {
        UNKNOWN = 0;
        ROLE_PLAYING = 1;
        SHOOTING = 2;
      }
      Category category = 4;

      message RolePlayingExtra {
        int32 number_of_bosses = 1;
      }
      message ShootingExtra {imagine
        int32 number_of_guns = 2;
      }

      oneof extra {
        RolePlayingExtra role_playing_extra = 5;
        ShootingExtra shooting_extra = 6;
      }
    }

作为特定领域的逻辑,让我们说我们需要确保每个类别(例如cole_playing),只能填充其各自的额外(例如roleplayingextra)。请注意,额外的消息不会是混凝土类型(至少在GO中),因为它可以包含不同类型的字段(例如Roleplayingextra和shoothExtra)。让我们看一下go代码的翻译:

    type Game struct {
     ...
     // Types that are assignable to Extra:
     //
     // *Game_RolePlayingExtra_
     // *Game_ShootingExtra_
     Extra isGame_Extra `protobuf_oneof:"extra"`
    }

    type isGame_Extra interface {
     isGame_Extra()
    }

    type Game_RolePlayingExtra_ struct {
     RolePlayingExtra *Game_RolePlayingExtra `protobuf:"bytes,5,opt,name=role_playing_extra,json=rolePlayingExtra,proto3,oneof"`
    }

    type Game_ShootingExtra_ struct {
     ShootingExtra *Game_ShootingExtra `protobuf:"bytes,6,opt,name=shooting_extra,json=shootingExtra,proto3,oneof"`
    }

    func (*Game_RolePlayingExtra_) isGame_Extra() {}

    func (*Game_ShootingExtra_) isGame_Extra() {}

额外的字段是类型的isgame_extra,它是由game_roleplyaingextra_和game_shootingextra_ structs实现的接口,应根据要求将其施加到所需的混凝土类型上。 Protoc-gen-Go实际上在处理这种类型的演员方面做得很好:

    func (x *Game) GetRolePlayingExtra() *Game_RolePlayingExtra {
     if x, ok := x.GetExtra().(*Game_RolePlayingExtra_); ok {
      return x.RolePlayingExtra
     }
     return nil
    }

    func (x *Game) GetShootingExtra() *Game_ShootingExtra {
     if x, ok := x.GetExtra().(*Game_ShootingExtra_); ok {
      return x.ShootingExtra
     }
     return nil
    }

但是,我们在这里有两个问题:

  1. 对于任何给定的游戏结构,为了设置额外的字段之一(例如,数字枪),我们必须照顾自己的铸造类型,这没什么大不了的,但可能会变得笨拙,并且会增加混乱在我们的代码中。

  2. Protobuf本身并不能保证类别和额外的领域相应地及其关系。这意味着我们可以从技术上进行一款游戏,而类别正在拍摄,而额外的是roleplayingextra。如果无法正确处理,这可能会在我们的数据中导致不一致和错误。

考虑上述问题,让我们看看自定义的ProtoC插件如何帮助我们克服它们。

原始插件

让我们看一下Protoc-gen-go如何生成.pb.go文件。第一步是将插件用作ProtoC(Protobuf编译器)的参数:

    protoc --go_out=. --go_opt=paths=source_relative game.proto

所以-go_out =。参数将告诉原始protoc-gen-go插件应调用。请注意,-go_opt = ...仅将一些可选的参数传递给可能影响生成行为的Protoc-Gen-Go。为了具有任何类型的ProtoC插件(既有官方插件,例如Protoc-gen-go或任何私人或自定义插件),它需要满足2个要求:

  • 可执行文件应放在您的$路径中的某个地方。

  • 它应该由图案protoc-gen-。

  • 命名
  • 因此可以像这样说:

    protoc --<MY_PLUGIN>_out=. --<MY_PLUGIN>_opt=... some.proto

现在让我们看一下protoc-gen-go插件的source code

    package main 

    import (
      "github.com/golang/protobuf/internal/gengogrpc"
      "google.golang.org/protobuf/compiler/protogen"
    )

    func main() {
     ...
     protogen.Options{...}.Run(func(gen *protogen.Plugin) error {
      ...
      for _, f := range gen.Files {
       ...
       g := gengo.GenerateFile(gen, f)
       ...
      }
      ...
      return nil
     })
    }

用省略号填充的线表示目前对我们并不重要的其他信息。让我们看看我们拥有的东西:

  • protogen.options {}:一个为Protoc插件提供配置选项的结构。它允许您自定义插件的行为,并指定与代码生成相关的各种设置。如果要使用标志作为您的选项,这是示例片段:
    var (
      flags flag.FlagSet
      myOpt = flags.String("myopt", "", "my random option")
     )
     protogen.Options{ParamFunc: flags.Set}.Run(func(gen *protogen.Plugin) error {...})

这些标志可以像这样传递到我们的插件中:

    protoc --<MY_PLUGIN>_out=. --<MY_PLUGIN>_opt=myopt=something some.proto
  • 运行(func(gen *protogen.plugin)错误):执行一个函数作为原始插件。它从OS.STDIN读取CodeGeneratorRequest消息,调用插件函数,并将CodeGeneratorSponse消息写入OS.STDOUT。如果在阅读或写作时发生故障,请运行将错误打印到OS.STDERR并调用OS.EXIT(1)。

  • 最后,我们迭代命令行中指定的文件并将其传递给gogen.generateFile,我们将负责该过程的其余部分(即创建实际的输出文件并将自动化内容放入其中)) 。

让我们看一下gogen.generateFile函数:

    // GenerateFile generates the contents of a .pb.go file.
    func GenerateFile(gen *protogen.Plugin, file *protogen.File) *protogen.GeneratedFile {
     filename := file.GeneratedFilenamePrefix + ".pb.go"
     g := gen.NewGeneratedFile(filename, file.GoImportPath)

     ...

     g.P(packageDoc, "package ", f.GoPackageName)
     g.P()

     ...

     genImport(gen, g, f, imps.Get(i))
     genEnum(g, f, enum)
     genMessage(g, f, message)
     genExtensions(g, f)
     genReflectFileDescriptor(gen, g, f)

     ...

     return g
    }

我们可以通过传递所需的文件名和原始文件中定义的GoimportPath来创建尽可能多的新文件。这将为我们提供 *protogen.generatedFile的实例,其超方便函数p()。让我们仔细研究一下:

    // P prints a line to the generated output. It converts each parameter to a
    // string following the same rules as fmt.Print. It never inserts spaces
    // between parameters.
    func (g *GeneratedFile) P(v ...interface{}) {
     for _, x := range v {
      switch x := x.(type) {
      case GoIdent:
       fmt.Fprint(&g.buf, g.QualifiedGoIdent(x))
      default:
       fmt.Fprint(&g.buf, x)
      }
     }
     fmt.Fprintln(&g.buf)
    }

简单地说,p()在输入上迭代,并试图将其导出到生成的文件中,以适当的特定于GO特定的凹痕,因此我们可以轻松地编写我们想要的任何合法的GO语句或声明,无论如何凹痕或领先的标签/空格。

generateFile()函数中介绍的其他行只是照顾我们的原始文件的不同组件(例如,消息,枚举等)

现在,我们对ProtoC插件的工作原理有了基本的了解,现在该看看我们如何利用这些工具来编写自己的自定义插件。

原始自定义插件

您可以在我的github repo中找到完整的代码:https://github.com/HomayoonAlimohammadi/protoc-gen-gamedata

让我们尽可能简单,这是一个主要。

    package main

    import (
     "github.com/HomayoonAlimohammadi/protoc-gen-gamedata/gamedata"
     "google.golang.org/protobuf/compiler/protogen"
    )

    func main() {
     protogen.Options{}.Run(func(p *protogen.Plugin) error {
      return gamedata.Generate(p)
     })
    }

它调用Gamedata软件包的生成函数,如您所见,该函数确实很简单且最少:

    func Generate(p *protogen.Plugin) error {
     g := p.NewGeneratedFile("autogen/gamedata.autogen.go", protogen.GoImportPath("gamedata"))
     g.P("// Code generated by gamedata. DO NOT EDIT")
     g.P("package gamedata")

     game, err := extract(p, g)
     if err != nil {
      return err
     }

     genHelpers(g, game)

     return nil
    }

在Autogen相对目录中创建新文件后,我们只是提出了一个注释,表明它是自动生成的,即包含包名称。

中间有此提取功能,我们将在此稍后讨论,但是在此之前,GenHelpers实际上将在我们的gamedata.autogen.go文件中生成GO代码。它负责生成帮助我们克服最初挑战的功能(根据类别字段设置额外的字段,考虑到自动类型)。

正如您从上面的代码中推断出的那样,提取功能试图总结并返回有关我们的游戏消息的信息,如下所示:

    type Game struct {
     Fields     []Field
     CatToExtra map[string]Extra
    }

    type Extra struct {
     Name string
     Fields []Field
    }

    type Field struct {
     Name string
     Type string
    }

    func extract(p *protogen.Plugin, g *protogen.GeneratedFile) (*Game, error) {
     for _, f := range p.Files {
      for _, m := range f.Messages {
       if m.Desc.Name() == "Game" {
        return extractGameData(m, g)
       }
      }
     }

     return nil, errors.New("failed to find `Game` message")
    }

从所有可用文件(p.files,这里只有一个)和消息(f.Messages,再次仅一个)中找到游戏消息后,它将组件转化为游戏结构。请注意,您可以使用相应的 *protogen.field, *protogen.message等上的desc()方法访问fieldDescriptor,MessagedScriptor,EnumdeScriptor等。

    func extractGameData(m *protogen.Message, g *protogen.GeneratedFile) (*Game, error) {
     game := &Game{CatToExtra: make(map[string]Extra)}

     prefillCategories(m, game)

     for _, f := range m.Fields {
      if f.Desc.ContainingOneof() != nil && f.Desc.ContainingOneof().Name() == "extra" {
       err := fillExtras(g, f, game)
       if err != nil {
        return nil, fmt.Errorf("failed to fill extra: %w", err)
       }
      }

      game.Fields = append(
       game.Fields,
       Field{
        Name: string(f.Desc.Name()),
        Type: fieldGoType(g, f),
       },
      )
     }

     return game, nil
    }

让我们分解此功能,以便我们更好地了解其工作原理:

  • 首先,我们预填充类别,这将稍后便捷。
    func prefillCategories(m *protogen.Message, game *Game) {
     for _, e := range m.Enums {
      if string(e.Desc.Name()) == "Category" {
       for _, v := range e.Values {
        if string(v.Desc.Name()) == "UNKNOWN" {
         continue
        }

        game.CatToExtra[string(v.Desc.Name())] = Extra{}
       }
      }
     }
    }
  • 我们迭代游戏消息的字段

  • 如果我们遇到了额外的Oneof中包含的字段,我们会尝试找到其各自的类别并填写CattoExtra:

    func fillExtras(g *protogen.GeneratedFile, f *protogen.Field, game *Game) error {
     if !strings.HasSuffix(strings.ToLower(string(f.Desc.Name())), "_extra") {
      return fmt.Errorf("extra message %s does not end with _extra", f.Desc.Name())
     }

     name := strings.TrimSuffix(strings.ToLower(string(f.Desc.Name())), "_extra")

     var fields []Field
     for _, innerf := range f.Message.Fields {
      fields = append(fields, Field{Name: string(innerf.Desc.Name()), Type: fieldGoType(g, innerf)})
     }

     _, ok := game.CatToExtra[strings.ToUpper(name)]
     if !ok {
      return fmt.Errorf("no category available for `%s`", strings.ToUpper(name))
     }

     game.CatToExtra[strings.ToUpper(name)] = Extra{
      Name:   string(f.Desc.Name()),
      Fields: fields,
     }

     return nil
    }

根据我们的域逻辑要求,我们还确保所有额外的字段都以_EXTRA后缀结束,并且也没有可用的额外消息(悬挂在悬挂的类别中,就像没有与该额外的类别相关的类别)。 /p>

唯一剩下的就是实施逻辑来创建我们需要保持数据一致性和正确性所需的帮助人功能。由于这确实不会有助于了解Protobuf,所以我不会在这里为此编写代码,但是我确定您可以轻松地弄清楚它的完成方式。

提示:如果/else语句和g.p()

,它基本上是一堆迭代

为了查看上面的代码给我们的内容,让我们快速将其序列化到JSON并查看结果:

    b, _ := json.MarshalIndent(game, "", "\t")
    fmt.Println(string(b))
    {
     "Fields": [
      {
       "Name": "Title",
       "Type": "string"
      },
      {
       "Name": "Description",
       "Type": "string"
      },
      {
       "Name": "developer",
       "Type": "*game.Game_Developer"
      },
      {
       "Name": "category",
       "Type": "game.Game_Category"
      },
      {
       "Name": "role_playing_extra",
       "Type": "*game.Game_RolePlayingExtra"
      },
      {
       "Name": "shooting_extra",
       "Type": "*game.Game_ShootingExtra"
      }
     ],
     "CatToExtra": {
      "ROLE_PLAYING": {
       "Name": "role_playing_extra",
       "Fields": [
        {
         "Name": "number_of_bosses",
         "Type": "int32"
        }
       ]
      },
      "SHOOTING": {
       "Name": "shooting_extra",
       "Fields": [
        {
         "Name": "number_of_guns",
         "Type": "int32"
        }
       ]
      }
     }
    }

最后,我们必须构建插件并将其放在我们的$路径中,以便可以通过Protoc进行调用:

    go build -o $(GOPATH)/bin/protoc-gen-gamedata main.go
    protoc --gamedata_out=. game.proto

包起来

总而言之,我们在GO中探索了Protoc插件的迷人世界,并演示了如何编写自定义插件以增强协议缓冲区中的代码生成。通过揭示ProtoC插件的复杂性并提供实际示例,希望您可能有一个很好的了解这些有趣的插件如何工作以及如何编写自己的插件。不用说您的评论,建议和想法以及您的整体印象对我来说非常重要。