☰
Servlet+JSP+MySQL前后端分离用户列表系统实战
2026/10/15 7:08:49 网站建设 项目流程

1. 项目缘起与整体设计思路

这个项目说起来挺有意思,最早是我在帮一个做企业内训的朋友整理学员档案时碰到的需求。他们之前用的是一个单机版的小工具,数据存在本地文件里,几个人同时用就各种冲突,导出个名单还得手动复制粘贴。后来找到我,说能不能搞一个简单的Web端用户信息列表展示系统,要求不高:能录入、能查看、能搜索、能分页,最好还能导出。预算有限,服务器就是一台普通的云主机,环境是现成的Tomcat。

我当时第一反应是用Spring Boot一把梭,但仔细想了想,这个场景其实没必要上那么重的框架。一来服务器配置一般,二来维护的人对Spring生态不熟,出了问题排查成本高。最后我选了Servlet+JSP+MySQL这套经典组合,但做了一个关键调整——前后端分离。注意,这里的“前后端分离”不是指现在流行的Vue/React那一套,而是指JSP只负责页面骨架渲染,数据交互全部走Servlet提供的接口,通过AJAX异步获取。这样既保留了JSP上手快的优势,又避免了传统JSP里嵌一堆Java代码的混乱局面。

为什么这么设计?我解释一下背后的考量。传统JSP开发模式里,页面里经常混着<% %>脚本片段、JDBC查询、HTML标签,一个文件几百行,改一个字段要翻半天。而纯前后端分离(比如后端只出JSON,前端用Vue)对这个项目来说又太重了,引入Node构建工具链、跨域配置、打包部署,维护成本反而上去了。折中方案就是:JSP作为视图模板,只输出HTML结构和少量初始化数据;所有业务数据通过fetch或XMLHttpRequest调用Servlet接口获取,Servlet返回JSON。这样职责清晰,调试也方便——打开浏览器控制台就能看到每个请求的返回。

技术选型上,我列一下具体的版本和理由:

技术组件版本选择选型理由
JDK1.8服务器上已有环境,兼容性最好,Tomcat 8.5对1.8支持稳定
Tomcat8.5轻量,启动快,对Servlet 3.1规范支持完整
MySQL5.7朋友服务器上已安装,InnoDB引擎支持事务,utf8mb4字符集
Servlet3.1支持注解配置@WebServlet,省去web.xml繁琐配置
JSP2.3配合JSTL标签库做页面渲染
前端库原生JS + Bootstrap 4不引入构建工具,CDN直接引用,降低维护门槛
JSON库Jackson序列化方便,性能稳定

这个组合的好处是零构建、零额外依赖,把项目打成war包扔进Tomcat的webapps目录就能跑。对于中小型内部系统来说,这种“轻量级分离”方案在开发效率和可维护性之间找到了一个不错的平衡点。

数据库设计方面,用户信息表我设计了这些字段:id(自增主键)、username(用户名)、real_name(真实姓名)、email(邮箱)、phone(手机号)、department(部门)、status(状态:1启用/0禁用)、create_time(创建时间)、update_time(更新时间)。索引方面,在username和phone上建了唯一索引,department和status上建了普通索引用于筛选。字符集统一用utf8mb4,避免emoji存储报错——这个坑我后面会细说。

2. 核心细节解析与实操要点

2.1 项目目录结构与职责划分

目录结构这件事,看似简单,但规划不好后期维护很痛苦。我采用的是标准Maven Web项目结构,但做了一些微调:

userlist/ ├── src/main/java/ │ ├── com/example/userlist/ │ │ ├── entity/ # 实体类,与数据库表对应 │ │ ├── dao/ # 数据访问层,纯JDBC操作 │ │ ├── service/ # 业务逻辑层 │ │ ├── servlet/ # 控制器层,处理HTTP请求 │ │ └── util/ # 工具类:数据库连接、JSON封装 ├── src/main/webapp/ │ ├── static/ │ │ ├── css/ # 自定义样式 │ │ ├── js/ # 页面交互脚本 │ │ └── lib/ # 第三方库本地备份 │ ├── WEB-INF/ │ │ ├── web.xml # 仅配置欢迎页和编码过滤器 │ │ └── views/ # JSP页面存放,外部无法直接访问 │ └── index.jsp # 入口页,重定向到列表页 └── pom.xml # Maven依赖管理

这里有个关键点:JSP页面放在WEB-INF/views/下面,而不是直接放在webapp根目录。为什么?因为放在WEB-INF下的JSP无法通过浏览器地址栏直接访问,必须经过Servlet的RequestDispatcher.forward()转发。这样做的好处是,所有页面访问都经过控制器,可以在转发前做权限校验、数据预处理,避免用户直接敲URL绕过业务逻辑。比如用户列表页,必须先经过UserListServlet查询数据、设置请求属性,再转发到list.jsp,这样JSP里拿到的就是已经准备好的数据。

2.2 数据库连接池的取舍

数据库连接这块,我纠结过要不要上连接池。理论上,每次请求都DriverManager.getConnection()创建新连接,在高并发下会直接把数据库连接数打满。但这个项目是内部使用,同时在线人数撑死几十个,用连接池反而增加了配置复杂度。

不过最后我还是用了Druid连接池,原因有两个:一是Druid自带监控页面,能直观看到SQL执行情况和连接池状态,排查问题方便;二是配置简单,一个druid.properties文件加几行初始化代码就搞定。具体配置如下:

driverClassName=com.mysql.jdbc.Driver url=jdbc:mysql://localhost:3306/userlist?useUnicode=true&characterEncoding=utf8mb4&useSSL=false username=root password=your_password initialSize=5 minIdle=5 maxActive=20 maxWait=30000 validationQuery=SELECT 1 testWhileIdle=true testOnBorrow=false testOnReturn=false

这里有几个参数值得说明。maxActive=20是根据Tomcat默认最大线程数200来定的,一般每个请求持有连接的时间很短,20个连接足够周转。maxWait=30000表示获取连接的超时时间是30秒,超过就抛异常,避免请求无限等待。validationQuery=SELECT 1是连接有效性检测语句,testWhileIdle=true表示空闲时检测,这样能及时发现被数据库服务器断开的死连接。

注意:MySQL 8.0以上版本驱动类名是com.mysql.cj.jdbc.Driver,URL里还需要加serverTimezone=Asia/Shanghai,否则会报时区错误。我用的MySQL 5.7所以没这个问题,但如果你环境不同,记得调整。

2.3 统一JSON响应格式设计

前后端分离的核心是接口约定。我设计了一个统一的响应格式,所有Servlet接口都返回这个结构:

{ "code": 200, "message": "操作成功", "data": { ... }, "total": 100 }

code用HTTP状态码风格:200成功、400参数错误、500服务器异常。message是给前端提示用的。data是实际业务数据,可能是对象、数组或null。total专门用于分页场景,表示总记录数。

为什么要统一格式?因为前端处理响应时可以写一个通用函数,根据code判断成功失败,成功就取data,失败就弹message。不用每个接口写一套解析逻辑。我在util包里封装了一个Result类:

public class Result<T> { private int code; private String message; private T data; private Long total; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "操作成功"; r.data = data; return r; } public static <T> Result<T> success(T data, Long total) { Result<T> r = success(data); r.total = total; return r; } public static <T> Result<T> error(int code, String message) { Result<T> r = new Result<>(); r.code = code; r.message = message; return r; } // getter/setter省略 }

Servlet里这样用:

@WebServlet("/api/user/list") public class UserListServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("application/json;charset=UTF-8"); PrintWriter out = resp.getWriter(); try { int page = Integer.parseInt(req.getParameter("page") == null ? "1" : req.getParameter("page")); int limit = Integer.parseInt(req.getParameter("limit") == null ? "10" : req.getParameter("limit")); String keyword = req.getParameter("keyword"); String department = req.getParameter("department"); PageResult<User> pageResult = userService.queryUsers(page, limit, keyword, department); out.print(new ObjectMapper().writeValueAsString( Result.success(pageResult.getList(), pageResult.getTotal()))); } catch (Exception e) { out.print(new ObjectMapper().writeValueAsString( Result.error(500, "查询失败:" + e.getMessage()))); } } }

这段代码里有个细节:resp.setContentType("application/json;charset=UTF-8")必须在getWriter()之前调用,否则编码设置不生效,返回的中文会变成乱码。这个坑我踩过不止一次。

3. 实操过程与核心环节实现

3.1 数据库表创建与初始化数据

先把表建好,这是所有操作的基础。SQL脚本如下:

CREATE DATABASE IF NOT EXISTS userlist DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE userlist; CREATE TABLE `sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(50) NOT NULL COMMENT '登录账号', `real_name` varchar(50) NOT NULL COMMENT '真实姓名', `email` varchar(100) DEFAULT NULL COMMENT '电子邮箱', `phone` varchar(20) DEFAULT NULL COMMENT '手机号码', `department` varchar(50) DEFAULT NULL COMMENT '所属部门', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1启用 0禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`), UNIQUE KEY `uk_phone` (`phone`), KEY `idx_department` (`department`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';

插入一些测试数据,方便调试:

INSERT INTO `sys_user` (`username`, `real_name`, `email`, `phone`, `department`, `status`) VALUES ('zhangsan', '张三', 'zhangsan@example.com', '13800000001', '技术部', 1), ('lisi', '李四', 'lisi@example.com', '13800000002', '市场部', 1), ('wangwu', '王五', 'wangwu@example.com', '13800000003', '技术部', 1), ('zhaoliu', '赵六', 'zhaoliu@example.com', '13800000004', '人事部', 0), ('sunqi', '孙七', 'sunqi@example.com', '13800000005', '财务部', 1);

提示:create_time和update_time用了MySQL的默认值特性,插入时不用手动传,更新时update_time自动刷新。这样能减少代码里的时间处理逻辑,也避免时区转换问题。

3.2 分页查询的SQL实现与参数计算

分页是列表展示的核心功能。我采用的是物理分页,即用LIMIT offset, size在数据库层面截取数据,而不是查全量再内存分页。为什么?因为用户表数据量虽然不大,但养成好习惯很重要,万一以后数据涨到几万条,内存分页会直接把JVM堆撑爆。

分页SQL的拼接逻辑在DAO层:

public PageResult<User> queryUsers(int page, int limit, String keyword, String department) { StringBuilder sql = new StringBuilder("SELECT * FROM sys_user WHERE 1=1"); List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { sql.append(" AND (username LIKE ? OR real_name LIKE ? OR phone LIKE ?)"); String likeKeyword = "%" + keyword.trim() + "%"; params.add(likeKeyword); params.add(likeKeyword); params.add(likeKeyword); } if (department != null && !department.trim().isEmpty()) { sql.append(" AND department = ?"); params.add(department.trim()); } // 先查总数 String countSql = "SELECT COUNT(*) FROM (" + sql.toString() + ") AS t"; long total = queryCount(countSql, params.toArray()); // 再查当前页数据 sql.append(" ORDER BY id DESC LIMIT ?, ?"); params.add((page - 1) * limit); params.add(limit); List<User> list = queryList(sql.toString(), params.toArray()); return new PageResult<>(list, total); }

这里有个性能优化点:先查总数再查数据,而不是用SQL_CALC_FOUND_ROWS。因为SQL_CALC_FOUND_ROWS在MySQL 8.0已经废弃,而且它需要扫描所有匹配行,性能不如单独执行COUNT(*)。另外,COUNT(*)查询我包了一层子查询,这样能复用WHERE条件,避免重复拼接。

参数计算方面,offset = (page - 1) * limit。比如第1页每页10条,offset=0;第2页offset=10。前端传参时,page从1开始,limit默认10,最大限制100,防止恶意请求拉全表。

3.3 前端AJAX请求与表格渲染

前端这块,我用原生fetchAPI,不引入jQuery。虽然jQuery的$.ajax写起来更短,但原生fetch是标准,而且能强制自己理解Promise和异步流程。列表页的核心逻辑在static/js/user-list.js里:

const API_BASE = '/userlist/api/user'; let currentPage = 1; const pageSize = 10; async function loadUserList() { const keyword = document.getElementById('searchKeyword').value.trim(); const department = document.getElementById('searchDepartment').value; const params = new URLSearchParams({ page: currentPage, limit: pageSize, keyword: keyword, department: department }); try { const response = await fetch(`${API_BASE}/list?${params}`); const result = await response.json(); if (result.code === 200) { renderTable(result.data); renderPagination(result.total); } else { alert('加载失败:' + result.message); } } catch (error) { console.error('请求异常', error); alert('网络异常,请稍后重试'); } } function renderTable(users) { const tbody = document.getElementById('userTableBody'); if (!users || users.length === 0) { tbody.innerHTML = '<tr><td colspan="7" class="text-center">暂无数据</td></tr>'; return; } tbody.innerHTML = users.map(user => ` <tr> <td>${user.id}</td> <td>${escapeHtml(user.username)}</td> <td>${escapeHtml(user.realName)}</td> <td>${escapeHtml(user.email || '-')}</td> <td>${escapeHtml(user.phone || '-')}</td> <td>${escapeHtml(user.department || '-')}</td> <td> <span class="badge ${user.status === 1 ? 'badge-success' : 'badge-secondary'}"> ${user.status === 1 ? '启用' : '禁用'} </span> </td> </tr> `).join(''); } function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>"']/g, function(match) { const escapeMap = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; return escapeMap[match]; }); }

这里有个安全细节:所有从后端返回的字符串都要经过escapeHtml转义再插入DOM。虽然这个系统是内部使用,但万一有人把用户名改成<script>alert(1)</script>,直接innerHTML就会触发XSS。养成转义习惯,比事后补救强。

分页组件我用Bootstrap的pagination样式,动态生成页码:

function renderPagination(total) { const totalPages = Math.ceil(total / pageSize); const pagination = document.getElementById('pagination'); if (totalPages <= 1) { pagination.innerHTML = ''; return; } let html = ''; // 上一页 html += `<li class="page-item ${currentPage === 1 ? 'disabled' : ''}"> <a class="page-link" href="#">let searchTimer = null; document.getElementById('searchKeyword').addEventListener('input', function() { clearTimeout(searchTimer); searchTimer = setTimeout(() => { currentPage = 1; // 搜索时重置到第一页 loadUserList(); }, 300); });

300毫秒的延迟是实测比较舒服的值。太短了请求太频繁,太长了用户觉得卡顿。部门筛选用change事件,立即触发查询,因为下拉选择是明确操作,不需要防抖。

搜索和筛选条件可以叠加,比如同时按“技术部”和关键词“张”查询。后端SQL里用AND连接条件,前端把参数拼在URL里一起传。这里要注意:每次改变搜索条件都要把currentPage重置为1,否则可能出现当前在第5页,搜索后结果只有2页,页面显示空白的情况。

4. 常见问题与排查技巧实录

4.1 中文乱码问题的三层排查

中文乱码是Java Web开发的老大难问题,我遇到过至少三种不同的乱码场景,排查思路如下:

乱码现象可能原因排查方法解决方案
表单提交后数据库存的是问号请求编码未设置检查request.getCharacterEncoding()在Filter里统一设置request.setCharacterEncoding("UTF-8")
页面显示中文正常但接口返回乱码响应编码未设置浏览器控制台看Response Headers的Content-Typeresp.setContentType("application/json;charset=UTF-8")
数据库查询出来是乱码连接URL或表字符集不对执行SHOW VARIABLES LIKE 'character%'URL加characterEncoding=utf8mb4,表用utf8mb4

我写了一个编码过滤器,在web.xml里配置对所有请求生效:

@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); } }

注意:response.setContentType()和response.setCharacterEncoding()的区别。前者设置MIME类型和编码,后者只设置编码。对于JSON接口,必须用setContentType("application/json;charset=UTF-8"),否则浏览器可能按默认编码解析。

4.2 分页查询总数与数据不一致

这个问题比较隐蔽:用户列表显示总共100条,翻到第10页却只有5条数据。原因通常是查询总数和查询数据的条件不一致。比如总数查询用了keyword条件,但数据查询忘了加,或者反过来。

我的解决办法是把条件拼接逻辑抽成一个方法,总数查询和数据查询共用同一套WHERE子句:

private String buildWhereClause(String keyword, String department, List<Object> params) { StringBuilder where = new StringBuilder(" WHERE 1=1"); if (keyword != null && !keyword.trim().isEmpty()) { where.append(" AND (username LIKE ? OR real_name LIKE ? OR phone LIKE ?)"); String likeKeyword = "%" + keyword.trim() + "%"; params.add(likeKeyword); params.add(likeKeyword); params.add(likeKeyword); } if (department != null && !department.trim().isEmpty()) { where.append(" AND department = ?"); params.add(department.trim()); } return where.toString(); }

这样总数SQL就是SELECT COUNT(*) FROM sys_user+ whereClause,数据SQL就是SELECT * FROM sys_user+ whereClause +LIMIT ?,?。条件永远一致,不会出现对不上的情况。

4.3 数据库连接泄漏的排查

连接泄漏的表现是:系统运行一段时间后,所有请求都卡住,日志里出现wait millis 30000, active 20这样的错误。原因是某个地方获取了连接但没关闭。

排查方法:在Druid监控页面看活跃连接数,如果持续增长不下降,就是泄漏了。或者用lsof -i:3306看服务器上的连接数。

我的经验是,所有JDBC操作必须放在try-with-resources里:

public List<User> queryList(String sql, Object... params) { List<User> list = new ArrayList<>(); try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } } catch (SQLException e) { throw new RuntimeException("查询失败", e); } return list; }

try-with-resources会自动关闭Connection、PreparedStatement和ResultSet,即使中间抛异常也不影响。这比手动在finally里close靠谱得多。

4.4 前端跨域与404问题

虽然这个项目前后端在同一个Tomcat里,不存在跨域,但如果你把前端页面单独部署(比如用Nginx托管静态页),就会遇到CORS问题。解决方案是在Servlet里加响应头:

resp.setHeader("Access-Control-Allow-Origin", "*"); resp.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); resp.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");

另外,OPTIONS预检请求要直接返回200,不执行业务逻辑:

if ("OPTIONS".equalsIgnoreCase(req.getMethod())) { resp.setStatus(HttpServletResponse.SC_OK); return; }

404问题通常是路径写错了。我的建议是:Servlet的@WebServlet路径和前端请求路径保持完全一致,并且注意context path。比如项目部署在/userlist下,前端请求要写/userlist/api/user/list,而不是/api/user/list。可以在JSP里用${pageContext.request.contextPath}动态获取:

const API_BASE = '${pageContext.request.contextPath}/api/user';

这样无论项目部署在什么路径下,前端都能正确请求。

4.5 常见问题速查表

问题现象可能原因快速解决
页面空白,控制台报404请求路径错误或Servlet未注册检查@WebServlet路径和前端请求URL
接口返回500,日志报ClassNotFound缺少Jackson或MySQL驱动依赖检查pom.xml,重新mvn clean package
分页点击无反应事件绑定失败或页码计算错误控制台看是否有JS报错,检查>mvn clean package -DskipTests

生成的war包在target/userlist.war,复制到Tomcat的webapps目录下,启动Tomcat会自动解压部署。访问地址是http://服务器IP:8080/userlist/。

Tomcat配置方面,我调整了server.xml里的连接器参数:

<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" maxThreads="200" minSpareThreads="20" acceptCount="100" URIEncoding="UTF-8"/>

URIEncoding="UTF-8"很重要,否则GET请求里的中文参数会乱码。maxThreads=200是默认值,对于内部系统够用了。acceptCount=100表示等待队列长度,超过就拒绝连接。

5.2 JSP预编译与静态资源缓存

JSP第一次访问时会编译成Servlet,这个过程比较慢。可以在Tomcat的conf/web.xml里开启JSP预编译:

<servlet> <servlet-name>jsp</servlet-name> <servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class> <init-param> <param-name>development</param-name> <param-value>false</param-value> </init-param> <init-param> <param-name>genStringAsCharArray</param-name> <param-value>true</param-value> </init-param> </servlet>

development=false会关闭JSP的运行时编译检查,提升性能。静态资源(CSS/JS/图片)可以配置缓存头,减少重复请求:

@WebFilter("/static/*") public class CacheFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp = (HttpServletResponse) response; resp.setHeader("Cache-Control", "max-age=86400"); chain.doFilter(request, response); } }

max-age=86400表示缓存一天。如果更新了静态文件,改一下文件名或加版本号参数即可。

5.3 数据库查询优化

用户列表查询主要慢在LIKE '%keyword%'上,因为前置通配符无法走索引。优化方案有两个:一是限制搜索字段,只对username和phone做前缀匹配(LIKE 'keyword%'),这样能走索引;二是如果数据量大,引入全文索引或搜索引擎。

对于这个项目,数据量预计在几千条以内,LIKE '%keyword%'的全表扫描在毫秒级完成,没必要过度优化。但我还是加了一个查询超时设置,防止意外慢查询拖垮系统:

ps.setQueryTimeout(5); // 5秒超时

另外,ORDER BY id DESC如果id是主键,MySQL会直接按主键倒序扫描,效率很高。如果按create_time排序,记得给create_time建索引。

5.4 日志配置与问题追踪

日志是排查问题的眼睛。我用Logback替换了默认的JUL,配置如下:

<configuration> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>/var/log/userlist/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>/var/log/userlist/app.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <logger name="com.example.userlist" level="DEBUG"/> <root level="INFO"> <appender-ref ref="FILE"/> </root> </configuration>

关键日志点:请求进入Servlet时记录参数,SQL执行前记录语句和参数,异常时记录完整堆栈。这样出问题时,看日志就能还原整个请求链路。

6. 项目扩展方向与个人体会

这个项目上线后运行了半年多,中间迭代了几次。后来加的功能包括:用户信息导出Excel(用POI)、批量启用/禁用、操作日志记录。导出功能特别受欢迎,因为之前他们手动复制粘贴要花半小时,现在点一下按钮几秒钟搞定。

如果让我重新设计,我会在几个地方做改进。第一,引入MyBatis或JPA替代手写JDBC。虽然手写JDBC让我对SQL有完全控制,但重复的mapRow和参数绑定代码太多,用ORM能省不少事。第二,前端用轻量级框架比如Alpine.js或Petite-Vue,比原生JS操作DOM更优雅,又不像Vue那么重。第三,加一层Redis缓存,把部门列表、状态字典这些不常变的数据缓存起来,减少数据库查询。

不过话说回来,技术选型没有绝对的对错,关键看场景。这个项目的核心诉求是“简单、稳定、好维护”,Servlet+JSP+MySQL这套组合虽然老,但胜在成熟、资料多、出问题好查。对于内部工具类项目,快速交付和低维护成本比技术先进性更重要。

最后分享一个我踩过的坑:Tomcat的work目录缓存。有次改了JSP页面,重启Tomcat后还是显示旧内容,折腾了半天才发现是work/Catalina/localhost/userlist下的编译缓存没清。后来我养成了习惯,部署前先删work目录,或者直接在Tomcat的context.xml里加<Context reloadable="true">开启热加载。这个细节文档里很少提,但实际开发中经常遇到。

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

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

立即咨询