网络iOS

iOS URL编码(百分号编码)研究

2018-11-21  本文已影响90人  brownfeng

URL编码(URL encoding)也称为百分号编码(Percent-encoding), 是特定上下文统一资源定位符(URI)的编码机制. 实际上也使用与统一资源标志符(URI)的编码.

对于URI, 具体的结构如下:

foo://example.com:8042/over/there?name=ferret#nose

   \_/ \______________/ \________/\_________/ \__/

    |         |              |         |        |

  scheme     authority      path      query   fragment

我们能够看到:/?#[]@是用来分隔URI的不同的组件的.

混乱的URL编码

具体参考: 阮一峰: 关于URL编码.

URI的字符类型

URI所允许的字符分成保留未保留. 保留字符是那些具有特殊含义的字符. 例如, /字符用于URL不同部分的分节符(https://www.baidu.com/news). 未保留字符没有这些特殊含义. 百分号编码把保留字符表示为特殊字符序列, 根据URI的版本的不同略有变化, 下面是 RFC 3986保留字符, 与未保留字符:

除此之外, URI中的其它字符必须用百分号编码.

URI的保留字符的百分号编码

前面我们知道, URI的保留字符有特殊含义(reserved purpose), 并且URI中必须使用该字符用于其他目的, 那么该字符必须百分号编码. 这里所说的其他目的, 我们可以这样理解, 在URL中?key1=val1&key2=val2中的val1中如果有保留字符&或者*,那么这里保留字符用于其他目的, 需要编码.

百分号编码一个保留字符, 首先需要把该字符的ASCII的值表示为两个16进制的数字, 然后在其前面放置转义字符("%"), 置入URI中的相应位置. (对于非ASCII字符, 需要转换为UTF-8字节序, 然后每个字节按照上述方式表示.)

!   #   $   &   '   (   )   *   +   ,   /   :   ;   =   ?   @   [   ]

%21 %23 %24 %26 %27 %28 %29 %2A %2B %2C %2F %3A %3B %3D %3F %40 %5B %5D

在特定上下文中没有特殊含义的保留字符也可以被百分号编码, 在语义上与不百分号编码的该字符没有差别(这个特点非常重要, 如果我们不知道该字符是否需要被百分号编码, 那么最好用百分号编码一下).

在URI的查询部分(?字符后的部分)中, 例如/仍然是保留字符但是没有特殊含义, 除非一个特定的URI有其它规定. 该/字符在没有特殊含义时不需要百分号编码.例如https://www.baidu.com/news?name=p/p&age=13, 其中name=p/p中的/保留字符但是没有特殊含义, 在实际使用中可以不用给它进行百分号编码.

如果保留字符具有特殊含义, 那么该保留字符用百分号编码的URI与该保留字符仅用其自身表示的URI具有不同的语义.

URI中百分号编码未保留字符

未保留字符不需要百分号编码.

两个URI的差别如果仅在于未保留字符是用百分号编码还是字符本身表示, 那么这两个URI具有等价意义. 虽然是这样规定, 但是很多浏览器没有这样去设定.因此实际开发中, 我们建议, 尽量不要将未保留字符进行百分号编码, 防止不同的实现导致不同的结果.

其他不安全字符也需要百分号编码

这些字符, 当被直接放在URL中的时候, 能会引起解析程序的歧义。这些字符被视为不安全字符,原因有很多:

因此这些不安全的字符最好也进行百分号编码.

URI中百分号编码的非标准实现

有一些不符合标准的把Unicode字符在URI中表示为: %uxxxx, 其中xxxx是用4个十六进制数字表示的Unicode的码位值。

任何RFC都没有这样的字符表示方法,并且已经被W3C拒绝。第三版的ECMA-262仍然包含函数escape(string)使用这种语法, 但也有函数encodeURI(uri)转换字符到UTF-8字节序列并用百分号编码每个字节。

这里就涉及到 JS 的几个百分号编码函数, 建议使用encodeURI(uri)

iOS中百分号编码问题

在前序知识铺垫以后. iOS里面如何处理百分号编码的问题呢?

HTTP协议里面在URL中传递参数,是在?后面使用key=value这种键值对方式, 如果有多个参数传递, 就需要用&进行分割, 例如?key1=val1&key2=val2&key3=val3, 当服务器收到请求以后, 会用&分割出每个key=value参数, 然后用=分割出具体的键值.

现在如果在我们的参数key-value中就有=或者&怎么办? 这样后台在解析参数的时候, 就会产生歧义. 因此解决方法就是对参数进行百分号编码!!!!

iOS 中,我们在请求的中经常与百分号编码相关的方法 -- stringByAddingPercentEscapesUsingEncoding:

Summary

Returns a representation of the receiver using a given encoding to determine the percent escapes necessary to convert the receiver into a legal URL string.
Declaration

- (NSString *)stringByAddingPercentEscapesUsingEncoding:(NSStringEncoding)enc;
Discussion

It may be difficult to use this function to "clean up" unescaped or partially escaped URL strings where sequences are unpredictable. See CFURLCreateStringByAddingPercentEscapes for more information.
Parameters

encoding    
The encoding to use for the returned string. If you are uncertain of the correct encoding you should use NSUTF8StringEncoding.
Returns

A representation of the receiver using encoding to determine the percent escapes necessary to convert the receiver into a legal URL string. Returns nil if encoding cannot encode a particular character.
Open in Developer Documentation

当URL中有汉字时候, 用上面的方法, 会将汉字转化成 unicode 编码的结果, 但是对于复杂场景这个方法并不能满足需求, 例如&符号:

NSString *queryWord = @"汉字&ss";
NSString *urlString = [NSString stringWithFormat:@"https://www.baidu.com/s?ie=UTF-8&wd=%@", queryWord];
NSString *escapedString = [urlString stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
NSLog(@"%@", escapedString); // https://www.baidu.com/s?ie=UTF-8&wd=%E6%B1%89%E5%AD%97&ss

这个实例在开发中很常见(我们项目中是将某个人的昵称当做参数传递给后台), 后台在收到这种被转义以后的URL取得的参数如下:

["ie": "UTF-8", "wd" : "汉字", "ss": nil]

即使我们做如下处理, 在请求前将每个参数都转义, 再使用&拼接参数也无效:

NSString *queryWord = @"汉字&ss";
NSString *escapedQueryWord = [queryWord stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
NSString *urlString = [NSString stringWithFormat:@"https://www.baidu.com/s?ie=UTF-8&wd=%@", escapedQueryWord];
NSLog(@"%@", urlString); // https://www.baidu.com/s?ie=UTF-8&wd=%E6%B1%89%E5%AD%97&ss

这是因为stringByAddingPercentEscapesUsingEncoding方法并不会对&字符进行百分号编码!!!!

iOS中正确的使用百分号编码

如果要想自己控制哪些内容被编码, 哪些内容不会被编码, iOS提供了另外一个方法 -- stringByAddingPercentEncodingWithAllowedCharacters:.

这个方法会对字符串进行更彻底的转义,但是需要传递一个参数: 这个参数是一个字符集,表示: 在进行转义过程中,不会对这个字符集中包含的字符进行转义, 而保持原样保留下来。

NSString *queryWord = @"汉字&ss";
NSString *escapedQueryWord = [queryWord stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet letterCharacterSet]];
NSLog(@"%@", escapedQueryWord); // %E6%B1%89%E5%AD%97%26ss
NSString *urlString = [NSString stringWithFormat:@"https://www.baidu.com/s?ie=UTF-8&wd=%@", escapedQueryWord];
NSLog(@"%@", urlString); // https://www.baidu.com/s?ie=UTF-8&wd=%E6%B1%89%E5%AD%97%26ss

在上面的例子中传递参数[NSCharacterSet letterCharacterSet]来保证字母不被转义。所以被转义之后的参数值是:%E6%B1%89%E5%AD%97%26ss,这样&就能够正确被百分号编码.

但是如果实际场景中, 可能出现如下情况:

https://www.baidu.com/s?person[contact]=13801001234&person[address]=北京&habit[]=游泳&habit[]=骑行

此时, 需要自己构建 AllowedCharacters, 因为其中的[]是不需要转意的.

NSMutableCharacterSet *mutableCharSet = [[NSMutableCharacterSet alloc] init];
[mutableCharSet addCharactersInString:@"[]"]; // 允许'['和']'不被转义
NSCharacterSet *charSet = mutableCharSet.copy;

NSMutableString *mutableString = [NSMutableString string];
for (unit in queryString) {
    NSString *escapedField = [unit.field stringByAddingPercentEncodingWithAllowedCharacters:charSet];
    NSString *escapedValue = [unit.value stringByAddingPercentEncodingWithAllowedCharacters:charSet];
    [mutableString addFormat:@"%@=%@", escapedField, escapedValue];
}

准确说, 步骤如下:

  1. 构建AllowedCharactersNSCharacterSet
  2. 针对参数的k-v值, 进行遍历, 将针对keyvalue分别调用stringByAddingPercentEncodingWithAllowedCharacters进行百分号编码.
  3. @"?%@=%@&%@=%@"进行kv参数拼接, 和不同参数的拼接.

AFNetworking中对百分号编码的处理

对这种特定的字符进行百分号编码已经能够满足基本需求, 但是如果我们传递的参数非常复杂, 我们应该如何处理呢??

我们可以直接使用AFNetworking中的代码, 实例如下:

NSDictionary *params = @{@"name": @"p&p",
                            @"nick_name": @"p&p[]@= =!",
                            @"father name": @"~!@#$%^&*(){}"
                            };
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
NSMutableURLRequest *request = [serializer requestWithMethod:@"GET" URLString:@"https://www.baidu.com" parameters:params error:nil];
NSString *urlString = request.URL.absoluteString;
NSLog(@"%@", urlString);

//https://www.baidu.com?father%20name=~%21%40%23%24%25%5E%26%2A%28%29%7B%7D&name=p%26p&nick_name=p%26p%5B%5D%40%3D%20%3D%21

具体的处理方式, 建议参考AFNetworking的源码, 或者参考文章iOS. PercentEscape是错用的URLEncode,看看AFN和Facebook吧.

AFNetworking源码以及编码过程

关于AFNetworking中是如何做的:

FOUNDATION_EXPORT NSArray * AFQueryStringPairsFromDictionary(NSDictionary *dictionary);
FOUNDATION_EXPORT NSArray * AFQueryStringPairsFromKeyAndValue(NSString *key, id value);
FOUNDATION_EXPORT NSString * AFPercentEscapedStringFromString(NSString *string);


@interface AFQueryStringPair : NSObject
@property (readwrite, nonatomic, strong) id field;
@property (readwrite, nonatomic, strong) id value;

- (instancetype)initWithField:(id)field value:(id)value;
- (NSString *)URLEncodedStringValue;
@end

@implementation AFQueryStringPair
- (instancetype)initWithField:(id)field value:(id)value {
    self = [super init];
    if (!self) {
        return nil;
    }

    self.field = field;
    self.value = value;

    return self;
}

- (NSString *)URLEncodedStringValue {
    if (!self.value || [self.value isEqual:[NSNull null]]) {
        return AFPercentEscapedStringFromString([self.field description]);
    } else {
        return [NSString stringWithFormat:@"%@=%@", AFPercentEscapedStringFromString([self.field description]), AFPercentEscapedStringFromString([self.value description])];
    }
}


/**
 传入 Dict -> 返回对应的查询的参数String

 @param parameters Dict内部是 key-value
 @return 查询的参数String
 */
NSString * AFQueryStringFromParameters(NSDictionary *parameters) {
    NSMutableArray *mutablePairs = [NSMutableArray array];
    // 生成一组 AFQueryStringPair 数组
    NSArray<AFQueryStringPair *> *pairs = AFQueryStringPairsFromDictionary(parameters);

    // 遍历数组, 将每个 AFQueryStringPair 生成 "key=value", 并将结果String加入到结果数组
    for (AFQueryStringPair *pair in pairs) {
        // 将封装的 StringPair 进行 URLEncode 核心代码
        [mutablePairs addObject:[pair URLEncodedStringValue]];
    }
    // 将结果数组中每个字符通过 "&" 字符链接, 输出 Query 结果
    return [mutablePairs componentsJoinedByString:@"&"];
}


/**
 将dict 转化成  NSArray<AFQueryStringPair *>
 */
NSArray<AFQueryStringPair *> * AFQueryStringPairsFromDictionary(NSDictionary *dictionary) {
    return AFQueryStringPairsFromKeyAndValue(nil, dictionary);
}

/**
 key - value 核心方法

 @param key key
 @param value value -- 可能是常用的集合类 -- NSDictionary, NSArray, NSSet,
                        以及非集合类 -- 普通的 key - value
 @return 返回NSArray<AFQueryStringPair *> *
 */
NSArray<AFQueryStringPair *> * AFQueryStringPairsFromKeyAndValue(NSString *key, id value) {
    NSMutableArray *mutableQueryStringComponents = [NSMutableArray array];

    //1. key-value 会重新排序 -- 升序进行排序
    NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"description" ascending:YES selector:@selector(compare:)];

    //2. 根据当前value内容, 分辨进行不同的处理. 实际场景中, 我们最常用的是 Dict - 内部是key-value
    //   场景上来说

    /*
     2.1 value -> NSDictionary

     NSDictionary *dict = @{@"phone": @{@"mobile": @"xx", @"home": @"xx"};
     -> 会进入第一个分支 - Dict分支
     phone[mobile]=xx&phone[home]=xx
     */
    if ([value isKindOfClass:[NSDictionary class]]) {
        NSDictionary *dictionary = value;
        // Sort dictionary keys to ensure consistent ordering in query string, which is important when deserializing potentially ambiguous sequences, such as an array of dictionaries
        // 先将dictionarys 内容排序
        for (id nestedKey in [dictionary.allKeys sortedArrayUsingDescriptors:@[ sortDescriptor ]]) {
            id nestedValue = dictionary[nestedKey];
            if (nestedValue) {
                // 递归调用.
                [mutableQueryStringComponents addObjectsFromArray:AFQueryStringPairsFromKeyAndValue((key ? [NSString stringWithFormat:@"%@[%@]", key, nestedKey] : nestedKey), nestedValue)];
            }
        }

        /*
         2.2 value -> NSArray

         NSDictionary *dict = @{"members": @[@"pp", @"brownfeng"]};
         -> Array分支
         members[]=pp&members[]=brownfeng
         */
    } else if ([value isKindOfClass:[NSArray class]]) {
        NSArray *array = value;
        for (id nestedValue in array) {
            [mutableQueryStringComponents addObjectsFromArray:AFQueryStringPairsFromKeyAndValue([NSString stringWithFormat:@"%@[]", key], nestedValue)];
        }
        /*
         2.3 value -> NSSet

         NSDictionary *dict = @{@"counts": [NSSet setWithObjects:@"1", @"2", nil]};
         -> NSSet分支
         counts=1&counts=2
         */
    } else if ([value isKindOfClass:[NSSet class]]) {
        NSSet *set = value;
        for (id obj in [set sortedArrayUsingDescriptors:@[ sortDescriptor ]]) {
            [mutableQueryStringComponents addObjectsFromArray:AFQueryStringPairsFromKeyAndValue(key, obj)];
        }
    } else {
        /*
         2.4 value -> NSString

         普通的 key-value类型. 直接生成 AFQueryStringPair

         NSDictionary *dict = @{@"name": @"pp"};
         -> 其他分支
         name=p
         */
        [mutableQueryStringComponents addObject:[[AFQueryStringPair alloc] initWithField:key value:value]];
    }

    return mutableQueryStringComponents;
}

/**
 百分号编码的核心代码!!!!!

 Returns a percent-escaped string following RFC 3986 for a query string key or value.
 RFC 3986 states that the following characters are "reserved" characters.
 - General Delimiters: ":", "#", "[", "]", "@", "?", "/"   -> 常见的分隔符
 - Sub-Delimiters: "!", "$", "&", "'", "(", ")", "*", "+", ",", ";", "="  -> 其他分隔符

 In RFC 3986 - Section 3.4, it states that the "?" and "/" characters should not be escaped to allow
 query strings to include a URL. Therefore, all "reserved" characters with the exception of "?" and "/"
 should be percent-escaped in the query string.
 - parameter string: The string to be percent-escaped.
 - returns: The percent-escaped string.

 上面注释写的很清楚:

 "?"和"/"两个符号在query必须进行百分号编码, 因为query部分不允许包含URL!!!!
 */
NSString * AFPercentEscapedStringFromString(NSString *string) {
    // 需要被百分号编码 - @":#[]@"
    static NSString * const kAFCharactersGeneralDelimitersToEncode = @":#[]@"; // does not include "?" or "/" due to RFC 3986 - Section 3.4
    // 需要被百分号编码 - @"!$&'()*+,;="
    static NSString * const kAFCharactersSubDelimitersToEncode = @"!$&'()*+,;=";
    // "?", "/" - 没有被百分号编码!!!!

    // 1. 创建字符集 - URL Query 部分允许的字符集
    NSMutableCharacterSet * allowedCharacterSet = [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
    // 2. 从允许字符集中删掉不允许的字符集, 因此编码时候,
    [allowedCharacterSet removeCharactersInString:[kAFCharactersGeneralDelimitersToEncode stringByAppendingString:kAFCharactersSubDelimitersToEncode]];

    // 解决iOS7,8中可能导致的crash问题

    // FIXME: https://github.com/AFNetworking/AFNetworking/pull/3028
    // return [string stringByAddingPercentEncodingWithAllowedCharacters:allowedCharacterSet];

    static NSUInteger const batchSize = 50;

    NSUInteger index = 0;
    NSMutableString *escaped = @"".mutableCopy;

    while (index < string.length) {
        NSUInteger length = MIN(string.length - index, batchSize);
        NSRange range = NSMakeRange(index, length);

        // To avoid breaking up character sequences such as 👴🏻👮🏽
        range = [string rangeOfComposedCharacterSequencesForRange:range];

        NSString *substring = [string substringWithRange:range];
        NSString *encoded = [substring stringByAddingPercentEncodingWithAllowedCharacters:allowedCharacterSet];
        [escaped appendString:encoded];

        index += range.length;
    }

    return escaped;
}


因此如果比较复杂的query内容比如如下:

{
    NSDictionary *params = @{
                            @"name": @"小A", // 标准 key-value, 汉字需要被编码
                            @"phone": @{@"mobile": @"xx", @"home": @"xx"}, // key - Dict
                            @"families": @[@"father", @"mother"], // key - Array
                            @"nums": [NSSet setWithObjects:@"1", @"2", nil], // key - set
                            @"does_not_include": @"/?", // 不会被编码    (注意: OC中的"\\"才能表示"\")
                            @"space": @" ", //需要被编码  (空格)
                            @"GeneralDelimitersToEncode": @":#[]@", // 需要完全被编码
                            @"SubDelimitersToEncode": @"!$&'()*+,;=", // 需要完全被编码
                            };
    AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
    NSMutableURLRequest *request = [serializer requestWithMethod:@"GET" URLString:@"https://www.baidu.com" parameters:params error:nil];
    NSString *urlString = request.URL.absoluteString;
    NSLog(@"%@", urlString);
    /*
https://www.baidu.com?GeneralDelimitersToEncode=%3A%23%5B%5D%40&SubDelimitersToEncode=%21%24%26%27%28%29%2A%2B%2C%3B%3D&does_not_include=/?&families%5B%5D=father&families%5B%5D=mother&name=%E5%B0%8FA&nums=1&nums=2&phone%5Bhome%5D=xx&phone%5Bmobile%5D=xx&space=%20

https://www.baidu.com?GeneralDelimitersToEncode=:#[]@&SubDelimitersToEncode=!$&'()*+,;=&does_not_include=/?&families[]=father&families[]=mother&name=小A&nums=1&nums=2&phone[home]=xx&phone[mobile]=xx&space=
     */
}

AFNetworking中的参数解析过程如下:

第一块, key-value的模式

@{
    @"name": @"小A",
    @"phone": @{@"mobile": @"xx", @"home": @"xx"},
    @"families": @[@"father", @"mother"],
    @"nums": [NSSet setWithObjects:@"1", @"2", nil],
};
->
@[
     field: @"name", value: @"小A",
     field: @"phone[mobile]", value: @"xx",
     field: @"phone[home]", value: @"xx",
     field: @"families[]", value: @"father",
     field: @"families[]", value: @"mother",
     field: @"nums", value: @"1",
     field: @"nums", value: @"2",
]
->
name=%E5%B0%8FA&phone[mobile]=xx&phone[home]=xx&families[]=father&families[]=mother&nums=1&num=2

第二部分: 哪些内容需要编码

@{
    @"does_not_include": @"/?",
    @"space": @" ", 
    @"GeneralDelimitersToEncode": @":#[]@",
    @"SubDelimitersToEncode": @"!$&'()*+,;=",
};
->
@[
     field: @"does_not_include", value: @"/?",
     field: @"space", value: @" ",
     field: @"GeneralDelimitersToEncode", value: @":#[]@",
     field: @"SubDelimitersToEncode", value: @"!$&'()*+,;=",
]
->
https://www.baidu.com?GeneralDelimitersToEncode=%3A%23%5B%5D%40&SubDelimitersToEncode=%21%24%26%27%28%29%2A%2B%2C%3B%3D&does_not_include=/?&space=%20


-> URL Decode以后
https://www.baidu.com?GeneralDelimitersToEncode=:#[]@&SubDelimitersToEncode=!$&'()*+,;=&does_not_include=/?&space= 

参考文章

  1. 为什么要进行URL编码
  2. 阮一峰: 关于URL编码
  3. wiki: 百分号编码
  4. iOS. PercentEscape是错用的URLEncode,看看AFN和Facebook吧
上一篇 下一篇

猜你喜欢

热点阅读