別再使用stringByAddingPercentEscapesUsingEncoding 當遇到發送網路請求的參數中有漢字的情況,很多人一股腦地使用 進行轉義,這樣帶有漢字的urlString就會將每個漢字轉成相應的unicode編碼對應的3個%形式,這叫urlEncode(每個能寫後端的語言都....
別再使用stringByAddingPercentEscapesUsingEncoding
當遇到發送網路請求的參數中有漢字的情況,很多人一股腦地使用stringByAddingPercentEscapesUsingEncoding:
進行轉義,這樣帶有漢字的urlString就會將每個漢字轉成相應的unicode編碼對應的3個%形式,這叫urlEncode(每個能寫後端的語言都有的方法),但是蘋果的stringByAddingPercentEscapesUsingEncoding:
卻不是urlEncode。實際上我們使用的參數值可能會包含一些特殊的字元,如&
,?
這樣的字元,而Percent轉義已經不能滿足需求了,如下麵的例子:
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
產生這種情況的原因是:百分號轉義不等於URLEncode
該編碼不同於URL編碼,由於不會對&
字元編碼,因此不會改變URL參數的分隔。URL編碼會編碼&
、?
與其他標點符號。如果查詢字元串包含了這些字元,那麼需要實現一種更加徹底的編碼方法。
不過還好iOS7.0推出了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
,這樣問題就解決了,但是有時候會遇到queryString中的表單域也需要轉義的情況,比如是一個表單數組如:
https://www.baidu.com/s?person[contact]=13801001234&person[address]=北京&habit[]=游泳&habit[]=騎行
這樣可以使用將key轉義,不過key中的[
和]
字元是不需要轉義的:可以自定義一個CharacterSet實現需求:
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];
}
這樣問題已經圓滿解決了,美中不足的是:當queryString非常多的時候你如何保證從queryString正確地提取出來每個unit呢,這個牽扯到複雜的字元串解析的問題。先不做討論。實際上有一個好的方案是使用AFN將每個參數的URL和queryString在構建的時候分離,使用URL和parameter(字典)分別傳入的方法,也就是說在使用AFN的時候避免使用:
GET:@"https://www.baidu.com/s?ie=UTF-8&wd=%E6%B1%89%E5%AD%97%26ss"
parameters:nil
success:nil
failure:nil
而是儘量使用
GET:@"https://www.baidu.com/s"
parameters:@{@"ie":@"UTF-8",@"wd":@"漢字&ss"};
success:nil
failure:nil
為什麼要這樣,翻看AFN的源碼會發現,AFN對queryString的組裝是這樣進行的:
AFN會將parameters的傳遞的字典通過將每個表單元素的field和value進行urlcode之後拼接,然後再直接附加在傳遞的URLString後面(當然,如果是POST方式就不是附加了,而是將拼好的串放到HTTP body中)。
那麼如果要使用第一種方式,必須要確保自己在傳入的URLString是經過完美轉義的,因為AFN不會對你傳入的URLString進行檢測有沒有進行了轉義或者正確與否,但是AFN對上面方法中parameter參數的解析時非常徹底的,因此強烈建議使用第二種方式調用AFN的方法。那麼AFN是如何完美解析parameter參數的呢,這剛好是一個可以將字典轉為queryString的模塊呀!!!,下麵就來看一下:
對AFN urlEncode的研究
AFN將網路訪問分割為三個過程模塊
:
1.請求前:構建request的header和queryString、uploadContent和配置(如超時等),這部分的功能在AFURLRequestSerialization中
2.請求中:分別有基於NSConnection的訪問(3.0移除)和基於NSURLSession的訪問模塊
3.請求後:1錯誤處理2.成功處理:數據格式轉換和解析,主要在AFURLResponseSerialization中
requestSerialization就像過濾器一樣,每一個用於構建網路請求的URLRequest對象都會經過requestSerialization配置,再返回一個NSMutableURLRequest對象(參見2.x版本的dataTaskWithHTTPMethod: URLString: parameters: success: failure
方法,3.0版本dataTaskWithHTTPMethod URLString: parameters: uploadProgress: downloadProgress: success:
方法),NSURLSession對象會使用這個NSMutableURLRequest對象創建task。而我們要討論的將parameter轉為queryString的功能全部在AFURLRequestSerialization中,它實際上使用了
NSMutableURLRequest *request = [self.requestSerializer requestWithMethod:method URLString:[[NSURL URLWithString:URLString relativeToURL:self.baseURL] absoluteString] parameters:parameters error:&serializationError];
就完成了所有的請求前的配置功能,可以查看一下內部的實現,有一句關鍵性的代碼
mutableRequest = [[self requestBySerializingRequest:mutableRequest withParameters:parameters error:error] mutableCopy];
// 這裡的self是AFURLResponseSerialization對象
這句代碼用於對request對象設置requestHeader和轉義queryString,我們僅僅看一下對queryString進行轉義的
其內部按照這樣的思路實現:
1.如果傳遞過來的parameters不為空,就會判斷self.queryStringSerialization是否為空(self.queryStringSerialization屬性是一個 AFQueryStringSerializationBlock類型的block,它是用來實現轉義的核心代碼塊)
2.如果self.queryStringSerialization不為空,使用self.queryStringSerialization(request, parameters, &serializationError);
進行轉義和組裝:
3.如果self.queryStringSerialization為空,使用一個內部函數來執行:AFQueryStringFromParameters(parameters),實際上每一個AFURLResponseSerialization對象在創建的時候queryStringSerialization屬性都是空的,因此外部不傳遞block類型的值給queryStringSerialization屬性時都會走這條路線,也就是使用AFQueryStringFromParameters(parameters)來解析參數。
AFQueryStringFromParameters
的實現是這樣的:
NSString * AFQueryStringFromParameters(NSDictionary *parameters) {
NSMutableArray *mutablePairs = [NSMutableArray array];
for (AFQueryStringPair *pair in AFQueryStringPairsFromDictionary(parameters)) {
[mutablePairs addObject:[pair URLEncodedStringValue]];
}
return [mutablePairs componentsJoinedByString:@"&"];
}
而其中使用到的AFQueryStringPairsFromDictionary
函數是這樣實現的:
NSArray * AFQueryStringPairsFromDictionary(NSDictionary *dictionary) {
return AFQueryStringPairsFromKeyAndValue(nil, dictionary);
}
NSArray * AFQueryStringPairsFromKeyAndValue(NSString *key, id value); // 這個方法太長,只放置了原型
思路為:
1.利用AFQueryStringPairsFromKeyAndValue函數將parameters字典中的每個key-value對取出,將每個key-value對構建為AFQueryStringPair對象,放到一個數組中。
2.在AFQueryStringFromParameters方法內部遍歷這個數組(每個元素為AFQueryStringPair對象),使用AFQueryStringPair類的轉義方法URLEncodedStringValue將AFQueryStringPair轉為字元串,將這些字元串存入新的數組中。這樣新數組中的每個元素就是轉義之後的field=value字元串,最後用&
將數組元素連接即可。
函數AFQueryStringPairsFromKeyAndValue是一個非常完美的演算法,基本上考慮到了所有類型的表單域:包括表單數組的處理和對一個表單域賦值多個value的情況的處理,表單數組在html頁面經常用到的:
<form method="GET" action="http://127.0.0.1/test.php">
<input name="habit[]" value="游泳" />
<input name="habit[]" value="騎行" />
<input type="submit" value="提交">
</form>
瀏覽器自動轉義: habit%5B%5D=%E6%B8%B8%E6%B3%B3&habit%5B%5D=%E9%AA%91%E8%A1%8C
AFN傳遞parameter = @{@"habit":@[@"游泳", @"騎行"]}:
2.x版本:habit[]=%E6%B8%B8%E6%B3%B3&habit[]=%E9%AA%91%E8%A1%8C
3.0版本:habit%5B%5D=%E6%B8%B8%E6%B3%B3&habit%5B%5D=%E9%AA%91%E8%A1%8C (與瀏覽器相同)
php會將$_GET解析為:
array(1) {
["habit"]=> array(2) {
[0]=> string(6) "游泳"
[1]=> string(6) "騎行"
}
}
如果是這種寫法:
<form method="GET" action="http://127.0.0.1/test.php">
<input name="person[contact]" value="13801001234" />
<input name="person[address]" value="北京" />
<input type="submit" value="提交">
</form>
瀏覽器自動轉義:person%5Bcontact%5D=13801001234&person%5Baddress%5D=%E5%8C%97%E4%BA%AC
AFN傳遞parameter = @{@"person":@{@"contact":@"13801001234", @"address":@"北京"}}:
2.x版本: person[address]=%E5%8C%97%E4%BA%AC&person[contact]=13801001234 (沒有將[]轉義)
3.0版本:person%5Baddress%5D=%E5%8C%97%E4%BA%AC&person%5Bcontact%5D=13801001234 (與瀏覽器相同,但對field進行了排序)
結果為:
array(1) {
["person"]=> array(2) {
["contact"]=> string(11) "13801001234"
["address"]=> string(6) "北京"
}
}
如果是對於一個field多參數的情況
<form method="GET" action="http://127.0.0.1/test.php">
<input type="checkbox" name="habit" value="游泳">游泳
<input type="checkbox" name="habit" value="騎行">騎行
<input type="submit" value="提交">
</form>
瀏覽器自動轉義:habit=%E6%B8%B8%E6%B3%B3&habit=%E9%AA%91%E8%A1%8C
AFN傳遞parameter = @{@"habit":set} 其中set為:NSSet *set = [NSSet setWithObjects:@"游泳", @"騎行", nil];:
2.x版本: habit=%E6%B8%B8%E6%B3%B3&habit=%E9%AA%91%E8%A1%8C (與瀏覽器相同)
3.0版本:habit=%E6%B8%B8%E6%B3%B3&habit=%E9%AA%91%E8%A1%8C (與瀏覽器相同)
以上各種類型的html表單域的處理,函數AFQueryStringPairsFromKeyAndValue
都已經很好地處理,不管如此,還對每個field按照NSString預設排序規則(字母表順序)進行了排序。
在接下來我會針對2.x版本的AFN剖析一下AFQueryStringPair的轉義方法- (NSString *)URLEncodedStringValueWithEncoding:(NSStringEncoding)stringEncoding
的實現,中間會說到一些和3.0的差別:
我們看一下- (NSString *)URLEncodedStringValueWithEncoding:(NSStringEncoding)stringEncoding
的代碼
- (NSString *)URLEncodedStringValueWithEncoding:(NSStringEncoding)stringEncoding { // AFN3.0的區別是換了個方法名,而且不用傳遞stringEncoding
if (!self.value || [self.value isEqual:[NSNull null]]) { // 如果value為空值,只轉義field
return AFPercentEscapedQueryStringKeyFromStringWithEncoding([self.field description], stringEncoding);
} else { // 將field和value轉義後拼接
return [NSString stringWithFormat:@"%@=%@", AFPercentEscapedQueryStringKeyFromStringWithEncoding([self.field description], stringEncoding), AFPercentEscapedQueryStringValueFromStringWithEncoding([self.value description], stringEncoding)];
}
}
而對於AFPercentEscapedQueryStringKeyFromStringWithEncoding
和AFPercentEscapedQueryStringValueFromStringWithEncoding
方法,它的實現是這樣的:
static NSString * const kAFCharactersToBeEscapedInQueryString = @":/?&=;+!@#$()',*"; // 在queryString進行URLEncode時需要進行轉義的字元
static NSString * AFPercentEscapedQueryStringKeyFromStringWithEncoding(NSString *string, NSStringEncoding encoding) {
static NSString * const kAFCharactersToLeaveUnescapedInQueryStringPairKey = @"[]."; // 在urlencode時不需要轉義
return (__bridge_transfer NSString *)CFURLCreateStringByAddingPercentEscapes(kCFAllocatorDefault, (__bridge CFStringRef)string, (__bridge CFStringRef)kAFCharactersToLeaveUnescapedInQueryStringPairKey, (__bridge CFStringRef)kAFCharactersToBeEscapedInQueryString, CFStringConvertNSStringEncodingToEncoding(encoding));
}
static NSString * AFPercentEscapedQueryStringValueFromStringWithEncoding(NSString *string, NSStringEncoding encoding) {
return (__bridge_transfer NSString *)CFURLCreateStringByAddingPercentEscapes(kCFAllocatorDefault, (__bridge CFStringRef)string, NULL, (__bridge CFStringRef)kAFCharactersToBeEscapedInQueryString, CFStringConvertNSStringEncodingToEncoding(encoding));
}
這裡是使用了一個CoreFoundation中定義的函數:CFURLCreateStringByAddingPercentEscapes
這函數的參數解釋如下:
index | 參數名 | 解釋 |
---|---|---|
1 | allocator | 為新的CFString對象分配記憶體的分配器,傳遞NULL或者kCFAllocatorDefault使用當前預設的分配器 |
2 | originalString | 要copy的CFString對象 |
3 | charactersToLeaveUnescaped | 在百分號轉義過程中要完好地留下的字元集,傳遞NULL指明所有非法的字元會被轉義 |
4 | legalURLCharactersToBeEscaped | 需要轉義的合法的字元集。傳遞NULL指明沒有合法的字元集要被替換(所有字元都不轉義) |
5 | encoding | 轉化過程使用的編碼 如果你不關心正確的編碼,你應該使用UTF-8 (kCFStringEncodingUTF8), 這是一個由RFC 3986設計的在URL使用中很合適的編碼 |
看了參數的說明應該很容易理解為什麼要那樣傳遞參數,不過需要註意的是傳遞的字元串都是CFStringRef類型:因此要和NSString做一下橋接:
// NSString 轉為CFStringRef
(__bridge CFStringRef)string
// CFStringRef 轉為NSString
(__bridge_transfer NSString *)string
而對於3.0的AFN無論是對field的轉義還是對value的轉義都使用了相同的函數NSString * AFPercentEscapedStringFromString(NSString *string)
其在內部的實現就是使用了在本文第一部分提到提到的系統方法- (nullable NSString *)stringByAddingPercentEncodingWithAllowedCharacters:(NSCharacterSet *)allowedCharacters
,而apple的文檔中指出這個方法內部會按照UTF-8編碼進行轉義,因此這裡剛好解釋了為什麼之前2.x版本需要傳遞編碼參數,而3.0就不用了。
這裡是NSString * AFPercentEscapedStringFromString(NSString *string)
函數的一點核心代碼:
NSString * AFPercentEscapedStringFromString(NSString *string) {
static NSString * const kAFCharactersGeneralDelimitersToEncode = @":#[]@"; // does not include "?" or "/" due to RFC 3986 - Section 3.4
static NSString * const kAFCharactersSubDelimitersToEncode = @"!$&'()*+,;=";
NSMutableCharacterSet * allowedCharacterSet = [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
[allowedCharacterSet removeCharactersInString:[kAFCharactersGeneralDelimitersToEncode stringByAppendingString:kAFCharactersSubDelimitersToEncode]];
// ......
// .....
return eacapedString;
}
可以看到允許轉義的字元集一開始是URLQueryAllowedCharacterSet,然後去掉了kAFCharactersGeneralDelimitersToEncode(@":#[]@")和kAFCharactersSubDelimitersToEncode(@"!$&'()*+,;=")包含的字元,也就是說這些字元最終都需要轉義,相比較2.x版本確實是多轉義了[
,]
和.
。這也是剛纔看到說的使用AFN2.x版本參數值和3.0版本轉義後參數值不同而3.0與瀏覽器中相同的原因。
費了那麼大的勁,終於把這部分梳理清晰了,然後來做一件有意義的事:(將字典轉為queryString的功能抽取)
將AFN字典轉queryString模塊抽取
這裡我只是寫了一個NSDictionary的分類
@interface NSDictionary (ConvertToQueryString)
- (NSString *)convertToQueryString;
@end
#import "NSDictionary+ConvertToQueryString.h"
#import "AFNetworking.h"
@implementation NSDictionary (ConvertToQueryString)
- (NSString *)convertToQueryString {
if (!self || [self isEqual:[NSNull null]]) {
return @"";
}
#if AFN Version < 3.0
return AFQueryStringFromParametersWithEncoding(self, NSUTF8StringEncoding);
#else
return AFQueryStringFromParameters(self);
#endif
}
@end
一切就是那麼簡單,但是很有用處。接下來就是一點點擴展了,我們知道AFN已經封裝了字典轉為queryString的功能,那麼有時候會有將queryString轉為字典的需求,雖然這種需求並不常見,但偶爾也會碰到。那麼具體怎麼做呢。我推薦一個比較優秀的框架:Facebook的facebook-ios-sdk這是一個用於構建iOS應用的基礎框架:包含了facebook登錄和分享、處理應用間跳轉的功能、一些繪圖API,應用的數據統計模塊等功能。這個框架並不是多麼龐大,源碼文件也比較少,但是其中一個網路工具還是挺好用的:FBSDKUtility。
在這個類的頭文件中只是聲明瞭這樣4個方法:
@interface FBSDKUtility : NSObject
+ (NSDictionary *)dictionaryWithQueryString:(NSString *)queryString;
+ (NSString *)queryStringWithDictionary:(NSDictionary *)dictionary error:(NSError *__autoreleasing *)errorRef;
+ (NSString *)URLDecode:(NSString *)value;
+ (NSString *)URLEncode:(NSString *)value;
@end
在m文件中的實現並不複雜,單就URLEncode來說,它雖然對了一些對參數值的驗空操作,但是沒有想AFN那樣將各種情況都充分考慮,因此若要完成queryStringWithDictionary:的功能還是建議使用AFN的功能,將AFN的方法加入到FBSDKUtility中,這種代碼的普適性降低了但是增加幾分可靠性。
至於dictionaryWithQueryString:
方法,我相信我們都能寫出這樣的方法,但是說到底數據被轉換後是要拿給後臺使用的,排除各種後臺語言和框架的差異,我們應該做到儘量使得傳遞的數據與後臺經過解析之後獲取的數據一致。
+ (NSDictionary *)dictionaryWithQueryString:(NSString *)queryString
{
NSMutableDictionary *result = [[NSMutableDictionary alloc] init];
NSArray *parts = [queryString componentsSeparatedByString:@"&"];
for (NSString *part in parts) {
if ([part length] == 0) {
continue;
}
NSRange index = [part rangeOfString:@"="];
NSString *key;
NSString *value;
if (index.location == NSNotFound) {
key = part;
value = @"";
} else {
key = [part substringToIndex:index.location];
value = [part substringFromIndex:index.location + index.length];
}
key = [self URLDecode:key];
value = [self URLDecode:value];
if (key && value) {
result[key] = value;
}
}
return result;
}