Web 开发中,我们经常使用 HTTP 协议中的 HEAD、GET、POST 等方式发送请求,等待响应。但 RPC 的消息格式与标准的 HTTP 协议并不兼容,在这种情况下,就需要一个协议的转换过程。HTTP 协议的 CONNECT 方法提供了这个能力,CONNECT 一般用于代理服务。
CONNECT请求是HTTP协议中的一种特殊请求方法,主要用于建立隧道连接。它允许客户端通过代理服务器与目标服务器建立一条直接的TCP连接,用于传输非HTTP协议的数据。
现在大多数浏览器与服务器之间都是 HTTPS 通信,其都是加密的,浏览器通过代理服务器发起 HTTPS 请求时,由于请求的站点地址和端口号都是加密保存在 HTTPS 请求报文头中的,代理服务器如何知道往哪里发送请求呢?
为了解决这个问题,浏览器通过 HTTP 明文形式向代理服务器发送一个 CONNECT 请求告诉代理服务器目标地址和端口,代理服务器接收到这个请求后,会在对应端口与目标站点建立一个 TCP 连接,连接建立成功后返回 HTTP 200 状态码告诉浏览器与该站点的加密通道已经完成。接下来代理服务器仅需透传浏览器和服务器之间的加密数据包即可,代理服务器无需解析 HTTPS 报文。
浏览器向代理服务器发送 CONNECT 请求的例子。
CONNECT www.microsoft.com:443 HTTP/1.0
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko
Host: www.microsoft.com
Content-Length: 0
DNT: 1
Connection: Keep-Alive
Pragma: no-cache
主要是三步:
1.浏览器向代理服务器发送 CONNECT 请求。
CONNECT www.baidu.com:443 HTTP/1.0
2.代理服务器返回 HTTP 200 状态码表示连接已经建立。
HTTP/1.0 200 Connection Established
3.之后浏览器和服务器开始 HTTPS 握手并交换加密数据,代理服务器只负责传输彼此的数据包,并不能读取具体数据内容(代理服务器也可以选择安装可信根证书解密 HTTPS 报文)。
客户端向服务端发起连接,就像第一步的浏览器向代理服务器发送 CONNECT 请求,所以客户端需要添加HTTP CONNECT 请求创建连接的逻辑。而服务端就需要将客户端的HTTP协议的消息转化成该rpc协议。
这里默认读者对Go语言的http使用是相对熟悉的了,不会讲解太多基础内容。
那通信过程应该是这样的:
那服务端就需要添加返回HTTP200状态码给客户端的操作。那回顾下服务端的建立连接的操作。
func (server *Server) Accept(lis net.Listener) {
for {
conn, err := lis.Accept()
// 拿到客户端的连接, 开启新协程异步去处理.
go server.ServeConn(conn)
}
}
accept后就到了server.ServeConn(conn),所以后序http中我们会需要用到这个方法的。
//server.go
const (
connected = "200 Connected to RPC"
defaultRPCPath = "/myrpc"
defaultDebugPath = "/debug/rpc"
)
// server HTTP部分,server实现了ServeHTTP方法,就是http.Handler接口了
func (server *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method != "CONNECT" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusMethodNotAllowed)
io.WriteString(w, "405 must CONNECT\n")
return
}
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
log.Print("rpc hijacking ", req.RemoteAddr, " :", err.Error())
}
io.WriteString(conn, "HTTP/1.0 "+connected+"\n\n")
//server.ServeConn(conn)就回到了之前的accept后的那部分
server.ServeConn(conn)
}
func (server *Server) HandleHTTP() {
//方法原型func Handle(pattern string, handler Handler)
http.Handle(defaultRPCPath, server)
}
func HandleHTTP() {
DefaultServer.HandleHTTP()
}
defaultDebugPath
是为后续 DEBUG 页面预留的地址。
Go语言实现http是比较容易的,只需要实现接口 Handler 即可作为一个 HTTP Handler 处理 HTTP 请求。接口 Handler 只定义了一个方法 ServeHTTP
,实现该方法即可。
ServeHTTP方法中首先是判断HTTP请求方法是否是CONNECT。之后就到了
w.(http.Hijacker).Hijack()。
http.ResponseWriter是接口类型,w.(http.Hijacker)是将w转化成http.Hijacker类型。
这里是接管 HTTP 连接,其指接管了 HTTP 的 TCP 连接,也就是说 Golang 的内置 HTTP 库和 HTTPServer 库将不会管理这个 TCP 连接的生命周期,这个生命周期已经划给 Hijacker 了。
Hijack()
可以将HTTP对应的TCP连接取出,连接在Hijack()
之后,HTTP的相关操作就会受到影响,调用方需要负责去关闭连接。
之前已经分析了,要把HTTP协议的转换成自定义的RPC协议,所以就可以使用Hijack()。
一般在创建连接阶段使用HTTP连接,后续自己完全处理connection,那就符合了我们想把HTTP协议的转换成自定义的RPC协议的做法。
来看看和正常的HTTP请求的区别
func main() {
http.HandleFunc("/hijack", func(w http.ResponseWriter, r *http.Request) {
conn, buf, _ := w.(http.Hijacker).Hijack()
defer conn.Close()
buf.WriteString("hello hijack\n")
buf.Flush()
})
http.HandleFunc("/htt", func(writer http.ResponseWriter, request *http.Request) {
io.WriteString(writer, "hello htt\n")
})
http.ListenAndServe("localhost:10000", nil)
}
首先我们能看到使用Hijack
的请求返回没有响应头信息。这里我们要明白的是,Hijack
之后虽然能正常输出数据,但完全没有遵守http协议。这里net/http源码里做的一些处理,这里就不展开说了。
总结:Hijack的使用场景
:当不想使用内置服务器的HTTP协议实现时,请使用Hijack
。一般在创建连接阶段使用HTTP连接,后续自己完全处理connection的情况。
客户端要做的,发起 CONNECT 请求,检查返回状态码即可成功建立连接。
//client.go
// HTTP部分
func NewHTTPClient(conn net.Conn, opt *Option) (*Client, error) {
io.WriteString(conn, fmt.Sprintf("CONNECT %s HTTP/1.0\n\n", defaultRPCPath))
resp, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: "CONNECT"})
if err == nil && resp.Status == connected {
return NewClient(conn, opt)
}
if err != nil {
err = errors.New("unexpected HTTP response: " + resp.Status)
}
return nil, err
}
func DialHTTP(network, address string, opts ...*Option) (*Client, error) {
opt, err := parseOptions(opts...)
if err != nil {
return nil, err
}
return dialTimeout(NewHTTPClient, network, address, opt)
}
上一节的newClientFunc类型在这里就派上用场了,我们编写一个建立HTTP连接的函数,把该函数传给dialTimeout即可。
在NewHTTPClient函数中,通过 HTTP CONNECT 请求建立连接之后,后续的通信过程就交给 NewClient 了。
为了简化调用,提供了一个统一入口 XDial
// 统一的建立rpc客户端的接口
// rpcAddr格式 [email protected]:34232,[email protected]:10000
func XDial(rpcAddr string, opts ...*Option) (*Client, error) {
parts := strings.Split(rpcAddr, "@")
if len(parts) != 2 {
return nil, fmt.Errorf("rpc client err: wrong format '%s', expect protocol@addr", rpcAddr)
}
protocol, addr := parts[0], parts[1]
switch protocol {
case "http":
return DialHTTP("tcp", addr, opts...)
default:
// tcp, unix or other transport protocol
return Dail(protocol, addr, opts...)
}
}
支持 HTTP 协议的好处在于,RPC 服务仅仅使用了监听端口的 /myrpc
路径,在其他路径上我们可以提供诸如日志、统计等更为丰富的功能。接下来我们在 /debug/rpc
上展示服务的调用统计视图。
//debug.go
//debugText不需要关注过多
const debugText = `
GeeRPC Services
{{range .}}
Service {{.Name}}
Method Calls
{{range $name, $mtype := .Method}}
{{$name}}({{$mtype.ArgType}}, {{$mtype.ReplyType}}) error
{{$mtype.NumCalls}}
{{end}}
{{end}}
`
var debug = template.Must(template.New("RPC debug").Parse(debugText))
type debugHTTP struct {
*Server //继承做法
}
type debugService struct {
Name string
Method map[string]*methodType
}
// Runs at /debug/rpc, 调用的是debugHTTP的ServeHTTP,不是server结构体的ServeHTTP
func (server debugHTTP) ServerHTTP(w http.ResponseWriter, rep *http.Request) {
var services []debugService
//sync.Map遍历,Range方法并配合一个回调函数进行遍历操作。通过回调函数返回遍历出来的键值对。
server.serviceMap.Range(func(namei, svci any) bool {
svc := svci.(*service) //转换成*service类型
services = append(services, debugService{
Name: namei.(string),
Method: svc.method,
})
return true //当需要继续迭代遍历时,Range参数中回调函数返回true;否则返回false
})
err := debug.Execute(w, services)
if err != nil {
fmt.Fprintln(w, "rpc: error executing template:", err.Error())
}
}
在这里,我们将返回一个 HTML 报文,这个报文将展示注册所有的 service 的每一个方法的调用情况。
将 debugHTTP 实例绑定到地址 /debug/rpc,需要在server.go文件的func (server *Server) HandleHTTP()方法中继续添加。
func (server *Server) HandleHTTP() {
//方法原型func Handle(pattern string, handler Handler)
http.Handle(defaultRPCPath, server)
http.Handle(defaultDebugPath, debugHTTP{Server: server}) //这个是新添加的,处理debug的
}
debugHTTP做好后,就可以进行测试了。使用HTTP协议的rpc用法和之前的是稍微有点不同。
服务端中的变化是将 startServer 中的 geerpc.Accept()
替换为了 geerpc.HandleHTTP()
,之后就是使用http.ListenAndServe()。
type My int
type Args struct{ Num1, Num2 int }
func (m *My) Sum(args Args, reply *int) error {
*reply = args.Num1 + args.Num2
// time.Sleep(time.Second * 3)
return nil
}
func startServer(addrCh chan string) {
var myServie My
//这里一定要用&myServie,因为前面Sum方法的接受者是*My;若接受者是My,myServie或者&myServie都可以
if err := geerpc.Register(&myServie); err != nil {
slog.Error("register error:", err) //slog是Go官方的日志库
os.Exit(1)
}
geerpc.HandleHTTP()
addrCh <- "127.0.0.1:10000"
log.Fatal(http.ListenAndServe("127.0.0.1:10000", nil))
//之前的写法
// l, err := net.Listen("tcp", "localhost:10000")
// geerpc.Accept(l)
}
客户端将 Dial
替换为 DialHTTP
,其余地方没有发生改变。
func clientCall(addrCh chan string) {
addr := <-addrCh
fmt.Println(addr)
client, err := geerpc.DialHTTP("tcp", addr)
if err != nil {
panic(err)
}
defer client.Close()
num := 5
var wg sync.WaitGroup
wg.Add(num)
for i := 0; i < num; i++ {
go func(i int) {
defer wg.Done()
args := &Args{Num1: i, Num2: i * i}
var reply int = 1324
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
if err := client.Call(ctx, "My.Sum", args, &reply); err != nil {
log.Println("call Foo.Sum error:", err)
}
fmt.Println("reply: ", reply)
}(i)
}
wg.Wait()
}
func main() {
ch := make(chan string)
go clientCall(ch)
startServer(ch)
}
效果如下
若是在浏览器输入http://localhost:10000/debug/rpc,出现如下效果
完整代码:https://github.com/liwook/Go-projects/tree/main/geerpc/5-http-debug