简介一份面向Java Web初学者的入门级Servlet/JSP综合练手项目整体围绕MiniWebSystem展开。项目覆盖注册、登录、商品添加与商品浏览等典型CRUD场景代码量适中麻雀虽小五脏俱全便于快速建立对JSP和Servlet协作机制的直观认识。资源打包为RAR格式共56个文件其中包含18个Java源文件主要对应Servlet控制类和业务类、18个编译后的class文件、5个JSP视图页面、4个第三方jar包、2个XML配置文件以及project、classpath、.mymetadata等工程文件压缩包整体约6.35MB下载后解压即可导入MyEclipse/Eclipse使用。通过对照源码与JSP页面的配合可了解请求如何从页面表单提交到Servlet再由Servlet完成业务处理并转发响应页面的完整过程同时也能看到JDBC连接数据库的常见写法示例代码注释也相对清晰。目前已有1386人学习下载对于刚接触Java Web、需要结合实例快速入门的初学者很有参考价值。1. 这个 Servlet/JSP 例子到底在解决什么问题很多初学者在学完 Java 基础之后会卡在一个尴尬的位置直接学 Spring Boot搞不清楚 HTTP 请求、Servlet 容器、Session 这些底层概念回头再看纯 Java又不知道网络编程和 Web 有什么关系。这个 Servlet JSP 的实用例子就是为了把这个断层补上——它带你用手写 Servlet 的方式完整走一遍「浏览器发起请求 → 容器调用 Servlet → Servlet 读写数据库 → JSP 渲染页面 → 返回响应」的闭环。我这里要讲的项目例子是一个带数据库的「用户注册与列表管理」小系统麻雀虽小但请求参数处理、GET/POST 分发、JDBC 访问、Session 状态保存、JSP JSTL 渲染这些 Java Web 初学阶段必须掌握的东西全都覆盖了。你照着敲完不会只得到几个孤立的概念而是拥有一个能部署到 Tomcat 上、真正能注册登录、能展示数据的小站点。适合刚学完 Java SE、还没接触框架、想亲手跑通一次完整 Web 请求链路的同学也适合需要给学生做课堂实战的指导者。2. Servlet/JSP 项目的技术选型为什么不用框架以及需要哪些准备2.1 先理解 Servlet 在请求链路里的位置初学者最大的困惑是「Servlet 到底是个什么」。简单说Servlet 是运行在 Web 容器里的一组 Java 类它接收 HTTP 请求处理后把响应写回给浏览器。Tomcat 就是最常见的容器。当你访问http://localhost:8080/app/register时Tomcat 会根据部署描述符web.xml或注解把路径/register映射到某个 Servlet 类的doGet或doPost方法。这个映射关系就是你要手写的第一行「业务入口」。JSP 则是 Servlet 的补充Servlet 里拼接 HTML 非常痛苦JSP 允许你在 HTML 里嵌入 Java 代码或 EL 表达式容器会把 JSP 编译成 Servlet 再执行。所以一个实用例子的核心分工是Servlet 做请求接收、业务处理、数据准备JSP 只负责显示。2.2 推荐的环境与版本组合我建议初学者直接用最稳定的组合不要追新JDK 8 或 11。两者都够用注意 Tomcat 版本对 JDK 有要求。Tomcat 9.x。它对应 Servlet 4.0 规范支持WebServlet注解不用再手工维护大量web.xml配置。MySQL 5.7 或 8.0配合 MySQL Connector/J 8.0 驱动。Maven 3.6用于管理依赖和打 war 包。IDE 用带 Tomcat 插件的版本即可自己会配部署路径就行。Maven 的pom.xml里最核心的依赖如下dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependencies注意前两个依赖的scope是provided因为 Tomcat 自己带了 Servlet/JSP 的实现类打 war 包时不能把它们的 jar 打进去否则会和容器冲突。JSTL 则必须随项目走容器不带它。2.3 一个能跑起来的项目目录长什么样用 Maven 创建的 web 项目目录结构是固定的。别用自己的 随手建文件夹 方式否则打包和部署会出各种奇怪问题。my-web-demo/ ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com/example/demo │ │ │ ├── servlet │ │ │ │ ├── RegisterServlet.java │ │ │ │ ├── LoginServlet.java │ │ │ │ └── UserListServlet.java │ │ │ └── util │ │ │ └── DBUtil.java │ │ └── webapp │ │ ├── WEB-INF │ │ │ └── web.xml │ │ ├── register.jsp │ │ ├── login.jsp │ │ └── userList.jsp └── targetwebapp目录是 Web 根目录浏览器可以直接访问到 JSP 和静态资源。WEB-INF下的web.xml是部署描述符即使你全部用注解也建议保留一个空壳文件因为 Tomcat 会检查它的存在。java目录下按包名组织源代码这里我把 Servlet 和工具类分开实用项目至少要有这个分层意识。3. 从数据到页面实现一个完整的用户注册与列表功能3.1 建表与数据库连接DBUtil 的写法首先在 MySQL 里建一张用户表字段不要太多够演示就好CREATE DATABASE web_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE web_demo; CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );密码字段我没有用明文但当前例子为了专注流程先存简单哈希或原文后面可以考虑BCrypt。这里注意utf8mb4是必须的否则用户输入 emoji 或生僻字会乱码。然后写一个DBUtil把获取连接和关闭资源的逻辑收拢。初学者最容易犯的错是每个 Servlet 里都写一遍Class.forName和DriverManager.getConnection代码重复且容易漏关资源。DBUtil的常见写法如下package com.example.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/web_demo?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 你的数据库密码; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL驱动加载失败); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这段代码里有两个关键参数serverTimezoneAsia/Shanghai是因为 MySQL 8.0 默认时区可能与本地不同不加会报 The server time zone value 的异常characterEncodingutf8保证连接层面的编码与数据库一致。静态块里加载驱动保证整个应用生命周期里只加载一次。ExceptionInInitializerError是包装错误当你看到这个异常时第一反应就应该是去查驱动的坐标有没有写错。3.2 第一个 Servlet接收表单参数并写入数据库在RegisterServlet.java里我们处理来自注册页面的 POST 请求。用注解/register映射路径省去在web.xml里写一堆servlet-mapping的麻烦。package com.example.demo.servlet; import com.example.demo.util.DBUtil; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; WebServlet(/register) public class RegisterServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 设置请求和响应编码防止中文乱码 req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); // 2. 接收参数 String username req.getParameter(username); String password req.getParameter(password); // 3. 简单校验 if (username null || username.trim().isEmpty() || password null || password.trim().isEmpty()) { resp.getWriter().write(用户名和密码不能为空); return; } // 4. 写入数据库 String sql INSERT INTO user (username, password) VALUES (?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); int rows ps.executeUpdate(); if (rows 0) { // 注册成功后重定向到登录页避免刷新重复提交 resp.sendRedirect(req.getContextPath() /login.jsp); } else { resp.getWriter().write(注册失败); } } catch (SQLException e) { // 如果是唯一键冲突说明用户名重复 if (e.getMessage().contains(Duplicate entry)) { resp.getWriter().write(用户名已存在); } else { throw new ServletException(数据库插入失败, e); } } } }这里有一个所有初学者都应该记住的细节req.setCharacterEncoding(UTF-8)必须放在第一次读取参数之前放在doPost开头是最稳妥的。resp.setContentType设置的是浏览器端解析响应用的编码两行缺一不可。sendRedirect用的是重定向比forward更适合注册成功后的场景因为刷新生效也不会再次提交表单。PreparedStatement 的占位符写法是 SQL 注入的最佳防御手段千万别用字符串拼接 SQL哪怕只是练习也要养成这个习惯。try-with-resources是 Java 7 以后的写法连接、语句自动关闭不会漏关。3.3 JSP 页面里怎么显示数据EL 表达式和 JSTL 标签用户列表页面是 JSP 最典型的使用场景。先用UserListServlet查询数据然后把结果放到request域里转发给 JSP。JSP 里的脚本片段% %能用但一旦页面复杂就会变成一锅粥。实用项目里我用 EL 表达式$(...)加 JSTL 的c:forEach来遍历。先写UserListServletpackage com.example.demo.servlet; import com.example.demo.util.DBUtil; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; WebServlet(/userList) public class UserListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); ListUser users new ArrayList(); String sql SELECT id, username, created_at FROM user ORDER BY id DESC; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { User u new User(); u.setId(rs.getInt(id)); u.setUsername(rs.getString(username)); u.setCreatedAt(rs.getTimestamp(created_at)); users.add(u); } } catch (SQLException e) { throw new ServletException(查询用户列表失败, e); } req.setAttribute(users, users); req.getRequestDispatcher(/userList.jsp).forward(req, resp); } }这段代码里我用了User这个简单 JavaBean它只有id、username、createdAt三个属性和对应的 getter/setter。request.setAttribute把数据放入请求域然后转发到 JSP。转发和重定向的区别是转发发生在服务端地址栏不变request 域里的属性可以传递重定向是浏览器发起第二次请求request 域里的东西全部丢失。userList.jsp的核心片段如下% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html body h2用户列表/h2 table border1 tr thID/th th用户名/th th注册时间/th /tr c:forEach items${users} varu tr td${u.id}/td td${u.username}/td td${u.createdAt}/td /tr /c:forEach /table /body /html如果你在页面上看到${u.username}没有变成具体值而是原样输出三个原因最可能一是 JSTL 依赖没有引入二是% taglib %指令写错三是容器版本与 JSTL 版本不兼容。Tomcat 9 自带的 JSP 引擎需要 JSTL 1.2注意 pom 里配置的版本号不要随便用 1.0。3.4 把登录、删除、编辑也补上Servlet 路径设计的习惯光有注册和列表还不够一个实用的例子必须带登录否则 Session 没法体现。登录 Servlet 的doPost里校验用户名密码成功后用req.getSession().setAttribute(loginUser, username)保存登录状态然后重定向到列表页。后续要判断用户是否登录只需要检查 Session 里有没有这个属性。对于删除和编辑功能常见做法是每个功能一个 Servlet路径用/deleteUser?id1这样的格式。这里有一个很重要的经验不要在路径里用?作为资源标识的唯一方式但删除操作用 GET 参数也没什么大问题因为初学者还没涉及 RESTful API。建议把删除做成 POST 提交使用表单或 JavaScript 触发避免爬虫或浏览器预取误删数据。请求路径的规划直接决定了项目后期能不能看懂。我自己的习惯是所有 Servlet 的WebServlet路径统一小写动词在前资源在后比如/user/register、/user/login、/user/list、/user/delete。如果你的例子项目已经有多个 Servlet建议一开始就按模块区分包名别让所有 Servlet 堆在同一个包下面。4. 部署到 Tomcat从 war 包到本地调试的完整过程4.1 用 Maven 打包并部署在项目根目录执行mvn clean packageMaven 会编译、测试、把项目打成my-web-demo.war。第一次打包前检查你的pom.xml里是否有packagingwar/packaging否则打出来不是可部署的 web 包。然后把 war 复制到 Tomcat 的webapps目录下面重启 Tomcat访问http://localhost:8080/my-web-demo/register.jsp就能看到页面。如果你用的是 IDE 内的 Tomcat 插件通常右键项目选择 Run on Server 即可IDE 会自动部署。有一点要特别注意Tomcat 解压 war 时会把 context 路径默认设为 war 包文件名。所以访问路径是http://localhost:8080/my-web-demo/不是http://localhost:8080/。如果你希望根路径直接访问需要把 war 改名为ROOT.war或在 Tomcat 的server.xml里配置Context。初学者经常在地址里漏掉这个上下文路径然后报 404。4.2 本地调试时容易忽略的编码与路径问题编码问题在 Windows 本地开发时尤其突出。Tomcat 的配置文件conf/server.xml里连接器可以加上URIEncodingUTF-8这个配置影响的是请求 URI 里的参数编码。如果你发现 GET 请求的中文参数乱码而 POST 正常多半是这里没配置。另外 JSP 页面顶部一定要有contentTypetext/html;charsetUTF-8HTML 里的meta charset也要保持一致否则浏览器会用自身默认编码去猜猜错就是乱码。路径问题最常见的是在 Servlet 里写死了/login.jsp没有加req.getContextPath()。当你的项目部署在根路径时两者没区别一旦部署为my-web-demo上下文写死路径就会全部 404。正确写法是resp.sendRedirect(req.getContextPath() /login.jsp);JSP 页面里的链接也一样所有以/开头的路径都应带上${pageContext.request.contextPath}。这个前缀就是req.getContextPath()在 EL 里的等价写法。4.3 断点调试与日志初学者最快的排错手段我见过太多初学者用System.out.println逐行打印然后还是找不到问题在哪。正确的习惯是学会用 IDE 的断点。在 Servlet 的doPost第一行打上断点以 Debug 模式重启 Tomcat浏览器提交表单后IDE 会停在断点上。这时可以逐行查看req.getParameter(username)到底是什么值、SQL 语句执行到哪一步异常。如果你没有 Debug 条件那就得靠日志。别在代码里裸写System.out.println至少用简单的方式包装一层或者直接使用 Tomcat 的控制台输出。关键是日志里要带上下文比如System.out.println(【RegisterServlet】username username);而不是只打印一个变量名。另外MySQL 连接失败时的日志经常把密码错误和连接超时混在一起注意看堆栈里最内层的Caused by那才是问题根源。如果真的要看 SQL 执行情况可以在 JDBC URL 上加?useSSLfalseallowPublicKeyRetrievaltrue解决一部分连接问题剩下的就用 IDE 的 Debugger 慢慢看。5. 避坑指南Servlet/JSP 初学者最容易翻车的 6 个坑5.1 现象JSP 页面取不到request.getParameter的值一直为 null你写了表单提交Servlet 里req.getParameter(username)却得到 null。常见原因是表单输入控件的name属性和 Servlet 里取的名字不一致。初学者经常把id和name弄混id是给 CSS/JavaScript 用的服务端只看name。检查register.jsp里是否写的是input typetext nameusername而不是只有idusername。另一个原因是你用了doGet处理 POST 提交。请确认表单的methodpost同时 Servlet 里重写的是doPost方法。如果你只写了doGetPOST 请求不会进入该方法反而会有 405 或什么都不发生。5.2 现象全站中文乱码数据库里存进去也是问号这是最典型的编码连环坑。解决顺序是第一步JSP 页面顶部写% page contentTypetext/html;charsetUTF-8 %第二步Servlet 里req.setCharacterEncoding(UTF-8)必须放在读取参数前第三步数据库连接 URL 加characterEncodingutf8第四步MySQL 表本身用utf8mb4。四步缺一不可。如果你做了前三步还是乱码检查你的web.xml里有没有配置 CharacterEncodingFilter。Tomcat 8 以上req.setCharacterEncoding对于 POST 生效但 GET 请求的参数编码由URIEncoding控制所以server.xml里的配置也别漏。5.3 现象访问login.jsp报 404但启动没有报错404 的原因要从两个方向查。第一文件是不是真的放在src/main/webapp下而不是放在src/main/java下。Maven 项目里只有webapp目录下的文件才会被打进 war 包。第二你的访问路径是否带了上下文路径。如果 war 包名是my-web-demo.war路径应该是http://localhost:8080/my-web-demo/login.jsp。很多初学者用 IDE 启动 Tomcat 后IDE 的部署上下文是自动生成的和 war 名不一致。在 IDE 的 Run Configuration 里把 Application context 改成/或你自己定义的路径能省去很多困惑。5.4 现象启动时ClassNotFoundException: com.mysql.cj.jdbc.Driver这个异常几乎都是依赖没有被打进最终部署包导致的。检查三处pom.xml里 mysql-connector-java 是否没有scopeprovided/scope如果误加了上去运行时容器没有它打包后看 war 文件里WEB-INF/lib目录下有没有mysql-connector-java-8.0.33.jar用 IDE 部署时是否勾选了 Deploy all Maven dependencies into WEB-INF/lib。另外老版本驱动用com.mysql.jdbc.Driver新版本是com.mysql.cj.jdbc.Driver写错类名同样找不到。5.5 现象登录成功后刷新一下又变成未登录Session 失效是初学阶段最容易被忽略的。原因之一是 Tomcat 的默认 Session 超时是 30 分钟但你在调试时反复重启 Tomcat每次重启都会清空内存里的 Session浏览器带的 JSESSIONID 就失效了。另一个原因是浏览器禁用了 CookieTomcat 会尝试把 SessionID 拼到 URL 上但你直接访问地址栏没有那个参数自然就认为是新用户。想验证 Session 是否存在可以在登录 Servlet 里打印req.getSession(false)是否为 null。注意getSession(false)不会主动创建新 Session而getSession(true)会这两个方法在判断登录状态时语义不同。如果项目里需要保存登录状态建议统一用 Filter 拦截判断而不是每个 Servlet 里重复写判断代码。5.6 现象修改了 JSP 页面或 Servlet 代码刷新没变化Tomcat 对 JSP 有热部署但修改java文件后如果 IDE 没有正确触发重新编译你看到的还是旧 class。最稳妥的做法是如果是 Servlet 代码手动重启 Tomcat如果是 JSP 页面确认 Tomcat 的conf/web.xml里对 JSP 检查更新的时间间隔不是负值。默认是 4 秒但 IDE 的 Debug 模式有时不会把新文件同步到部署目录。可以找到 Tomcat 的部署目录IDE 控制台会打印看看WEB-INF/classes下的.class文件修改时间是否更新。如果不是重新执行mvn clean compile后再部署。6. 把这个例子升级成能拿得出手的小项目两个必学技巧6.1 用 Filter 统一处理全局编码与登录校验你在每个 Servlet 里写req.setCharacterEncoding(UTF-8)很啰嗦而且容易漏。正确做法是写一个EncodingFilter用它拦截所有请求package com.example.demo.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); chain.doFilter(req, resp); } }登录校验 Filter 可以拦截除了login.jsp、LoginServlet、register.jsp和RegisterServlet以外的所有路径检查 Session 中是否有登录标记没有则重定向到登录页。这个思路就是很多框架里拦截器的原型。你把这个例子做完后动手封装这两个 Filter就真正理解了「横切逻辑」的价值。6.2 用统一返回封装和异常页面Servlet 里直接resp.getWriter().write(中文)会让客户端拿到纯文本不是好习惯。实用项目建议 JSP 作为唯一视图Servlet 里尽量用forward跳转并且把错误信息放到request域里。比如注册失败时req.setAttribute(error, 用户名已存在); req.getRequestDispatcher(/register.jsp).forward(req, resp);然后在register.jsp页面上用${error}显示。另外为项目配置一个统一的 404/500 错误页面也没有坏处在web.xml里加error-page配置用户访问错误路径时不会看到默认的异常堆栈。这个细节能让你在演示或平时自己测试时观感更像一个正规项目。6.3 跑完这个例子后你应该能回答的 5 个问题浏览器访问一个 Servlet 路径时Tomcat 是怎么找到对应类的GET 和 POST 在 Servlet 中分别由哪个方法处理编码问题有什么不同转发与重定向在 request 域数据传递上的差异是什么为什么 JDBC 必须用 PreparedStatement而不是字符串拼接 SQLSession 依赖什么机制记住“登录过的人”如果这 5 个问题你都能不看代码讲清楚说明你不是在抄例子而是在理解例子。我的习惯是每做完一个功能就关闭浏览器、重启 Tomcat然后只看地址栏和页面输出反推请求经历了哪些类、方法、参数。这个过程远比多看几个 Hello World 有价值。希望这个例子能帮你顺利跨过 Java Web 的起步门槛。本文还有配套的精品资源点击获取