HTTP/HTTPS 抓包 ---- Charles

Charles Tutorial

Posted by Tony Chan on 2016-09-18

网上数据抓包,在当前抓包工具横行的时代,对于一个IT开发者来说,是一个很简单的必备的技能,例如青花瓷(Charles)等等工具.

https通讯过程


在讲https抓包之前,必须要了解https的整个校验和通信过程,我们就简单的精简的画一下重要的过程,至于什么三个随机数或者通信秘钥的生成就不详细介绍,主要是针对讲一下https的中间人攻击(https抓包的实现基础)过程

在这个过程中,正常的话,如果哪个步骤出现问题,链接都会停止,无法进行通信,这个是https简单的校验的一个过程介绍.

那么,https在抓包工具中是如何实现抓包的呢?
抓包工具就是在上面的过程中,证书认证生成通信密钥中做了手脚.
以青花瓷为例,大家使用青花瓷抓http请求时,由于没有做安全校验,很容易就实现了数据拦截和转发,至于https呢?

中间人攻击的情形


抓取https包的时候,青花瓷会要求使用者 对抓包的设备(手机或其他设备)
,安装一个证书,安装这个证书的时候,其实是安装了一个根证书(允许颁发CA的一个证书机构的根证书),当你安装了该根证书之后,该证书机构颁发的其他证书,默认都会被你的系统所信任,这个就是青花瓷完成https抓包的一个重要前提!!

当客户端设置了代理,并且开始发出网络请求的时候,这个网络请求的校验过程就会变成这样

  • 当客户端Client对服务器Server发送请求(带着随机数和加密算法),由于青花瓷做了代理,请求被青花瓷拦截,处理(青花瓷的角色现在对于Client来说是服务器),青花瓷将客户端带的随机数和加密算法处理,然后返回自己的证书通过客户端校验,获取到客户端提交的请求参数等数据,
  • 青花瓷作为客户端(自己去产生随机数和携带支持的加密算法)去请求刚刚Client想要请求的Server,然后,Server会和青花瓷完成上面讲的那个完整的校验,并且读取青花瓷带错来的具体请求,返回正常的数据结果.
  • 青花瓷得到服务器数据的返回结果之后,开始继续和过程1中的Client以服务器的身份,去做处理,首先收到客户端的随机数和加密算法,自己生成一个随机数和选择一个客户端的加密算法,然后重要* 青花瓷会返回一个伪造的CA证书(公钥和真实的server不一样,但是域名是一样的,或者说,除了域名是一致的,其他的都不是一致的,而且这个签发机构是青花瓷之前让你安装的根证书 签发的,所以,当返回这个证书的时候,你的客户端的信任链是可以完成的,会被系统信任),然后Client在这个伪造的证书(对于青花瓷和Client是真实证书(验证信任链和证书信息都通过了),但是和真实的域名对应的证书来看,是伪造证书)的基础上,和青花瓷通信,然后青花瓷再和Server通信,成了一个中间人的角色,这样,整个过程的数据传输,都被青花瓷给监听到了,在此,中间人攻击的过程 就完成了

如何防止被抓包


当进行网络请求的时候,客户端判断当前是否设置了代理,如果设置了代理,不允许进行访问(不知道微信浏览器 里面 是不是这样实现的,微信里面 设置了代理看公众号等信息就都不允许看了,无法访问)
附带判断是否设置代理的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
+ (BOOL)getProxyStatus {
NSDictionary *proxySettings = NSMakeCollectable([(NSDictionary *)CFNetworkCopySystemProxySettings() autorelease]);
NSArray *proxies = NSMakeCollectable([(NSArray *)CFNetworkCopyProxiesForURL((CFURLRef)[NSURL URLWithString:@"http://www.google.com"], (CFDictionaryRef)proxySettings) autorelease]);
NSDictionary *settings = [proxies objectAtIndex:0];

NSLog(@"host=%@", [settings objectForKey:(NSString *)kCFProxyHostNameKey]);
NSLog(@"port=%@", [settings objectForKey:(NSString *)kCFProxyPortNumberKey]);
NSLog(@"type=%@", [settings objectForKey:(NSString *)kCFProxyTypeKey]);

if ([[settings objectForKey:(NSString *)kCFProxyTypeKey] isEqualToString:@"kCFProxyTypeNone"])
{
//没有设置代理
return NO;
}
else
{
//设置代理了
return YES;
}
}

客户端本地做证书校验,并且设置不仅仅校验公钥,设置完整的正式校验模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
+(AFSecurityPolicy*)customSecurityPolicy
{
// /先导入证书
NSString *cerPath = [[NSBundle mainBundle] pathForResource:@"test" ofType:@"cer"];//证书的路径
NSData *certData = [NSData dataWithContentsOfFile:cerPath];
// AFSSLPinningModeCertificate 使用证书验证模式 (AFSSLPinningModeCertificate是证书所有字段都一样才通过认证,AFSSLPinningModePublicKey只认证公钥那一段,AFSSLPinningModeCertificate更安全。但是单向认证不能防止“中间人攻击”)
AFSecurityPolicy *securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate];
// allowInvalidCertificates 是否允许无效证书(也就是自建的证书),默认为NO
// 如果是需要验证自建证书,需要设置为YES
securityPolicy.allowInvalidCertificates = YES;

//validatesDomainName 是否需要验证域名,默认为YES;
//假如证书的域名与你请求的域名不一致,需把该项设置为NO;如设成NO的话,即服务器使用其他可信任机构颁发的证书,也可以建立连接,这个非常危险,建议打开。

//置为NO,主要用于这种情况:客户端请求的是子域名,而证书上的是另外一个域名。因为SSL证书上的域名是独立的,假如证书上注册的域名是www.google.com,那么mail.google.com是无法验证通过的;当然,有钱可以注册通配符的域名*.google.com,但这个还是比较贵的。
//如置为NO,建议自己添加对应域名的校验逻辑。
securityPolicy.validatesDomainName = YES;
NSSet<NSData*> * set = [[NSSet alloc]initWithObjects:certData , nil];
securityPolicy.pinnedCertificates = set;


return securityPolicy;
}

这样的话,证书会校验请求的时候不仅仅校验域名,会将证书中的公钥及其他信息也进行校验,这样的话,中间人伪造的证书就无法通过验证,无法进行抓包

如何抓包


要掌握 HTTP ,就需要先看到 HTTP 到底长什么样?(不了解「网络七层协议模型」和 TCP 的同学先不着急,本系列的后面几篇会涉及到。)

1、安装 HTTP 抓包工具


在 Chrome 开发者工具下我们可以看到,打开一个网页后,浏览器会发起许多 HTTP 的请求(HTTP Request),这些请求经过服务器端处理后会返回对应的数据(HTTP Response),浏览器会按照这些数据的类型将它们渲染出来。

Chrome Network Panel

Chrome 中看到的 Request/Response Header 是其格式化之后的形式,要看到它们的原始模样(Raw Source),我们需要借助两个 HTTP 接口调试利器。

其中 Windows 系统下使用 Fiddler,Mac 系统下使用 Charles。Fiddler 具体的安装与使用教程,请自行百度(安装 Fiddler4 还需同时安装 .NET Framework 4),Charles 相关教程,推荐参考 iOS 大神唐巧的《Charles 从入门到精通》。使用 Linux 系统的说明已经是网络编程方面的大牛了,不需要继续往下看 :P

2、查看 HTTP 详细报文


运行 Fiddler(或 Charles) 之后,使用 Chrome 浏览器打开「猫哥学前班」的新浪微博主页:http://weibo.com/mgxqb

在 Fiddler 左侧面板下选中该条 HTTP 请求,再将右侧面板的请求部分和响应部分都切换到 Raw 标签页。如下图所示:

Fiddler Panel

Charles 下的操作与 Fiddler 类似:

Charles Panel

HTTP 协议规范由 W3C 制定,与具体的抓包工具无关,接下来我们主要以 Charles 为例,详细讲解下 HTTP 的报文格式,这对理解基于 HTTP 的 API 接口设计和网页性能优化有很大帮助。

我们先看一下请求头的源码(Request Raw),为了防止隐私泄露,我已删除部分 Cookie 信息:

1
2
3
4
5
6
7
8
9
GET /mgxqb HTTP/1.1
Host: weibo.com
Cache-Control: max-age=0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.73 Safari/537.36
Accept-Encoding: gzip, deflate, sdch
Accept-Language: en-US,en;q=0.8,zh-CN;q=0.6,zh;q=0.4
Cookie: YF-Page-G0=f70469e0b5607cacf38b47457e34254f; _s_tentry=passport.weibo.com

仔细观察以上源码,我们能大概总结出 HTTP 协议的格式规范:

  • 第一行定义了请求类型(GET)、请求路径(/mgxqb)与协议类型及其版本号(HTTP/1.1),使用一个半角空格间隔这三块信息;
  • 示例源码的最后是两个空行。由于 HTTP 规范中要求一个合法的 HTTP 报文至少包含有一个空行,其中第一个空行用来间隔报文的头部信息(HTTP Request/Response Header)和主体信息(HTTP Request/Response Body)。在空行的下一行是报文的主体信息,由于本例为 GET 类型请求,其主体(Body)信息通常为空,这便是第二个空行的含义;
  • 余下的部分有着相同的格式,即 「HTTP Header 字段名+半角冒号+半角空格+值」,我们可以把它看成 YAML 格式的简易版。其中 HTTP Header 在规范中有着明确的定义,具体参见 HTTP头字段列表

这便是一个 HTTP 协议报文的源码格式,以下我们简单讲解下最常见的 HTTP header 的含义。

3、常见 HTTP header


User-Agent:客户端身份标识


User-Agent (以下简称 UA)字段记录了访问当前网页的用户浏览器的类型与版本、操作系统类型与版本。根据不同的 UA 信息,提供不同的站点内容是使用 UA 的常见场景。例如,如果用户使用手机访问魅族官网 www.meizu.com,浏览器会自动跳转至魅族手机官网 m.meizu.com。这种跳转实现既可以由前端 JavaScript 完成,也可以通过后端返回 302 重定向来完成。

JavaScript 访问 window.navigator.userAgent 属性即可获取该信息。虽然该属性是只读的,但有很多前端手段可以伪造 UA 。如下图,Chrome 开发者工具在模拟不同的手机机型时,也会改变浏览器 UA 值。由此可见,通过检测 HTTP User-Agent Header 来识别是否为爬虫程序,不是一个有效的方法。

使用 Chrome 开发者工具模拟不同手机设备 UA

在 PHP 中,所有的 HTTP Header 字段信息都保存在 $_SERVER 对象中,通过访问 $_SERVER['HTTP_USER_AGENT'] 即可获取 User-Agent 的值。

Cookie:用户身份标识


由于 HTTP 协议最初被设计成一种无状态的数据传输协议,服务器端无法判断每次处理的请求相互之间以及与之前处理的请求之间的关系,Cookie 的设计就是为了解决这个问题。

用户在浏览器中首次访问一个站点时,会通过请求响应头或页面JS脚本生成一些用于标识用户身份的 Cookie 信息,这些信息会按照域名分类,存放在浏览器本地缓存文件当中。例如 Windows 系统下通过访问 「C:\Users<用户名>\AppData\Local\Microsoft\Windows\Temporary Internet Files」 目录可以查看到 IE 浏览器保存在本地的 Cookie 文件。当用户再次访问该站点时,这些 Cookie 信息会被浏览器自动添加到 HTTP Request Header 的 Cookie 字段中,服务器通过读取这些信息,来区分当前请求的用户身份与状态。

浏览器可以通过读写 document.cookie 属性来添加或删除 Cookie 信息,服务器端可以通过 HTTP Response Header(响应头)中的 Set-Cookie 来改写客户端的 Cookie 信息。每一条 Cookie 属性通常都会设置一个过期时间,过期之后的 Cookie 浏览器将会自动清理它们,不会再被携带在 HTTP Request Header(请求头)中。

例如,以下 PHP 语句可以通过设置 Cookie 过期时间为前一个小时来触发客户端 Cookie 过期,达到删除 Cookie 的目的:

1
setcookie('key', '', time() - 3600, '/');

由于 Cookie 通常用于记录用户「帐号信息」和用户的「操作记录」,所以泄露 Cookie 会带来个人帐号与隐私泄露的风险。这也是为什么你在百度上搜索「贷款」的关键词之后,访问其他网站时就能看到相关的推荐广告,甚至第二天就会有各种放贷电话找上门来。

又由于 Cookie 可以随意被客户端修改(通过修改 document.cookie 属性),因此浏览器厂商们一起制定了 HttpOnly 的 Cookie 机制。服务器端在 setcookie 时,通过设置 HttpOnly 的标识,可以防止客户端通过 JavaScript 修改 Cookie 的信息。不过这种方法对于基于 HTTP 协议进行篡改的方法来说无法防范,在之后的猫哥网络编程系列中,我将会介绍如何通过监控 Wi-Fi 流量来截取、伪造用户身份。

在 YSlow 性能优化最佳实践中,有两条与 Cookie 相关的建议:

虽然浏览器对 Cookie 的大小与数量有着较为严格的限制,但很多网站(尤其是包含登录态的)的 Cookie 信息量通常比其他所有 HTTP Header 加起来的还要多。为了减少不必要的 HTTP 数据传输量,YSlow 给出了以上两条优化建议。由于网页的静态资源(图片、CSS、JS)文件无需记录用户状态,因此通常会使用一个额外的域名(Cookie 是按域名来分类存储)来存放静态资源文件。

我们使用 Chrome 开发者工具查看「猫哥学前班」新浪微博主页,可以看到新浪微博使用了 img.t.sinajs.cn 的域名来存放它的 CSS 文件,这个域名发起的 HTTP Request Header 中没有自动带上 Cookie 字段信息 (因为前后端脚本都没有在这个域名上设置 Cookie,而是设置在了 weibo.com 域名上):

Use Cookie-free Domains

这里还需要引申一个知识点:Session,它和 Cookie 有什么关系?由于 Session 与本文所讲的 HTTP 协议关系不大,相关知识点请自行百度。

Cache-Control:浏览器资源缓存标识


网站性能优化中,最为关键的是缓存机制(又是没有之一)。在服务器端通常会使用 Memcached、Redis 等服务来缓存经常访问的数据。例如在一个电商网站中,用户经常访问的热卖商品数据会被缓存在内存中,用户在一定时间内访问商品详情页时,后台程序直接从缓存服务中获取这段数据,这种方法可以大幅降低数据库的访问压力。

在用户端,浏览器会有一系列机制通过缓存来提升页面加载速度。例如 IE/Chrome 都会缓存 GET 类型的 AJAX 请求,IE 甚至会缓存 POST 类型的请求,需要通过增加时间戳参数的方式来强制清除缓存。对于所有的静态资源文件来说,最佳实践是为它们增加一个 「Never Expires」(永不过期)的强(长)缓存,以下是一个强缓存静态资源服务器的 Nginx 配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
server {
listen 80;
server_name yekai.net;
root /var/www/yekai.net;

location / {
index index.html index.htm;
}
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf|js|css)$ {
expires 365d;
}
}

通过配置 「expires 365d」,HTTP Response Header(响应头)中会返回 「Cache-Control: max-age=31536000」 的头字段,配合 Last-Modified 头字段。浏览器便可以自动完成资源的强缓存。

Cache-Control 是浏览器缓存机制中最为重要的一个配置,以下是浏览器加载静态资源文件时的缓存检查机制流程:

浏览器缓存检查机制流程

由此可见,静态资源缓存优化的最佳状态是:直接从本地缓存中读取 304 状态 200 状态。关于 HTTP 状态码,与网站性能优化有关的主要是以下几个。

  • 尽量减少 200 状态码的请求。200 表示是一个正常的请求返回,此条优化规则要求尽可能多的减少页面的 HTTP Request 数量。常见的方法有:合并打包静态资源、使用 CSS Sprite 雪碧图合并、缓存 AJAX、使用 LocalStorage/UserData/Manifest 等本地缓存技术。
  • 清理返回 301/302 状态码的入口链接。301 表示永久重定向,302 表示临时重定向。服务器端使用重定向返回通常是为了兼容一个旧的入口链接。我们能做的优化是,将调用旧入口的场景进行清理,直接调用重定向之后的新 URL 地址。
  • 304 表示静态资源未更新,浏览器可直接使用本地缓存文件。通常 304 的产生与浏览器的处理机制以及服务器缓存头配置有一定的关系。304 虽然未传输文件主体内容,但 HTTP 请求的建立依然是一个可以避免的性能损耗。腾讯 KM(内部知识分享平台)上有一篇文章通过在真实海量业务场景(没记错的话是 Qzone 业务)中,正交验证 HTTP 1.0 与 1.1 协议中与缓存相关的 HTTP Header 配置,结合日志分析得出了一个最佳实践:**关闭 Etag 配置,只启用 Cache-Control 与 Last-Modified 响应头。为了兼容老浏览器,可保留 Expires。**因为 Etag 的缓存方案,在经过 CDN 及网关代理服务器后,会导致缓存命中率下降。从以上「浏览器缓存检查机制流程」图上可以看出,使用强缓存(Cache-Control max-age 设置为一年)后浏览器在资源过期前不会发起 HTTP 请求,那如何保证静态资源在服务器上更新后本地的缓存也能同步更新呢?可参考百度 FIS 的「文件指纹」方案。
  • 清理返回 404 状态码的入口链接。静态资源文件的 404 调用需严格避免,而入口页面的 404 则在所难免。通过在全站 404 页面进行产品引导与体验优化,并结合数据上报记录来源页(HTTP Referer Headerdocument.referrer),可以找到并清理 404 来源入口。对于由搜索引擎进入的来源,可通过主动提交新索引至搜索引擎,或使用 301/302 重定向的方式,有效利用起这些「被浪费的流量」。
  • 502 服务器出错。如果是 Nginx + FastCGI 的常见架构,通常是由于 Nginx 缓冲区溢出或服务器资源被耗尽引起,针对不同的业务场景进行 Nginx 的配置优化能显著提升服务器抗压性能。

如果你对上文提及的「网络性能优化」的知识点十分感兴趣,建议你通读 Steve Souders 的《高性能网站建设指南》与《高性能网站建设进阶指南》,Steve Souders 的个人网站上积累了很多性能优化的方法与案例。

如果你能看到这里,相信你已经知道明白关于 「HTTP 协议、状态码、缓存与性能优化」相关的问题。

调试


「HTTP PEM 调试法」之 Proxy


Windows 下的 Fiddler 和 Mac 下的 Charles 这两款 HTTP 抓包工具,其实它们就是两个 HTTP 代理服务器(HTTP Proxy Server)。由于 HTTP 是一种符合 REST 架构风格(Representational State Transfer)的协议,具有无状态(Stateless)与统一接口(Uniform Interface)的架构约束,因此其代理机制的实现十分的简单。

打个比方,我们可以把 Proxy Server 理解成一个快递中转站,当一个包裹经过中转站时,包裹的信息(发件人、收件人与包裹里的货物)通常不会做任何的改动,直接发往下一个中转站或顾客手中。但中转站完全有能力修改快递单信息、拆箱检查货物,甚至是私吞或调换货物。

当我们需要快速定位「线上产品的接口问题」时,如果没有源码、数据、依赖服务和足够的时间去搭建一个测试环境,则通常会使用 HTTP 代理服务器来进行快速抓包调试。

Fiddler 默认只允许本地 IP(127.0.0.1)使用代理服务,通过设置「Tools -Connections -Allow remote computers to connect」可以开启其他 IP(通常是同一局域网内的其他设备)使用代理服务。

Fiddler 开启 Remote Proxy

Charles 默认开放代理服务,但陌生设备首次连接时需要授权确认,通过以下配置可以设置成无需授权。

授权所有设备使用 Charles 代理服务

以上两款软件默认的代理端口均是 8888 ,软件开启之后,我们可以在对应的平台终端下通过 ipconfig(Windows) 或 ifconfig(Mac)命令查询本机的局域网 IP,还可以使用 telnet 命令检查代理通道是否可用。(注:Win7 下如何开启 telnet 命令请参考百度经验。)

以下是 Windows 下 CMD 终端的使用截图,Mac 系统下请类比参考。

CMD 下 ipconfig 与 telnet

接下来,我们将手机的 Wi-Fi 代理设置为上述的 IP 与 端口号,以下是 iOS 的设置截图( Android 系统通常是长按已连接的 Wi-Fi ,在弹出的高级设置菜单中配置代理服务器)。

iOS 下设置 HTTP 代理

至此,手机上任意应用发起的 HTTP 请求都将会被代理服务器(本例中的 Fiddler/Charles 软件)监听到。

「HTTP PEM 调试法」之 Edit


通过代理服务器监听到 HTTP 请求之后,我们可以通过浏览报文的详细信息,定位出可能的接口问题。Fiddler 与 Charles 都具有同样强大的 HTTP 编辑(Edit)、重发(Replay/Repeat)、断点(Breakpoints)功能。Charles 的基础与高级用法请参考《Charles 从入门到精通》,Fiddler 教程可以参考 OSChina 专题《HTTP调试代理 Fiddler》,以下介绍 Fiddler 的部分常见用法。

Fiddler Edit 与 AutoResponder


抓到手机 HTTP 请求之后,通过编辑(Unlock For Editing)和重发(Replay)操作可以不断地调试接口的响应是否符合预期。

Fiddler 手动修改调试请求

通过设置自动响应规则(AutoResponder Rules)可以将响应头设置成常见状态码的返回,或将响应体映射成本地文件,通过外部编辑器修改文件内容进行调试。其中,若设置响应为 *bpu*bpafter 可以在请求前与响应前的事件触发时进行断点调试,十分方便。

Fiddler 将请求映射本地文件

需要注意的是,在 Fiddler 中使用 Replay 功能重发请求时,请求由 Fiddler 代理重新发起而非手机,因此手机 App 中的 H5 不会有任何变化。只有重新刷新 App 的 H5 页面,配合 HTTP 断点调试(Breakpoints )的方式才可以让修改后的 HTTP 响应体在 App中生效。这里介绍另外一种配合 Weinre 的调试用法。

Weinre 基本用法


Weinre 属于知名 Hybrid 框架 Cordova 中的一款 Web App 远程调试工具。通过在页面中注入一段 JS 脚本,可以在 PC 和手机端的 H5 页面之间建立一个 Socket 双向数据传输通道。原理上可以理解为,当我们在 PC 端的后台进行 debug 时,相关的操作被序列化成一组 JSON 字符串,数据经由通道传输给手机端中的 H5 页面,页面在接收到这些数据之后反序列化成相应的 JS 脚本操作,在其 window 上下文中执行,并将执行的结果回传给通道,PC 端的 Chrome 通过监听通道获取到相应的数据在 debug 后台中展现出来。

以下介绍 Weinre 的基本用法:

  1. 通过 npm 全局安装 weinre: npm install -g weinre
  2. 在本地 8081 端口上启动 weinre 服务:weinre --boundHost 0.0.0.0 --httpPort 8081 。通常在 Node.js 的服务中绑定 IP 为 0.0.0.0 而非 127.0.0.1(本地 IP),意味着可以让任意来源的 IP 访问该服务
  3. 通过上文介绍的 ipconfig(Mac 为 ifconfig)命令获取本机 IP 后,在本机 Chrome 浏览器中访问 Weinre 管理后台:http://10.2.69.47:8081 (本例中我的 IP 为 10.2.69.47,请注意将其替换成自己的局域网 IP)
  4. 在管理后台我们能看到相关使用说明,要求将以下脚本插入需要调试的 H5 页面中:<script src="http://10.2.69.47:8081/target/target-script-min.js#anonymous"></script>
  5. 将以上脚本插入进 H5 页面后,我们在 PC 端 Chrome 中,通过 http://10.2.69.47:8081/client/#anonymous 后台点击进入相应的客户端调试界面

问题是,我们「如何将 Weinre Script 自动注入到手机的 H5 页面中」?

HTTP Script 注入


想必用过中国电信宽带的同学都有过这样的体验:在刚开始浏览网页时,会自动跳出一些「宽带升级优惠」、「宽带缴费提醒」之类的页面。这种耍流氓的方式便是宽带运营商在 HTTP 代理层面的 Script 注入行为。前面已经提到 HTTP 协议是一种 REST 风格的架构,并且他的头部与主体报文为字符串文本流(对比二机制、十六进制数据流),在不使用 HTTPS 的情况下,很容易被中间路由或代理网关进行消息篡改。

通过 Fiddler Script 特性,我们可以自动对经过 Fiddler 的 HTTP 流量进行二次修改,注入任意内容(Mac 用户若已了解相关知识点,请直接跳至下方的 Charles 截图)。

打开 Fiddler 菜单「Rules -Customize Rules… 」,如果是首次开启会要求先下载安装 Fiddler ScriptEditor。打开 Fiddler ScriptEditor 之后,找到以下代码块(或使用菜单「Go -to OnBeforeResponse」):

1
2
3
4
5
static function OnBeforeResponse(oSession: Session) {
if (m_Hide304s && oSession.responseCode == 304) {
oSession["ui-hide"] = "true";
}
}

Fiddler Script 使用的编程语言是 JScript.NET(JavaScript 和 C# 的混合语法,类似 TypeScript),OnBeforeResponse 是 HTTP Response 响应前的事件函数,我们只需要在这里判断「如果开启了 Weinre Debug 功能,那么就在所有的 HTML 响应体中注入 Weinre Script」,以下是我修改的示例代码,覆盖以上代码块即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static RulesOption("Enable Weinre Script")
var m_EnableWeinreScript: boolean = true;

public static var g_weinreScriptString: String = '<script src="http://127.0.0.1:8080/target/target-script-min.js#anonymous"></script>';

public static ToolsAction("Config Weinre Script")
function ConfigWeinreScript(){
g_weinreScriptString = FiddlerObject.prompt("Text beblow will inject into HTML pages when 'Enable Weinre Script' rule is Enabled.", g_weinreScriptString , "Please Input the Weinre Script");
}

static function OnBeforeResponse(oSession: Session) {
if (m_Hide304s && oSession.responseCode == 304) {
oSession["ui-hide"] = "true";
}
if (m_EnableWeinreScript && oSession.oResponse.headers.ExistsAndContains("Content-Type","text/html")){
oSession.utilDecodeResponse();

if(oSession.utilFindInResponse("</html>", false)>-1){
oSession["ui-backcolor"] = "#5E30B5";
oSession["ui-color"] = "white";
oSession.utilReplaceRegexInResponse("<\/html>", g_weinreScriptString + '</html>');
}
}
}

修改保存后重启 Fiddler(或使用菜单「Tools -Reset Script」)以生效规则,接下来运行「Tools」菜单中新出现的「Config Weinre Script」,将 127.0.0.1:8080 替换成自己本机的局域网 IP 与 weinre 服务端口号,同时开启菜单「Rules -Enable Weinre Script」。至此,所有 HTML 页面将会被自动注入 Weinre Script,之后我们就可以在 weinre 后台中开始调试相关页面。以下是参考截图:

Fiddler 中的 HTTP Script 注入

可以看到 HTTP 响应体中已经被动态注入 Weinre Script。

在 Mac Charles 下的 Script 注入配置更加容易,只需利用其 「Rewrite」功能进行简单的配置即可,参看下图:

Charles Rewrite 配置 HTTP Script 注入

通过 Fiddler/Charles 代理工具将 JS 脚本注入成功后,我们便可以通过前文提到的 weinre 后台开始 debug 相应的页面,以下是在 iPhone 模拟器中调试新浪微博界面的截图:

Weinre 后台 debug Webview H5 页面

使用该方法可以调试 Android 和 iOS 中「任意 App 的 H5 页面」,但由于主要使用了 weinre 服务,其原理决定了该方法无法像真正的 Chrome DevTools 一样支持 JS 断点调试、Profiles 性能分析等功能,具有一定的局限性。在实际 Web App 开发过程中,推荐使用以下工具进行调试 :

由此可见,「HTTP PEM 调试法」是一个通用的 HTTP 接口调试方案,可以用来快速定位线上接口问题,对于开发人员来说掌握其背后的 HTTP 协议及其代理机制的原理更加重要,接下来我们聊聊常见的 HTTP 接口开发协作方法与 Mock 思路。

我的开发任务没法推进,因为某某的接口还没提供给我。

「HTTP PEM 调试法」之 Mock


希望新手程序员在看完这一章节之后,不要再向你的项目组和上级反馈这样的说法,因为 HTTP Mock(接口数据模拟)是一项网络编程的基础技能,从实际项目经验来看,大部分基于 HTTP 接口的任务都可以并行开发。

最简 HTTP API


不同岗位(例如前端开发与后台开发)或不同业务(例如订单系统与账户系统)的开发人员开始并行开发任务之前,首先要做的应该是对耦合和相互依赖的任务进行边界划分与规则约定。具体到某个 HTTP API 接口的约定上,至少应该明确以下信息:

  1. 是否按照 RESTful API 的约定来设计接口
  2. 接口的路径、提交方法、参数、编码类型(Enctype/Content-Type)
  3. 接口返回的错误码(code)、消息说明(message)、业务数据(data)

针对以上三条信息,我设想的「最简」 HTTP API 包含以下几条原则,供各位参考:

1、不使用 RESTful API 来设计接口


RESTful API 实际上是利用 HTTP 协议的语义(提交类型、返回码、Hypermedia Link)来将所有接口操作抽象化为一系列资源对象。这要求 API 的设计者与调用者都具备深厚的 HTTP 协议功底、语义化与抽象化能力。

  • RESTful 作为一个 Buzzword(流行词),其含义已经被曲解。HTTP 协议和 REST 架构的设计者 Roy Fielding 很反感这一点,还专门开了博客以正视听。大多数人只将 HTTP 当做一种传输协议来使用(既成事实),并不能真正理解 REST 架构风格;
  • RESTful API 将所有请求抽象化为资源名词(Resources)的做法争议很大。这种做法总会让我回想起上个世纪用 FrontPage 做网页的经历,「设置一个超链接,从某个资源跳到另外一个资源」。在经过 Web 2.0 浪潮,进入移动互联网时代后,这种 API 设计容易给人带来困惑。例如「登录、注册」这样的「动词」如何抽象成「名词」(还好有 Github API 可以参考 )。而刻意的使用 「HTTP CRUD」(POST/GET/PUT/DELETE Method)操作「资源化」之后的接口,并未带来更多实质上的收益;
  • HTTP 状态码的分层思路在 RESTful API 模式下被破坏了。HTTP 1.0 中定义的常见状态码已经足够网络中间组件(代理、网关、路由)使用,HTTP 1.1 中加入的很多状态码缺乏实际场景(例如 306 状态码的废弃),它们增加了中间组件以及浏览器对规范理解与实现的要求。尽可能的将状态码交给相应的接口逻辑层而非 HTTP 协议层,能够将问题简化;
  • 对比以英文为母语的国外开发者而言,国内开发者对语义化的认知难度更高,例如 RESTful 建议资源命名用复数形式,那收货地址单词 address 的复数形式是什么?address or addresses ?address-list or address-lists?(没过英语八级的同学已经哭晕在厕所 T_T)
  • 每个人对 RESTful API 的理解都不同,在 HTTP 协议层面做扩展与实现,不如交给接口设计者与调用者自己来约定数据结构(或者参考 JSON-RPC 规范)。把 HTTP 只当做传输协议来使用的好处是,当后端服务间的接口需要直接基于 TCP 传输层来做性能优化时,可以十分方便的切换成 Socket 的实现(之前在腾讯做微博相关项目时,微博开放平台对外只提供 HTTP 的 Open API,但对内可以提供更高频率与频次调用的原生 Socket 协议)。

2、只使用 GET/POST Method


由于 HTTP 1.0 尤其是 HTML 的规范与应用已经深入人心。大部分开发者能够很自然的这样理解:「GET」 表示「读」操作,「POST」 表示「写」操作。这样既可以保证中间组件与浏览器很好的利用 GET 的缓存机制,又能降低接口设计的复杂度。HTTP 之父 Roy Fielding 也说过「It is okay to use POST」:

Some people think that REST suggests not to use POST for updates. Search my dissertation and you won’t find any mention of CRUD or POST. (很多人认为 RESTful 建议不要使用 POST 用于提交更新,去翻一翻我的论文,压根就没提到过 POST 和其他「增查改删」方面的内容。)

但使用 POST 方法时尤其要注意:「使用统一的 Content-Type」。这是一个容易被新手忽略的细节,也是接口设计中经常出错的点。

一个 POST 请求的 Content-Type 有多少种,传输的数据格式有何区别?

以下举例一些常见类型的 HTTP POST Request 报文,请注意其中的 Content-Type 与 Body 的对应关系(已手动删除无关 HTTP Header)

1
2
3
4
5
6
POST /test.php HTTP/1.1
Host: 127.0.0.1:8080
Content-Length: 54
Content-Type: application/json

{"weixin_id":"imgXQB","weixin_name":"学前班"}
1
2
3
4
5
6
POST /test.php HTTP/1.1
Host: 127.0.0.1:8080
Content-Length: 74
Content-Type: application/x-www-form-urlencoded

weixin_id=imgXQB&weixin_name=%E7%8C%AB%E5%93%A5%E5%AD%A6%E5%89%8D%E7%8F%AD
1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /test.php HTTP/1.1
Host: 127.0.0.1:8080
Content-Length: 259
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryl60ti7CVoBj2kxfX

------WebKitFormBoundaryl60ti7CVoBj2kxfX
Content-Disposition: form-data; name="weixin_id"

imgXQB
------WebKitFormBoundaryl60ti7CVoBj2kxfX
Content-Disposition: form-data; name="weixin_name"

学前班
------WebKitFormBoundaryl60ti7CVoBj2kxfX--

只有客户端 POST 请求体的消息格式与其请求头声明的 Content-Type 一致时,服务端才能正确的接收与响应。因为许多后端的 Web 应用框架会遵照 HTTP 协议的内容协商原则(Content Negotiation)对响应体进行预处理,以提升开发体验。例如,Python 的 Flask 框架 封装了 request.jsonrequest.formrequest.data 等一系列属性用于存放不同类型的来源数据。

3、接口 URI 与参数命名风格的一致性


  • API URI 应该全小写。屏蔽掉 Linux/Windows 操作系统对文件名大小写敏感度不一致的问题;
  • URI 命名上应该使用连字符「-」来间隔,而不是使用下划线「_」或驼峰式。这是出于视觉美观度和英文语义方面的考虑,英文域名规范规定可以使用连字符,但不能使用下划线,API 路径应该和 Domain 命名风格一致;
  • URI 使用「动词+名词」或者「名词+动词」均可,但选定一种之后应该保持一致。接口风格的一致性,可以降低使用者的理解成本,好的 API 命名风格能让人「以一知万」,能从一个 API 猜测出所有其他 API 的命名形式;
  • 参数命名上应该使用下划线「_」而非连接符「-」。这点主要是从数据库字段设计的统一性和后台应用程序框架的易用性来考虑;
  • 不同接口的相同参数命名应保持统一,并考虑扩展要求。例如,收集用户信息的参数可以统一叫「ua」,为了便于扩展可以约定将客户端分辨率、浏览器型号等信息使用「||」字符串连接,如 ua=1280x768||chrome,当需要添加操作系统字段时,客户端只需按规则追加信息到原来的参数上,如 ua=1280x768||chrome||windows。该条原则还有许多其他的方法来实现,不再一一举例。

4、返回数据结构的一致性


基本的返回体结构,可参考以下示例代码。

1
2
3
4
5
6
7
8
{
"code": "0",
"message": "success",
"data": {
"id" : "1",
"list" : []
}
}

寥寥的几行代码饱含了几部深刻的血泪史:

  • 出于一致性的考虑,code 表示返回码(也可以理解成错误码),成功时返回 "0" ,出错时按预设的错误码规则返回(微信的返回码规范设计的并不好,因为没有内建的规律和语义);
  • 同上,可以理解 messagedata 的设计。需要注意的是 data 只具有 Object 一种类型。无数据的时候返回一个空对象 {}(而非 null),有多条数据的时候将 Array 类型数据放在其内部的 list 之类的属性中;
  • 所有原始数据类型建议统一使用字符串类型,包括布尔值用 "0""1"。原因是前后端对浮点数运算精度不一致,会导致商品价格的计算与展示出错;iOS/Android 客户端对 JSON null、布尔类型转换的不一致会导致频繁的 App Crash。

当然,也有许多其他的方案可以解决上面提到的问题,但出于「最简」的原则,这样约定的理解成本最低。

最简 Mock Server


有了最简 API 的约定之后,实现最简 Mock Server 就相对简单多了。

1、编写返回的模拟数据


首先,我们按照 API 接口约定来新建一些模拟数据文件。例如新建一个 「mock-data.json」 的文件,将以上返回体数据保存其中。

2、运行 php 内置服务器


在命令行模式下运行 php 命令,Mac 用户直接打开终端即可,Windows 用户需要先安装 XAMPP 套件,并将 php.exe 所在的目录配置到系统环境变量中,再使用 CMD 运行以下命令:

1
php -S 0.0.0.0:8080 mock-data.json

开启之后访问任意 API 地址(http://127.0.0.1:8080/any-api-uri-you-want/)均会返回 mock-data.json 的数据响应体。通过将 8080 端口换成 80 端口(Mac 需要使用 sudo 权限),再设置类似 127.0.0.1 www.example.com 的 HOST 配置,便可以模拟 API 的 Domain Host(http://www.example.com/any-api-uri-you-want/)形式。

当然,也可以自己编写一个 index.php 的入口文件来实现一个基于 URL Path 规则的简单 Rewrite 功能,用来同时支持多个 API 的数据模拟。

使用 Fiddler/Charles 的 Map Local 功能


Fiddler/Charles 的 Map Local(本地映射)不光是用于 HTTP Edit,同样可以用于 HTTP Mock,当一个 404 请求(还未真正实现的 API)被代理服务器捕获后,可以设置映射到本地自定义的 mock-data.json 模拟数据文件,从而被模拟成一个正常的 200 请求。

自动化 Mock System 构想


迄今为止,我还未发现一个理想中的 Mock API 开源系统,如有哪位同学有见到过请在 Github 上留言周知,以下是我对最理想 Mock System 的构想:

  1. API 录入后台。包含一个按项目(一般是 Domain)维度进行 API 管理的后台。可以在后台上录入「请求 URI、参数、多种业务数据响应体、全局错误码、API 错误码」等接口信息;
  2. API 接口文档。能够基于 API 后台数据,生成在线的 API 文档平台;
  3. Postman 导入/导出。能够基于 API 数据导出生成 Postman Collections,以便导入 Postman 中进行 API 调试;
  4. Mock Server。能够基于 API 数据快速搭建类似 MockServer 的本地服务,或提供远程模拟接口服务。

「HTTP PEM」系统分析利器


这个接口很复杂,内部调用了好几个其他接口,如何定位问题究竟出在哪一步?

对于新人来说,最快的成长方式是不断地在新项目中实践,从头到尾参与到项目的每个系统细节的设计与讨论。如果能参与到重点、大型项目中,甚至幸运地得到大牛的亲自指导,成长速度将会突飞猛进。

但更多的情况是,新人作为离职程序员的补充力量来接手一个老项目甚至是烂摊子。面对一个复杂的陌生系统,吐槽与抱怨无济于事。这时,如果能使用「HTTP PEM 调试法」,从接口设计与调用的角度来剖析、理解整个系统的设计,就能快速上手业务。例如,PHP 程序员可以在项目代码中所有的 curl 调用点,将「CURLOPT_PROXY」设置成 Fiddler/Charles 的代理服务,然后一步步调试,从接口字段上理解数据库设计和 Controller 背后的业务逻辑。

最后,欢迎各位给我留言分享更多关于「HTTP PEM」和其他调试方法的经验与体会。


Reference:

GitHub Repo:Tony Studio

Follow: CoderTonyCHan · GitHub