HTTP 协议及 Java 套接字模拟 HTTP 客户与服务器程序

HTTP 协议及 Java 套接字模拟 HTTP 客户与服务器程序

HTTP 协议(Hypertext Transfer Protocol,超文本传输协议),顾名思义,是关于如何在网络上传输超文本(即 HTML 文档)的协议。HTTP 协议规定了 Web 的基本运作过程,以及浏览器与 Web 服务器之间的通信细节。HTTP 协议采用客户/服务器(C/S)通信模式,是目前在 Internet 上应用最广泛的通信协议之一。

如图所示,在分层的网络体系结构中,HTTP 协议位于应用层,建立在 TCP/IP 协议的基础上。HTTP 协议使用可靠的 TCP 连接,默认端口是 80 端口

image

分层 功能描述 数据单位 常见组件/协议/技术
应用层 为操作系统或网络应用程序提供网络服务的接口 - Telnet、HTTP、DNS、HTTPS、SNMP
表示层 提供数据格式转换服务(加密与解密、图片编码和解码、数据压缩和解压缩等) - URL 加密、口令加密、图片编码解码
会话层 建立端连接并提供访问验证和会话管理(session) - 服务器验证登录、断点续传
传输层 提供应用进程间的逻辑通信(建立连接、处理数据包错误、数据包次序等) 数据段(segment) TCP、UDP、SPX、进程、端口(socket)
网络层 为数据在结点之间传输创造逻辑链路、并分组转发数据(子网间路由选择等) 数据包(package) 路由器、多层交换机、防火墙、IP、IPX、RIP、OSPF
数据链路层 在通信的实体间建立数据链路连接(数据分帧、流控制、物理地址寻址、重发等) 帧(frame) 网卡、网桥、二层交换机
物理层 为数据端设备提供原始比特流的传输通路(网络通信的数据传输介质相关) 比特(bit) 中继机、集线器、网线、HUB、RJ-45 标准

HTTP 协议运作过程

HTTP 协议规定 Web 的基本运作过程基于客户/服务器通信模式,客户端主动发出 HTTP 请求,服务端接收 HTTP 请求,再返回相应的 HTTP 响应结果。客户端与服务器之间的一次信息交换包括以下过程:

  1. 客户端与服务器端建立 TCP 连接
  2. 客户端发出 HTTP 请求
  3. 服务器端发出相应的 HTTP 响应
  4. 客户端与服务器端之间的 TCP 连接关闭

image

HTTP 协议特点

基于请求-响应的模式

HTTP 协议规定,请求从客户端发出,最后服务器端响应该请求并返回。换句话说,肯定是先从客户端开始建立通信的,服务器端在没有接收到请求之前不会发送响应。

浏览器功能

  • 请求与 Web 服务器建立 TCP 连接
  • 创建并发送 HTTP 请求
  • 接收并解析 HTTP 响应
  • 在窗口中展示 HTML 文档

Web 服务器功能

  • 接收来自浏览器的 HTTP 请求
  • 接收并解析 HTTP 请求
  • 创建并发送 HTTP 响应

目前最常用的 HTTP 客户程序包括 IE、Chrome、Firefox、Opera 和 Netscape 等,最常用的 HTTP 服务器包括 IIS 和 Apache 等。

无状态保存

HTTP 是一种不保存状态,即无状态(stateless)协议。HTTP 协议自身不对请求和响应之间的通信状态进行保存。也就是说在 HTTP 这个级别,协议对于发送过的请求或响应都不做持久化处理。使用 HTTP 协议,每当有新的请求发送时,就会有对应的新响应产生。协议本身并不保留之前一切的请求或响应报文的信息。这是为了更快地处理大量事务,确保协议的可伸缩性,而特意把 HTTP 协议设计成如此简单的。

无连接

无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间,并且可以提高并发性能,不能和每个用户建立长久的连接,请求一次响应一次,服务端和客户端就中断了。

无连接有两种方式:

  • 早期的 HTTP 协议:一个请求一个响应之后,直接断开连接
  • HTTP 1.1 版本:请求响应后,保持连接几秒。若用户在这几秒内有新的请求,通过之前的连接通道收发消息;若超过几秒无新请求,则断开连接。此举可提高效率,减少短时间内建立连接的次数(建立连接耗时耗资源)

简单快速

客户向服务器请求服务时,只需传送请求方法和路径。请求方法常用的有 GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS。每种方法规定了客户与服务器联系的类型不同。

由于 HTTP 协议简单,使得 HTTP 服务器的程序规模小,因而通信速度很快。

其中 GET 与 POST 请求方式最常用,二者区别如下:

  • GET 提交的数据会放在 URL 之后,以 ? 分割 URL 和传输数据,参数之间以 & 相连(如 EditBook?name=test1&id=123456);POST 方法是把提交的数据放在 HTTP 包的请求体中
  • GET 提交的数据大小有限制(浏览器对 URL 的长度有限制),而 POST 方法提交的数据没有限制
  • GET 与 POST 请求在服务端获取请求数据方式不同

灵活

HTTP 允许传输任意类型的数据对象。正在传输的类型由 Content-Type 加以标记【MIME】

HTTP 请求头与响应头

请求头

请求头包含许多有关客户端环境和请求正文的有用信息。例如:浏览器的类型、所用的语言、请求正文的类型,以及请求正文的长度等。

请求头和请求正文之间必须以空行分割。

image

响应头

由三部分构成:

  1. HTTP 协议的版本、状态码和描述
  2. 响应头
  3. 响应正文

image

状态码解释

  • 1××:保留
  • 2××:表示请求成功地接收
  • 3××:为完成请求客户需进一步细化请求
  • 4××:客户错误(404:请求内容不存在;405 等)
  • 5××:服务器错误

用 Java 套接字创建 HTTP 客户与服务器程序

前提准备

  1. 目录结构

image

  1. static.html 内容放在 resources/pages/ 目录下,内容如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>漂浮广告案例</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            height: 200vh; /* 让页面有滚动条,展示漂浮效果 */
            background-color: #f0f0f0;
        }
        
        /* 广告容器样式 */
        #floatAd {
            position: fixed; /* 固定定位,相对于视口 */
            width: 150px;
            height: 200px;
            cursor: pointer;
            border-radius: 8px;
            overflow: hidden;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
            transition: transform 0.2s ease;
        }
        
        #floatAd img {
            width: 100%;
            height: 100%;
            object-fit: cover;
        }
        
        #floatAd:hover {
            transform: scale(1.05);
        }
    </style>
</head>
<body>
    <!-- 漂浮广告元素 -->
    <div id="floatAd">
        <img src="../photo/image.png" alt="广告图片">
    </div>

    <script>
        // 获取广告元素
        const floatAd = document.getElementById('floatAd');
        
        // 初始位置和速度
        let posX = 100;
        let posY = 100;
        let speedX = 3;
        let speedY = 2;
        
        // 设置初始位置
        floatAd.style.left = posX + 'px';
        floatAd.style.top = posY + 'px';
        
        // 动画计时器
        let animationId;
        
        // 漂浮动画函数
        function floatAnimation() {
            // 获取视口尺寸
            const windowWidth = window.innerWidth;
            const windowHeight = window.innerHeight;
            
            // 获取广告尺寸
            const adWidth = floatAd.offsetWidth;
            const adHeight = floatAd.offsetHeight;
            
            // 更新位置
            posX += speedX;
            posY += speedY;
            
            // 检测左右边界碰撞
            if (posX <= 0 || posX >= windowWidth - adWidth) {
                speedX = -speedX; // 反向
                // 确保不会超出边界
                posX = Math.max(0, Math.min(posX, windowWidth - adWidth));
            }
            
            // 检测上下边界碰撞
            if (posY <= 0 || posY >= windowHeight - adHeight) {
                speedY = -speedY; // 反向
                // 确保不会超出边界
                posY = Math.max(0, Math.min(posY, windowHeight - adHeight));
            }
            
            // 应用新位置
            floatAd.style.left = posX + 'px';
            floatAd.style.top = posY + 'px';
            
            // 继续动画
            animationId = requestAnimationFrame(floatAnimation);
        }
        
        // 开始动画
        animationId = requestAnimationFrame(floatAnimation);
        
        // 鼠标悬停停止动画
        floatAd.addEventListener('mouseenter', () => {
            cancelAnimationFrame(animationId);
        });
        
        // 鼠标离开继续动画
        floatAd.addEventListener('mouseleave', () => {
            animationId = requestAnimationFrame(floatAnimation);
        });
        
        // 窗口大小改变时重新计算
        window.addEventListener('resize', () => {
            // 确保广告不会超出新的窗口边界
            posX = Math.max(0, Math.min(posX, window.innerWidth - floatAd.offsetWidth));
            posY = Math.max(0, Math.min(posY, window.innerHeight - floatAd.offsetHeight));
            
            // 更新位置
            floatAd.style.left = posX + 'px';
            floatAd.style.top = posY + 'px';
        });
    </script>
</body>
</html>
  1. photo 目录下添加一张任意照片(命名为 image.png

Java 代码

package com.http;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executors;

/**
 * Http服务器:(主要针对静态资源)
 *  接收客户端请求
 *  处理请求,给出响应
 * @author Jing61
 */
public class HttpServer {
    public static void main(String[] args) {
        // 创建线程池处理请求,实际开发中不建议使用Executors
        var executor = Executors.newVirtualThreadPerTaskExecutor();
        // 创建一个ServerSocket,监听端口8848
        try(var server = new ServerSocket(8848)) {
            while(true) { // 一直接收等待请求
                // 接收客户端请求
                var socket = server.accept(); // 接收客户端请求,如果没有请求,则阻塞在这个位置
                // 处理请求
                executor.execute(new HttpHandler(socket));
            }
        } catch(IOException e) {
            System.out.println(e.getMessage());
        }
    }

    /**
     * 处理客户端请求
     *  1. 接收请求
     *  2. 给请求进行响应(静态资源)
     *      根据url定位静态资源(在资源resources文件中加入静态资源,例如/page/static.html)
     *      根据资源类型进行响应
     */
    public static class HttpHandler implements Runnable {
        private final Socket socket;

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

        @Override
        public void run() {
            try(var input = socket.getInputStream();
                var output = socket.getOutputStream()) { // 获取输入输出流
                /*
                 * available():在读写操作前得知数据流中有多少个字节可以读取
                 * 该方法用于网络数据读取时,容易出现错误:当使用available读取时,对方发送的数据可能还没有到达,得到的长度为0
                 */
                var size = input.available();
                while(size == 0) size = input.available(); // 等待数据到达
                // 读取请求
                byte[] buffer = new byte[size];
                input.read(buffer);

                // 将字节数组转成字符串
                var request = new String(buffer);
                System.out.println(request);

                // 请求路径
                var path = request.split("\r\n")[0].split(" ")[1];
                if(path.contains("?")) path = path.substring(0, path.indexOf("?"));

                System.out.println("请求的url:" + path);

                // 决定http响应类型
                String contentType;
                if(path.indexOf(".html") != -1 || path.indexOf(".htm") != -1)
                    contentType = "text/html; charset=UTF-8"; //  html
                else if(path.indexOf(".jpg") != -1 || path.indexOf(".jpeg") != -1)
                    contentType = "image/jpeg"; // jpg
                else if(path.indexOf(".gif") != -1)
                    contentType = "image/gif"; // gif
                else
                    contentType = "application/octet-stream"; // 其他文件

                var file = new File("http/src/main/resources" + path);
                if (file.exists()) {
                    // 文件存在,响应请求:200
                    // 响应头
                    output.write("HTTP/1.1 200 OK\r\n".getBytes());
                    // 响应正文和响应头之间有一个空行
                    output.write(("content-type: " + contentType + "\r\n\r\n").getBytes());
                    var in = new BufferedInputStream(new FileInputStream(file));
                    int len = -1;
                    var buff = new byte[1024 * 8]; // 一次读取8KB
                    while((len = in.read(buff)) != -1) output.write(buff, 0, len);
                    in.close();
                } else {
                    // 文件不存在,响应请求:404
                    output.write("HTTP/1.1 404 NOT FOUND\r\n".getBytes());
                    output.write(("content-type: text/html; charset=UTF-8\r\n\r\n").getBytes());
                    output.write("404,你所请求的资源不存在".getBytes());
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            } finally {
                // 关闭socket资源
                try {
                    socket.close();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}

运行效果

  1. 运行程序后,浏览器输入 http://localhost:8848/pages/static.html,可看到漂浮广告页面
    image

  2. 输入不存在的资源(如 http://localhost:8848/pages/404.html),显示 404 提示
    image

  3. 响应图片资源,输入 http://localhost:8848/photo/image.png,可查看图片
    image

代码解释

该程序通过 Socket 套接字 实现了一个简易的 HTTP 服务器,核心功能是接收客户端(如浏览器)的 HTTP 请求,定位并返回静态资源(HTML、图片等),下面分模块详细解释代码逻辑:

整体结构

程序包含两个核心部分:

  • 外部类 HttpServer:负责创建服务器套接字、监听端口、接收客户端连接,并通过线程池分发请求任务
  • 内部类 HttpHandler:实现 Runnable 接口,负责具体的请求处理(解析请求、定位资源、生成响应)

HttpServer 类

package com.http;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executors;

public class HttpServer {
    public static void main(String[] args) {
        // 1. 创建线程池:采用虚拟线程池(Java 19+ 特性),每个任务对应一个虚拟线程,轻量高效
        // 注意:实际开发中不建议直接使用 Executors,建议自定义线程池(如 ThreadPoolExecutor)以控制核心参数
        var executor = Executors.newVirtualThreadPerTaskExecutor();
        
        // 2. 创建 ServerSocket 并监听 8848 端口
        // try-with-resources 语法:自动关闭 ServerSocket,避免资源泄漏
        try(var server = new ServerSocket(8848)) {
            // 3. 无限循环:一直接收客户端连接(服务器常驻逻辑)
            while(true) { 
                // accept() 方法:阻塞等待客户端连接,连接成功后返回一个 Socket 对象(代表客户端与服务器的通信通道)
                var socket = server.accept(); 
                // 4. 提交请求处理任务到线程池:避免主线程阻塞,支持并发处理多个客户端请求
                executor.execute(new HttpHandler(socket));
            }
        } catch(IOException e) {
            // 捕获 ServerSocket 创建或监听时的异常(如端口被占用)
            System.out.println("服务器启动失败:" + e.getMessage());
        }
    }
    // ... 内部类 HttpHandler 省略
}

关键

  • ServerSocket:服务器端套接字,用于监听指定端口的客户端连接请求
  • accept():阻塞方法,直到有客户端连接才返回,返回的 Socket 是客户端与服务器的“通信桥梁”
  • 线程池:解决“一个连接一个线程”的资源浪费问题,虚拟线程池比传统线程池更轻量,支持更高并发

HttpHandler 类

该类是程序的核心,负责完成“接收请求 → 解析请求 → 定位资源 → 生成响应”的完整流程,代码分步骤解释如下:

构造方法与资源初始化
public static class HttpHandler implements Runnable {
    private final Socket socket; // 与客户端通信的 Socket

    // 构造方法:接收 ServerSocket.accept() 返回的 Socket 对象
    public HttpHandler(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        // try-with-resources 语法:自动关闭输入流、输出流,避免资源泄漏
        try(var input = socket.getInputStream(); // 获取客户端发送数据的输入流
            var output = socket.getOutputStream()) { // 向客户端发送数据的输出流
            // ... 核心处理逻辑
        } catch (IOException e) {
            throw new RuntimeException("请求处理失败:" + e.getMessage(), e);
        } finally {
            // 最终关闭 Socket:无论请求处理成功与否,都要释放连接资源
            try {
                socket.close();
            } catch (IOException e) {
                throw new RuntimeException("Socket 关闭失败:" + e.getMessage(), e);
            }
        }
    }
}

关键

  • Socket.getInputStream():读取客户端发送的 HTTP 请求数据(如请求行、请求头)
  • Socket.getOutputStream():向客户端写入 HTTP 响应数据(如响应行、响应头、响应体)
  • finally 块:确保 Socket 最终被关闭,避免连接泄漏
读取 HTTP 请求数据
// 读取请求数据:解决网络数据延迟问题(available() 可能先返回 0)
var size = input.available(); // 获取输入流中可读取的字节数
while(size == 0) size = input.available(); // 等待数据到达(简易处理,实际可优化为超时机制)

// 创建字节数组缓冲区,读取请求数据
byte[] buffer = new byte[size];
input.read(buffer); // 将输入流中的数据读入缓冲区

// 将字节数组转为字符串(HTTP 请求是文本协议,按默认编码解析)
var request = new String(buffer);
System.out.println("接收的 HTTP 请求:\n" + request);

关键

  • available():返回输入流中当前可读取的字节数,但网络传输存在延迟,可能先返回 0,因此需要循环等待
  • 局限性:该方法仅适用于简易场景,实际开发中建议使用 BufferedReader 按行读取,避免因数据未完全到达导致的读取不完整问题
解析请求路径(核心步骤)

HTTP 请求的第一行(请求行)格式为:请求方法 请求路径 协议版本(如 GET /pages/static.html HTTP/1.1),代码通过字符串分割提取请求路径:

// 1. 按 "\r\n" 分割请求内容,取第一行(请求行)
// 2. 按 " " 分割请求行,取第二个元素(请求路径,如 /pages/static.html)
var path = request.split("\r\n")[0].split(" ")[1];

// 处理带参数的路径(如 /pages/static.html?id=1):截取 "?" 之前的部分
if(path.contains("?")) path = path.substring(0, path.indexOf("?"));

System.out.println("解析后的请求路径:" + path);

示例

  • 原始请求行:GET /pages/static.html?id=1 HTTP/1.1
  • 分割后得到的 path/pages/static.html(去除参数部分)
确定响应内容类型(MIME 类型)

HTTP 响应需要通过 Content-Type 告诉客户端响应体的类型(如 HTML、图片),代码根据请求路径的后缀判断类型:

String contentType; // 响应的 Content-Type 字段值
if(path.indexOf(".html") != -1 || path.indexOf(".htm") != -1) {
    contentType = "text/html; charset=UTF-8"; // HTML 文件:指定 UTF-8 编码避免乱码
} else if(path.indexOf(".jpg") != -1 || path.indexOf(".jpeg") != -1) {
    contentType = "image/jpeg"; // JPG 图片
} else if(path.indexOf(".gif") != -1) {
    contentType = "image/gif"; // GIF 图片
} else {
    contentType = "application/octet-stream"; // 其他类型:按二进制流下载
}

关键

  • Content-Type:HTTP 响应头的核心字段,客户端(如浏览器)根据该字段解析响应体(如 text/html 则渲染为页面,image/jpeg 则显示为图片)
  • 编码指定:text/html; charset=UTF-8 确保 HTML 中的中文不会乱码
定位静态资源并生成响应

根据解析后的请求路径,拼接本地资源路径,判断资源是否存在,再分别生成 200 成功响应404 失败响应

资源存在(200 OK 响应)
// 拼接本地资源路径:项目中 resources 目录的绝对路径 + 请求路径
var file = new File("http/src/main/resources" + path);
if (file.exists()) {
    // 1. 写入响应行:HTTP/1.1 200 OK(协议版本 + 状态码 + 状态描述)
    output.write("HTTP/1.1 200 OK\r\n".getBytes());
    // 2. 写入响应头:Content-Type + 空行(响应头与响应体的分隔符)
    output.write(("content-type: " + contentType + "\r\n\r\n").getBytes());
    
    // 3. 读取本地文件(静态资源)并写入响应体
    var in = new BufferedInputStream(new FileInputStream(file)); // 缓冲流读取文件,提高效率
    int len = -1;
    var buff = new byte[1024 * 8]; // 8KB 缓冲区:平衡读取效率与内存占用
    while((len = in.read(buff)) != -1) { // 循环读取文件内容
        output.write(buff, 0, len); // 将读取的内容写入输出流(发送给客户端)
    }
    in.close(); // 关闭文件输入流
}

示例流程

  • 请求路径:/pages/static.html
  • 本地文件路径:http/src/main/resources/pages/static.html
  • 响应内容:先发送 HTTP/1.1 200 OKContent-Type: text/html; charset=UTF-8,再发送 HTML 文件内容
资源不存在(404 Not Found 响应)
else {
    // 1. 写入响应行:HTTP/1.1 404 NOT FOUND(客户端请求的资源不存在)
    output.write("HTTP/1.1 404 NOT FOUND\r\n".getBytes());
    // 2. 写入响应头:指定响应体为 HTML 类型(显示中文提示)
    output.write(("content-type: text/html; charset=UTF-8\r\n\r\n").getBytes());
    // 3. 写入响应体:404 错误提示文本
    output.write("404,你所请求的资源不存在".getBytes());
}

关键

  • 404 状态码:告诉客户端“请求的资源不存在”,是 HTTP 最常见的错误状态码之一
  • 响应体:直接返回中文提示,浏览器会按 Content-Type 渲染为文本
posted @ 2025-11-28 16:06  Jing61  阅读(0)  评论(0)    收藏  举报