iOS和前端中的那些交集——URL编码篇

iOS和前端中的那些交集——URL编码篇

为什么URL需要编码,不编码的URL会有什么问题,系统提供的编码方式存在哪些弊端?这是一些思考。

因为同时在做前端开发和iOS开发,工作过程中发现两者之间有些差距又有些相似,所以试着总结一些两者的交集,可能选取的都是一些不那么大众且有些奇奇怪怪的点,但可能正是很容易被忽略的。

URL的组成

URL的全称是Uniform Resource Locator,中文名字叫统一资源定位符,俗称网址。

一个URL的组成是什么呢,拿一个网址举例子:

http://username:[email protected]:80/path/to/myfile.html?key1=value1&key2=value2#SomewhereInTheDocument
元素名称 举例 备注
协议类型 scheme http
凭证信息 authority username:password
服务器地址 host www.example.com
端口号 port 80 如果不填,默认为80
路径 path /path/to/myfile.html
查询 query ?key1=value1&key2=value2
片段 fragment #SomewhereInTheDocument

一个引申:URI是什么

虽然和URL很像,但是URI还是有不同的。URI全称为Uniform Resource Identifier,指的是统一资源标识符。两者一个是标识,一个是定位,区别就在此。简单来说,URI可以用来唯一标识一个资源,但标识的方式却不止一种,比如通过某种路径的方式一层层查找、或者仅仅通过名字(唯一id,比如用于图书查询的ISBN)。那么这两种标识的方式一个叫统一资源定位符(URL),一个叫统一资源名称(URN,Uniform Resource Name)。

img

URL和URN都是URI的子集。

为什么需要URL编码

简单来说,编码的意义都很相似:利于传输(加密和解密也可以这样理解吧)、消除歧义。而URL编码正是为了避免传输过程中产生歧义。

根据RFC标准,URL中只能包含字母a-zA-Z、数字0-9、特殊字符- _ . ~、保留字符! * ' ( ) ; : @ & = + $ , / ? # [ ]以及一些不安全字符。除此之外,出现在URL中的其他字符都应当编码成这些字符。

信息量有些大,我们来列个表:

名称 内容
字母 a-zA-Z
数字 0-9
特殊字符 - _ . ~
保留字符 ! * ' ( ) ; : @ & = + $ , / ? # [ ]
不安全字符 `% 空格 < > ' " { }

名称内容字母a-zA-Z数字0-9特殊字符- _ . ~保留字符! * ' ( ) ; : @ & = + $ , / ? # [ ]不安全字符% 空格 < > ' " { } | \ ^ ~ [ ]

众所周知,在url中传递参数,通常使用query形式。将所有参数按照"key=value"格式拼接,再用"&"符号分隔,最后拼在整个url的后面,比如?key1=value1&key2=value2;要解析的时候,也是拿出这么一串字符串,按照"&"符号分隔开,再把每一项通过"="号区分出键值对,如此而已。

但是如果在key或者value里混入了"="或者"&",就没这么简单了,起码可以预想到仅仅把字符串做分隔就会出幺蛾子——

举个极端一些的例子,有这样一个网址:

http://test.com/index.html?key1=结果1&key2=val=ue2&key3=val?ue=3&key4=?val/#ue4&key5==&va%lue5

这个网页在解析传入参数的时候,可能就逐渐开始纳闷:key1=结果1,key2是个啥?key3是个啥?怎么又出来个ue=3?....

实际上,我们可能真正想表达的是这样:key1=结果1、key2=val=ue2、key3=val?ue=3、key4=?val/#ue4、key5===&va%lue5

不止参数,当这些字符侵入其他部分,也会影响阅读性甚至歧义。

所以,正常情况下,你见过的所有正常和稀奇古怪的网址实际上都是由这些字符组成的。正因为如此,大部分情况下你可能见不着什么“http://新华网.中国”、“http://www.aβγ.com”、甚至是“http://📙.la”,因为这种字符压根不允许存在(当然,这些网址还真的存在,这个下文再谈)。

理想情况下,如果编码操作只靠简单粗暴的一次字符替换就能完成,那就没有下文了。实际上,这种针对URL的编码还是有一些学问的。

为什么URL编码是个学问

再思考一下上面的问题:如果把url整体做一次字符串替换,把不允许的变为允许的,会发生什么?

就拿写这边文章时正在开着的网页举例子,它的URL是https://www.google.com/search?q=url编码&newwindow=1。假设我们已经拿到了一张对应表,告诉你某个字符的对应编码是什么,我们先来试试对着表做一次简单替换:

https%3a%2f%2fwww.google.com%2fsearch%3fq%3durl%e7%bc%96%e7%a0%81%26newwindow%3d1

简直是一团乱麻,就算把这串东西粘贴到浏览器,浏览器也不认识你要做什么。

实际上,URL编码并不是对URL的全盘替换,而应该针对网址中的每个组成部分分别处理,有些应该转、有些不应该转,有些部分则要换一种转义方式......

最常见的URL编码方式——百分号编码

百分号编码方式是针对URL中字符的常用编码方式,其本质是ASCII。在转码时,把某个字符取对应的ASCII表示为两个16进制字符,然后在前面添加百分号即可。例如,a的ASCII值为97,用16进制表示为0x61,那么a的百分号编码结果就为%61.

对于非ASCII字符,就要将字符先转化为UTF8字节,将结果的每个字节分别进行ASCII编码,例如“中文”需要先转化为utf-8字节,再分别做百分号编码。一个中文字符为三个字节,所以转化的结果就为“%e4%b8%ad%e6%96%87”。

有了转义方式,下面看看转义的要求。事实上,官方的编码规范如下:如果一个保留字符在特定上下文中具有特殊含义(称作"reserved purpose") , 且URI中必须使用该字符用于其它目的, 那么该字符必须百分号编码;未保留字符不需要百分号编码。

玄机就在“保留字符”、“不安全字符”上面。保留字符是指具有特殊含义的字符,例如斜线"/"代表URL不同部分的分界符。那么转义的规则就是,如果这些字符确实用于特殊含义,那么不用转义;如果被用作其他目的(比如用作普通的一个value),那么就必须进行转义。

还拿这个url举例子:

http://test.com/index.html?key1=结果1&key2=val=ue2&key3=val?ue=3&key4=?val/#ue4&key5==&va%lue5

要把它转义,大概是这么个流程:

  • 把所有不允许的字符做转义:结果 -> %e7%bb%93%e6%9e%9c
  • 判断所有保留字符和不安全字符:=符号共出现了8次,其中5个是分隔key与value用的,3个为value值内容,那么把这三个做转义;?共出现了3次,1个是用来分隔url主体与查询参数的,另外两个为value值内容,那么把这两个做转义......

最后的结果是:

http://test.com/index.html?key1=%e7%bb%93%e6%9e%9c1&key2=val%3due2&key3=val%3fue%3d3&key4=%3fval%2f%23ue4&key5=%3d&va%25lue5

一个引申:host的编码

在国际化的趋势下,人们希望能在域名上玩出花来,各种国家的人希望用母语来输入网址,由此产生了国际化域名(Internationalized Domain Name,缩写IDN)。由于早期的DNS只支持英文域名,为了保证兼容性,需要用一种方式把其他文字的语言转义为英文。这种转义就用到了Punycode方式。把其他语言的域名经过Punycode编码后,加上前缀"xn--"就变成了合法的英文域名。

JavaScript中的URL编码

前面提到,URL由多个部分组成,经验证明,一般将URL拆解为各个小块,对每个部分做编码即可。

为此,JavaScript 提供了encodeURIencodeURIComponent两个方法用来对 URL 进行编码,可以方便适应各种情况。

encodeURI

encodeURI是一种粗略的编码方式,它针对整个网址进行一次字符替换。encodeURI假定字符串中的每个保留字符都是作为特殊含义使用的,于是,它不会转义保留字符。

encodeURI的不编码集为!#$&'()*+,/:;=?@-._~0-9a-zA-Z

通常,如果要编码的是整个URL,那么用encodeURI即可,虽然它并不能消除歧义。

let url = encodeURI('https://www.google.com/search?q=url编码');
// https://www.google.com/search?q=url%E7%BC%96%E7%A0%81

encodeURIComponent

encodeURIComponent的不编码集为!'()*-._~0-9a-zA-Z,与encodeURI相比,少了,/?:@&=+$#几个字符。

其实看名字也能大概了解区别,Component意为组件,所以encodeURIComponent是针对URL组件的一种编码。encodeURIComponent会认为字符串中的每个保留字符都是仅当字符串使用的,所以它的编码范围就比较广了,除了encodeURI里的内容,它会把保留字符也做转义。

在前端拼接参数的时候,通常会把每一个value值做encodeURIComponent编码,最后按照查询参数格式做顺序拼接。

let url = 'https://www.google.com/search?q=' + encodeURIComponent('url编码');
// https://www.google.com/search?q=url%E7%BC%96%E7%A0%81

iOS中的URL编码

NSURL的URLWithString方法有个特点,如果传入的urlString不符合RFC标准(比如前面提到的,包含了不允许出现的字符如空格、中文等),那么就会返回nil值。所以,在iOS中对URL做编码就更有必要了。

和JavaScript比起来,iOS中的URL编码方法有些混乱,但也可以说比较开放,它提供了基于NSCharacterSet的编码方式,以便于更灵活地控制需要编码的字符。

了解一下:NSCharacterSet是什么

NSCharacterSet规定了由若干字符组成的一组字符集,以便于配合NSString使用,做一些批量替换、过滤、分割等等操作。

NSCharacterSet提供了一些内置字符集:

controlCharacterSet // 控制符字符集
whitespaceCharacterSet // 空格和空白符号字符集
whitespaceAndNewlineCharacterSet // 空格和换行字符集
decimalDigitCharacterSet // 十进制数字字符集
letterCharacterSet // 字母字符集
lowercaseLetterCharacterSet // 小写字母字符集
uppercaseLetterCharacterSet // 大写字母字符集
nonBaseCharacterSet // 非基础字符集
alphanumericCharacterSet // 字母和数字字符集
decomposableCharacterSet // 可分解字符集
illegalCharacterSet // 非法字符集
punctuationCharacterSet // 标点字符集
capitalizedLetterCharacterSet // 首字母大写字符集
symbolCharacterSet // 符号字符集
newlineCharacterSet // 换行符字符集

同时,也可以通过[NSCharacterSet characterSetWithCharactersInString:@"string"]方法来创建自己的字符集。

这样,进行一些字符串处理的话就比较方便:

// 去除首尾空格
[str stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
// 字符串按照空白分割
[str componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet];

CFURLCreateStringByAddingPercentEscapes

'CFURLCreateStringByAddingPercentEscapes' is deprecated: first deprecated in iOS 9.0 - Use [NSString stringByAddingPercentEncodingWithAllowedCharacters:] instead, which always uses the recommended UTF-8 encoding, and which encodes for a specific URL component or subcomponent (since each URL component or subcomponent has different rules for what characters are valid).

废弃方法。解编码的方法为CFURLCreateStringByReplacingPercentEscapesUsingEncoding:

stringByAddingPercentEscapesUsingEncoding:

Use -stringByAddingPercentEncodingWithAllowedCharacters: instead, which always uses the recommended UTF-8 encoding, and which encodes for a specific URL component or subcomponent since each URL component or subcomponent has different rules for what characters are valid.

废弃方法。它内置了一组编码规则,用于把非法字符进行百分号编码,同时允许指定字符串编码方式,如UTF-8(但是真的不推荐更改编码方式,所以新的API去掉了这个参数)。解编码的方法为stringByReplacingPercentEscapesUsingEncoding:

但是这个方法编码并不完善,一些特殊字符仍然没有被编码。

stringByAddingPercentEncodingWithAllowedCharacters:

iOS 7.0开始支持,这个方法可以针对指定的NSCharacterSet不做编码,对其余字符进行百分号编码(其实正类似于JavaScript encodeURI中的“不编码集”概念),是目前比较推荐的URL编码方式。解编码的方法为stringByRemovingPercentEncoding

值得提出的是,在NSURL.h里面也提供了六个内置的字符集,分别是:

URLUserAllowedCharacterSet // url整体允许字符集,编码字符为"#%/:<>?@[\]^`
URLPasswordAllowedCharacterSet // url密码中允许字符集,编码字符为"#%/:<>?@[\]^`{|}
URLHostAllowedCharacterSet // url域名中允许字符集,编码字符为"#%/<>?@\^`{|}
URLPathAllowedCharacterSet // url路径中允许字符集,编码字符为"#%;<>?[\]^`{|}
URLQueryAllowedCharacterSet // url查询中允许字符集,编码字符为"#%<>[\]^`{|}
URLFragmentAllowedCharacterSet // url片段中允许字符集,编码字符为"#%<>[\]^`{|}

而上面这几种,实际上都不能实现encodeURIComponent的效果,对于URL某个参数的自编码,无论使用哪种NSCharacterSet都无法转换完全,比如"+"(%2b)、"="(%3d)、"&"(%26)等等。也就是说,这样无法真正地消除URL歧义,只是做了一些表面工作。

一个办法是,在内置字符集的基础上加以修改,加上一些其他的编码字符:

NSMutableCharacterSet *charset = [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
[charset removeCharactersInString:@"!*'();:@&=+$,/?%#[]"]; // 加上一些需要编码的字符
NSString *encodedValue = [string stringByAddingPercentEncodingWithAllowedCharacters:charset];

另一个办法是,完全自定义一个新的字符集:

NSCharacterSet *charset = [NSCharacterSet characterSetWithCharactersInString:@":/?&=;+!@#$()',*% "].invertedSet;
NSString *encodedValue = [string stringByAddingPercentEncodingWithAllowedCharacters:charset];

参考