众所周知计算机软件的架构目前最为常见的是 B/S 和 C/S 架构,B/S 和 C/S 架构的软件都采用了分层结构,将系统划分为不同的模块层次,每个模块层负责相应功能,这种架构便于管理和维护,同时提高了系统的可扩展性和灵活性。笔者感觉最主要的特点是在使用软件时都会产生数据,而这些数据如果存储在本地时很不安全的,并且用户产生的数据才是开发对应软件公司想用的东西,数据才是一个软件科技公司的财产。两种架构都将用户界面和数据处理分离,有助于系统的灵活性和维护性,用户界面只需要负责展示信息,而数据处理由服务器端或客户端中的逻辑处理模块完成;无论是 B/S 还是 C/S 架构,都涉及客户端和服务器之间的通信,客户端向服务器发送请求,并接收服务器的响应,这种通信机制实现了应用程序的交互和数据交换,这篇博文将探讨一些在 B/S 架构下的网络通信协议技术。
Web 浏览器和客户端
当浏览器发生数据请求就会采用 HTTP 协议和应用服务器交换数据,而应用服务器和数据库服务器交互则采用传输层的 TCP 协议。
HTTP Protocol
HTTP 协议是 HyperText Transfer Protocol 的缩写 (即超文本传输协议),是由 w3c(万维网联盟)制定的一种应用层协议,用来定义浏览器与 Web 服务器之间如何通信以及通信的数据格式。因为 B/S 架构中的通信模块就是以 HTTP 这个协议作为标准协议的,所以对该协议有所了解可以更好的编写程序。 HTTP 协议的发展历史悠久,已经不断的迭代多个版本,不同时期的 HTTP 版本特性,整理表格:
时间段 | HTTP版本 | 主要特点 |
---|---|---|
1989-1995 | HTTP/0.9 | - 最初版本,仅支持 GET 请求。 - 无请求头或状态码,只有响应主体。 - 用于传输 HTML 文件。 |
1996-1999 | HTTP/1.0 | - 引入多种 HTTP 方法(GET、POST、HEAD 等)。 - 引入状态码、请求头、响应头等概念。 - 服务器在每次请求后关闭连接。 |
1999-2015 | HTTP/1.1 | - 引入持久连接,减少连接建立的开销。 - 引入管道化(Pipelining),实现多请求并发。 - 引入 Host 头字段,允许多个域名共享 IP 地址。 - 引入分块传输编码和压缩,提高效率。 |
2015-至今 | HTTP/2.0 | - 完全重新设计,旨在提高性能和效率。 - 改进多路复用,支持并行请求和响应。 - 引入头部压缩,减少数据传输大小。 - 允许服务器推送资源,提高效率。 - 强制使用加密(TLS),提高安全性。 |
2020-至今 | HTTP/3.0 | - 基于 QUIC 协议,进一步提高性能和安全性。 - 改进连接建立速度和稳定性,减少握手延迟。 - 实现数据包级别的错误纠正和拥塞控制。 - 采用基于 UDP 的传输协议。 |
如果要测试 HTTP 协议的一些特性这里推荐使用 telnet
命令行工具,可以在命令行进行 HTTP 数据包封装,Telnet 使用了 TCP/IP 协议进行通讯,Telnet 协议默认是 23 端口。它允许用户通过网络连接到远程主机,并在远程系统上执行命令,就像在本地终端上输入命令一样。完全可以使用 telent 命令行工具,来模拟整个 HTTP 协议连接建立的过程,HTTP 请求和响应的过程如下:
- 浏览器根据 IP 地址和端口号与服务器建立连接
- 向 Web 服务器发送请求数据包
- Web 服务器接收请求数据包后,发送相应的响应数据包
- 浏览器接收响应数据后关闭连接
在 Windows 中的 telent 默认会在高级功能设置中,而 MacOS 和 Linux 用户则需要手动进行安装对应的 telent
命令,下图是一次使用 telent 进行 HTTP/1.1 版本请求响应的例子日志细节。
$: telnet www.ibyte.me 80
Trying 172.67.151.15...
Connected to www.ibyte.me.
Escape character is '^]'.
GET / HTTP/1.1
HTTP/1.1 400 Bad Request
Server: cloudflare
Date: Mon, 09 Oct 2023 11:55:59 GMT
Content-Type: text/html
Content-Length: 155
Connection: close
CF-RAY: -
<html>
<head><title>400 Bad Request</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<hr><center>cloudflare</center>
</body>
</html>
Connection closed by foreign host.
另外 Keep-Alive 超时间隔时间在各个 Web 服务器配置可以设置,Keep-Alive 机制对应的请求头中的是 Connection
字段,服务器会根据此字断来做相应的处理。
字段 | 作用 |
---|---|
Connection: keep-alive | TCP 持久连接 |
Connection: close | 要求服务器关闭 TCP 连接 |
下面是笔者一个使用 Telnet 例子日志信息:
$: telnet www.ibyte.me 80
Trying 172.67.151.15...
Connected to www.ibyte.me.
Escape character is '^]'.
GET /path1 HTTP/1.1
Host: www.ibyte.me
Connection: keep-alive
GET /path2 HTTP/1.1
Host: www.ibyte.me
Connection: keep-alive
GET /path3 HTTP/1.1
Host: www.ibyte.me
Connection: keep-alive
而 HTTP/2.0 引入了多路复用 Multiplexing 机制,允许客户端同时发送多个 HTTP 请求并在同一个 TCP 连接上进行传输,同时服务器也可以将多个响应并行地返回给客户端,不同请求的数据可以交错传输,而不必等待前面的请求响应完成。这样可以充分利用连接,减少了等待时间,提高了效率,总体来看不同性能提升方案差异对照图。
至于对于具体值和索引,建议查阅 IETF RFC7541 规范文档,如果不是自己去开发实现 HTTP/2.0 协议的话。
另外仿照 TCP 的流量控制策略,HTTP/2.0 也设计了流量控制策略算法,在同一个 TCP 连接上的多个数据流是共享当前运输层的 TCP 连接的,基于 SETTING 和 WINDOW_UPDATE 帧进行数据窗口的调节,具体如何调节看客户端和服务器端的场景。所谓窗口调节也就是在请求头上双方协商调整滑动窗口的大小,这是一段 Java 代码的例子:
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
public class Http2FlowControlExample {
public static void main(String[] args) throws IOException {
// 创建 HTTP/2 服务器
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
// 创建上下文处理器
server.createContext("/", new MyHandler());
// 启动服务器
server.start();
}
static class MyHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
// 发送流控制窗口大小为 4096 的响应头
exchange.getResponseHeaders().add("Content-Type", "text/plain");
exchange.getResponseHeaders().add("Window-Size", "4096");
// 获取输出流
OutputStream os = exchange.getResponseBody();
// 模拟发送数据,每次发送 1024 字节
byte[] data = new byte[1024];
for (int i = 0; i < 10; i++) {
// 发送数据并检查流控制窗口
os.write(data);
os.flush();
// 模拟接收方处理数据
handleData(data);
// 发送 WINDOW_UPDATE 帧,增加流控制窗口大小
exchange.getHttpContext().getServer().getExecutor().execute(() -> {
exchange.getResponseHeaders().add("Window-Update", "4096");
});
// 暂停 1 秒以模拟处理延迟
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 关闭输出流
os.close();
}
private void handleData(byte[] data) {
// 模拟接收方处理数据
System.out.println("Received data: " + new String(data));
}
}
}
正常情况下,流量控制的处理应该由 HTTP/2 协议自动进行,而不需要手动添加 Window-Update
这样的字段,而发送 WINDOW_UPDATE 帧是由 HTTP/2 实现自动处理的。而不是通过添加响应头的方式,HTTP/2 协议会自动进行流量控制,确保在不超过对端流控制窗口大小的情况下发送数据。
针对 HTTP/1.1 主动请求资源的方式,HTTP/2.0 升级了主动推送服务,当服务器决定要推送一些资源给客户端时,如资源的 URL 、头部信息等。客户端可以选择接受或拒绝这个推送,如果客户端接受了推送,那么服务器就会在接下来发送相应的数据帧,而不需要等待客户端发起请求,在经典的 Web 服务器 Nginx 中可以使用 http2_push
指令来实现这类的推送功能:
server {
# 启用 HTTP/2 协议版本
listen 443 ssl http2;
server_name example.com;
ssl_certificate /path/to/your/certificate.crt;
ssl_certificate_key /path/to/your/private_key.key;
# 启用 HTTP/2 服务器推送
http2_push_preload on;
location / {
# 设置推送的资源
http2_push /styles.css;
http2_push /script.js;
# 处理正常的请求
try_files $uri $uri/ =404;
}
}
Ajax 异步 HTTP
通过上面篇幅已经知道 HTTP 只能适用于从客户端主动发起,而服务器只能被动提供服务,对请求做出对应的请求数据响应;当然这里在 HTTP/2.0 允许服务器推送资源,提高效率,HTTP/2.0 会根据客户端请求主动推送对应的数据包到客户端中。在这种架构下客户端和客户端之间不是直接 P2P 方式进行通信的,而是通过服务器进行通信交互数据。典型的全双工通信例子有股票市场的 K 图,或者一些在线的 IM 即时通讯应用,目前 Web 实现这类应用的方案有通过 HTTP 协议进行请求轮训的方式,或者 WebSocket 的方式,这也是本篇技术博客要详细讲解的技术。
在使用基于 HTTP/1.x 版本进行 Ajax 轮询的时候会出现一个问题,每次轮询都需要建立一次 TCP 连接,了解 TCP 协议的都明白每次建立一次 TCP 协议连接代价很大,都需要进行三次握手。为了解决这个问题开发者又提出了长轮询的概念 (Long Polling),普通轮询在服务器接收到请求后,开始处理如果有新的数据可用,服务器立即将数据返回给客户端,没有也会立即断开连接;而长轮询的做法,如果服务器暂时没有新的数据可用,服务器和客户端会保持连接打开,等待直到有新的数据为止,或者加一些策略在一定时间后关闭连接做超时控制,下面是一段 Java 实现的代码:
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
@WebServlet("/polling")
public class PollingServlet extends HttpServlet {
private static BlockingQueue<String> dataQueue = new LinkedBlockingQueue<>();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
try {
// 设置响应类型为纯文本
response.setContentType("text/plain;charset=UTF-8");
// 设置响应缓冲区不缓存数据
response.setBufferSize(0);
// 设置响应字符编码
response.setCharacterEncoding("UTF-8");
// 从队列中取出数据,如果队列为空,则等待一定时间
String data = dataQueue.poll(10, TimeUnit.SECONDS);
// 没有新数据,返回适当的响应
response.getWriter().write(data != null ? data : "No new data");
} catch (InterruptedException e) {
e.printStackTrace();
// 处理异常,例如返回错误信息
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Internal Server Error");
}
}
// 此方法可以在其他地方先调用,例如其他的 Servlet 方法
private static void startDataProducer() {
// 启动数据产生者线程
Thread producerThread = new Thread(() -> {
try {
int count = 1;
while (true) {
String data = "Data-" + count++;
dataQueue.put(data);
// 模拟数据每两秒产生一次
Thread.sleep(2000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
producerThread.start();
}
}
对于客户端只需要在收到消息之后继续发送一个 XHR 请求到服务器,JavaScript 代码:
function longPolling() {
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/longpoll', true);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
var responseData = JSON.parse(xhr.responseText);
// 处理从服务器返回的数据
longPolling(); // 再次发起长轮询请求
} else {
// 处理错误,重新发起长轮询请求
longPolling();
}
}
};
xhr.send();
}
longPolling(); // 初始调用
SSE 事件推送
HTTP 和采用 HTTP 轮询的方式都是为了解决,服务器和客户端之间的数据交换,轮询又是基于 HTTP 协议基础之上一种技术应用的实现,目的是让及时获取到服务器产生的数据变更,客户端能获取得到。这两种方式的都是采用客户端主动发送 HTTP 协议来获取数据,遇到的问题也很明显存在消息同步延迟,不能由服务器在产生新的数据同时就将消息同步到客户端中。针对这个轮询延迟交付问题,多浏览器厂商设计一款新的 API 来帮助解决服务器推送消息的问题,采用 Event Source 和 Event Stream 进行服务器推送通知,一个 Go 的实现自定义事件类型和数据推送功能:
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
type CustomEvent struct {
Type string `json:"type"`
Message string `json:"message"`
Timestamp string `json:"timestamp"`
}
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// 设置响应头,指定内容类型为 text/event-stream
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// 设置初始数据
initialData := CustomEvent{
Type: "message",
Message: "Initial data",
Timestamp: time.Now().Format(time.RFC3339),
}
sendEvent(w, initialData)
// 模拟定时向客户端推送不同类型的事件
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// 向客户端发送 "message" 事件
messageEvent := CustomEvent{
Type: "message",
Message: "Server time",
Timestamp: time.Now().Format(time.RFC3339),
}
sendEvent(w, messageEvent)
// 向客户端发送 "alert" 事件
alertEvent := CustomEvent{
Type: "alert",
Message: "Important message",
Timestamp: time.Now().Format(time.RFC3339),
}
sendEvent(w, alertEvent)
case <-r.Context().Done():
// 客户端断开连接时关闭循环
fmt.Println("Client disconnected")
return
}
}
})
// 启动服务器
fmt.Println("Server listening on :8080")
http.ListenAndServe(":8080", nil)
}
func sendEvent(w http.ResponseWriter, event CustomEvent) {
// 序列化事件为 JSON 格式
jsonData, err := json.Marshal(event)
if err != nil {
fmt.Println("Error encoding JSON:", err)
return
}
// 向客户端发送 JSON 格式的事件
fmt.Fprintf(w, "event: %s\n", event.Type)
fmt.Fprintf(w, "data: %s\n\n", string(jsonData))
w.(http.Flusher).Flush() // 强制将数据推送到客户端
}
WebSocket 全双工通信
与传统的 Web 通信方式不同,WebSocket 允许客户端和服务器之间进行双向通信,双方都可以独立地发送消息。WebSocket 在客户端和服务器之间建立了一个持久连接,这个连接保持打开状态,允许低延迟通信。这样设计的好处允许客户端和服务器之间能交叉发送数据,客户端和服务器双方都可以随时发送消息到给对方。WebSocket 从一个 HTTP 握手开始,然后 HTTP 协议升级到 WebSocket 协议,然后再通过 WebSocket 连接发送的数据被组织成帧,帧可以携带文本或二进制数据,帧的机制支持高效的通信并支持不同的数据类型。
因为和上述的通信方式一样,没有固定的消息格式,通信采用二进制数据流的方式,如果需要交互数据,可以在应用和服务器端设计子协议,子协议可以定义应用层的通信规范,规定在 WebSocket 连接上双方如何交换数据、消息和命令。这有助于确保在 WebSocket 连接上进行的通信是按照特定协议的规范进行的。子协议存在的目的统一客户端和服务器端都知道消息首部,各方面采用子协议就能正常编解码消息体,如果双方在握手时都同意使用特定的子协议,那么连接将在该子协议下进行通信,否则连接将使用默认的 WebSocket 协议。