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 在客户端与服务器之间支持四种通信方式。下面我们逐一进行介绍。

- Unary(单次请求-响应)
客户端发送一次请求 → 服务器返回一次响应。类似普通函数:
protobuf
rpc GetMarks(StudentRequest) returns (MarksResponse);
这是最常见的类型,非常适合 CRUD 风格的操作。
- Server Streaming(服务器流)
客户端一次请求,服务器持续推送多条响应。
protobuf
rpc StreamSemesterResults(SemesterRequest) returns (stream MarksResponse);
当服务器需要发送大量数据时,这种方式特别有用------结果一准备好就会实时推送给你。
- Client Streaming(客户端流)
客户端持续发送一连串请求 → 服务器最终返回一个汇总响应。也就是说,客户端批量推送数据,服务器全部处理完毕后一次性给出结果。
protobuf
rpc UploadAttendance(stream AttendanceEntry) returns (UploadStatus);
非常适合用于发送日志、监控指标或进行批量上传。
- Bidirectional Streaming(双向流)
客户端和服务器可以同时向对方持续发送数据流。就像实时聊天一样,双方能够一边说话一边收听彼此的消息。
protobuf
rpc LiveQuiz(stream QuizMessage) returns (stream QuizMessage);
这正是 gRPC 大显身手的地方:实时多人游戏、协作工具、实时仪表盘------统统不在话下。
Protocol Buffers(Protobuf)简介
Google 推出的跨语言、跨平台序列化协议。
相较 JSON:
- 体积更小:二进制编码;
- 速度更快:序列化 / 反序列化省时;
- 字段用编号:解析无需比对字符串;
Protobuf Buffers 工作流程:
- 写
.proto
描述文件; protoc
编译生成各语言代码;- 在代码里直接调用序列化 / 反序列化方法;

示例:
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 server
和 make 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
函数逻辑如下:
- 不断从客户端接收查询;
- 立即返回对应结果(若存在);
- 直到客户端结束(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,还是想在微服务中实现高效通信,都希望这份项目能提供一个良好的起点。