golang转发请求(简单代理)

7,781 阅读5分钟

「这是我参与11月更文挑战的第9天,活动详情查看:2021最后一次更文挑战」。

写模拟 API 服务工具的时候,想实现这样一个功能。
如果单个 API 未开发完成,前端可选择使用模拟 API 服务;如果单个 API 已开发完成,前端也可根据需要选择使用模拟 API 服务或调用真实 API。
那在模拟 API 服务的工具里加上转发真实 API 的处理,根据设定选择使用就可以了。
网上搜了一下,如果直接转发,然后把转发后的结果直接再返回给调用端的话,还是比较简单。
只是如果中间想记录下请求响应的各种数据时, Body 读取后即关闭,想要再次读取的话,需要使用 Body 的内容重新创建 Reader。

相关代码: hellogo/proxy at main · bettersun/hellogo (github.com)

选项

// Option 选项
type Option struct {
   Port    string `yaml:"port"`    // 端口
   ApiHost string `yaml:"apiHost"` // API主机(协议 + IP + 端口)
}

选项中定义了两个成员:

  • 一个是端口,即服务启动时的端口。
  • 一个是API 主机,即真实API服务所在主机的 协议 + IP + 端口。

例如:http://127.0.0.1:8012

golang的HTTP服务

// Proxy 代理
//   option: 选项
func Proxy(option Option) {
   // 监听端口
   port := ":" + option.Port
   server = http.Server{
      Addr: port,
   }

   log.Println(fmt.Sprintf("服务运行中... 端口[%v]", option.Port))
   http.ListenAndServe(port, http.HandlerFunc(DoHandle))
}

响应函数

// DoHandle 响应函数
func DoHandle(w http.ResponseWriter, r *http.Request) {
   // 转发请求
   doProxy(w, r, VerOption)
}

这里定义了一个全局公共变量:选项。

// VerOption 全局公共变量:选项
var VerOption Option

全局变量应该控制使用,因为http.HandlerFunc()的参数是固定的,其它需要设定的选项就只能通过全局变量来控制了。

如果有什么其它好方法,不吝赐教。

响应函数里只是把参数的 http.ResponseWriter 和 *http.Request 都传递给实际的处理函数,顺便把全局变量的选项也传递给实际的处理函数。

转发请求

// 转发请求
func doProxy(w http.ResponseWriter, r *http.Request, option Option) {
	// 创建一个HttpClient用于转发请求
	cli := &http.Client{}

	// 读取请求的Body
	body, err := ioutil.ReadAll(r.Body)
	if err != nil {
		log.Println("读取请求体发生错误")
		// 响应状态码
		w.WriteHeader(http.StatusServiceUnavailable)
		return
	}

	// 转发的URL
	reqURL := option.ApiHost + r.URL.String()

	// 创建转发用的请求
	reqProxy, err := http.NewRequest(r.Method, reqURL, strings.NewReader(string(body)))
	if err != nil {
		log.Println("创建转发请求发生错误")
		// 响应状态码
		w.WriteHeader(http.StatusServiceUnavailable)
		return
	}

	// 转发请求的 Header
	for k, v := range r.Header {
		reqProxy.Header.Set(k, v[0])
	}

	// 发起请求
	responseProxy, err := cli.Do(reqProxy)
	if err != nil {
		log.Println("转发请求发生错误")
		// 响应状态码
		w.WriteHeader(http.StatusServiceUnavailable)
		return
	}
	defer responseProxy.Body.Close()

	// 转发响应的 Header
	for k, v := range responseProxy.Header {
		w.Header().Set(k, v[0])
	}

	// 转发响应的Body数据
	var data []byte

	// 读取转发响应的Body
	data, err = ioutil.ReadAll(responseProxy.Body)
	if err != nil {
		log.Println("读取响应体发生错误")
		// 响应状态码
		w.WriteHeader(http.StatusServiceUnavailable)
		return
	}

	// 转发响应的输出数据
	var dataOutput []byte
	// gzip压缩判断
	isGzipped := isGzipped(responseProxy.Header)
	// gzip压缩编码数据
	if isGzipped {
		// 读取后 r.Body 即关闭,无法再次读取
		// 若需要再次读取,需要用读取到的内容再次构建Reader
		resProxyGzippedBody := ioutil.NopCloser(bytes.NewBuffer(data))
		defer resProxyGzippedBody.Close() // 延时关闭

		// gzip Reader
		gr, err := gzip.NewReader(resProxyGzippedBody)
		if err != nil {
			log.Println("创建gzip读取器发生错误")
			// 响应状态码
			w.WriteHeader(http.StatusServiceUnavailable)
			return
		}
		defer gr.Close()

		// 读取gzip对象内容
		dataOutput, err = ioutil.ReadAll(gr)
		if err != nil {
			log.Println("读取gzip对象内容发生错误")
			// 响应状态码
			w.WriteHeader(http.StatusServiceUnavailable)
			return
		}
	} else { // 非gzip压缩
		dataOutput = data
	}
	// 打印转发响应的Body数据,查看转发响应的响应数据时需要。
	log.Println(string(dataOutput))

	// response的Body不能多次读取,
	// 上面已经被读取过一次,需要重新生成可读取的Body数据。
	resProxyBody := ioutil.NopCloser(bytes.NewBuffer(data))
	defer resProxyBody.Close() // 延时关闭

	// 响应状态码
	w.WriteHeader(responseProxy.StatusCode)
	// 复制转发的响应Body到响应Body
	io.Copy(w, resProxyBody)
}

大体的处理流程:

  1. 接收到的请求的 URL,前面拼接上真实的 API 主机(协议 + IP + 端口)。
  2. 用接收到的请求的 Body 创建要转发的请求,接收到的请求的 Header 设置给转发请求(真实 API)。
  3. 发起转发请求(真实 API)。
  4. 读取转发请求(真实 API)的响应的 Header,设置到要返回给调用端的响应的 Header。
  5. 读取转发请求(真实 API)的响应的 Body。
    因为这里加了一个输出 Body 内容的处理,所以需要读取一下转发请求(真实 API)的响应的 Body。
    读取后,响应的 Body 会被关闭,需要使用 Body 的内容重新创建 Reader,再次读取后返回给调用端。
    另外,读取 Body 时,这里根据是否为 gzip 加了一个对应的读取处理。
  6. 设置响应状态码。
  7. 把转发请求(真实 API)的响应的 Body 返回给调用端。

gzip 判断函数:

// HTTP Header 常量 Content-Encoding
const headerContentEncoding = "Content-Encoding"
const encodingGzip = "gzip"
// gzip压缩判断
func isGzipped(header http.Header) bool {
   if header == nil {
      return false
   }

   contentEncoding := header.Get(headerContentEncoding)
   isGzipped := false
   if strings.Contains(contentEncoding, encodingGzip) {
      isGzipped = true
   }

   return isGzipped
}

现在的写法比较原始,从 Header 中读取 Content-Encoding 的值, 看是否包含 gzip。

启动转发请求的服务

main()里设置一下选项的成员的值,调用上面定义好的转发请求的服务即可。

package main

import (
	"hellogo/proxy"
)

func main() {
	// 转发请求
	proxy.VerOption.ApiHost = "http://127.0.0.1:8012"
	proxy.VerOption.Port = "9009"

	// 启动服务
	proxy.Proxy(proxy.VerOption)
}

用来测试的真实API

package server

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

func main() {
   server := http.Server{
      Addr: "127.0.0.1:8012",
   }

   http.HandleFunc("/", home)
   http.HandleFunc("/hello", hello)

   log.Println("ListenAndServe 127.0.0.1:8012")
   server.ListenAndServe()
}

func home(w http.ResponseWriter, r *http.Request) {
   fmt.Fprintf(w, "Welcome, bettersun")
}

func hello(w http.ResponseWriter, r *http.Request) {
   resp := fmt.Sprintf("[%v] Hello, world.", r.Method)
   fmt.Fprintf(w, resp)
}

测试一下

proxy.gif

再试一下

如果把真实的地址换成百度会怎么样呢?
因为这个转发请求并不支持 https,这里把真实 API 的主机设置为百度的 http 地址:

proxy.VerOption.ApiHost = "http://www.baidu.com"

再访问一下localhost:9009/,能看到返回了百度的响应内容:

proxy.png