深入理解 RPC:从 HTTP 模拟到 Go 内置实现

一、RPC 核心概念

1. 什么是 RPC

RPC(Remote Procedure Call,远程过程调用) 是一个节点请求另一个节点提供的服务。

  • 本地过程调用:最常见的形式是函数调用,如 Add(1, 2),程序在同一进程内执行
  • 远程过程调用:将函数执行放在远程机器上,通过网络返回结果,调用方感知不到“远程”的存在

RPC 的目标是让开发者像调用本地函数一样调用远程服务,框架负责屏蔽网络通信、序列化、函数定位等底层细节。

RPC 不是某个具体的第三方服务,而是一种设计思想或编程模式——它定义了一套规范,让程序可以调用另一个程序中的函数。这个“另一个程序”通常运行在独立的进程中(即微服务架构中的一个服务),两者通过网络通信。实现这套规范的库(如 Go 的 net/rpc、gRPC 等)统称为 RPC 框架。

2. RPC 要解决的三大核心问题

1) Call ID 映射

远程调用无法像本地调用那样使用函数指针(不同进程的地址空间不同),需要为每个函数分配唯一的 Call ID,客户端和服务端各自维护函数与 Call ID 的映射表。

调用流程:

  1. 客户端查表获取目标函数的 Call ID
  2. 将 Call ID 通过网络发送到服务端
  3. 服务端查表确定要调用的函数
2) 序列化与反序列化

参数和返回值需要在网络传输前转换为字节流,接收方再还原为内存对象。

操作方向说明
序列化对象 → 字节流客户端发送参数、服务端返回结果
反序列化字节流 → 对象服务端接收参数、客户端接收结果
  • 常见序列化协议

    JSON、XML、Protocol Buffers(Protobuf)、MessagePack、Gob(Go 内置)

  • 序列化协议的选择逻辑

    序列化协议的核心差异在于数据格式解析效率

    • 文本协议(JSON、XML):可读性好,便于调试,但体积大、解析慢。适合对外 API、调试场景
    • 二进制协议(Protobuf、MessagePack、Gob):体积小、解析快,但不可读。适合内部服务间通信

    这也是 gRPC 选择 Protobuf 的原因——微服务内部通信对性能和带宽有更高要求,二进制协议的优势在此场景下更为明显。

3) 网络传输

需要可靠的网络传输机制,传输 Call ID 和序列化后的数据。

协议特点适用场景
TCP可靠、面向连接大多数 RPC 框架的基础
HTTP/1.x短连接、文本协议简单的 Web API
HTTP/2多路复用、长连接gRPC 的基础

3. RPC 架构的四大组件

组件角色核心职责
Client(客户端)服务调用方发起 RPC 请求
Client Stub(客户端存根)本地代理对调用方隐藏“远程”的存在,让远程调用像本地调用一样自然
Server Stub(服务端存根)本地代理接收请求,反序列化,调用实际方法,返回结果
Server(服务端)服务提供方执行实际的业务逻辑

Stub 的本质价值是让调用方只关心“调什么”,不关心“怎么调”。没有 Stub,调用方需要手动处理连接、序列化、网络发送、接收、反序列化等一系列步骤——相当于笔记中“通过 HTTP 理解 RPC”示例的客户端 Add 函数里的全部代码。Stub 将这些底层细节完全封装,开发者只需要像调用本地函数一样调用代理方法即可。


二、通过 HTTP 理解 RPC

在深入 Go 内置 RPC 之前,先用原生 HTTP 实现一个简易 RPC 示例,理解 RPC 要解决的核心问题。

1. 服务端实现

// server/main.go
package main

import (
    "encoding/json"
    "net/http"
    "strconv"
)

// 加法服务
func addHandler(w http.ResponseWriter, r *http.Request) {
    // 1. 解析 URL 参数
    r.ParseForm()
    a, _ := strconv.Atoi(r.Form.Get("a"))
    b, _ := strconv.Atoi(r.Form.Get("b"))

    // 2. 执行业务逻辑
    result := a + b

    // 3. 返回 JSON 响应
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]int{"data": result})
}

func main() {
    // 注册路由:将 URL 路径 /add 映射到 addHandler 函数
    http.HandleFunc("/add", addHandler)

    // 启动 HTTP 服务,监听 8000 端口(阻塞调用)
    http.ListenAndServe(":8000", nil)
}

2. 客户端实现

// client/main.go
package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
)

type Response struct {
    Data int `json:"data"`
}

func Add(a, b int) int {
    // 1. 构造请求 URL(参数以 key=value 格式拼接到 URL 中,这是一种最简单的序列化方式)
    url := fmt.Sprintf("http://127.0.0.1:8000/add?a=%d&b=%d", a, b)

    // 2. 发送 HTTP GET 请求(阻塞调用,等待服务端响应)
    resp, err := http.Get(url)
    if err != nil {
        fmt.Println("请求失败:", err)
        return 0
    }
    defer resp.Body.Close()

    // 3. 读取响应体
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Println("读取响应失败:", err)
        return 0
    }

    // 4. 反序列化 JSON 响应
    var result Response
    json.Unmarshal(body, &result)
    return result.Data
}

func main() {
    fmt.Println(Add(1, 2)) // 3
}

代码中 http.Get 是阻塞调用,它会等待服务器返回响应(或超时、出错)后才会继续执行后续代码。

3. 这种实现的局限

通过 HTTP 手动实现远程调用,虽然能工作,但存在明显的不足:

问题说明
手动处理 HTTP 细节每个接口都需要重复写 URL 拼接、Header 设置、状态码判断
手动序列化/反序列化每次调用都要手动 json.Marshal/Unmarshal
函数映射硬编码函数通过 URL 路径(如 /add)硬编码,无法动态扩展
  • 当前客户端 Add 函数虽然已经封装了 HTTP 调用细节,从调用方视角看“像本地函数”,但这个封装是手动为每个函数逐一编写的。如果有几十上百个函数,手动封装的工作量会急剧膨胀。RPC 框架的价值在于:自动完成这个封装过程——你只需要定义服务接口,框架自动生成客户端代理,序列化、网络传输、函数映射等细节都由框架处理。

  • 这个 HTTP 示例很好地说明了 RPC 框架要解决的核心问题:将网络通信、序列化、函数映射等通用逻辑抽象出来,让开发者无需手动为每个函数编写封装代码,就能像调用本地函数一样调用远程服务。


三、Go 内置 RPC 开发

Go 标准库 net/rpc 提供了完整的 RPC 实现,自动处理序列化、网络传输和函数映射。

1. 服务端实现

// server/main.go
package main

import (
    "net"
    "net/rpc"
)

// 1. 定义服务结构体(首字母大写,导出)
type HelloService struct{}

// 2. 定义服务方法
// 方法签名要求:func (t *T) MethodName(args *Args, reply *Reply) error
func (h *HelloService) Hello(request string, reply *string) error {
    *reply = "hello " + request
    return nil
}

func main() {
    // 3. 注册服务
    rpc.RegisterName("HelloService", new(HelloService))

    // 4. 启动 TCP 监听
    listener, _ := net.Listen("tcp", ":1234")

    // 5. 接受并处理连接(并发)
    for {
        conn, _ := listener.Accept()
        go rpc.ServeConn(conn)
    }
}

2. 客户端实现

// client/main.go
package main

import (
    "fmt"
    "net/rpc"
)

func main() {
    // 1. 建立连接
    client, err := rpc.Dial("tcp", "localhost:1234")
    if err != nil {
        panic("连接失败")
    }

    // 2. 发起 RPC 调用
    var reply string
    err = client.Call("HelloService.Hello", "bobby", &reply)
    if err != nil {
        panic("调用失败")
    }

    fmt.Println(reply) // hello bobby
}

3. 方法签名规则

Go 内置 RPC 对方法签名有严格要求,否则无法注册:

func (t *T) MethodName(args *Args, reply *Reply) error

必须满足以下条件:

  • 方法必须导出(首字母大写)
  • 接收者类型必须导出(首字母大写)或为内置类型
  • 方法必须有两个参数:请求参数(任意类型)和响应参数(必须是指针
  • 返回值必须是 error

4. 常见错误

错误1:响应参数未分配内存

// 错误写法:传递 nil 指针
var reply *string
client.Call("HelloService.Hello", "bobby", reply)
// 报错:DecodeValue of unassignable value

// 正确写法:使用 new 或已声明的变量
reply := new(string)
client.Call("HelloService.Hello", "bobby", reply)

// 或
var reply string
client.Call("HelloService.Hello", "bobby", &reply)

错误2:方法签名不正确

// 错误写法:第二个参数不是指针
func (h *HelloService) Hello(request string, reply string) error

// 错误写法:返回值不是 error
func (h *HelloService) Hello(request string, reply *string) string

// 错误写法:方法未导出
func (h *HelloService) hello(request string, reply *string) error

// 正确写法
func (h *HelloService) Hello(request string, reply *string) error

四、替换序列化协议为 JSON

Go 内置 RPC 默认使用 gob 协议(Go 原生二进制格式),性能好但仅限 Go 语言使用。替换为 JSON 协议后,任何语言只要支持 JSON-RPC 即可调用。

1. 服务端改造

// server/main.go
package main

import (
    "net"
    "net/rpc"
    "net/rpc/jsonrpc"
)

type HelloService struct{}

func (h *HelloService) Hello(request string, reply *string) error {
    *reply = "hello " + request
    return nil
}

func main() {
    rpc.RegisterName("HelloService", new(HelloService))

    listener, _ := net.Listen("tcp", ":1234")

    for {
        conn, _ := listener.Accept()
        go func() {
            // 关键改动:使用 jsonrpc 替代默认的 gob
            rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
        }()
    }
}

2. 客户端改造

// client/main.go
package main

import (
    "fmt"
    "net"
    "net/rpc"
    "net/rpc/jsonrpc"
)

func main() {
    // 关键改动:使用 net.Dial + jsonrpc
    conn, _ := net.Dial("tcp", "localhost:1234")
    client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))

    var reply string
    client.Call("HelloService.Hello", "bobby", &reply)
    fmt.Println(reply)
}

3. 跨语言验证:Python 客户端

import json
import socket

# 构造 JSON-RPC 请求
request = {
    "id": 0,
    "params": ["bobby"],
    "method": "HelloService.Hello"
}

# 发送 TCP 请求
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("localhost", 1234))
sock.sendall(json.dumps(request).encode())

# 接收并解析响应
response = json.loads(sock.recv(1024).decode())
print(response["result"])  # hello bobby

通过 Python 客户端成功调用 Go 服务,证明 JSON-RPC 可以实现跨语言通信


五、封装客户端代理

原生 client.Call 调用方式不够直观,需要通过代理模式封装,实现类似本地函数的调用体验。

1. 原生调用的不足

// 原生调用需要手动拼接服务名和方法名
var reply string
client.Call("HelloService.Hello", "bobby", &reply)

问题

  • 服务名和方法名以字符串形式硬编码,容易拼写错误
  • 每次调用都要重复写相同的参数结构
  • 调用体验与本地函数差异大

2. 代理模式实现

// client/proxy.go
package main

import (
    "net/rpc"
)

// 客户端代理结构体
type HelloServiceClient struct {
    client *rpc.Client
}

// 工厂函数:创建代理实例
func NewHelloServiceClient(addr string) *HelloServiceClient {
    client, _ := rpc.Dial("tcp", addr)
    return &HelloServiceClient{client: client}
}

// 代理方法:封装 RPC 调用
func (c *HelloServiceClient) Hello(request string, reply *string) error {
    return c.client.Call("HelloService.Hello", request, reply)
}

3. 使用代理

// client/main.go
package main

import (
    "fmt"
)

func main() {
    // 代理封装了连接和调用细节
    client := NewHelloServiceClient("localhost:1234")

    var reply string
    client.Hello("bobby", &reply)
    fmt.Println(reply) // hello bobby
}

现在调用方式与本地函数完全一致,RPC 细节完全隐藏在代理内部。这为后续迁移到 gRPC 等框架打下了基础。

在实际项目中,客户端代理通常由代码生成工具自动生成(如 gRPC 的 protoc 插件),无需手写。这里的示例是为了理解代理模式的工作原理。如果项目有多个服务,可以按服务拆分代理文件(如 hello_proxy.gouser_proxy.go),但包名需要根据目录结构决定——如果放在 client/ 下并与 main.go 同包,则仅限该包内使用;如果设计为可被其他包导入的库,则需要将代理代码放在独立的包中(如 client/proxy/ 下声明 package proxy)。


六、业务逻辑解耦

将业务逻辑从网络层分离,提高代码的可维护性和可测试性。

1. 独立业务逻辑包

// handler/hello.go
package handler

type HelloService struct{}

func (h *HelloService) Hello(request string, reply *string) error {
    *reply = "hello " + request
    return nil
}

2. 服务端注册

// server/main.go
package main

import (
    "go-playground/handler"
    "net"
    "net/rpc"
)

func main() {
    // 注册业务逻辑(不依赖网络细节)
    rpc.RegisterName("HelloService", new(handler.HelloService))

    listener, _ := net.Listen("tcp", ":1234")
    for {
        conn, _ := listener.Accept()
        go rpc.ServeConn(conn)
    }
}
3. 解耦的核心价值
价值说明
可测试性业务逻辑层与网络层解耦后,测试时可以直接实例化 HelloService,像测试普通函数一样测试 Hello 方法,无需启动完整的 RPC 服务进程
可维护性业务代码变更不影响网络层代码
框架迁移切换 RPC 框架(如从内置 RPC 迁移到 gRPC)时,业务逻辑层无需改动
代码清晰职责分离,每层关注自己的核心问题

七、Go 内置 RPC 的适用场景

Go 内置 RPC(net/rpc)适合学习和理解 RPC 原理,但在实际生产项目中较少使用。主要原因:

  • 传输协议单一:仅支持 TCP,不支持 HTTP/2,无法利用多路复用、流控等特性
  • 跨语言能力弱:Gob 是 Go 特有的,JSON-RPC 虽然跨语言但性能不如 Protobuf
  • 缺乏治理能力:没有服务发现、负载均衡、超时控制、链路追踪等分布式系统所需的功能
  • 生态已迁移:社区和企业几乎都转向了 gRPC,后者已成为 Go 微服务通信的事实标准

这也正是后续学习 gRPC 的价值所在。


小结

知识点核心内容
RPC 三大核心问题Call ID 映射、序列化/反序列化、网络传输
RPC 四大架构组件Client、Client Stub、Server Stub、Server
Go 内置 RPCnet/rpc + net/rpc/jsonrpc;方法签名 func(*args, *reply) error
序列化协议Gob(Go 默认)、JSON(跨语言)、Protobuf(高性能)
客户端代理封装通过代理结构体隐藏网络细节,实现本地化调用
业务解耦业务逻辑独立于网络层,便于测试和框架迁移
跨语言通信JSON-RPC 使得 Python 等语言可调用 Go 服务
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值