简介:这是一个留言板系统的完整实现项目,基于原生Servlet作为服务端核心,结合Vue构建前端页面,使用MySQL存储留言及用户数据,整体技术栈经典且易于上手。项目共有122个文件,主要包含Java源码与编译产物、Vue页面组件、XML配置、JS脚本以及数据库相关文件,压缩包大小13.47MB。从内容可看出,系统实现了登录验证、留言列表展示、回复处理等核心Servlet类,覆盖了用户注册登录、发表留言、查看留言、管理员回复的完整业务流程,同时还有数据库脚本和说明文档辅助搭建环境。目录组织清晰,源码、配置、前端资源分类存放,并配有使用说明,方便直接导入到IDE中运行学习。当前已有96人学习过该项目,特别适合计算机专业学生完成课程设计或毕业设计时参考,也适合希望理解Servlet与Vue分离开发模式的开发者作为练习项目。
1. 用原生Servlet+Vue+MySQL做留言板:一条绕开框架的完整请求链路
把留言板从 Spring Boot 换回原生 Servlet,会发现一个反直觉的事实:写显式 API 比写自动化更让人理解容器。这个标题里三个词没有一个难到需要查文档,但把它们串起来,却是很多从业者第一次亲手画出完整请求链路的机会——浏览器里的 axios 发出请求,Tomcat 的线程池把 HttpServletRequest 交给你的 doGet,JDBC 把 SQL 送进 MySQL,再原路返回渲染到 Vue 的 data 里。如果你简历里写过 Spring Boot 却解释不清 DispatcherServlet 和 ServletContext 的差异,或者调过 Vue 接口但不知道静态资源到底由谁托管,这组技术组合就是最廉价的补课路径。它还解决一个实际问题:给内部团队临时搭的反馈板、给课程设计交的作业、给老系统做的运维小工具,都不值得为此引入一个全家桶框架。
2. 环境与目录规划:Tomcat 9、pom 依赖和 MySQL 8.0 的选型
2.1 为什么是 Tomcat 9 + javax.servlet,而不是 Tomcat 10
从 Spring Boot 过来的人,最容易在 Tomcat 版本上栽跟头。Tomcat 10 之后,Servlet API 的包名从javax.servlet改成了jakarta.servlet,网上大量老教程、老代码都是按javax写的,直接把这种 war 丢进 Tomcat 10,启动时就会看到NoClassDefFoundError: javax/servlet/Filter一类错误。这个项目的定位是原生 Servlet,大概率是 web.xml 或@WebServlet注解的写法,用 Tomcat 9 最不容易出岔。
Tomcat 9 对应 Servlet 4.0,也就是 Java EE 8 时代的规范。Spring Boot 2.x 内嵌的 Tomcat 正是这个版本,所以你在 Spring Boot 里遇到的SpringBootServletInitializer,本质就是替你注册 DispatcherServlet 的那段样板逻辑。遇到java.lang.NoClassDefFoundError: org/springframework/boot/web/servlet/support/SpringBootServletInitializer这类报错时,根因往往就是对底层 Servlet 容器的工作原理不熟。把原生 Servlet 跑一遍,再看 Spring 的RootWebApplicationContext,你会明白它只是挂在 ServletContext 上的一个属性。
| 容器 | Servlet 规范 | API 包名 | 适合场景 |
|---|---|---|---|
| Tomcat 9 | Servlet 4.0 | javax.servlet | 本标题对应的传统 war 项目、老教程代码 |
| Tomcat 10 / 11 | Servlet 5.0+ | jakarta.servlet | 新写的 Jakarta EE 项目或 Spring Boot 3.x |
2.2 pom.xml 最小依赖和三处必须注意的版本细节
用 Maven 管理依赖时,项目只需要三个库:Servlet API、MySQL 驱动、Druid 连接池。下面是能直接打包的最小配置:
<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>message-board</artifactId> <version>1.0.0</version> <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> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> </dependencies> <build> <finalName>message-board</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <version>3.4.0</version> </plugin> </plugins> </build> </project>这里有两个容易忽略的细节。第一,javax.servlet-api的 scope 必须是provided,因为 Tomcat 自己带了 Servlet API 实现,如果打包进 war,会和容器的类产生冲突。第二,如果换用 Tomcat 10,这段依赖要整体换成jakarta.servlet:jakarta.servlet-api,包名也要从javax.servlet改成jakarta.servlet。
MySQL 驱动这里写的是旧坐标mysql:mysql-connector-java,新坐标是com.mysql:mysql-connector-j,两者在 Maven 中央仓库都能拉到。8.x 驱动的类名是com.mysql.cj.jdbc.Driver,不是早期教程里的com.mysql.jdbc.Driver,后者在 8.0.26 之后已经被移除了。MySQL 8.0 的安装配置对中文支持更友好,默认字符集就是 utf8mb4,存储留言里的 emoji 不会出问题;如果你还在按几年前那套 MySQL 5.7 的教程配库,这次建议直接用 8.0。
2.3 工程目录与静态资源归属:webapp 里放 Vue
原生 Servlet 项目的静态资源全部放在src/main/webapp下,这个目录在打包后会原样复制到 war 的根路径。Vue 的index.html放在 webapp 根目录,vue.min.js和axios.min.js放在js/子目录,编译后的dist内容也是拷贝到这里:
message-board/ ├── pom.xml ├── src/main/java/com/example/message/ │ ├── servlet/ │ │ ├── MessageListServlet.java │ │ ├── AddMessageServlet.java │ │ └── DeleteMessageServlet.java │ ├── filter/ │ │ ├── EncodingFilter.java │ │ └── CorsFilter.java │ └── util/DbUtil.java ├── src/main/resources/ │ └── druid.properties └── src/main/webapp/ ├── index.html └── js/ ├── vue.min.js └── axios.min.jsTomcat 默认欢迎文件里包含index.html,所以不需要额外配置 welcome-file;但如果你用 IDE 里的旧模板工程,里面留了一个空web.xml,建议删掉,因为 Servlet 3.0 之后可以用注解代替配置,留着反而会让新手误以为必须写 XML。部署后访问路径是http://localhost:8080/message-board/,如果想让应用直接响应根路径,把 war 改名为ROOT.war再部署即可。
3. 后端 Servlet 接口:留言列表、新增留言与 MySQL 写入
3.1 三个接口的设计与 @WebServlet 注册
留言板的后端只需要三个接口:查列表、发留言、删最后一条。接口路径统一以/api/msg开头,这样和静态资源路径天然隔离,也方便在 Filter 里按前缀做权限或 CORS 处理。
| 方法 | 路径 | 请求参数 | 返回 |
|---|---|---|---|
| GET | /api/msg/list | 无 | {code:0, data:[{id,nickname,content,created_at}]} |
| POST | /api/msg/add | nickname, content | {code:0, data:新留言id} |
| DELETE | /api/msg/latest | 无 | {code:0, data:被删除的留言} |
用@WebServlet注解注册,就不需要再碰 web.xml。一个常见误写是把路径设置成/msg/list这种短路径,虽然能跑,但和静态资源的命名空间混在一起,后期加 Filter 会麻烦。我一般按/api/msg/list这种前缀路径写,语义清晰,还能为将来加接口版本号留空间。
@WebServlet("/api/msg/list") public class MessageListServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("application/json;charset=UTF-8"); List<Map<String, Object>> list = new ArrayList<>(); String sql = "SELECT id, nickname, content, created_at FROM message ORDER BY id DESC LIMIT 50"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Map<String, Object> item = new HashMap<>(); item.put("id", rs.getInt("id")); item.put("nickname", rs.getString("nickname")); item.put("content", rs.getString("content")); item.put("created_at", rs.getTimestamp("created_at").toLocalDateTime().toString()); list.add(item); } } catch (SQLException e) { throw new ServletException("查询留言失败", e); } resp.getWriter().write(buildJson(0, list)); } }buildJson是手写的 JSON 拼接方法,标题项目里通常不会引 Jackson,因为只有一个返回结构,手写更直观。如果返回结构多了,再换成 Jackson 或 Fastjson 也不迟。注意LIMIT 50:留言板页面只展示最近 50 条,查询不会随着表增长而越来越慢,配合ORDER BY id DESC走主键倒序,MySQL 的 InnoDB 主键本来就是聚簇索引,这已经是这个场景下最优的取数方式。
3.2 Druid 连接池与 DbUtil:直连 JDBC 的坑都避掉
原生 JDBC 最常见的负面体验是每次都DriverManager.getConnection(),在高并发下反复建连、断连,数据库连接数被打满后页面直接转圈。这个项目用 Druid 连接池,配置放在src/main/resources/druid.properties:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/message_board?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username=root password=你的密码 initialSize=2 maxActive=8 maxWait=3000对应的工具类用配置文件初始化数据源,业务代码里只负责取连接:
public class DbUtil { private static DruidDataSource dataSource; static { try (InputStream in = DbUtil.class.getClassLoader().getResourceAsStream("druid.properties")) { Properties props = new Properties(); props.load(in); dataSource = (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }URL 参数里三个值不能删:characterEncoding=utf8保证中文写入不乱码;serverTimezone=Asia/Shanghai是因为 MySQL 8 的驱动默认要求时区,不写会报The server time zone value异常;allowPublicKeyRetrieval=true解决 MySQL 8 默认caching_sha2_password插件首次连接时Public Key Retrieval is not allowed的报错。maxWait=3000的意思是拿连接超过 3 秒就抛异常,避免线程全部阻塞在获取连接上。
3.3 留言写入与删除最后一条:PreparedStatement 和子查询的坑
新增留言用PreparedStatement防止 SQL 注入,这是底线,不讨论拼接字符串的方案。插入时用RETURN_GENERATED_KEYS拿自增主键,方便前端接下来刷新列表:
@WebServlet("/api/msg/add") public class AddMessageServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); resp.setContentType("application/json;charset=UTF-8"); String nickname = req.getParameter("nickname"); String content = req.getParameter("content"); if (nickname == null || content == null || nickname.trim().isEmpty() || content.trim().isEmpty()) { resp.getWriter().write("{\"code\":1,\"msg\":\"昵称和内容不能为空\"}"); return; } String sql = "INSERT INTO message(nickname, content) VALUES(?, ?)"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, nickname.trim()); ps.setString(2, content.trim()); ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { keys.next(); resp.getWriter().write("{\"code\":0,\"data\":" + keys.getInt(1) + "}"); } } catch (SQLException e) { throw new ServletException("写入留言失败", e); } } }删除「最后一条」这个业务,是网上mysql 更新子查询高发报错的典型场景。很多人会图省事写一条 SQL:
DELETE FROM message WHERE id = (SELECT MAX(id) FROM message);这条语句在 MySQL 里会直接报 1093 错误:You can't specify target table for update in FROM clause。原因是 MySQL 不允许在修改目标表的子查询里再查同一张表。常见做法是拆成两条语句,放在同一个连接里执行:
String maxIdSql = "SELECT MAX(id) FROM message"; String deleteSql = "DELETE FROM message WHERE id = ?"; try (Connection conn = DbUtil.getConnection()) { int maxId; try (PreparedStatement ps = conn.prepareStatement(maxIdSql); ResultSet rs = ps.executeQuery()) { rs.next(); maxId = rs.getInt(1); } try (PreparedStatement ps = conn.prepareStatement(deleteSql)) { ps.setInt(1, maxId); ps.executeUpdate(); } }如果并发很高,两条语句之间有其他请求插队,可能出现“删除的不是你以为的最后一条”。留言板这个场景并发极低,拆两条语句完全够用;真要严格串行化,就把SELECT MAX(id) FOR UPDATE和DELETE放进同一个事务。这种「先查再改」的思路也适用于 MySQL 里其他带聚合条件的更新、删除操作,原则就一条:不要在修改语句里直接查同一张表。
3.4 一个 Filter 统一编码:避免 request 参数乱码
不用 Spring 的话,编码问题会直接暴露在你面前。req.getParameter读取中文时乱码,原因通常是请求体编码没有被正确设置。POST 请求的参数编码取决于请求头的 Content-Type,request.setCharacterEncoding("UTF-8")必须在第一次读取参数之前调用。最稳妥的位置是 Filter 里统一处理:
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); chain.doFilter(request, response); } }加上这个 Filter 后,所有 Servlet 里都不用再单独调setCharacterEncoding。如果项目里还有其他 Filter,比如权限校验、日志记录,要注意EncodingFilter的过滤器顺序:它必须排在最前面,否则后面的 Filter 里一旦调用request.getParameter,编码就来不及修正了。注解方式下 Filter 的执行顺序不保证,如果项目里 Filter 变多,建议改用 web.xml 显式声明顺序。
4. Vue 页面接入 Servlet:axios 请求、CORS 调试与数据渲染
4.1 非构建式 Vue 引入:留一条 npm build 的路
这个标题的项目是 zip 形式,大概率不是vue create脚手架出来的工程,而是直接在页面里引vue.min.js。这也是原生 Servlet 项目最常见的配套方式:不用 Node 环境、不用 webpack,浏览器加载两个 JS 文件就能跑。
<script src="js/vue.min.js"></script> <script src="js/axios.min.js"></script>如果你熟悉的是npm run build那套vue安装及环境配置流程,编译后的dist目录内容一样拷贝到src/main/webapp下,效果完全相同。区别只在开发方式:非构建式组件写在模板字符串里,构建式组件是.vue单文件。留言板这种几十行的交互页面,非构建式反而更直观,前后端代码在同一个项目里,部署也只需要一个 war。
4.2 模板与数据流:v-model、异步 load、URLSearchParams
页面核心代码就是一个 Vue 实例,数据从created()钩子里拉取,表单提交后重新加载列表:
<div id="app"> <form @submit.prevent="submit"> <input v-model.trim="form.nickname" placeholder="昵称" /> <textarea v-model.trim="form.content" placeholder="留言内容"></textarea> <button type="submit">发留言</button> </form> <ul> <li v-for="msg in messages" :key="msg.id"> <b>{{ msg.nickname }}</b>:{{ msg.content }} <span>{{ msg.created_at }}</span> </li> </ul> </div> <script> new Vue({ el: '#app', data: { messages: [], form: { nickname: '', content: '' } }, created() { this.load() }, methods: { async load() { const res = await axios.get('/message-board/api/msg/list') if (res.data.code === 0) { this.messages = res.data.data } }, async submit() { if (!this.form.nickname || !this.form.content) return const res = await axios.post('/message-board/api/msg/add', new URLSearchParams(this.form)) if (res.data.code === 0) { this.form.content = '' this.load() } } } }) </script>两个细节值得说明。第一,new URLSearchParams(this.form)把对象转成application/x-www-form-urlencoded格式,这和 Servlet 的req.getParameter是天然对齐的。如果直接axios.post(url, this.form),请求体是 JSON,Servlet 端就必须手动读流、解析 JSON,凭空多写一套工具代码。第二,请求路径写全路径/message-board/api/msg/list,不要写相对路径api/msg/list。页面根路径部署在/message-board/下,如果前端还用了前端路由,相对路径解析会出问题;最省心的做法是在 axios 配置里设baseURL: '/message-board',接口路径写成/api/msg/list,后面改部署路径只动一个变量。
如果部署成 ROOT.war,baseURL就改成空字符串。开发时建议打开浏览器开发者工具的 Vue Devtools 插件,可以直接在控制台改messages数组验证渲染逻辑,比刷新页面看结果高效得多。
4.3 CORS 过滤器:前后端分离调试的必写配置
非构建式 Vue 和 Servlet 在同一个 war 里时,浏览器请求是同源的,不需要 CORS。但开发阶段如果你用npm run serve起了 Vue 开发服务器(默认 5173 端口),前端调http://localhost:8080/message-board/api/msg/*就是跨域。这时后端需要一个 CORS 过滤器:
@WebFilter("/api/*") public class CorsFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp = (HttpServletResponse) response; resp.setHeader("Access-Control-Allow-Origin", "http://localhost:5173"); resp.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); resp.setHeader("Access-Control-Allow-Headers", "Content-Type"); HttpServletRequest req = (HttpServletRequest) request; if ("OPTIONS".equalsIgnoreCase(req.getMethod())) { return; } chain.doFilter(request, response); } }OPTIONS请求需要单独拦截掉,因为浏览器预检请求不需要经过后面的业务 Servlet。Access-Control-Allow-Origin在调试期可以临时写成*,但一旦要携带 Cookie,浏览器就禁止*和allowCredentials=true同时出现;留到排查会话类问题时再踩这个坑不值得。如果你在若依这类前后端分离的开源项目里调过接口,它的跨域处理思路和这个过滤器完全一致,只是把跨域规则挪到了 Spring 配置里。
5. war 部署到 Tomcat 的验收与排错:验证接口、中文乱码和三处常见坑
5.1 打包后先检查 war 内容,别急着扔进 webapps
mvn clean package之后,target/message-board.war生成。部署到 Tomcat 前,先用压缩工具看一眼 war 内部结构,排查最常见的问题——自己写的 class 是否真的编进去了:
unzip -l target/message-board.war | grep WEB-INF/classes unzip -l target/message-board.war | grep index.htmlWEB-INF/classes下应该有com/example/message/servlet/下的 class 文件,以及配置目录里的druid.properties。如果druid.properties不在 classes 根路径,说明 resources 目录没被 Maven 默认复制,检查src/main/resources是否存在,或者 pom 里是否改过资源插件配置。war 复制到 Tomcat 的webapps/目录后,启动日志无报错,再访问/message-board/api/msg/list验证接口通不通。这一条验证路径比先开页面再猜问题快得多。
5.2 中文乱码四步定位:页面、请求、响应、数据库
留言板项目最顽固的 bug 是中文乱码。排查顺序固定按下面四条走,不要跳步:
- 浏览器开发者工具看响应头 Content-Type 是否包含
charset=UTF-8。缺失说明 Servlet 里没调setContentType,或 Filter 只设置了response.setCharacterEncoding。 - 看 HTML 页面
<meta charset="UTF-8">声明,以及 JS 文件本身是不是 UTF-8 编码。用 IDE 打开看到中文正常、浏览器里全是乱码,基本是文件保存编码不对。 - 看数据库连接 URL 有没有
characterEncoding=utf8。注意这个参数的值是utf8,不是utf8mb4,MySQL 驱动不认utf8mb4这个值。如果你用 Workbench 或 Navicat 直连数据库看数据本身是正常的,但页面查出来是问号,问题在 JDBC 这一层。 - 看表结构的 collation 是否 utf8mb4。MySQL 8 默认就是 utf8mb4,如果从 5.7 迁移过来,
message表可能还是utf8_general_ci,中文能存,但 emoji 会报Incorrect string value,执行ALTER TABLE message CONVERT TO CHARACTER SET utf8mb4即可。
5.3 连接池超时与「只能删最后一条」的边界处理
Druid 跑一段时间后偶尔报GetConnectionTimeoutException,通常不是连接池参数问题,而是 MySQL 的wait_timeout把空闲连接杀了,连接池里的连接变“死连接”。Druid 官方解法是开启连接保活测试,在druid.properties里加一行:
testWhileIdle=true validationQuery=SELECT 1testWhileIdle默认就是 true,很多项目其实是因为没写validationQuery,Druid 不知道用什么语句探活。加上后,空闲回收线程在把连接交给业务线程前会先执行SELECT 1,无效连接直接抛弃。最后提醒一句,如果是长期部署在服务器上,Windows 环境写一个mysql自动备份bat放到计划任务里,Linux 用 crontab 跑mysqldump,留言数据不多,全量备份每天一次足够。
本文还有配套的精品资源,点击获取