彻底学会 gRPC:用 Go 实现一个迷你考试服务

Slack、Netflix 甚至 Kubernetes 这类现代系统,都能高效地完成实时通信。它们庞大的后端被拆分为一个个微服务。那么,这些服务之间到底如何通信?大概率就是 gRPC 在背后发挥魔法。

本文将通过用 Go 编写一个最简化的考试服务,一步步演示 gRPC 支持的四种 RPC 形式:一元调用(Unary)、服务器端流(Server Streaming)、客户端流(Client Streaming)和双向流(Bidirectional Streaming)。

在此之前,我们先快速扫清一些概念和行话,做好充电。如果你已经很熟悉并且只想要代码,可以在这里查看相关仓库:github.com/pixperk/grp...

RPC 是啥?

RPC(Remote Procedure Call,远程过程调用)乍听高大上,其实就是 "在另一台机器上调用一个函数"。

想象你是客户端,要问服务器 "学生 42 的成绩是多少?"。借助 RPC,就像本地函数调用一样简单,虽然函数真正执行的地方在远端。

早期 RPC 系统在今天看来问题多多:

  • 数据格式混乱:XML、私有格式,臃肿又慢;
  • 很难流式通信:实时或长链接几乎做不了;
  • 语言绑定有限:经常绑定特定生态(Java RMI、CORBA 等);
  • 自动化差:代码生成寥寥,样板代码海量;
  • 安全自己管:TLS、认证要开发者手写;
  • 扩展性差:不复用连接、无多路复用,高并发直接跪;

gRPC 则把这些痛点一次性解决:更快、更安全、开发体验更爽。

gRPC --- Google 出品的现代化 RPC 框架

gRPC 是 Google 开发的开源 RPC 框架。它旨在让服务间通信变得更:

  • 快速(得益于 HTTP/2);
  • 类型安全(通过 Protocol Buffers);
  • 流式友好(支持实时通信)。

HTTP/2 小科普

gRPC 在底层使用 HTTP/2。与 HTTP/1.1 相比,它具有:

  • 多路复用:在同一连接上并行处理多个请求;
  • 支持双向流:客户端和服务器可同时发送和接收数据;
  • **内置头部压缩:**加快数据传输速度。

得益于此,gRPC 能在低延迟下搞定实时通信。

gRPC vs REST

特性 REST gRPC
数据格式 JSON / XML Protocol Buffers
流式 罕见 / 需自己造轮子 内置
传输层 HTTP/1.1 HTTP/2
性能 冗长 紧凑 & 快
开发体验 手动写文档 自动生成代码

gRPC 支持的四种 RPC 通信方式

gRPC 在客户端与服务器之间支持四种通信方式。下面我们逐一进行介绍。

  1. Unary(单次请求-响应)

客户端发送一次请求 → 服务器返回一次响应。类似普通函数:

protobuf 复制代码
rpc GetMarks(StudentRequest) returns (MarksResponse);

这是最常见的类型,非常适合 CRUD 风格的操作。

  1. Server Streaming(服务器流)

客户端一次请求,服务器持续推送多条响应。

protobuf 复制代码
rpc StreamSemesterResults(SemesterRequest) returns (stream MarksResponse);

当服务器需要发送大量数据时,这种方式特别有用------结果一准备好就会实时推送给你。

  1. Client Streaming(客户端流)

客户端持续发送一连串请求 → 服务器最终返回一个汇总响应。也就是说,客户端批量推送数据,服务器全部处理完毕后一次性给出结果。

protobuf 复制代码
rpc UploadAttendance(stream AttendanceEntry) returns (UploadStatus);

非常适合用于发送日志、监控指标或进行批量上传。

  1. Bidirectional Streaming(双向流)

客户端和服务器可以同时向对方持续发送数据流。就像实时聊天一样,双方能够一边说话一边收听彼此的消息。

protobuf 复制代码
rpc LiveQuiz(stream QuizMessage) returns (stream QuizMessage);

这正是 gRPC 大显身手的地方:实时多人游戏、协作工具、实时仪表盘------统统不在话下。

Protocol Buffers(Protobuf)简介

Google 推出的跨语言、跨平台序列化协议。

相较 JSON:

  • 体积更小:二进制编码;
  • 速度更快:序列化 / 反序列化省时;
  • 字段用编号:解析无需比对字符串;

Protobuf Buffers 工作流程:

  1. .proto 描述文件;
  2. protoc 编译生成各语言代码;
  3. 在代码里直接调用序列化 / 反序列化方法;

示例:

protobuf 复制代码
syntax = "proto3";

message StudentRequest {
  string student_id = 1;
}

动手实践:Go 版 Exam Service

仓库初始化

首先,来初始化一个 Go 项目:

bash 复制代码
mkdir grpc_exam && cd grpc_exam
go mod init github.com/pixperk/grpc_exam

安装必要的生成插件:

bash 复制代码
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

如果你没有安装 protoc 编译工具,可以参考 protobuf.dev/installatio... 进行安装。

接下来,准备好目录结构:

plain 复制代码
├── client/
│   ├── clients/
│   │   ├── unary.go
│   │   ├── server_stream.go
│   │   ├── client_stream.go
│   │   ├── bi_stream.go
│   └── main.go
├── proto/
│   ├── exam.proto
│   └── generated/exampb/
│       ├── exam.pb.go
│       └── exam_grpc.pb.go
├── server/
│   ├── main.go
│   └── servers/
│       ├── unary.go
│       ├── server_stream.go
│       ├── client_stream.go
│       ├── bi_stream.go
│       └── exam_service_server.go
├── utils/
│   └── logger.go
├── go.mod
├── go.sum
└── Makefile

接下来,编写一个 Makefile,避免每次都执行很长的命令来执行操作:

makefile 复制代码
proto:
    protoc \
        --proto_path=proto \
        --go_out=proto \
        --go-grpc_out=proto \
        proto/*.proto
    @echo "Proto files generated in the 'proto' directory."

server:
    go run server/main.go

client_unary:
    go run client/main.go unary

client_server:
    go run client/main.go server

client_client:
    go run client/main.go client

client_bidi:
    go run client/main.go bidi

.PHONY: proto server client_unary client_server client_client client_bidi

make proto 一键生成 Go 代码。

构建 Unary RPC

在 exam.proto 文件中编写 Exam Service:

protobuf 复制代码
syntax = "proto3";

package exam;

option go_package = "generated/exampb";

service ExamService {
  rpc GetExamResult(GetExamResultRequest) returns (GetExamResultResponse); //unary
}

message GetExamResultRequest {
  string student_id = 1;
  string exam_id = 2;
}

message GetExamResultResponse {
  string student_name = 1;
  string subject = 2;
  int32 marks_obtained = 3;
  int32 total_marks = 4;
  string grade = 5;
}

执行 make proto 生成 Go 代码,代码会被放到 proto/generated 目录。

server/servers/exam_service_server.go 中定义:

go 复制代码
package servers

import "github.com/pixperk/grpc_exam/proto/generated/exampb"

type ExamServiceServer struct {
    exampb.UnimplementedExamServiceServer
    examData map[string]*exampb.GetExamResultResponse
}

func NewExamServiceServer() *ExamServiceServer {
    data := map[string]*exampb.GetExamResultResponse{
        "123_math101": {
            StudentName:   "John Doe",
            Subject:       "Math 101",
            MarksObtained: 95,
            TotalMarks:    100,
            Grade:         "A+",
        },
        "456_phy101": {
            StudentName:   "Jane Smith",
            Subject:       "Physics 101",
            MarksObtained: 88,
            TotalMarks:    100,
            Grade:         "A",
        },
    }

    return &ExamServiceServer{
        examData: data,
    }
}

接下来设计服务端和客户端。先写开发客户端和服务端的 main.go。

server/main.go:

go 复制代码
package main

import (
    "net"

    "log/slog"

    "github.com/pixperk/grpc_exam/proto/generated/exampb"
    "github.com/pixperk/grpc_exam/server/servers"

    "github.com/pixperk/grpc_exam/utils"
    "google.golang.org/grpc"
)

func main() {
    utils.InitLogger(true)
    //Spin up a TCP Server
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        slog.Error("failed to listen", "error", err)
    }

    //New gRPC server instance
    s := grpc.NewServer()

    //Register services
    exampb.RegisterExamServiceServer(s, servers.NewExamServiceServer())

 // Start serving gRPC requests
    if err := s.Serve(lis); err != nil {
        slog.Error("failed to serve", "error", err)
    }

}

client/main.go:

go 复制代码
package main

import (
    "log/slog"
    "os"

    "github.com/pixperk/grpc_exam/client/clients"
    "github.com/pixperk/grpc_exam/proto/generated/exampb"
    "github.com/pixperk/grpc_exam/utils"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
)

func main() {
    // Initialize logger (true = debug mode)
    utils.InitLogger(true)

    // Create a gRPC client connection to the server
    conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        slog.Error("Failed to connect to server", "error", err)
        return
    }
    defer conn.Close()

    // Create a client for the ExamService
    client := exampb.NewExamServiceClient(conn)

    clients.Unary(client)
}

编写 Unary 服务端和客户端。

服务端 (server/servers/unary.go):

go 复制代码
package servers

import (
    "context"
    "fmt"

    "github.com/pixperk/grpc_exam/proto/generated/exampb"
)

func (s *ExamServiceServer) GetExamResult(ctx context.Context, req *exampb.GetExamResultRequest) (*exampb.GetExamResultResponse, error) {
    key := fmt.Sprintf("%s_%s", req.StudentId, req.ExamId)
    if result, ok := s.examData[key]; ok {
        return result, nil
    } else {
        return nil, fmt.Errorf("exam result not found for student ID %s and exam ID %s", req.StudentId, req.ExamId)
    }
}

客户端 (client/clients/unary.go):

go 复制代码
package clients

import (
    "context"
    "fmt"
    "time"

    "github.com/pixperk/grpc_exam/proto/generated/exampb"
)

func Unary(client exampb.ExamServiceClient) {

    fmt.Println("Enter student ID and exam ID (e.g., 123 math101):")
    var studentID, examID string
    fmt.Scanf("%s %s", &studentID, &examID)

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    resp, err := client.GetExamResult(ctx, &exampb.GetExamResultRequest{StudentId: studentID, ExamId: examID})
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }

    fmt.Printf("Student Name: %s\n", resp.StudentName)
    fmt.Printf("Subject: %s\n", resp.Subject)
    fmt.Printf("Marks Obtained: %d out of %d\n", resp.MarksObtained, resp.TotalMarks)
    fmt.Printf("Grade: %s\n", resp.Grade)
    fmt.Println("Unary RPC call completed successfully.")

}

上面的代码,就是一些基础的函数调用与简单的 Go 编程。在两个终端分别运行 make servermake client 即可看到效果。

Server Streaming 和 Client Streaming 也很简单,只要熟悉 Go 流式处理即可。下面直接跳到双向流(Bidirectional Streaming)。

构建双向 RPC

在 exam.proto 中增加:

protobuf 复制代码
rpc LiveExamQuery(stream GetExamResultRequest) returns (stream GetExamResultResponse); //bidi streaming

在 servers/bi_stream.go 文件中开发以下代码:

go 复制代码
package servers

import (
    "fmt"
    "io"

    "github.com/pixperk/grpc_exam/proto/generated/exampb"
)


func (s *ExamServiceServer) LiveExamQuery(stream exampb.ExamService_LiveExamQueryServer) error {
    for {
        // Receive a stream request from the client
        req, err := stream.Recv()
        if err != nil {
            // If the client closes the stream (EOF), stop the loop gracefully
            if err == io.EOF {
                return nil
            }
            // If another error occurred, return it
            return err
        }

        key := fmt.Sprintf("%s_%s", req.StudentId, req.ExamId)

        result, ok := s.examData[key]

        // If result is not found, send a default "Not Found" response
        if !ok {
            err := stream.Send(&exampb.GetExamResultResponse{
                StudentName:   "N/A",
                Subject:       req.ExamId,
                MarksObtained: 0,
                TotalMarks:    0,
                Grade:         "Not Found",
            })
            if err != nil {
                return err // Stop on send error
            }
            continue
        }

        // If result is found, send it back to the client over the stream
        if err := stream.Send(result); err != nil {
            return err // Stop on send error
        }
    }
}

LiveExamQuery 函数逻辑如下:

  1. 不断从客户端接收查询;
  2. 立即返回对应结果(若存在);
  3. 直到客户端结束(EOF);

Go Channel 简介

Go Channel 像管道一样在 goroutine 间传递数据,可安全同步,无需显式 mutex。

创建:done := make(chan struct{})

发送:done <- struct{}{}

接收:<-done

Go Channel 发送或接收前都会阻塞,非常适合协同。

回到 bi-dir 客户端

go 复制代码
package clients

import (
    "bufio"
    "context"
    "fmt"
    "io"
    "log"
    "os"
    "strings"

    "github.com/pixperk/grpc_exam/proto/generated/exampb"
)

func BiDirectional(client exampb.ExamServiceClient) {
    //body
}

让我们逐步来看看这个函数体里都发生了什么:

go 复制代码
stream, err := client.LiveExamQuery(context.Background())
done := make(chan struct{})
  • LiveExamQuery 会与服务器建立一个双向流。
  • 创建 done 通道是为了在接收方 goroutine 结束时发出信号。
go 复制代码
go func() {
        for {
            res, err := stream.Recv() //receive stream from the server
            if err != nil {
                if err == io.EOF {
                    break
                }
                log.Fatalf("Error receiving response: %v", err)
                break
            }
            fmt.Printf("🎓 %s | %s: %d/%d (%s)\n",
                res.StudentName, res.Subject, res.MarksObtained, res.TotalMarks, res.Grade)

            fmt.Print("Enter student_id and exam_id (or 'exit'): ")
        }
        close(done)

    }()

    // Initial prompt
    fmt.Print("Enter student_id and exam_id (or 'exit'): ")
  • 这个 goroutine 用来监听服务器的响应并将其打印出来。
  • 它在后台运行,而主线程负责处理用户输入。
go 复制代码
reader := bufio.NewReader(os.Stdin)
//Send data
    for {
        line, _ := reader.ReadString('\n')
        line = strings.TrimSpace(line)
        if line == "exit" {
            stream.CloseSend()
            break
        }
        parts := strings.Fields(line)
        if len(parts) != 2 {
            fmt.Println("⚠️  Usage: <student_id> <exam_id>")
            continue
        }
        req := &exampb.GetExamResultRequest{
            StudentId: parts[0],
            ExamId:    parts[1],
        }
        if err := stream.Send(req); err != nil {
            log.Printf("send error: %v", err)
            break
        }
    }
  • 读取用户输入(student_id exam_id)。
  • 通过流将每个请求发送到服务器。
  • 如果输入 exit,客户端会关闭发送流。
go 复制代码
<-done
fmt.Println("👋 Session ended.")
  • 通过 done 通道等待接收端 goroutine 结束。
  • 确保所有通信完成后程序才退出。
  • 现在可以像之前那样,使用 make 命令来测试这个双向 RPC。

收获总结

通过该项目你将学会了以下知识:

  • 编写 .proto 并生成 Go 代码;
  • 实现 Unary / Streaming / 双向流 全套 RPC;
  • 在 Go 中用通道与协程管理并发;
  • 组织微服务项目结构,保持易维护、可扩展。

无论你是刚入门 RPC,还是想在微服务中实现高效通信,都希望这份项目能提供一个良好的起点。

  • 知识星球:云原生AI实战营。10+ 高质量体系课( Go、云原生、AI Infra)、15+ 实战项目,P8 技术专家助你提高技术天花板,冲击百万年薪!
  • 公众号:令飞编程,分享 Go、云原生、AI Infra 相关技术。回复「资料」免费下载 Go、云原生、AI 等学习资料;
  • 哔哩哔哩:令飞编程 ,分享技术、职场、面经等,并有免费直播课「云原生AI高新就业课」,大厂级项目实战到大厂面试通关;
相关推荐
yzx9910133 分钟前
集成学习实际案例
人工智能·机器学习·集成学习
CodeJourney.4 分钟前
DeepSeek与WPS的动态数据可视化图表构建
数据库·人工智能·信息可视化
jndingxin5 分钟前
OpenCV 图形API(62)特征检测-----在图像中查找最显著的角点函数goodFeaturesToTrack()
人工智能·opencv·计算机视觉
努力犯错7 分钟前
昆仑万维开源SkyReels-V2,解锁无限时长电影级创作,总分83.9%登顶V-Bench榜单
大数据·人工智能·语言模型·开源
用户01422600298412 分钟前
golang方法指针接收者和值接收者
go
小华同学ai14 分钟前
40.8K star!让AI帮你读懂整个互联网:Crawl4AI开源爬虫工具深度解析
人工智能
文慧的科技江湖27 分钟前
图文结合 - 光伏系统产品设计PRD文档 -(慧哥)慧知开源充电桩平台
人工智能·开源·储能·训练·光伏·推理
白熊18831 分钟前
【计算机视觉】CV实战项目 - 基于YOLOv5与DeepSORT的智能交通监控系统:原理、实战与优化
人工智能·yolo·计算机视觉
gis收藏家43 分钟前
几何编码:启用矢量模式地理空间机器学习
人工智能·机器学习