A protoc plugin for generating versioned, forwards compatible gRPC services.
go install github.com/dane/protoc-gen-go-svc@latest
Services are sorted lexically by their Package names with the private service appended. All methods, messages, fields, oneofs, and enums assume they will map to an identical definition in the subsequent service. The latest version (by package name) passes messages to/from the private service.
(v2.CreateRequest) -> [v2.Create (v2.CreateRequest >> private.CreateRequest)] -> [private.Create]
(v2.CreateResponse) <- [v2.Create (v2.CreateResponse << private.CreateResponse)] <- [private.Create]
Older service versions (anything that isn't the latest) passes messages to/from the subsequent service in the chain, ultimatley reaching the private service. This form of chaining to the next version is to ensure forwards compatibility between service versions.
[v1.Create] -> [v2.Create] -> [v3.Create] -> [private.Create]
Deprecated RPCs are chained directly to the private service as they don't have a counterpart in later services. Deprecated fields are passed down the service chain through a concept called mutators. Mutators are functions that store a value and assign it to a private message field before the message is sent to the private service RPC.
The Update RPC is deprecated in the v1 service.
[v1.Create] -> [v2.Create] -> [v3.Create] -> [private.Create]
[v1.Update] -------------------------------> [private.Update]
The FirstName and LastName are deprecated in the v1 service.
v1.CreateRequest -> v2.CreateRequest -> v3.CreateRequest -> private.CreateRequest
FirstName -------------------------------------------------> FirstName
LastName --------------------------------------------------> LastName
FullName ---------> FullName ----------> FullName
Finer control, renaming or deprecating of fields, methods, etc. can be managed
with gen.svc options explained in the next section.
The following section describes all gen.svc options.
option (gen.svc.go_package) = "github.com/dane/protoc-gen-go-svc/example/proto/go/service;service";
The gen.svc.go_package option sets the import path for all generated services.
Each service version will be a subdirectory within the import path.
service
├── private
│ └── service.pb.go
├── service.pb.go
├── v1
│ ├── service.pb.go
│ └── testing
│ └── service.pb.go
└── v2
├── service.pb.go
└── testing
└── service.pb.go
rpc Update(UpdateRequest) returns (UpdateResponse) {
option (gen.svc.method).deprecated = true;
option (gen.svc.method).delegate = { name: "Set" };
}
The gen.svc.method option supports marking an RPC as deprecated and
identifying which RPC in the next service it should be delegated to. A deprecated
RPC will not be present in the following public service and will delegate
directly to the private service. A delegate name can be set between all service
RPCs, including the private service. Setting both deprecated and delegate = { name: "..." } will result in the RPC mapping directly to the private service
to an RPC stated in name.
The Create RPC is not deprecated, but the Update RPC is and delegated to the
Set RPC.
[v1.Create] -> [v2.Create] -> [private.Create]
[v1.Update] ----------------> [private.Set]
message UpdateRequest {
option (gen.svc.message).deprecated = true;
option (gen.svc.message).delegate = { name: "SetRequest" };
}
The gen.svc.message option supports marking a message as deprecated and
identifying which message in the next service it should be delegated to. A
deprecated message will not be present in the following public service and will
delegate directly to the private service. A delegate name can be set between all
service messages, including the private service. Setting both deprecated and
delegate = { name: "..." } will result in the message mapping directly to the
private service to a message stated in name.
The CreateRequest message is not deprecated, but the UpdateRequest message
is and delegated to the SetRequest message.
[v1.CreateRequest] -> [v2.CreateRequest] -> [private.CreateRequest]
[v1.UpdateRequest] -----------------------> [private.SetRequest]
string last_name = 1 [
(gen.svc.field).deprecated = true,
(gen.svc.field).delegate = { name: "Surname" },
(gen.svc.field).receive = { required: true },
(gen.svc.field).validate = {
required: true,
min: { int64: 2 },
max: { int64: 30 }
}
];
string email = 2 [
(gen.svc.field).validate = { is: EMAIL }
];
string website = 3 [
(gen.svc.field).validate = { is: URL }
];
string id = 4 [
(gen.svc.field).validate = { is: UUID }
];
string region = 5 [
(gen.svc.field).validate = { in: ["east", "west", "north", "south"] }
];
Employment employment = 6 [
(gen.svc.field).validate = { in: ["EMPLOYED", "UNEMPLOYED"] }
];
The gen.svc.field option supports a variety of input validations, name
delegating (identical to message and method delegating), deprecating, and
backwards compatibility enforcenment.
The (gen.svc.field).deprecated and (gen.svc.field).delegate options behave
identically to that of messages and methods.
The (gen.svc.field).receive option indicates the field must be populated from
the response of the next service in the chain otherwise the request will receive
a FailedPrecondition error. It is useful for deprecated fields to require a
receive value to ensure a resource created in a newer service version is either
compatible with the service being requested or the request is rejected.
The (gen.svc.field).validate option defines input validations. Method inputs
and nested messages can have validations. See the Validate message in
annotations.proto for a list of all possible validations.
oneof hobby {
option (gen.svc.oneof).validate = { required: true };
Biking biking = 1 [(gen.svc.field).delegate = { name: "cycling" }];
}
Protobufs consider a oneof to be different from a message field, but it
follows the same delegate, receive, and deprecated conventions as a message
field. (gen.svc.oneof).validate is limited to stating that a value must be
present. Additional validations must be set on each oneof message.
enum Employment {
option (gen.svc.enum).delegate = { name: "WorkStatus" };
// ...
}
enums only support (gen.svc.enum).delegate. Additional validations must be
set on the message field where the enum is used.
enum Employment {
EMPLOYED = 1 [
(gen.svc.enum_value).delegate = { name: "FULL_TIME" },
(gen.svc.enum_value).receive = { names: ["FULL_TIME", "PART_TIME"] },
];
}
enum values support (gen.svc.enum_value).delegate, just like the enum. It
also suports (gen.svc.enum_value).receive with a names property that is
unique to the enum value. The names provide a mapping of multiple enum
values that may populate this value. In a scenario where a newer service version
has expanded upon an enum value concept (eg: EMPLOYED to FULL_TIME and
PART_TIME), forwards compatibility is provided through the delegate option,
and backwards compatibility is provided through receive = { names: ["..."] }.
Annotate your proto files by including the gen/svc/annotations.proto file
and defining gen.svc options as needed. See the examples/proto directory
view the annotations in pratice.
protoc -I . \
--go-svc_opt=private_package={PRIVATE_PACKAGE_NAME} \
--go-svc_out={PATH_TO_DESTINATION} \
/path/to/proto/example/v1/service.proto \
/path/to/proto/example/v2/service.proto \
/path/to/proto/example/private/service.proto
After file generation, register the public services with your gRPC server and private service implementation.
package main
import (
// ...
"google.golang.org/grpc"
servicepb "github.com/dane/protoc-gen-go-svc/example/proto/go/service"
private "github.com/dane/protoc-gen-go-svc/example/service/private"
)
func main() {
// ...
privateImpl := &private.Service{}
srv := grpc.NewServer()
servicepb.RegisterServer(srv, privateImpl)
// ...
}
Validators are generated for all services, public and private. Converters are generated between services to convert Go structs from the v1 package to the v2 package and v2 to the private service structs, for example. Validators and converters can be overwritten by embedding them into a struct of your own and redefining the necessary method(s).
The example below modifies how a v1.CreateRequest is converted into a
v2.CreateRequest. The FirstName and LastName fields are deprecated in
favor of a FullName field. The Age field is introduced in v2.CreateRequest
so this is an opportunity to set a default value when create requests come
through the v2 service.
package v1
import (
"fmt"
publicv1 "github.com/dane/protoc-gen-go-svc/example/proto/go/service/v1"
publicpb "github.com/dane/protoc-gen-go-svc/example/proto/go/v1"
nextpb "github.com/dane/protoc-gen-go-svc/example/proto/go/v2"
)
type Converter struct {
publicv1.Converter
}
func (c Converter) ToNextCreateRequest(req *publicpb.CreateRequest) *nextpb.CreateRequest {
nextReq := c.Converter.ToNextCreateRequest(req)
nextReq.FullName = fmt.Sprintf("%s %s", req.FirstName, req.LastName)
nextReq.Age = 36
return nextReq
}
To use the custom Converter, pass it as argument to the RegisterServer
function.
import (
// ...
"google.golang.org/grpc"
servicev1 "github.com/dane/protoc-gen-go-svc/example/proto/go/service/v1"
servicepb "github.com/dane/protoc-gen-go-svc/example/proto/go/service"
private "github.com/dane/protoc-gen-go-svc/example/service/private"
overridev1 "github.com/dane/protoc-gen-go-svc/example/override/v1"
)
func main() {
// ...
converterV1 := overridev1.Converter{servicev1.NewConverter()}
privateImpl := &private.Service{}
srv := grpc.NewServer()
servicepb.RegisterServer(srv, privateImpl, converterV1)
// ...
}