鸿蒙系统后端开发java web 基础篇,快速学习后端

Java Web 从 Servlet 鸿蒙后端技术点
后端入门到实战完整教程(环境搭建 → 数据库 → 接口 → 拦截器 → 日志 → 事务)

适合人群:鸿蒙人员学习后端之前需要了解的有 Java web 基础(会写类、接口、集合、异常)。 学习路线:Servlet 原生开发 → 分层架构 → 综合实战 → 为后续 Spring / Spring Boot 打地基。 阅读方式建议:每一章都先看「整体步骤」,脑子里先有全局流程,再看「代码实现」,最后动手敲一遍。快速过一遍不求精通,
因为以为我们用sprint boot  并且我们都 用ai  写代码,但是这些是基础概念,建议花一周学习完以下知识点


鸿蒙后端不限与一种 语言,python    node      go    c#  都 行的,只是java 是个经典的技术,值得学习,借签后端业务思想非常值 

目录

  • 第一章:先搞清楚 Java Web 是什么?
  • 第二章:开发基础环境   环境编重要环节,(JDK + IDEA + Maven + Tomcat + MySQL)
  • 第三章:第一个 Servlet 程序(两种配置方式)快速入门
  • 第四章:Servlet 生命周期与请求响应对象
  • 第五章:请求转发、重定向与会话技术(Cookie / Session)
  • 第六章:数据库接入(JDBC → 连接池 → DAO 封装)
  • 第七章:分层架构(Controller - Service - DAO)
  • 第八章:编写接口(RESTful 风格 + JSON + 统一返回格式)
  • 第九章:过滤器 Filter 与拦截器
  • 第十章:日志系统(SLF4J + Logback)
  • 第十一章:事务(从 JDBC 手动事务到声明式事务思想)
  • 第十二章:综合实战 —— 用户管理系统(串起全部知识点)
  • 第十三章:常见问题排查清单与后续学习路线

第一章:先搞清楚 Java Web 到底是怎么回事,为鸿蒙后端打好基础

1.1 先用大白话建立全局图景

学 Java Web 之前,先不要碰代码,先搞清楚这 5 个问题:
问题 1:Web 程序和普通 Java 程序有什么区别?
普通 Java 程序:你双击运行,main 方法一跑,程序在你自己电脑上执行。
Web 程序:程序跑在一台服务器上,用户通过浏览器(或手机 App)通过网络访问它。整个过程是:
plain
 
用户浏览器  ---发送HTTP请求--->  服务器(Tomcat + 你的Java代码)  ---查数据--->  数据库(MySQL)
用户浏览器  <---返回HTTP响应---  服务器                          <---返回数据---  数据库
问题 2:HTTP 请求到底是什么?
你在浏览器输入 http://localhost:8080/user/list,浏览器其实发了一段纯文本给服务器,长这样:
plain
 
GET /user/list HTTP/1.1
Host: localhost:8080
Cookie: JSESSIONID=abc123
服务器回给你的也是纯文本(HTML 或 JSON)。HTTP 协议就是浏览器和服务器之间约定的"书信格式",没有魔法。
问题 3:Tomcat 是干什么的?
Tomcat 是一个 Web 服务器(Servlet 容器)。它的工作:
  1. 监听端口(比如 8080),接收浏览器发来的 HTTP 请求;
  2. 把请求"翻译"成 Java 对象(HttpServletRequest);
  3. 找到你写的 Servlet,调用它;
  4. 把你返回的结果(HttpServletResponse)翻译成 HTTP 响应文本,发回浏览器。
一句话:Tomcat 负责网络通信,你负责业务逻辑,Servlet 是你们之间的接口标准。
问题 4:Servlet 是干什么的?
Servlet 是 Java 官方定的一套规范(接口):凡是想处理 Web 请求的 Java 类,都必须实现这套规范。你写的类继承 HttpServlet,重写 doGet / doPost 方法,Tomcat 就会在请求到达时调用你的方法。
问题 5:为什么要学原生 Servlet?现在不都用 Spring Boot 吗?
因为 Spring Boot 的 Controller,底层就是 Servlet。你学的 @RestController、@GetMapping、拦截器、过滤器,全部建立在 Servlet 之上。先学 Servlet,后面学 Spring 你会有一种"原来它只是帮我做了自动化"的通透感;直接学 Spring,遇到底层问题(乱码、拦截顺序、转发重定向差异)就会蒙。

1.2 本教程的整体技术栈

表格
 
 
层次技术作用
开发工具 IDEA + Maven 写代码、管理依赖
Web 服务器 Tomcat 9 接收请求、运行 Servlet
Web 规范 Servlet 4.0(javax 包) 处理请求的标准接口
数据库 MySQL 8 存数据
数据库连接 JDBC + Druid 连接池 Java 操作数据库
JSON 处理 Jackson Java 对象 ↔ JSON 字符串
日志 SLF4J + Logback 记录程序运行过程
构建运行 war 包部署到 Tomcat 项目打包方式
注意:本教程使用 javax.servlet(Tomcat 9 及以前)。Tomcat 10 改成了 jakarta.servlet,包名不同但用法几乎一样,初学建议用 Tomcat 9,教程和资料最多。

第二章:开发环境搭建(JDK + IDEA + Maven + Tomcat + MySQL)

2.1 整体步骤

plain
 
第1步:安装 JDK 8(或 11/17),配置 JAVA_HOME 环境变量
第2步:安装 IDEA(社区版够用,旗舰版对 Web 支持更好)
第3步:安装/配置 Maven(IDEA 自带也行,建议自己装一个并换阿里云镜像)
第4步:下载 Tomcat 9 解压版,配置到 IDEA 里
第5步:安装 MySQL 8(或用 Docker 起一个),装好客户端工具(Navicat / DataGrip / 命令行均可)
第6步:逐一验证:java -version、mvn -version、启动 Tomcat 访问 8080、连上 MySQL

2.2 第一步:安装 JDK

  1. 下载 JDK:Oracle 官网、Adoptium(推荐,免费)均可,选 JDK 8 或 17 的 LTS 版本。
  2. 安装后配置环境变量(Windows):
    • JAVA_HOME = C:\Program Files\Java\jdk1.8.0_xxx
    • Path 追加 %JAVA_HOME%\bin
  3. 验证:打开命令行输入:
bash
 
java -version
javac -version
能显示版本号即成功。

2.3 第二步:安装与配置 Maven

Maven 的作用一句话:自动帮你下载 jar 包(依赖),不用手动去网上找 jar 复制进项目。
  1. 官网下载 Maven 3.8+,解压到无中文无空格路径,如 D:\dev\apache-maven-3.9.6。
  2. 配置环境变量 MAVEN_HOME,Path 追加 %MAVEN_HOME%\bin。
  3. 换阿里云镜像(不换的话下载依赖慢到怀疑人生):编辑 conf/settings.xml,在 <mirrors> 节点内加入:
xml
 
<mirror>
    <id>aliyunmaven</id>
    <mirrorOf>*</mirrorOf>
    <name>阿里云公共仓库</name>
    <url>https://maven.aliyun.com/repository/public</url>
</mirror>
  1. 设置本地仓库位置(同文件内):
xml
 
<localRepository>D:\dev\maven-repo</localRepository>
  1. 在 IDEA 里配置:File → Settings → Build Tools → Maven,指定 Maven home path 和 settings.xml。

2.4 第三步:安装 Tomcat 9

  1. 官网 tomcat.apache.org 下载 Tomcat 9 的 zip 版(Core → 64-bit Windows zip,Linux 选 tar.gz)。
  2. 解压到无中文路径,如 D:\dev\apache-tomcat-9.0.xx。
  3. 目录结构认识一下:
plain
 
apache-tomcat-9/
├── bin/        启动脚本(startup.bat / startup.sh)
├── conf/       配置文件(server.xml 改端口在这)
├── lib/        Tomcat 自己的 jar 包
├── logs/       日志
├── webapps/    ★ 你的项目最终就放在这里,一个文件夹就是一个网站
└── work/       JSP 编译后的临时文件
  1. 验证:运行 bin/startup.bat(Windows)或 bin/startup.sh(Linux/Mac),浏览器访问 http://localhost:8080,看到 Tomcat 首页即成功。
  2. IDEA 配置 Tomcat:Run → Edit Configurations → + → Tomcat Server → Local,指定 Tomcat 解压目录。(社区版 IDEA 没有内置 Tomcat 集成,可用 Smart Tomcat 插件,或手动把 war 包复制到 webapps 目录启动,效果一样。)

2.5 第四步:安装 MySQL 并建库

  1. 安装 MySQL 8(安装过程略,记得 root 密码)。
  2. 用客户端连上,创建本教程要用的数据库和表:
sql
 
-- 创建数据库,指定 utf8mb4 避免中文乱码
CREATE DATABASE web_demo DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;

USE web_demo;

-- 用户表:本教程全程围绕这张表演练
CREATE TABLE t_user (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
    username    VARCHAR(50)  NOT NULL UNIQUE      COMMENT '用户名',
    password    VARCHAR(100) NOT NULL             COMMENT '密码',
    nickname    VARCHAR(50)                       COMMENT '昵称',
    age         INT                                 COMMENT '年龄',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE = InnoDB COMMENT '用户表';

-- 插入两条测试数据
INSERT INTO t_user (username, password, nickname, age) VALUES
('zhangsan', '123456', '张三', 25),
('lisi',     '123456', '李四', 30);

2.6 环境搭建验收清单

表格
 
 
检查项命令/操作期望结果
JDK java -version 显示 1.8 或 17
Maven mvn -version 显示 3.x
Tomcat 启动后访问 localhost:8080 看到 Tomcat 猫页面
MySQL 客户端连接执行 SELECT * FROM t_user; 查出 2 条数据
四项全绿,环境搭建完成,进入编码阶段。

第三章:第一个 Servlet 程序(两种配置方式)

3.1 整体步骤

plain
 
第1步:用 Maven 创建 webapp 骨架项目(war 包结构)
第2步:在 pom.xml 引入 servlet-api 依赖
第3步:写一个类继承 HttpServlet,重写 doGet 方法
第4步:告诉 Tomcat「哪个 URL 交给哪个 Servlet 处理」——两种做法:
        方式A:web.xml 里配置(传统方式,便于理解原理)
        方式B:@WebServlet 注解(Servlet 3.0+,现在主流)
第5步:IDEA 里把项目部署到 Tomcat,启动
第6步:浏览器访问对应 URL,看到输出即成功

3.2 第一步:创建 Maven Web 项目

IDEA:File → New → Project → Maven Archetype,选择 maven-archetype-webapp。
创建后的标准目录结构(缺什么文件夹就手动建):
plain
 
web-demo/
├── pom.xml                          ← Maven 配置文件(依赖都在这加)
└── src/
    └── main/
        ├── java/                    ← Java 源码
        │   └── com/example/web/
        ├── resources/               ← 配置文件(db.properties、logback.xml 放这)
        └── webapp/                  ← Web 资源根目录
            ├── WEB-INF/
            │   └── web.xml          ← Web 应用配置文件
            └── index.jsp

3.3 第二步:pom.xml 引入依赖

xml
 
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>web-demo</artifactId>
    <version>1.0-SNAPSHOT</version>
    <!-- ★ 打包方式为 war:Web 项目的标准打包格式,可被 Tomcat 识别 -->
    <packaging>war</packaging>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- Servlet API:只写代码时用,运行由 Tomcat 提供,所以 scope 是 provided -->
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>4.0.1</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
</project>
scope=provided 的含义:编译时需要这个包,但打包时不打进去,因为 Tomcat 自己带了 servlet-api。如果打进去会和 Tomcat 的包冲突报错,这是新手常见坑。

3.4 第三步:写第一个 Servlet

java
 
package com.example.web.servlet;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

/**
 * 我的第一个 Servlet
 * 核心理解:Servlet 就是一个"被 Tomcat 回调的普通 Java 类"。
 * 浏览器发 GET 请求 → Tomcat 调用 doGet()
 * 浏览器发 POST 请求 → Tomcat 调用 doPost()
 */
public class HelloServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // 1. 设置响应内容类型和编码(不写这行中文必乱码!)
        resp.setContentType("text/html;charset=UTF-8");

        // 2. 从请求里取参数:浏览器访问 /hello?name=张三 时,能拿到 "张三"
        String name = req.getParameter("name");
        if (name == null || name.isEmpty()) {
            name = "陌生人";
        }

        // 3. 通过响应对象拿到"输出流",往浏览器写内容
        PrintWriter out = resp.getWriter();
        out.println("<h1>Hello, " + name + "!这是我的第一个 Servlet</h1>");
    }
}

3.5 第四步(方式 A):web.xml 配置(理解原理必看)

编辑 webapp/WEB-INF/web.xml:
xml
 
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                             http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">

    <!-- 第一步:给 Servlet 起个名字,并指向类的全路径 -->
    <servlet>
        <servlet-name>helloServlet</servlet-name>
        <servlet-class>com.example.web.servlet.HelloServlet</servlet-class>
    </servlet>

    <!-- 第二步:把 URL 和这个 Servlet 绑定(映射) -->
    <servlet-mapping>
        <servlet-name>helloServlet</servlet-name>
        <!-- 浏览器访问 http://localhost:8080/web_demo/hello 就会触发 HelloServlet -->
        <url-pattern>/hello</url-pattern>
    </servlet-mapping>

</web-app>
web.xml 配置的本质:就是一张「URL → Servlet 类」的对照表。Tomcat 启动时读这张表,请求来了按表找人。

3.6 第四步(方式 B):@WebServlet 注解(实际开发用这个)

注解方式不需要改 web.xml,直接在类头上标注:
java
 
import javax.servlet.annotation.WebServlet;

// 一个注解搞定 web.xml 里的两段配置
// urlPatterns 支持多个路径:@WebServlet(urlPatterns = {"/hello", "/hi"})
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
    // ...代码不变
}
原理:Tomcat 启动时会扫描所有类,发现 @WebServlet 注解就自动注册映射。两种配置方式二选一,不要同时配,会冲突。

3.7 第五步:部署运行并验证

  1. IDEA 的 Tomcat 配置里,Deployment 选项卡添加 web-demo:war exploded。
  2. 注意 Application context(应用上下文路径),建议改成 /web_demo,它决定了访问 URL 的前缀。
  3. 启动 Tomcat,浏览器访问:
plain
 
http://localhost:8080/web_demo/hello?name=小明
页面显示「Hello, 小明!这是我的第一个 Servlet」即成功。
至此你完成了 Web 开发的最小闭环:浏览器 → HTTP → Tomcat → 你的 Java 代码 → 浏览器。后面所有内容都是在这个闭环上叠加能力。

第四章:Servlet 生命周期与请求响应对象

4.1 整体步骤(本章要建立的认知)

plain
 
第1步:理解 Servlet 的生命周期:创建 → 初始化 → 反复服务 → 销毁
        (关键结论:一个 Servlet 只有一个实例,多线程共享,别在类里放可变成员变量!)
第2步:吃透 HttpServletRequest:拿参数、拿请求头、拿请求体
第3步:吃透 HttpServletResponse:写内容、跳转、设状态码和响应头
第4步:解决一个必踩的坑:POST 请求中文乱码
第5步:理解 GET 和 POST 的区别及 doGet/doPost/service 的关系

4.2 Servlet 生命周期(面试高频)

java
 
@WebServlet("/life")
public class LifeServlet extends HttpServlet {

    // 第1阶段:构造方法 —— Tomcat 通过反射创建实例,整个应用运行期间只执行 1 次
    public LifeServlet() {
        System.out.println("1. 构造方法:Servlet 被创建");
    }

    // 第2阶段:init 初始化 —— 创建后立即执行,只执行 1 次
    // 适合做"启动时一次性"的事:读配置、建连接池
    @Override
    public void init() {
        System.out.println("2. init:初始化完成");
    }

    // 第3阶段:service 服务 —— 每次请求都会执行,N 个请求执行 N 次
    // service 内部会根据请求方式分发到 doGet/doPost
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        System.out.println("3. doGet:正在处理请求,当前线程 = "
                + Thread.currentThread().getName());
    }

    // 第4阶段:destroy 销毁 —— Tomcat 关闭时执行 1 次
    // 适合做资源释放:关连接池
    @Override
    public void destroy() {
        System.out.println("4. destroy:Servlet 被销毁");
    }
}
极其重要的推论:Servlet 是单例多线程的!100 个用户同时访问,Tomcat 用 100 个线程调用同一个 Servlet 对象。
❌ 错误写法(线程不安全):把"当前登录用户"存在 Servlet 的成员变量里,A 用户的数据会被 B 用户覆盖。 ✅ 正确写法:请求相关的数据一律放方法局部变量或 request/session 域对象里。

4.3 HttpServletRequest 核心用法(请求的所有信息都在这)

java
 
@WebServlet("/requestDemo")
public class RequestDemoServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        PrintWriter out = resp.getWriter();

        // ===== 一、获取请求参数(最常用,没有之一)=====
        // URL: /requestDemo?username=zhangsan&hobby=篮球&hobby=足球
        String username = req.getParameter("username");        // 单个值
        String[] hobbies = req.getParameterValues("hobby");     // 同名多值(复选框)

        // ===== 二、获取请求行信息 =====
        out.println("请求方式:" + req.getMethod());            // GET
        out.println("请求URI:" + req.getRequestURI());         // /web_demo/requestDemo
        out.println("完整URL:" + req.getRequestURL());         // http://localhost:8080/web_demo/requestDemo
        out.println("查询字符串:" + req.getQueryString());     // username=zhangsan&hobby=篮球...
        out.println("客户端IP:" + req.getRemoteAddr());        // 127.0.0.1

        // ===== 三、获取请求头 =====
        out.println("浏览器类型:" + req.getHeader("User-Agent"));
        out.println("来源页面:" + req.getHeader("Referer"));   // 可做防盗链判断
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // POST 表单参数同样用 getParameter 获取,Tomcat 会帮你解析请求体
        String username = req.getParameter("username");
        resp.getWriter().println("POST 收到:" + username);
    }
}

4.4 HttpServletResponse 核心用法

java
 
@WebServlet("/responseDemo")
public class ResponseDemoServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // ===== 一、输出字符内容(最常用)=====
        resp.setContentType("text/html;charset=UTF-8");
        resp.getWriter().println("<h1>你好</h1>");

        // ===== 二、输出字节内容(文件下载场景)=====
        // resp.getOutputStream().write(字节数组);

        // ===== 三、设置状态码和响应头 =====
        // resp.setStatus(404);
        // resp.setHeader("X-Token", "abc123");

        // ===== 四、重定向:让浏览器自己去访问另一个地址 =====
        // resp.sendRedirect(req.getContextPath() + "/hello");
    }
}

4.5 POST 中文乱码(新手第一大坑)

现象:表单提交中文,后台 getParameter 拿到的是 å¼ ä¸ 这种乱码。
原因:Tomcat 默认用 ISO-8859-1 解码请求体。
解决:在获取任何参数之前,设置请求体解码方式:
java
 
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    // ★ 必须在第一行!必须在 getParameter 之前调用才有效
    req.setCharacterEncoding("UTF-8");
    // 只对 POST 请求体有效;GET 参数乱码需改 Tomcat 的 server.xml(Tomcat 8+ 默认已 UTF-8)

    String username = req.getParameter("username");
    resp.setContentType("text/plain;charset=UTF-8");
    resp.getWriter().println("收到:" + username);
}
后面第九章会教你用过滤器统一处理编码,不用每个 Servlet 都写这行。

4.6 GET 和 POST 到底有什么区别

表格
 
 
维度GETPOST
参数位置 拼在 URL 后面 ?a=1&b=2 放在请求体里
用途约定 查询/获取数据 提交/修改数据
参数长度 受 URL 长度限制 理论上无限制
安全性 参数暴露在地址栏 相对隐蔽(但不是加密!)
典型场景 搜索、分页查询 登录、注册、表单提交
实际开发中 RESTful 风格接口(第八章)还会用到 PUT(修改)和 DELETE(删除),对应重写 doPut 和 doDelete。

第五章:请求转发、重定向与会话技术(Cookie / Session)

5.1 整体步骤

plain
 
第1步:分清两个跳转:请求转发(服务器内部转手) vs 重定向(让浏览器重新发请求)
第2步:理解三个"域对象":request / session / servletContext(各自的作用范围)
第3步:Cookie 实战:服务器往浏览器存一小段数据(如"记住我")
第4步:Session 实战:登录状态保持的标准做法
第5步:搞懂 Session 的底层原理:其实靠一个叫 JSESSIONID 的 Cookie

5.2 请求转发 vs 重定向(必考必用)

java
 
// ============ 方式一:请求转发(Forward)============
// 特点:浏览器只发了 1 次请求,地址栏不变,是服务器内部把请求转给另一个资源处理
req.getRequestDispatcher("/hello").forward(req, resp);

// ============ 方式二:重定向(Redirect)============
// 特点:服务器回一个 302,浏览器收到后自己再发 1 次新请求,地址栏会变
resp.sendRedirect(req.getContextPath() + "/hello");
怎么选?一张表搞定:
表格
 
 
对比点请求转发 forward重定向 redirect
请求次数 1 次 2 次
地址栏 不变 变
数据共享 共享同一个 request,可用 req.setAttribute 传值 不共享,request 数据丢失
路径写法 写服务器内部路径,不带项目名 要写完整路径,带 req.getContextPath()
典型场景 Servlet 处理完转发给 JSP 渲染页面 登录成功后跳首页;防止表单重复提交

5.3 三个域对象(存数据的"三个柜子")

java
 
// 柜子1:request 域 —— 只在"这一次请求"内有效(转发时可携带)
req.setAttribute("msg", "一次请求内的数据");
Object msg = req.getAttribute("msg");

// 柜子2:session 域 —— 同一个用户(浏览器)的多次请求之间有效
req.getSession().setAttribute("loginUser", user);

// 柜子3:servletContext 域(应用域)—— 整个项目共享,所有用户共用
getServletContext().setAttribute("onlineCount", 100);
记忆口诀:request 管一次,session 管一人,context 管全局。

5.4 Cookie 实战

Cookie = 服务器让浏览器帮忙存的一小条数据,浏览器以后每次请求都自动带上它。
java
 
@WebServlet("/cookieDemo")
public class CookieDemoServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");

        // ===== 写 Cookie:让浏览器记住上次访问时间 =====
        Cookie cookie = new Cookie("lastVisit", String.valueOf(System.currentTimeMillis()));
        cookie.setMaxAge(60 * 60 * 24 * 7);  // 存活 7 天(秒);不设置则浏览器关闭就消失
        cookie.setPath("/");                  // 整个项目都携带这个 Cookie
        resp.addCookie(cookie);

        // ===== 读 Cookie:浏览器每次请求自动带上 =====
        Cookie[] cookies = req.getCookies();
        if (cookies != null) {
            for (Cookie c : cookies) {
                if ("lastVisit".equals(c.getName())) {
                    resp.getWriter().println("你上次访问时间戳:" + c.getValue());
                }
            }
        }
    }
}

5.5 Session 实战:登录状态保持(重中之重)

业务场景:用户登录成功后,访问任何页面都知道"他是谁";没登录就踢回登录页。
java
 
// ============ 第一步:登录接口,登录成功把用户存进 Session ============
@WebServlet("/login")
public class LoginServlet extends HttpServlet {

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        req.setCharacterEncoding("UTF-8");
        resp.setContentType("text/plain;charset=UTF-8");

        String username = req.getParameter("username");
        String password = req.getParameter("password");

        // 实际项目这里要查数据库验证(第六章讲),这里先写死演示
        if ("admin".equals(username) && "123456".equals(password)) {
            // ★ 核心:把登录用户放进 session,表示"这个人登录过了"
            req.getSession().setAttribute("loginUser", username);
            resp.getWriter().println("登录成功");
        } else {
            resp.getWriter().println("用户名或密码错误");
        }
    }
}

// ============ 第二步:需要登录才能访问的页面,先检查 Session ============
@WebServlet("/profile")
public class ProfileServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");

        // 从 session 里取登录用户
        Object loginUser = req.getSession().getAttribute("loginUser");
        if (loginUser == null) {
            // 没登录,重定向到登录页
            resp.sendRedirect(req.getContextPath() + "/login.html");
            return;
        }
        resp.getWriter().println("欢迎你," + loginUser);
    }
}

// ============ 第三步:退出登录,销毁 Session ============
@WebServlet("/logout")
public class LogoutServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        req.getSession().invalidate();  // 销毁 session
        resp.sendRedirect(req.getContextPath() + "/login.html");
    }
}
Session 底层原理(大白话):
plain
 
1. 浏览器第一次访问,服务器创建 Session 对象(存在服务器内存),生成唯一编号 JSESSIONID=ABC123
2. 服务器通过 Cookie 把 ABC123 发给浏览器存起来
3. 浏览器以后每次请求都自动带上 Cookie: JSESSIONID=ABC123
4. 服务器一看编号,就知道"哦,还是上次那个人",取出对应的 Session 对象
所以 Session 是服务端存储,Cookie 里只存了个"门牌号",比把用户数据直接放 Cookie 安全。

第六章:数据库接入(JDBC → 连接池 → DAO 封装)

6.1 整体步骤

plain
 
第1步:pom.xml 引入 MySQL 驱动和 Druid 连接池依赖
第2步:裸 JDBC 走一遍完整流程(加载驱动→连接→SQL→结果集→关闭),理解原理
第3步:理解为什么需要连接池(每次新建连接太慢),引入 Druid
第4步:把连接池封装成工具类 JdbcUtils
第5步:按 DAO 模式封装:实体类 User + 数据访问类 UserDao,业务代码只调用方法

6.2 第一步:引入依赖(pom.xml 追加)

xml
 
<!-- MySQL 驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.33</version>
</dependency>

<!-- 阿里 Druid 连接池 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.2.20</version>
</dependency>

6.3 第二步:裸 JDBC 完整流程(理解原理,实际不这么写)

JDBC(Java Database Connectivity)是 Java 操作数据库的官方标准 API,任何数据库框架底层都是它。
java
 
public class JdbcRawDemo {

    public static void main(String[] args) throws Exception {
        // 第1步:加载驱动(JDBC 4.0+ 可省略,驱动自动注册)
        // Class.forName("com.mysql.cj.jdbc.Driver");

        // 第2步:获取连接(url、用户名、密码)
        String url = "jdbc:mysql://localhost:3306/web_demo"
                   + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8";
        Connection conn = DriverManager.getConnection(url, "root", "你的密码");

        // 第3步:创建预编译 SQL 对象 —— ★ 永远用 PreparedStatement,不要用字符串拼接 SQL!
        // ? 是占位符,能有效防止 SQL 注入攻击
        String sql = "SELECT id, username, nickname, age FROM t_user WHERE age > ?";
        PreparedStatement ps = conn.prepareStatement(sql);
        ps.setInt(1, 20);   // 给第 1 个 ? 赋值

        // 第4步:执行查询,拿到结果集
        ResultSet rs = ps.executeQuery();

        // 第5步:遍历结果集(一行一行读)
        while (rs.next()) {
            Long id = rs.getLong("id");
            String username = rs.getString("username");
            String nickname = rs.getString("nickname");
            Integer age = rs.getInt("age");
            System.out.println(id + " " + username + " " + nickname + " " + age);
        }

        // 第6步:关闭资源(后开的先关)—— 忘关会连接泄漏!
        rs.close();
        ps.close();
        conn.close();
    }
}
增删改和查询的区别:查询用 executeQuery() 返回 ResultSet;增删改用 executeUpdate() 返回受影响行数。
java
 
// 插入示例
String sql = "INSERT INTO t_user (username, password, nickname, age) VALUES (?, ?, ?, ?)";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, "wangwu");
ps.setString(2, "123456");
ps.setString(3, "王五");
ps.setInt(4, 28);
int rows = ps.executeUpdate();   // 返回插入了几行
System.out.println("插入成功,影响行数:" + rows);
SQL 注入警示(必讲):"SELECT * FROM t_user WHERE username = '" + username + "'" 这种拼字符串写法,用户输入 ' OR '1'='1 就能绕过登录验证。PreparedStatement 的 ? 占位符由驱动负责转义,从根上杜绝注入。

6.4 第三步:为什么需要连接池

裸 JDBC 的问题:每次操作数据库都要新建 TCP 连接 + 认证,一次要几十毫秒,高并发下完全不可接受。
连接池的思路:启动时先建好 10 个连接放在池子里备用,谁要用就借一个,用完还回去(不是真关闭),循环复用。
plain
 
不用连接池:每次请求 → 新建连接(慢) → 用 → 真关闭
用连接池:  每次请求 → 从池子借(快) → 用 → 归还池子

6.5 第四步:封装 Druid 工具类

先在 resources 下创建 druid.properties(配置外置,改配置不用改代码):
properties
 
# 数据库连接四要素
url=jdbc:mysql://localhost:3306/web_demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
username=root
password=你的密码
driverClassName=com.mysql.cj.jdbc.Driver

# 连接池参数
initialSize=5        # 启动时先建 5 个连接
maxActive=20         # 池子里最多 20 个连接
maxWait=3000         # 借不到连接最多等 3 秒,超时抛异常
工具类 com/example/web/util/JdbcUtils.java:
java
 
package com.example.web.util;

import com.alibaba.druid.pool.DruidDataSourceFactory;

import javax.sql.DataSource;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Properties;

/**
 * JDBC 工具类:全局唯一的数据源(连接池)
 * 用法:Connection conn = JdbcUtils.getConnection();
 *      用完 conn.close() 即可(Druid 会拦截 close,实际是"归还"到池子)
 */
public class JdbcUtils {

    // 静态数据源:整个应用只有一个连接池(对应 Servlet 单例思想)
    private static DataSource dataSource;

    // 静态代码块:类加载时执行一次,完成连接池初始化
    static {
        try {
            // 1. 读取 classpath 下的配置文件
            Properties props = new Properties();
            InputStream in = JdbcUtils.class.getClassLoader()
                    .getResourceAsStream("druid.properties");
            props.load(in);

            // 2. 用 Druid 工厂创建连接池
            dataSource = DruidDataSourceFactory.createDataSource(props);
            System.out.println("Druid 连接池初始化成功");
        } catch (Exception e) {
            // 初始化失败直接抛启动异常,让问题尽早暴露
            throw new ExceptionInInitializerError("连接池初始化失败:" + e.getMessage());
        }
    }

    /** 从池子借一个连接 */
    public static Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }

    /** 获取数据源本身(后面框架整合会用) */
    public static DataSource getDataSource() {
        return dataSource;
    }
}

6.6 第五步:DAO 模式封装

DAO(Data Access Object)模式:把所有 SQL 集中到一个类里,业务代码只调方法、不碰 SQL。
先建实体类(和数据库表一一对应)com/example/web/entity/User.java:
java
 
package com.example.web.entity;

import java.time.LocalDateTime;

/**
 * 用户实体类:t_user 表的一行数据 ↔ 一个 User 对象
 * 这种对应关系叫 ORM(对象关系映射)的雏形
 */
public class User {

    private Long id;
    private String username;
    private String password;
    private String nickname;
    private Integer age;
    private LocalDateTime createTime;

    // 构造方法、getter/setter、toString 省略(IDEA 快捷键 Alt+Insert 生成)
    // 实际写的时候必须生成全,这里为节省篇幅省略
}
数据访问类 com/example/web/dao/UserDao.java:
java
 
package com.example.web.dao;

import com.example.web.entity.User;
import com.example.web.util.JdbcUtils;

import java.sql.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

/**
 * 用户表的数据访问对象:所有 t_user 表的 SQL 都写在这一个类里
 * 好处:SQL 集中管理,业务代码与数据库细节解耦
 */
public class UserDao {

    /** 查询所有用户 */
    public List<User> findAll() {
        List<User> list = new ArrayList<>();
        String sql = "SELECT id, username, password, nickname, age, create_time FROM t_user";

        // try-with-resources 语法:括号里的资源用完自动关闭,不用手写 close
        try (Connection conn = JdbcUtils.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql);
             ResultSet rs = ps.executeQuery()) {

            while (rs.next()) {
                list.add(mapRow(rs));   // 每行结果 → 一个 User 对象
            }
        } catch (SQLException e) {
            throw new RuntimeException("查询用户列表失败", e);
        }
        return list;
    }

    /** 根据 id 查询单个用户,查不到返回 null */
    public User findById(Long id) {
        String sql = "SELECT id, username, password, nickname, age, create_time "
                   + "FROM t_user WHERE id = ?";
        try (Connection conn = JdbcUtils.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {

            ps.setLong(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return mapRow(rs);
                }
            }
        } catch (SQLException e) {
            throw new RuntimeException("查询用户失败,id=" + id, e);
        }
        return null;
    }

    /** 新增用户,返回生成的主键 id */
    public Long insert(User user) {
        String sql = "INSERT INTO t_user (username, password, nickname, age) VALUES (?, ?, ?, ?)";
        try (Connection conn = JdbcUtils.getConnection();
             // RETURN_GENERATED_KEYS:让数据库告诉我们自增主键是多少
             PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {

            ps.setString(1, user.getUsername());
            ps.setString(2, user.getPassword());
            ps.setString(3, user.getNickname());
            ps.setInt(4, user.getAge());
            ps.executeUpdate();

            // 取回自增 id
            try (ResultSet keys = ps.getGeneratedKeys()) {
                if (keys.next()) {
                    return keys.getLong(1);
                }
            }
        } catch (SQLException e) {
            throw new RuntimeException("新增用户失败", e);
        }
        return null;
    }

    /** 修改用户 */
    public int update(User user) {
        String sql = "UPDATE t_user SET nickname = ?, age = ? WHERE id = ?";
        try (Connection conn = JdbcUtils.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {

            ps.setString(1, user.getNickname());
            ps.setInt(2, user.getAge());
            ps.setLong(3, user.getId());
            return ps.executeUpdate();   // 返回影响行数:1 成功,0 说明 id 不存在
        } catch (SQLException e) {
            throw new RuntimeException("修改用户失败", e);
        }
    }

    /** 删除用户 */
    public int deleteById(Long id) {
        String sql = "DELETE FROM t_user WHERE id = ?";
        try (Connection conn = JdbcUtils.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {

            ps.setLong(1, id);
            return ps.executeUpdate();
        } catch (SQLException e) {
            throw new RuntimeException("删除用户失败,id=" + id, e);
        }
    }

    /** 结果集一行 → User 对象(抽取公共方法,避免五处重复代码) */
    private User mapRow(ResultSet rs) throws SQLException {
        User user = new User();
        user.setId(rs.getLong("id"));
        user.setUsername(rs.getString("username"));
        user.setPassword(rs.getString("password"));
        user.setNickname(rs.getString("nickname"));
        user.setAge(rs.getInt("age"));
        Timestamp ts = rs.getTimestamp("create_time");
        user.setCreateTime(ts == null ? null : ts.toLocalDateTime());
        return user;
    }
}
到这一步你已经能看出:手写 JDBC 非常啰嗦(每个方法都是 try/连接/SQL/遍历模板代码)。这就是 MyBatis 存在的意义——它帮你把这套模板自动化,你只写 SQL 和接口。学完本教程后 MyBatis 是下一站。

第七章:分层架构(Controller - Service - DAO)

7.1 整体步骤(先建立思想)

plain
 
第1步:理解为什么不能在 Servlet 里直接写 SQL(代码一锅粥,没法维护)
第2步:建立三层架构心智模型:
        Web 层(Servlet/Controller):接收请求、校验参数、返回响应 —— 服务员
        Service 层:业务逻辑、事务控制                            —— 厨师
        DAO 层:纯数据库操作                                     —— 采购员
第3步:明确调用方向:Web → Service → DAO,禁止跨层、禁止反向调用
第4步:动手把第六章的 UserDao 套上 Service 和 Servlet,形成完整调用链

7.2 三层各自管什么(大白话)

用餐厅类比:
  • 服务员(Web 层):接待顾客(接收请求)、看懂菜单(解析参数)、端菜上桌(返回响应)。服务员不进厨房。
  • 厨师(Service 层):真正干活的地方。一道菜需要多种食材配合(一次业务可能涉及多张表多次 SQL),厨师统筹。咸淡火候(业务规则)都在这层。
  • 采购员(DAO 层):只负责和仓库(数据库)打交道,拿食材、存食材。
为什么要分层? 假设你把 SQL 直接写在 Servlet 里:
  • 改个字段,要翻遍几十个 Servlet;
  • 同一个"查用户"逻辑在 5 个 Servlet 里复制粘贴了 5 份;
  • 想加事务,无从下手(事务要包住多个 SQL,必须有个统一的业务层)。

7.3 项目包结构

plain
 
com.example.web
├── servlet/      Web 层:Servlet 们
├── service/      Service 层:业务逻辑
│   ├── UserService.java        (接口)
│   └── impl/UserServiceImpl.java(实现类)
├── dao/          DAO 层:数据库操作
├── entity/       实体类
└── util/         工具类

7.4 Service 层代码实现

接口 com/example/web/service/UserService.java:
java
 
package com.example.web.service;

import com.example.web.entity.User;
import java.util.List;

/**
 * 用户业务接口:定义"能做什么",不关心怎么做
 * 面向接口编程的好处:以后换实现(比如从 JDBC 换 MyBatis),调用方代码一行不用改
 */
public interface UserService {

    List<User> listUsers();

    User getUser(Long id);

    /** 注册用户:业务规则在这层,比如用户名不能重复 */
    Long register(User user);

    boolean updateUser(User user);

    boolean deleteUser(Long id);
}
实现类 com/example/web/service/impl/UserServiceImpl.java:
java
 
package com.example.web.service.impl;

import com.example.web.dao.UserDao;
import com.example.web.entity.User;
import com.example.web.service.UserService;

import java.util.List;

/**
 * 用户业务实现:写业务规则,调用 DAO 完成数据操作
 */
public class UserServiceImpl implements UserService {

    // Service 持有 DAO(后面学 Spring,这行会变成 @Autowired 自动注入)
    private final UserDao userDao = new UserDao();

    @Override
    public List<User> listUsers() {
        // 简单查询业务层没有额外逻辑,直接透传(真实项目可能有权限过滤、缓存等)
        return userDao.findAll();
    }

    @Override
    public User getUser(Long id) {
        // 业务规则示例:参数合法性校验放 Service 层
        if (id == null || id <= 0) {
            throw new IllegalArgumentException("用户 id 不合法");
        }
        return userDao.findById(id);
    }

    @Override
    public Long register(User user) {
        // 业务规则:用户名不能为空
        if (user.getUsername() == null || user.getUsername().trim().isEmpty()) {
            throw new IllegalArgumentException("用户名不能为空");
        }
        // 业务规则:真实项目这里还会做密码加密(如 BCrypt),而不是存明文
        // user.setPassword(BCrypt.hashpw(user.getPassword(), BCrypt.gensalt()));
        return userDao.insert(user);
    }

    @Override
    public boolean updateUser(User user) {
        return userDao.update(user) > 0;
    }

    @Override
    public boolean deleteUser(Long id) {
        return userDao.deleteById(id) > 0;
    }
}
异常处理约定:DAO 层把 SQLException 包成 RuntimeException 往上抛;Service 层不捕获继续抛;最终由 Web 层(或统一异常处理)捕获,转成友好的错误提示返回给前端。这样每层代码都很干净。

第八章:编写接口(RESTful 风格 + JSON + 统一返回格式)

8.1 整体步骤

plain
 
第1步:理解"前后端分离":后端不再返回 HTML,只返回 JSON 数据,前端(Vue/React)负责渲染
第2步:引入 Jackson,解决 Java 对象 ↔ JSON 字符串的相互转换
第3步:设计统一返回格式 Result(code + message + data),全项目所有接口都长一个样
第4步:理解 RESTful 风格:用 URL 表示资源,用 HTTP 方法表示操作
第5步:实现一个 BaseServlet:让一个 Servlet 按 URL 分发到不同方法,避免类爆炸
第6步:实战:用户的增删改查五个 RESTful 接口

8.2 为什么要返回 JSON

传统模式:Servlet 转发给 JSP,服务器把数据填进 HTML 再返回——前后端耦合在一起。
现代主流(前后端分离):
plain
 
浏览器/App  ---请求--->  后端接口(只返回 JSON)
浏览器/App  <---JSON---  后端
   ↑ 前端自己用 JS 把 JSON 渲染成页面
后端只出"数据接口",一套接口同时服务网页、App、小程序。后端工程师的核心工作就是设计并实现 JSON 接口。

8.3 引入 Jackson 依赖

xml
 
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.16.1</version>
</dependency>
Jackson 三行核心用法:
java
 
ObjectMapper mapper = new ObjectMapper();

// 对象 → JSON 字符串
String json = mapper.writeValueAsString(user);   // {"id":1,"username":"zhangsan",...}

// JSON 字符串 → 对象
User user = mapper.readValue(jsonString, User.class);

// JSON 字符串 → 集合
List<User> users = mapper.readValue(jsonArray, new TypeReference<List<User>>() {});

8.4 统一返回格式 Result(企业标配)

为什么要统一? 如果每个接口返回结构都不一样,前端每接一个接口都要单独写解析逻辑,会疯掉。全项目统一成:
JSON
 
{
  "code": 200,
  "message": "操作成功",
  "data": { "id": 1, "username": "zhangsan" }
}
com/example/web/common/Result.java:
java
 
package com.example.web.common;

/**
 * 统一响应结果:所有接口都返回这个结构
 * code    : 业务状态码(200 成功,400 参数错误,401 未登录,500 服务器异常)
 * message : 给人看的提示信息
 * data    : 真正的业务数据(查列表是数组,查详情是对象,增删改可以是 null)
 */
public class Result<T> {

    private Integer code;
    private String message;
    private T data;

    public Result() {}

    private Result(Integer code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    /** 成功(带数据) */
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "操作成功", data);
    }

    /** 成功(不带数据,比如删除) */
    public static <T> Result<T> success() {
        return new Result<>(200, "操作成功", null);
    }

    /** 失败 */
    public static <T> Result<T> error(Integer code, String message) {
        return new Result<>(code, message, null);
    }

    // getter/setter 省略(必须生成,Jackson 序列化要靠 getter)
}

8.5 RESTful 风格速览

RESTful 是一种 URL 设计规范,核心思想:URL 只表示"资源"(名词),动作用 HTTP 方法表达(动词)。
表格
 
 
操作传统写法RESTful 写法HTTP 方法
查询用户列表 /user/list /users GET
查询 id=1 的用户 /user/get?id=1 /users/1 GET
新增用户 /user/add /users POST
修改 id=1 的用户 /user/update /users/1 PUT
删除 id=1 的用户 /user/delete?id=1 /users/1 DELETE

8.6 BaseServlet:按路径分发请求(原生 Servlet 的经典技巧)

问题:如果每个接口一个 Servlet 类,一个项目几百个接口就是几百个类,没法管理。
思路:写一个父类,在 service 方法里解析 URL 最后一段,用反射调用同名方法。所有业务 Servlet 继承它即可:
java
 
package com.example.web.servlet;

import com.example.web.common.Result;
import com.fasterxml.jackson.databind.ObjectMapper;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.reflect.Method;

/**
 * 基础 Servlet:URL 最后一段 = 方法名,自动分发
 * 例如 /users/list   → 调用子类的 list(req, resp) 方法
 *      /users/detail → 调用子类的 detail(req, resp) 方法
 * 这是 Spring MVC @RequestMapping 的原始雏形
 */
public abstract class BaseServlet extends HttpServlet {

    // 所有子类共用的 JSON 工具
    protected final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // 统一处理请求/响应编码
        req.setCharacterEncoding("UTF-8");
        resp.setContentType("application/json;charset=UTF-8");

        // 1. 从 URL 里截取方法名:/web_demo/users/list → list
        String uri = req.getRequestURI();
        String methodName = uri.substring(uri.lastIndexOf('/') + 1);

        try {
            // 2. 反射:在子类中找名为 methodName、参数为 (req, resp) 的方法并调用
            Method method = this.getClass().getMethod(
                    methodName, HttpServletRequest.class, HttpServletResponse.class);
            method.invoke(this, req, resp);
        } catch (NoSuchMethodException e) {
            writeJson(resp, Result.error(404, "接口不存在:" + methodName));
        } catch (Exception e) {
            // 统一异常兜底:业务层抛出的任何异常都在这里转成 JSON 错误
            Throwable cause = e.getCause() == null ? e : e.getCause();
            writeJson(resp, Result.error(500, cause.getMessage()));
        }
    }

    /** 工具方法:把任意对象以 JSON 写回浏览器 */
    protected void writeJson(HttpServletResponse resp, Object data) throws IOException {
        resp.getWriter().println(objectMapper.writeValueAsString(data));
    }
}

8.7 实战:用户接口 UserServlet

java
 
package com.example.web.servlet;

import com.example.web.common.Result;
import com.example.web.entity.User;
import com.example.web.service.UserService;
import com.example.web.service.impl.UserServiceImpl;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;

/**
 * 用户接口:
 *   GET  /users/list    查询全部用户
 *   GET  /users/detail?id=1  查询单个用户
 *   POST /users/add     新增用户(JSON 请求体)
 *   POST /users/update  修改用户(JSON 请求体)
 *   POST /users/delete?id=1  删除用户
 * 注意 /users/* 通配:/users 开头的所有 URL 都进这个 Servlet
 */
@WebServlet("/users/*")
public class UserServlet extends BaseServlet {

    // Web 层持有 Service(学 Spring 后变成 @Autowired)
    private final UserService userService = new UserServiceImpl();

    /** 查询用户列表 */
    public void list(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        List<User> users = userService.listUsers();
        writeJson(resp, Result.success(users));
    }

    /** 查询单个用户 */
    public void detail(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // 1. Web 层职责:取参数 + 基础校验
        String idStr = req.getParameter("id");
        if (idStr == null) {
            writeJson(resp, Result.error(400, "缺少参数 id"));
            return;
        }
        // 2. 调 Service 层干活
        User user = userService.getUser(Long.parseLong(idStr));
        if (user == null) {
            writeJson(resp, Result.error(404, "用户不存在"));
            return;
        }
        // 3. Web 层职责:组装响应
        writeJson(resp, Result.success(user));
    }

    /** 新增用户(接收 JSON 请求体) */
    public void add(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // 从请求体读 JSON 并转成 User 对象
        User user = objectMapper.readValue(req.getReader(), User.class);
        Long id = userService.register(user);
        writeJson(resp, Result.success(id));
    }

    /** 修改用户 */
    public void update(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        User user = objectMapper.readValue(req.getReader(), User.class);
        boolean ok = userService.updateUser(user);
        writeJson(resp, ok ? Result.success() : Result.error(404, "用户不存在,修改失败"));
    }

    /** 删除用户 */
    public void delete(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String idStr = req.getParameter("id");
        boolean ok = userService.deleteUser(Long.parseLong(idStr));
        writeJson(resp, ok ? Result.success() : Result.error(404, "用户不存在,删除失败"));
    }
}

8.8 用 Apifox / Postman 测试接口

浏览器只能方便地测 GET,POST/PUT/DELETE 要用接口测试工具(Apifox、Postman、IDEA 自带的 HTTP Client 均可):
plain
 
GET    http://localhost:8080/web_demo/users/list
GET    http://localhost:8080/web_demo/users/detail?id=1
POST   http://localhost:8080/web_demo/users/add
       Header: Content-Type: application/json
       Body: {"username":"wangwu","password":"123456","nickname":"王五","age":28}
POST   http://localhost:8080/web_demo/users/delete?id=3
至此你已经掌握了后端接口开发的完整套路:接参 → 校验 → 调 Service → 返回统一 JSON。Spring Boot 时代只是把 @WebServlet 换成 @RestController、把 BaseServlet 换成 @GetMapping 注解,思想一模一样。

第九章:过滤器 Filter 与拦截器

9.1 整体步骤

plain
 
第1步:理解过滤器的本质:请求到达 Servlet 之前/响应离开之后的"关卡",可以拦截所有请求
第2步:掌握过滤器的生命周期和执行链(多个 Filter 的顺序)
第3步:实战1——全局编码过滤器(消灭每个 Servlet 里的 setCharacterEncoding)
第4步:实战2——登录校验过滤器(未登录统一拦截,不用每个接口都检查)
第5步:搞清"过滤器"和"拦截器"的区别(面试高频)
第6步:了解监听器 Listener(第三大组件,用得较少,知道即可)

9.2 过滤器的工作模型

plain
 
浏览器 → [Filter1 前处理] → [Filter2 前处理] → Servlet 处理业务
浏览器 ← [Filter1 后处理] ← [Filter2 后处理] ←
关键代码 chain.doFilter(req, resp):放行——调用它,请求继续往下走;不调用,请求到此为止(拦截)。

9.3 实战一:全局编码过滤器

java
 
package com.example.web.filter;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 全局编码过滤器:/* 表示拦截所有请求
 * 有了它,任何 Servlet 都不用再写 setCharacterEncoding
 */
@WebFilter("/*")
public class EncodingFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) {
        // 过滤器创建时执行一次,可读取 web.xml 里配置的初始化参数
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        // ===== 前处理:请求到达 Servlet 之前执行 =====
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        req.setCharacterEncoding("UTF-8");
        resp.setCharacterEncoding("UTF-8");

        // ===== 放行:让请求继续走向下一个过滤器或 Servlet =====
        chain.doFilter(request, response);

        // ===== 后处理:Servlet 处理完之后、响应发回浏览器之前执行 =====
        // 可以在这里做:记录响应日志、压缩内容等
    }

    @Override
    public void destroy() {
        // 应用关闭时执行一次
    }
}

9.4 实战二:登录校验过滤器(最典型应用)

java
 
package com.example.web.filter;

import com.example.web.common.Result;
import com.fasterxml.jackson.databind.ObjectMapper;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 登录校验过滤器:所有 /api/ 开头的接口都要求登录
 * 白名单(登录、注册接口本身)要放行,否则死循环
 */
@WebFilter("/api/*")
public class LoginFilter implements Filter {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;

        // 1. 白名单放行:登录/注册接口不需要登录就能访问
        String uri = req.getRequestURI();
        if (uri.contains("/login") || uri.contains("/register")) {
            chain.doFilter(request, response);
            return;
        }

        // 2. 检查 session 里有没有登录用户
        Object loginUser = req.getSession().getAttribute("loginUser");
        if (loginUser != null) {
            chain.doFilter(request, response);   // 已登录,放行
            return;
        }

        // 3. 未登录:不放行,直接返回 401 JSON(不放行 = 请求根本到不了 Servlet)
        resp.setContentType("application/json;charset=UTF-8");
        resp.getWriter().println(
                objectMapper.writeValueAsString(Result.error(401, "未登录,请先登录")));
    }
}
多个过滤器的执行顺序:注解方式按类名字典序,web.xml 方式按配置先后顺序。有强顺序要求时用 web.xml 配置更可控。

9.5 过滤器 vs 拦截器(面试必考,先建立概念)

表格
 
 
对比点过滤器 Filter拦截器 Interceptor
出自谁 Servlet 规范(Tomcat 提供) Spring MVC 框架提供
拦截范围 所有进入 Tomcat 的请求(含静态资源) 只拦截进入 Controller 的请求
能用什么 只能操作 request/response 能拿到 Spring 容器里的所有 Bean
执行顺序 更靠前 在 Filter 之后
plain
 
浏览器 → Filter1 → Filter2 → 【DispatcherServlet】→ Interceptor1 → Interceptor2 → Controller
         ←——— Servlet 规范的地盘 ———←——————— Spring MVC 的地盘 ————————→
本教程阶段用 Filter 就够了;Interceptor 是 Spring MVC 的组件,学 Spring 时实现 HandlerInterceptor 接口即可,思路(前置处理/后置处理/完成后处理)和 Filter 完全相通。

9.6 监听器 Listener(了解)

监听器监听"域对象的创建/销毁/属性变化",典型用途是统计在线人数:
java
 
package com.example.web.listener;

import javax.servlet.annotation.WebListener;
import javax.servlet.http.HttpSessionEvent;
import javax.servlet.http.HttpSessionListener;

/**
 * 监听 Session 的创建和销毁,统计在线人数
 */
@WebListener
public class OnlineCountListener implements HttpSessionListener {

    @Override
    public void sessionCreated(HttpSessionEvent se) {
        // 一个新用户(新 Session)来了,在线人数 +1
        Object count = se.getSession().getServletContext().getAttribute("onlineCount");
        int current = (count == null) ? 0 : (Integer) count;
        se.getSession().getServletContext().setAttribute("onlineCount", current + 1);
    }

    @Override
    public void sessionDestroyed(HttpSessionEvent se) {
        // Session 过期或退出登录,在线人数 -1
        Object count = se.getSession().getServletContext().getAttribute("onlineCount");
        int current = (count == null) ? 1 : (Integer) count;
        se.getSession().getServletContext().setAttribute("onlineCount", current - 1);
    }
}
Servlet 三大组件记忆:Servlet 干活、Filter 拦路、Listener 放哨。

第十章:日志系统(SLF4J + Logback)

10.1 整体步骤

plain
 
第1步:先立规矩:正式项目禁止 System.out.println,理由有三
第2步:理解"日志门面 + 日志实现"的分层设计(SLF4J 是脸,Logback 是芯)
第3步:引入依赖(Logback 自带 SLF4J 接口)
第4步:写 logback.xml 配置文件(控制台 + 滚动文件双输出)
第5步:代码中使用 Logger 打印五个级别的日志
第6步:理解日志级别的作用:不同环境(开发/生产)打印不同详细程度

10.2 为什么不能用 System.out.println

  1. 不能分级:调试信息和严重错误混在一起,没法过滤;
  2. 不能输出到文件:服务器上没有控制台,程序一关什么都没留下;
  3. 性能差:println 是同步加锁的,高并发下会拖累系统。

10.3 门面模式:SLF4J + Logback

plain
 
你的代码  →  只调用 SLF4J 的接口(Logger.info(...))
                    ↓
              具体由谁实现?配置文件说了算:
              Logback(本教程)/ Log4j2 / JDK Logging
好处:以后想把日志实现从 Logback 换成 Log4j2,代码一行不用改,只换依赖和配置。这和你写 UserService 接口是同一个思想——面向接口编程。

10.4 引入依赖

xml
 
<!-- Logback 经典实现,内部已包含 SLF4J API -->
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.13</version>
</dependency>

10.5 logback.xml 配置(放 resources 目录,自动生效)

xml
 
<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- 定义日志输出格式:时间 线程 级别 类名 - 内容 -->
    <property name="LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>

    <!-- 输出器1:控制台(开发调试用) -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- 输出器2:滚动文件(生产环境留痕用)
         按天滚动,单个文件超 10MB 也滚动,最多保留 30 天 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/web-demo.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>logs/web-demo.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxFileSize>10MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- 根日志配置:默认 INFO 级别,同时输出到控制台和文件 -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>

    <!-- 可以针对某个包单独设置级别:自己项目的包开 DEBUG,方便开发 -->
    <logger name="com.example.web" level="DEBUG"/>

</configuration>

10.6 代码中使用(五个级别)

java
 
package com.example.web.service.impl;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class UserServiceImpl implements UserService {

    // 固定写法:每个类一个 Logger,参数传当前类
    // 学 Spring Boot 后用 @Slf4j 注解自动生成这一行
    private static final Logger log = LoggerFactory.getLogger(UserServiceImpl.class);

    @Override
    public Long register(User user) {
        // TRACE < DEBUG < INFO < WARN < ERROR,级别从细到粗
        log.debug("开始注册用户,参数:{}", user.getUsername());   // 开发期细节
        if (user.getUsername() == null) {
            log.warn("注册失败:用户名为空");                     // 业务异常,值得关注
            throw new IllegalArgumentException("用户名不能为空");
        }
        Long id = userDao.insert(user);
        log.info("用户注册成功,id={}", id);                      // 关键业务节点
        return id;
    }

    @Override
    public User getUser(Long id) {
        try {
            return userDao.findById(id);
        } catch (Exception e) {
            // ★ 记录异常必须传 e 对象,才能打印完整堆栈!只写 e.getMessage() 等于丢了现场
            log.error("查询用户异常,id={}", id, e);
            throw e;
        }
    }
}
级别使用心法:
表格
 
 
级别什么时候用例子
DEBUG 开发调试的细节,生产环境关掉 SQL 参数、中间变量
INFO 重要的业务节点,生产环境保留 用户登录成功、订单创建
WARN 不正常但程序还能跑 参数为空、重试了一次
ERROR 出错需要人介入,必须带堆栈 数据库连不上、接口超时
日志在生产环境的价值:凌晨 3 点用户反馈"支付失败",你不可能重现现场,唯一能依靠的就是日志。写日志就是在给未来的自己留线索。

第十一章:事务(从 JDBC 手动事务到声明式事务思想)

11.1 整体步骤

plain
 
第1步:用"转账"这个经典场景理解:为什么需要事务(ACID)
第2步:理解 JDBC 的默认行为:每条 SQL 自动提交(autoCommit=true)—— 这是事故根源
第3步:手写 JDBC 事务三板斧:关闭自动提交 → 执行多条 SQL → 成功提交/失败回滚
第4步:解决一个工程难题:事务的 Connection 如何在 Service 和 DAO 之间传递
       (ThreadLocal 方案——理解 Spring 事务的底层原理)
第5步:展望未来:Spring 的 @Transactional 一行注解搞定这一切

11.2 为什么需要事务:转账场景

A 给 B 转账 100 元,数据库要做两件事:
sql
 
UPDATE account SET balance = balance - 100 WHERE name = 'A';   -- 第1步:A 扣钱
UPDATE account SET balance = balance + 100 WHERE name = 'B';   -- 第2步:B 加钱
如果第 1 步执行完,服务器断电了,第 2 步没执行——A 的钱没了,B 没收到,100 元凭空消失。
事务(Transaction)就是解决这个问题的:把多条 SQL 绑成一个整体,要么全部成功,要么全部失败回滚(撤销)。
ACID 四特性(面试必背):
表格
 
 
特性大白话
原子性 Atomicity 多条 SQL 是一个不可分割的整体,全做或全不做
一致性 Consistency 转账前后,A+B 的总金额不变
隔离性 Isolation 两个转账同时进行,互不干扰
持久性 Durability 事务一旦提交,断电也不丢

11.3 JDBC 手动事务三板斧

java
 
// 先建一张账户表用于演示
// CREATE TABLE account (
//     id BIGINT PRIMARY KEY AUTO_INCREMENT,
//     name VARCHAR(50),
//     balance DECIMAL(10,2)
// );
// INSERT INTO account (name, balance) VALUES ('A', 1000), ('B', 1000);
java
 
package com.example.web.dao;

import com.example.web.util.JdbcUtils;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

/**
 * 转账演示:JDBC 手动事务的标准写法
 * 核心三行:conn.setAutoCommit(false) / conn.commit() / conn.rollback()
 */
public class TransferDao {

    /**
     * A 向 B 转账 amount 元
     * @return true 成功,false 失败(已回滚)
     */
    public boolean transfer(String fromName, String toName, double amount) {
        Connection conn = null;
        try {
            conn = JdbcUtils.getConnection();

            // ★ 第1板斧:关闭自动提交 —— 从这行开始,所有 SQL 进入"暂存区"
            conn.setAutoCommit(false);

            // 第1步:扣 A 的钱
            try (PreparedStatement ps = conn.prepareStatement(
                    "UPDATE account SET balance = balance - ? WHERE name = ? AND balance >= ?")) {
                ps.setDouble(1, amount);
                ps.setString(2, fromName);
                ps.setDouble(3, amount);
                if (ps.executeUpdate() == 0) {
                    throw new RuntimeException("余额不足或账户不存在");
                }
            }

            // 模拟中途发生异常(比如服务器故障、业务校验失败)
            // int i = 1 / 0;   // 放开这行测试回滚效果

            // 第2步:给 B 加钱
            try (PreparedStatement ps = conn.prepareStatement(
                    "UPDATE account SET balance = balance + ? WHERE name = ?")) {
                ps.setDouble(1, amount);
                ps.setString(2, toName);
                if (ps.executeUpdate() == 0) {
                    throw new RuntimeException("收款账户不存在");
                }
            }

            // ★ 第2板斧:所有 SQL 都成功,提交 —— "暂存区"的改动真正写入数据库
            conn.commit();
            return true;

        } catch (Exception e) {
            // ★ 第3板斧:任何一步失败,回滚 —— 撤销本次事务的所有改动,钱一分不少
            if (conn != null) {
                try {
                    conn.rollback();
                } catch (SQLException ex) {
                    ex.printStackTrace();
                }
            }
            return false;
        } finally {
            // 恢复自动提交并归还连接(好习惯:连接是池化的,状态要还原)
            if (conn != null) {
                try {
                    conn.setAutoCommit(true);
                    conn.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
验证方法:先正常转账,查余额两边都变了;再放开 int i = 1 / 0; 那行,转账"失败",查余额——A 的钱没有少,因为回滚了。

11.4 工程难题:Connection 怎么在层间传递

上面代码把两条 SQL 写在一个 DAO 方法里,事务好控制。但真实项目是分层调用:
java
 
// Service 层
public void transfer(String from, String to, double amount) {
    accountDao.minus(from, amount);   // DAO 方法1,里面自己 getConnection()
    accountDao.plus(to, amount);      // DAO 方法2,里面又自己 getConnection()
}
问题来了:两个 DAO 方法各自从池子里借连接,拿到的不是同一个 Connection,事务根本管不住它们!
解法:ThreadLocal(线程局部变量)
思路:Service 开启事务时,把 Connection 绑到当前线程上;DAO 取连接时,先看看当前线程上有没有绑好的,有就用它。因为一次请求从头到尾是同一个线程在处理,所以 Service 和 DAO 能拿到同一个 Connection:
java
 
package com.example.web.util;

import java.sql.Connection;
import java.sql.SQLException;

/**
 * 事务管理器:用 ThreadLocal 让同一线程(同一次请求)共享同一个 Connection
 * ★ 这就是 Spring 声明式事务的底层原理!
 */
public class TransactionManager {

    // ThreadLocal:每个线程有自己独立的一份变量副本,互不干扰
    private static final ThreadLocal<Connection> HOLDER = new ThreadLocal<>();

    /** 开启事务:借连接、关自动提交、绑到当前线程 */
    public static void begin() throws SQLException {
        Connection conn = JdbcUtils.getConnection();
        conn.setAutoCommit(false);
        HOLDER.set(conn);
    }

    /** 获取连接:事务中返回绑定的连接,否则从池子新借 */
    public static Connection getConnection() throws SQLException {
        Connection conn = HOLDER.get();
        return (conn != null) ? conn : JdbcUtils.getConnection();
    }

    /** 提交事务 */
    public static void commit() throws SQLException {
        Connection conn = HOLDER.get();
        if (conn != null) {
            conn.commit();
        }
    }

    /** 回滚事务 */
    public static void rollback() {
        Connection conn = HOLDER.get();
        if (conn != null) {
            try {
                conn.rollback();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }

    /** 收尾:解绑、还原、归还连接 */
    public static void close() {
        Connection conn = HOLDER.get();
        HOLDER.remove();              // ★ 必须 remove!线程是池化复用的,不清理会串数据
        if (conn != null) {
            try {
                conn.setAutoCommit(true);
                conn.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }
}
Service 层使用(DAO 里的 JdbcUtils.getConnection() 全部换成 TransactionManager.getConnection()):
java
 
public void transfer(String from, String to, double amount) {
    try {
        TransactionManager.begin();            // 开启事务
        accountDao.minus(from, amount);        // 和下面用的都是事务绑定的那个连接
        accountDao.plus(to, amount);
        TransactionManager.commit();           // 全部成功,提交
    } catch (Exception e) {
        TransactionManager.rollback();         // 任何失败,回滚
        throw new RuntimeException("转账失败", e);
    } finally {
        TransactionManager.close();            // 清理现场
    }
}

11.5 展望未来:@Transactional

理解上面这套原理后,Spring 的事务你会觉得亲切无比——它用 AOP 把 begin/commit/rollback/close 这套模板自动化了:
java
 
@Transactional   // 一行注解 = 上面整整一个 try-catch-finally
public void transfer(String from, String to, double amount) {
    accountDao.minus(from, amount);
    accountDao.plus(to, amount);
}
面试如果被问"@Transactional 的原理",你就答:AOP 代理 + 数据源连接绑定到 ThreadLocal。这正是你亲手实现过的东西。

第十二章:综合实战 —— 用户管理系统(串起全部知识点)

12.1 整体步骤(开发任何功能都按这个顺序走)

plain
 
第1步:设计数据库表(第二章已建好 t_user)
第2步:写实体类 User(第六章已写)
第3步:写 DAO 层 UserDao(第六章已写)→ 先用 main 方法测一遍 SQL
第4步:写 Service 层 UserService(第七章已写)→ 补充分页、注册校验业务逻辑
第5步:写 Web 层 UserServlet(第八章已写)→ 补充分页接口
第6步:加全局过滤器:编码 Filter(第九章已写)
第7步:加日志:logback.xml(第十章已写)→ 在关键位置补 log 语句
第8步:涉及多表操作的地方(如"删除用户同时删除其订单")加事务(第十一章方案)
第9步:用 Apifox 把全部接口测试一遍,出接口文档
注意这个顺序:自底向上写代码(表→DAO→Service→Web),自顶向下想业务(需求→接口→逻辑→数据)。新手常犯的错误是直接开写 Servlet,想到哪写到哪。

12.2 补一个分页查询(真实项目必考)

分页 SQL(MySQL 方言):
sql
 
-- LIMIT 偏移量, 每页条数
-- 第 1 页(每页 10 条):LIMIT 0, 10
-- 第 2 页:LIMIT 10, 10
-- 第 n 页:LIMIT (n-1)*pageSize, pageSize
SELECT * FROM t_user ORDER BY id LIMIT ?, ?;
DAO 层加分页方法:
java
 
/** 分页查询 */
public List<User> findByPage(int pageNum, int pageSize) {
    List<User> list = new ArrayList<>();
    String sql = "SELECT id, username, password, nickname, age, create_time "
               + "FROM t_user ORDER BY id LIMIT ?, ?";
    try (Connection conn = JdbcUtils.getConnection();
         PreparedStatement ps = conn.prepareStatement(sql)) {

        ps.setInt(1, (pageNum - 1) * pageSize);   // 偏移量
        ps.setInt(2, pageSize);
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                list.add(mapRow(rs));
            }
        }
    } catch (SQLException e) {
        throw new RuntimeException("分页查询失败", e);
    }
    return list;
}

/** 查询总条数(分页必须返回总数,前端才能算总页数) */
public long count() {
    String sql = "SELECT COUNT(*) FROM t_user";
    try (Connection conn = JdbcUtils.getConnection();
         PreparedStatement ps = conn.prepareStatement(sql);
         ResultSet rs = ps.executeQuery()) {
        if (rs.next()) {
            return rs.getLong(1);
        }
    } catch (SQLException e) {
        throw new RuntimeException("统计总数失败", e);
    }
    return 0;
}
分页结果封装 com/example/web/common/PageResult.java:
java
 
package com.example.web.common;

import java.util.List;

/**
 * 分页结果封装:前端分页组件需要这四个字段
 */
public class PageResult<T> {

    private List<T> list;     // 当前页数据
    private long total;       // 总条数
    private int pageNum;      // 当前第几页
    private int pageSize;     // 每页多少条

    public PageResult(List<T> list, long total, int pageNum, int pageSize) {
        this.list = list;
        this.total = total;
        this.pageNum = pageNum;
        this.pageSize = pageSize;
    }

    // getter/setter 省略
}
Web 层分页接口:
java
 
/** 分页查询:GET /users/page?pageNum=1&pageSize=10 */
public void page(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    // 取参数,给默认值,防止空指针
    String pageNumStr = req.getParameter("pageNum");
    String pageSizeStr = req.getParameter("pageSize");
    int pageNum = (pageNumStr == null) ? 1 : Integer.parseInt(pageNumStr);
    int pageSize = (pageSizeStr == null) ? 10 : Integer.parseInt(pageSizeStr);

    PageResult<User> result = new PageResult<>(
            userDao.findByPage(pageNum, pageSize),
            userDao.count(),
            pageNum, pageSize);
    writeJson(resp, Result.success(result));
}
返回效果:
JSON
 
{
  "code": 200,
  "message": "操作成功",
  "data": {
    "list": [{"id": 1, "username": "zhangsan", "nickname": "张三", "age": 25}],
    "total": 2,
    "pageNum": 1,
    "pageSize": 10
  }
}

12.3 最终项目结构总览

plain
 
web-demo/
├── pom.xml
└── src/main/
    ├── java/com/example/web/
    │   ├── common/
    │   │   ├── Result.java              统一返回格式
    │   │   └── PageResult.java          分页封装
    │   ├── entity/
    │   │   └── User.java                实体类
    │   ├── dao/
    │   │   ├── UserDao.java             用户表数据访问
    │   │   └── TransferDao.java         转账演示
    │   ├── service/
    │   │   ├── UserService.java
    │   │   └── impl/UserServiceImpl.java
    │   ├── servlet/
    │   │   ├── BaseServlet.java         请求分发基类
    │   │   ├── UserServlet.java         用户接口
    │   │   ├── LoginServlet.java        登录
    │   │   └── ProfileServlet.java      个人中心
    │   ├── filter/
    │   │   ├── EncodingFilter.java      全局编码
    │   │   └── LoginFilter.java         登录校验
    │   ├── listener/
    │   │   └── OnlineCountListener.java 在线人数统计
    │   └── util/
    │       ├── JdbcUtils.java           Druid 连接池
    │       └── TransactionManager.java  ThreadLocal 事务管理器
    ├── resources/
    │   ├── druid.properties             连接池配置
    │   └── logback.xml                  日志配置
    └── webapp/
        ├── WEB-INF/web.xml
        └── index.jsp

12.4 完整请求链路复盘(看到这张图你就真正懂了)

plain
 
浏览器 GET /web_demo/users/list
        ↓
Tomcat 接收请求,包装成 HttpServletRequest
        ↓
EncodingFilter:设置 UTF-8 编码 → 放行
        ↓
LoginFilter:检查 Session 是否登录 → 放行
        ↓
UserServlet(继承 BaseServlet)
  service() 解析出方法名 "list" → 反射调用 list()
        ↓
UserService.listUsers()            ← Service 层:业务逻辑、事务边界
        ↓
UserDao.findAll()                  ← DAO 层:JDBC + SQL
        ↓
Druid 连接池 → MySQL → 返回 ResultSet → 映射成 List<User>
        ↓(原路返回)
BaseServlet 用 Jackson 把 Result.success(list) 转成 JSON 写回
        ↓
浏览器收到:{"code":200,"message":"操作成功","data":[...]}

全程伴随:Logback 在控制台和 logs/web-demo.log 记录关键节点

第十三章:常见问题排查清单与后续学习路线

13.1 新手高频报错排查清单

表格
 
 
现象可能原因解决方案
访问接口 404 ① URL 路径写错(漏了项目上下文路径)② @WebServlet 路径不对 ③ war 没部署上 先看 Tomcat 启动日志有没有部署成功,再核对完整 URL
中文乱码(???) 没加编码过滤器,或 resp.setContentType 漏了 charset 按第九章加 EncodingFilter;检查 IDE 文件编码全为 UTF-8
ClassNotFoundException: com.mysql... MySQL 驱动依赖没引进来 检查 pom.xml 后点 Maven 刷新(Reload)
servlet-api 冲突报错 scope 忘了写 provided,jar 被打进 war 包 改 scope 后 clean 重新打包
连接池报错 Access denied druid.properties 密码不对 / 配置文件没放到 resources 核对密码;确认 resources 标记为资源目录
Too many connections 连接用完没 close(没归还池子) 所有 JDBC 代码必须用 try-with-resources
Session 取不到登录用户 跨域或 Cookie 被浏览器拦截;前后端分离时前端没开 withCredentials 前端 axios 配置 withCredentials: true,后端设置 CORS 允许携带凭证
数据改了页面没变 用了转发导致浏览器缓存,或增删改后没重定向 增删改操作用重定向/返回 JSON,不用转发

13.2 本教程知识点自测(能全部答出来才算过关)

  1. Tomcat 和 Servlet 分别是什么角色?
  2. 为什么 Servlet 里不能定义可变成员变量?
  3. 转发和重定向的四个区别?
  4. Session 是怎么靠 Cookie 工作的?
  5. 为什么用 PreparedStatement 而不是字符串拼 SQL?
  6. 三层架构每层职责是什么?为什么 Service 层是事务的最佳位置?
  7. 统一返回 Result 解决了什么问题?
  8. Filter 的 chain.doFilter() 不调用会发生什么?
  9. 日志五个级别分别什么场景用?
  10. 转账场景里 ThreadLocal 解决了什么问题?

13.3 后续学习路线(按此顺序,不要跳)

plain
 
第1站:MyBatis        —— 替代手写 JDBC,你第六章写的模板代码它全包了
第2站:Spring         —— IOC(对象不用 new 了,框架帮你管)+ AOP(日志/事务自动化)
第3站:Spring MVC     —— @RestController 替代 Servlet,拦截器 Interceptor
第4站:Spring Boot    —— 内嵌 Tomcat,告别 war 包和 xml,约定大于配置
第5站:MySQL 进阶     —— 索引、事务隔离级别、慢查询优化
第6站:Redis + JWT    —— 缓存;前后端分离下替代 Session 的登录方案
第7站:Linux 部署     —— 把项目部署到真实服务器,Nginx 反向代理
给初学者的一句话:本教程写的每一行"原始代码"(BaseServlet 反射分发、ThreadLocal 事务、手动 JDBC),在 Spring 生态里都有现成的自动化方案。但请你一定先把原始版跑通一遍——知道框架替你做了什么,框架出问题的时候你才有能力救它。

全文完。建议学习方式:对照本教程从零把项目敲一遍,每章跑通再进下一章,遇到报错先看第十三章排查清单,再看日志和堆栈信息定位问题。

上面学习完以后我们就可以快还了解一下java 的  sprint 框架,只有了解过srpint  以后我们才开始学习  sprint  boot  就能快速掌握了,很多东西有人带也就一个星期学会,自学的话可能是三个月
posted @ 2026-08-08 08:56  鬼门元歌  阅读(24)  评论(0)    收藏  举报