
简介这是一个留言板系统的完整实现项目基于原生Servlet作为服务端核心结合Vue构建前端页面使用MySQL存储留言及用户数据整体技术栈经典且易于上手。项目共有122个文件主要包含Java源码与编译产物、Vue页面组件、XML配置、JS脚本以及数据库相关文件压缩包大小13.47MB。从内容可看出系统实现了登录验证、留言列表展示、回复处理等核心Servlet类覆盖了用户注册登录、发表留言、查看留言、管理员回复的完整业务流程同时还有数据库脚本和说明文档辅助搭建环境。目录组织清晰源码、配置、前端资源分类存放并配有使用说明方便直接导入到IDE中运行学习。当前已有96人学习过该项目特别适合计算机专业学生完成课程设计或毕业设计时参考也适合希望理解Servlet与Vue分离开发模式的开发者作为练习项目。1. 用原生ServletVueMySQL做留言板一条绕开框架的完整请求链路把留言板从 Spring Boot 换回原生 Servlet会发现一个反直觉的事实写显式 API 比写自动化更让人理解容器。这个标题里三个词没有一个难到需要查文档但把它们串起来却是很多从业者第一次亲手画出完整请求链路的机会——浏览器里的 axios 发出请求Tomcat 的线程池把 HttpServletRequest 交给你的 doGetJDBC 把 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 9Servlet 4.0javax.servlet本标题对应的传统 war 项目、老教程代码Tomcat 10 / 11Servlet 5.0jakarta.servlet新写的 Jakarta EE 项目或 Spring Boot 3.x2.2 pom.xml 最小依赖和三处必须注意的版本细节用 Maven 管理依赖时项目只需要三个库Servlet API、MySQL 驱动、Druid 连接池。下面是能直接打包的最小配置project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdmessage-board/artifactId version1.0.0/version packagingwar/packaging properties maven.compiler.source8/maven.compiler.source maven.compiler.target8/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency /dependencies build finalNamemessage-board/finalName plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-war-plugin/artifactId version3.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/addnickname, 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;charsetUTF-8); ListMapString, 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()) { MapString, 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.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/message_board?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue usernameroot password你的密码 initialSize2 maxActive8 maxWait3000对应的工具类用配置文件初始化数据源业务代码里只负责取连接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 参数里三个值不能删characterEncodingutf8保证中文写入不乱码serverTimezoneAsia/Shanghai是因为 MySQL 8 的驱动默认要求时区不写会报The server time zone value异常allowPublicKeyRetrievaltrue解决 MySQL 8 默认caching_sha2_password插件首次连接时Public Key Retrieval is not allowed的报错。maxWait3000的意思是拿连接超过 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;charsetUTF-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 更新子查询高发报错的典型场景。很多人会图省事写一条 SQLDELETE FROM message WHERE id (SELECT MAX(id) FROM message);这条语句在 MySQL 里会直接报 1093 错误You cant 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-Typerequest.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 页面接入 Servletaxios 请求、CORS 调试与数据渲染4.1 非构建式 Vue 引入留一条 npm build 的路这个标题的项目是 zip 形式大概率不是vue create脚手架出来的工程而是直接在页面里引vue.min.js。这也是原生 Servlet 项目最常见的配套方式不用 Node 环境、不用 webpack浏览器加载两个 JS 文件就能跑。script srcjs/vue.min.js/script script srcjs/axios.min.js/script如果你熟悉的是npm run build那套vue安装及环境配置流程编译后的dist目录内容一样拷贝到src/main/webapp下效果完全相同。区别只在开发方式非构建式组件写在模板字符串里构建式组件是.vue单文件。留言板这种几十行的交互页面非构建式反而更直观前后端代码在同一个项目里部署也只需要一个 war。4.2 模板与数据流v-model、异步 load、URLSearchParams页面核心代码就是一个 Vue 实例数据从created()钩子里拉取表单提交后重新加载列表div idapp form submit.preventsubmit input v-model.trimform.nickname placeholder昵称 / textarea v-model.trimform.content placeholder留言内容/textarea button typesubmit发留言/button /form ul li v-formsg in messages :keymsg.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)请求体是 JSONServlet 端就必须手动读流、解析 JSON凭空多写一套工具代码。第二请求路径写全路径/message-board/api/msg/list不要写相对路径api/msg/list。页面根路径部署在/message-board/下如果前端还用了前端路由相对路径解析会出问题最省心的做法是在 axios 配置里设baseURL: /message-board接口路径写成/api/msg/list后面改部署路径只动一个变量。如果部署成 ROOT.warbaseURL就改成空字符串。开发时建议打开浏览器开发者工具的 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浏览器就禁止*和allowCredentialstrue同时出现留到排查会话类问题时再踩这个坑不值得。如果你在若依这类前后端分离的开源项目里调过接口它的跨域处理思路和这个过滤器完全一致只是把跨域规则挪到了 Spring 配置里。5. war 部署到 Tomcat 的验收与排错验证接口、中文乱码和三处常见坑5.1 打包后先检查 war 内容别急着扔进 webappsmvn 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 是否包含charsetUTF-8。缺失说明 Servlet 里没调setContentType或 Filter 只设置了response.setCharacterEncoding。看 HTML 页面meta charsetUTF-8声明以及 JS 文件本身是不是 UTF-8 编码。用 IDE 打开看到中文正常、浏览器里全是乱码基本是文件保存编码不对。看数据库连接 URL 有没有characterEncodingutf8。注意这个参数的值是utf8不是utf8mb4MySQL 驱动不认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里加一行testWhileIdletrue validationQuerySELECT 1testWhileIdle默认就是 true很多项目其实是因为没写validationQueryDruid 不知道用什么语句探活。加上后空闲回收线程在把连接交给业务线程前会先执行SELECT 1无效连接直接抛弃。最后提醒一句如果是长期部署在服务器上Windows 环境写一个mysql自动备份bat放到计划任务里Linux 用 crontab 跑mysqldump留言数据不多全量备份每天一次足够。本文还有配套的精品资源点击获取