B/S 架构与 HTTP 响应基础 | JavaSE

B/S 架构与 HTTP 响应基础

一、学习目标

学完本章,你应该能够:

  1. 能够解释 B/S(Browser/Server)架构的基本工作模型。
  2. 能够说明浏览器本质上也是一个网络客户端。
  3. 能够描述浏览器输入 URL 后与 Java 服务端之间最基本的 TCP + HTTP 交互流程。
  4. 能够理解 HTTP 请求和 HTTP 响应的基本结构。
  5. 能够解释状态行、响应头、空行和响应正文分别有什么作用。
  6. 能够手写最小 HTTP/1.1 响应。
  7. 能够使用 ServerSocket + Socket + OutputStream 返回 HTML 页面给浏览器。
  8. 能够理解 Content-TypeContent-Length 和 UTF-8 编码之间的关系。
  9. 能够理解 JavaSE 手写 HTTP Server 与后续 Java Web / Tomcat 等服务器之间的关系。

二、核心知识

2.1 什么是 B/S 架构

B/S:

Browser / Server
浏览器 / 服务端

基本模型:

Browser
   │
   │ 网络请求
   ▼
Server
   │
   │ 网络响应
   ▼
Browser

例如:

Chrome
Edge
Firefox
Safari

都可以充当:

客户端。

所以:

B/S

并不是:

“没有客户端。”

更加准确的理解是:

不需要为普通用户单独开发一个专用客户端,而是利用浏览器作为通用客户端。


2.2 C/S 与 B/S 再对比

C/S:

自己开发 Client
       ↓
     Server

例如:

桌面聊天客户端
游戏客户端
IDE 客户端

B/S:

Browser
   ↓
Server

例如:

个人博客
在线商城
后台管理系统
搜索网站

从开发者角度看,B/S 极大降低了客户端安装和更新成本。


2.3 浏览器和 Java Server 如何通信

假设启动:

Java Server
127.0.0.1:8080

浏览器访问:

http://127.0.0.1:8080/

最基础的过程:

浏览器
  ↓
找到 127.0.0.1
  ↓
连接 TCP 8080
  ↓
发送 HTTP Request
  ↓
Java Server 接收
  ↓
生成 HTTP Response
  ↓
通过 TCP 返回
  ↓
浏览器解析 HTTP
  ↓
解析 HTML
  ↓
显示网页

这里把整个网络编程阶段全部串起来了。


2.4 HTTP 是什么

HTTP:

Hypertext Transfer Protocol
超文本传输协议

HTTP 是:

应用层协议。

在本章演示的 HTTP/1.1 场景中,它工作在:

HTTP
 ↓
TCP
 ↓
IP

之上。

也就是说:

TCP

解决:

可靠传输字节。

而:

HTTP

解决:

浏览器和服务器如何理解这些字节分别代表请求、响应、状态码、头字段和网页正文。


2.5 HTTP 说明了“字节是什么意思”

上一章强调:

TCP
=
字节流

如果浏览器只是发送:

GET / HTTP/1.1

服务器必须知道:

GET
/
HTTP/1.1

分别是什么意思。

这些规则由:

HTTP 协议

规定。

因此:

TCP
负责传

HTTP
负责约定怎么说

这是理解网络分层的一个非常典型例子。


三、HTTP 请求基础

3.1 浏览器会主动发送 Request

浏览器连接服务器以后,并不是:

什么都不做

而是会发送 HTTP 请求。

例如一个极简请求:

GET / HTTP/1.1
Host: 127.0.0.1:8080

注意真正的 HTTP/1.1 文本协议行结束使用:

\r\n

因此更接近:

GET / HTTP/1.1\r\n
Host: 127.0.0.1:8080\r\n
\r\n

3.2 HTTP 请求的基本组成

初学阶段可以理解成:

请求行

请求头
请求头
请求头

空行

可选请求正文

例如:

GET /hello HTTP/1.1
Host: localhost:8080
User-Agent: ...


3.3 请求行

例如:

GET /hello HTTP/1.1

可以拆成:

GET
↓
请求方法
/hello
↓
请求目标
HTTP/1.1
↓
HTTP 协议版本

本章只需要认识:

GET

即可。

后续 Java Web 才系统学习:

  • GET
  • POST
  • PUT
  • DELETE
  • HTTP Headers
  • Query Parameters
  • Request Body

等完整内容。


四、HTTP 响应基础

4.1 浏览器为什么不能直接接收一段 HTML

假设服务端只写:

<h1>Hello Java</h1>

浏览器虽然可能在某些实验环境中看到数据,但这并不是完整规范的 HTTP 响应。

浏览器期待的是:

HTTP Response。

基本结构:

状态行

响应头
响应头
响应头

空行

响应正文

4.2 最小 HTTP 响应模型

课程原始模型:

HTTP/1.1 200 OK\r\n
Content-Type: text/html; charset=UTF-8\r\n
\r\n
网页正文

展开:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8

<html>
...
</html>

三个核心部分:

状态行
+
响应头
+
响应正文

中间通过:

空行

分隔响应头和响应正文。


4.3 状态行

第一行:

HTTP/1.1 200 OK

包含:

HTTP/1.1
↓
协议版本
200
↓
状态码
OK
↓
原因短语

其中最重要的是:

状态码

4.4 HTTP 状态码分类

HTTP 状态码按第一位通常分成:

| 状态码 | 含义 | | ------ | ------------------------- | | 1xx | Informational,信息性响应 | | 2xx | Successful,成功 | | 3xx | Redirection,重定向 | | 4xx | Client Error,客户端错误 | | 5xx | Server Error,服务端错误 |

初学最常见:

200 OK

表示:

请求成功处理。

以后还会经常遇到:

404 Not Found

资源不存在。

500 Internal Server Error

服务器内部错误。


4.5 Content-Type

响应头:

Content-Type: text/html; charset=UTF-8

表示:

text/html
↓
响应正文是什么类型

以及:

charset=UTF-8
↓
文本使用什么字符编码

浏览器根据:

Content-Type

决定:

如何理解响应正文。

如果返回:

<html>...</html>

通常应该:

text/html

如果返回纯文本:

text/plain

如果以后返回 JSON:

application/json

4.6 为什么 charset 很重要

假设 HTML:

<h1>星雨笔录</h1>

服务端编码:

UTF-8

浏览器却按照其他字符集解释:

可能乱码

因此:

Content-Type: text/html; charset=UTF-8

就是在协议层告诉浏览器:

响应正文中的文本按照 UTF-8 解码。


4.7 HTTP 中的空行为什么不能漏

HTTP Header 与 Body 之间:

必须存在边界

简单 HTTP/1.1 文本协议中表现为:

Header\r\n
Header\r\n
\r\n
Body

连续:

\r\n\r\n

表示:

Header 部分结束。

如果漏掉这个空行:

浏览器可能继续把正文理解成 Header

协议结构就会出错。


4.8 Content-Length

一个更加明确的 HTTP 响应还可以加入:

Content-Length: 123

表示:

响应正文有多少个字节。

注意:

Content-Length

表示的是:

字节数。

不是:

html.length()

这种 Java 字符数量。

正确思路:

byte[] body =
        html.getBytes(
                StandardCharsets.UTF_8
        );

int contentLength =
        body.length;

所以:

Java String 字符数
≠
UTF-8 编码后的字节数

特别是出现中文时更加明显。


五、使用方法

5.1 创建最小 B/S 服务端

先建立:

ServerSocket serverSocket =
        new ServerSocket(8080);

浏览器访问:

http://127.0.0.1:8080/

浏览器本质上会创建一个 TCP 客户端连接。

服务端:

Socket socket =
        serverSocket.accept();

就可以获得浏览器的 TCP Socket。


5.2 HTTP 处理任务

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public class HttpTask implements Runnable {

    private final Socket socket;

    public HttpTask(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {

        try (
                Socket client = socket;

                BufferedReader reader =
                        new BufferedReader(
                                new InputStreamReader(
                                        client.getInputStream(),
                                        StandardCharsets.US_ASCII
                                )
                        );

                OutputStream output =
                        client.getOutputStream()
        ) {

            // 1. 读取请求行
            String requestLine =
                    reader.readLine();

            if (requestLine == null) {
                return;
            }

            System.out.println(
                    "Request: "
                            + requestLine
            );

            // 2. 读取剩余请求头
            String line;

            while (
                    (line = reader.readLine())
                            != null
                    &&
                    !line.isEmpty()
            ) {
                System.out.println(line);
            }

            // 3. 准备 HTML
            String html = """
                    <!DOCTYPE html>
                    <html lang="zh-CN">
                    <head>
                        <meta charset="UTF-8">
                        <title>JavaSE HTTP Server</title>
                    </head>
                    <body>
                        <h1>星雨笔录 · JavaSE</h1>
                        <p>Hello HTTP!</p>
                        <p>这个页面来自 ServerSocket。</p>
                    </body>
                    </html>
                    """;

            byte[] body =
                    html.getBytes(
                            StandardCharsets.UTF_8
                    );

            // 4. 准备 HTTP Response Header
            String headers =
                    "HTTP/1.1 200 OK\r\n"
                    +
                    "Content-Type: text/html; "
                    +
                    "charset=UTF-8\r\n"
                    +
                    "Content-Length: "
                    +
                    body.length
                    +
                    "\r\n"
                    +
                    "Connection: close\r\n"
                    +
                    "\r\n";

            // 5. 输出 Header
            output.write(
                    headers.getBytes(
                            StandardCharsets.US_ASCII
                    )
            );

            // 6. 输出 Body
            output.write(body);

            output.flush();

        } catch (Exception e) {

            System.out.println(
                    "HTTP 请求处理失败:"
                            + e.getMessage()
            );
        }
    }
}

5.3 B/S 服务端主程序

结合上一章线程池:

import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class MiniHttpServer {

    public static void main(String[] args)
            throws Exception {

        ExecutorService pool =
                new ThreadPoolExecutor(
                        3,
                        10,
                        10,
                        TimeUnit.SECONDS,
                        new ArrayBlockingQueue<>(100),
                        Executors.defaultThreadFactory(),
                        new ThreadPoolExecutor.AbortPolicy()
                );

        try (
                ServerSocket serverSocket =
                        new ServerSocket(8080)
        ) {

            System.out.println(
                    "HTTP Server 已启动"
            );

            System.out.println(
                    "浏览器访问:"
                    +
                    "http://127.0.0.1:8080/"
            );

            while (true) {

                Socket socket =
                        serverSocket.accept();

                pool.execute(
                        new HttpTask(socket)
                );
            }

        } finally {

            pool.shutdown();
        }
    }
}

运行程序后,在浏览器输入:

http://127.0.0.1:8080/

如果请求与响应格式正确,浏览器即可显示:

星雨笔录 · JavaSE

Hello HTTP!

这个页面来自 ServerSocket。

至此,我们实际上已经:

用 JavaSE 手写出了一个最小 Web Server。


六、原理与进阶

6.1 一次访问完整发生了什么

浏览器输入:

http://127.0.0.1:8080/

完整知识链可以画成:

Browser
   │
   │ TCP connect
   ▼
127.0.0.1:8080
   │
   ▼
ServerSocket.accept()
   │
   ▼
Socket
   │
   ▼
ThreadPool
   │
   ▼
HttpTask
   │
   │ read HTTP Request
   ▼
Java 业务逻辑
   │
   ▼
HTML String
   │
   ▼
UTF-8 byte[]
   │
   ▼
HTTP Response
   │
   ▼
Socket OutputStream
   │
   ▼
TCP
   │
   ▼
Browser
   │
   ▼
解析 HTML
   │
   ▼
渲染页面

这一张图几乎串联了:

  • 字符串
  • 编码
  • IO
  • 网络
  • TCP
  • 多线程
  • 线程池
  • B/S
  • HTTP

6.2 为什么响应头使用 ASCII,而 HTML 使用 UTF-8

HTTP 协议中的:

HTTP/1.1 200 OK
Content-Type
Content-Length

这些基础协议元素属于非常有限的文本字符范围。

因此示例中:

headers.getBytes(
        StandardCharsets.US_ASCII
)

可以清晰表达:

这是 HTTP 协议头。

而真正网页正文包含:

中文

使用:

StandardCharsets.UTF_8

编码。


6.3 为什么原教学示例可以直接 println

课程原始案例使用:

PrintStream ps =
        new PrintStream(
                socket.getOutputStream()
        );

ps.println(
        "HTTP/1.1 200 OK"
);

ps.println(
        "Content-Type:text/html;"
        +
        "charset=utf-8"
);

ps.println();

ps.println("<html>...");

它非常适合第一次理解:

状态行
+
响应头
+
空行
+
HTML

但是从协议精确性来说:

HTTP/1.1 的协议行结束符定义为 CRLF,即 \r\n

而:

println()

使用的是:

当前平台的行结束符。

因此新版主方案直接:

"HTTP/1.1 200 OK\r\n"

显式构造协议数据。

这样不依赖:

Windows
Linux
macOS

的平台换行差异。


6.4 为什么加入 Content-Length

原始最小案例没有:

Content-Length

而是在写完网页后:

socket.close();

对于没有其他正文长度声明的 HTTP 响应:

连接关闭

本身可以成为正文结束的边界。

但学习一个更加明确的 HTTP 响应时:

Content-Length

非常值得加入。

它告诉浏览器:

接下来应该读取 N 个字节作为 Body

例如:

Content-Length: 235

比:

一直读到服务器断开

拥有更明确的消息边界。


6.5 Connection: close

本章为了降低 HTTP 持久连接的复杂度,明确发送:

Connection: close

表示:

当前响应完成后关闭 TCP 连接。

因此模型非常简单:

连接
 ↓
一个请求
 ↓
一个响应
 ↓
关闭连接

真实 HTTP/1.1 还支持:

持久连接

即同一个 TCP Connection 可以承载多个 HTTP 请求/响应。

但这已经超出本章最小 HTTP Server 的目标。


6.6 浏览器为什么可能连接不止一次

你明明只在地址栏输入了一次:

http://127.0.0.1:8080/

服务器日志却可能看到:

多次连接

这是正常现象。

因为网页可能引用:

<img src="...">
<link rel="stylesheet" href="...">
<script src="..."></script>

浏览器还会继续请求:

  • 图片
  • CSS
  • JavaScript
  • favicon
  • 其他资源

所以:

一个“网页”并不等于一个网络资源。


6.7 为什么浏览器访问图片会再次发 HTTP 请求

例如服务器返回:

<img src="/logo.png">

浏览器首先拿到:

index HTML

解析发现:

/logo.png

于是再发送:

GET /logo.png HTTP/1.1

所以未来真正 Web Server 必须根据:

Request Path

决定返回:

HTML
图片
CSS
JS
JSON

等不同内容。

这就是:

路由(Routing)

概念最早的雏形。


6.8 为什么我们没有实现真正完整的 HTTP Server

真正的 HTTP Server 需要处理:

  • 请求方法
  • URL
  • Header
  • Body
  • Content-Length
  • Chunked Encoding
  • Persistent Connection
  • 状态码
  • MIME Type
  • 错误响应
  • 安全问题
  • 并发
  • TLS / HTTPS
  • 超时
  • 日志

显然不应该全部自己重新实现。

因此企业 Java Web 开发不会每天:

new ServerSocket(8080);

手搓 HTTP 协议。

而是使用成熟 Web Server / Servlet Container / Framework。

例如后面会接触:

Tomcat
Spring Boot
Spring MVC

它们帮助开发者处理大量底层网络与 HTTP 细节。


6.9 Java Web 到底从哪里开始

可以建立这条知识链:

JavaSE Socket
      ↓
TCP
      ↓
HTTP
      ↓
Web Server
      ↓
Servlet / Controller
      ↓
Java Web

所以 Java Web 并不是凭空出现。

以前你写:

@GetMapping("/hello")
public String hello() {
    return "hello";
}

看起来只是一个 Controller 方法。

但它背后最终仍然要完成:

浏览器建立连接
↓
发送 HTTP Request
↓
服务器解析 Request
↓
调用业务代码
↓
生成 HTTP Response
↓
返回浏览器

JavaSE 网络编程就是这一切的底层认知基础。


6.10 HTTP/1.1 与所有 HTTP 是否完全等价

不是。

本章手写的是:

HTTP/1.1
+
TCP

用于建立最清晰的网络模型。

现代 HTTP 体系还有:

HTTP/2
HTTP/3

其中 HTTP/3 的底层网络机制已经与经典:

HTTP/1.1 over TCP

不同。

因此应该准确表达:

本章学习的是 B/S 架构与 HTTP/1.1 基础响应模型,目的是理解 Java Web 的网络底层,而不是完整覆盖现代 HTTP 协议族。


七、常见问题

7.1 B/S 是不是没有客户端

不是。

Browser 就是客户端。

区别主要是:

开发者不需要再为普通用户开发专门客户端。


7.2 HTTP 和 TCP 是同一个协议吗

不是。

TCP:

传输层

主要提供:

可靠、有序字节流

HTTP:

应用层

定义:

浏览器和服务器如何组织请求和响应

7.3 HTTP Request 是浏览器自动生成的吗

是。

当你访问:

http://127.0.0.1:8080/

浏览器会按照 HTTP 协议发送请求。


7.4 为什么服务器不能只写 HTML

浏览器需要知道:

这是 HTTP 响应
状态是什么
正文是什么类型
正文从哪里开始

因此应该按照 HTTP Response 格式返回。


7.5 HTTP 响应为什么需要空行

用来分隔:

Headers

与:

Message Body

7.6 Content-Type 有什么作用

告诉客户端:

正文是什么媒体类型、应该如何解释。

例如:

text/html

表示 HTML。


7.7 Content-Length 应该写 html.length() 吗

不应该简单这样写。

Content-Length 表示:

字节数

应该:

byte[] body =
        html.getBytes(
                StandardCharsets.UTF_8
        );

body.length

7.8 为什么中文更能暴露 Content-Length 错误

因为:

一个 Java char / 字符

与:

UTF-8 编码后的字节数

不是一一等价。

中文通常需要多个 UTF-8 字节。


7.9 println 为什么不作为新版主方案

因为 HTTP/1.1 协议行终止符是:

\r\n

println() 使用平台行结束符。

教学演示容易理解,但精确实现推荐显式写:

"\r\n"

7.10 为什么这个服务器响应一次就关闭 Socket

因为本章为了聚焦:

请求
↓
响应

采用:

Connection: close

简化持久连接问题。


7.11 浏览器刷新为什么服务器又执行一次

因为刷新代表:

浏览器重新发起 HTTP Request。

服务器因此重新:

accept
↓
处理
↓
response

7.12 这个 MiniHttpServer 可以上线当网站服务器吗

不应该。

它只是教学程序。

没有完整实现:

  • HTTP
  • HTTPS
  • 安全
  • 路由
  • 静态资源
  • 异常处理
  • 请求大小限制
  • 超时
  • 攻击防护
  • 生产级连接管理

它的价值在于:

帮你看清 Java Web 底下究竟发生了什么。


七、练习与验收

7.1 知识问答

  1. B/S 的全称是什么?
  2. Browser 在 B/S 中是不是客户端?
  3. 浏览器输入 URL 后最基础的网络流程是什么?
  4. HTTP 属于哪一层协议?
  5. HTTP/1.1 与 TCP 是什么关系?
  6. HTTP Request 最基础由哪些部分组成?
  7. HTTP Response 最基础由哪些部分组成?
  8. HTTP/1.1 200 OK 属于响应哪一部分?
  9. Content-Type 有什么作用?
  10. Header 与 Body 之间为什么要有空行?
  11. Content-Length 表示字符数还是字节数?
  12. 为什么一个网页可能触发多次 HTTP 请求?
  13. 为什么企业开发通常不直接使用 ServerSocket 手写完整 HTTP Server?

7.2 代码阅读

阅读:

String html =
        "<h1>你好 Java</h1>";

byte[] body =
        html.getBytes(
                StandardCharsets.UTF_8
        );

String headers =
        "HTTP/1.1 200 OK\r\n"
        +
        "Content-Type: text/html; "
        +
        "charset=UTF-8\r\n"
        +
        "Content-Length: "
        +
        body.length
        +
        "\r\n"
        +
        "\r\n";

回答:

  1. 200 表示什么?
  2. 浏览器为什么知道正文是 HTML?
  3. 为什么需要 UTF-8?
  4. 为什么使用 body.length
  5. 最后的 \r\n\r\n 表示什么?

7.3 手写代码

关闭 AI 自动补全。

实现:

MiniHttpServer

要求:

  • 监听 TCP 8080
  • 浏览器访问 /
  • 返回 HTTP/1.1 200 OK
  • 返回 text/html
  • 使用 UTF-8
  • 正确写入 Content-Length
  • Header 和 Body 正确分隔
  • 页面显示:
    • 标题
    • 一个 H1
    • 一个段落

然后浏览器访问:

http://127.0.0.1:8080/

验证结果。

7.4 Debug

下面响应有什么问题?

String html =
        "<h1>星雨笔录</h1>";

String response =
        "HTTP/1.1 200 OK\r\n"
        +
        "Content-Type: text/html; "
        +
        "charset=UTF-8\r\n"
        +
        "Content-Length: "
        +
        html.length()
        +
        "\r\n"
        +
        "\r\n"
        +
        html;

回答:

  1. html.length() 为什么可能不是正确 Content-Length?
  2. 中文字符为什么会暴露问题?
  3. 应该怎样计算正文长度?

7.5 综合训练

将服务器升级成:

GET /
→ 首页

GET /hello
→ Hello 页面

其他路径
→ 404

要求:

  1. 读取 Request Line。
  2. 从中分析请求路径。
  3. / 返回一个 HTML。
  4. /hello 返回另一个 HTML。
  5. 其他请求返回:
HTTP/1.1 404 Not Found

不要使用 Spring Boot。

只允许:

ServerSocket
Socket
IO
ThreadPool
String

完成。

这个任务就是一个:

极简路由 Web Server。

7.6 本章验收

如果你能够闭卷画出:

Browser
   │
   │ HTTP Request
   ▼
Socket
   │
   ▼
ServerSocket
   │
   ▼
ThreadPool
   │
   ▼
Java Business Logic
   │
   ▼
HTTP Response
   │
   ▼
TCP
   │
   ▼
Browser
   │
   ▼
HTML Render

并能手写:

HTTP/1.1 200 OK\r\n
Content-Type: text/html; charset=UTF-8\r\n
Content-Length: ...\r\n
\r\n
<html>...</html>

同时能够回答:

HTTP 与 TCP 到底分别负责什么?

Content-TypeContent-Length、空行分别有什么意义?

Java Web 为什么最终仍然建立在网络通信之上?

那么 JavaSE 网络编程的主线已经完整打通。