
简介基于原生Servlet、Vue与MySQL实现的留言板系统源码包面向Java Web课程设计、毕业设计以及想理解前后端分离开发的初学者是学习Servlet生命周期、Vue组件通信和SQL CRUD操作的完整案例也适合快速完成课程设计演示。项目采用Servlet处理请求、Vue组织页面、MySQL持久化数据的结构前端通过接口与后端交互涵盖用户留言发布、列表分页展示、回复管理等功能能够帮助读者从零打通原生Web开发全流程并掌握经典的Web项目分层思路。压缩包共122个文件、13.49MB包含13个Java源码、9个Vue组件、8个XML配置、2个JSP页面以及jar依赖库和编译后的class文件目录划分清晰便于按层阅读源码和依赖关系整体结构不复杂。源码已在本地编译验证按文档配置环境即可运行难度适中且经助教审定已有95人学习适合直接借鉴或在其上继续扩展部署成本低。1. 原生ServletVueMySQL的留言板为什么这个组合还值得做我有个习惯凡是评论区里有人问“Spring Boot 课设能不能过”我都建议他先把原生 Servlet 写透。留言板系统是典型的 Java Web 入门闭环用原生 Servlet 做后端、Vue 做前端、MySQL 存数据正好把一条请求从浏览器走到数据库的每一步都摆在明面上。它适合两类人一类是准备面试时要被问 Servlet 生命周期和 MySQL 连接池细节的另一类是课设被要求“不许用框架”的。这个 zip 里的东西不见得多花哨但它的价值在于——你想用它跑通什么、学到什么比它本身多厉害重要得多。2. 把环境配到位MySQL建库、Tomcat改端口与Vue代理的落地步骤在碰任何代码之前先把三样东西的关系理顺MySQL 负责存留言Tomcat 负责跑 ServletVue 的 devServer 负责开发时给你热更新。这三者默认都爱抢 8080 和 3306 这类端口配置顺序错了后面联调全是眼泪。2.1 先设计表留言板表结构为什么用 utf8mb4 而不是 utf8留言板的核心数据就一张表谁、说了什么、什么时候说的。别急着加用户表、回复表先把最小闭环跑通再去扩展。CREATE DATABASE IF NOT EXISTS message_board DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE message_board; CREATE TABLE message ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, nickname VARCHAR(50) NOT NULL COMMENT 留言人昵称, content VARCHAR(500) NOT NULL COMMENT 留言内容, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 留言时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个容易被跳过但很关键的选择。第一字符集用 utf8mb4 而不是 utf8因为用户在留言内容里可能直接发 emoji 或生僻字utf8 在 MySQL 里最多存 3 字节遇到 4 字节的 emoji 会直接报错或者落库成乱码。第二create_time 用 DATETIME 加 DEFAULT CURRENT_TIMESTAMP这样 JDBC 插入时不用手动填时间数据库自动打点省掉一个前端传参的环节。主键用 INT AUTO_INCREMENT 对留言量这个量级足够不需要一上来就 UUID。如果前面这些安装步骤你已经做过可以直接跳到 2.3如果是照着网上的 MySQL 安装教程刚装完系统建议看完这一节再走里面有连接串最常见的翻车点。2.2 MySQL 安装与连接串三个容易漏的配置点MySQL 装好后第一件事是用命令行确认能连上mysql -u root -p用 root 做本地开发没问题但如果你想把账号权限也一起练了建议单独建一个给应用用的账号CREATE USER boardlocalhost IDENTIFIED BY board123456; GRANT SELECT, INSERT, UPDATE, DELETE ON message_board.* TO boardlocalhost; FLUSH PRIVILEGES;只给查询和增删改权限不给 DDL这是“最小权限”的习惯。很多课设项目直接用 root 连库不是说不行而是你将来写简历时被问到权限设计会露怯。真正坑多的地方在 JDBC 连接串。无论你后面用不用连接池都要先理解这一段jdbc:mysql://localhost:3306/message_board?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiuseUnicodetrue 与 characterEncodingutf8保证中文从 Java 到 MySQL 不乱码。注意这里写 characterEncodingutf8 就够MySQL 服务端是 utf8mb4 时驱动会按对应字符集处理。useSSLfalse本机开发没有配置 SSL 证书不加这个参数MySQL 8 驱动会打一堆警告极端情况握手失败。serverTimezoneAsia/ShanghaiMySQL 8 默认时区跟驱动不一致时插入时间会偏 8 小时这个问题在 JDBC 8 里几乎是必现。还有一个只看连接串看不出来的坑驱动类全名。MySQL 5.x 时代写 com.mysql.jdbc.DriverMySQL 8 之后必须写 com.mysql.cj.jdbc.Driver。如果你用 Maven 引的是 mysql-connector-java 8却还在写老类名启动时直接 ClassNotFoundException。这个错几乎每个人都会遇到一次。2.3 Tomcat 端口与 Vue devServer 代理前端 8080、后端 8090 怎么不打架原生 Servlet 跑在 Tomcat 里默认端口是 8080。而 Vue 脚手架启动的 devServer 默认也是 8080。两个一起跑必有一个起来后把另一个顶掉。常见做法是让 Vue 继续占 8080把 Tomcat 挪到 8090。改 Tomcat 端口在 conf/server.xml 里Connector port8090 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 /改完记得重启 Tomcat这个动作说过多少次都不为过——很多人改了配置不重启然后对着白屏怀疑人生。后端端口挪走后前端开发时不能每个请求都写 http://localhost:8090否则跨域问题会立刻找上门。解决办法是在 vue.config.js 里配代理让浏览器以为请求都发给了同源的 8080module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8090, changeOrigin: true, pathRewrite: { ^/api: } } } } }这种写法的意思是前端发到 /api/message 的请求会被 devServer 转发到 8090并把 URL 开头的 /api 剥掉。于是后端 Servlet 只要映射到 /message 就行前端代码里统一带 /api 前缀。好处是以后部署到生产环境只要把 /api 的转发规则从 devServer 换到 Nginx前端代码一行不用动。验证这一步是否配通不要急着写页面。直接启动 Tomcat浏览器访问 http://localhost:8090/message?actionlist能看到 JSON 输出哪怕是空数组再启动 Vue说明环境链路已经通了。3. 后端用原生 Servlet 写 CRUD生命周期、反射分发与 Druid 连接池环境通了之后后端是最能体现“原生”二字的地方。这一章我们把 Servlet 的生命周期讲透然后写一个 BaseServlet 把增删改查收敛起来最后接上 Druid 连接池和 Gson 序列化。3.1 Servlet 生命周期里真正影响留言板的是 service 线程模型很多人背 Servlet 生命周期能背出来init → service → destroy。但落地到留言板这个具体场景真正要在意的不是 init 被调了几次而是 Servlet 是单实例多线程。Tomcat 对每个 Servlet 只 new 一次之后所有请求都进同一个实例的 service 方法。在原生 Servlet 里doGet 和 doPost 都会被 HttpServlet 的 service 分发到对应方法。出于教学方便我一般直接覆写 service把 GET 和 POST 统一收进同一个入口后面用 action 参数区分业务。public class BaseServlet extends HttpServlet { Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setContentType(application/json;charsetUTF-8); String action req.getParameter(action); if (action null || action.isEmpty()) { resp.getWriter().write({\code\:400,\msg\:\action is required\}); return; } try { Method method this.getClass().getMethod(action, HttpServletRequest.class, HttpServletResponse.class); method.invoke(this, req, resp); } catch (NoSuchMethodException e) { resp.getWriter().write({\code\:404,\msg\:\action not found\}); } catch (Exception e) { Throwable cause e.getCause(); resp.getWriter().write({\code\:500,\msg\:\ (cause ! null ? cause.getMessage() : e.getMessage()) \}); } } protected void write(HttpServletResponse resp, Object data) throws IOException { resp.getWriter().write(new Gson().toJson(data)); } }这里有几个动作的顺序不能乱setCharacterEncoding(UTF-8) 必须放在第一次 getParameter 之前否则 POST 表单的中文参数读出来是乱码setContentType 声明返回的是 JSON前端 axios 才会按 JSON 解析。反射调用的方法签名必须是 (HttpServletRequest, HttpServletResponse) 两个参数子类写业务方法时保持一致action 参数的值就等于方法名。明白这段代码后你的脑子要留下一条红线service 同时被多个线程执行凡是把 req、resp 或“当前用户数据”放进成员变量的写法都会串数据。留言板并发不高也许测不出来但面试官一问你立刻露馅。3.2 BaseServlet 反射分发之后子类只剩四件事有了 BaseServlet子类不需要再管编码和 JSON 包装专心写四个方法public class MessageServlet extends BaseServlet { private final MessageDao dao new MessageDao(); public void list(HttpServletRequest req, HttpServletResponse resp) throws IOException { MapString, Object result new HashMap(); result.put(code, 0); result.put(data, dao.findAll()); write(resp, result); } public void add(HttpServletRequest req, HttpServletResponse resp) throws IOException { String nickname req.getParameter(nickname); String content req.getParameter(content); int rows dao.insert(nickname, content); MapString, Object result new HashMap(); result.put(code, rows 0 ? 0 : 1); result.put(msg, rows 0 ? ok : insert failed); write(resp, result); } public void delete(HttpServletRequest req, HttpServletResponse resp) throws IOException { int id Integer.parseInt(req.getParameter(id)); int rows dao.deleteById(id); MapString, Object result new HashMap(); result.put(code, rows 0 ? 0 : 1); write(resp, result); } public void update(HttpServletRequest req, HttpServletResponse resp) throws IOException { int id Integer.parseInt(req.getParameter(id)); String nickname req.getParameter(nickname); String content req.getParameter(content); int rows dao.update(id, nickname, content); MapString, Object result new HashMap(); result.put(code, rows 0 ? 0 : 1); write(resp, result); } }注意“原生 Servlet”不代表不能用工具库。Gson 在这里做 JSON 序列化比手写字符串拼接安全十倍——至少不用自己转义引号。这四段代码里没有一个字符是多余的list 返回数组add 和 update 返回影响行数delete 按主键删。如果你还需要编辑回显可以再加一个 detail 方法按 id 查单条逻辑和 list 几乎一样。每个方法都返回统一结构 {code, data|msg}这是给前端省事的约定。code0 表示成功非 0 表示失败前端 axios 层只要判断 code不需要去猜 HTTP 状态码。HTTP 200 有时不代表业务成功这个思想从留言板开始养成后面接 Spring Boot 也顺。3.3 查询用 Druid 连接池返回用 Gson三层各自职责很多原生 Servlet 教程用 DriverManager 每次查询现场建连接这个跑通可以但离“能给人看”还差一步。真正项目里连接是稀罕资源每次 DriverManager.getConnection 都要走 TCP 握手和认证一百个并发就能把 MySQL 拖垮。Druid 是 Java 生态里很常用的连接池配置写在类路径下的 druid.properties 里jdbc.driverClassNamecom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/message_board?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai jdbc.usernameboard jdbc.passwordboard123456 jdbc.initialSize2 jdbc.maxActive10 jdbc.maxWait3000initialSize2启动时先建 2 个连接不然第一个请求要现场建连接会慢得明显。maxActive10最多 10 个连接超过后新请求要等 maxWait。maxWait3000拿连接最多等 3 秒超过就抛异常防止并发高时所有请求全部挂死。DAO 层通过 DruidDataSourceFactory 拿到 DataSource之后每次操作从池子里借连接用完归还public class MessageDao { private static DataSource ds; static { try (InputStream in MessageDao.class.getClassLoader() .getResourceAsStream(druid.properties)) { Properties props new Properties(); props.load(in); ds DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public ListMessage findAll() throws SQLException { String sql SELECT id, nickname, content, DATE_FORMAT(create_time, %Y-%m-%d %H:%i:%s) AS createTime FROM message ORDER BY id DESC; try (Connection conn ds.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { ListMessage list new ArrayList(); while (rs.next()) { Message m new Message(); m.setId(rs.getInt(id)); m.setNickname(rs.getString(nickname)); m.setContent(rs.getString(content)); m.setCreateTime(rs.getString(createTime)); list.add(m); } return list; } } public int insert(String nickname, String content) throws SQLException { String sql INSERT INTO message(nickname, content) VALUES(?, ?); try (Connection conn ds.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, nickname); ps.setString(2, content); return ps.executeUpdate(); } } }这里有一个很多人不理解的细节查询结果里的 createTime 我直接用 DATE_FORMAT 转成字符串而不是用 java.util.Date。原因很简单——Gson 默认把 Date 序列化成时间戳数字前端拿到还要自己转用字符串“2025-05-01 12:00:00”前端直接展示零成本。这就是“后端给前端省事”的典型取舍。try-with-resources 是 JDK 7 以后的语法conn、ps、rs 都会自动关闭不用手写 finally。记住连接池的连接借了必须还。如果 DAO 里某些分支忘了关连接连接池很快就耗尽现象就是“越跑越慢最后报 Connection is not available”。4. 前端 Vue 实现列表渲染、表单提交与路由参数的三个落地点后端接口就绪后前端把留言板做成能用的页面。这一章不教你 Vue 基础语法而是按“列表怎么刷出来、留言怎么发出去、编辑页怎么拿 id”三个真实落地点走一遍。4.1 留言列表怎么渲染axios created 钩子 v-for在项目里先装 axiosnpm install axios。然后在组件里按下面这个结构写列表页template div classmessage-list div v-foritem in list :keyitem.id classmessage-item span{{ item.nickname }}/span p{{ item.content }}/p small{{ item.createTime }}/small button clickgoEdit(item.id)编辑/button button clickremove(item.id)删除/button /div button clickload刷新列表/button /div /template script import axios from axios export default { data() { return { list: [] } }, created() { this.load() }, methods: { async load() { const resp await axios.get(/api/message, { params: { action: list } }) if (resp.data.code 0) { this.list resp.data.data } }, goEdit(id) { this.$router.push({ path: /edit/ id }) }, async remove(id) { const form new URLSearchParams() form.append(action, delete) form.append(id, id) const resp await axios.post(/api/message, form) if (resp.data.code 0) { this.load() } } } } /scriptcreated() 钩子会在组件实例被创建后立刻执行所以页面一进来就发请求。用 axios.get 加 params最终 URL 是这样/api/message?actionlist正好对应后端 BaseServlet 里 req.getParameter(action)。v-for 循环渲染 list:key 绑 id这是 Vue 做列表更新的重要约定不绑 key 时删除中间元素会出现渲染错位。删除操作这里故意用了 POST 而不是 GET。原因很简单删除是改数据的操作用 GET 会把 id 留在浏览器历史里而且容易被爬虫或预加载触发。POST URLSearchParams 是保险做法。URLSearchParams 是什么它把字段拼成 id3actiondelete 这种 form 格式这是 axios 能识别的表单编码后端 getParameter 就能直接读到。4.2 发布留言为什么前端不能直接 POST 一个 JSON 对象新增留言是表单页最典型的翻车点在这一步template div input v-model.trimnickname placeholder昵称 / textarea v-model.trimcontent placeholder留言内容/textarea button clicksubmit发布/button /div /template script import axios from axios export default { data() { return { nickname: , content: } }, methods: { async submit() { const form new URLSearchParams() form.append(action, add) form.append(nickname, this.nickname) form.append(content, this.content) const resp await axios.post(/api/message, form) if (resp.data.code 0) { this.$router.push(/) } else { alert(resp.data.msg || 发布失败) } } } } /script这里必须重点解释axios 在 post 时如果传入的是普通对象会自动 JSON.stringify并把 Content-Type 设成 application/json。而原生 Servlet 的 getParameter 只会解析 application/x-www-form-urlencoded 或 form-data 格式碰到 JSON 就跑进请求体里getParameter 永远返回 null。很多第一次做前后端分离的人在这里翻车整整一下午Network 面板里请求有数据后端却读到 null。解决办法就是上面代码里的 URLSearchParams。当 axios 看到 body 是 URLSearchParams 时它不会做 JSON 序列化而是按表单格式编码并自动设置 Content-Type 为 application/x-www-form-urlencoded和后端 getParameter 完美对上。v-model.trim 是 Vue 的修饰符自动去掉首尾空格可以避免“用户只敲空格就发布”的情况。但后端仍然要防线如果 nickname 和 content 都是空字符串DAO 插入空字符串是能成功的业务上应该拒绝。这道校验放在 Servlet 方法里if (nickname null || nickname.isEmpty() || content null || content.isEmpty()) { // 返回 code1提示“昵称和内容不能为空” }前端校验只服务体验后端校验才真正挡住脏数据这是从留言板就该建立的习惯。4.3 编辑页与路由参数从列表跳转时怎么把 id 带过去编辑页需要一个 id 才知道改哪条。Vue Router 的动态路由参数是标准做法。路由配置里这么写import Vue from vue import VueRouter from vue-router import MessageList from ./views/MessageList.vue import EditMessage from ./views/EditMessage.vue Vue.use(VueRouter) const routes [ { path: /, component: MessageList }, { path: /edit/:id, component: EditMessage } ]列表页的“编辑”按钮跳转时用 this.$router.push({ path: /edit/ id })编辑页里通过 this.$route.params.id 取到参数。注意这里两个对象别搞混$router 是路由实例负责跳转$route 是当前路由信息负责拿参数。编辑页的完整逻辑是created 里先按 id 调后端的 detail 接口拿到留言内容填充到表单用户改完提交时再调 updateasync loadDetail() { const resp await axios.get(/api/message, { params: { action: detail, id: this.$route.params.id } }) if (resp.data.code 0) { this.nickname resp.data.data.nickname this.content resp.data.data.content } }在 vue-router 的 hash 模式下即使后端没配任何转发规则/edit/3 这种前端路由也不会发给 Tomcat因为 hash 后面的部分根本不参与 HTTP 请求。这是新手容易踩的一个点以为前端路由也要后端配合其实开发模式下完全不需要。等 npm run build 部署到 Tomcat 后直接访问 /edit/3 会 404但用户从列表页点进去永远先访问 / 再前端跳转所以这个坑只在“刷新页面”时出现——后面避坑章节再细说。5. 留言板系统避坑指南联调时最常见的 5 个翻车点这一章是血泪经验。以下每一条我都亲眼见过至少三次按“现象 → 原因 → 解决”写清楚你照着查就行。5.1 现象前端请求数据已发出后端 getParameter 读到 null原因前端 axios post 传了一个普通对象axios 把它序列化成 JSONContent-Type 是 application/json。原生 Servlet 的 getParameter 是针对表单编码解析的JSON 在请求体里它看不见。解决不要传对象改传 URLSearchParams 实例或者后端读 request.getReader() 手动解析 JSON。两种选一种别混用。我一般推荐前者因为代码改动最小。排查时打开 Chrome 的 Network 面板点开这个请求看 Request Headers 里的 Content-Type 是 application/json 还是 application/x-www-form-urlencoded一眼就能确认原因。如果团队里坚持要 JSON那后端加一个 JSON 解析工具把 reader 里的内容用 Gson 转成 Map再按 key 取值这也行但要保证每个 Servlet 都这么写。5.2 现象MySQL 插入中文变成问号或乱码原因不是单点问题是三层有一处不一致——数据库字符集不是 utf8mb4、连接串没带 characterEncodingutf8、页面表单没按 UTF-8 提交。任何一层断了中文就变。解决建库时指定 DEFAULT CHARACTER SET utf8mb4druid.properties 连接串带上 useUnicodetruecharacterEncodingutf8Web 层在 BaseServlet 第一行加 req.setCharacterEncoding(UTF-8)。定位时用三条 SQL 逐一排查SHOW CREATE DATABASE message_board、SHOW CREATE TABLE message、SELECT content, HEX(content) FROM message WHERE id1。HEX 里出现 E4 开头的字节说明落库正确问题在查询展示落库就错了问题在连接串或页面。也可以在 mysql 命令行里先手动执行一条带中文的 INSERT如果命令行都乱码那先从 MySQL 服务端字符集查起别急着改 Java 代码。5.3 现象Tomcat 启动后第一个请求要等好几秒或者运行一段时间后报 Connection is not available原因连接池 initialSize 设成 0所有连接都等第一次请求时才创建MySQL 的 wait_timeout 默认把空闲连接断掉了池里拿到的是死连接。解决把 initialSize 配成 23让应用启动时就把连接准备好连接池打开 testWhileIdletrue并配 validationQuerySELECT 1。Druid 的配置里这两项是针对“拿连接超时”和“连接被 MySQL 回收”的后悔药。本机调的时候可以临时用 SET GLOBAL wait_timeout3600 拉长空闲超时但这只是治标连接池自身的检测必须开。如果你用了 Druid 的监控页还能看到当前活跃连接数——活跃数一直等于 maxActive 且请求变慢基本就是连接没归还。5.4 现象两个用户同时操作A 的留言内容显示在 B 的表单里原因Servlet 是单实例多线程如果代码里把 request 或当前操作的用户数据存进了成员变量多个线程就会互相覆盖。比如有人写private String currentNickname;然后在 service 里 this.currentNickname req.getParameter(nickname)另一个线程进来就把值改了B 拿到的就是 A 的数据。解决所有请求相关数据都作为方法局部变量传递。DAO 和 Service 可以做成单例但里面不能带“当前请求”状态。如果你在写代码时觉得“这里存个成员变量省得反复传参”建议立刻刹车。顺手查一遍现有代码类里面有没有没加 static 的非 final 字段如果有且不是连接池这类无状态资源基本都是隐患。5.5 现象npm run build 部署到 Tomcat 后页面白屏接口 404原因开发时 vue.config.js 的代理只在 devServer 里生效打包后的静态文件里保留的是 /api/message 这种路径。部署到 Tomcat 后没有 devServer 帮你转发浏览器直接请求 Tomcat 的 /api/message而 Tomcat 里只有 /message 这个 Servlet自然 404。解决生产环境让请求路径和后端 Servlet 映射对齐。如果部署在 webapps/ROOT最简单是去掉 /api 前缀让前端直接请求 /message?actionlist如果还想保留 /api就用 Nginx 把 /api 反代到 Tomcat把路径里的 /api 剥掉配置和开发时 vue.config.js 几乎一样。注意后端 Servlet 的映射路径始终是 /message不管前面有没有 /api最终都要靠反向代理把前缀消掉。打包前先搜一下 dist 目录里有没有残留的 localhost:8090这个动作能提前暴露问题。6. 收尾用 Postman 和日志验证接口顺手把留言板改成带分页的版本到这里核心链路已经能跑了但一个能拿出手的留言板系统还需要最后两步验证和扩展。验证接口最稳的方式不是写页面而是用 Postman 建一个 Message 集合按 add → list → update → delete 的顺序跑每步检查返回的 code0。为什么非要这样做因为页面操作会被路由、按钮、缓存干扰接口直接返回的结果才是后端真实状态。我之前调试一个删除失败的问题页面里点了没反应用 Postman 一跑才发现是 id 传来的类型和预期不符DAO 里 Integer.parseInt 抛了异常。Tomcat 的日志在安装目录的 logs 文件夹下Linux/macOS 主要看 catalina.outWindows 看按日期滚动的 catalina.xxxx-xx-xx.log 和 localhost.log。接口 500 时最有效的动作是打开 localhost.log 拉到最底部看 Caused by 那几行基本能定位到 80% 的问题。看日志看到 Caused by 再动手改代码不要只看第一行 Exception 就猜。如果还想往前一步把留言板升级成带分页的版本是很合适的练手后端 list 方法接收 page 和 size 参数SQL 变成 SELECT ... ORDER BY id DESC LIMIT ? OFFSET ?前端列表页加一个页码状态请求时带上 page渲染时把 total 除以 size 得到总页数。这一步做完你接触到的就不再是“能跑的 demo”而是需要考虑数据量增长的正式写法。我做这类项目有个习惯每一层只做一件事Servlet 只收参数和返回 JSONDAO 只碰 SQLVue 组件只负责把数据变成页面。早期我总想着在一个 Servlet 里把连接、SQL、拼 JSON 全写完后来被一个线上问题教育了——连接池耗尽时日志里全是一层层堆出来的排错排到怀疑人生。希望帮到你。本文还有配套的精品资源点击获取