Schema analysis tools
In vanilla protobuf/gRPC usage, protoc
is the tool used to parse .proto schemas for code-generation; protobuf-net provides managed tools that provide additional schema analysis tools,
via the protobuf-net.Reflection package.
For example, let’s consider the TimeService.proto
from the protobuf-net.Grpc examples. At the time
of writing, this file contains:
syntax = "proto3";
package MegaCorp;
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
option csharp_namespace = "MegaCorp";
message TimeResult {
.google.protobuf.Timestamp Time = 1;
}
service TimeService {
rpc Subscribe(.google.protobuf.Empty) returns (stream TimeResult);
}
This generates code including:
static readonly string __ServiceName = "MegaCorp.TimeService";
...
static readonly grpc::Method<global::Google.Protobuf.WellKnownTypes.Empty, global::MegaCorp.TimeResult> __Method_Subscribe = new grpc::Method<global::Google.Protobuf.WellKnownTypes.Empty, global::MegaCorp.TimeResult>(
grpc::MethodType.ServerStreaming,
__ServiceName,
"Subscribe",
__Marshaller_google_protobuf_Empty,
__Marshaller_MegaCorp_TimeResult);
This means that the TimeService.Subscribe
method corresponds to the HTTP route: /MegaCorp.TimeService/Subscribe
, but we’ve had to look at the generated code. So: can we do this from
the schema directly? We can with protobuf-net.Reflection!
Parsing a schema
The key type here is FileDescriptorSet
, which represents a composite parse operation. In particular, note that we aren’t parsing a single file - the use of import
means that multiple
files can be involved. In this case, these are all “standard” Google schemas, but this could also be other user schemas. With a FileDescriptorSet
, we can add multiple files (although it is
very common to only add one file manually); this can be loaded from the file-system, or the file contents can be provided via a TextReader
. In advanced scenarios (for
increased isolation, usually), a custom virtual file-system can be provided via the .FileSystem
property. To load files, one or more folder paths (physical or virtual) must be
provided; then when adding individual files, these folders are checked in order. For example:
FileDescriptorSet schemaSet = new();
schemaSet.AddImportPath(@"C:\Work\protobuf-net.Grpc\examples\grpc\Shared");
schemaSet.Add("TimeService.proto");
schemaSet.Process();
var errors = schemaSet.GetErrors();
foreach (var error in errors)
{
Console.WriteLine($"{(error.IsWarning ? "warning" : "error")} {error.ErrorNumber}: {error.File}#{error.LineNumber}: {error.Message}");
}
This uses the protobuf-net.Grpc source folder, and adds the TimeService.proto
file. When we call .Process()
, all files added are parsed, which can mean loading additional files.
In this case, we will get the additional files from google/protobuf/empty.proto
and google/protobuf/timestamp.proto
. You might observe that those imported files don’t actually exist on
the file system; protobuf-net.Reflection has many standard imports embedded directly: if no file is located in the available file systems, these resources are checked too.
Once we have parsed the schema (assuming there are no errors), each file has a .Services
collection, each service has a .Methods
collection, which we can iterate:
foreach (var file in schemaSet.Files)
{
Console.WriteLine($"{file.Name}: {file.Services.Count} services");
// only inspect services for files that were added explicitly
// (rather than implicitly via imports)
if (file.IncludeInOutput)
{
Console.WriteLine($"package: '{file.Package}'");
foreach (var service in file.Services)
{
Console.WriteLine($"service: '{service.Name}'; {service.Methods.Count} methods");
foreach (var method in service.Methods)
{
Console.WriteLine($"> method: {method.Name}; CS: {method.ClientStreaming}, SS: {method.ServerStreaming}");
Console.WriteLine($" ({GetMethodType(method.ClientStreaming, method.ServerStreaming)}; {method.InputType}; {method.OutputType})");
}
}
}
static MethodType GetMethodType(bool clientStreaming, bool serverStreaming)
=> clientStreaming
? serverStreaming ? MethodType.DuplexStreaming : MethodType.ClientStreaming
: serverStreaming ? MethodType.ServerStreaming : MethodType.Unary;
}
This gives us the output:
TimeService.proto: 1 services
package: 'MegaCorp'
service: 'TimeService'; 1 methods
> method: Subscribe; CS: False, SS: True
(ServerStreaming; .google.protobuf.Empty; .MegaCorp.TimeResult)
google/protobuf/empty.proto: 0 services
google/protobuf/timestamp.proto: 0 services
We can then roughly construct the final URL via:
static string GetUri(string package, string service, string method)
{
if (string.IsNullOrWhiteSpace(package))
{
return "/" + service.TrimStart('.') + "/" + method;
}
return "/" + package + "." + service.TrimStart('.') + "/" + method;
}
(noting that the package name is optional)
The same inspections apply to all the message/enum types in the schema - everything is available.