背景
公司内一般对调试环境会分为线上
、预发布
和QA测试
三种,而在切换环境的时候大多还是通过网页或桌面应用,当在调试客户端应用时这样的方式就比较麻烦了,所以我们组社会光哥
将整套环境切换和请求转发的原理理清晰之后实现了 Android 版的 Debug 环境切换工具,而我则根据其原理实现了 iOS 版。客户端接入该工具之后,只需在应用内即可切换环境和指定调试目录(每个目录独立部署代码)。
原理
环境切换
先简单介绍下环境切换原理(From 光哥)
不同环境对应不同的内网 IP 机器,它是一台反向代理服务器,将网络请求代理到对应环境的云端机器上处理,处理完成之后通过反向代理服务器再返回给请求方。
环境设置实际是向对应的地址发送一个网络请求,携带上自己的 WiFi IP(如果开启 Charles 等代理工具后,则是电脑的 WiFi IP),以及云端机器的 IP 和 Port进行绑定,例如:
1 2 3 4 5 6 7 8
| NSString *URLString = [@"http://" stringByAppendingFormat:@"%@:12345/index", [self IPByEnv:self.env]]; NSURL *URL = [NSURL URLWithString:URLString]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:URL]; request.HTTPMethod = @"POST"; [request setValue:self.proxyIP forHTTPHeaderField:@"X-Forward-For"];
GCCarmenModel *carmenModel = [self carmenConfig]; [request setHTTPBody:[[carmenModel toPostBodyString] dataUsingEncoding:NSUTF8StringEncoding]];
|
请求转发
我们需要使用 NSURLProtocol
拦截客户端发出的所有请求,我们继承 NSURLProtocol
后需要在 AppDelegate 中注册其子类,而 NSURLProtocol
的执行顺序和注册顺序是相反的,所以一般情况我们把它放在最后注册。
1 2 3
| #ifdef DEBUG [NSURLProtocol registerClass:NSClassFromString(@"ZanDNSProtocol")]; #endif
|
目前大多数网络库我们用的都是 AFNetworking
,是基于 NSURLSession
的网络请求,所以在构造 NSURLSessionConfiguration
之后,需要指定它的 protocolClasses
,但为了不破坏 AF 源码,我们可以 Swizzling
系统 NSURLSession
的 API sessionWithConfiguration:delegate:delegateQueue:
,做到对网络库无侵入。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @implementation NSURLSession (DNSSwizzling)
+ (void)load { #ifdef DEBUG dns_classMethodSwizzle(self, @selector(sessionWithConfiguration:delegate:delegateQueue:), @selector(zan_sessionWithConfiguration:delegate:delegateQueue:)); #endif }
+ (NSURLSession *)zan_sessionWithConfiguration:(NSURLSessionConfiguration *)configuration delegate:(nullable id <NSURLSessionDelegate>)delegate delegateQueue:(nullable NSOperationQueue *)queue { configuration.protocolClasses = @[NSClassFromString(@"ZanDNSProtocol")]; return [self zan_sessionWithConfiguration:configuration delegate:delegate delegateQueue:queue]; }
@end
|
接着在 ZanDNSProtocol
( NSURLProtocol 子类)中实现父类方法,一共四步:
第一步判断是否需要拦截,本地我们维护了一份 DNS 列表,我们只拦截在列表中的域名请求:
1 2 3 4 5 6 7 8 9 10 11
| + (BOOL)canInitWithRequest:(NSURLRequest *)request { // ... NSString *host = request.URL.host; NSDictionary *DNSConfig = [ZanDNSManager sharedInstance].DNSConfig; if ([[DNSConfig allKeys] containsObject:host]) { return YES; } return NO; }
|
第二步我们修改拦截到的请求,我们解析出当前环境下 Host 对应列表中的 IP 地址,然后在 URL
中用 IP 替换掉 Host,并在请求头中加上 Host
,目的是为了防止开启代理之后,代理工具做 DNS 解析,解析到线上的 IP 地址,这样转发的请求还是往线上发:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| + (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { // ... NSMutableURLRequest *forwardRequest = [request mutableCopy]; NSString *URLString = forwardRequest.URL.absoluteString; NSString *originalHost = forwardRequest.URL.host; NSDictionary *DNSConfig = [ZanDNSManager sharedInstance].DNSConfig; NSString *IP = DNSConfig[originalHost]; URLString = [URLString stringByReplacingOccurrencesOfString:originalHost withString:IP]; forwardRequest.URL = [NSURL URLWithString:URLString]; [forwardRequest setValue:originalHost forHTTPHeaderField:@"Host"]; [forwardRequest setValue:[ZanDNSManager sharedInstance].proxyIP forHTTPHeaderField:@"X-Forward-For"]; return forwardRequest; }
|
第三步就是发送请求,如果我们开启了代理,可以配置 NSURLSessionConfiguration
的 connectionProxyDictionary
,好处是我们可以不用再下载 Charles 证书到手机,可以直接代理到 Charles 来抓包:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| - (void)startLoading { [NSURLProtocol setProperty:@(YES) forKey:propertyKey inRequest:[self.request mutableCopy]]; NSURLSessionConfiguration *configuration; if ([ZanDNSManager sharedInstance].openProxy) { NSDictionary *proxyDic = @{@"HTTPEnable": @(YES), (NSString *)kCFStreamPropertyHTTPProxyHost: [ZanDNSManager sharedInstance].proxyIP, (NSString *)kCFStreamPropertyHTTPProxyPort: @([ZanDNSManager sharedInstance].proxyPort), @"HTTPSEnable": @(YES), (NSString *)kCFStreamPropertyHTTPSProxyHost: [ZanDNSManager sharedInstance].proxyIP, (NSString *)kCFStreamPropertyHTTPSProxyPort: @([ZanDNSManager sharedInstance].proxyPort)}; configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration]; configuration.connectionProxyDictionary = proxyDic; } else { configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; } self.session = [NSURLSession zan_sessionWithConfiguration:configuration delegate:self delegateQueue:nil]; NSURLSessionDataTask *task = [self.session dataTaskWithRequest:self.request]; [task resume]; }
|
第四步是我们对请求过程中信任所有证书
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *_Nullable))completionHandler { if (!challenge) { return; }
NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling; NSString *host = self.request.allHTTPHeaderFields[@"host"]; if (![challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) { completionHandler(disposition, nil); return; } NSURLCredential *credential = nil; if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) { disposition = NSURLSessionAuthChallengeUseCredential; credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]; } completionHandler(disposition, credential); }
|
至此我们完成了一整套环境切换和请求拦截转发的方案,但是该工具还未能做到拦截转发 WKWebView
的请求,欢迎大家一起来交流交流~
2018年9月24日更新:
看到腾讯在使用 WK 时遇到的坑里面提到对 WK 的网络拦截,简单的说可以通过注册 http(s) scheme 来拦截到 WK 的网络请求,但是由于 WK 是在独立进程中进行网络通信的,使用 NSURLProtocol 将网络请求拦截到 App 进程时,出于性能原因,IPC 会将 HTTPBody 和 HTTPStream 丢弃,所以 POST 请求的 Body 内容会丢失,不能做到完美的拦截。