跳到主要内容

开发流程概述

在 Go 语言中开发 gRPC 服务的完整流程:

  1. 定义 proto 文件 - 使用 Protocol Buffers 定义服务接口和消息类型
  2. 生成 Go 代码 - 使用 protoc 编译器生成 Go 语言的客户端和服务端代码
  3. 实现服务接口 - 实现生成的 gRPC 服务接口
  4. 注册服务 - 将服务注册到 gRPC 服务器
  5. 启动服务器 - 启动 gRPC 服务器并监听端口

定义 proto 文件

protoc 文件是使用 Protocol Buffers 语言定义的文件,描述了 gRPC 服务的接口和消息格式。更多内容可以参考官方文档

在项目中创建 proto 文件,放在 grpc/proto/ 目录下。例如 user.proto

// 指定使用的 Protocol Buffers 语法版本,proto3 是当前推荐使用的版本
syntax = "proto3";

// 指定生成的 Go 代码的包路径和包名
// 格式:完整包路径;包名
// 第一部分是 Go 模块的完整导入路径(需要替换为你实际的模块路径)
// 分号后的 pb 是生成的 Go 代码的包名
// 这个选项是必需的,否则无法生成 Go 代码
option go_package = "github.com/example/user-service/grpc/pb;pb";

// 定义 proto 文件的命名空间,用于避免命名冲突
// 使用点号分隔的层次结构,使用反向域名格式(如 com.example.service.v1)
// 这个包名会出现在生成的代码中,用于区分不同的服务
package user.v1;

// CreateUserRequest 创建用户请求
// message 定义数据结构,类似于 Go 中的结构体
// 字段格式:类型 字段名 = 字段编号;
// 字段编号:每个字段必须有唯一的编号(1-536870911)
// 1-15 编号占用 1 字节,16-2047 占用 2 字节,建议常用字段使用 1-15
// 编号一旦使用不能更改(向后兼容性)
message CreateUserRequest {
string username = 1; // 用户名
string email = 2; // 邮箱
string phone = 3; // 电话
}

// CreateUserResponse 创建用户响应
message CreateUserResponse {
int64 id = 1; // 用户ID
string username = 2; // 用户名
string email = 3; // 邮箱
string message = 4; // 响应消息
}

// GetUserListRequest 获取用户列表请求
message GetUserListRequest {
int32 page = 1; // 页码,从1开始
int32 page_size = 2; // 每页数量
string keyword = 3; // 搜索关键词(可选)
}

// User 用户信息
message User {
int64 id = 1; // 用户ID
string username = 2; // 用户名
string email = 3; // 邮箱
string phone = 4; // 电话
int64 created_at = 5; // 创建时间(时间戳)
}

// GetUserListResponse 获取用户列表响应
message GetUserListResponse {
repeated User users = 1; // 用户列表,repeated 表示数组/列表类型
int32 total = 2; // 总数
int32 page = 3; // 当前页码
int32 page_size = 4; // 每页数量
}

// UserService 用户服务
// service 定义 gRPC 服务接口
// RPC 方法类型:
// - 一元 RPC(Unary RPC):rpc Method(Request) returns (Response);
// 客户端发送一个请求,服务端返回一个响应,最常用的 RPC 类型
// - 服务端流式 RPC:rpc Method(Request) returns (stream Response);
// 客户端发送一个请求,服务端返回一个流
// - 客户端流式 RPC:rpc Method(stream Request) returns (Response);
// 客户端发送一个流,服务端返回一个响应
// - 双向流式 RPC:rpc Method(stream Request) returns (stream Response);
// 客户端和服务端都可以发送流
service UserService {
// CreateUser 创建用户
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);

// GetUserList 获取用户列表
rpc GetUserList(GetUserListRequest) returns (GetUserListResponse);
}

Proto 文件高级特性

reserved 关键字

reserved 用于保留字段编号或字段名,防止未来不小心重用已删除的字段。这对于维护向后兼容性非常重要。

使用场景

  • 当你需要删除某个字段时,应该使用 reserved 标记该字段编号,而不是直接删除
  • 防止未来的开发者重用已删除字段的编号,导致数据解析错误
  • 保证不同版本的 proto 文件之间的兼容性

语法示例

message User {
// 保留单个字段编号
reserved 2;

// 保留多个字段编号
reserved 4, 5, 6;

// 保留字段编号范围
reserved 8 to 10;

// 保留字段名(防止字段名被重用)
reserved "old_field", "deprecated_name";

int64 id = 1;
string username = 3;
string email = 7;
int64 created_at = 11;
}

注意事项

  • 不能在同一个 reserved 语句中混合使用字段编号和字段名,需要分开写
  • 字段编号保留后,不能再用于新字段
  • 字段名保留后,不能再用于新字段

实际示例

假设最初的 proto 定义:

message User {
int64 id = 1;
string username = 2;
string password = 3; // 敏感字段,需要删除
string email = 4;
}

删除 password 字段后的正确做法:

message User {
reserved 3; // 保留编号 3,防止未来误用
reserved "password"; // 保留字段名

int64 id = 1;
string username = 2;
string email = 4;
}

optional 关键字(可选参数)

在 Protocol Buffers 中,proto3 和 proto2 对字段的处理方式不同:

proto2

  • 支持 requiredoptionalrepeated 三种修饰符
  • required:必填字段,如果未设置会导致序列化失败
  • optional:可选字段,可以检测字段是否被设置

proto3

  • 默认所有字段都是可选的(除了 repeated
  • 移除了 required 关键字(避免兼容性问题)
  • 从 proto3.15 开始,重新引入了 optional 关键字,用于区分"未设置"和"设置为默认值"

proto3 中的 optional

在 proto3 中,默认字段无法区分"未设置"和"设置为零值"。例如:

syntax = "proto3";

message User {
string username = 1; // 默认可选
int32 age = 2; // 默认可选,但无法区分 0 和未设置
}

在 Go 代码中:

user := &pb.User{}
fmt.Println(user.Age) // 输出 0,但无法确定是未设置还是真的设置为 0

使用 optional 关键字后(需要 proto3.15+):

syntax = "proto3";

message User {
string username = 1;
optional int32 age = 2; // 显式声明为可选
optional string phone = 3;
}

在 Go 代码中,optional 字段会生成为指针类型:

user := &pb.User{
Username: "alice",
Age: proto.Int32(25), // 使用指针
}

// 检查字段是否被设置
if user.Age != nil {
fmt.Printf("Age is set: %d\n", *user.Age)
} else {
fmt.Println("Age is not set")
}

proto2 vs proto3 对比

// proto2 风格
syntax = "proto2";

message CreateUserRequest {
required string username = 1; // 必填,未设置会导致序列化失败
optional string email = 2; // 可选,可以检测是否设置
optional string phone = 3; // 可选
repeated string tags = 4; // 数组类型
}
// proto3 风格(推荐)
syntax = "proto3";

message CreateUserRequest {
string username = 1; // 默认可选,但约定为必填(通过业务逻辑验证)
string email = 2; // 默认可选
optional string phone = 3; // 显式可选,可区分未设置和空字符串
repeated string tags = 4; // 数组类型
}

使用建议

  1. 推荐使用 proto3:proto3 是当前推荐的版本,语法更简洁
  2. 必填字段验证:在 proto3 中,通过业务逻辑代码验证必填字段,而不是使用 required
  3. 使用 optional 的场景
    • 需要区分"未设置"和"零值"时(如年龄为 0 vs 未填写年龄)
    • 需要区分"空字符串"和"未设置"时
    • 部分更新场景(PATCH 操作)

实际示例

syntax = "proto3";

// 用户信息更新请求
message UpdateUserRequest {
int64 user_id = 1; // 必填(业务逻辑验证)
optional string username = 2; // 可选更新
optional string email = 3; // 可选更新
optional string phone = 4; // 可选更新
optional int32 age = 5; // 可选更新,可以区分未设置和设置为 0
}

在 Go 代码中处理:

func (c *UserController) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.UpdateUserResponse, error) {
// 验证必填字段
if req.UserId == 0 {
return nil, status.Errorf(codes.InvalidArgument, "user_id is required")
}

// 只更新设置了的字段
updates := make(map[string]interface{})

if req.Username != nil {
updates["username"] = *req.Username
}

if req.Email != nil {
updates["email"] = *req.Email
}

if req.Age != nil {
updates["age"] = *req.Age
}

// 执行更新
err := c.UserService.UpdateUser(ctx, req.UserId, updates)
// ...
}

任意类型(google.protobuf.Any)

当你需要在 proto 消息中存储任意类型的数据时,可以使用 google.protobuf.Any 类型。它类似于 Go 中的 interface{}

基本用法

syntax = "proto3";

// 导入 Any 类型
import "google/protobuf/any.proto";

// 定义具体的消息类型
message UserProfile {
string bio = 1;
string avatar_url = 2;
}

message CompanyProfile {
string company_name = 1;
string industry = 2;
}

// 使用 Any 类型存储不同类型的数据
message Account {
int64 id = 1;
string username = 2;
google.protobuf.Any profile = 3; // 可以存储 UserProfile 或 CompanyProfile
}

Go 代码中使用 Any

import (
"google.golang.org/protobuf/types/known/anypb"
pb "your-module/pb"
)

// 创建并打包 Any 消息
func CreateAccount() (*pb.Account, error) {
// 创建用户档案
userProfile := &pb.UserProfile{
Bio: "Software Engineer",
AvatarUrl: "https://example.com/avatar.jpg",
}

// 将 userProfile 打包为 Any 类型
profileAny, err := anypb.New(userProfile)
if err != nil {
return nil, err
}

// 创建账户
account := &pb.Account{
Id: 1,
Username: "alice",
Profile: profileAny,
}

return account, nil
}

// 解包 Any 消息
func ProcessAccount(account *pb.Account) error {
// 检查 Any 类型中存储的是什么类型
if account.Profile.MessageIs(&pb.UserProfile{}) {
// 解包为 UserProfile
var userProfile pb.UserProfile
if err := account.Profile.UnmarshalTo(&userProfile); err != nil {
return err
}
fmt.Printf("User bio: %s\n", userProfile.Bio)
} else if account.Profile.MessageIs(&pb.CompanyProfile{}) {
// 解包为 CompanyProfile
var companyProfile pb.CompanyProfile
if err := account.Profile.UnmarshalTo(&companyProfile); err != nil {
return err
}
fmt.Printf("Company: %s\n", companyProfile.CompanyName)
}

return nil
}

使用场景

  1. 动态类型数据:需要存储多种类型的数据,但类型在编译时不确定
  2. 插件系统:允许扩展系统接受未知类型的数据
  3. 通用 API:设计通用的 API 接口,支持多种请求/响应类型
  4. 事件系统:事件载荷可以是任意类型

注意事项

  • Any 类型会增加序列化后的数据大小(需要存储类型信息)
  • 类型检查在运行时进行,不如直接使用具体类型安全
  • 优先考虑使用 oneof(见下文),如果类型是已知的有限集合

oneof 关键字(类型联合)

当你有一组互斥的字段(同时只能设置其中一个)时,使用 oneofAny 更高效和类型安全。

基本用法

syntax = "proto3";

message SearchRequest {
string query = 1;

// 搜索条件:只能选择其中一种
oneof filter {
string category = 2;
int32 price_range = 3;
string brand = 4;
}
}

// 更复杂的示例
message Account {
int64 id = 1;
string username = 2;

// 账户类型:个人或企业(二选一)
oneof profile_type {
UserProfile user_profile = 3;
CompanyProfile company_profile = 4;
}
}

message UserProfile {
string bio = 1;
string avatar_url = 2;
}

message CompanyProfile {
string company_name = 1;
string industry = 2;
}

Go 代码中使用 oneof

// 创建账户 - 个人账户
account := &pb.Account{
Id: 1,
Username: "alice",
ProfileType: &pb.Account_UserProfile{ // 注意类型名称格式
UserProfile: &pb.UserProfile{
Bio: "Software Engineer",
AvatarUrl: "https://example.com/avatar.jpg",
},
},
}

// 创建账户 - 企业账户
account := &pb.Account{
Id: 2,
Username: "company_xyz",
ProfileType: &pb.Account_CompanyProfile{
CompanyProfile: &pb.CompanyProfile{
CompanyName: "XYZ Corp",
Industry: "Technology",
},
},
}

// 检查 oneof 字段的类型
switch profile := account.ProfileType.(type) {
case *pb.Account_UserProfile:
fmt.Printf("User bio: %s\n", profile.UserProfile.Bio)
case *pb.Account_CompanyProfile:
fmt.Printf("Company: %s\n", profile.CompanyProfile.CompanyName)
case nil:
fmt.Println("No profile set")
}

oneof vs Any 对比

特性oneofAny
类型安全性编译时类型检查运行时类型检查
性能更高效(无额外类型信息)较低(需要存储类型 URL)
类型范围必须预先定义所有可能的类型可以是任意 protobuf 类型
序列化大小更小更大
使用场景类型是已知的有限集合类型在编译时不确定或需要动态扩展
Go 代码生成类型安全的接口需要手动类型检查和转换

推荐:如果类型集合是已知且有限的,优先使用 oneof;只有在真正需要动态类型时才使用 Any

多个 proto 文件引用(import)

在实际项目中,通常会将 proto 定义拆分到多个文件中,通过 import 语句引用其他文件的定义。

基本用法

项目结构:

grpc/proto/
├── common/
│ ├── types.proto # 公共类型定义
│ └── timestamp.proto # 时间戳定义
└── user/
└── user.proto # 用户服务定义

common/types.proto

syntax = "proto3";

// 定义包名,用于避免命名冲突
package common.v1;

// 指定 Go 包路径
option go_package = "github.com/example/user-service/grpc/pb/common;common";

// 公共类型定义
message Address {
string country = 1;
string province = 2;
string city = 3;
string street = 4;
string postal_code = 5;
}

message PhoneNumber {
string country_code = 1;
string number = 2;
}

user/user.proto

syntax = "proto3";

package user.v1;

option go_package = "github.com/example/user-service/grpc/pb/user;user";

// 导入其他 proto 文件
import "common/types.proto";
import "google/protobuf/timestamp.proto";

message User {
int64 id = 1;
string username = 2;
string email = 3;

// 使用导入的类型(需要带包名前缀)
common.v1.Address address = 4;
common.v1.PhoneNumber phone = 5;
google.protobuf.Timestamp created_at = 6;
}

message CreateUserRequest {
string username = 1;
string email = 2;
common.v1.Address address = 3;
}

message CreateUserResponse {
User user = 1;
}

service UserService {
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

生成 Go 代码

使用命令行生成

可以直接使用 protoc 命令生成代码:

# 创建输出目录
mkdir -p grpc/pb
mkdir -p docs

# 生成代码
protoc --proto_path=grpc/proto \
--go_out=paths=source_relative:grpc/pb \
--go-grpc_out=paths=source_relative:grpc/pb \
--openapi_out=fq_schema_naming=true,default_response=false:docs \
grpc/proto/user.proto

参数说明

  • --proto_path: 指定 proto 文件的搜索路径,可以指定多个路径用于导入其他 proto 文件
  • --go_out: 指定生成 Go 代码的输出路径和选项
    • paths=source_relative: 生成的代码路径与源文件相对路径保持一致
    • 例如:proto/user.proto 会生成到 pb/proto/user.pb.go
  • --go-grpc_out: 指定生成 gRPC Go 代码的输出路径和选项
    • 会生成 *_grpc.pb.go 文件,包含客户端和服务端接口
  • --openapi_out: 生成 OpenAPI 文档(可选)
    • fq_schema_naming=true: 使用完全限定名称
    • default_response=false: 不包含默认响应
    • 需要安装 protoc-gen-openapi 插件
    • 示例:--openapi_out=fq_schema_naming=true,default_response=false:docs

使用 Makefile(推荐)

在项目的 Makefile 中添加 gRPC 代码生成目标:

# gRPC相关变量
PROTO_DIR := grpc/proto
PROTO_OUT_DIR := grpc/pb
DOCS_DIR := docs
PROTO_FILES := $(shell find $(PROTO_DIR) -name *.proto)

grpc: ## 生成gRPC代码
@mkdir -p $(PROTO_OUT_DIR)
@mkdir -p $(DOCS_DIR)
@for proto in $(PROTO_FILES); do \
echo "processing: $$proto"; \
protoc --proto_path=$(PROTO_DIR) \
--go_out=paths=source_relative:$(PROTO_OUT_DIR) \
--go-grpc_out=paths=source_relative:$(PROTO_OUT_DIR) \
--openapi_out=fq_schema_naming=true,default_response=false:$(DOCS_DIR) \
$$proto || exit 1; \
done
@echo "done..."

执行 make grpc 后会生成以下文件:

  • grpc/pb/user.pb.go:消息类型的 Go 代码
  • grpc/pb/user_grpc.pb.go:gRPC 服务接口代码

实现服务接口

grpc/controller/user.go 中实现服务:

package user

import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/example/user-service/grpc/pb"
userS "github.com/example/user-service/internal/service/user"
)

type UserController struct {
UserService userS.UserService
// 必须嵌入 UnimplementedUserServiceServer,保证向前兼容性
// 如果将来 proto 文件添加新方法,不会导致编译错误
pb.UnimplementedUserServiceServer
}

func NewUserController(s userS.UserService) *UserController {
return &UserController{
UserService: s,
}
}

实现 UserServiceServer 接口中的方法:

// CreateUser 创建用户
// 使用 gRPC 状态码:
// codes.InvalidArgument: 参数无效
// codes.AlreadyExists: 资源已存在
// codes.Internal: 内部服务器错误
// 更多状态码参考:https://grpc.github.io/grpc/core/md_doc_statuscodes.html
func (c *UserController) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
// 参数验证
if req.Username == "" {
return nil, status.Errorf(codes.InvalidArgument, "username is required")
}
if req.Email == "" {
return nil, status.Errorf(codes.InvalidArgument, "email is required")
}

// 调用业务逻辑
user, err := c.UserService.CreateUser(ctx, &userS.CreateUserParams{
Username: req.Username,
Email: req.Email,
Phone: req.Phone,
})
if err != nil {
// 处理业务错误
if err == userS.ErrUserExists {
return nil, status.Errorf(codes.AlreadyExists, "user already exists")
}
// 系统错误
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
}

// 返回成功响应
return &pb.CreateUserResponse{
Id: user.ID,
Username: user.Username,
Email: user.Email,
Message: "user created successfully",
}, nil
}

// GetUserList 获取用户列表
func (c *UserController) GetUserList(ctx context.Context, req *pb.GetUserListRequest) (*pb.GetUserListResponse, error) {
// 参数验证和默认值设置
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100 // 限制最大每页数量
}

// 调用业务逻辑
users, total, err := c.UserService.GetUserList(ctx, &userS.GetUserListParams{
Page: int(page),
PageSize: int(pageSize),
Keyword: req.Keyword,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user list: %v", err)
}

// 转换为响应格式
pbUsers := make([]*pb.User, 0, len(users))
for _, u := range users {
pbUsers = append(pbUsers, &pb.User{
Id: u.ID,
Username: u.Username,
Email: u.Email,
Phone: u.Phone,
CreatedAt: u.CreatedAt.Unix(),
})
}

// 返回响应
return &pb.GetUserListResponse{
Users: pbUsers,
Total: int32(total),
Page: int32(page),
PageSize: int32(pageSize),
}, nil
}

注册服务

grpc/router.go 中注册服务:

package grpc

import (
"google.golang.org/grpc"
"github.com/example/user-service/grpc/pb"
userC "github.com/example/user-service/grpc/controller"
)

// RegisterGrpcServers 注册所有 gRPC 服务
// RegisterUserServiceServer 是 protoc 自动生成的函数
// 第一个参数是 gRPC 服务器实例,第二个参数是实现服务接口的结构体实例
// 可以注册多个服务,每个服务调用一次注册函数
func RegisterGrpcServers(grpcSrv *grpc.Server) {
pb.RegisterUserServiceServer(grpcSrv, userC.User)
}

启动服务器

cmd/run_server.go 中启动 gRPC 服务器:

package cmd

import (
"context"
"fmt"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"github.com/example/user-service/common/config"
"github.com/example/user-service/handler/routers"
)

func RunServer() {
// 创建 gRPC 服务器实例
grpcSrv := grpc.NewServer()

// 创建 TCP 监听器
listen, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", config.AppConfig.ServerConfig.GrpcPort))
if err != nil {
logger.Fatal(fmt.Sprintf("failed to listen error: %v", err))
}

// 注册 gRPC 服务
routers.RegisterGrpcServers(grpcSrv)

// 注册反射服务(可选,用于 grpcurl 调试)
// 开发环境可以启用反射,方便使用 grpcurl 调试
if config.GetEnvAsBool("DEBUG_MODE", false) {
reflection.Register(grpcSrv)
logger.Info("grpc reflection enabled for debug mode")
}

// 启动服务器,调用 grpcSrv.Serve(listen) 开始监听请求
logger.Info(fmt.Sprintf("grpc server is running on 0.0.0.0:%d", config.AppConfig.ServerConfig.GrpcPort))
go func() {
if err := grpcSrv.Serve(listen); err != nil {
logger.Fatal("grpc server error: %s", err)
}
}()

// 优雅关闭(可选)
// ... 实现优雅关闭逻辑
}

扩展:HTTP 网关和错误码生成

将 gRPC 服务暴露为 HTTP REST API 较通用的有两种方式:

  1. gRPC-Gateway:使用 --grpc-gateway_out(通用方案,不依赖特定框架)
    • 运行时转换:必须同时运行 gRPC 和 HTTP 服务器,HTTP 服务器作为反向代理
  2. protoc-gen-go-http:使用 --go-http_out(Kratos 专用)
    • 编译时生成:按需启用服务器,仅做代码生成,更轻量级

HTTP 网关代码生成(gRPC-Gateway)

gRPC-Gateway 是最常用的 gRPC 到 HTTP 转换方案,不依赖任何特定框架。

核心特点

  • 运行时转换:在运行时进行 gRPC 和 HTTP 之间的协议转换
  • 双服务器架构:必须同时运行 gRPC 服务器和 HTTP 服务器
  • 反向代理模式:HTTP 服务器作为 gRPC 服务的反向代理,接收 HTTP 请求后转换为 gRPC 调用

proto 文件配置

增加使用 google.api.http 注解

import "google/api/annotations.proto";

service UserService {
// CreateUser 创建用户
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
option (google.api.http) = {
post: "/api/v1/user"
body: "*"
};
}

// GetUserList 获取用户列表
rpc GetUserList(GetUserListRequest) returns (GetUserListResponse) {
option (google.api.http) = {
get: "/api/v1/user"
};
}

// GetUser 获取单个用户
rpc GetUser(GetUserRequest) returns (GetUserResponse) {
option (google.api.http) = {
get: "/api/v1/user/{id}"
};
}
}

安装插件

go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest

准备第三方 proto 文件

gRPC-Gateway 需要 google/api/annotations.protogoogle/api/http.proto 文件。可以通过以下方式获取:

方式1:使用 buf 工具管理依赖(推荐)

  1. 安装 buf 工具:
# macOS
brew install bufbuild/buf/buf

# 或使用 Go 安装
go install github.com/bufbuild/buf/cmd/buf@latest
  1. 在项目根目录创建 buf.yaml 配置文件:
version: v2
modules:
- path: api
excludes:
- third_party
deps:
- buf.build/googleapis/googleapis
  1. 初始化并下载依赖:
buf mod update
  1. 在 Makefile 中使用 buf 生成代码:
api:
buf generate
# 或使用 protoc,但需要指定 buf 的缓存路径
protoc --proto_path=./api \
--proto_path=$(shell buf export buf.build/googleapis/googleapis --path google/api) \
--go_out=paths=source_relative:./api \
--go-grpc_out=paths=source_relative:./api \
--grpc-gateway_out=paths=source_relative:./api \
$(API_PROTO_FILES)

方式2:手动下载到 third_party 目录

mkdir -p third_party/google/api
# 从 https://github.com/googleapis/googleapis 下载以下文件到 third_party/google/api/ 目录:
# - annotations.proto
# - http.proto
# - httpbody.proto(如果使用 body 参数)

Makefile 配置

api:
protoc --proto_path=./api \
--proto_path=./third_party \
--go_out=paths=source_relative:./api \
--go-grpc_out=paths=source_relative:./api \
--grpc-gateway_out=paths=source_relative:./api \
--openapiv2_out=./api \
$(API_PROTO_FILES)

生成的文件

  • *_pb.gw.go:gRPC-Gateway 生成的 HTTP 网关代码
  • *.swagger.json:OpenAPI/Swagger 文档(如果使用 --openapiv2_out

服务端集成

gRPC-Gateway 需要同时运行 gRPC 服务器和 HTTP 服务器,HTTP 服务器作为 gRPC 服务的反向代理:

import (
"context"
"log"
"net"
"net/http"

"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"

pb "your-module/pb" // 生成的 pb 包
)

func main() {
// 启动 gRPC 服务器
lis, err := net.Listen("tcp", ":9090")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
pb.RegisterUserServiceServer(grpcServer, &userService{}) // 注册 gRPC 服务
go func() {
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}()

// 创建 gRPC-Gateway mux
mux := runtime.NewServeMux()

// 注册 gRPC 服务到 HTTP(通过反向代理)
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
err = pb.RegisterUserServiceHandlerFromEndpoint(
context.Background(),
mux,
"localhost:9090", // gRPC 服务地址
opts,
)
if err != nil {
log.Fatalf("failed to register gateway: %v", err)
}

// 启动 HTTP 服务器
log.Println("HTTP server listening on :8080")
if err := http.ListenAndServe(":8080", mux); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

工作原理:gRPC-Gateway 在运行时创建一个 HTTP 服务器,接收 RESTful 请求,在运行时将其转换为 gRPC 调用,然后转发给 gRPC 服务器,最后将 gRPC 响应转换回 HTTP 响应。这是一个运行时转换过程,因此必须同时运行 gRPC 和 HTTP 两个服务器。

HTTP 网关代码生成(protoc-gen-go-http)

protoc-gen-go-http 是 Kratos 项目提供的插件,可以单独使用,但生成的代码会依赖 Kratos 的 HTTP 传输层包(github.com/go-kratos/kratos/v2/transport/http)。如果项目不使用 Kratos 框架,建议使用 gRPC-Gateway 方案。

核心特点

  • 编译时生成:在代码生成阶段直接生成 HTTP 处理器代码,而非运行时转换
  • 按需启用服务器:可以选择只启动 gRPC 服务器、只启动 HTTP 服务器,或同时启动两者
  • 更轻量级:不需要额外的网关运行时组件,生成的代码直接处理 HTTP 请求

proto 文件配置

在 proto 文件中使用 google.api.http 注解定义 HTTP 路由:

import "google/api/annotations.proto";

service UserService {
// CreateUser 创建用户
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
option (google.api.http) = {
post: "/api/v1/user"
body: "*"
};
}

// GetUserList 获取用户列表
rpc GetUserList(GetUserListRequest) returns (GetUserListResponse) {
option (google.api.http) = {
get: "/api/v1/user"
};
}

// GetUser 获取单个用户
rpc GetUser(GetUserRequest) returns (GetUserResponse) {
option (google.api.http) = {
get: "/api/v1/user/{id}"
};
}
}

安装插件

go install github.com/go-kratos/kratos/cmd/protoc-gen-go-http/v2@latest

Makefile 配置

api:
protoc --proto_path=./api \
--proto_path=./third_party \
--go_out=paths=source_relative:./api \
--go-http_out=paths=source_relative:./api \
--go-grpc_out=paths=source_relative:./api \
$(API_PROTO_FILES)

生成的文件

  • *_http.pb.go:HTTP 网关代码,包含 HTTP 路由注册函数
  • 实现了 UserServiceHTTPServer 接口,用于注册 HTTP 路由

服务端集成

protoc-gen-go-http 在编译时生成 HTTP 处理器代码,因此可以按需启用服务器

  • 只启动 HTTP 服务器:如果只需要 HTTP REST API,可以只启动 HTTP 服务器
  • 只启动 gRPC 服务器:如果只需要 gRPC 服务,可以只启动 gRPC 服务器
  • 同时启动两个服务器:如果需要同时提供 HTTP 和 gRPC 接口,可以同时启动

如果使用 Kratos 框架,可以直接使用框架的 HTTP 服务器注册:

import (
"github.com/go-kratos/kratos/v2/transport/http"
pb "your-module/pb"
)

func main() {
// 只启动 HTTP 服务器(按需启用)
httpSrv := http.NewServer(
http.Address(":8080"),
)

// 注册 HTTP 服务(编译时生成的代码)
pb.RegisterUserServiceHTTPServer(httpSrv, &userService{})

// 启动服务器(Kratos 框架会自动处理)
}

如果不使用 Kratos 框架,需要手动集成 Kratos 的 HTTP 传输层包,或者自行实现 HTTP 处理器。

在 Gin 框架中集成 protoc-gen-go-http

方案一:最小依赖 + 适配器(推荐)

引入 Kratos 的 HTTP 传输层包(不引入整个 Kratos 框架),然后创建一个适配器将 Gin 路由转换为调用生成的 HTTP 处理器:

package adapter

import (
"context"
"net/http"

"github.com/gin-gonic/gin"
"github.com/go-kratos/kratos/v2/transport/http"
pb "your-module/pb"
)

// GinToKratosAdapter 将 Gin 的 Context 转换为 Kratos HTTP 的 Context
type GinToKratosAdapter struct {
userService pb.UserServiceHTTPServer
}

func NewGinToKratosAdapter(userService pb.UserServiceHTTPServer) *GinToKratosAdapter {
return &GinToKratosAdapter{
userService: userService,
}
}

// RegisterRoutes 在 Gin 中注册路由
func (a *GinToKratosAdapter) RegisterRoutes(r *gin.Engine) {
// 创建 Kratos HTTP 服务器(仅用于路由注册,不启动)
kratosSrv := http.NewServer(
http.Address(":8080"), // 地址不会被使用
)

// 注册到 Kratos HTTP 服务器(这会生成路由信息)
pb.RegisterUserServiceHTTPServer(kratosSrv, a.userService)

// 从 Kratos 服务器提取路由信息,转换为 Gin 路由
// 注意:这需要访问 Kratos 的内部路由信息,可能需要反射或手动映射

// 手动注册路由(推荐方式)
v1 := r.Group("/api/v1")
{
v1.POST("/user", a.createUser)
v1.GET("/user", a.getUserList)
v1.GET("/user/:id", a.getUser)
}
}

// createUser 处理创建用户请求
func (a *GinToKratosAdapter) createUser(c *gin.Context) {
// 将 Gin Context 转换为 Kratos HTTP Context
kratosCtx := a.ginToKratosContext(c)

// 解析请求体
var req pb.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// 调用生成的 HTTP 处理器
resp, err := a.userService.CreateUser(kratosCtx, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, resp)
}

// getUserList 处理获取用户列表请求
func (a *GinToKratosAdapter) getUserList(c *gin.Context) {
kratosCtx := a.ginToKratosContext(c)

var req pb.GetUserListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

resp, err := a.userService.GetUserList(kratosCtx, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, resp)
}

// getUser 处理获取单个用户请求
func (a *GinToKratosAdapter) getUser(c *gin.Context) {
kratosCtx := a.ginToKratosContext(c)

id := c.Param("id")
req := &pb.GetUserRequest{Id: id}

resp, err := a.userService.GetUser(kratosCtx, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, resp)
}

// ginToKratosContext 将 Gin Context 转换为 Kratos HTTP Context
func (a *GinToKratosAdapter) ginToKratosContext(c *gin.Context) context.Context {
ctx := c.Request.Context()
// 可以在这里传递额外的上下文信息
return ctx
}

main.go 中使用:

package main

import (
"github.com/gin-gonic/gin"
pb "your-module/pb"
"your-module/adapter"
userC "your-module/grpc/controller/user"
)

func main() {
r := gin.Default()

// 创建服务实现
userService := userC.NewUserController(...)

// 创建适配器
adapter := adapter.NewGinToKratosAdapter(userService)

// 注册路由
adapter.RegisterRoutes(r)

// 启动 Gin 服务器
r.Run(":8080")
}

方案二:手动实现接口(更灵活)

不依赖 Kratos HTTP 传输层,直接手动实现生成的接口,在 Gin 中注册路由:

package handler

import (
"net/http"

"github.com/gin-gonic/gin"
pb "your-module/pb"
userC "your-module/grpc/controller/user"
)

type UserHTTPHandler struct {
userService *userC.UserController
}

func NewUserHTTPHandler(userService *userC.UserController) *UserHTTPHandler {
return &UserHTTPHandler{
userService: userService,
}
}

// RegisterRoutes 在 Gin 中注册路由
func (h *UserHTTPHandler) RegisterRoutes(r *gin.Engine) {
v1 := r.Group("/api/v1")
{
v1.POST("/user", h.CreateUser)
v1.GET("/user", h.GetUserList)
v1.GET("/user/:id", h.GetUser)
}
}

// CreateUser 创建用户
func (h *UserHTTPHandler) CreateUser(c *gin.Context) {
var req pb.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// 调用 gRPC 服务实现(复用业务逻辑)
resp, err := h.userService.CreateUser(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, resp)
}

// GetUserList 获取用户列表
func (h *UserHTTPHandler) GetUserList(c *gin.Context) {
var req pb.GetUserListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

resp, err := h.userService.GetUserList(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, resp)
}

// GetUser 获取单个用户
func (h *UserHTTPHandler) GetUser(c *gin.Context) {
id := c.Param("id")
req := &pb.GetUserRequest{Id: id}

resp, err := h.userService.GetUser(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, resp)
}

方案对比

方案优点缺点适用场景
方案一(适配器)可以复用生成的 HTTP 处理器代码需要引入 Kratos HTTP 传输层包,代码较复杂需要利用生成代码的完整功能
方案二(手动实现)不依赖 Kratos,代码更简洁需要手动实现所有路由处理只需要简单的 HTTP 接口,或想完全控制路由逻辑
gRPC-Gateway不依赖特定框架,通用性强运行时转换,需要同时运行两个服务器通用 Go 项目,不需要 Kratos 依赖

Kratos vs gRPC-Gateway 对比

特性protoc-gen-go-http (--go-http_out)gRPC-Gateway (--grpc-gateway_out)
核心区别编译时生成代码,按需启用服务器运行时转换,必须同时运行两个服务器
转换时机编译时生成 HTTP 处理器代码运行时进行协议转换
服务器架构按需启用,可只运行 gRPC 或只运行 HTTP必须同时运行 gRPC 和 HTTP 服务器
代码依赖依赖 Kratos HTTP 传输层包不依赖特定框架
集成方式Kratos 框架自动集成,或手动集成 HTTP 传输层需要手动注册路由,同时运行 gRPC 和 HTTP 服务器
代码生成*_http.pb.go*_pb.gw.go
工作原理直接生成 HTTP 处理器,直接处理 HTTP 请求HTTP 服务器作为 gRPC 服务的反向代理
性能开销更轻量,无运行时转换开销有运行时转换开销
适用场景Kratos 项目或需要 Kratos HTTP 传输层的项目通用 Go 项目

错误码生成(Kratos 框架)

使用 --go-errors_out 可以生成统一的错误码定义。详情可以参考 Kratos 官方文档

安装插件

go install github.com/go-kratos/kratos/cmd/protoc-gen-go-errors/v2@latest

Makefile 配置

api:
protoc --proto_path=./api \
--proto_path=./third_party \
--go_out=paths=source_relative:./api \
--go-http_out=paths=source_relative:./api \
--go-grpc_out=paths=source_relative:./api \
--go-errors_out=paths=source_relative:./api \
$(API_PROTO_FILES)

错误码定义

在 proto 文件中定义错误码:

enum ErrorReason {
option (errors.default_code) = 500;

USER_NOT_FOUND = 0 [(errors.code) = 404];
USER_ALREADY_EXISTS = 1 [(errors.code) = 409];
INVALID_PARAMS = 2 [(errors.code) = 400];
}

扩展:生成前端 TypeScript 代码

可以使用 protoc-gen-ts_proto 从 proto 文件生成前端 TypeScript 类型定义,用于前端项目的类型安全。

提示

关于 protoc-gen-ts_proto 的详细安装、配置和使用说明,请参考 protoc-gen-ts_proto 文档

本文参考资料: