原文地址

009: HTTP 中如何处理表单数据的提交?

在 http 中,有两种主要的表单提交的方式,体现在两种不同的Content-Type取值:

  • application/x-www-form-urlencoded
  • multipart/form-data

由于表单提交一般是POST请求,很少考虑GET,因此这里我们将默认提交的数据放在请求体中。

application/x-www-form-urlencoded

对于application/x-www-form-urlencoded格式的表单内容,有以下特点:

  • 其中的数据会被编码成以&分隔的键值对
  • 字符以URL编码方式编码。

如:

  1. // 转换过程: {a: 1, b: 2} -> a=1&b=2 -> 如下(最终形式)
  2. "a%3D1%26b%3D2"

multipart/form-data

对于multipart/form-data而言:

  • 请求头中的Content-Type字段会包含boundary,且boundary的值有浏览器默认指定。例: Content-Type: multipart/form-data;boundary=——WebkitFormBoundaryRRJKeWfHPGrS4LKe。
  • 数据会分为多个部分,每两个部分之间通过分隔符来分隔,每部分表述均有 HTTP 头部描述子包体,如Content-Type,在最后的分隔符会加上—表示结束。

相应的请求体是下面这样:

  1. Content-Disposition: form-data;name="data1";
  2. Content-Type: text/plain
  3. data1
  4. ----WebkitFormBoundaryRRJKeWfHPGrS4LKe
  5. Content-Disposition: form-data;name="data2";
  6. Content-Type: text/plain
  7. data2
  8. ----WebkitFormBoundaryRRJKeWfHPGrS4LKe--

小结

值得一提的是,multipart/form-data 格式最大的特点在于:每一个表单元素都是独立的资源表述。另外,你可能在写业务的过程中,并没有注意到其中还有boundary的存在,如果你打开抓包工具,确实可以看到不同的表单元素被拆分开了,之所以在平时感觉不到,是以为浏览器和 HTTP 给你封装了这一系列操作。
而且,在实际的场景中,对于图片等文件的上传,基本采用multipart/form-data而不用application/x-www-form-urlencoded,因为没有必要做 URL 编码,带来巨大耗时的同时也占用了更多的空间。

010: HTTP1.1 如何解决 HTTP 的队头阻塞问题?

什么是 HTTP 队头阻塞?

从前面的小节可以知道,HTTP 传输是基于请求-应答的模式进行的,报文必须是一发一收,但值得注意的是,里面的任务被放在一个任务队列中串行执行,一旦队首的请求处理太慢,就会阻塞后面请求的处理。这就是著名的HTTP队头阻塞问题。

域名分片

一个域名不是可以并发 6 个长连接吗?那我就多分几个域名。
比如 content1.sanyuan.com 、content2.sanyuan.com。
这样一个sanyuan.com域名下可以分出非常多的二级域名,而它们都指向同样的一台服务器,能够并发的长连接数更多了,事实上也更好地解决了队头阻塞的问题。

011: 对 Cookie 了解多少?

Cookie 简介

前面说到了 HTTP 是一个无状态的协议,每次 http 请求都是独立、无关的,默认不需要保留状态信息。但有时候需要保存一些状态,怎么办呢?
HTTP 为此引入了 Cookie。Cookie 本质上就是浏览器里面存储的一个很小的文本文件,内部以键值对的方式来存储(在chrome开发者面板的Application这一栏可以看到)。向同一个域名下发送请求,都会携带相同的 Cookie,服务器拿到 Cookie 进行解析,便能拿到客户端的状态。而服务端可以通过响应头中的Set-Cookie字段来对客户端写入Cookie。举例如下:

  1. // 请求头
  2. Cookie: a=xxx;b=xxx
  3. // 响应头
  4. Set-Cookie: a=xxx
  5. set-Cookie: b=xxx

Cookie 属性

生存周期

Cookie 的有效期可以通过ExpiresMax-Age两个属性来设置。

  • Expires即过期时间
  • Max-Age用的是一段时间间隔,单位是秒,从浏览器收到报文开始计算。

若 Cookie 过期,则这个 Cookie 会被删除,并不会发送给服务端。

作用域

关于作用域也有两个属性: Domainpath, 给 Cookie 绑定了域名和路径,在发送请求之前,发现域名或者路径和这两个属性不匹配,那么就不会带上 Cookie。值得注意的是,对于路径来说,/表示域名下的任意路径都允许使用 Cookie。

安全相关

如果带上Secure,说明只能通过 HTTPS 传输 cookie。
如果 cookie 字段带上HttpOnly,那么说明只能通过 HTTP 协议传输,不能通过 JS 访问,这也是预防 XSS 攻击的重要手段。
相应的,对于 CSRF 攻击的预防,也有SameSite属性。
SameSite可以设置为三个值,Strict、Lax和None。
a. 在Strict模式下,浏览器完全禁止第三方请求携带Cookie。比如请求sanyuan.com网站只能在sanyuan.com域名当中请求才能携带 Cookie,在其他网站请求都不能。
b. 在Lax模式,就宽松一点了,但是只能在 get 方法提交表单况或者a 标签发送 get 请求的情况下可以携带 Cookie,其他情况均不能。
c. 在None模式下,也就是默认模式,请求会自动携带上 Cookie。

Cookie 的缺点

  1. 容量缺陷。Cookie 的体积上限只有4KB,只能用来存储少量的信息。
  2. 性能缺陷。Cookie 紧跟域名,不管域名下面的某一个地址需不需要这个 Cookie ,请求都会携带上完整的 Cookie,这样随着请求数的增多,其实会造成巨大的性能浪费的,因为请求携带了很多不必要的内容。但可以通过Domain和Path指定作用域来解决。
  3. 安全缺陷。由于 Cookie 以纯文本的形式在浏览器和服务器中传递,很容易被非法用户截获,然后进行一系列的篡改,在 Cookie 的有效期内重新发送给服务器,这是相当危险的。另外,在HttpOnly为 false 的情况下,Cookie 信息能直接通过 JS 脚本来读取。

    012: 如何理解 HTTP 代理?

    我们知道在 HTTP 是基于请求-响应模型的协议,一般由客户端发请求,服务器来进行响应。
    当然,也有特殊情况,就是代理服务器的情况。引入代理之后,作为代理的服务器相当于一个中间人的角色,对于客户端而言,表现为服务器进行响应;而对于源服务器,表现为客户端发起请求,具有双重身份
    那代理服务器到底是用来做什么的呢?

    功能

  4. 负载均衡。客户端的请求只会先到达代理服务器,后面到底有多少源服务器,IP 都是多少,客户端是不知道的。因此,这个代理服务器可以拿到这个请求之后,可以通过特定的算法分发给不同的源服务器,让各台源服务器的负载尽量平均。当然,这样的算法有很多,包括随机算法轮询一致性hashLRU(最近最少使用)等等,不过这些算法并不是本文的重点,大家有兴趣自己可以研究一下。

  5. 保障安全。利用心跳机制监控后台的服务器,一旦发现故障机就将其踢出集群。并且对于上下行的数据进行过滤,对非法 IP 限流,这些都是代理服务器的工作。
  6. 缓存代理。将内容缓存到代理服务器,使得客户端可以直接从代理服务器获得而不用到源服务器那里。下一节详细拆解。

    相关头部字段

    Via

    代理服务器需要标明自己的身份,在 HTTP 传输中留下自己的痕迹,怎么办呢?
    通过Via字段来记录。举个例子,现在中间有两台代理服务器,在客户端发送请求后会经历这样一个过程:

    1. 客户端 -> 代理1 -> 代理2 -> 源服务器

    在源服务器收到请求后,会在请求头拿到这个字段:

    1. Via: proxy_server1, proxy_server2

    而源服务器响应时,最终在客户端会拿到这样的响应头:

    1. Via: proxy_server2, proxy_server1

    可以看到,Via中代理的顺序即为在 HTTP 传输中报文传达的顺序。

    X-Forwarded-For

    字面意思就是为谁转发, 它记录的是请求方的IP地址(注意,和Via区分开,X-Forwarded-For记录的是请求方这一个IP)。

    X-Real-IP

    是一种获取用户真实 IP 的字段,不管中间经过多少代理,这个字段始终记录最初的客户端的IP。
    相应的,还有X-Forwarded-Host和X-Forwarded-Proto,分别记录客户端(注意哦,不包括代理)的域名和协议名。

    X-Forwarded-For产生的问题

    前面可以看到,X-Forwarded-For这个字段记录的是请求方的 IP,这意味着每经过一个不同的代理,这个字段的名字都要变,从客户端到代理1,这个字段是客户端的 IP,从代理1到代理2,这个字段就变为了代理1的 IP。
    但是这会产生两个问题:

  7. 意味着代理必须解析 HTTP 请求头,然后修改,比直接转发数据性能下降。

  8. 在 HTTPS 通信加密的过程中,原始报文是不允许修改的。

由此产生了代理协议,一般使用明文版本,只需要在 HTTP 请求行上面加上这样格式的文本即可:

  1. // PROXY + TCP4/TCP6 + 请求方地址 + 接收方地址 + 请求端口 + 接收端口
  2. PROXY TCP4 0.0.0.1 0.0.0.2 1111 2222
  3. GET / HTTP/1.1
  4. ...

这样就可以解决X-Forwarded-For带来的问题了。

013: 如何理解 HTTP 缓存及缓存代理?

关于强缓存和协商缓存的内容,我已经在能不能说一说浏览器缓存做了详细分析,小结如下:
首先通过 Cache-Control 验证强缓存是否可用

  • 如果强缓存可用,直接使用
  • 否则进入协商缓存,即发送 HTTP 请求,服务器通过请求头中的If-Modified-Since或者If-None-Match这些条件请求字段检查资源是否更新
    • 若资源更新,返回资源和200状态码
    • 否则,返回304,告诉浏览器直接从缓存获取资源

这一节我们主要来说说另外一种缓存方式: 代理缓存

为什么产生代理缓存?

对于源服务器来说,它也是有缓存的,比如Redis, Memcache,但对于 HTTP 缓存来说,如果每次客户端缓存失效都要到源服务器获取,那给源服务器的压力是很大的。
由此引入了缓存代理的机制。让代理服务器接管一部分的服务端HTTP缓存,客户端缓存过期后就近到代理缓存中获取,代理缓存过期了才请求源服务器,这样流量巨大的时候能明显降低源服务器的压力。
那缓存代理究竟是如何做到的呢?
总的来说,缓存代理的控制分为两部分,一部分是源服务器端的控制,一部分是客户端的控制。

源服务器的缓存控制

private 和 public

在源服务器的响应头中,会加上Cache-Control这个字段进行缓存控制字段,那么它的值当中可以加入private或者public表示是否允许代理服务器缓存,前者禁止,后者为允许。
比如对于一些非常私密的数据,如果缓存到代理服务器,别人直接访问代理就可以拿到这些数据,是非常危险的,因此对于这些数据一般是不会允许代理服务器进行缓存的,将响应头部的Cache-Control设为private,而不是public。

proxy-revalidate

must-revalidate的意思是客户端缓存过期就去源服务器获取,而proxy-revalidate则表示代理服务器的缓存过期后到源服务器获取。

s-maxage

s是share的意思,限定了缓存在代理服务器中可以存放多久,和限制客户端缓存时间的max-age并不冲突。
讲了这几个字段,我们不妨来举个小例子,源服务器在响应头中加入这样一个字段:

  1. Cache-Control: public, max-age=1000, s-maxage=2000

相当于源服务器说: 我这个响应是允许代理服务器缓存的,客户端缓存过期了到代理中拿,并且在客户端的缓存时间为 1000 秒,在代理服务器中的缓存时间为 2000 s。

客户端的缓存控制

max-stale 和 min-fresh

在客户端的请求头中,可以加入这两个字段,来对代理服务器上的缓存进行宽容限制操作。比如:

  1. max-stale: 5

表示客户端到代理服务器上拿缓存的时候,即使代理缓存过期了也不要紧,只要过期时间在5秒之内,还是可以从代理中获取的。
又比如:

  1. min-fresh: 5

表示代理缓存需要一定的新鲜度,不要等到缓存刚好到期再拿,一定要在到期前 5 秒之前的时间拿,否则拿不到。

only-if-cached

这个字段加上后表示客户端只会接受代理缓存,而不会接受源服务器的响应。如果代理缓存无效,则直接返回504(Gateway Timeout)。
以上便是缓存代理的内容,涉及的字段比较多,希望能好好回顾一下,加深理解。

014: 什么是跨域?浏览器如何拦截响应?如何解决?

在前后端分离的开发模式中,经常会遇到跨域问题,即 Ajax 请求发出去了,服务器也成功响应了,前端就是拿不到这个响应。接下来我们就来好好讨论一下这个问题。

什么是跨域

回顾一下 URI 的组成:
2022/02/12  【(建议精读)HTTP灵魂之问,巩固你的 HTTP 知识体系 2/2】 - 图1
浏览器遵循同源政策(scheme(协议)、host(主机)和port(端口)都相同则为同源)。非同源站点有这样一些限制:

  • 不能读取和修改对方的 DOM
  • 不读访问对方的 Cookie、IndexDB 和 LocalStorage
  • 限制 XMLHttpRequest 请求。(后面的话题着重围绕这个)

当浏览器向目标 URI 发 Ajax 请求时,只要当前 URL 和目标 URL 不同源,则产生跨域,被称为跨域请求。
跨域请求的响应一般会被浏览器所拦截,注意,是被浏览器拦截,响应其实是成功到达客户端了。那这个拦截是如何发生呢?首先要知道的是,浏览器是多进程的,以 Chrome 为例,进程组成如下:
2022/02/12  【(建议精读)HTTP灵魂之问,巩固你的 HTTP 知识体系 2/2】 - 图2
2022/02/12  【(建议精读)HTTP灵魂之问,巩固你的 HTTP 知识体系 2/2】 - 图3
WebKit 渲染引擎V8 引擎都在渲染进程当中。
当xhr.send被调用,即 Ajax 请求准备发送的时候,其实还只是在渲染进程的处理。为了防止黑客通过脚本触碰到系统资源,浏览器将每一个渲染进程装进了沙箱,并且为了防止 CPU 芯片一直存在的SpectreMeltdown漏洞,采取了站点隔离的手段,给每一个不同的站点(一级域名不同)分配了沙箱,互不干扰。具体见YouTube上Chromium安全团队的演讲视频
在沙箱当中的渲染进程是没有办法发送网络请求的,那怎么办?只能通过网络进程来发送。那这样就涉及到进程间通信(IPC,Inter Process Communication)了。接下来我们看看 chromium 当中进程间通信是如何完成的,在 chromium 源码中调用顺序如下:
2022/02/12  【(建议精读)HTTP灵魂之问,巩固你的 HTTP 知识体系 2/2】 - 图4
可能看了你会比较懵,如果想深入了解可以去看看 chromium 最新的源代码,IPC源码地址Chromium IPC源码解析文章
总的来说就是利用Unix Domain Socket套接字,配合事件驱动的高性能网络并发库libevent完成进程的 IPC 过程。
好,现在数据传递给了浏览器主进程,主进程接收到后,才真正地发出相应的网络请求。
在服务端处理完数据后,将响应返回,主进程检查到跨域,且没有cors(后面会详细说)响应头,将响应体全部丢掉,并不会发送给渲染进程。这就达到了拦截数据的目的。
接下来我们来说一说解决跨域问题的几种方案。

CORS

CORS 其实是 W3C 的一个标准,全称是跨域资源共享。它需要浏览器和服务器的共同支持,具体来说,非 IE 和 IE10 以上支持CORS,服务器需要附加特定的响应头,后面具体拆解。不过在弄清楚 CORS 的原理之前,我们需要清楚两个概念: 简单请求非简单请求
浏览器根据请求方法和请求头的特定字段,将请求做了一下分类,具体来说规则是这样,凡是满足下面条件的属于简单请求:

  • 请求方法为 GET、POST 或者 HEAD
  • 请求头的取值范围: Accept、Accept-Language、Content-Language、Content-Type(只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain)


浏览器画了这样一个圈,在这个圈里面的就是简单请求, 圈外面的就是非简单请求,然后针对这两种不同的请求进行不同的处理。

简单请求

请求发出去之前,浏览器做了什么?

它会自动在请求头当中,添加一个Origin字段,用来说明请求来自哪个源。服务器拿到请求之后,在回应时对应地添加Access-Control-Allow-Origin字段,如果Origin不在这个字段的范围中,那么浏览器就会将响应拦截。
因此,Access-Control-Allow-Origin字段是服务器用来决定浏览器是否拦截这个响应,这是必需的字段。与此同时,其它一些可选的功能性的字段,用来描述如果不会拦截,这些字段将会发挥各自的作用。
Access-Control-Allow-Credentials。这个字段是一个布尔值,表示是否允许发送 Cookie,对于跨域请求,浏览器对这个字段默认值设为 false,而如果需要拿到浏览器的 Cookie,需要添加这个响应头并设为true, 并且在前端也需要设置withCredentials属性:

  1. let xhr = new XMLHttpRequest();
  2. xhr.withCredentials = true;

Access-Control-Expose-Headers。这个字段是给 XMLHttpRequest 对象赋能,让它不仅可以拿到基本的 6 个响应头字段(包括Cache-Control、Content-Language、Content-Type、Expires、Last-Modified和Pragma), 还能拿到这个字段声明的响应头字段。比如这样设置:

  1. Access-Control-Expose-Headers: aaa

那么在前端可以通过 XMLHttpRequest.getResponseHeader(‘aaa’) 拿到 aaa 这个字段的值。

非简单请求

非简单请求相对而言会有些不同,体现在两个方面: 预检请求响应字段
我们以 PUT 方法为例。

  1. var url = 'http://xxx.com';
  2. var xhr = new XMLHttpRequest();
  3. xhr.open('PUT', url, true);
  4. xhr.setRequestHeader('X-Custom-Header', 'xxx');
  5. xhr.send();

当这段代码执行后,首先会发送预检请求。这个预检请求的请求行和请求体是下面这个格式:

  1. OPTIONS / HTTP/1.1
  2. Origin: 当前地址
  3. Host: xxx.com
  4. Access-Control-Request-Method: PUT
  5. Access-Control-Request-Headers: X-Custom-Header

预检请求的方法是OPTIONS,同时会加上Origin源地址和Host目标地址,这很简单。同时也会加上两个关键的字段:

  • Access-Control-Request-Method, 列出 CORS 请求用到哪个HTTP方法
  • Access-Control-Request-Headers,指定 CORS 请求将要加上什么请求头

这是预检请求。接下来是响应字段,响应字段也分为两部分,一部分是对于预检请求的响应,一部分是对于 CORS 请求的响应。
预检请求的响应。如下面的格式:

  1. HTTP/1.1 200 OK
  2. Access-Control-Allow-Origin: *
  3. Access-Control-Allow-Methods: GET, POST, PUT
  4. Access-Control-Allow-Headers: X-Custom-Header
  5. Access-Control-Allow-Credentials: true
  6. Access-Control-Max-Age: 1728000
  7. Content-Type: text/html; charset=utf-8
  8. Content-Encoding: gzip
  9. Content-Length: 0

其中有这样几个关键的响应头字段:

  • Access-Control-Allow-Origin: 表示可以允许请求的源,可以填具体的源名,也可以填*表示允许任意源请求。
  • Access-Control-Allow-Methods: 表示允许的请求方法列表。
  • Access-Control-Allow-Credentials: 简单请求中已经介绍。
  • Access-Control-Allow-Headers: 表示允许发送的请求头字段
  • Access-Control-Max-Age: 预检请求的有效期,在此期间,不用发出另外一条预检请求。

在预检请求的响应返回后,如果请求不满足响应头的条件,则触发XMLHttpRequest的onerror方法,当然后面真正的CORS请求也不会发出去了。
CORS 请求的响应。绕了这么一大转,到了真正的 CORS 请求就容易多了,现在它和简单请求的情况是一样的。浏览器自动加上Origin字段,服务端响应头返回Access-Control-Allow-Origin。可以参考以上简单请求部分的内容。

JSONP

虽然XMLHttpRequest对象遵循同源政策,但是script标签不一样,它可以通过 src 填上目标地址从而发出 GET 请求,实现跨域请求并拿到响应。这也就是 JSONP 的原理,接下来我们就来封装一个 JSONP:

  1. const jsonp = ({ url, params, callbackName }) => {
  2. const generateURL = () => {
  3. let dataStr = '';
  4. for(let key in params) {
  5. dataStr += `${key}=${params[key]}&`;
  6. }
  7. dataStr += `callback=${callbackName}`;
  8. return `${url}?${dataStr}`;
  9. };
  10. return new Promise((resolve, reject) => {
  11. // 初始化回调函数名称
  12. callbackName = callbackName || Math.random().toString.replace(',', '');
  13. // 创建 script 元素并加入到当前文档中
  14. let scriptEle = document.createElement('script');
  15. scriptEle.src = generateURL();
  16. document.body.appendChild(scriptEle);
  17. // 绑定到 window 上,为了后面调用
  18. window[callbackName] = (data) => {
  19. resolve(data);
  20. // script 执行完了,成为无用元素,需要清除
  21. document.body.removeChild(scriptEle);
  22. }
  23. });
  24. }
  25. //当然在服务端也会有响应的操作, 以 express 为例:
  26. let express = require('express')
  27. let app = express()
  28. app.get('/', function(req, res) {
  29. let { a, b, callback } = req.query
  30. console.log(a); // 1
  31. console.log(b); // 2
  32. // 注意哦,返回给script标签,浏览器直接把这部分字符串执行
  33. res.end(`${callback}('数据包')`);
  34. })
  35. app.listen(3000)
  36. //前端这样简单地调用一下就好了:
  37. jsonp({
  38. url: 'http://localhost:3000',
  39. params: {
  40. a: 1,
  41. b: 2
  42. }
  43. }).then(data => {
  44. // 拿到数据进行处理
  45. console.log(data); // 数据包
  46. })

和CORS相比,JSONP 最大的优势在于兼容性好,IE 低版本不能使用 CORS 但可以使用 JSONP,缺点也很明显,请求方法单一,只支持 GET 请求。

Nginx

Nginx 是一种高性能的反向代理服务器,可以用来轻松解决跨域问题。
what?反向代理?我给你看一张图你就懂了。
2022/02/12  【(建议精读)HTTP灵魂之问,巩固你的 HTTP 知识体系 2/2】 - 图5正向代理帮助客户端访问客户端自己访问不到的服务器,然后将结果返回给客户端。
反向代理拿到客户端的请求,将请求转发给其他的服务器,主要的场景是维持服务器集群的负载均衡,换句话说,反向代理帮其它的服务器拿到请求,然后选择一个合适的服务器,将请求转交给它。
因此,两者的区别就很明显了,正向代理服务器是帮客户端做事情,而反向代理服务器是帮其它的服务器做事情。
好了,那 Nginx 是如何来解决跨域的呢?
比如说现在客户端的域名为client.com,服务器的域名为server.com,客户端向服务器发送 Ajax 请求,当然会跨域了,那这个时候让 Nginx 登场了,通过下面这个配置:

  1. server {
  2. listen 80;
  3. server_name client.com;
  4. location /api {
  5. proxy_pass server.com;
  6. }
  7. }

Nginx 相当于起了一个跳板机,这个跳板机的域名也是client.com,让客户端首先访问 client.com/api,这当然没有跨域,然后 Nginx 服务器作为反向代理,将请求转发给server.com,当响应返回时又将响应给到客户端,这就完成整个跨域请求的过程。
其实还有一些不太常用的方式,大家了解即可,比如postMessage,当然WebSocket也是一种方式,但是已经不属于 HTTP 的范畴,另外一些奇技淫巧就不建议大家去死记硬背了,一方面从来不用,名字都难得记住,另一方面临时背下来,面试官也不会对你印象加分,因为看得出来是背的。当然没有背并不代表减分,把跨域原理和前面三种主要的跨域方式理解清楚,经得起更深一步的推敲,反而会让别人觉得你是一个靠谱的人。