在 Protobuf 中定义服务与消息
编写 .proto 契约,并为 NestJS 微服务生成类型化接口。
在 Protobuf 中定义服务与消息 是 CoddyKit 上的免费 NestJS Enterprise Backend APIs 课时。 这是第 1 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 NestJS Enterprise Backend APIs 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 NestJS Enterprise Backend APIs 课程共包含 4 节课。
本课时的部分内容尚未翻译,以英文显示。
Why Protobuf Drives the Contract
In a NestJS gRPC microservice, the .proto file is the single source of truth. It defines the wire format, the RPC surface, and — once compiled — the TypeScript types both client and server share.
- Messages describe the data shapes that travel over the wire.
- Services declare the callable RPC methods and their request/response messages.
Unlike REST + OpenAPI (where the schema is often written after the code), with gRPC you author the contract first and generate code from it. This is contract-first design.
Anatomy of a .proto File
Every contract starts by declaring the syntax version and a package. The package becomes a namespace that NestJS uses to locate your service at runtime.
syntax = "proto3";— always proto3 for modern gRPC.package billing;— the namespace referenced in the NestJS transport options.
Below is a minimal but complete contract for an invoicing service.
// proto/billing.proto
syntax = "proto3";
package billing;
service InvoiceService {
rpc GetInvoice (GetInvoiceRequest) returns (Invoice);
}
message GetInvoiceRequest {
string id = 1;
}
message Invoice {
string id = 1;
string customer_id = 2;
int64 amount_cents = 3;
string currency = 4;
}Field Numbers Are the Contract
Each field has a tag number (the = 1, = 2...). These numbers — not the field names — are what gets encoded on the wire.
- Never reuse or renumber an existing field; doing so breaks binary compatibility with deployed clients.
- Tags 1–15 use one byte; reserve them for the most frequently sent fields.
- You may safely rename a field (the name is local to generated code), but never change its number or type.
When you remove a field, mark its number reserved so it is never accidentally recycled.
message Invoice {
reserved 5, 6;
reserved "legacy_tax_field";
string id = 1;
string customer_id = 2;
int64 amount_cents = 3;
string currency = 4;
}Scalar Types and the int64 Trap
Proto3 scalars map to TypeScript, but the mapping has sharp edges for enterprise APIs handling money or IDs.
string→string,bool→boolean,int32/float/double→number.int64,uint64,fixed64→ represented asstring(orLong) by most loaders, because JS numbers lose precision beyond 2^53.
For amount_cents as int64, your generated interface should treat it as a string to avoid silent rounding on large values.
// Generated-style interface for the Invoice message
export interface Invoice {
id: string;
customerId: string;
// int64 surfaces as string to preserve precision
amountCents: string;
currency: string;
}snake_case In, camelCase Out
Protobuf convention is snake_case for field names. The NestJS gRPC loader (@grpc/proto-loader) defaults to keepCase: false, which converts fields to camelCase in the generated/runtime objects.
customer_idin.protobecomescustomerIdin TypeScript.- If you set
keepCase: true, you must readcustomer_idverbatim — this is a frequent source ofundefinedbugs.
Pick one convention per project and configure the loader consistently.
// main.ts transport options
import { Transport, GrpcOptions } from '@nestjs/microservices';
import { join } from 'path';
export const grpcOptions: GrpcOptions = {
transport: Transport.GRPC,
options: {
package: 'billing',
protoPath: join(__dirname, 'proto/billing.proto'),
loader: { keepCase: false, longs: String, enums: String },
},
};Enums and Their Zero-Value Rule
Proto3 enums must define a zero value as the first entry — it is the implicit default when a field is unset on the wire.
- Name the zero value
*_UNSPECIFIEDso unset and "first real state" are distinguishable. - Enums are open in proto3: a client on a newer schema may send a number your server does not know, so always handle the default branch.
With enums: String in the loader, values arrive as their string names in TypeScript.
enum InvoiceStatus {
INVOICE_STATUS_UNSPECIFIED = 0;
INVOICE_STATUS_DRAFT = 1;
INVOICE_STATUS_SENT = 2;
INVOICE_STATUS_PAID = 3;
INVOICE_STATUS_VOID = 4;
}
message Invoice {
string id = 1;
InvoiceStatus status = 5;
}The Service Interface in NestJS
Each rpc method becomes a method on a generated TypeScript interface. Unary RPCs return an Observable (or Promise) of the response message in NestJS.
You typically hand-write or generate an interface and inject the client via ClientGrpc.getService().
import { Observable } from 'rxjs';
export interface GetInvoiceRequest {
id: string;
}
export interface InvoiceServiceClient {
getInvoice(request: GetInvoiceRequest): Observable<Invoice>;
}
export interface Invoice {
id: string;
customerId: string;
amountCents: string;
currency: string;
status: string;
}Implementing the Service Handler
On the server side, decorate a controller method with @GrpcMethod. The first argument is the service name from the .proto, the second is the rpc method name.
- The method receives the decoded request message as a plain object.
- Return the response message shape directly, or an
Observable/Promiseof it.
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
@Controller()
export class InvoiceController {
@GrpcMethod('InvoiceService', 'GetInvoice')
getInvoice(data: { id: string }): Invoice {
return {
id: data.id,
customerId: 'cust_42',
amountCents: '120000',
currency: 'EUR',
status: 'INVOICE_STATUS_SENT',
};
}
}Nested Messages and Composition
Messages compose. A field can be another message type, letting you model rich aggregates without flattening everything into one shape.
- Reference a message type by name; define it before or after — order does not matter within a file.
- An unset nested message arrives as
undefinedin TypeScript, so guard before accessing its fields.
message Money {
int64 amount_cents = 1;
string currency = 2;
}
message LineItem {
string sku = 1;
uint32 quantity = 2;
Money unit_price = 3;
}
message Invoice {
string id = 1;
string customer_id = 2;
Money total = 3;
repeated LineItem line_items = 4;
}Repeated Fields, maps, and Streaming RPCs
Two more building blocks complete most enterprise contracts:
repeated T field = N;→ a TypeScript arrayT[]. An empty list and an unset list are indistinguishable on the wire.map<string, T>→ a TypeScript record; useful for labels/metadata.
Prefix stream on the request, response, or both to declare server-streaming, client-streaming, or bidirectional RPCs. In NestJS, streaming methods use @GrpcStreamMethod and work with RxJS Observable streams.
service InvoiceService {
rpc GetInvoice (GetInvoiceRequest) returns (Invoice);
// server-streaming: many invoices for one query
rpc ListInvoices (ListInvoicesRequest) returns (stream Invoice);
}
message ListInvoicesRequest {
string customer_id = 1;
map<string, string> filters = 2;
}Generating Typed Interfaces with ts-proto
Hand-writing interfaces drifts from the .proto. Use a generator like ts-proto (via protoc) to emit interfaces, encode/decode helpers, and a NestJS-friendly client/service shape that stays in lockstep with the contract.
nestJs=trueemits the gRPC service/client interfaces NestJS expects.- Run it in CI so a stale generated file fails the build, guaranteeing code matches the contract.
// package.json script
{
"scripts": {
"proto:gen": "protoc --plugin=node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./src/generated --ts_proto_opt=nestJs=true,outputServices=grpc-js,useDate=false ./proto/billing.proto"
}
}Quick Check: Evolving the Contract
You shipped Invoice with string customer_id = 2;. A new requirement needs you to stop sending customer_id and instead send a richer Customer customer = 6; nested message. Deployed older clients must keep working.
Recap: Contract-First Protobuf
You can now author a service contract and turn it into typed NestJS interfaces:
- Structure:
syntax = proto3, apackagenamespace,messageshapes, and aservicewithrpcmethods. - Field numbers are the wire contract — additive evolution only;
reservedretired numbers and names. - Type mapping: watch
int64→string, snake_case → camelCase, enums need a*_UNSPECIFIEDzero value. - Composition: nested messages,
repeatedarrays,map, andstreamRPCs. - Generation: drive types from the
.protowith ts-proto in CI so code can never drift from the contract.
用 AI 导师学习 TypeScript — 免费
在浏览器中编写并运行真实代码,获得全天候 AI 导师的即时帮助,并在网页或应用中继续学习。
- 课程
- 20
- 课程
- 76
常见问题解答
「在 Protobuf 中定义服务与消息」课时是免费的吗?
是的 — 「在 Protobuf 中定义服务与消息」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 NestJS Enterprise Backend APIs 课程的其余内容,请升级到 CoddyKit PRO。 NestJS Enterprise Backend APIs 课程共包含 4 节课。
「在 Protobuf 中定义服务与消息」这节课中我会学到什么?
编写 .proto 契约,并为 NestJS 微服务生成类型化接口。 你通过在浏览器中直接运行的动手代码来练习 NestJS Enterprise Backend APIs,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 NestJS Enterprise Backend APIs 需要有经验吗?
无需任何先前经验。CoddyKit 上的 NestJS Enterprise Backend APIs 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 1 节课,共 4 节。
「在 Protobuf 中定义服务与消息」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 NestJS Enterprise Backend APIs 课中编写并运行代码吗?
能。每节 NestJS Enterprise Backend APIs 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- 在 Protobuf 中定义服务与消息
- 实现并调用 gRPC 方法
- 流式 RPC 与背压
- 契约演进与向后兼容