1.HTTP的CONNECT方法
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协议。
2.服务端支持 HTTP 协议
这里默认读者对Go语言的http使用是相对熟悉的了,不会讲解太多基础内容。
那通信过程应该是这样的:
- 客户端发送CONNECT请求
- RPC 服务器返回 HTTP 200 状态码表示连接建立。
- 客户端使用创建好的连接发送 RPC 报文,先发送 Option,再发送 N 个请求报文,服务端处理 RPC 请求并响应。
那服务端就需要添加返回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()。
Hijacker
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的情况。
3.客户端支持 HTTP 协议
客户端要做的,发起 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格式 http@10.0.0.1:34232,tpc@10.0.0.1: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 protocolreturn Dail(protocol, addr, opts...)}
}
4.实现简单的 DEBUG 页面
支持 HTTP 协议的好处在于,RPC 服务仅仅使用了监听端口的 /myrpc
路径,在其他路径上我们可以提供诸如日志、统计等更为丰富的功能。接下来我们在 /debug/rpc
上展示服务的调用统计视图。
//debug.go
//debugText不需要关注过多
const debugText = `<html><body><title>GeeRPC Services</title>{{range .}}<hr>Service {{.Name}}<hr><table><th align=center>Method</th><th align=center>Calls</th>{{range $name, $mtype := .Method}}<tr><td align=left font=fixed>{{$name}}({{$mtype.ArgType}}, {{$mtype.ReplyType}}) error</td><td align=center>{{$mtype.NumCalls}}</td></tr>{{end}}</table>{{end}}</body></html>`var debug = template.Must(template.New("RPC debug").Parse(debugText))type debugHTTP struct {*Server //继承做法
}type debugService struct {Name stringMethod 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的
}
5.测试
debugHTTP做好后,就可以进行测试了。使用HTTP协议的rpc用法和之前的是稍微有点不同。
服务端中的变化是将 startServer 中的 geerpc.Accept()
替换为了 geerpc.HandleHTTP()
,之后就是使用http.ListenAndServe()。
type My inttype 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 := <-addrChfmt.Println(addr)client, err := geerpc.DialHTTP("tcp", addr)if err != nil {panic(err)}defer client.Close()num := 5var wg sync.WaitGroupwg.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 = 1324ctx, 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