Golang net/http 客户端快速入门

文章目录

golang内置了非常多优秀的库,net/http是其中之一,net/http不只是一个http客户端,它还实现了服务端,鉴于它非常贴心的设计,基于net/http做一个web server是一件非常容易的事情,但是本文主要聚焦于http客户端相关的功能。

快速入门

package main

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

func main() {
	resp, err := http.Get("https://baidu.com")
	if err != nil {
		panic(err)
	}
	defer func() {
		err := resp.Body.Close()
		if err != nil {
			fmt.Println("关闭Body失败:", err)
		}
	}()
	data, err := io.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s", data)
}

如果使用GetPost, 可以直接使用http.Gethttp.Post。而其他方法如Put, Delete等方法就需要自己构造请求了。

请求前

如果要自定义请求前的各项参数,就需要自己创建Request, 通过这个Request设置各个字段。

查询参数

查询参数值一般指url中问号后的部分, 比如https://www.baidu.com/s?ie=UTF-8&wd=test中的ie=UTF-8&wd=test, 在golang中可以通过以下方式实现

func main() {
	endpoint := "http://30.171.78.89:60081"
	client := &http.Client{}
	request, _ := http.NewRequest(http.MethodGet, endpoint, nil)
	query := url.Values{}
	query.Add("ie", "UTF-8")
	query.Add("wd", "test")
	request.URL.RawQuery = query.Encode()
	resp, err := client.Do(request)
    // 后续省略
}

http请求头参数

常见的http请求头有User-Agent, 用于简单的反爬以及反反爬。

func main() {
	endpoint := "http://30.171.78.89:60081"
	client := &http.Client{}
	request, _ := http.NewRequest(http.MethodGet, endpoint, nil)
	request.Header.Add("User-Agent", "youerning.top")
	resp, err := client.Do(request)
	// 后续省略
}

请求体参数

既然有了查询参数为啥还需要请求体参数? 因为查询参数在url中,总不可能上传个文件也把文件的编码到url中,那么这个url太长了,并且url的长度有有限制的。

一般来说,常用的请求体参数有以下三种。

  1. 表单 对应的Content-Type是application/x-www-form-urlencoded
  2. json 对应的Content-Type是application/json
  3. 包含文件的表单 对应的Content-Type是multipart/form-data

上传表单

可能是考虑到比较常用,所以net/http包含了封装好的接口

弱弱的问一句, json请求不是更常见么….

func main() {
	endpoint := "http://30.171.78.89:60081"
	form := url.Values{}
	form.Add("username", "youerning.top")
	form.Add("password", "123456")
	resp, err := http.PostForm(endpoint, form)
}

你可能注意到了, form表单用的也是url.Values, 这是因为它们编码后的结果其实是一样的!!!都是

JSON请求

func main() {
	endpoint := "http://30.171.78.89:60081"
	data := struct {
		Username string
		Password string
	}{
		Username: "youerning.top",
		Password: "123456",
	}
	payload, err := json.Marshal(data)
	if err != nil {
		panic(err)
	}
	resp, err := http.Post(endpoint, "application/json", bytes.NewReader(payload))
}

上传文件的表单

func main() {
	var err error
	endpoint := "http://30.171.78.89:60081"
	bodyBuf := &bytes.Buffer{}
	bodyWriter := multipart.NewWriter(bodyBuf)

	// 创建文件文件字段的对应的句柄
	file1writer, err := bodyWriter.CreateFormFile("file1", "file1")
	if err != nil {
		fmt.Println("写入字段名,文件名失败")
		panic(err)
	}

	// 打开文件
	fh, err := os.Open("file1.txt")
	if err != nil {
		fmt.Println("打开file1.txt失败:", err)
		panic(err)
	}
	defer fh.Close()

	//复制文件内容
	_, err = io.Copy(file1writer, fh)
	if err != nil {
		fmt.Println("读取file1.txt失败:", err)
		panic(err)
	}

	bodyWriter.WriteField("username", "youerning.top")
	// 这里比较重要
	bodyWriter.Close()

	resp, err := http.Post(endpoint, bodyWriter.FormDataContentType(), bodyBuf)
}

在请求前一定要调用bodyWriter.Close(), 不然内容不会写到bodyBuf里。

编码后的请求体内容如下:

--f424a574a686c72343e093e7bdbf26d4afdfabc5a73f494226c9104be3cb
Content-Disposition: form-data; name="file1"; filename="file1"
Content-Type: application/octet-stream

test1
--f424a574a686c72343e093e7bdbf26d4afdfabc5a73f494226c9104be3cb
Content-Disposition: form-data; name="username"

youerning.top
--f424a574a686c72343e093e7bdbf26d4afdfabc5a73f494226c9104be3cb--

file1.txt的文件内容是test1.

上传表单带有文件的表单还是比较复杂的。

net/http自带了cookiejar, 不过这个只能保存在内存中,如果需要持久化,需要使用第三方的cookieJar, 比如github.com/juju/persistent-cookiejar

func main() {
	jar, err := cookiejar.New(nil)
	if err != nil {
		log.Fatal(err)
	}
	client := http.Client{
		Jar: jar,
	}
	resp, err := client.Get("http://30.171.78.89:60081")
	if err != nil {
		panic(err)
	}
	defer func() {
		err := resp.Body.Close()
		if err != nil {
			fmt.Println("关闭Body失败:", err)
		}
	}()
	reader := bufio.NewReader(resp.Body)

	content, _ := io.ReadAll(reader)
	fmt.Printf("%s\n", content)
}

cookiejar可以设置的值有很多,可以自行搜索并应用。

超时

超时可以很简单的设置,比如

func main() {
	endpoint := "http://30.171.78.89:60081"
	client := &http.Client{Timeout: 10 * time.Second}
	request, _ := http.NewRequest(http.MethodGet, endpoint, nil)
	resp, err := client.Do(request)
}

这里设置了一个总的超时时间。

但是超时也可以设置的更精细一些,比如下面这样

func main() {
	endpoint := "http://30.171.78.89:60081"
	client := &http.Client{
		Transport: &http.Transport{
			Dial: (&net.Dialer{
				Timeout:   30 * time.Second,
				KeepAlive: 30 * time.Second,
			}).Dial,
			TLSHandshakeTimeout:   10 * time.Second,
			ResponseHeaderTimeout: 10 * time.Second,
			ExpectContinueTimeout: 1 * time.Second,
		},
	}
	request, _ := http.NewRequest(http.MethodGet, endpoint, nil)
	resp, err := client.Do(request)
}

客户端的各复杂的超时设置可以参考这张图片

客户端超时阶段

SSL证书

对于自签名证书最常见的就是不验证证书,代码如下

func main() {
	endpoint := "https://30.171.78.89:6443"
	http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
	resp, err := http.Get(endpoint)

	printBody(resp, err)
}

不验证证书肯定是不安全的,所以可以加载自签名证书, 代码如下

var (
	CACertFilePath = "ca-cert.pem"
	CertFilePath   = "client-cert.pem"
	KeyFilePath    = "client-key.pem"
)

func main() {
	// 加载证书
	clientTLSCert, err := tls.LoadX509KeyPair(CertFilePath, KeyFilePath)
	if err != nil {
		fmt.Println("加载证书失败:", err)
		panic(err)
	}
	// 注入自签名证书
	certPool, err := x509.SystemCertPool()
	if err != nil {
		panic(err)
	}
	if caCertPEM, err := ioutil.ReadFile(CACertFilePath); err != nil {
		panic(err)
	} else if ok := certPool.AppendCertsFromPEM(caCertPEM); !ok {
		panic("非法的ca证书")
	}
	tlsConfig := &tls.Config{
		RootCAs:      certPool,
		Certificates: []tls.Certificate{clientTLSCert},
	}
	tr := &http.Transport{
		TLSClientConfig: tlsConfig,
	}
	client := &http.Client{Transport: tr}
	resp, err := client.Get("https://30.171.78.89:6443")
}

代理

常见的代理协议就两种, http, socks5, 这里以http为例

func main() {
	proxyUrl, err := url.Parse("http://127.0.0.1:1080")
	if err != nil {
		fmt.Println("解析代理地址失败:", err)
		panic(err)
	}
	client := &http.Client{Transport: &http.Transport{
		Proxy: http.ProxyURL(proxyUrl),
	}}
	resp, err := client.Get("http://30.171.78.89:60081")
}

重定向

有时候可以限制重定向的测试来避免重定向次数过多。

func main() {
	client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
		if len(via) > 5 {
			return errors.New("重试次数超过5次")
		}
		return nil
	}}
	resp, err := client.Get("http://30.171.78.89:60081")
	printBody(resp, err)
}

net/http 默认的重定向检查是10次。

请求后

请求后会获得一个http.Response, 这个结构体有许多比较有用的字段。

响应头信息/状态码

响应头信息可以用于一些特殊字段的判断,比如字符集,而状态码可以简单的判断请求是否成功,

func main() {
	resp, err := http.Get("http://30.171.78.89:60081")
	printBody(resp, err)
	fmt.Println("status:", resp.StatusCode)
	fmt.Printf("%v\n", resp.Header)
}

响应体

如果默认响应体的编码是utf8或者以字节流的方式保存,就可以直接处理响应体。

func main() {
	client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
		if len(via) > 5 {
			return errors.New("重试次数超过5次")
		}
		return nil
	}}
	resp, err := client.Get("http://30.171.78.89:60081")
	if err != nil {
		panic(err)
	}
	defer func() {
		err := resp.Body.Close()
		if err != nil {
			fmt.Println("关闭Body失败:", err)
		}
	}()
	data, err := io.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s", data)
}

编码

一般来说大家都是用utf-8了,但是总有一些例外情况,所以为了安全的解析响应体内容,需要检测响应体的编码格式。

响应体的内容检测需要一个额外的包: golang.org/x/net/html/

// https-client-mtls.go
package main

import (
	"bufio"
	"fmt"
	"io"
	"net/http"

	"golang.org/x/net/html/charset"
	"golang.org/x/text/transform"
)

func main() {
	resp, err := http.Get("http://30.171.78.89:60081")
	if err != nil {
		panic(err)
	}
	defer func() {
		err := resp.Body.Close()
		if err != nil {
			fmt.Println("关闭Body失败:", err)
		}
	}()
	reader := bufio.NewReader(resp.Body)
	// charset最多只会读取前1024个字节
	test, err := reader.Peek(1024)
	if err != nil {
		fmt.Println("读取前1024字节失败:", err)
		panic(err)
	}
	//resp.Header.Get("content-type")
	e, _, _ := charset.DetermineEncoding(test, resp.Header.Get("content-type"))
	eReader := transform.NewReader(reader, e.NewDecoder())
	content, _ := io.ReadAll(eReader)
	fmt.Printf("%s\n", content)
}

golang.org/x/net/html/charset的检测效果并不是那么好, 因为它比较依赖Content-Type, 如果需要更通用的检测方式,可以使用github.com/saintfish/chardet, 代码如下:

package main

import (
	"bufio"
	"fmt"
	"io"
	"net/http"
	"strings"

	"github.com/saintfish/chardet"
	"golang.org/x/net/html/charset"
	"golang.org/x/text/transform"
)

func main() {
	resp, err := http.Get("http://30.171.78.89:60081")
	if err != nil {
		panic(err)
	}
	defer func() {
		err := resp.Body.Close()
		if err != nil {
			fmt.Println("关闭Body失败:", err)
		}
	}()
	reader := bufio.NewReader(resp.Body)
	test, err := reader.Peek(1024)
	if err != nil {
		fmt.Println("读取前1024字节失败:", err)
		panic(err)
	}

	decoder := chardet.NewTextDetector()
	r, err := decoder.DetectBest(test)
	if err != nil {
		panic(err)
	}
	// chardet GB-18030 对应 go编码中的 gb18030
    // Lookup方法会进行大小写转换
	_charset := strings.Replace(r.Charset, "-", "", 1)
	encoder, _ := charset.Lookup(_charset)
	if encoder == nil {
		panic("没有找到对应的转码对象")
	}

	eReader := transform.NewReader(reader, encoder.NewDecoder())
	content, _ := io.ReadAll(eReader)
	fmt.Printf("%s\n", content)
}

值得注意的是chardet的Charset使用的是IANA格式,而go不知道是啥格式, 比如前者的GB-18030格式对应的是后者的gb18030,所以上面的代码把-去掉。

总结

net/http自然是比较强大的,提供了各种接口用于扩展,但是相对于一些场景来说用户体验并不是那么好,所以自己写项目,往往需要再封装一层,其实http里面还有一个非常重要的部分没有讲,那就是Transport, 这个比较复杂,可以单独写一篇文章。

参考链接:

https://www.oschina.net/translate/the-complete-guide-to-golang-net-http-timeouts?cmp

https://www.bastionxp.com/blog/how-to-setup-https-client-in-golang-with-self-signed-ssl-tls-client-certificate/