鸿蒙系统后端开发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 容器)。它的工作:
-
监听端口(比如 8080),接收浏览器发来的 HTTP 请求;
-
把请求"翻译"成 Java 对象(
HttpServletRequest); -
找到你写的 Servlet,调用它;
-
把你返回的结果(
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
-
下载 JDK:Oracle 官网、Adoptium(推荐,免费)均可,选 JDK 8 或 17 的 LTS 版本。
-
安装后配置环境变量(Windows):
-
JAVA_HOME=C:\Program Files\Java\jdk1.8.0_xxx -
Path追加%JAVA_HOME%\bin
-
-
验证:打开命令行输入:
bash
java -version
javac -version
能显示版本号即成功。
2.3 第二步:安装与配置 Maven
Maven 的作用一句话:自动帮你下载 jar 包(依赖),不用手动去网上找 jar 复制进项目。
-
官网下载 Maven 3.8+,解压到无中文无空格路径,如
D:\dev\apache-maven-3.9.6。 -
配置环境变量
MAVEN_HOME,Path追加%MAVEN_HOME%\bin。 -
换阿里云镜像(不换的话下载依赖慢到怀疑人生):编辑
conf/settings.xml,在<mirrors>节点内加入:
xml
<mirror>
<id>aliyunmaven</id>
<mirrorOf>*</mirrorOf>
<name>阿里云公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
-
设置本地仓库位置(同文件内):
xml
<localRepository>D:\dev\maven-repo</localRepository>
-
在 IDEA 里配置:
File → Settings → Build Tools → Maven,指定 Maven home path 和 settings.xml。
2.4 第三步:安装 Tomcat 9
-
官网
tomcat.apache.org下载 Tomcat 9 的 zip 版(Core → 64-bit Windows zip,Linux 选 tar.gz)。 -
解压到无中文路径,如
D:\dev\apache-tomcat-9.0.xx。 -
目录结构认识一下:
plain
apache-tomcat-9/
├── bin/ 启动脚本(startup.bat / startup.sh)
├── conf/ 配置文件(server.xml 改端口在这)
├── lib/ Tomcat 自己的 jar 包
├── logs/ 日志
├── webapps/ ★ 你的项目最终就放在这里,一个文件夹就是一个网站
└── work/ JSP 编译后的临时文件
-
验证:运行
bin/startup.bat(Windows)或bin/startup.sh(Linux/Mac),浏览器访问http://localhost:8080,看到 Tomcat 首页即成功。 -
IDEA 配置 Tomcat:
Run → Edit Configurations → + → Tomcat Server → Local,指定 Tomcat 解压目录。(社区版 IDEA 没有内置 Tomcat 集成,可用 Smart Tomcat 插件,或手动把 war 包复制到 webapps 目录启动,效果一样。)
2.5 第四步:安装 MySQL 并建库
-
安装 MySQL 8(安装过程略,记得 root 密码)。
-
用客户端连上,创建本教程要用的数据库和表:
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 第五步:部署运行并验证
-
IDEA 的 Tomcat 配置里,
Deployment选项卡添加web-demo:war exploded。 -
注意 Application context(应用上下文路径),建议改成
/web_demo,它决定了访问 URL 的前缀。 -
启动 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 到底有什么区别
表格
| 维度 | GET | POST |
|---|---|---|
| 参数位置 | 拼在 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
-
不能分级:调试信息和严重错误混在一起,没法过滤;
-
不能输出到文件:服务器上没有控制台,程序一关什么都没留下;
-
性能差: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 本教程知识点自测(能全部答出来才算过关)
-
Tomcat 和 Servlet 分别是什么角色?
-
为什么 Servlet 里不能定义可变成员变量?
-
转发和重定向的四个区别?
-
Session 是怎么靠 Cookie 工作的?
-
为什么用 PreparedStatement 而不是字符串拼 SQL?
-
三层架构每层职责是什么?为什么 Service 层是事务的最佳位置?
-
统一返回 Result 解决了什么问题?
-
Filter 的
chain.doFilter()不调用会发生什么? -
日志五个级别分别什么场景用?
-
转账场景里 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 就能快速掌握了,很多东西有人带也就一个星期学会,自学的话可能是三个月
浙公网安备 33010602011771号