Go Http标准库及实现原理
本文章的标准库版本为1.20.6
1.整体架构
1.1 架构模式
在http协议下,交互的双方分别为服务端(server)和客户端(client),即熟知的CS模式。本文的研究分别从这两段入手进行展开。
1.2 启动一个简单的CS服务
由于Go的标准库为我们已经封装好了相应的API,所以启动服务的代码非常简单。
go
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/ping", func(writer http.ResponseWriter, request *http.Request) {
fmt.Println("req:ping")
writer.Write([]byte("pong"))
})
http.ListenAndServe(":8080", nil)
}
如上为服务端(server)的代码,主要工作有两个:
- 在
http.HandleFunc
方法中指定了请求路径(pattern)"/ping"的handler函数 - 利用
http.ListenAndServe
方法在本地的8080端口启动了该http服务
至于这两个方法背后隐藏的细节将在第二章中深入探讨。
1.3 在客户端发起http请求
接下来我们通过一个单元测试用例向localhost:8080
发送请求。
go
package main
import (
"io/ioutil"
"net/http"
"testing"
)
func Test_client(t *testing.T) {
resp, err := http.Post("http://localhost:8080/ping", "", nil)
if err != nil {
t.Error(err)
return
}
body, _ := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
t.Logf("%s", body)
t.Error("test")
}
返回结果为:
go
=== RUN Test_client
main_test.go:18: pong
main_test.go:19: test
--- FAIL: Test_client (0.01s)
服务端成功向客户端返回了pone
字符串。
2.服务端
2.1 服务端数据结构
首先我们对http的服务端封装好的数据结构和接口进行分析。
(1)Server类
基于面向对象的思想,服务端模块的属性被封装在Server
结构体中。
该结构体中的两个核心字段分别为Addr
和Handler
go
Server struct {
// Addr optionally specifies the TCP address for the server to listen on,
// in the form "host:port". If empty, ":http" (port 80) is used.
// The service names are defined in RFC 6335 and assigned by IANA.
// See net.Dial for details of the address format.
Addr string
Handler Handler // handler to invoke, http.DefaultServeMux if nil
//···
//···
}
Addr
即为服务端的地址。
Handler
则实现了从请求端path到处理函数handler的注册和映射。
如果在声明服务时,Handler
字段未显式地声明,即传入的参数为nil
,则会使用默认的DefaultServeMux
。
(2)Handler类
Handler在标准库中是以interfac实现的,接口中只有一个方法ServerHTTP
,只要实现了该方法的结构体即可作为一个Handler。
该方法的作用是,根据 http 请求 Request 中的请求路径 path 映射到对应的 handler 处理函数,对请求进行处理和响应。
go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
(3)ServeMux类
先看源码以及注释
go
// ServeMux is an HTTP request multiplexer.
// It matches the URL of each incoming request against a list of registered
// patterns and calls the handler for the pattern that
// most closely matches the URL.
//
type ServeMux struct {
mu sync.RWMutex //对ServeMux对象操作时用的锁
m map[string]muxEntry
es []muxEntry // slice of entries sorted from longest to shortest.
hosts bool // whether any patterns contain hostnames
}
在该结构体内部维护了一个从URL
映射到handler
的map
。由于是静态注册,所以不支持动态路由。
(4)muxEntry类
go
type muxEntry struct {
h Handler
pattern string
}
muxEntry作为一个handler
单元,对pattern和handler进行进一步的封装。
2.2 注册Handler
先来一张流程图看一下大致流程:
在net/http包下会声明一个单例ServeMux
,当用户通过http.HandleFunc
的方法来注册handler时,handler会被注册到这个DefaultServeMux
中。
go
// DefaultServeMux is the default ServeMux used by Serve.
var DefaultServeMux = &defaultServeMux
var defaultServeMux ServeMux
go
// HandleFunc registers the handler function for the given pattern
// in the DefaultServeMux.
// The documentation for ServeMux explains how patterns are matched.
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}
在 ServeMux.HandleFunc
内部会将处理函数 handler 转为实现了 ServeHTTP
方法的 HandlerFunc
类型,将其作为 Handler interface
的实现类注册到 ServeMux
的路由 map 当中。
go
// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
// ...
mux.Handle(pattern, HandlerFunc(handler))
}
实现路由注册的核心逻辑在ServeMux.Handle
方法中,其中有两个重点:
- 方法中将path和handler包装成一个
muxEntry
类,在以path为key注册到ServeMux.m 这个map中 - 在响应请求时采用模糊匹配的机制。对于以 '/' 结尾的 path,根据 path 长度将 muxEntry 有序插入到数组 ServeMux.es 中。(模糊匹配将会在下一小节详细说明)
go
func (mux *ServeMux) Handle(pattern string, handler Handler) {
mux.mu.Lock()
defer mux.mu.Unlock()
if pattern == "" {
panic("http: invalid pattern")
}
if handler == nil {
panic("http: nil handler")
}
if _, exist := mux.m[pattern]; exist {
panic("http: multiple registrations for " + pattern)
}
if mux.m == nil {
mux.m = make(map[string]muxEntry)
}
e := muxEntry{h: handler, pattern: pattern}
mux.m[pattern] = e
if pattern[len(pattern)-1] == '/' {
mux.es = appendSorted(mux.es, e)
}
if pattern[0] != '/' {
mux.hosts = true
}
}
go
func appendSorted(es []muxEntry, e muxEntry) []muxEntry {
n := len(es)
i := sort.Search(n, func(i int) bool {
return len(es[i].pattern) < len(e.pattern)
})
if i == n {
return append(es, e)
}
// we now know that i points at where we want to insert
es = append(es, muxEntry{}) // try to grow the slice in place, any entry works.
copy(es[i+1:], es[i:]) // Move shorter entries down
es[i] = e
return es
}
2.3 Server,启动!
在之前服务端程序的最后,我们调用了http/net
包下的ListenAndServe
方法,这就是服务端启动的入口。在程序内部会创建一个Server
对象,然后嵌套使用Server.ListenAndServe
方法。
go
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
在Server.ListenAndServe
方法中,会根据用户传入的地址和端口,申请一个监听TCP请求的监听器,继而调用Server.Serve
方法。
go
func (srv *Server) ListenAndServe() error {
if srv.shuttingDown() {
return ErrServerClosed
}
addr := srv.Addr
if addr == "" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return srv.Serve(ln)
}
Server.Serve
方法的核心就在于它的for + listener.accept
机制:
- 首先将server封装成一个k-v对,存入context中
- 开启for循环,每一轮循环中调用
Listener.Accept
方法,相当于在阻塞式地接收请求 - 每当有一个请求到达后,服务端会创建一个
goroutine
去异步地执行conn.serve
方法处理请求
go
var ServerContextKey = &contextKey{"http-server"}
type contextKey struct {
name string
}
func (srv *Server) Serve(l net.Listener) error {
//...
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, err := l.Accept()
//...
connCtx := ctx
//...
c := srv.newConn(rw)
//...
go c.serve(connCtx)
}
}
conn.serve
是响应端口的核心方法:
- 从 conn 中读取到封装到 response 结构体,以及请求参数 http.Request
- 调用 serveHandler.ServeHTTP 方法,根据请求的 path 为其分配 handler
- 通过特定 handler 处理并响应请求
go
func (c *conn) serve(ctx context.Context) {
//...
c.r = &connReader{conn: c}
c.bufr = newBufioReader(c.r)
c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)
for {
w, err := c.readRequest(ctx)
// ...
serverHandler{c.server}.ServeHTTP(w, w.req)
w.cancelCtx()
// ...
}
}
在 serveHandler.ServeHTTP
方法中,会对 Handler 作判断,倘若其未声明,则取全局单例 DefaultServeMux 进行路由匹配,呼应了 http.HandleFunc
中的处理细节。
go
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
//...
handler.ServeHTTP(rw, req)
}
接下来,会依次经历ServeMux.ServeHTTp
、ServeMux.Handler
、ServeMux.handler
,最终在ServeMux.match
中以Request中的path为pattern在路由表中匹配handler,最终在Handler.ServeHTTP
中进行请求的处理和响应。
go
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
// ...
h, _ := mux.Handler(r)
h.ServeHTTP(w, r)
}
go
func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) {
// ...
return mux.handler(host, r.URL.Path)
}
go
func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
mux.mu.RLock()
defer mux.mu.RUnlock()
// ...
h, pattern = mux.match(path)
// ...
return
}
书接上回,当查找路由map未命中handler时,就会启用模糊匹配,两个核心规则如下:
- 以 '/' 结尾的 pattern 才能被添加到 Server.es 数组中,才有资格参与模糊匹配
- 模糊匹配时,会找到一个与请求路径 path 前缀完全匹配且长度最长的 pattern,其对应的handler 会作为本次请求的处理函数
在mux.es中,patterns是以由长到短排列的
go
// Find a handler on a handler map given a path string.
// Most-specific (longest) pattern wins.
func (mux *ServeMux) match(path string) (h Handler, pattern string) {
// Check for exact match first.
v, ok := mux.m[path]
if ok {
return v.h, v.pattern
}
// Check for longest valid match. mux.es contains all patterns
// that end in / sorted from longest to shortest.
for _, e := range mux.es {
if strings.HasPrefix(path, e.pattern) {
return e.h, e.pattern
}
}
return nil, ""
}
至此,服务端的流程已经走完,接下来来到客户端一方。
3.客户端
3.1 客户端数据结构
(1)Client
与服务端相同,客户端也为我们封装了一个Client类
- Transport:负责http通信部分,也是客户端的核心组件
- CheckRedirect:详细记录了处理重定向的策略,这次不是我们重点讨论的部分
- Jar:Cookie管理
- Timeout:超时设置
go
type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}
(2)RoundTripper
RoundTripper 是通信模块的 interface,需要实现方法 Roundtrip,即通过传入请求 Request,与服务端交互后获得响应 Response。
go
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
(3)Transport
Tranport 是 RoundTripper 的实现类,核心字段包括:
- idleConn:空闲连接 map,实现复用
- DialContext:新连接生成器
go
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请求的结构体。
go
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响应参数结构体。
go
type Response struct {
Status string // e.g. "200 OK"
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
来处理请求。
go
// DefaultClient is the default Client and is used by Get, Head, and Post.
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
方法处理请求。
go
func (c *Client) Post(url, contentType string, body io.Reader) (resp *Response, err error) {
req, err := NewRequest("POST", url, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)
return c.Do(req)
}
3.4 NewRequest
在NewRequest
中会调用NewRequestWithContext
方法,根据用户传入的method,url等构建出Request示例。
go
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
if method == "" {
// 默认为"GET"请求
method = "GET"
}
// ...
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
方法。
go
func (c *Client) Do(req *Request) (*Response, error) {
return c.do(req)
}
go
func (c *Client) do(req *Request) (retres *Response, reterr error) {
//...
var (
deadline = c.deadline()
reqs []*Request
resp *Response
//...
)
for {
// ...
var err error
if resp, didTimeout, err = c.send(req, deadline); err != nil {
// ...
}
// ...
}
}
在Client.send
方法中,会在通过 send 方法发送请求之前和之后,分别对 cookie 进行更新。
go
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连接完成与服务端的交互
go
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
}
go
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
}
go
func (t *Transport) RoundTrip(req *Request) (*Response, error) {
return t.roundTrip(req)
}
go
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
方法尝试复用采用相同连接协议、访问相同主机端口的空闲连接 - 假如无已有连接可用,则调用
dialConnFor
方法异步创建一个连接,并通过select的方式接收ready channel中的信号,来确认连接工作完成
go
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
req := treq.Request
trace := treq.trace
ctx := req.Context()
//...
//获取连接的请求参数体
w := &wantConn{
cm: cm,
key: cm.key(),//包含http协议、请求地址等
ctx: ctx,
ready: make(chan struct{}, 1),//用于接收连接的ready信号
//...
}
//假如连接失败,则将tco连接放入队列中供以后复用
defer func() {
if err != nil {
w.cancel(t, err)
}
}()
// Queue for idle connection.
if delivered := t.queueForIdleConn(w); delivered {
pc := w.pc
//...
return pc, nil
}
//...
// Queue for permission to dial.
t.queueForDial(w)
// Wait for completion or cancellation.
select {
//通过阻塞的方式等待ready信号
case <-w.ready:
//...
return w.pc, w.err
//...
//因为错误接收到cancel信号
case <-req.Cancel:
return nil, errRequestCanceledConn
}
}
(1)复用连接
- 尝试从
Transport.idleConn
中获取指向同一服务端的空闲连接 - 获取到连接后会调用
wantConn.tryDeliver
方法将连接绑定到 wantConn 请求参数上 - 绑定成功后,会关闭
wantConn.ready
channel,以唤醒阻塞读取该 channel 的 goroutine
go
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
}
go
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.。相比于串行化执行而言,这种异步交互的模式,具有更高的灵活度
go
func (t *Transport) queueForDial(w *wantConn) {
w.beforeDial()
//当每个主机的最大连接数小于等于0时会创建
if t.MaxConnsPerHost <= 0 {
go t.dialConnFor(w)
return
}
t.connsPerHostMu.Lock()
defer t.connsPerHostMu.Unlock()
//当已有的连接数小于最大连接数时会创建
if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost {
if t.connsPerHost == nil {
t.connsPerHost = make(map[connectMethodKey]int)
}
t.connsPerHost[w.key] = n + 1
go t.dialConnFor(w)
return
}
//...
}
Transport.dialConnFor
方法中,首先调用 Transport.dialConn
创建 tcp 连接 persisConn,接着执行 wantConn.tryDeliver
方法,将连接绑定到 wantConn 上,然后通过关闭 ready channel
操作唤醒上游读 ready channel
的 goroutine。
go
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 方法,组成提交请求、接收响应的循环
go
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
}
go
func (t *Transport) dial(ctx context.Context, network, addr string) (net.Conn, error) {
if t.DialContext != nil {
return t.DialContext(ctx, network, addr)
}
//...
}
在伴生读协程 persistConn.readLoop
方法中,会读取来自服务端的响应,并添加到 persistConn.reqCh
中,供上游 persistConn.roundTrip
方法接收。
go
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
读取到客户端提交的请求,然后将其发送到服务端。
go
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)归还连接
有复用连接的能力,就必然存在归还连接的机制。
首先,在构造新连接中途,倘若被打断,则可能会将连接放回队列以供复用:
go
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
// ...
// 倘若连接获取失败,在 wantConn.cancel 方法中,会尝试将 tcp 连接放回队列中以供后续复用
defer func() {
if err != nil {
w.cancel(t, err)
}
}()
// ...
}
go
func (w *wantConn) cancel(t *Transport, err error) {
// ...
if pc != nil {
t.putOrCloseIdleConn(pc)
}
}
go
func (t *Transport) putOrCloseIdleConn(pconn *persistConn) {
if err := t.tryPutIdleConn(pconn); err != nil {
pconn.close(err)
}
}
go
func (t *Transport) tryPutIdleConn(pconn *persistConn) error {
// ...
key := pconn.cacheKey
// ...
t.idleConn[key] = append(idles, pconn)
// ...
return nil
}
其次,假如与服务端的一轮交流结束,也会将连接放回以供复用。
go
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)
// ...
}
}
}
3.7 persistConn.roundTrip
在上一小节有提到,一个persistConn是有生命周期的,在创建的过程中会为其维护负责读写的两个守护线程,来与上游调用的channel进行通信。
其中承上启下的方法就是persistConn.roundTrip
:
- 首先将 http 请求通过
persistConn.writech
发送给连接的守护协程 writeLoop,并进一步传送到服务端 - 其次通过读取 resc channel,接收由守护协程 readLoop 代理转发的客户端响应数据
go
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
// ...
}
}
}
4.结语
本文差不多到此结束了,感谢up主"小徐先生1212"的视频帮助我熟悉了net/http原生库,同时为我之后的web框架学习打好了基础。