❝Http相关的文章网上一搜一大把,所以笔者这一系列的文章不会只陈述一些概念,更多的是通过实战(抓包+代码实现)的方式来跟大家讨论Http协议中的各种细节,帮助大家理解那些反反复复记不住的的概念! ❞
我们选用netty搭建一个服务端,使用httpclient来实现http客户端。
❝对netty或者httpclient不熟悉的同学不用担心,涉及到的代码都非常简单。 服务端我之所以选用这两个框架是因为相对来说,它们对http协议的封装较浅,在后面的文章中我可以带大家看看代码层次上http协议是如何封装的,这样可以将http协议理解的更加透彻,在本文中大家将注意力放到抓包的分析过程即可! ❞
代码如下:
pom文件引入依赖:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.65.Final</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
服务端代码:
public class HttpServer {
public static void main(String[] args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.option(ChannelOption.SO_BACKLOG, 1024);
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new HttpHelloWorldServerInitializer());
Channel ch = b.bind(8080).sync().channel();
ch.closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
public class HttpHelloWorldServerInitializer extends ChannelInitializer<SocketChannel> {
@Override
public void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
// 我们搭建的是一个http服务器,要使用http编解码器
p.addLast(new HttpServerCodec());
// http请求处理核心类
p.addLast(new HttpHelloWorldServerHandler());
}
}
/**
* 这个类是处理http请求的核心类,这里我们简单处理
* 不论收到什么信息我们都返回Hello World
*/
public class HttpHelloWorldServerHandler extends SimpleChannelInboundHandler<HttpObject> {
private static final byte[] CONTENT = { 'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd' };
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.flush();
}
@Override
public void channelRead0(ChannelHandlerContext ctx, HttpObject msg) {
if (msg instanceof HttpRequest) {
HttpRequest req = (HttpRequest) msg;
FullHttpResponse response = new DefaultFullHttpResponse(req.protocolVersion(), OK,Unpooled.wrappedBuffer(CONTENT));
response.headers()
.set(CONTENT_TYPE, TEXT_PLAIN)
.setInt(CONTENT_LENGTH, response.content().readableBytes());
ctx.write(response);
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
客户端代码如下:
public class HttpClient {
public static void main(String[] args) throws Exception {
final CloseableHttpClient httpClient =
HttpClientBuilder.create()
.setConnectionManager(new BasicHttpClientConnectionManager())
.build();
final HttpGet httpGet = new HttpGet("http://127.0.0.1:8080");
final CloseableHttpResponse execute = httpClient.execute(httpGet);
System.out.println(EntityUtils.toString(execute.getEntity()));
// 这里代码并不规范哈,正常应该try catch finally,不过这不是本文重点
execute.close();
}
}
我们将服务端启动后,运行客户端正常输出“Hello World”说明项目搭建成功
Wireshark简单介绍
Wireshark(前身 Ethereal)是一个网络包分析工具。该工具主要是用来捕获网络数据包,并自动解析数据包,为用户显示数据包的详细信息,供用户对数据包进行分析。
❝下载链接:https://www.wireshark.org/download.html ❞
下载成功后,我们打开主界面如下:
WireShark
这里我们看到的这个列表是我们本机的网卡列表,我们在抓包之前要确认具体的网卡,常用的网卡就是我在图中框选的两个
lo0
:回环网卡,对应localhost
/127.0.0.1
等本地服务 举个例子,如果我要请求 http://localhost:12345/xxx
这个服务,对应的网卡就选择lo0
。127.0.0.1
的服务同理。en0
:外网请求,比如,我要在浏览器访问 www.baidu.com/ 这个服务,对应的网卡一般选en0
。那么如何确定我们要抓取的数据包对应的网卡是哪个呢?
现在要确定当我们访问www.baidu.com
使用的是哪个网卡,我们可以执行如下操作:
www.baidu.com
数据:sudo tcpdump host www.baidu.com -xnt -v -A | grep -C3 www.baidu.com
curl www.baidu.com
。从这里可以看到我们访问百度时使用的ip地址(图中马塞克部分)
之后,通过执行ifconfig
命令,就能查询到这个ip对应的网卡
在我本机对应的就是en0这张网卡。确认了具体网卡后,我们在主界面选定此网卡,双击即可,此时可能会出现如下报错:
这是因为网卡权限问题,我们只需要在终端中输入如下命令即可:sudo chmod 777 /dev/bpf*
。执行完后记得要重启WireShark。
重启完成后,选定网卡双击会进入如下界面:
一进这个界面可能会有点懵,因为我们没有输入任何过滤表达式,所以此时整个界面上展示的是en0这个网卡上所有的数据包。关于WireShark的表达式不是本文重点,笔者也不打算过多介绍,本文用到的表达式都非常简单,每个表达式我会做简单解释。
此时我们想要抓取访问百度时的数据包,我们只需要输入如下表达式:
http and ip.addr==112.80.248.76
表达式中的第一个http代表,我们要抓取的是http协议相关的数据包,同理,你可以输入tcp,icmp等协议名称过滤出对应协议相关的数据包。这个表达式的意思是,我要抓取http协议的数据包,同时通信的某一方的ip地址为112.80.248.76
,这个ip地址可以通过ping www.baidu.com
得到。
根据上述表达式我们可以抓到如下数据包:
我们选中对应的报文,右键跟踪http流,即可得到具体的http报文信息
那么接下来我们正式开始抓包实验,确保你的测试项目及Wireshark都是ok的哦~
http and tcp.port==8080
,代表我们要抓取8080端口上所有http协议的包(因为是抓取回环网卡网卡上的数据包,所以我们可以不指定IP)接下来我们启动sever端,然后运行client端发起一个http请求,在WireShark上可以抓取到如下数据:
按照前文所述,我们追踪下这个http流可以看到如下数据:
HTTP 协议的请求报文和响应报文的结构基本相同,由三大部分组成:
「起始行」(start line):描述请求或响应的基本信息,在请求中我们称之为请求行,响应中我们称之为状态行;
「头部字段集合」(header):使用 key-value 形式更详细地说明报文,在请求中我们称之为请求头,响应中我们称之为响应头。
「消息正文」(entity):实际传输的数据,它不一定是纯文本,可以是图片、视频等二进制数据,也称之为请求体或响应体
HTTP 协议规定报文必须有 header,但可以没有 body,而且在 header 之后必须要有一个“空行”,也就是“CRLF”,十六进制的“0D0A”。
将抓包得到的报文用上述结构描述即如下图所示:
如下图所示,请求行中主要包含三部分信息
三部分之间使用空格进行分隔
请求方法 | 描述信息 | 补充 |
---|---|---|
GET | 请求从服务器获取资源 | 这个资源既可以是静态的文本、页面、图片、视频,也可以是由 PHP、Java 动态生成的页面或者其他格式的数据 |
POST | 向服务器提交数据(例如提交表单或者上传文件),数据包含在请求体中 | POST 表示的是“新建”“create”的含义 |
PUT | PUT 的作用与 POST 类似,数据也包含在请求体中 | 通常 POST 表示的是“新建”“create”的含义,而 PUT 则是“修改”“update”的含义。 |
DELETE | 指示服务器删除资源 | 在RESTful架构使用较多下使用较多 |
HEAD | 类似于 GET 请求,只不过返回的响应中没有具体的内容,用于获取报头 | HEAD 方法可以看做是 GET 方法的一个“简化版”或者“轻量版”。因为它的响应头与 GET 完全相同,所以可以用在很多并不真正需要资源的场合,避免传输 body 数据的浪费。 |
OPTIONS | 方法要求服务器列出可对资源实行的操作方法,在响应头的 Allow 字段里返回。 | 它的功能很有限,用处也不大,有的服务器(例如 Nginx)干脆就没有实现对它的支持。 |
TRACE | 用于对 HTTP 链路的测试或诊断,可以显示出请求 - 响应的传输路径。 | 它的本意是好的,但存在漏洞,会泄漏网站的信息,所以 Web 服务器通常也是禁止使用。 |
CONNECT | 要求使用隧道协议连接代理 | 关于隧道大家可查看:https://www.zhihu.com/question/21955083,本文不再赘述 |
❝RESTful架构下,会使用四个表示操作方式的动词:GET、POST、PUT、DELETE。它们分别对应四种基本操作:「GET用来获取资源,POST用来新建资源(也可以用于更新资源),PUT用来更新资源,DELETE用来删除资源。」 ❞
如下图所示,请求行中主要包含三部分信息
三部分之间使用空格进行分隔
RFC 标准把状态码分成了五类,用数字的第一位表示分类,这样状态码的实际可用范围就大大缩小了,由 000~999 变成了 100~599。
这五类的具体含义是:
1××:提示信息,表示目前是协议处理的中间状态,还需要后续的操作;
2××:成功,报文已经收到并被正确处理;
3××:重定向,资源位置发生变动,需要客户端重新发送请求;
4××:客户端错误,请求报文有误,服务器无法处理;
5××:服务器错误,服务器在处理请求时内部发生了错误。
❝实际上需要注意的是HTTP本身是一个协议,需要通信的双方共同遵守,但这并不是必须的。目前 RFC 标准里总共有 41 个状态码,但状态码的定义是开放的,允许自行扩展。所以 Apache、Nginx 等 Web 服务器都定义了一些专有的状态码。如果你自己开发 Web 应用,也完全可以在不冲突的前提下定义新的状态码。 ❞
1xx
类状态码属于「提示信息」,是协议处理中的一种中间状态。例如在需要进行协议升级时,服务器会响应101。如下图所示,使用websocket时,会进行一次协议升级:
2xx
类状态码表示服务器「成功」处理了客户端的请求,也是我们最愿意看到的状态。
HEAD
请求,服务器返回的响应头都会有 body 数据。3xx
类状态码表示客户端请求的资源发生了变动,需要客户端用新的 URL 重新发送请求获取资源,也就是「重定向」。
301 和 302 都会在响应头里使用字段 Location
,指明后续要跳转的 URL,浏览器会自动重定向新的 URL。
4××类状态码表示客户端发送的请求报文有误,服务器无法处理,它就是真正的“错误码”含义了。
「「400 Bad Request」」是一个通用的错误码,表示请求报文有错误,但具体是数据格式错误、缺少请求头还是 URI 超长它没有明确说,只是一个笼统的错误,客户端看到 400 只会是“一头雾水”“不知所措”。所以,在开发 Web 应用时应当尽量避免给客户端返回 400,而是要用其他更有明确含义的状态码。
「「403 Forbidden」」实际上不是客户端的请求出错,而是表示服务器禁止访问资源。原因可能多种多样,例如信息敏感、法律禁止等,如果服务器友好一点,可以在 body 里详细说明拒绝请求的原因,不过现实中通常都是直接给一个“闭门羹”。
「「404 Not Found」」可能是我们最常看见也是最不愿意看到的一个状态码,它的原意是资源在本服务器上未找到,所以无法提供给客户端。但现在已经被“用滥了”,只要服务器“不高兴”就可以给出个 404,而我们也无从得知后面到底是真的未找到,还是有什么别的原因,某种程度上它比 403 还要令人讨厌。
4××里剩下的一些代码较明确地说明了错误的原因,都很好理解,开发中常用的有:
5xx
类状态码表示客户端请求报文正确,但是「服务器处理时内部发生了错误」,属于服务器端的错误码。
ttp header 消息通常被分为4个部分:general header, request header, response header, entity header。但是这种分法就理解而言,感觉界限不太明确。根据维基百科对http header内容的组织形式,大体分为Request(请求头)和Response(响应头)两部分。
Header | 解释 | 示例 |
---|---|---|
Accept | 指定客户端能够接收的内容类型 | Accept: text/plain, text/html |
Accept-Charset | 客户端可以接受的字符编码集。 | Accept-Charset: iso-8859-5 |
Accept-Encoding | 指定客户端可以支持的web服务器返回内容压缩编码类型。 | Accept-Encoding: compress, gzip |
Accept-Language | 客户端可接受的语言 | Accept-Language: en,zh |
Accept-Ranges | 可以请求网页实体的一个或者多个子范围字段 | Accept-Ranges: bytes |
Authorization | HTTP授权的授权证书 | Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== |
Cache-Control | 指定请求和响应遵循的缓存机制 | Cache-Control: no-cache |
Connection | 表示是否需要持久连接。(HTTP 1.1默认进行持久连接) | Connection: close |
Cookie | HTTP请求发送时,会把保存在该请求域名下的所有cookie值一起发送给web服务器。 | Cookie: $Version=1; Skin=new; |
Content-Length | 请求的内容长度 | Content-Length: 348 |
Content-Type | 请求的与实体对应的MIME信息 | Content-Type: application/x-www-form-urlencoded |
Date | 请求发送的日期和时间 | Date: Tue, 15 Nov 2010 08:12:31 GMT |
Expect | 请求的特定的服务器行为 | Expect: 100-continue |
From | 发出请求的用户的Email | From: user@email.com |
Host | 指定请求的服务器的域名和端口号 | Host: www.zcmhi.com |
If-Match | 只有请求内容与实体相匹配才有效 | If-Match: “737060cd8c284d8af7ad3082f209582d” |
If-Modified-Since | 如果请求的部分在指定时间之后被修改则请求成功,未被修改则返回304代码 | If-Modified-Since: Sat, 29 Oct 2010 19:43:31 GMT |
If-None-Match | 如果内容未改变返回304代码,参数为服务器先前发送的Etag,与服务器回应的Etag比较判断是否改变 | If-None-Match: “737060cd8c284d8af7ad3082f209582d” |
If-Range | 如果实体未改变,服务器发送客户端丢失的部分,否则发送整个实体。参数也为Etag | If-Range: “737060cd8c284d8af7ad3082f209582d” |
If-Unmodified-Since | 只在实体在指定时间之后未被修改才请求成功 | If-Unmodified-Since: Sat, 29 Oct 2010 19:43:31 GMT |
Max-Forwards | 限制信息通过代理和网关传送的时间 | Max-Forwards: 10 |
Pragma | 用来包含实现特定的指令 | Pragma: no-cache |
Proxy-Authorization | 连接到代理的授权证书 | Proxy-Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== |
Range | 只请求实体的一部分,指定范围 | Range: bytes=500-999 |
Referer | 先前网页的地址,当前请求网页紧随其后,即来路 | Referer: http://www.zcmhi.com/archives/71.html |
TE | 客户端愿意接受的传输编码,并通知服务器接受接受尾加头信息 | TE: trailers,deflate;q=0.5 |
Upgrade | 向服务器指定某种传输协议以便服务器进行转换(如果支持) | Upgrade: HTTP/2.0, SHTTP/1.3, IRC/6.9, RTA/x11 |
User-Agent | User-Agent的内容包含发出请求的用户信息 | User-Agent: Mozilla/5.0 (Linux; X11) |
Via | 通知中间网关或代理服务器地址,通信协议 | Via: 1.0 fred, 1.1 nowhere.com (Apache/1.1) |
Warning | 关于消息实体的警告信息 | Warn: 199 Miscellaneous warning |
Header | 解释 | 示例 |
---|---|---|
Accept-Ranges | 表明服务器是否支持指定范围请求及哪种类型的分段请求 | Accept-Ranges: bytes |
Age | 从原始服务器到代理缓存形成的估算时间(以秒计,非负) | Age: 12 |
Allow | 对某网络资源的有效的请求行为,不允许则返回405 | Allow: GET, HEAD |
Cache-Control | 告诉所有的缓存机制是否可以缓存及哪种类型 | Cache-Control: no-cache |
Content-Encoding | web服务器支持的返回内容压缩编码类型。 | Content-Encoding: gzip |
Content-Language | 响应体的语言 | Content-Language: en,zh |
Content-Length | 响应体的长度 | Content-Length: 348 |
Content-Location | 请求资源可替代的备用的另一地址 | Content-Location: /index.htm |
Content-MD5 | 返回资源的MD5校验值 | Content-MD5: Q2hlY2sgSW50ZWdyaXR5IQ== |
Content-Range | 在整个返回体中本部分的字节位置 | Content-Range: bytes 21010-47021/47022 |
Content-Type | 返回内容的MIME类型 | Content-Type: text/html; charset=utf-8 |
Date | 原始服务器消息发出的时间 | Date: Tue, 15 Nov 2010 08:12:31 GMT |
ETag | 请求变量的实体标签的当前值 | ETag: “737060cd8c284d8af7ad3082f209582d” |
Expires | 响应过期的日期和时间 | Expires: Thu, 01 Dec 2010 16:00:00 GMT |
Last-Modified | 请求资源的最后修改时间 | Last-Modified: Tue, 15 Nov 2010 12:45:26 GMT |
Location | 用来重定向接收方到非请求URL的位置来完成请求或标识新的资源 | Location: http://www.zcmhi.com/archives/94.html |
Pragma | 包括实现特定的指令,它可应用到响应链上的任何接收方 | Pragma: no-cache |
Proxy-Authenticate | 它指出认证方案和可应用到代理的该URL上的参数 | Proxy-Authenticate: Basic |
refresh | 应用于重定向或一个新的资源被创造,在5秒之后重定向(由网景提出,被大部分浏览器支持) | Refresh: 5; url=http://www.zcmhi.com/archives/94.html |
Retry-After | 如果实体暂时不可取,通知客户端在指定时间之后再次尝试 | Retry-After: 120 |
Server | web服务器软件名称 | Server: Apache/1.3.27 (Unix) (Red-Hat/Linux) |
Set-Cookie | 设置Http Cookie | Set-Cookie: UserID=JohnDoe; Max-Age=3600; Version=1 |
Trailer | 指出头域在分块传输编码的尾部存在 | Trailer: Max-Forwards |
Transfer-Encoding | 文件传输编码 | Transfer-Encoding:chunked |
Vary | 告诉下游代理是使用缓存响应还是从原始服务器请求 | Vary: * |
Via | 告知代理客户端响应是通过哪里发送的 | Via: 1.0 fred, 1.1 nowhere.com (Apache/1.1) |
Warning | 警告实体可能存在的问题 | Warning: 199 Miscellaneous warning |
WWW-Authenticate | 表明客户端请求实体应该使用的授权方案 | WWW-Authenticate: Basic |
通过这篇文章我们搭建了测试项目,对wireShark有了一定了解,也知道了http协议的整体结构。仅仅是这样我们很难对http有一个直观深入的了解,所以下篇文章我会跟大家一起探讨目前的主流框架是如何实现http协议的,例如:http的长连接在代码层次是怎么实现?服务器跟客户端做了什么去实现长连接呢?
下篇文章将从代码实现的角度来分析http协议,跟着我卷起来~
参考: