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。这样职责清晰,调试也方便——打开浏览器控制台就能看到每个请求的返回。
技术选型上,我列一下具体的版本和理由:
| 技术组件 | 版本选择 | 选型理由 |
|---|---|---|
| JDK | 1.8 | 服务器上已有环境,兼容性最好,Tomcat 8.5对1.8支持稳定 |
| Tomcat | 8.5 | 轻量,启动快,对Servlet 3.1规范支持完整 |
| MySQL | 5.7 | 朋友服务器上已安装,InnoDB引擎支持事务,utf8mb4字符集 |
| Servlet | 3.1 | 支持注解配置@WebServlet,省去web.xml繁琐配置 |
| JSP | 2.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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; 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-Type | resp.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包在 Tomcat配置方面,我调整了
5.2 JSP预编译与静态资源缓存JSP第一次访问时会编译成Servlet,这个过程比较慢。可以在Tomcat的
5.3 数据库查询优化用户列表查询主要慢在 对于这个项目,数据量预计在几千条以内, 另外, 5.4 日志配置与问题追踪日志是排查问题的眼睛。我用Logback替换了默认的JUL,配置如下: 关键日志点:请求进入Servlet时记录参数,SQL执行前记录语句和参数,异常时记录完整堆栈。这样出问题时,看日志就能还原整个请求链路。 6. 项目扩展方向与个人体会这个项目上线后运行了半年多,中间迭代了几次。后来加的功能包括:用户信息导出Excel(用POI)、批量启用/禁用、操作日志记录。导出功能特别受欢迎,因为之前他们手动复制粘贴要花半小时,现在点一下按钮几秒钟搞定。 如果让我重新设计,我会在几个地方做改进。第一,引入MyBatis或JPA替代手写JDBC。虽然手写JDBC让我对SQL有完全控制,但重复的 不过话说回来,技术选型没有绝对的对错,关键看场景。这个项目的核心诉求是“简单、稳定、好维护”,Servlet+JSP+MySQL这套组合虽然老,但胜在成熟、资料多、出问题好查。对于内部工具类项目,快速交付和低维护成本比技术先进性更重要。 最后分享一个我踩过的坑:Tomcat的work目录缓存。有次改了JSP页面,重启Tomcat后还是显示旧内容,折腾了半天才发现是 |