4.0 KiB
Migrating from Protocol Buffers + gRPC to ZAP
This is the guide we wrote while moving a 450k-line distributed storage system (Hanzo S3, a SeaweedFS fork) off protobuf and gRPC. It is opinionated and battle-tested.
Why
- No encode step. A protobuf message is a Go struct you
Marshalto bytes andUnmarshalback. A ZAP message is the bytes —New<Msg>(...)builds the wire buffer directly;Wrap<Msg>(buf)reads fields in place. Build once, read many. Zero copy. - No gRPC. RPC rides
transport— TCP, Unix, or QUIC, all PQ-secured (X25519MLKEM768). OneConn/Dispatchcontract; the generated client takes the*Conndirectly. - Post-quantum by default, pure Go, no liboqs, no CGO in the core.
The model — three layers, decomplected
schema (.zap) ──zapgen──▶ <msg>_zap.go // build/read, zero-copy
rpc (envelopes) rpc.Call/Response // promise pipelining
transport(connections) transport.Conn // tcp/unix/quic + PQ
Each layer is one concern. A message knows nothing about transport; the transport carries opaque envelopes; the rpc layer correlates them.
The toolchain
.proto ──[pb2zap -emit zap]──▶ .zap ──[zapgen]──▶ _zap.go
(one-time bootstrap) (canonical source) (generated)
- pb2zap bootstraps native
.zapschemas from your existing.proto(then delete the.proto—.zapis the source of truth), and rewrites Go call sites off the*_pbpackages. - zapgen generates the zero-copy
_zap.gofrom.zap.
The cut pattern (per service)
The mechanical shape — copy it everywhere:
// client: pb.NewFooClient(grpcConn) -> transport.Dial + foowire client
conn, _ := transport.Dial("unix", sock) // or DialTLS(addr, PQTLSConfig(cfg))
client := foowire.NewFooClient(conn, nil)
_, body, _ := client.Bar(foowire.NewBarRequest(foowire.BarRequestInput{X: v}))
resp, _ := foowire.WrapBarResponse(body)
use(resp.Y()) // accessor, not resp.Y
// server: pb.RegisterFooServer(grpcS, impl) -> transport.Serve + DispatchFoo
transport.Serve(listener, func(env []byte) ([]byte, error) {
return foowire.DispatchFoo(handler, env)
})
// handler: (ctx, *pb.BarRequest) -> (*pb.BarResponse, error)
// becomes (req []byte) -> ([]byte, error) via Wrap/New
Streaming: client.OpenStream(ordinal, init) + stream.Send/Recv (io.EOF on
half-close); server transport.ListenStream(...). Chained reads (lookup →
list → read) use promise pipelining (MethodOn(promise)) — the server
resolves the chain without round-trips, something request/response protobuf
could not express.
Strategy — strangler, never big-bang
Migrate service by service, keeping the tree green at every commit:
- Generate the wire package for the service (
.proto → .zap → _zap.go). - Rewrite its call sites to wire + transport —
pb2zapfor construction, then by hand for the field reads (immutable ZAP has no field write; reads become accessors). Leave the*_pbpackage in place. - Once nothing imports
<svc>_pb, delete the package and its.proto. - When the last service is cut, drop
grpc+protobuffromgo.mod.
The honest part: construction is mechanical, but field-access conversion is a
data-flow redesign (*pb.Msg is one type for build+read; ZAP splits it into
Input → bytes → View). Budget for it. The deeply-woven services (a metadata
filer, a volume server) are real refactoring, not a codemod — pb2zap does the
safe part and reports the rest.
Checklist
.zapschemas generated,.protodeleted, schemas committed as source- each service: client dials
transport, server servesDispatch<Svc> - streaming methods on
OpenStream/ListenStream grep -r '_pb"'empty (outside generated),grpc/protobufout of go.mod- PQ on the wire:
DialTLS(..., transport.PQTLSConfig(...))or QUIC