Go语言net/url包源码初探

问题起因

问题的起因是最近需要写一个小工具,期间因为需要自己在服务器上搭建了一个 Web 服务来提供 RESTful API 接口,在请求接口的时候需要拼接 url,没有考虑到部分字符的编码问题,导致 400 Bad Request 错误。原来使用的是这样:

url := fmt.Sprintf("%ssearch?keywords=%s %s&limit=1", Mp3Server, name, singer)
resp,err := http.Get(url)
......

这个 name 和 singer 都有可能是中文,直接请求服务器无法识别,所以导致 400 无效请求的错误。后来改成这样,就成功了:

q := url.Values{}
q.Set("keywords", name+" "+singer)
q.Set("limit", "1")
u := url.URL{
	Scheme:   "http",
	Host:     "shiluo.design:3000",
	Path:     "search",
	RawQuery: q.Encode(), 				// 关键在这
}

resp, err := http.Get(u.String())

问题解决的关键就是在 RawQuery: q.Encode() 。由于我一直有看源码的习惯,我就 ctrl+左键跟进去把处理逻辑看了几遍,收获不小,特别记录下来。

整体处理逻辑

整个方法的主逻辑过程:

// Encode encodes the values into ``URL encoded'' form
// ("bar=baz&foo=quux") sorted by key.
func (v Values) Encode() string {
	if v == nil {
		return ""
	}
	var buf strings.Builder
	keys := make([]string, 0, len(v))
	for k := range v {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		vs := v[k]
		keyEscaped := QueryEscape(k)
		for _, v := range vs {
			if buf.Len() > 0 {
				buf.WriteByte('&')
			}
			buf.WriteString(keyEscaped)
			buf.WriteByte('=')
			buf.WriteString(QueryEscape(v))
		}
	}
	return buf.String()
}

其最终目的是把类似 http://shiluo.design:3000/search?keywords=海阔天空 beyond&limit=1 这种的 URL 编码为服务器可识别的 http://shiluo.design:3000/search?keywords=%E6%AD%8C%E6%9B%B2+%E6%AD%8C%E6%89%8B&limit=1 这种格式的 URL。

遍历的 vs 就是某个 key 对应的所有 value 值,buf.WriteByte('&') 是在任何一次写入后追加 & 连接查询字符串。我们重点关注最后一行代码 buf.WriteString(QueryEscape(v))

深入剖析

Values 是自定义的 type Values map[string][]string

处理过程是:判断 Values 是否为空,如果是返回空字符串。设置一个 Builder 用来构建未来需要返回的字符串。由于 url 的 query string 查询时一个 key 允许有多个 value,所以遍历每个 key 的所有value值,判定 key 值中是否有需要转义的字符,拿到转义处理后的字符串,然后通过 buf.WriteString(keyEscaped)buf.WriteByte('=') 写入一对 key-value 键值对 。也就是说 key 判断转义了一次,它下面的 n 个 value 都判断转义了一次,最终处理完一个 key 时,写入了 n 对键值对 。其实最核心的逻辑在 QueryEscape() 这个函数。

// QueryEscape escapes the string so it can be safely placed
// inside a URL query.
func QueryEscape(s string) string {
	return escape(s, encodeQueryComponent)
}

func escape(s string, mode encoding) string {
	spaceCount, hexCount := 0, 0
	for i := 0; i < len(s); i++ {
		c := s[i]
		if shouldEscape(c, mode) {
			if c == ' ' && mode == encodeQueryComponent {
				spaceCount++
			} else {
				hexCount++
			}
		}
	}

	if spaceCount == 0 && hexCount == 0 {
		return s
	}

	var buf [64]byte
	var t []byte

	required := len(s) + 2*hexCount
	if required <= len(buf) {
		t = buf[:required]
	} else {
		t = make([]byte, required)
	}

	if hexCount == 0 {
		copy(t, s)
		for i := 0; i < len(s); i++ {
			if s[i] == ' ' {
				t[i] = '+'
			}
		}
		return string(t)
	}

	j := 0
	for i := 0; i < len(s); i++ {
		switch c := s[i]; {
		case c == ' ' && mode == encodeQueryComponent:
			t[j] = '+'
			j++
		case shouldEscape(c, mode):
			t[j] = '%'
			t[j+1] = upperhex[c>>4]
			t[j+2] = upperhex[c&15]
			j += 3
		default:
			t[j] = s[i]
			j++
		}
	}
	return string(t)
}

// Return true if the specified character should be escaped when
// appearing in a URL string, according to RFC 3986.
//
// Please be informed that for now shouldEscape does not check all
// reserved characters correctly. See golang.org/issue/5684.
func shouldEscape(c byte, mode encoding) bool {
	// §2.3 Unreserved characters (alphanum)
	if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' {
		return false
	}

	if mode == encodeHost || mode == encodeZone {
		// mode 不符合,与这段代码无关....
	}

	switch c {
	case '-', '_', '.', '~': // §2.3 Unreserved characters (mark)
		return false

	case '$', '&', '+', ',', '/', ':', ';', '=', '?', '@': // §2.2 Reserved characters (reserved)
		// Different sections of the URL allow a few of
		// the reserved characters to appear unescaped.
		switch mode {
		case encodePath: // §3.3
			// The RFC allows : @ & = + $ but saves / ; , for assigning
			// meaning to individual path segments. This package
			// only manipulates the path as a whole, so we allow those
			// last three as well. That leaves only ? to escape.
			return c == '?'

		case encodePathSegment: // §3.3
			// The RFC allows : @ & = + $ but saves / ; , for assigning
			// meaning to individual path segments.
			return c == '/' || c == ';' || c == ',' || c == '?'

		case encodeUserPassword: // §3.2.1
			// The RFC allows ';', ':', '&', '=', '+', '$', and ',' in
			// userinfo, so we must escape only '@', '/', and '?'.
			// The parsing of userinfo treats ':' as special so we must escape
			// that too.
			return c == '@' || c == '/' || c == '?' || c == ':'

		case encodeQueryComponent: // §3.4
			// The RFC reserves (so we must escape) everything.
			return true

		case encodeFragment: // §4.1
			// The RFC text is silent but the grammar allows
			// everything, so escape nothing.
			return false
		}
	}

	if mode == encodeFragment {
		// mode 不符合,与这段代码无关....
	}

	// Everything else must be escaped.
	return true
}

这个函数判断了哪些字符需要转义,并对这些字符进行处理。这里就举例说明一下 http://shiluo.design:3000/search?keywords=海阔天空 beyond&limit=1 这个 keywords 参数的转换过程,因为其包含了中文字符和空格。

首先通过包裹了 escape 通过参数 mode 将过滤类型设置为 URL 编码类型shouldEscape(c,mode) 判定哪些字符需要转义,里面根据 RFC 规范进行了各种判断就是为了找出必须要转义的字符。

首先是大小写字母和一些规范内的正常字符是不需要转义的,其余的比如我们参数中的中文和空格都不在这些字符内,所以最终不满足任何一种条件,返回 true,也就是需要转义。所以遍历过“海阔天空 beyond”这个 value 后,统计出来的 hex 值是 4,space 值是 1。

因为一个汉字例如“海”转义后是“%E6”,由原来的一个字节变为了三个字节,所以要根据上面统计出来的 hex 值扩大容量。如果 buf 能够容纳就直接获取,否则使用 make 重新分配内存。最终遍历“海阔天空 beyond”这个字符串,j 是用来移动数组下标的。

const upperhex = "0123456789ABCDEF"

过程如下:“海”是需要转义的,所以将第一个字符设置为 %,紧接着取“海”这个字占用的字节数组 [230 181 183] 的第一位s[0] 230 右移4位是 14 对应是 ‘E’。第二位是和 15 按位与,结果是6,然后将下标指针向后移动三位,指向下一个存储位置。处理这个过程直到遇到空格,然后将空格替换为'+',后面的英文正常不变,处理完成。

最终我们可以看出,所谓转义就是取这个字符字节数组的第一位,按照一定规则将这个字符转换为十六进制的过程。所以到这里也很容易理解为什么我之前这样拼接 URL 会导致错误,之前一直没发现问题也可能是因为拼接的都是英文字符。

最后,每次看完官方的某些代码还是不得不感叹处理逻辑的紧密,考虑之周到。还是要多多看别人源码,不断学习。