Golang HTTP 标准库实现原理(下) | 青训营
2023/8/22 ·雨辰login
接着讲HTTP。
本篇内容引用自知乎用户@小徐先生.
这真的是一个宝藏博主,B站也有号:小徐先生1212
所以把他的笔记找来跟大家分享,希望大家都去看看,真的做的非常好。
3 客户端
3.1 核心数据结构
(1)Client
与 Server 对仗,客户端模块也有一个 Client 类,实现对整个模块的封装:
- Transport:负责 http 通信的核心部分,也是接下来的讨论重点
- Jar:cookie 管理
- Timeout:超时设置
type Client struct {
// ...
Transport RoundTripper
// ...
Jar CookieJar
// ...
Timeout time.Duration
}
(2)RoundTripper
RoundTripper 是通信模块的 interface,需要实现方法 Roundtrip,即通过传入请求 Request,与服务端交互后获得响应 Response.
type RoundTripper interface {
RoundTrip(*Request) (* Response, error)
}
(3)Transport
Tranport 是 RoundTripper 的实现类,核心字段包括:
- idleConn:空闲连接 map,实现复用
- DialContext:新连接生成器
type Transport struct {
idleConn map[connectMethodKey][]*persistConn // most recently used at end*
// ...
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
// ...
}
(4)Request
http 请求参数结构体.
type Request struct {
// 方法
Method string
// 请求路径
URL *url.URL*
// 请求头
Header Header
// 请求参数内容
Body io.ReadCloser
// 服务器主机
Host string
// query 请求参数
Form url.Values
// 响应参数 struct
Response *Response*
// 请求链路的上下文
ctx context.Context
// ...
}
(5)Response
http 响应参数结构体.
type Response struct {
// 请求状态,200 为 请求成功
StatusCode int // e.g. 200
// http 协议,如:HTTP/1.0
Proto string // e.g. "HTTP/1.0"
// 请求头
Header Header
// 响应参数内容
Body io.ReadCloser
// 指向请求参数
Request *Request*
// ...
}
3.2 方法链路总览
客户端发起一次 http 请求大致分为几个步骤:
- 构造 http 请求参数
- 获取用于与服务端交互的 tcp 连接
- 通过 tcp 连接发送请求参数
- 通过 tcp 连接接收响应结果
整体方法链路如下图:
3.3 Client.Post
调用 net/http 包下的公开方法 Post 时,需要传入服务端地址 url,请求参数格式 contentType 以及请求参数的 io reader.
方法中会使用包下的单例客户端 DefaultClient 处理这次请求.
var DefaultClient = &Client{}
func Post(url, contentType string, body io.Reader) (resp *Response, err error) {*
return DefaultClient.Post(url, contentType, body)
}
在 Client.Post 方法中,首先会结合用户的入参,构造出完整的请求参数 Request;继而通过 Client.Do 方法,处理这笔请求.
func (c *Client) Post(url, contentType string, body io.Reader) (resp *Response, err error) {
req, err := NewRequest("POST", url, body)
// ...
req.Header.Set("Content-Type", contentType)
return c.Do(req)
}
3.4 NewRequest
NewRequestWithContext 方法中,根据用户传入的 url、method等信息,构造了 Request 实例.
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
// ...
u, err := urlpkg.Parse(url)
// ...
rc, ok := body.(io.ReadCloser)
// ...
req := &Request{
ctx: ctx,
Method: method,
URL: u,
// ...
Header: make(Header),
Body: rc,
Host: u.Host,
}
// ...
return req, nil
}
3.5 Client.Do
发送请求方法时,经由 Client.Do、Client.do 辗转,继而步入到 Client.send 方法中.
func (c *Client) Do(req *Request) (*Response, error) {
return c.do(req)
}
func (c *Client) do(req *Request) (retres *Response, reterr error) {
var (
deadline = c.deadline()
resp *Response
// ...
)
for {
// ...
var err error
if resp, didTimeout, err = c.send(req, deadline); err != nil {
// ...
}
// ...
}
}
在 Client.send 方法中,会在通过 send 方法发送请求的前后,分别对 cookie 进行更新.
func (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
// 设置 cookie 到请求头中
if c.Jar != nil {
for _, cookie := range c.Jar.Cookies(req.URL) {
req.AddCookie(cookie)
}
}
// 发送请求
resp, didTimeout, err = send(req, c.transport(), deadline)
if err != nil {
return nil, didTimeout, err
}
// 更新 resp 的 cookie 到请求头中
if c.Jar != nil {
if rc := resp.Cookies(); len(rc) > 0 {
c.Jar.SetCookies(req.URL, rc)
}
}
return resp, nil, nil
}
在调用 send 方法时,需要注入 RoundTripper 模块,默认会使用全局单例 DefaultTransport 进行注入,核心逻辑位于 Transport.RoundTrip 方法中,其中分为两个步骤:
- 获取/构造 tcp 连接
- 通过 tcp 连接完成与服务端的交互
var DefaultTransport RoundTripper = &Transport{
// ...
DialContext: defaultTransportDialContext(&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}),
// ...
}
func (c *Client) transport() RoundTripper {
if c.Transport != nil {
return c.Transport
}
return DefaultTransport
}
func send(ireq *Request, rt RoundTripper, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
// ...
resp, err = rt.RoundTrip(req)
// ...
return resp, nil, nil
}
func (t *Transport) RoundTrip(req *Request) (*Response, error) {
return t.roundTrip(req)
}
func (t *Transport) roundTrip(req *Request) (*Response, error) {
// ...
for {
// ...
treq := &transportRequest{Request: req, trace: trace, cancelKey: cancelKey}
// ...
pconn, err := t.getConn(treq, cm)
// ...
resp, err = pconn.roundTrip(treq)
// ...
}
}
3.6 Transport.getConn
获取 tcp 连接的策略分为两步:
- 通过 queueForIdleConn 方法,尝试复用采用相同协议、访问相同服务端地址的空闲连接
- 倘若无可用连接,则通过 queueForDial 方法,异步创建一个新的连接,并通过接收 ready channel 信号的方式,确认构造连接的工作已经完成.
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
// 获取连接的请求参数体
w := &wantConn{
cm: cm,
// key 由 http 协议、服务端地址等信息组成
key: cm.key(),
ctx: ctx,
// 标识连接构造成功的信号发射器
ready: make(chan struct{}, 1),
}
// 倘若连接获取失败,在 wantConn.cancel 方法中,会尝试将 tcp 连接放回队列中以供后续复用
defer func() {
if err != nil {
w.cancel(t, err)
}
}()
// 尝试复用指向相同服务端地址的空闲连接
if delivered := t.queueForIdleConn(w); delivered {
pc := w.pc
// ...
return pc, nil
}
// 异步构造新的连接
t.queueForDial(w)
select {
// 通过阻塞等待信号的方式,等待连接获取完成
case <-w.ready:
// ...
return w.pc, w.err
// ...
}
}
(1)复用连接
- 尝试从 Transport.idleConn 中获取指向同一服务端的空闲连接 persisConn
- 获取到连接后会调用 wantConn.tryDeliver 方法将连接绑定到 wantConn 请求参数上
- 绑定成功后,会关闭 wantConn.ready channel,以唤醒阻塞读取该 channel 的 goroutine
func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
// ...
if list, ok := t.idleConn[w.key]; ok {
// ...
for len(list) > 0 && !stop {
pconn := list[len(list)-1]
// ...
delivered = w.tryDeliver(pconn, nil)
if delivered {
// ...
list = list[:len(list)-1]
}
stop = true
}
// ...
if stop {
return delivered
}
}
// ...
return false
}
func (w *wantConn) tryDeliver(pc *persistConn, err error) bool {
w.mu.Lock()
defer w.mu.Unlock()
// ...
w.pc = pc
w.err = err
// ...
close(w.ready)
return true
}
(2)创建连接
在 queueForDial 方法会异步调用 Transport.dialConnFor 方法,创建新的 tcp 连接. 由于是异步操作,所以在上游会通过读 channel 的方式,等待创建操作完成.
这里之所以采用异步操作进行连接创建,有两部分原因:
- 一个 tcp 连接并不是一个静态的数据结构,它是有生命周期的,创建过程中会为其创建负责读写的两个守护协程,伴随而生
- 在上游 Transport.queueForIdleConn 方法中,当通过 select 多路复用的方式,接收到其他终止信号时,可以提前调用 wantConn.cancel 方法打断创建连接的 goroutine. 相比于串行化执行而言,这种异步交互的模式,具有更高的灵活度
func (t *Transport) queueForDial(w *wantConn) {
// ...
go t.dialConnFor(w)
// ...
}
Transport.dialConnFor 方法中,首先调用 Transport.dialConn 创建 tcp 连接 persisConn,接着执行 wantConn.tryDeliver 方法,将连接绑定到 wantConn 上,然后通过关闭 ready channel 操作唤醒上游读 ready channel 的 goroutine.
func (t *Transport) dialConnFor(w *wantConn) {
// ...
pc, err := t.dialConn(w.ctx, w.cm)
delivered := w.tryDeliver(pc, err)
// ...
}
Transport.dialConn 方法包含了创建连接的核心逻辑:
- 调用 Transport.dial 方法,最终通过 Tranport.DialContext 成员函数,创建好 tcp 连接,封装到 persistConn 当中
- 异步启动连接的伴生读写协程 readLoop 和 writeLoop 方法,组成提交请求、接收响应的循环
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
pconn = &persistConn{
t: t,
reqch: make(chan requestAndChan, 1),
writech: make(chan writeRequest, 1),
// ...
}
conn, err := t.dial(ctx, "tcp", cm.addr())
// ...
pconn.conn = conn
// ...
go pconn.readLoop()
go pconn.writeLoop()
return pconn, nil
}
func (t *Transport) dial(ctx context.Context, network, addr string) (net.Conn, error) {
// ...
return t.DialContext(ctx, network, addr)
// ...
}
在伴生读协程 persisConn.readLoop 方法中,会读取来自服务端的响应,并添加到 persistConn.reqCh 中,供上游 persistConn.roundTrip 方法接收.
func (pc *persistConn) readLoop() {
// ...
alive := true
for alive {
// ...
rc := <-pc.reqch
// ...
var resp *Response
// ...
resp, err = pc.readResponse(rc, trace)
// ...
select{
rc.ch <- responseAndError{res: resp}:
// ...
}
// ...
}
}
在伴生协协程 persisConn.writeLoop方法中,会通过 persistConn.writech 读取到客户端提交的请求,然后将其发送到服务端.
func (pc *persistConn) writeLoop() {
for {
select {
case wr := <-pc.writech:
// ...
err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
// ...
}
}
(3)归还连接
有复用连接的能力,就必然存在归还连接的机制.
首先,在构造新连接中途,倘若被打断,则可能会将连接放回队列以供复用:
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
// ...
// 倘若连接获取失败,在 wantConn.cancel 方法中,会尝试将 tcp 连接放回队列中以供后续复用
defer func() {
if err != nil {
w.cancel(t, err)
}
}()
// ...
}
func (w *wantConn) cancel(t *Transport, err error) {
// ...
if pc != nil {
t.putOrCloseIdleConn(pc)
}
}
func (t *Transport) tryPutIdleConn(pconn *persistConn) error {
// ...
key := pconn.cacheKey
// ...
t.idleConn[key] = append(idles, pconn)
// ...
return nil
}
其次,倘若与服务端的一轮交互流程结束,也会将连接放回队列以供复用.
func (pc *persistConn) readLoop() {
tryPutIdleConn := func(trace *httptrace.ClientTrace) bool {
if err := pc.t.tryPutIdleConn(pc); err != nil {
// ...
}
// ...
}
// ...
alive := true
for alive {
// ...
select {
case bodyEOF := <-waitForBodyRead:
// ...
tryPutIdleConn(trace)
// ...
}
}
}
func (t *Transport) putOrCloseIdleConn(pconn *persistConn) {
if err := t.tryPutIdleConn(pconn); err != nil {
pconn.close(err)
}
}
3.7 persistConn.roundTrip
3.6 小节中谈到,一个连接 persistConn 是一个具有生命特征的角色. 它本身伴有 readLoop 和 writeLoop 两个协程,与应用者之间通过 channel 进行读写交互.
而其中扮演应用者这一角色的,正式本小节谈到的主流程中的方法:persistConn.roundTrip:
- 首先将 http 请求通过 persistConn.writech 发送给连接的守护协程 writeLoop,并进一步传送到服务端
- 其次通过读取 resc channel,接收由守护协程 readLoop 代理转发的客户端响应数据.
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
// ...
pc.writech <- writeRequest{req, writeErrCh, continueCh}
resc := make(chan responseAndError)
pc.reqch <- requestAndChan{
req: req.Request,
cancelKey: req.cancelKey,
ch: resc,
// ...
}
// ...
for {
select {
// ...
case re := <-resc:
// ...
return re.res, nil
// ...
}
}
}
个人感受
读了这篇文章真的收益匪浅,在此与大家分享,希望大家都能有所收益。