56

Go36-47-基于HTTP协议的网络服务(net/http)

 5 years ago
source link: https://studygolang.com/articles/18158?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

基于HTTP协议的网络服务

HTTP协议是基于TCP/IP协议栈的,并且是一个面向普通文本的协议。原则上,使用任何一个文本编辑器,都可以写出一个完整的HTTP请求报文。只要搞清楚了请求报文的头部(header、 请求头 )和主体(body、 请求体 )应该包含的内容。

如果只是访问基于HTTP协议的网络服务,那么使用net/http包中的程序实体会非常方便。

http.Get函数

调用http.Get函数,只需要传递给它一个URL即可:

package main

import (
    "fmt"
    "net/http"
    "os"
)

func main() {
    resp, err := http.Get("http://baidu.com")
    if err != nil {
        fmt.Fprintf(os.Stderr, "request sending error: %v\n", err)
        return
    }
    defer resp.Body.Close()
    line := resp.Proto + " " + resp.Status
    fmt.Println("返回的第一行的内容:", line)
}

http.Get函数会返回两个结果:

  • (resp *Response): 网络服务返回的响应内容的结构化表示
  • (err error): 创建和发送HTTP请求,以及接收和解析HTTP响应的过程中可能发生的错误

http.Get函数会在内部使用缺省的HTTP客户端,并且调用它的Get方法来完成功能。这个缺省的HTTP客户端就是net/http包中的公开变量DefaultClient,源码中是这样的:

// 源码中提供的缺省的客户端
var DefaultClient = &Client{}

// 使用缺省的客户端调用Get方法
func Get(url string) () {
    return DefaultClient.Get(url)
}

所以下面的这两行代码:

var httpClient http.Client
resp, err := httpClient.Get(utl)

与示例中的这一行代码:

resp, err := http.Get(url)

是等价的。这里只是不使用DefaultClient而是自己创建了一个客户端。

http.Client类型

http.Client是一个结构体,并且它包含的字段都是公开的:

type Client struct {
    Transport RoundTripper
    CheckRedirect func(req *Request, via []*Request) error
    Jar CookieJar
    Timeout time.Duration
}

该类型是开箱即用的,因为它的所有字段,要么存在相应的缺省值,要么其零值直接就可以使用,并且代表着特定的含义。

Transport字段

主要看下Transport字段,该字段向网络服务发送HTTP请求,并从网络服务接收HTTP响应。该字段的方法RoundTrip应该实现单次HTTP事务(或者说基于HTTP协议的单次交互)需要的所有步骤。这个字段是一个接口:

type RoundTripper interface {
    RoundTrip(*Request) (*Response, error)
}

并且该字段有一个由http.DefaultTransport变量的缺省值:

func (c *Client) transport() RoundTripper {
    if c.Transport != nil {
        return c.Transport
    }
    return DefaultTransport
}

在初始化http.Client类型的时候,如果没有显式的为该字段赋值,这个Client字段就会直接使用DefaultTransport。

Timeout字段

该字段是单次HTTP事务的超时时间,它是time.Duration类型。它的零值是可用的,用于表示没有设置超时时间。

http.Transport类型

http.Transport类型是一个结构体,该类型包含的字段很多。这里通过http.Client结构体中的Transport字段的缺省值DefaultTransport,来深入了解一下。DefaultTransport是一个*http.Transport的结构体,做了一些默认的设置:

var DefaultTransport RoundTripper = &Transport{
    Proxy: ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
        DualStack: true,
    }).DialContext,
    MaxIdleConns:          100,
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
}

这里Transport结构体的指针就是就是RoundTripper接口的默认实现:

func (t *Transport) RoundTrip(req *Request) (*Response, error) {
    return t.roundTrip(req)
}

这个类型是可以被复用的,并且也推荐被复用。同时它也是并发安全的。所以http.Client类型也是一样,推荐复用,并且并发安全。

看上面的默认设置,http.Transport类型,内部的DialContext字段会使用net.Dialer类型的值,并且把Timeout设置为30秒。仔细看,该值是一个方法,这里把Dialer值的DialContext方法赋值给了DefaultTransport里的同名字段,并且已经设置好了调用该方法时的结构体。

操作超时相关字段

http.Transport类型还包含了很多其他的字段,其中有一些字段是关于操作超时的:

  • IdleConnTimeout: 空闲的连接在多久之后就应该被关闭。DefaultTransport把该值设置为90秒。如果是0,表示不关闭空闲的连接,注意,这样很可能会造成资源的泄露。
  • ResponseHeaderTimeout: 从客户端把请求完全递交给操作系统到从操作系统那里接收到响应报文的最长时长。DefaultTransport没有设定该字段的值。
  • ExpectContinueTimeout: 在客户端递交了请求报文头之后,等待接收第一个响应报文头的最长时间。在客户端想要使用POST发送一个很大的请求给服务端的时候,可以通过发送一个包含了“Expect: 100-continue”的请求头,来询问服务端是否愿意接收这个大请求体。这个字段就是用于设定在这种情况下的超时时间的。如果该字段的值不大于0,那么就不询问了,直接把请求体一并发出,无论多大。这样可能会造成网络资源的浪费。DefaultTransport把该值设置为1秒。
  • TLSHandshakeTimeout: 表示基于TLS协议的连接在被建立时的握手阶段的超时时间。如果是0,则表示没有超时限制。DefaultTransport把该值设置为10秒。

TLS 是 Transport Layer Security 的缩写,可以被翻译为传输层安全。

连接数限制相关字段

此外,还有一些与IdleConnTimeout相关的字段值也值得关注:

  • MaxIdleConns
  • MaxIdleConnsPerHost
  • MaxConnsPerHost

MaxIdleConns无论当前访问了多少个网络服务,MaxIdleConns字段只会对空闲连接的总数做限定。

MaxIdleConnsPerHost

而MaxIdleConnsPerHost字段限定的是,每一个网络服务的最大空闲连接数。每一个网络服务都有自己的网络地址,可能会使用不同的网络协议,对于一些HTTP请求也可能会用到代理。地址、协议、代理,通脱这三个方面的具体情况来鉴别不同的网络服务。

MaxIdleConnsPerHost是有缺省值的,由常量http.DefaultMaxIdleConnsPerHost表示,值为2:

const DefaultMaxIdleConnsPerHost = 2

func (t *Transport) maxIdleConnsPerHost() int {
    if v := t.MaxIdleConnsPerHost; v != 0 {
        return v
    }
    return DefaultMaxIdleConnsPerHost
}

在默认情况下,每一个网络服务,它的空闲连接数最多只能由2个。

MaxConnsPerHostMaxConnsPerHost字段限制针对每一个网络服务的最大连接数,不论这些链接是否是空闲的。并且,该字段没有相应的缺省值,零值就是不做限制。

小结不限制连接数,默认也不限制每一个网络服务的连接数。要限制整体的空闲连接数以及严格限制对每一个网络服务的空闲连接数。

空闲的连接

简单说明一下,为什么会出现空闲的连接。

HTTP协议的请求头里有一个Connection。在HTTP协议的1.1版本中,默认值是“keep-alive”。在这种情况下的网络连接是持久连接的,它们会在当前的HTTP事务完成后仍然保持着连通性,因此是可以被复用的。

既然连接可以被复用,就会有两种可能:

  1. 针对同一个网络服务,有新的HTTP请求被提交,该连接被再次使用。
  2. 不再对该网络服务提交HTTP请求,该连接被闲置。这样就产生了空闲的连接。

另外,如果分配给某一个网络服务的连接过多的话,也可能会导致空闲连接的产生。因为没一个HTTP请求只会使用一个空闲的连接。所以,在大多数情况下,都需要限制空闲连接数。

关闭keep-alive

另外,请求头的Connection还可以设置为“close”,这样就彻彻底杜绝了空闲连接的生成。这会告诉网络服务,这个网络连接不必保持,当前的HTTP事务完成后就可以断开它了。做法是在初始化Transport值的时候,将DisableKeepAlives字段设置为true。

这么做的话,每次提交HTTP请求,就会产生一个新的网络连接。这样会明显的加重网络服务以及客户端的负载,并会让每个HTTP事务都耗费更多的时间。所以默认不设置这个DisableKeepAlives字段。

net.Dialer类型

http.Transport类型,内部的DialContext字段会使用net.Dialer类型的值。在net.Dialer类型中,也有一个KeepAlive字段。该字段是直接作用在底层的socket上的。

它的背后是一种针对网络连接(更确切的是说,是TCP连接)的存活探测机制。它的值用于表示每间隔多长时间发送一次探测包。当该值不大于0是,则表示不开启这种机制。

DefaultTransport会把这个字段设置为30秒。

Client示例

自定义Client和Transport使用的示例:

package main

import (
    "fmt"
    "io/ioutil"
    "net"
    "net/http"
    "strings"
    "sync"
    "time"
)

var domains = []string{
    "baidu.com",
    "sina.com.cn",
    "www.baidu.com",
    "www.sina.com.cn",
    "tieba.baidu.com",
    "news.baidu.com",
    "news.sina.com.cn",
}

func main() {
    myTransport := &http.Transport{
        Proxy: http.ProxyFromEnvironment,
        DialContext: (&net.Dialer{
            Timeout:   15 * time.Second,
            KeepAlive: 15 * time.Second,
            DualStack: true,
        }).DialContext,
        MaxConnsPerHost:       2,
        MaxIdleConns:          10,
        MaxIdleConnsPerHost:   2,
        IdleConnTimeout:       30 * time.Second,
        ResponseHeaderTimeout: 0,
        ExpectContinueTimeout: 1 * time.Second,
        TLSHandshakeTimeout:   10 * time.Second,
    }
    myClient := http.Client{
        Transport: myTransport,
        Timeout:   20 * time.Second,
    }

    var wg sync.WaitGroup
    for _, domain := range domains {
        wg.Add(1)
        go func(domain string) {
            var logBuf strings.Builder
            var diff time.Duration
            defer func() {
                logBuf.WriteString(fmt.Sprintf("持续时间: %s\n", diff))
                fmt.Println(logBuf.String())
                wg.Done()
            }()
            url := "https://" + domain
            logBuf.WriteString(fmt.Sprintf("发送请求: %s\n", url))
            tStart := time.Now()
            resp, err := myClient.Get(url)
            diff = time.Now().Sub(tStart)
            if err != nil {
                logBuf.WriteString(fmt.Sprintf("request get error: %v\n", err))
                return
            }
            defer resp.Body.Close()
            line := resp.Proto + " " + resp.Status
            logBuf.WriteString(fmt.Sprintf("response: %s\n", line))

            data, err := ioutil.ReadAll(resp.Body)
            if err != nil {
                logBuf.WriteString(fmt.Sprintf("get data error: %v\n", err))
                return
            }
            index1 := strings.Index(string(data), "<title>")
            index2 := strings.Index(string(data), "</title>")
            if index1 > 0 && index2 > 0 {
                logBuf.WriteString(fmt.Sprintf("title: %s\n", string(data)[index1+len("<title>"):index2]))
            }
        }(domain)
    }
    wg.Wait()
    fmt.Println("All Done")
}

http.Server类型

http.Server类型与http.Client是相对应的。http.Server代表的是基于HTTP协议的服务端,或者说网络服务。

ListenAndServe方法

http.Server类型的ListenAndServe方法的功能是:监听一个基于TCP协议的网络地址,并对接收到的HTTP请求进行处理。这个方法会默认开启针对网络连接的存活探测机制,以保证连接是持久的。同时,该方法会一直执行,直到有严重的错误发生或者被外界关掉。当被外界关掉时,它会返回一个由http.ErrServerClosed变量代表的错误值。

这个ListenAndServe方法主要会做以下几件事情:

  1. 检查当前的http.Server类型的值的Addr字段。Addr是当前的网络服务需要使用的网络地址,即:IP地址和端口号。如果这个字段的值为空字符串,那么就用":http"代替。也就是说,使用任何可以代表本机的域名和IP地址,并且端口号为80。
  2. 通过调用net.Listen函数在已确定的网络地址上启动基于TCP协议的监听。
  3. 检查net.Listen函数返回的错误值。如果该错误值不为nil,那么就直接返回该错误值。否则,通过调用当前http.Server值的Serve方法准备接受和处理将要到来的HTP请求。

这里又牵出两个问题:

  1. net.Listen函数
  2. http.Server类型的Serve方法

net.Listen函数

net.Listen函数的作用:

  • 解析参数值中包含的网络地址隐含的IP地址和端口号
  • 根据给定的网络协议,确定监听的方法,并开始进行监听

再往下深入的话,就会涉及到net.socket函数以及相关的socket知识。就此打住。

http.Server类型的Serve方法

在一个for循环中,网络监听器Accept方法会不断地调用,该方法的源码如下:

type tcpKeepAliveListener struct {
    *net.TCPListener
}

func (ln tcpKeepAliveListener) Accept() (net.Conn, error) {
    tc, err := ln.AcceptTCP()
    if err != nil {
        return nil, err
    }
    tc.SetKeepAlive(true)
    tc.SetKeepAlivePeriod(3 * time.Minute)
    return tc, nil
}

Accept方法会返回两个结果值:

  • net.Conn : 代表包含了新到来的HTTP请求的网络连接
  • error : 代表了可能发生的错误的error的类型值

当错误值不为nil时,如果此时是一个暂时性的错误,那么循环的下一次迭代将会在一段时间之后开始执行。否则,循环会被终止。

如果没有错误,返回的错误值就是nil。那么这里的程序将会把它的第一个结果值包装成一个*http.conn类型的值,然后通过在新的goroutine中调用这个conn值的serve方法,来对当前的HTTP请求进行处理。

上面最后说的处理的细节还是很多的:

  • conn值的各种状态,各状态代表的处理阶段
  • 处理过程中会用到的读取器和写入器,及其作用
  • 让程序调用自定义的处理函数

这些都没有一一说明,建议去看下源码。

Server示例

在下面的示例中,启动了3个Server。启动后,可以用浏览器访问进行验证:

package main

import (
    "fmt"
    "net/http"
    "os"
    "sync"
)

var wg sync.WaitGroup

// 一般没有这么用的,http.Server的Handler字段
// 要么是nil,就用包里的http.DefaultServeMux
// 要么用NewServeMux()来创建一个*http.ServeMux
// 我这里按照http.Handler接口的要求实现了一个,赋值给Handler字段
// 这个自定义的Handler不支持路由
func startServer1() {
    defer wg.Done()
    var httpServer http.Server
    httpServer.Addr = "127.0.0.1:8001"
    httpServer.Handler = http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            fmt.Println(*r)
            fmt.Fprint(w, "Hello World")
        },
    )
    fmt.Println("启动服务,访问: http://127.0.0.1:8001")
    if err := httpServer.ListenAndServe(); err != nil {
        if err == http.ErrServerClosed {
            fmt.Println("HTTP Server1 Closed.")
        } else {
            fmt.Fprintf(os.Stderr, "HTTP Server1 Error: %v\n", err)
        }
    }
}

// 这个最简单,都是调用http包里的函数。本质上还是要调用方法的,都会用默认的或是零值
func startServer2() {
    defer wg.Done()
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "Hello World\nThis is Server2")
    })
    fmt.Println("启动服务,访问: http://127.0.0.1:8002")
    // 第二个参数传nil,就是用包里的http.DefaultServeMux,或者也可以自己创建一个传给第二个参数
    if err := http.ListenAndServe("127.0.0.1:8002", nil); err != nil {
        if err == http.ErrServerClosed {
            fmt.Println("HTTP Server2 Closed.")
        } else {
            fmt.Fprintf(os.Stderr, "HTTP Server2 Error: %v\n", err)
        }
    }
}

// 这个例子里用到了解析Get请求的参数,并且还设置了2个路由
func startServer3() {
    defer wg.Done()
    mux := http.NewServeMux()
    mux.HandleFunc("/hi", func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/hi" {
            // 这个分支应该是进不来的,因为要进入这个分支,路径应该必须是"/hi"
            fmt.Println("Server3 hi 404")
            http.NotFound(w, r)
            return
        }
        name := r.FormValue("name")
        if name == "" {
            fmt.Fprint(w, "Hi!")
        } else {
            fmt.Fprintf(w, "Hi, %s!", name)
        }
    })
    mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "Hello World\nThis is Server3")
    })
    // 如果只是定义http.Server的下面2个字段,完全可以使用http.ListenAndServe函数来启动服务
    // 这样的用法可以对http.Server里更多的字段进行自定义
    httpServer := http.Server{
        Addr: "127.0.0.1:8003",
        Handler: mux,
    }
    fmt.Println("启动服务,访问: http://127.0.0.1:8003/hi?name=Adam")
    if err := httpServer.ListenAndServe(); err != nil {
        if err == http.ErrServerClosed {
            fmt.Println("HTTP Server3 Closed.")
        } else {
            fmt.Fprintf(os.Stderr, "HTTP Server3 Error: %v\n", err)
        }
    }
}

func main() {
    wg.Add(1)
    go startServer1()
    wg.Add(1)
    go startServer2()
    wg.Add(1)
    go startServer3()
    wg.Wait()
}

补充-优雅的停止HTTP服务

包里还提供了一个Shutdown方法,可以优雅的停止HTTP服务:

func (srv *Server) Shutdown(ctx context.Context) error {
    // 内容省略
}

我们要做的就是在需要的时候,可以调用该Shutdown方法。

这里的问题是,调用了ListenAndServe方法之后,就进入了无限循环的流程。这里最好是用一个goroutine来启动ListenAndServe方法,在goroutine外声明http.Server。然后在主线程里等待一个信号,比如是从通道接收值。这样就可以在主线程里调用这个Shutdown方法执行了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK