web & http
http 概述
Web 的应用层协议是 HTTP(超文本传输协议), HTTP 由客户端程序和服务端程序实现, 两个程序运行在不同的端系统中, 通过交换 HTTP 报文进行会话. HTTP 协议定义了这些报文的结构以及客户端和服务端进行报文交换的方式.
Web Page(也叫文档, 开发者工具里的网络可以在文档里找到)由 对象 组成.
对象 是一个文件, 比如 html、jpg 等, 可以通过一个 URL 寻址, 大部分 Web Page 含有一个 HTML 基本文件以及几个引用对象.
URL 由两部分组成: 主机名和路径名. 如https://www.w4ter.com/favicon.ico
, https://www.w4ter.com
就是主机名, /favicon.ico
就是路径名.
通常情况下, 由 Web 浏览器实现 HTTP 的客户端.
HTTP 使用 TCP 作为它的支撑协议. HTTP 客户首先发起一个与服务器的 TCP 连接. 连接建立后该浏览器和服务器进程通过 socket 访问 TCP 进行报文交换.
HTTP 是 无状态 的, 服务器不会存储某一客户的状态信息, 即使某客户短时间连续请求同一个对象, 服务器不会因为刚提供对象而拒绝服务, 而是再次发送对象.
HTTP 的发展
HTTP/0.9
在最初的 HTTP 没有版本号, 后来它的版本号被定为在 0.9 来区分后来的版本. HTTP/0.9 非常的简单, 请求只有一条指令, 唯一的方法是 GET, 后面跟着目标资源的路径(此时 TCP 连接已经确定)
GET /index.html
响应也非常简单, 直接返回 index.html 本身即可
HTTP/1.0
跟 HTTP/0.9 相比, 现在有版本号了. 每次请求都需要在后面写上协议版本信息
GET /index.html HTTP/1.0
同时, 引入了标头的概念(Header), 传输的数据类型并不局限于 html, 并且响应有了状态码, 可以告诉客户端是否成功等信息.
请求
GET /wow.gif HTTP/1.0
User-Agent Mozilla/5.0 (Windows NT 10.0; Win64; x64)
响应
200 OK
Date: xxx
Server: xxx
Content-Type: text/gif
图片内容
非持续连接及存在的问题
这个版本的 HTTP 使用的是 非持续连接 , 每一次 HTTP 请求/响应都是一个单独的 TCP 连接.
在非持续连接中, 假设浏览器请求https://www.w4ter.com/index.html
, 那么大概流程是这样子:
建立 TCP 连接
浏览器发送请求报文
服务器接收请求报文, 并响应
服务器通知 TCP 断开连接. (实际是在客户端收到响应报文后, TCP 才中断连接)
浏览器解析获取的 HTML 内容, 如果有引用(图片、js、css 等), 重复 1~4
每一次请求都需要建立一次 TCP 连接, 这样就产生了资源的浪费, 也会让客户获取完整资源的时间延长.
HTTP/1.1
该版本引入了多项改进, 现在也很多在使用 HTTP/1.1 的网站.
Host 标头
该版本引入了一个Host
标头, 这使得同一 ip 地址的服务器可以配置多个不同的域名.
我这里是 windows 的 powershell, 以百度为例:
ping baidu.com
这样可以拿到 baidu.com 解析出来的 ip39.156.66.10
先简单给 baidu.com 发个 GET 请求
curl --request GET --url http://baidu.com/
如果网络正常应该可以看到这个
<html>
<meta http-equiv="refresh" content="0;url=http://www.baidu.com/" />
</html>
现在我们换成 ip 试试
curl --request GET --url http://39.156.66.10/
可以看到报错了
curl: (56) Recv failure: Connection was reset
这是因为我们没有加上 Host 标头的原因. 如果有使用 nginx 的经验的话, 应该知道大概是怎么配置的.
server {
listen 80;
server_name example.com;
location / {
alias /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}
在大部分配置中我们都是监听的 80 端口, 同时各个配置基本只有 server_name 不同. 那 nginx 要怎么知道对于一个 http 请求, 应该怎么处理呢?这个时候就需要查看 Host 标头了. nginx 会查看 Host 标头, 并查询 server_name, 转发给对应的请求.
在之前对 ip 的请求, 写成 http 报文
GET / HTTP/1.1
这里没有指定 Host, 所以百度的服务器不知道应该把这个请求转发到哪里, 于是给了我们一个报错. 现在加上 Host 试试
curl --request GET --url http://39.156.66.10/ --header 'host: baidu.com'
得到
<html>
<meta http-equiv="refresh" content="0;url=http://www.baidu.com/">
</html>
这下正常了.
持续连接及存在的问题
HTTP/1.1 采用的是持续连接(在请求头中有一个 Connection 字段, 如果是 keep-alive 表示该连接是持续连接). 相同的客户端和服务端只建立一个(或几个)TCP 连接, 避免多次 TCP 握手.
HTTP/1.1 的持久连接实现了一次连接 串行 处理多个请求. 于是也产生了新的问题: 队头阻塞.
由于连接是串行处理请求, 如果前面的请求未完成就会阻塞后面的请求. 在遇到大文件下载情况下就阻塞整个连接. 浏览器对此的策略是建立多个并行连接来绕过对头阻塞, 不过这样会造成服务器的负担.
此外, 其实还有一个管道化的解决方法, 不过很少被使用. 它允许一次发送多个请求, 并且不需要等待前面的请求完成再发送下一个请求. 但是这里存在一个问题, 请求确实可以不等待之前的请求完成就发出去, 但是要求顺序一致. 也就是说先发的请求需要先得到响应.
假设客户端同时发送 A, B, C 三个请求给服务器, 那么服务器就应该依次响应 A, B, C. 如果 A 或者 B 是一个耗时的操作呢?这样即使后面的操作很快也会造成阻塞. 而且这加大了开发的难度, 如果服务器想并行处理请求就必须跟踪请求顺序, 也增加了资源的消耗.
在《计算机网络: 自顶向下方法(原书第 8 版)》中有写, HTTP 的默认模式是使用带流水线的持续连接, 也就是使用管道化, 但实际上是默认使用持续连接, 不需要在头部写 Connect: keep-alive. 大部分浏览器都默认禁用了管道化.
HTTP/2
HTTP/2 采用的同样是持续连接, 但不是串行处理请求, 而是并行处理请求.
HTTP/2 使用的是二进制协议而不是文本协议, 并使用了头部压缩来减少重复头传输.
在 HTTP/2 中, 原本的报文被拆分成帧, 帧可以分成首部帧和数据帧, 可以看作是把原本 HTTP 报文的首部和实体给拆成两部分. 此外, 这个帧还有一个StreamId
, 来标识这个帧属于哪一个数据流. 这样浏览器就不需要让帧按照某个顺序到达服务器, 再按照某个顺序来接收帧.
看上去对头阻塞的问题解决了, 但是 HTTP 是在应用层上的, 即使应用层上看上去是并行
处理, 实际在传输层还是 TCP 一个一个发过去. 那么假如其中一个数据包丢失了, 就要进行重传. 在重传完成前, 所有的数据都不会交付给应用层 HTTP/2. 这个就是 TCP 层队头阻塞 .
HTTP/3
在 HTTP/2 中解决了 HTTP/1.1 的应用层上的队头阻塞, 但是还有一个传输层的队头阻塞. 直接修改 TCP 层是不现实的, 传输层由操作系统实现, 不太可能让用户自己换设备. 于是 HTTP/3 选择使用 UDP 协议.
HTTP 的报文格式
HTTP 请求报文
一个 HTTP 请求报文大概是长这样的
GET / HTTP/1.1
user-agent: xxx
host: www.w4ter.com
accept-encoding: gzip, deflate
Connection: close
报文的第一行是请求行(request line), 后面的叫首部行(header line).
请求行有三个字段, 第一个是方法字段, 第二个是 URL 字段, 第三个是 HTTP 版本字段.
请求行和首部行最后面都跟着一个回车符和换行符, 这里的报文实际上是这样的
GET / HTTP/1.1\r\n
user-agent: xxx\r\n
host: www.w4ter.com\r\n
accept-encoding: gzip, deflate\r\n
Connection: close\r\n
这里为了好看我换行了, 实际上把\r\n 写出来是不该换行的.
在前后端开发中, 我们还会用到 body, 这个叫实体体(entity body), 在 header 之后. 一个完整的 POST 请求大概是这样的
POST / HTTP/1.1\r\n
host: www.w4ter.com\r\n
content-type: application/json\r\n
accept-encoding: gzip, deflate\r\n
content-length: 23\r\n
Connection: close\r\n
\r\n
{
"test":"test"
}
服务端遇到一个空行就知道后面是 body 内容了.
HTTP 响应报文
一个响应报文大概是这样的
HTTP/1.1 200 OK\r\n
Connection: close\r\n
Content-Type: application/json\r\n
\r\n
内容
第一行叫初始状态行, 然后是首部行, 再然后是实体体
HTTP 的 cookie
为什么需要 cookie
HTTP 本身是无状态的, 但有时候我们需要验证用户的信息或识别用户身份, 这时候我们可以使用 cookie.
在服务器需要设置 cookie 时, 会在响应头里加上 Set-cookie 字段, 浏览器识别到这个首部行之后, 会在它管理的 cookie 文件里添加一行, 保存服务器的主机名和 cookie. 之后用户访问该网站时, 浏览器就会查询 cookie 并放在请求报文中的 cookie 首部行中, 服务器就可以根据这个 cookie 进行身份验证了.
cookie 存在的问题
在前后端开发中, 有 cookie 确实能省很多事情, 比如我们不希望某些图片资源被所有用户获取, 只允许特定用户获取, 这时候 cookie 就可以发挥作用了.
浏览器发起请求时, 会查询 cookie 文件是否有对应的 cookie, 如果有, 那么就带上. 这是自动的行为, 并不需要程序员自行控制, 前端程序员可以放心地写. 但是这也引发一些问题.
资源浪费
并不是所有的请求都需要 cookie 的, 但是所有的请求只要有 cookie 就会带上. 如果一个网站有着大量的图片、css、js 等文件, 那么每个 HTTP 请求都会带上 cookie, 这是对带宽的一种浪费.
不安全
CSRF
cookie 一般是明文保存, 有心人很容易篡改. 当然, 也可以加密保存, 后端再解密就可以了. 不过这只是不安全的一点.
就如之前所说, 浏览器发起请求时会查询是否有对应的 cookie, 如果有就带上. 那么假如我在www.a.com向www.b.com发送请求, 并且用户恰好有www.b.com的cookie呢?
在比较早的时期, 无论是在哪里发送的请求, 只要浏览器找到了对应的 cookie, 请求就会带上 cookie, 这就衍生出一种攻击方式 跨站请求仿造 (CSRF). CSRF 的流程大概是这样的:
- 攻击者诱导用户访问自己的钓鱼网站(这里记作 A 吧)
- 网站 A 向用户已经登录的网站 B 发送请求, 由于浏览器有网站 B 的 cookie, 这个请求会带上 cookie
- 网站 B 觉得这就是用户自己发起的合法请求, 于是执行了操作
随着技术的发展, 也有了很多防范方案. 浏览器对此的策略是 SameSite.
SameSite
浏览器会检查 cookie, 如果里面设置了 SameSite 不为 None 并且发出请求的网址与请求的网址不符合, 那么浏览器不会带上 cookie.
SameSite 有这三种值
- Strict 完全阻止第三方网站请求携带 cookie
- Lax 允许顶级导航(比如点击链接)携带 cookie, 但是阻止其它跨站请求携带(如果后端没有设置 SameSite, 那么浏览器默认为 Lax)
- None 允许跨站请求携带 cookie(浏览器为保证安全, 要求 None 需要启用 HTTPS)
web 缓存器
Web 缓存器(Web cache)是通过代理服务器实现的(proxy server). Web 缓存器有自己的磁盘存储空间, 保存最近请求过的对象的副本. 开发者/网站运营者可以配置客户使用哪些缓存器, 让客户发起的 HTTP 请求发给缓存器. 缓存器会查询本地是否存储了该对象副本, 如果有, 就返回响应: 如果没有, 那么缓存器向服务器发起 HTTP 请求, 收到响应再返回给客户.
在计算机网络: 自顶向下方法(原书第 8 版)中, 写的是 Web 缓存器也叫代理服务器, 个人感觉说的不太好.
可能另一个词会更熟悉一些, CDN(内容分发网络).
小实验
使用 go 搭建一个简易的 http 服务器
go 本身自带了一个net/http
库, 可以让我们快速搭建 http 服务器, 即使不使用 gin 等框架. 不过我们也可以使用net
库来搭建.
package main
import (
"net"
"fmt"
)
func main() {
server, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("Error starting server: ", err)
}
defer server.Close()
fmt.Println("Server started on 8080")
}
这样就能监听 8080 端口的 tcp 请求了, 接下来解析请求.
// ...
func main() {
// ...其他代码
for {
client, err := server.Accept() // 接收请求
if err != nil {
fmt.Println("Error accept: ", err)
continue
}
go func(c net.Conn) { // 开一个协程来处理tcp连接
defer c.Close()
msg := make([]byte, 1024)
_, err := c.Read(msg) // 读取信息
if err != nil {
fmt.Println("Error reading from client: ", err)
return
}
fmt.Println(string(msg))
}(client)
}
}
可以试试在浏览器打开http://localhost:8080
, 看看会打印什么.
没意外的话会看到发起了多次请求, 这是浏览器的重试: 每个请求跟之前所说的一样, 第一行是请求行, 接下来是头部行. 可以试试发一个 POST 请求给http://localhost:8080
, 如果有 body 的话还可以看到实体体.
POST http://localhost:8080/ HTTP/1.1
Content-Type: application/json
{
"name": "test",
}
POST / HTTP/1.1
user-agent: vscode-restclient
content-type: application/json
accept-encoding: gzip, deflate
content-length: 25
Host: localhost:8080
Connection: close
{
"name": "test",
}
这里 json 的格式是错的, 不过没有关系, 因为现在后端还什么都没做.
接下来把请求处理单独抽离出来, 继续完善功能.
type HttpRequest struct {
Method string
Path string
Version string
Headers map[string]string
Body []byte
Conn net.Conn
}
func handleConn(c net.Conn) {
defer c.Close()
msg := make([]byte, 1024)
_, err := c.Read(msg)
if err != nil {
fmt.Println("Error reading from client:", err)
return
}
req := &HttpRequest{
Conn: c,
}
request := bytes.Split(msg, []byte("\r\n\r\n"))
if len(request) > 2 || len(request) == 0 {
fmt.Println("Invalid request format")
return
}
if len(request) == 2 {
req.Body = request[1]
}
lines := bytes.Split(request[0], []byte("\r\n")) // 提取每一行
requestLine := lines[0] // 请求行
lines = lines[1:] // 头部行
headers := make(map[string]string)
for _, line := range lines {
if len(line) == 0 {
break
}
header := bytes.Split(line, []byte(": "))
headers[string(header[0])] = string(header[1])
}
method, path, version := handleRequestLine(requestLine)
req.Method = method
req.Path = path
req.Version = version
req.Headers = headers
fmt.Println("Method:", req.Method)
fmt.Println("Path:", req.Path)
fmt.Println("Version:", req.Version)
fmt.Println("Headers:", req.Headers)
fmt.Println("Body:", string(req.Body))
generateResponse(req)
}
func handleRequestLine(line []byte) (string, string, string) {
parts := bytes.Split(line, []byte(" "))
if len(parts) != 3 {
return "", "", ""
}
method := string(parts[0])
path := string(parts[1])
version := string(parts[2])
return method, path, version
}
func generateResponse(req *HttpRequest) {
response := "HTTP/1.1 200 OK\r\n" +
"Content-Type: text/plain\r\n" +
"Content-Length: 13\r\n" +
"\r\n" +
"Hello, World!"
req.Conn.Write([]byte(response))
fmt.Println("Response sent to client")
}
这样就解析出各种数据了, 如果你想的话可以继续写下去, 完成一个简单的 http 服务器, 这里只是简单返回了一个Hello, World!
.