Protobuf heeft eindelijk een language server. Wat contract-first gRPC in .NET daarmee wint

Buf leverde een volwassen LSP voor Protobuf. Dit is wat het verandert voor een .NET-team dat zijn C# al met Grpc.Tools genereert, waar het past in je build, en waar de aankondiging harder loopt dan de werkelijkheid.

Jean-Pierre Broeders

Freelance .NET Developer

17 augustus 202611 min. leestijd
Protobuf heeft eindelijk een language server. Wat contract-first gRPC in .NET daarmee wint

Buf leverde deze week iets waar Protobuf al zolang ik ermee werk op wachtte: een echte language server. Het haalde de voorpagina van Hacker News, en de reacties vielen precies zoals je verwacht in twee kampen uiteen. De helft zegt "eindelijk." De andere helft zegt "wacht, bestond dit nog niet?" Beide kloppen.

Protobuf zit overal. gRPC draagt een flink deel van het interne verkeer bij bedrijven die daar nooit over zullen bloggen. En toch werd het .proto-bestand, de ene bron waaruit al die services worden gegenereerd, jarenlang bewerkt met een plugin voor syntax highlighting en veel gokwerk. Je hernoemde een message en hoopte er het beste van. Je paste een field number aan en kwam er bij de review achter, of erger, tijdens runtime.

Ik schrijf .NET voor de kost, vooral backend services, en een aardig deel daarvan praat gRPC. Dus in plaats van weer een "kijk hoe leuk"-stukje loop ik door wat die language server echt verandert voor een .NET-team, waar hij past in een build die al op Grpc.Tools leunt, en waar ik vind dat de aankondiging zichzelf voorbijloopt.

Het .proto-bestand was de blinde vlek

Wat me nooit lekker zat, is dit. In een .NET gRPC-project wordt de C# gegenereerd. Je voegt een Protobuf-item toe aan je .csproj, Grpc.Tools draait de compiler tijdens de build, en eruit rolt een stapel C# met volledige IntelliSense. Hernoem een gegenereerde Order-klasse in je editor en Roslyn loopt de hele solution voor je na.

Maar het bestand dat dat allemaal produceerde, kreeg niets van dat alles. De .proto was een tweederangsburger in zijn eigen repo. Geen go-to-definition als je een ander bestand importeerde. Geen waarschuwing als je een fieldnaam typte die niet op de message stond waar je naar verwees. Geen hint dat field number 3 drie regels hoger al bezet was. De compiler ving het uiteindelijk wel, maar "uiteindelijk" betekende een gefaalde build met een regelnummer, niet een rood kringeltje terwijl je typt.

Dat gat wordt groter naarmate je API-oppervlak groeit. Een speelgoedservice met vijf messages bewerk je prima op de tast. Een gedeeld schema met veertig messages, geneste imports en drie teams die eraan zitten niet. En daar zit nou juist de meeste gRPC-pijn, precies waar de tooling het zwakst was.

Wat de language server doet

De LSP van Buf spreekt het standaard Language Server Protocol, dus hij klikt vast in VS Code, Neovim en alles wat verder LSP praat. De featurelijst is wat je van een fatsoenlijke language server wilt, voor het eerst op Protobuf toegepast in een vorm die standhoudt:

  • Go to definition. Ctrl-klik een messagetype of een geïmporteerd symbool en je landt op de declaratie, over bestanden heen.
  • Code completion. Begin een fieldtype te typen en je krijgt de messages, enums en well-known types die echt in scope zijn.
  • Find references. Zie elke plek waar een message of enum gebruikt wordt voor je hem hernoemt.
  • Highlighting die de semantiek kent. Geen regex-kleuring. De editor weet dat een token een type is, een field of een keyword.
  • Diagnostics terwijl je typt. Dubbele field numbers, onbekende types en andere structurele fouten komen meteen boven in plaats van tijdens de build.

Buf is ook eerlijk over wat nog op de roadmap staat: automatische importsuggesties, completion voor custom options en Protovalidate-integratie met CEL-highlighting. Die laatste telt als je op het schema valideert in plaats van in handgeschreven C#, en waarom dat een goed idee is kom ik zo op terug.

De compiler eronder is het echte verhaal

De features zijn fijn. De reden dat je ze kunt vertrouwen, is de motor waarop Buf ze bouwde, en dat deel zou ik niet overslaan.

De meeste Protobuf-tooling leunde van oudsher op protoc en het FileDescriptorProto-model dat daaruit komt. Dat model is gemaakt voor code-generatie, niet voor editors. Het gooit de precieze bronposities weg die een editor nodig heeft om de juiste drie tekens te onderstrepen. Buf gebruikt in plaats daarvan hun eigen protocompile-frontend met een custom AST, en beschrijft die als een "query-driven frontend which enables incremental compilation and much better diagnostics."

Query-driven en incremental is de zinsnede die telt. Het betekent dat de server alleen het deel van het schema herberekent dat je aanraakte, in plaats van bij elke toetsaanslag de hele wereld opnieuw te parsen. Dat is het verschil tussen een language server die instant aanvoelt en een die je na een dag uitzet omdat hij hapert. Heb je ooit met een trage plugin op een grote repo gevochten, dan weet je aan welke kant van die grens beslist wordt of een tool je setup overleeft.

Aan de praat krijgen

De installatie is saai, en dat is een compliment.

In VS Code installeer je de Buf-extensie uit de marketplace. Die detecteert de Buf CLI op je machine en start de server. In Neovim installeer je de Buf CLI en koppel je hem via nvim-lspconfig, of je wijst je config direct naar het commando:

buf lsp serve

Elke andere editor met LSP-ondersteuning werkt hetzelfde: registreer een server waarvan het commando buf lsp serve is. De server leest je module-indeling uit buf.yaml, dus hij resolvet imports precies zoals je builds en CI dat doen. Dat is het punt. Eén bron van waarheid voor waar bestanden staan en welke regels gelden:

version: v2
modules:
  - path: proto
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE

Waar dit past in een .NET-build

Dan de vraag die een .NET-lezer echt stelt: vervangt dit Grpc.Tools? Nee. En dat onderscheid is het hele punt.

De language server en de linter werken op het schema. Ze helpen je correcte .proto te schrijven en correct te houden. Je C# komt nog steeds uit Grpc.Tools tijdens de build, gestuurd door dezelfde Protobuf-items die je al hebt:

<ItemGroup>
  <PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
</ItemGroup>

<ItemGroup>
  <Protobuf Include="proto/acme/orders/v1/order.proto" GrpcServices="Server" />
</ItemGroup>

Je kunt C# ook via buf generate maken, en Buf host een protocolbuffers/csharp-plugin op hun registry voor de messagetypes. Maar de C# gRPC-service-stubs, de baseklassen waar je daadwerkelijk van erft, hadden nooit een eersteklas plek in Bufs publieke remote-plugincatalogus zoals de Go- en Java-stubs die wel hebben. Voor de meeste .NET-teams betekent dat een verstandige splitsing: houd MSBuild en Grpc.Tools voor codegen, want dat zit al vast aan je build, je Rider- en VS-tooling en je NuGet-verhaal. Neem de LSP, buf lint en buf breaking erbij als de laag voor authoring en governance daarbovenop.

Dit is het soort schema dat die laag eerlijk houdt:

syntax = "proto3";

package acme.orders.v1;

import "google/protobuf/timestamp.proto";

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);
  rpc ListOrders(ListOrdersRequest) returns (stream Order);
}

message GetOrderRequest {
  string order_id = 1;
}

message Order {
  string id = 1;
  string customer_id = 2;
  OrderStatus status = 3;
  google.protobuf.Timestamp created_at = 4;
}

enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;
  ORDER_STATUS_PENDING = 1;
  ORDER_STATUS_PAID = 2;
  ORDER_STATUS_CANCELLED = 3;
}

En de C# die je ertegen schrijft blijft klein, want het contract doet het zware werk:

public sealed class OrderServiceImpl : OrderService.OrderServiceBase
{
    private readonly IOrderStore _store;

    public OrderServiceImpl(IOrderStore store) => _store = store;

    public override async Task<Order> GetOrder(
        GetOrderRequest request,
        ServerCallContext context)
    {
        var order = await _store.FindAsync(request.OrderId, context.CancellationToken);
        if (order is null)
        {
            throw new RpcException(
                new Status(StatusCode.NotFound, $"order {request.OrderId} niet gevonden"));
        }

        return order;
    }
}

Lint en breaking-change-detectie verdienen hun plek

De LSP haalt de koppen, maar voor een team zijn de twee saaie commando's ernaast meer waard.

buf lint dwingt de conventies af die een schema prettig maken om te consumeren: enum-waarden met de enumnaam als prefix, die verplichte nulwaarde, packageversionering als v1. Kijk naar de ORDER_STATUS_UNSPECIFIED = 0 in de enum hierboven. Dat is niet mij die netjes doet, dat is een regel, en de linter laat de build falen als je hem vergeet. Klein ding, maar het is het verschil tussen een schema dat een nieuw team kan lezen en een dat wratten kweekt die niemand meer durft weg te halen.

buf breaking is degene die ik niet zonder zou draaien op een gedeelde API. Hij vergelijkt je branch met het gecommitte schema en faalt als je een field weghaalde, hernoemde of het type zo veranderde dat het wire-formaat breekt. In een wire-protocol zijn die fouten stil en duur. De consumer krijgt geen compileerfout. Die krijgt een field dat stilletjes naar zijn default deserialiseert en drie weken later een supportticket. Het in de pull request vangen is het hele spel.

Hang het aan GitHub Actions en het draait op elke PR:

name: proto
on: pull_request
jobs:
  buf:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: bufbuild/buf-action@v1
        with:
          lint: true
          breaking: true
          breaking_against: "https://github.com/${{ github.repository }}.git#branch=main"

Dat is veel veiligheid voor tien regels YAML, en niets ervan hangt aan de editor-tooling. Je krijgt de waarde ook voor de developer die de LSP nooit installeert.

De eerlijke afweging

Buf is een bedrijf, en Buf wil je op hun registry. Niks mis mee, maar wees helder over welke stukken je vastzetten.

De delen die ik zonder aarzelen zou nemen, zijn de lokale. buf.yaml, buf lint, buf breaking en de LSP draaien allemaal via de CLI tegen bestanden in je repo. Trek Buf er morgen uit en je houdt gewone .proto-bestanden over die protoc en Grpc.Tools compileren. Geen lock-in daar.

Het deel om over na te denken zijn remote plugins en de Buf Schema Registry in je buildpad. Code genereren uit een gehoste plugin is handig, tot de middag dat de registry een slechte dag heeft en je build hem niet kan resolven. Ga je die kant op, pin dan pluginversies en houd een lokale fallback. Voor een .NET-winkel is dat sowieso geen echte verleiding, want Grpc.Tools bezit de codegen al en er is geen reden om die te verplaatsen. Neem de laag voor authoring en governance, want dat is het echt nieuwe hier, en laat je build staan waar hij staat.

Veelgestelde vragen over contract-first gRPC in .NET

Vervangt de Buf language server Grpc.Tools in mijn .NET-build? Nee, ze lossen verschillende problemen op. De LSP en linter werken op de .proto-bron om je te helpen die te schrijven en te reviewen, terwijl Grpc.Tools de C# eruit genereert tijdens de build. Je draait beide, en geen van beide weet of geeft om de ander.

Kan ik C# met buf generate maken in plaats van MSBuild? Je kunt de messagetypes genereren via Bufs protocolbuffers/csharp-plugin, maar de C# gRPC-service-baseklassen hadden nooit een eersteklas remote plugin zoals Go en Java die wel hebben. Voor de meeste .NET-teams is de pragmatische keuze om codegen in Grpc.Tools te houden en Buf alleen voor de schemalaag te gebruiken.

Is buf lint de moeite als mijn proto-bestand klein is? Zelfs bij een klein bestand dwingt hij de conventies af die later pijn doen, zoals de verplichte nulwaarde van een enum en consistente packageversionering. Draaien kost bijna niets en het voorkomt dat een service met vijf messages stilletjes uitgroeit tot een inconsistente met veertig.

Heb ik een Buf Schema Registry-account nodig voor de language server? Nee. De LSP, buf lint en buf breaking draaien allemaal lokaal vanuit de CLI tegen bestanden in je repo. De registry komt pas in beeld als je remote plugins wilt gebruiken of je schema wilt publiceren, en elke lokale feature kun je gebruiken zonder hem aan te raken.

Waarom telt buf breaking zwaarder dan een gewone code review? Een breaking change op een wire-protocol is stil. Haal een field weg of hernummer het en de consumer faalt niet bij het compileren, hij deserialiseert het ontbrekende field naar zijn default en gedraagt zich later fout. buf breaking vergelijkt met het gecommitte schema en laat de PR falen, en dat is de enige goedkope plek om het te vangen.

Verder lezen


Conclusie: Behandel de Protobuf-LSP als de ontbrekende authoring-laag voor je schema, niet als reden om C#-generatie van Grpc.Tools af te halen. De editor-intelligentie is welkom, maar de echte winst voor een .NET-team is dat buf lint en buf breaking nu bovenop een .proto zitten die je eindelijk met vertrouwen kunt bewerken.

Bronnen: Bufs aankondiging van de Protobuf-LSP · Hacker News-discussie.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op