news 2026/9/9 3:58:40

JavaWeb入门实战:基于Servlet+JSP+JDBC的用户管理系统开发

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaWeb入门实战:基于Servlet+JSP+JDBC的用户管理系统开发

简介:这是一份面向Java Web初学者的基础实战项目,完整实现了用户登录、注册、信息修改与删除等常见管理功能,帮助学习者理解Servlet、JSP、JDBC及分层开发的基本流程。压缩包共89个文件,以Java源码(14个java)、JSP页面(8个jsp)、CSS/JS样式脚本、数据库映射文件及少量jar包等为主,大小仅4.67MB,结构清晰,便于快速导入Eclipse运行调试。项目采用典型的DAO分层设计,包含用户管理、会话控制、数据库连接工具等模块,配合Bootstrap前端组件,可直接用于课程设计或毕业设计参考。已有6300人学习下载,对于想通过实操掌握Java Web开发全流程的初学者,是一份轻量且完整的入门资料。

1. 项目概述与整体思路

先说结论:这个选题看着简单,但真正动手做一遍,你会发现它几乎覆盖了JavaWeb入门阶段全部核心知识点——Servlet生命周期、HTTP请求响应、JDBC数据库操作、会话跟踪、请求转发与重定向,以及最基础的前端页面交互。我当年带学生做课设,十个里有八个选类似题目,但真正能把它做得干净利落、逻辑严密的,其实不多。

这个项目适合谁?两类人。一类是刚学完JavaSE、正准备进入Web开发大门的新手,需要一个完整案例把零散知识点串起来;另一类是正在做课程设计、需要快速交付一个能跑通全流程的JavaWeb项目的同学。项目本身不复杂,但麻雀虽小五脏俱全,登录、注册、查询列表、修改、删除这几个功能点,正好对应了Web开发中最经典的CRUD加认证场景。

那为什么不直接上Spring Boot?我的看法是:新手阶段用纯Servlet + JSP + JDBC老老实实写一遍,能帮你把HTTP协议、请求参数封装、数据库连接这些底层机制彻底搞明白。用Spring Boot一键生成的Hello World,你永远不知道Tomcat在背后做了什么。等这个项目跑通了,再回头看Spring Boot,你会觉得它不过是对你今天写的这些代码的封装和优化而已。

2018年之前,IDEA开JavaWeb项目还需要手动配置Tomcat、手动导入servlet-api.jar,步骤繁琐,劝退了不少新人。现在2023版本的IDEA已经把这些操作简化了很多,创建项目时直接自带Servlet依赖和web.xml(或者用注解方式),这对新手来说是一大利好。不过也别高兴太早——简化归简化,该懂的原理如果不懂,出了Bug照样抓瞎。

2. 环境准备与项目搭建

2.1 开发环境选型与版本搭配

先说环境。选型表如下:

组件推荐版本说明
JDK1.8 或 118是经典,11是LTS,两者都可以
IDEA2023.x新版对JavaWeb支持更友好
Tomcat9.x对应Servlet 4.0规范,够用
MySQL5.7 或 8.08.0注意驱动包要用新的
Maven3.8+用Maven管理依赖,避免手动导包

有一个关键点:如果你用Maven创建Web项目,pom.xml里需要声明Servlet API依赖,并且依赖范围要标为provided。这个provided的意思是编译时需要、但运行时由Tomcat提供,如果漏标或标成compile,部署时会导致包冲突,报一堆莫名其妙的ClassNotFoundException。这是新手最容易踩的坑之一。

2.2 IDEA 2023创建JavaWeb项目的完整流程

IDEA 2023版创建JavaWeb项目比老版本顺手多了,具体操作:

第一步,在IDEA欢迎页选择"New Project",左侧选择"Jakarta EE",右侧勾选"Web Application",注意不要选带Spring/Spring Boot的模板,那是另一条路线。Project SDK选好JDK版本,然后直接Next。

第二步,选择"Create from archetype"时,如果你看到有org.apache.maven.archetypes:maven-archtype-webapp这个内置原型,选它即可。如果IDEA自带模板列表里没有,可以在"Maven Central"搜索添加。这里有个细节:archetype生成的项目结构里,pom.xml的打包方式默认就是<packaging>war</packaging>,这一步IDEA帮你设置好了。

第三步,配置Tomcat。在IDEA右上角找到"Add Configuration",点"+"号,选择"Tomcat Server" → "Local"。在Application Server那里点Configure,选择你本地解压好的Tomcat目录。这时候你还需要在Deployment选项卡里点击"+",把这个war包添加进去,Application context一般填/或者/javaweb_demo,取决于你想让项目根路径叫什么。

配置完成后,IDEA的Run按钮旁边会多出一个Tomcat下拉选项,点运行,浏览器自动打开Tomcat默认页面,说明环境就通了。

2.3 数据库准备:建库建表与初始化数据

数据库用MySQL,先手动创建库和表。我建议直接用命令行或者Navicat执行以下SQL脚本:

CREATE DATABASE IF NOT EXISTS user_db DEFAULT CHARACTER SET utf8mb4; USE user_db; DROP TABLE IF EXISTS `user`; CREATE TABLE `user` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(100) NOT NULL, `email` VARCHAR(100) DEFAULT NULL, `phone` VARCHAR(20) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

这里有三个细节值得注意。

第一,数据库字符集用utf8mb4而不是utf8。MySQL的utf8最多存3字节的字符,遇到表情符号(emoji)会直接报错。虽然咱们这个项目不太可能存emoji,但养成用utf8mb4的习惯没有坏处。

第二,密码字段长度设为100而不是32。原因后面讲注册功能时会说——我们需要存的是密码的哈希值加盐值,长度比明文密码长得多。

第三,create_time设置默认值为CURRENT_TIMESTAMP,这样插入数据时不需要显式赋值,自动记录注册时间。这个小设计会让用户列表页面看起来更完整。

如果手头有mysql-connector-java驱动包,建议先手动执行几条INSERT语句,把几条测试数据插进去,后面联调登录和查询功能时不用每次都先去注册新号。

3. 项目结构设计与核心代码实现

3.1 分层思想:为什么把代码拆成这么多包

很多新手拿到这个项目,第一反应是"把所有代码写在一个Servlet类里不就行了?"确实能跑,但你会很快感受到维护的痛苦。等你要加一个权限校验、改一个查询条件,或者从MySQL切换到别的数据库时,牵一发动全身。

我建议按经典的三层架构来组织:

src/main/java ├── com.demo.dao // 数据访问层,跟数据库打交道 │ ├── BaseDao.java // JDBC基础操作封装 │ └── UserDao.java // 用户表增删改查 ├── com.demo.entity // 实体类,对应数据库表结构 │ └── User.java ├── com.demo.service // 业务层,处理业务逻辑 │ └── UserService.java ├── com.demo.servlet // 控制层,接收请求、调用业务、跳转页面 │ ├── LoginServlet.java │ ├── RegisterServlet.java │ ├── UserListServlet.java │ ├── UserUpdateServlet.java │ └── UserDeleteServlet.java └── com.demo.util // 工具类 └── DBUtil.java // 数据库连接管理

src/main/webapp目录下再放JSP页面和静态资源:

webapp ├── index.jsp // 首页/登录页 ├── register.jsp // 注册页 ├── userList.jsp // 用户列表页 ├── userUpdate.jsp // 修改用户页 ├── css/style.css // 极简样式 └── WEB-INF/web.xml // 若用注解可以留空

分层的逻辑很简单:Servlet只做两件事——接收HTTP请求参数、根据结果决定页面跳转。具体的数据库操作全部封装在DAO层。Service层在这个小项目里显得有点"多余",但它是以后项目扩展的必经之路——比如注册时要做用户名唯一性校验,登录时要校验密码,这些都属于业务规则,塞进Servlet里会让控制层变得臃肿,放进DAO层又越权了。

3.2 数据库连接:DBUtil的写法与优化

数据库连接这块儿,教科书上最经典的写法是用DriverManager.getConnection(),但每次都重新建立数据库连接开销很大。实际项目中常用连接池,比如C3P0、Druid。咱们这个项目为了简单,先用一个静态块加载驱动、再提供一个获取连接的方法:

package com.demo.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/user_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USERNAME = "root"; private static final String PASSWORD = "your_password"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USERNAME, PASSWORD); } public static void close(Connection conn, java.sql.Statement stmt, java.sql.ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (stmt != null) stmt.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }

这段代码里有两个坑得提醒一下。

第一,mysql-connector-java8.0以上版本的驱动类名是com.mysql.cj.jdbc.Driver,老版本是com.mysql.jdbc.Driver。如果用错了,会报ClassNotFoundException。如果你用的MySQL 5.7且驱动是5.x版本,那就用老驱动类名,这两个要匹配好。

第二,连接字符串里serverTimezone=Asia/Shanghai不能省,否则高版本驱动会报"Server returns invalid timezone"的异常。useSSL=false则是因为本地开发没必要对连接做SSL加密,省去一堆证书配置。

如果你用Maven,依赖这么加:

<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>

3.3 实体类的设计逻辑

实体类User.java对应数据库的user表,字段一一映射。注意用包装类Integer而不是基础类型int来定义id——因为数据库里这个字段是自增的,新增用户时还没值,如果实体类是int类型,默认值0会让程序分不清"没有id"和"id为0"的区别。虽然在这个场景没坑,但养成用包装类的习惯是好的。

package com.demo.entity; import java.util.Date; public class User { private Integer id; private String username; private String password; private String email; private String phone; private Date createTime; // 无参构造、有参构造 // getter/setter方法 // toString方法(方便调试打印) }

3.4 UserDao:核心的增删改查SQL

DAO层是整个项目的重头戏,所有的SQL语句拼接都在这里完成。我给你写一个完整的UserDao,注意看我在预处理语句和防止SQL注入上的处理:

package com.demo.dao; import com.demo.entity.User; import com.demo.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class UserDao { // 登录校验:根据用户名查询用户 public User findByUsername(String username) { String sql = "SELECT * FROM user WHERE username = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return mapRow(rs); } } } catch (SQLException e) { e.printStackTrace(); } return null; } // 注册:插入新用户 public int insert(User user) { String sql = "INSERT INTO user (username, password, email, phone) VALUES (?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getEmail()); ps.setString(4, user.getPhone()); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } // 查询所有用户 public List<User> findAll() { List<User> list = new ArrayList<>(); String sql = "SELECT * FROM user ORDER BY id DESC"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } catch (SQLException e) { e.printStackTrace(); } return list; } // 根据id查询用户 public User findById(int id) { String sql = "SELECT * FROM user WHERE id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return mapRow(rs); } } } catch (SQLException e) { e.printStackTrace(); } return null; } // 修改用户信息 public int update(User user) { String sql = "UPDATE user SET username = ?, password = ?, email = ?, phone = ? WHERE id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getEmail()); ps.setString(4, user.getPhone()); ps.setInt(5, user.getId()); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } // 删除用户 public int deleteById(int id) { String sql = "DELETE FROM user WHERE id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } private User mapRow(ResultSet rs) throws SQLException { User user = new User(); user.setId(rs.getInt("id")); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); user.setEmail(rs.getString("email")); user.setPhone(rs.getString("phone")); user.setCreateTime(rs.getTimestamp("create_time")); return user; } }

这里我用了JDK7开始支持的try-with-resources语法,Connection、PreparedStatement、ResultSet都自动关闭,代码看起来干净多了。注意PreparedStatement?占位符,参数通过setStringsetInt设置——千万不要用字符串拼接SQL,经典的SQL注入就是这么来的。比如用户输入用户名为' OR '1'='1,如果拼SQL就变成了SELECT * FROM user WHERE username = '' OR '1'='1',直接绕过登录。用PreparedStatement的话,这个输入会被当作参数值处理,不会改变SQL结构。

3.5 注册功能的完整实现

注册是用户接触到的第一个功能,核心逻辑分三步:校验用户名是否已存在、对密码加密处理、插入数据库。

先看UserService里的注册逻辑:

package com.demo.service; import com.demo.dao.UserDao; import com.demo.entity.User; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; public class UserService { private UserDao userDao = new UserDao(); // 注册 public String register(User user) { // 1. 检查用户名是否已存在 User existing = userDao.findByUsername(user.getUsername()); if (existing != null) { return "用户名已被注册"; } // 2. 密码加密(SHA-256加盐) String salt = generateSalt(); String hashedPassword = sha256(user.getPassword(), salt); user.setPassword(salt + ":" + hashedPassword); // 3. 插入数据库 int result = userDao.insert(user); if (result > 0) { return "注册成功"; } return "注册失败,请稍后重试"; } private String generateSalt() { // 实际项目中用SecureRandom生成随机盐 return "demo_salt_2023"; } private String sha256(String password, String salt) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] hash = md.digest((salt + password).getBytes(StandardCharsets.UTF_8)); StringBuilder hexString = new StringBuilder(); for (byte b : hash) { String hex = Integer.toHexString(0xff & b); if (hex.length() == 1) hexString.append('0'); hexString.append(hex); } return hexString.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } }

密码存明文是绝对不可取的。我见过不少课设项目直接把password字段原样存进数据库,这要是被导师看到,审查的时候免不了一顿批。所以这里用SHA-256加盐做哈希,数据库里存的是"盐值:哈希值"格式,登录时取出盐值再算一遍比对。虽然这个项目的密码安全级别还达不到生产标准,但至少体现出了"密码不能明文存储"的安全意识。

对应的RegisterServlet:

package com.demo.servlet; import com.demo.entity.User; import com.demo.service.UserService; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; @WebServlet("/register") public class RegisterServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); String email = request.getParameter("email"); String phone = request.getParameter("phone"); // 基础校验 if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { request.setAttribute("msg", "用户名和密码不能为空"); request.getRequestDispatcher("/register.jsp").forward(request, response); return; } User user = new User(); user.setUsername(username.trim()); user.setPassword(password); user.setEmail(email); user.setPhone(phone); String result = userService.register(user); if ("注册成功".equals(result)) { response.sendRedirect(request.getContextPath() + "/login.jsp?registered=true"); } else { request.setAttribute("msg", result); request.getRequestDispatcher("/register.jsp").forward(request, response); } } }

注意看两个细节。

第一,request.setCharacterEncoding("UTF-8")必须放在获取参数之前,否则中文用户名会乱码。这个方法的本质是告诉容器,请求体里的参数用什么字符集解码。

第二,注册成功后我用的是response.sendRedirect()也就是重定向,而注册失败时用的是request.getRequestDispatcher().forward()也就是转发。区别很关键:重定向是浏览器再发起一次新的请求,地址栏会变成login.jsp,刷新页面不会重复提交表单;转发是服务器内部跳转,地址栏不变,如果用户刷新页面,浏览器会重新提交上一次的POST请求,造成重复注册。所以"表单提交后跳转"这个场景,重定向是正解。这个也被称为Post/Redirect/Get模式。

3.6 登录功能与会话管理

登录功能是核心中的核心。教科书上的登录无非三步:接收参数 → 查库校验 → 跳转。但真正落地时有几个细节没处理好,就会让你在演示时当众翻车。

先说会话管理。登录成功后,把用户信息放进Session:

@WebServlet("/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); if (username == null || password == null || username.trim().isEmpty() || password.isEmpty()) { request.setAttribute("msg", "用户名和密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } User user = userService.login(username, password); if (user != null) { // 登录成功:关键信息存Session HttpSession session = request.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); // 30分钟超时 response.sendRedirect(request.getContextPath() + "/userList"); } else { request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } }

注意登录逻辑里不要用findByUsername查出User后,再拿密码做字符串比对。更好的做法是在SQL层面按用户名和密码一起查:

public User login(String username, String password) { String sql = "SELECT * FROM user WHERE username = ? AND password = ?"; ... }

但这跟加盐哈希存储方式冲突了,所以业务层要先取出用户、再验密码:

public User login(String username, String rawPassword) { User user = userDao.findByUsername(username); if (user == null) return null; String stored = user.getPassword(); // 格式:salt:hash String[] parts = stored.split(":"); if (parts.length != 2) return null; String salt = parts[0]; String expectedHash = parts[1]; String actualHash = sha256(rawPassword, salt); if (expectedHash.equals(actualHash)) { return user; } return null; }

Session存用户信息之后,要加一个登录校验过滤器或者在每个Servlet里手动检查。简单起见,在UserListServlet这类需要登录才能访问的页面前加一段校验逻辑:

HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; }

这段代码很朴素,但它体现了一个重要的概念:会话管理。HTTP协议本身是无状态的,服务器不记得每个请求是谁发来的。Session机制通过给浏览器发一个Cookie(JSESSIONID),下次请求时带着这个ID,服务器就能识别出是同一个用户了。你如果不理解这一层,后面学Spring Security、Shiro的时候会一头雾水。

3.7 用户列表、修改与删除

列表页的逻辑最简单,查出所有用户,在JSP里遍历展示。

UserListServlet:

@WebServlet("/userList") public class UserListServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 登录校验 HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } List<User> userList = userService.findAll(); request.setAttribute("userList", userList); request.getRequestDispatcher("/userList.jsp").forward(request, response); } }

userList.jsp的核心部分用JSTL或者最原始的Java代码循环都行。我用JSTL的方式,因为以后你接触Spring Boot + Thymeleaf会非常熟悉这种语法:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>用户列表</title> </head> <body> <h2>用户列表</h2> <table border="1" cellpadding="8" cellspacing="0"> <tr> <th>ID</th> <th>用户名</th> <th>邮箱</th> <th>手机号</th> <th>注册时间</th> <th>操作</th> </tr> <c:forEach items="${userList}" var="user"> <tr> <td>${user.id}</td> <td>${user.username}</td> <td>${user.email}</td> <td>${user.phone}</td> <td>${user.createTime}</td> <td> <a href="${pageContext.request.contextPath}/userEdit?id=${user.id}">修改</a> <a href="${pageContext.request.contextPath}/userDelete?id=${user.id}" onclick="return confirm('确定要删除该用户吗?')">删除</a> </td> </tr> </c:forEach> </table> </body> </html>

如果你不想引JSTL依赖,用Java代码嵌在JSP里也可以,但页面会显得很乱。建议还是花十秒钟把JSTL依赖加到pom里:

<dependency> <groupId>jstl</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>

修改功能分两步走:先根据id查出用户信息,回显在修改页面上;用户提交新数据后,走UPDATE语句更新。

@WebServlet("/userEdit") public class UserEditServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int id = Integer.parseInt(request.getParameter("id")); User user = userService.findById(id); request.setAttribute("user", user); request.getRequestDispatcher("/userUpdate.jsp").forward(request, response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); int id = Integer.parseInt(request.getParameter("id")); String username = request.getParameter("username"); String email = request.getParameter("email"); String phone = request.getParameter("phone"); User user = new User(); user.setId(id); user.setUsername(username); user.setEmail(email); user.setPhone(phone); userService.update(user); response.sendRedirect(request.getContextPath() + "/userList"); } }

修改页面上密码字段怎么处理?有两种策略:一是在修改页面上放一个密码输入框,用户不填就不改(保持原密码),填了就更新;二是修改密码走单独的页面。第一种更常用,因为"修改个人资料"一般不改密码。我在UpdateServlet里做了个判断:如果password参数非空,则加密后更新密码字段;否则不动密码。

删除功能更简单,接收id,执行DELETE语句,重定向回列表页:

@WebServlet("/userDelete") public class UserDeleteServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int id = Integer.parseInt(request.getParameter("id")); userService.deleteById(id); response.sendRedirect(request.getContextPath() + "/userList"); } }

这里有个安全小坑:删除操作一般不用GET方式,因为GET请求可以通过URL直接触发(比如爬虫、预加载、甚至<img>标签都能发起GET请求)。专业点的做法是改成POST,配合表单提交。不过课设项目里用GET加JS确认弹窗也够用了,这里提一嘴让你心里有数。

3.8 部署到Tomcat:Windows Server + Apache 的发布场景

热搜词里有"winserver apache + tomcat 发布javaweb项目",说明有人需要把项目部署到服务器上。本地IDEA里跑Tomcat很简单,部署到独立服务器上又是另一套操作。

步骤其实不复杂。把项目打成war包,IDEA里Maven面板执行package,会在target目录生成xxx.war。把这个war包拷贝到Tomcat的webapps目录下,启动Tomcat(Linux下是startup.sh,Windows下是startup.bat),Tomcat会自动解压部署,访问路径就是http://服务器IP:8080/包名/

如果你希望用80端口对外提供访问,常见做法是前面放一个Apache或者Nginx做反向代理,把80端口流量转发到Tomcat的8080端口。Apache的虚拟主机配置大致是这样:

<VirtualHost *:80> ServerName yourdomain.com ProxyPreserveHost On ProxyPass / http://localhost:8080/javaweb_demo/ ProxyPassReverse / http://localhost:8080/javaweb_demo/ </VirtualHost>

注意JSP里如果写死了路径前缀/javaweb_demo/,反向代理后会有一堆路径问题。这也是为什么我在所有JSP里都用${pageContext.request.contextPath}动态获取上下文路径——这是个好习惯,开发时在本机跑、部署到服务器也不用手动改代码。

4. 常见问题与排查技巧实录

4.1 404、500、ClassNotFoundException:三大经典报错

项目跑不起来,九成是下面四个问题之一:

问题一:404页面找不到。先看URL路径写没写对——Servlet的@WebServlet("/xxx")注解路径是一回事,JSP的物理路径是另一回事。请求/userList走的是UserListServlet,请求/userList.jsp直接访问的是物理文件。你如果没有在web.xml里配置欢迎页,直接访问根路径可能啥都看不到。排查方法:看IDEA控制台里的Tomcat日志,它会打印"Deploying web application archive"之类的信息,能看到实际部署的应用路径。

问题二:500服务器内部错误。最常见原因是数据库连接失败。把IDEA控制台里的异常栈完整看一遍,如果看到Communications link failure,就是MySQL没启动或者端口不对;Access denied for user就是账号密码错误;Unknown database就是库名写错。

问题三:ClassNotFoundException: com.mysql.jdbc.Driver。典型原因是Maven依赖没加或者驱动jar包没打进去。检查pom.xml里有没有mysql-connector-java依赖,以及war包结构里WEB-INF/lib下有没有显示这个jar。如果手动导包,记得把jar放在WEB-INF/lib目录下,而不是src目录下。

问题四:HTTP状态405 —— HTTP method POST is not supported by this URL。Servlet里只重写了doGet,但表单的method是post。反过来也一样,doPost写了但链接跳转是GET访问。解决办法是保持Servlet里两个方法都重写,或者让doGet调用doPost方法,二选一。

4.2 中文乱码:一个字符集问题引发的连锁反应

中文乱码是JavaWeb新手最头疼的问题之一。要根治乱码,需要全程统一字符集。我梳理一下乱码可能出现的所有环节以及对策:

环节解决方式
请求参数中文乱码request.setCharacterEncoding("UTF-8")(POST);Tomcat 9默认UTF-8(GET)
响应页面中文乱码JSP第一行声明<%@ page contentType="text/html;charset=UTF-8" %>
数据库乱码JDBC连接串加characterEncoding=utf8;建库时用utf8mb4
浏览器显示乱码确保HTML的<meta charset="UTF-8">存在

我遇到过一种隐蔽的乱码场景:数据库里存的数据是好的,JSP页面用${user.username}显示就乱码,改成<%=((User)request.getAttribute("user")).getUsername()%>就正常。查了半天,原来是IDEA里JSP文件本身的编码是GBK,文件里插入中文写死了编码,编译后字节流对不上。解决:在IDEA设置里把File Encoding全改成UTF-8,已存在的文件手动File → File Properties → File Encoding转换。

4.3 MySQL 8.0的时区与驱动坑

MySQL 8.0发布后,很多人从5.7升级发现原来的项目连不上,报错The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。这个乱码一样的提示其实是"中国标准时间"被错误解码了。解决办法就是前面DBUtil连接串里加的serverTimezone=Asia/Shanghai。如果用的是MySQL 8.0.23以上的驱动,还可以直接用serverTimezone=UTC,不过存时间字段会有8小时时差,还是用Asia/Shanghai更直观。

4.4 修改功能里密码不更新的隐藏坑

很多人在做修改功能时,把密码框留空,结果数据库里的密码被更新成了空字符串,下次登录直接失败。原因很简单——UPDATE语句里把password字段一起更新了。

解决办法有两个:要么在Service层判断密码字段是否为空,为空就不更新password;要么在UpdateServlet里单独处理。我在代码里用的策略是:如果password参数为null或空字符串,就从User对象中去掉密码字段,构造一个不含密码的UPDATE语句。这个逻辑看起来啰嗦,但它避免了一个非常容易踩的坑。如果你不想写动态SQL,简单办法是修改页面的密码框不跟其他字段放同一个表单,或者默认在密码框里填入当前用户的密码哈希值,用户不填就等于提交原来的值——但这会把哈希值暴露在页面上,不够优雅。

4.5 Session失效与登录状态丢失

本地测试一切正常,部署到服务器后,明明登录成功了,点一下其他功能又跳回登录页。这种问题大概率是Session没有保持住。排查思路:浏览器F12打开开发者工具,切到Network面板,点击那个跳转请求,看响应和后续请求的Cookie里有没有JSESSIONID;如果没有,说明浏览器可能禁止了Cookie,或者应用上下文路径改变了导致Cookie的Path不匹配。第三种可能是Tomcat配置了session超时时间太短。Tomcat的Web应用默认超时是30分钟,但你在server.xml或web.xml里可以配置,检查一下别改成了1分钟。

5. 进阶思考:这个项目还能怎么扩展

在上面这些功能都跑通之后,我建议你别急着交作业。试着往这个项目里加一点东西,你会学到更多。

第一个方向是增加"记住我"功能。原理是登录成功后,把用户信息加密后写入Cookie,设置过期时间7天;下次访问时过滤器检查Session没有登录信息,就去查Cookie里的记住我凭证,自动恢复登录状态。这涉及Cookie的加密解密、HttpOnly属性、Secure标志等知识点。

第二个方向是给密码加更严密的存储方案。SHA-256加盐已经比明文好多了,但现代密码存储推荐用BCrypt,它是一种自适应哈希算法,可以人为让计算变慢(每次约100ms),暴力破解的成本大幅提升。Java里可以用jBCrypt库,几行代码就搞定,替换成本很低。

第三个方向是做分页和模糊查询。当用户表数据量大了以后,SELECT * FROM user会越来越慢,这时候可以加一个LIMIT offset, pageSize的分页查询,配合LIKE '%keyword%'做用户名模糊搜索。这是所有后台管理系统都逃不开的通用需求,做一次就彻底掌握了。

第四个方向是为这个项目加一个Filter统一处理编码和登录校验。现在每个Servlet里都重复写一段登录判断,有了Filter,可以在请求进入Servlet之前统一拦截,代码干净很多。等以后学SpringMVC,你会发现在DispatcherServlet前加一个拦截器干的也是同样的事。

我个人在实际操作中的体会是:细节决定体验。注册成功后要不要自动跳到登录页?列表页删除前要不要加确认提示?修改完是回到列表还是回到详情?这些一个个的小决策,堆在一起就是项目的完整度。踩过几次坑之后你就会发现,真正买你账的,不是某个炫酷的技术,而是这些看着不起眼,但真实影响使用者感受的小设计。希望这篇内容能帮你把这个经典项目做扎实——后面不管你是要应付课设,还是继续往Spring生态走,今天折腾清楚的这些东西,都会有它的用武之地。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 3:56:58

跨窗口通信设计指南:从编辑器到运行时的实时同步方案

先说清楚&#xff1a;这篇文章里的“从编辑器到运行时”&#xff0c;不是讲编译器&#xff0c;也不是讲IDE插件的内部机制&#xff0c;而是Web前端架构里特别常见、但又特别容易被写成一坨if/else的一类问题——一个可视化编辑器窗口&#xff0c;要把内容或配置实时同步给另一个…

作者头像 李华
网站建设 2026/9/9 3:54:42

如何让AI生成测试用例不重复:从等价类到Embedding相似度的去重实践

不得不说&#xff0c;第一次把 AI 接进测试用例生成的时候&#xff0c;我是有点兴奋的。输入一段需求描述&#xff0c;几十条用例唰一下就出来了&#xff0c;格式工整、步骤齐全&#xff0c;看起来很专业。但等我把这批用例交给测试组评审&#xff0c;人家看完第一页就皱眉了&a…

作者头像 李华
网站建设 2026/9/9 3:54:16

从马尾辫的底层逻辑到实操避坑:皮筋、高度、脸型适配全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 3:53:44

2026届嵌入式校招全攻略:核心技能图谱与面试实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 3:53:37

深入理解IO多路转接:select与poll底层原理、实现细节和避坑指南

如果你用原生 socket 写过服务器&#xff0c;多半见过这个场景&#xff1a;服务器 accept 了一个客户端之后&#xff0c;如果没有额外写并发逻辑&#xff0c;它就会一直阻塞在 read/recv 那里干等数据&#xff0c;第二个客户端想连进来也只能排在后面。这是阻塞 IO 最直接的…

作者头像 李华
网站建设 2026/9/9 3:53:10

2026网站建设公司排行:排核验内容维护、询盘承接与三年服务成本

摘要&#xff1a;网站建设公司排行的决策重点不在于找到一个名称靠前的工具或公司&#xff0c;而在于确认栏目管理、产品参数、案例更新、表单通知、搜索基础设置和账号归属能否由真实人员持续完成。CNNIC第54次报告显示&#xff0c;截至2024年6月&#xff0c;中国网民规模为10…

作者头像 李华