☰
原生Servlet+Vue+MySQL留言板:前后端分离最小闭环实战
2026/10/6 8:19:17 网站建设 项目流程

简介:这是一套面向Java Web初学者与课程设计、毕业设计需求者的留言板系统源码,采用原生Servlet搭配Vue与MySQL实现,适合想脱离框架、理解请求响应底层流程的开发者练手。压缩包共122个文件,约13.49MB,其中jar包与class文件构成运行依赖,java源码承载Servlet核心逻辑,vue与js负责前端交互,xml、json、jsp等完成配置与页面渲染,另有md文档辅助说明。目前已有96人学习下载。项目经本地编译可直接运行,按文档配置环境即可启动,难度适中且经助教审定,涵盖登录、留言列表、回复等典型模块,能帮助读者掌握Servlet生命周期、前后端数据交互与数据库操作,也可作为课程设计或毕业设计的参考方案,遇到问题还可私信作者获取解答。

1. 原生 Servlet + Vue + MySQL 留言板:一套能跑通的前后端分离最小闭环

很多人第一次接触 Java Web,都是从留言板开始的。但真正动手时才发现,卡住自己的不是业务逻辑,而是环境:Tomcat 版本对不上、Servlet 映射 404、Vue 打包后跨域、MySQL 8 的驱动类名换了。这套「原生 Servlet + Vue + MySQL」的组合,恰好绕开了 Spring 全家桶的封装,把 HTTP 请求、JSON 序列化、JDBC 连接这些底层环节全部暴露出来,适合想搞懂「一个请求到底怎么从浏览器走到数据库再回来」的人。

它解决的核心问题是:在不引入任何框架的前提下,跑通一套前后端分离的增删改查。前端 Vue 负责渲染和交互,后端 Servlet 负责接收请求、操作 MySQL、返回 JSON,两边通过 HTTP 接口通信。适合刚学完 Java 语法、想找一个完整项目练手的学生,也适合长期写框架、想回头补一补原生基础的开发者。下面按「环境怎么搭 → 接口怎么写 → 前端怎么接 → 坑在哪」的顺序拆开讲。

2. 环境搭建:JDK、Tomcat、MySQL 与 Vue 脚手架的版本对齐

2.1 四个组件的版本选择与安装顺序

这套方案涉及四个独立组件,版本不匹配是最常见的翻车点。我一般按 JDK → Tomcat → MySQL → Node/Vue 的顺序装,因为前面的组件会影响后面的配置。

组件推荐版本关键说明
JDK8 或 11Servlet 3.1+ 需要 JDK 7 以上,JDK 8 兼容性最好
Tomcat9.xTomcat 10 把javax.servlet改成了jakarta.servlet,老代码直接报错
MySQL5.7 或 8.08.0 驱动类名是com.mysql.cj.jdbc.Driver,5.7 是com.mysql.jdbc.Driver
Node.js16 或 18Vue 3 的 Vite 需要 Node 14.18+,Vue CLI 需要 Node 8+

Tomcat 10 的包名迁移是血泪经验。如果你从网上抄的代码里写的是import javax.servlet.http.HttpServlet,在 Tomcat 10 上部署会直接抛ClassNotFoundException。要么换 Tomcat 9,要么把所有javax.servlet改成jakarta.servlet。新手建议直接用 Tomcat 9,省掉这层麻烦。

MySQL 8.0 安装时有个细节:认证插件默认是caching_sha2_password,老版本的 JDBC 驱动连不上。要么升级驱动到 8.0.x,要么在 MySQL 里执行ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';改回旧插件。

2.2 后端项目结构与依赖配置

用 Maven 建一个 webapp 项目,pom.xml里只需要三个依赖:Servlet API、MySQL 驱动、JSON 处理库。

<dependencies> <!-- Servlet API,scope 必须是 provided,因为 Tomcat 自带 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- MySQL 8.0 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- 手动拼 JSON 太痛苦,用 Gson 省事 --> <dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> <version>2.10.1</version> </dependency> </dependencies>

scope设为provided是关键。Servlet API 由 Tomcat 容器提供,如果打成 war 包时带进去,会和容器自带的版本冲突,启动时报NoSuchMethodError。MySQL 驱动和 Gson 则必须打包进去,因为容器不管这两个。

数据库建一张表就够了:

CREATE TABLE message ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, content TEXT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

utf8mb4而不是utf8,因为 MySQL 的utf8只支持 3 字节,存 emoji 会报错。create_time用DEFAULT CURRENT_TIMESTAMP让数据库自动填时间,省掉 Java 端格式化日期的代码。

2.3 Vue 前端初始化与目录约定

前端用 Vue CLI 或 Vite 都行,我倾向 Vite,启动快。初始化命令:

npm create vite@latest message-board-frontend -- --template vue cd message-board-frontend npm install npm install axios npm run dev

axios用来发 HTTP 请求。Vue 3 的<script setup>语法比 Options API 简洁,下面统一用它。目录上,把接口请求单独抽到src/api/message.js,组件里只调函数,不直接写 URL,方便后期改地址。

提示:Vue 开发服务器默认跑在 5173 端口,Tomcat 跑在 8080,两个端口不同源,浏览器会拦跨域请求。解决办法在下一章讲。

3. 后端接口:Servlet 接收请求、操作 MySQL、返回 JSON

3.1 一个 Servlet 处理增删改查的映射方式

原生 Servlet 没有路由框架,每个 URL 对应一个 Servlet 类,或者用一个 Servlet 根据参数分发。留言板接口少,我一般用两个 Servlet:MessageListServlet处理查询和新增,MessageDeleteServlet处理删除。用@WebServlet注解配置路径,不用改web.xml。

@WebServlet("/api/message") public class MessageServlet extends HttpServlet { private final Gson gson = new Gson(); // GET 请求:查询所有留言 @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("application/json;charset=UTF-8"); List<Message> list = new ArrayList<>(); String sql = "SELECT id, username, content, create_time FROM message ORDER BY id DESC"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Message m = new Message(); m.setId(rs.getInt("id")); m.setUsername(rs.getString("username")); m.setContent(rs.getString("content")); m.setCreateTime(rs.getString("create_time")); list.add(m); } } catch (SQLException e) { resp.setStatus(500); resp.getWriter().write("{\"error\":\"" + e.getMessage() + "\"}"); return; } resp.getWriter().write(gson.toJson(list)); } }

doGet里用 try-with-resources 自动关闭Connection、PreparedStatement、ResultSet,避免连接泄漏。ORDER BY id DESC让最新的留言排在最前面。返回时用 Gson 把 List 直接序列化成 JSON 数组,前端拿到就能渲染。

doPost处理新增,从请求体读 JSON:

@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); resp.setContentType("application/json;charset=UTF-8"); // 读取请求体 StringBuilder sb = new StringBuilder(); try (BufferedReader reader = req.getReader()) { String line; while ((line = reader.readLine()) != null) { sb.append(line); } } Message msg = gson.fromJson(sb.toString(), Message.class); // 参数校验,空值直接拒绝 if (msg.getUsername() == null || msg.getUsername().trim().isEmpty() || msg.getContent() == null || msg.getContent().trim().isEmpty()) { resp.setStatus(400); resp.getWriter().write("{\"error\":\"用户名和内容不能为空\"}"); return; } String sql = "INSERT INTO message (username, content) VALUES (?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, msg.getUsername().trim()); ps.setString(2, msg.getContent().trim()); ps.executeUpdate(); } catch (SQLException e) { resp.setStatus(500); resp.getWriter().write("{\"error\":\"" + e.getMessage() + "\"}"); return; } resp.getWriter().write("{\"code\":0,\"msg\":\"success\"}"); }

req.setCharacterEncoding("UTF-8")必须在读取请求体之前调用,否则中文会乱码。参数校验放在 SQL 执行前,空字符串直接返回 400,不要等到数据库报错。PreparedStatement的?占位符既防 SQL 注入,又省去手动转义引号的麻烦。

3.2 数据库连接工具类与连接池的取舍

上面代码里的DBUtil.getConnection()需要自己写。最简版本用DriverManager每次新建连接:

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/message_board?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; 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 RuntimeException("MySQL 驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }

URL 里三个参数必须加:useSSL=false关掉 SSL 警告,serverTimezone=Asia/Shanghai解决时区差 8 小时的问题,characterEncoding=utf8保证中文正常。Class.forName在 JDBC 4.0 之后其实可以省略,但显式加载能更早暴露驱动缺失的问题。

每次请求新建连接在留言板这种低并发场景够用,但连接创建开销大。如果要做压力测试,换成 Druid 或 HikariCP 连接池,把getConnection改成从池里借。留言板项目我一般先用DriverManager跑通,确认逻辑没问题再换池,避免一开始就引入额外配置。

3.3 跨域处理:一个 Filter 解决 OPTIONS 预检

前端跑在 5173,后端跑在 8080,浏览器同源策略会拦截。最省事的办法是写一个 CORS Filter:

@WebFilter("/api/*") public class CorsFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletResponse response = (HttpServletResponse) resp; response.setHeader("Access-Control-Allow-Origin", "http://localhost:5173"); response.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); response.setHeader("Access-Control-Allow-Headers", "Content-Type"); response.setHeader("Access-Control-Max-Age", "3600"); HttpServletRequest request = (HttpServletRequest) req; // OPTIONS 预检请求直接返回 200,不往下走 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); return; } chain.doFilter(req, resp); } }

Access-Control-Allow-Origin写具体的前端地址,不要写*,因为带 Cookie 的请求不允许通配符。Access-Control-Max-Age设为 3600 秒,让浏览器缓存预检结果,减少 OPTIONS 请求次数。OPTIONS 请求直接返回,不要传给 Servlet,否则 Servlet 没有doOptions方法会返回 405。

注意:生产环境前端打包后和 Tomcat 部署在同一域名下,就不需要这个 Filter 了。跨域只是开发阶段的临时方案。

4. 前端对接:Vue 组件发请求、渲染列表、提交表单

4.1 axios 封装与接口调用

在src/api/message.js里统一管理接口:

import axios from 'axios' // 开发环境指向 Tomcat,生产环境改成同域路径 const baseURL = 'http://localhost:8080/api' const request = axios.create({ baseURL, timeout: 5000 }) // 响应拦截器,统一处理错误 request.interceptors.response.use( res => res.data, err => { console.error('请求失败:', err.message) return Promise.reject(err) } ) export const getMessages = () => request.get('/message') export const addMessage = (data) => request.post('/message', data) export const deleteMessage = (id) => request.delete(`/message?id=${id}`)

axios.create把baseURL和超时时间抽出来,后面改地址只改一处。响应拦截器直接返回res.data,组件里就不用每次写.data。timeout设 5000 毫秒,超过就报错,避免请求卡死时界面一直转圈。

4.2 留言列表组件与表单提交

主组件MessageBoard.vue:

<template> <div class="board"> <form @submit.prevent="handleSubmit"> <input v-model="form.username" placeholder="昵称" maxlength="50" /> <textarea v-model="form.content" placeholder="说点什么..." maxlength="500"></textarea> <button type="submit" :disabled="loading">发布</button> </form> <ul class="list"> <li v-for="item in messages" :key="item.id"> <strong>{{ item.username }}</strong> <span>{{ item.createTime }}</span> <p>{{ item.content }}</p> <button @click="handleDelete(item.id)">删除</button> </li> </ul> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { getMessages, addMessage, deleteMessage } from '../api/message' const messages = ref([]) const loading = ref(false) const form = ref({ username: '', content: '' }) const loadMessages = async () => { messages.value = await getMessages() } const handleSubmit = async () => { if (!form.value.username.trim() || !form.value.content.trim()) return loading.value = true try { await addMessage(form.value) form.value = { username: '', content: '' } await loadMessages() } finally { loading.value = false } } const handleDelete = async (id) => { await deleteMessage(id) await loadMessages() } onMounted(loadMessages) </script>

@submit.prevent阻止表单默认提交行为,否则页面会刷新。v-model双向绑定输入框,maxlength在前端限制长度,和后端数据库字段长度对齐。提交成功后清空表单并重新拉取列表,保证显示的是数据库最新状态。loading状态防止用户连点按钮重复提交。

4.3 时间格式与列表渲染的细节

后端返回的createTime是 MySQL 的DATETIME格式,形如2024-01-15 10:30:00,前端直接显示就行。如果想让显示更友好,可以在 Vue 里加一个格式化函数:

const formatTime = (str) => { if (!str) return '' // 把 "2024-01-15 10:30:00" 转成 "01-15 10:30" return str.slice(5, 16) }

列表渲染用:key="item.id",不要用 index。用 index 做 key 时,删除中间某条会导致后续元素复用错误的 DOM,出现内容错位。item.id是数据库主键,唯一且稳定。

提示:如果列表数据量大,v-for渲染几百条会卡。留言板一般不会,但如果要优化,用虚拟滚动或分页。分页在 Servlet 里加LIMIT和OFFSET即可。

5. 避坑与排查:从 404 到乱码的 5 个真实问题

5.1 访问接口返回 404,Tomcat 日志无报错

现象:前端请求/api/message返回 404,Tomcat 的catalina.out里没有任何异常。

原因:@WebServlet注解没生效,或者web.xml里metadata-complete="true"导致容器不扫描注解。

解决:检查web.xml的<web-app>标签,metadata-complete必须是false或干脆不写这个属性。另外确认pom.xml里 Servlet API 的scope是provided,如果打成compile会冲突。最后看 Tomcat 的conf/server.xml里appBase指向的目录,war 包是否真的解压了。

5.2 中文乱码:请求体和响应各有一套解法

现象:提交的留言在数据库里显示为???,或者前端收到的 JSON 里中文变成乱码。

原因:请求体没设 UTF-8,响应头没设charset。

解决:doPost第一行写req.setCharacterEncoding("UTF-8"),doGet和doPost都要写resp.setContentType("application/json;charset=UTF-8")。数据库连接 URL 加characterEncoding=utf8,建表用utf8mb4。四个地方缺一个都会乱码,按顺序排查。

5.3 MySQL 8 连接报Public Key Retrieval is not allowed

现象:JDBC 连接 MySQL 8.0 时抛java.sql.SQLException: Public Key Retrieval is not allowed。

原因:MySQL 8.0 默认用caching_sha2_password认证,JDBC 驱动需要额外参数允许公钥检索。

解决:在连接 URL 后面加allowPublicKeyRetrieval=true。完整 URL 变成jdbc:mysql://localhost:3306/message_board?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true。或者改用户认证插件为mysql_native_password。

5.4 前端打包后刷新页面 404

现象:npm run build生成的dist放到 Tomcat 里,首页能打开,但刷新/list这类路由就 404。

原因:Vue Router 的 history 模式需要服务端把所有路径都转发到index.html,Tomcat 默认按文件路径找,找不到就 404。

解决:要么把 Vue Router 改成 hash 模式(URL 带#),要么在 Tomcat 里配一个web.xml的 error-page 把 404 转发到index.html。留言板页面少,直接用 hash 模式最省事。

5.5 删除接口用 GET 请求,被浏览器预取导致误删

现象:用户没点删除,数据却少了。

原因:删除接口写成了 GET,浏览器或爬虫会预取链接,触发删除。

解决:删除必须用 DELETE 或 POST,不要用 GET。前端axios.delete发的是 DELETE 请求,后端 Servlet 要重写doDelete方法。如果 Tomcat 版本低不支持 DELETE,用 POST 加一个_method=delete参数模拟。

6. 进阶技巧:把接口响应时间从 200ms 压到 20ms 的验证方法

留言板跑通之后,下一步是验证它到底扛不扛得住。我一般用两步:先加连接池,再用ab或wrk压测对比。

连接池换 HikariCP,DBUtil改成:

public class DBUtil { private static final HikariDataSource dataSource; static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/message_board?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true"); config.setUsername("root"); config.setPassword("你的密码"); config.setMaximumPoolSize(10); // 最大连接数 config.setMinimumIdle(2); // 最小空闲连接 config.setConnectionTimeout(3000); // 获取连接超时 3 秒 config.setIdleTimeout(60000); // 空闲连接 60 秒回收 dataSource = new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }

maximumPoolSize设 10 是因为留言板并发低,设太大反而浪费数据库连接。connectionTimeout设 3000 毫秒,拿不到连接就快速失败,不要让请求线程一直等。

压测命令:

# 安装 ab(Apache Bench) # 100 个并发,总共 1000 个请求 ab -n 1000 -c 100 http://localhost:8080/api/message

重点看Requests per second和Time per request两个指标。用DriverManager时,100 并发下每秒大概 200-300 请求,平均响应 200ms 左右;换 HikariCP 后,每秒能到 2000 以上,平均响应降到 20ms 以内。差距主要来自连接复用,省掉了 TCP 握手和 MySQL 认证的开销。

验证时还要看Failed requests是否为 0。如果有失败,检查连接池是否耗尽,把maximumPoolSize调大或看慢 SQL。留言板的查询语句简单,一般不会慢,但如果留言量到几十万条,ORDER BY id DESC需要索引,主键自带索引所以没问题。

我自己的习惯是:任何接口上线前都跑一遍ab,哪怕只是留言板。因为压测能暴露连接泄漏——如果Failed requests随时间递增,多半是某个分支没关连接。原生 Servlet 没有框架帮你管连接,try-with-resources必须写全,漏一个ResultSet关闭,跑几百次请求后连接池就满了。

最后一个小技巧:把create_time的格式化放到前端做,后端直接返回时间戳或原始字符串,减少 Java 端的SimpleDateFormat开销。SimpleDateFormat不是线程安全的,每次 new 一个也浪费,不如交给前端toLocaleString。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询