分类 Web 相关 下的文章

http keep-alive 实验

之前一篇讲道客户端和服务端是如何处理 http keep-alive 的, 其中很多都是一笔带过. 本篇补充一些细节.

版本

对于 http keep-alive 的概念, 这里的讨论只局限于 http 1.0, http 1.1. 对于 HTTP/2, HTTP/3 这里的讨论不适用.

http header - Connection & Keep-Alive

http 1.1 里面默认是持久连接. 但是我们可以看到下面的默认情况:
Chrome:

  1. 默认发送 Connection: keep-alive 头, 但是不发 Keep-Alive.

curl:

  1. Ubuntu 上的 curl 7.81.0 默认连 Connection 都没发.
  2. Mac 上的 curl 8.6.0 也没发 Connection header.

python requests:

GET / HTTP/1.1
Host: www.henu.edu.cn
User-Agent: python-requests/2.31.0
Connection: keep-alive

Java:

GET / HTTP/1.1
User-Agent: Java/17.0.4.1
Host: www.henu.edu.cn
Connection: keep-alive

JDK 里面的 Java client 默认的处理细节

Keep-Alive header 里面的 timeout 和 max 分别对应 JDK HttpClient 里面的字段:
timeout - keepAliveTimeout
max - keepAliveConnections.

keepAliveTimeout

首先, 在 HttpClient 里面定义了一个 keepAliveTimeout 字段. JDK 21 到链接:
https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.base/share/classes/sun/net/www/http/HttpClient.java#L136C9-L136C25.

这个字段有4种取值可能:

  1. 正值 - timeout 的秒数, 对应 Keep-Alive header 里面的 timeout 值.
  2. 0 - 对方明确不需要 Keep-Alive.
  3. -1: 需要保持连接, http 1.1 设置或者不设 Connection: keep-alive, 但是没有设置 timeout 值.
  4. -2: 明确在 Keep-alive header 里面设置了 timeout: 0 这个值.

如果把我们上面实验的客户端的结果反过来看成对方发来的response 来看, 都属于上面的第3种: -1 类型.

真实的JDK java 客户端的解析过程在这些代码中:
https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.base/share/classes/sun/net/www/http/HttpClient.java#L907-L917

真正的使用这个值的地方

真正使用这个值的地方在:
https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.base/share/classes/sun/net/www/http/KeepAliveCache.java#L162-L177

keepAliveConnections

这个值全部都是在 HttpClient 里面使用的.

parse:

  1. 若头部带来了 Keep-Alivemax 则使用这个值.
  2. 若没带来 max, 则如果使用代理, 则是50, 否则是5.
    细节代码: https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.base/share/classes/sun/net/www/http/HttpClient.java#L902-L904

WebSocket 协议

最近有个项目要做一个Slack Robot, 就是根据用户发到Slack 某个Channel的内容, 给出智能回复.

与Slack的交互方式有2种: HTTP连接的方式和WebSocket的方式. HTTP 连接的方式又有2种实现方式:

  1. 我们提供一个公开的endpoint, 当Slack的Channel 有消息的时候, Slack 以webhook的方式及时通知我们, 我们的app 给出回复.
  2. 我们不提供endpoint, 我们连接Slack的endpoint, 以固定时间间隔的方式去poll 消息, 如果有新消息, 我们的app 给出回复.

以上这2种方式都不是很好的方式, 对于第一种, 我们要提供一个公开的endpoint, 在我们的生产环境基本不可能, 对于第二种方式, 不是基于事件的, 不管有没有消息, 都要固定的去poll, 对于一个app 里面有多个实例的情况, 还要控制那个实例去poll.

所以 WebSocket 的方式是最好的, 不过当时就有开发人员提出了意见: 我们的生产环境时不能连外网的, 要连只能通过http 代理, 而我们的生产环境的代理只支持 http 代理, 不支持sock 方式, 所以这种 WebSocket 行不通. 是真的吗?

在我们看看 WebSocket 到底是什么之前, 先做一个 WebSocket 的例子: 使用Node.js 开一个WebSocket 服务, 然后在浏览器开一个 WebSocket 客户端.

WebSocket 服务端

  1. 新建一个文件夹

    mkdir wsServer
    cd wsServer
  2. 新建一个 package.json

    vim package.json

    输入如下代码

    {
     "name": "wsServer",
     "version": "0.0.1",
     "type": "module",
     "dependencies": {
       "ws":"8.8.1"
     }
    }
  3. 新建一个服务端代码文件

    vim server.js

    输入如下代码:

    var WebSocketServer = require('ws').Server
    var fs = require('fs')
    
    const wss = new WebSocketServer({ port: 8080 });
    
    wss.on('connection', function connection(ws) {
     ws.on('message', function message(data) {
     console.log('received: %s', data);
     ws.send(data + " -> server ack");
     });
    
     fs.watch('/tmp/', (eventType, fileName) => {
     console.log('Get event on file ' + fileName + ', type: ' + eventType);
     ws.send('file -> ' + fileName + ' -> ' + eventType);
     })
    
     ws.send('welcome to WebSocket world!');
    });

    上面这段代码做3件事情:

  4. 启动 WebSocket 服务器在8080 端口上, 当有人来连接的时候, 发送欢迎消息;
  5. 当收到客户端消息的时候, 打印收到的消息, 并且发送给客户端 ack 消息;
  6. 监听本地 /tmp 文件夹的文件变动事件, 打印日志, 并推送给客户端;

启动服务端代码:

node server.js

WebSocket 客户端

在 Chrome 浏览器打开任意页面的控制台, 输入如下JavaScript 代码

const ws = new WebSocket('ws://10.249.64.103:8080');

// Listen for messages
ws.addEventListener('message', (event) => {
    console.log('Message from server: ', event.data);
});

document.addEventListener('click', (event) => {
    console.log("just clicked");
    ws.send('click on (' + event.x + ', ' + event.y + ')');
});

上面的代码做下面的事情:

  1. 连接服务端 WebSocket;
  2. 然后当收到服务器端消息的时候, 打印收到的消息;
  3. 当页面上收到点击事件的时候, 推送给服务端点击事件的坐标;

运行效果

客户端效果:
client.png

服务器端效果:
server.png

使用代理

因为客户端使用的是浏览器, 可以设置在chrome 设置对于这个IP 启用代理, 使用http 代理, 依然能联通server 端, 正常运转.

使用curl

使用curl 只能被动的接受服务器传来的消息, 不能发送任何消息. (这个 Sec-WebSocket-Key 是从浏览器刚才发送的历史中复制过来的)

curl \
    --include \
    --no-buffer \
    --header "Connection: Upgrade" \
    --header "Upgrade: websocket" \
    --header "Host: 10.249.64.103:8080" \
    --header "Origin: http://10.249.64.103:8080" \
    --header "Sec-WebSocket-Key: 1TFTcjPQ7iG2XvsZ83WgZg==" \
    --header "Sec-WebSocket-Version: 13" \
    http://10.249.64.103:8080

效果(curl 对回应消息只能拼接, 不换行):
server.png

JavaScript WebSocket API 文档

官方文档在这里: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket
这个 WebSocket 类很简单:

  1. 构造函数给出服务器的地址, 包括ws协议, host 加 port;
  2. 有几个字段: 比如 url, readyState, protocol, bufferedAcmount, binaryType等, 有些是只读的;
  3. 客户端只有发送 send() 和 close() 方法;
  4. event handler:

    1. message: 当收到消息时;
    2. open: 当连接建立时;
    3. error: 当发送错误时;
    4. close: 当关闭连接时;

Node.js 的 WebSocket 实现库 ws 的文档

官方文档: https://www.npmjs.com/package/ws#sending-and-receiving-text-data
它不仅仅包含一个server端的API实现, 还包含一个做为 Node.js 客户端端代码实现

WebSocket 协议

官方文档: https://www.rfc-editor.org/rfc/rfc6455
当我们看过上面的 WebSocket 的例子之后, 再来看这个RFC 文档, 就不是那么难了.

为什么需要 WebSocket 协议

在 web 通信等某些场景下, 如果服务端发生了某些事件, 需要实时推送给客户端. 在传统的基于web的技术下, 需要客户端不断的去poll消息, 不管服务端到底有没有事件更新, 每次poll 都需要客户端发送一个http request, 并且如果发送的频率过低, 可能不能及时收到服务端事件更新, 如果频率过高, 又会对网络和服务端造成一些压力.

所以, 如果建立一个连接的情况下, 服务端事件变更主动推送客户端, 客户端只要等待就好了, 就完美解决了这个问题, 于是就有了 WebSocket 协议.

WebSocket 基本介绍

  1. 它可以使客户端和服务端在一个连接里面双向不间断通信;
  2. 通信过程分2阶段, 先是使用http协议握手连接, 然后以数据帧的方式双向发送数据;
  3. 广泛使用在游戏, 股票等需要实时消息通讯等软件中;
  4. WebSocket 可以使用现有web的 proxy和认证等成熟的机制;
  5. 通常开在80或443, 可以通过防火墙;

通常的 连接握手协议:

The handshake from the client looks as follows:

        GET /chat HTTP/1.1
        Host: server.example.com
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
        Origin: http://example.com
        Sec-WebSocket-Protocol: chat, superchat
        Sec-WebSocket-Version: 13

   The handshake from the server looks as follows:

        HTTP/1.1 101 Switching Protocols
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
        Sec-WebSocket-Protocol: chat

HTTP post/put 的 payload 的内容(content-type)类型

虽然看过几遍 HTTP 权威指南, 可是每次遇到 post 上传文件或者看到 Postman 的 form-data, x-www-form-urlencoded 的时候, 还是有点迷糊. 今天看 everything about curl 的时候, 又谈到了 post 的 payload 内容类型. 索性把它理清楚, 方便自己以后查找.

关于 POST 和 PUT 区别

有 ID, 每次根据 ID 去创建/更新, 就用 PUT, 具有幂等性, 多次提交不影响结果.
没有 ID, 每次都创建, 就用 POST, 不具有幂等性, 多次提交产生多次内容.

网页表单(FORM)提交

网页表单提交时, 若 method 为 POST, 表单的 enctype 属性决定了提交的负载内容(payload)的 MIME(网络多媒体类型)类型. enctype 是 encoding type 的缩写, 表示了内容的编码类型, 相当于告诉接受请求的服务器: 我这边把发送的内容经过了某种编码类型A 编码, 你收到之后也用同样的类型解码一下. 现在(20220102)enctype 可能的值(反映到 Content-Type)只有3种:

  1. application/x-www-form-urlencoded 只支持键值对,键值对中间用&分隔开, 比如 name=eric&gender=1&age=50
  2. multipart/form-data 如果表单的某个值为文件时(上传文件), 使用这种方式, 否则使用上面编码方式(简单).
  3. text/plain HTML5 新加的, 主要用于调试.

除去第三种调试用的, 我们在网页上看到的基本就是上面2种, 如果没有文件上传, 基本是第一种, 因为它最简单, 基本就是把 form 的内容根据键值对使用&连接到一起.
multipart/form-data 要对表单内容的每个项目使用分隔符分开, 它的分隔符比较长, 中间还有空行. 比如我上传一个图片文件(sni.png)和另外一个字段(key1=value1), 它的负载的内容是:

------WebKitFormBoundarycsbFnSl9t4kuAjfv
Content-Disposition: form-data; name="a"; filename="sni.png"
Content-Type: image/png

.PNG
.
...
IHDR................e....sRGB........leXIfMM.*.
<<中间省略 png 里面的1000多行二进制内容>>
...adfas
------WebKitFormBoundarycsbFnSl9t4kuAjfv
Content-Disposition: form-data; name="key1"

value1
------WebKitFormBoundarycsbFnSl9t4kuAjfv--

Ajax 请求表单/代码(微服务)提交表单

这种情况下, Content-type 可以是其它类型: application/xml, application/json

关于 HTTP header Content Security Policy (CSP)

为什么需要 CSP

允许用户提交数据的网站总是会遇到跨站脚本(Cross-Site Scripting)和各种数据注入的安全问题, 通常这些脚本都会发送消息给第三方搜集数据或者加载第三方站点的各种资源来进一步扩大攻击面. 那么如何保证我们自己的站点不要加载或使用第三方资源呢?

什么是 CSP

Content Security Policy 就是专门用来发现和缓解上面提到的各种潜在的安全问题的解决方案. 它限制浏览器可以使用的各种资源(JavaScript, css, 图片, video, 字体, frame, audio等)的 URL. 比如只允许使用本站点或信任站点的 JavaScript 或 CSS.

CSP 的实现

服务器端返回的 response 里的 Content-Security-Policy header 用来实现 CSP.
Content-Security-Policy: default-src 'self' trusted.com *.trusted.com
或者通过 HTML 里面的 meta 元素来实现:

<meta http-equiv="Content-Security-Policy" content="default-src 'self' trusted.com *.trusted.com">

Content-Security-Policy 的值称之为 policy. 每个 policy 定义一种或多种资源的这种资源被允许的来源. default-src 用来设置默认的policy, 当没有设置某种资源的限制时, 就使用 default-src的 policy.

一些例子:

Content-Security-Policy: default-src 'self'
Content-Security-Policy: default-src 'self'; img-src *; media-src media1.com media2.com; script-src userscripts.example.com
Content-Security-Policy: default-src 'self' *.mailsite.com; img-src *

更多例子参考:
https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

当你只是想测试一下你的 policy 时(比如, 你不确定你的站点现在共使用了哪些第三方的资源时), 你可以使用这个 header 去测试, 它只会报告给你, 不会真正限制时候第三方资源:

Content-Security-Policy-Report-Only: policy

如何上报

可以在 policy 里面设置 report-to 或 report-uri (deprecated) 如:

Content-Security-Policy: ...; report-uri https://endpoint.com; report-to groupname

当浏览器不支持 CSP 时, 它会降级到同源策略 (CORS)来保护站点.

当遇到违反 CSP 的情况时候, console 会报错:
sampel.png