#golang #url #源码分析 #Golang 标准库

Golang 源码分析 - net/url 包分析

摘要

URL(Uniform Resource Locator) 统一资源定位符如何定义有一套完整的规范。URL 的使用需要注意什么,jsencodeURI 又做了哪些转义?学习 Golang 对于此规范的实现,有助于 Golang 的熟悉和对规范在实际应用中处于何种地位有至关重要的作用。

背景

URL 是连接用户与服务的媒介,通过 URL 你可以获取到你需要的资源。在编程的过程中,经常会使用到。通常使用起来也非常简单,基本没有什么问题。可是当涉及到转义及空格时,经常会引起莫名的问题。以 http 开头的为 URL 的字集。

URL 的设计有一整套的规范,可以参见:《Uniform Resource Identifier (URI): Generic Syntax》

本文将以 golang 1.11.1 版本标准库中的 net/url 进行分析,进而了解 URL 规范在实际应用中的实现。golang 基本遵循 RFC3986 进行设计,因为一些兼容问题会作此许的修改。

学习编程语言对规范的实现,一来可以提高自己对于此语言的认识,二来可以对规范有更深刻的理解,三来可以学习下编程的思想。这些都很重要,所以有时间还想再去研究一下 Rust 中对 URL 的实现。

开始

Golang 对于实现 URL 规范让我想到了傅里叶变换,单拿整个 URL 其实挺复杂的,而 Golang 使用分而治之的方式会让代码更加清晰,实现起来也更加简单。

GolangURL 分为如下结构:

[scheme:][//[userinfo@]host][/]path[?query][#fragment]

我们可以称每个 [] 为一个组件,下文提到组件均为些概念。

规范中对于每一个 [] 中都有一定的转义规则,转义的好处是可以更好得在互联网上进行传播,而不会丢失数据。

把每块 [] 解析转义之后,如果没有错误,则是一个正确的 URL

net/url 包简介

这个包对外主要提供了 URL 的解析 Parsequery 数据的转义与反转义 QueryEscape, QueryUnescape。我认为,转义和解析是这个包的主要功能。只要理解了这两个功能,就已经理解这个包的具体功能了。对于 URL 标准也能有个粗略的了解决。

先看看转义 QueryEscape

URL 中的转义为把非安全的字符转义为包含一个百分号(%)(%)后面跟着两个表示字符 ASCII 码的十六进制数。

// 转义 [?query] 组件中需要转义的字符
func QueryEscape(s string) string {
	return escape(s, encodeQueryComponent)
}

// 转义的基本方法,按 mode 转义不同字符,mode 有(encodePath, encodePathSegment, encodeHost,
// encodeZone, encodeUserPassword, encodeQueryComponent, encodeFragment)
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
	}

	// 转义后原字符会用 "%AB" 表示,所以长度增加了 2 倍的转义字符数
	t := make([]byte, len(s)+2*hexCount)
	j := 0 // t 的索引,记录写入的位置
	for i := 0; i < len(s); i++ {
		switch c := s[i]; {
		case c == ' ' && mode == encodeQueryComponent: // 可以看到转义对于 query 组件,会替换空格为 + 号
			t[j] = '+'
			j++
		case shouldEscape(c, mode): // 需要转义的字符
			// 可以看到转义算法很简单
			// 1. 添加一个百分号(%)
			// 2. 取这个字符的高 4 位对应的 16 进制
			// 3. 取这个字符的低 4 位对应的 16 进制
			t[j] = '%'
			t[j+1] = "0123456789ABCDEF"[c>>4]
			t[j+2] = "0123456789ABCDEF"[c&15]
			j += 3
		default: // 不需要转义的
			t[j] = s[i]
			j++
		}
	}
	return string(t)
}

// 根据 mode 类型判断 字符 c 是否需要转义,所有规则都在 RFC3986 中:https://tools.ietf.org/html/rfc3986
// 按规则判断是否转义,就不翻译了。
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 {
		// §3.2.2 Host allows
		//	sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
		// as part of reg-name.
		// We add : because we include :port as part of host.
		// We add [ ] because we include [ipv6]:port as part of host.
		// We add < > because they're the only characters left that
		// we could possibly allow, and Parse will reject them if we
		// escape them (because hosts can't use %-encoding for
		// ASCII bytes).
		switch c {
		case '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', ':', '[', ']', '<', '>', '"':
			return false
		}
	}

	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 {
		// RFC 3986 §2.2 allows not escaping sub-delims. A subset of sub-delims are
		// included in reserved from RFC 2396 §2.2. The remaining sub-delims do not
		// need to be escaped. To minimize potential breakage, we apply two restrictions:
		// (1) we always escape sub-delims outside of the fragment, and (2) we always
		// escape single quote to avoid breaking callers that had previously assumed that
		// single quotes would be escaped. See issue #19917.
		switch c {
		case '!', '(', ')', '*':
			return false
		}
	}

	// Everything else must be escaped.
	return true
}

接着看看反转义 QueryUnescape

// 此方法是 `QueryEscape` 的逆运算
// 转换每三个像 "%AB" 的字符为十六进制 0xAB.
// 当百分号(%)后跟没有跟着正确的十六进制则抛出异常
func QueryUnescape(s string) (string, error) {
	return unescape(s, encodeQueryComponent)
}

// 按 mode 类型来反转义字符串 s,一般按组件来调用这个方法。
func unescape(s string, mode encoding) (string, error) {
	// 计数,百分号(%)的个数,也就是有多少个转义的字符数
	n := 0
	hasPlus := false // 记录 query 组件中是否出现加(+)号
	for i := 0; i < len(s); {
		switch s[i] {
		case '%':
			n++
			// 三种情况说明是不合法的 URL 转义
			// 1. 百分号(%)后不足 2 位。
			// 2. 百分号(%)后一位不是合法的十六进制字符
			// 3. 百分号(%)后二位不是合法的十六进制字符
			// 这里 ishex 其它就是判断当前字符是否在 "0123456789ABCDEF" 内
			// 用的字符直接比较,'0' <= c && c <= '9', 'a' <= c && c <= 'f'
			// 'A' <= c && c <= 'F',我想这么比较应该会比字符串比较来得更快。
			if i+2 >= len(s) || !ishex(s[i+1]) || !ishex(s[i+2]) {
				s = s[i:]
				if len(s) > 3 {
					s = s[:3]
				}
				return "", EscapeError(s)
			}
			// https://tools.ietf.org/html/rfc3986#page-21
			// 在 host 组件中 "%AB" 这种转义方式只能对于非 ASIIC 码中的字符
			// 不过在这个规范中 https://tools.ietf.org/html/rfc6874#section-2
			// 提及了 %25 允许在 host 组件的 IPv6作用域地址进行转义
			// unhex(s[i+1]) < 8 的意思是字符 s[i+1] 是 ASIIC 码中的字符
			// 我是这么理解的,unhex(s[i+1]) < 8 表示取值范围是 s[i+1] 的取值范围
			// 是 0-7, s[i+2] 取值为 0-15,正好是 8*16=128,十六进行的前 127 个为
			// ASIIC 码中的字符。
			if mode == encodeHost && unhex(s[i+1]) < 8 && s[i:i+3] != "%25" {
				return "", EscapeError(s[i : i+3])
			}
			if mode == encodeZone {
				// 这段没看懂,有朋友懂的还请告知,原注释如下
				// RFC 6874 says basically "anything goes" for zone identifiers
				// and that even non-ASCII can be redundantly escaped,
				// but it seems prudent to restrict %-escaped bytes here to those
				// that are valid host name bytes in their unescaped form.
				// That is, you can use escaping in the zone identifier but not
				// to introduce bytes you couldn't just write directly.
				// But Windows puts spaces here! Yay.
				v := unhex(s[i+1])<<4 | unhex(s[i+2])
				if s[i:i+3] != "%25" && v != ' ' && shouldEscape(v, encodeHost) {
					return "", EscapeError(s[i : i+3])
				}
			}
			i += 3
		case '+':
			hasPlus = mode == encodeQueryComponent
			i++
		default:
			if (mode == encodeHost || mode == encodeZone) && s[i] < 0x80 && shouldEscape(s[i], mode) {
				return "", InvalidHostError(s[i : i+1])
			}
			i++
		}
	}

	// 没有被转义,且 query 组件中不含加号(+)
	if n == 0 && !hasPlus {
		return s, nil
	}

	// 分配最小的空间
	t := make([]byte, len(s)-2*n)
	j := 0
	for i := 0; i < len(s); {
		switch s[i] {
		case '%':
			// 之前 escape 的逆运算
			t[j] = unhex(s[i+1])<<4 | unhex(s[i+2])
			j++
			i += 3
		case '+':
			if mode == encodeQueryComponent {
				t[j] = ' '
			} else {
				t[j] = '+'
			}
			j++
			i++
		default:
			t[j] = s[i]
			j++
			i++
		}
	}
	return string(t), nil
}

再聊聊解析 Parse

// 解析 rawurl(URL 字符串,可以是相对路径,不包含 host 组件) 返回 URL 结构
func Parse(rawurl string) (*URL, error) {
	// 截取 # 之后的,获取 fragment 组件
	// 此 split 方法当第三个参数
	// 为 true 时,第二个值返回不包含 # 的两段;
	// 为 false 时, 第二个值返回包含 # 的两段;
	u, frag := split(rawurl, "#", true)
	url, err := parse(u, false)
	if err != nil {
		return nil, &Error{"parse", u, err}
	}
	if frag == "" {
		return url, nil
	}
	// 反转义 frag 成功则将解析出的 fragment 添加到 URL 结构体
	if url.Fragment, err = unescape(frag, encodeFragment); err != nil {
		return nil, &Error{"parse", rawurl, err}
	}
	return url, nil
}

// 解析 URL 基础方法,第二个参数 viaRequest 为 true 时只接受绝对地址,否则允许所有形式的相对url
// 此方法授受的 rawurl 不包含 fragment 组件
func parse(rawurl string, viaRequest bool) (*URL, error) {
	var rest string
	var err error

	if rawurl == "" && viaRequest {
		return nil, errors.New("empty url")
	}
	url := new(URL)

	if rawurl == "*" {
		url.Path = "*"
		return url, nil
	}

	// 获取 URL 中的 Scheme,例如:"http:", "mailto:", "ftp:"。不能包含转义字符。
	if url.Scheme, rest, err = getscheme(rawurl); err != nil {
		return nil, err
	}
	// 这里可以看到协议是不区分大小写的
	url.Scheme = strings.ToLower(url.Scheme)

	// 以问号(?)结尾,且只有一个问号(?)。reset 重置,去除问号(?)
	if strings.HasSuffix(rest, "?") && strings.Count(rest, "?") == 1 {
		url.ForceQuery = true
		rest = rest[:len(rest)-1]
	} else {
		// 截取出 reset([//[userinfo@]host][/]path)和原始的 query 组件(url.RawQuery)的内容
		rest, url.RawQuery = split(rest, "?", true)
	}

	// reset 不是以 / 开头,按理说我们会截出像([//[userinfo@]host][/]path),会以 // 开头
	if !strings.HasPrefix(rest, "/") {
		if url.Scheme != "" {
			// We consider rootless paths per RFC 3986 as opaque.
			url.Opaque = rest
			return url, nil
		}
		if viaRequest {
			return nil, errors.New("invalid URI for request")
		}

		// Avoid confusion with malformed schemes, like cache_object:foo/bar.
		// See golang.org/issue/16822.
		//
		// RFC 3986, §3.3:
		// In addition, a URI reference (Section 4.1) may be a relative-path reference,
		// in which case the first path segment cannot contain a colon (":") character.
		colon := strings.Index(rest, ":")
		slash := strings.Index(rest, "/")
		if colon >= 0 && (slash < 0 || colon < slash) {
			// First path segment has colon. Not allowed in relative URL.
			return nil, errors.New("first path segment in URL cannot contain colon")
		}
	}

	if (url.Scheme != "" || !viaRequest && !strings.HasPrefix(rest, "///")) && strings.HasPrefix(rest, "//") {
		var authority string

		// 下列对应关系
		// rest[2:] = [userinfo@]host][/]path
		// authority = [userinfo@]host
		// rest = [/]path
		authority, rest = split(rest[2:], "/", false)
		url.User, url.Host, err = parseAuthority(authority)
		if err != nil {
			return nil, err
		}
	}
	// 设置 URL 的 path 组件,如果 reset 被转义,则还会设置 URL 结构体的 RawPath 的值
	if err := url.setPath(rest); err != nil {
		return nil, err
	}
	return url, nil
}

// 设置 p 为 URL 的 path 组件,此方法将反转义 p
// p 被包含转义则 RawPath 为 p,Path 为反转义的 p
// 否则 RawPath 为空,Path 为 p
//  例:
// - setPath("/foo/bar")   Path="/foo/bar" , RawPath=""
// - setPath("/foo%2fbar") Path="/foo/bar" , RawPath="/foo%2fbar"
// p 包含不合法转义字符时,将抛出异常
func (u *URL) setPath(p string) error {
	path, err := unescape(p, encodePath)
	if err != nil {
		return err
	}
	u.Path = path
	if escp := escape(path, encodePath); p == escp {
		// 没有转义,原始的 path 既是空。
		u.RawPath = ""
	} else {
		u.RawPath = p
	}
	return nil
}

小结

net/url 包的主要实现就是这三个,其中还有一些辅助方法这里不多做介绍。

总结

看标准库对于字符的判断以及对字符空间的分配,当可确定长度的时候,坚决按需分配,减少不必要的浪费。

整体代码看起来不吃力,一来是因为着实简单,二来我觉得标准库写得确实清晰,也不仅仅是这个库。

Author Mo 最后更新: 2018-12-28 00:31:01