news 2026/10/29 15:39:14

JavaWeb运动健康管理系统源码拆解、排错与二次开发

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaWeb运动健康管理系统源码拆解、排错与二次开发

简介:面向课程设计、毕业设计及JavaWeb入门进阶者的运动与健康管理系统完整源码与数据库包,围绕用户管理、运动记录、健康监测、数据分析、提醒建议和社区互动等模块,提供可参考的实现范例与表结构设计。压缩包共707个文件、约9.59MB,包含74个JSP页面、34个Java源文件与对应class文件、15个jar依赖,以及181个JavaScript、18个CSS和47个HTML等前端资源,并配有图片素材与SQL初始化脚本,基本覆盖系统运行与页面展示所需内容。已有2020人学习下载。读者可据此梳理Servlet/JSP与JavaBean的协作方式、用户表/运动记录表/健康指标表之间的关系,理解卡路里统计、趋势图展示和个性化建议的实现思路,也便于在本地部署后做功能裁剪与二次开发,作为课程答辩和项目练习的对照材料。

1. 一台跑不起来的运动健康系统,卡住的往往不是业务代码

前阵子有人丢过来一份 javaweb 运动与健康管理系统 的压缩包,说库也建了、Tomcat 也起了,登录页能打开,可一点“保存运动记录”就白屏,控制台刷出一片红。我打开看了十分钟,问题根本不在业务逻辑:jdbc.properties里还留着原作者本机的root/123456,Servlet 里拿String直接往java.util.Date上塞,驱动包是 5.x 却连着一个 8.x 的库。把这三处按顺序处理掉,整套系统就跑通了。

这类源码包的价值不在于代码多高级,而在于它把“运动打卡 + 健康指标 + 饮食记录 + 后台统计”这条完整业务线铺开了,MVC 分层干净,表结构也能直接看懂。适合两类人:一类是要拿它做课程设计、毕业设计底座的学生,改改界面加两个报表就能交;另一类是想把 JavaWeb 那套请求链路从头到尾走一遍的开发者,Servlet 怎么接参数、DAO 怎么拼 SQL、JSP 怎么回显,全在明面上。下面按我实际拆解的顺序来:先看结构和表,再把环境跑起来,然后拆一条完整链路,最后讲怎么往上加东西。

2. 技术栈与目录结构:一个 JavaWeb 工程的分层长什么样

拿到一个没写 README 的源码包,第一件事不是急着点运行,而是先确认它属于哪一代技术栈。这决定了你后面要装什么、改哪里、能不能直接扔进 IDEA 跑。

2.1 技术选型:为什么还在用 JSP + Servlet + JDBC 这套组合

拆开WEB-INF/lib目录基本就能定性。这份运动与健康管理系统用的是典型的原生 JavaWeb 三件套:JSP 负责视图渲染,Servlet 承担控制器,JavaBean 做数据载体,持久层直接手写 JDBC,中间没有 Spring、没有 MyBatis,也没有 Maven 的pom.xml。前端是 Bootstrap 3 加 jQuery,统计页面上挂了 ECharts 画柱状图和折线图,数据库是 MySQL。

这套组合放在今天看确实老,但它有几个对初学者特别友好的地方。第一是“所见即所得”,没有框架的依赖注入和代理,一个 HTTP 请求从index.jsp发出,到XxxServlet.doPost()里被接住,再到XxxDao里拼 SQL,链路短得能一眼看穿,出了错堆栈直接指到行号。第二是部署零门槛,不用配 Maven 私服、不用管 Spring Boot 的自动装配,把 jar 包往WEB-INF/lib里一拷,整个工程丢进 Tomcat 的webapps就能跑。第三是改起来没心理负担,想加个字段就在表里加一列、Bean 里加一个属性、JSP 里加一个<input>,三处改完就通了。

代价也很明显:SQL 全靠手写,注入防护得自己上PreparedStatement;分页要手动算limit偏移量;连接池如果作者没配,那就是每次请求DriverManager.getConnection()新建连接,压测一上来数据库直接拒绝服务。这些在后面章节会逐个说。

技术栈清单大致是这样:

层次技术说明
视图层JSP + JSTL + Bootstrap 3 + jQuery页面在WebContent下,含少量 EL 表达式
控制层Servlet 3.0(注解或 web.xml 注册)每个业务模块一个 Servlet
模型层普通 JavaBean与数据库表字段一一对应
持久层原生 JDBC +DBUtil工具类手写 SQL,无 ORM
数据库MySQL 5.7 / 8.0随包附带.sql脚本
容器Tomcat 8.5 / 9.0Servlet 3.1 规范
图表ECharts用于体重趋势、运动时长统计

提示:如果lib里出现spring-webmvc-x.x.jar或mybatis-x.x.jar,说明这是 SSM 版本,启动方式要换成applicationContext.xml加 Tomcat 插件,别按纯 Servlet 的思路去改。

2.2 目录结构:src、WebContent、sql 三块怎么摆

源码包解压后通常是三个平级目录,加一个说明文档。结构大致如下,路径名各家会有出入,但分层逻辑基本一致:

sport-health/ ├── src/ # Java 源码根目录 │ ├── com/health/bean/ # 实体类:User、SportRecord、HealthRecord │ ├── com/health/dao/ # 数据访问:SportRecordDao、UserDao │ ├── com/health/servlet/ # 控制器:LoginServlet、SportServlet │ ├── com/health/util/ # 工具类:DBUtil、DateUtil、PageUtil │ └── db.properties # 数据库连接配置(有的放在 src 根下) ├── WebContent/ # Web 根目录,等价于 Maven 的 webapp │ ├── WEB-INF/ │ │ ├── lib/ # mysql-connector、jstl、servlet-api 等 jar │ │ └── web.xml # Servlet 映射、欢迎页、过滤器配置 │ ├── css/ js/ images/ # 静态资源,含 bootstrap 与 echarts │ ├── common/ # 公共页头页尾,如 header.jsp、footer.jsp │ ├── admin/ # 后台页面:用户管理、数据审核 │ └── *.jsp # 前台页面:登录、运动打卡、健康档案 └── sql/ └── health_db.sql # 建库建表 + 初始数据的完整脚本

src下的包名是判断分层是否规范的关键。合格的写法是bean / dao / servlet / util四层拆开,dao里只有 SQL 和结果集映射,servlet里只做参数接收和跳转,业务判断尽量薄。我见过不少改过的版本把所有逻辑塞进 Servlet 的doPost里,一个方法两百行,那种后面想加个统计功能基本等于重写。

WebContent/WEB-INF/web.xml值得单独看一眼。如果是 Servlet 3.0 以上,作者多半用@WebServlet("/sport")注解注册,web.xml里就只剩欢迎页和CharacterEncodingFilter的配置。字符编码过滤器非常关键,缺了它,JSP 提交的中文运动备注进库就是一堆问号,这个坑在第 5 章会展开说。

sql/health_db.sql是整个包里最该先读的文件。它比任何文档都直观:库名、表名、字段类型、初始化的测试账号,全在里面。很多人上来就点点点,结果发现登录不进去,其实脚本末尾就写着默认管理员账号密码。

2.3 数据库表设计:运动记录与健康指标怎么建

运动与健康管理这类系统,核心就四张业务表加一张用户表。字段名各家不同,但关系模型是通的。下面这份是这类项目最典型的建表方式,我按它讲,你手上的包字段名可能叫sport_type而不是sportType,意思一样:

-- 用户表:登录与基础档案 CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(64) NOT NULL COMMENT '明文或MD5,看作者实现', `real_name` varchar(50) DEFAULT NULL, `gender` tinyint(1) DEFAULT '0' COMMENT '0女 1男', `age` int(11) DEFAULT NULL, `height` decimal(5,2) DEFAULT NULL COMMENT '身高cm,用于算BMI', `role` tinyint(1) DEFAULT '0' COMMENT '0普通用户 1管理员', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 运动记录表:一次运动一条,是系统的主业务表 CREATE TABLE `sport_record` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `sport_type` varchar(30) NOT NULL COMMENT '跑步/游泳/骑行等', `duration` int(11) NOT NULL COMMENT '时长,单位分钟', `calorie` decimal(8,2) DEFAULT NULL COMMENT '消耗卡路里', `sport_date` date NOT NULL COMMENT '运动日期,统计按这个字段分组', `remark` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_date` (`user_id`,`sport_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 健康指标表:体重、体脂、心率等,按天记录 CREATE TABLE `health_record` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `weight` decimal(5,2) DEFAULT NULL, `body_fat` decimal(5,2) DEFAULT NULL, `heart_rate` int(11) DEFAULT NULL, `record_date` date NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_date` (`user_id`,`record_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

表设计里有三个点值得注意。sport_record上建了(user_id, sport_date)的联合索引,因为统计页最常见的查询就是“某用户某段时间的运动汇总”,没这个索引数据量一过万就明显卡。health_record把体重和体脂拆成独立字段而不是键值对,好处是 SQL 好写、图表好取,坏处是加新指标要改表结构,二次开发时如果想加“睡眠时长”,就得走ALTER TABLE。user.height冗余存身高,是为了在列表页直接算 BMI 而不用联表,属于典型的用空间换查询简单。

餐饮记录表和运动计划表结构类似,都是user_id+ 日期 + 若干业务字段,这里不重复贴。真正要留意的是外键:这份脚本大概率没建物理外键,只在 Java 层维护关联,删用户的时候记得手动清理子表数据,否则会留一堆孤儿记录,统计报表上就出现“无主的运动数据”。

3. 环境搭建与启动:从导入工程到第一个页面出来

结构看明白了,接下来是让它在自己机器上跑起来。这段是最容易劝退的,因为版本一错,报错信息跟真实原因八竿子打不着。

3.1 JDK、Tomcat、MySQL 的版本对齐

三个组件的版本必须先定下来,再动手。这类原生 JavaWeb 项目的兼容窗口其实很窄:

  • JDK 用 8 最稳。项目里如果出现<% out.print(...) %>和大量匿名内部类,用 JDK 17 编译大概率会因为模块化限制报javax包找不到。真要用高版本,把 Tomcat 升到 10 之前先想清楚,Tomcat 10 把javax.servlet全改成了jakarta.servlet,源码里所有import javax.servlet.*都得改,工程量不小。
  • Tomcat 用 8.5 或 9.0。9.0 是javax命名空间的最后一版,跟 JDK 8 配合最省事。
  • MySQL 用 5.7 或 8.0,但驱动包版本必须跟着库走。连 5.7 用mysql-connector-java-5.1.x,连 8.0 用8.0.x,混搭就会出现Unknown system variable 'query_cache_size'或者 SSL 握手警告。

我一般会先看一眼WEB-INF/lib/mysql-connector-java-*.jar的版本号,再决定装哪个版本的 MySQL,而不是反过来。改驱动比改数据库省事得多。

3.2 数据库导入与连接参数修改

先建库再导数据。用命令行或者 Navicat 都行,注意脚本里如果写了CREATE DATABASE,直接整份执行;如果只有USE,得先手动建库。执行完检查一下表数量和字符集,utf8mb4才对,出现latin1后面中文一定出问题。

接下来改连接配置。找到src/db.properties或src/com/health/util/DBUtil.java里的硬编码,两种形态都常见:

# src/db.properties 典型内容 jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/health_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=你的本机密码

三个参数必须改:username和password换成自己的;url里的库名health_db对上你实际建的库名。serverTimezone是 8.0 驱动的必需项,不写会在插入date类型时报时区异常。如果用的是 5.1.x 驱动,驱动类名是com.mysql.jdbc.Driver,cj那一段要去掉,这是最常见的复制粘贴翻车点。

如果作者把配置写死在DBUtil里而不是配置文件,就改常量:

// com/health/util/DBUtil.java public class DBUtil { // 注意:这里的库名、账号、密码要和本机 MySQL 保持一致 private static final String URL = "jdbc:mysql://localhost:3306/health_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PWD = "你的本机密码"; // 静态块加载驱动,整个应用生命周期只执行一次 static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL 驱动未找到,检查 WEB-INF/lib 下是否有对应 jar"); } } // 每次调用返回一个新连接,调用方必须自己 close public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } }

static块加载驱动只做一次,getConnection()每次返回新连接,这个设计简单但也有坑——调用方忘了close()就是连接泄漏,几十次请求之后数据库端wait_timeout一到,整个系统开始随机报错。后面第 5 章会讲怎么临时兜住。

3.3 项目部署与访问验证

部署分两步:把工程放进 Tomcat,把依赖 jar 补全。

IDEA 里的操作路径是File → Project Structure → Modules把src标成 Sources,WebContent标成 Web Resource Directory,然后配一个Tomcat Local的 Run Configuration,Deployment 里加artifact: xxx:war exploded,Application context 填/sport之类。Eclipse 用户直接Import → Existing Projects into Workspace,工程如果带了.project和.classpath会自动识别。

命令行部署更直白:把整个工程目录重命名为sport,拷进TOMCAT_HOME/webapps/,把WEB-INF/lib里的 jar 确认齐全(至少要有mysql-connector、jstl、standard),启动bin/startup.bat,访问http://localhost:8080/sport/。

验证顺序我一般这么走,出问题能快速定位在哪一层:

  1. 先访问一个纯静态页或欢迎页,能出来说明 Tomcat 和部署路径没问题。
  2. 再访问login.jsp,能渲染说明 JSP 编译没问题。
  3. 用初始化脚本里的测试账号登录,能进主页说明数据库连通、DBUtil配置正确。
  4. 最后点一次运动记录保存,能跳回列表且数据出现在表格里,说明 Servlet 到 DAO 整条链路通了。

四步里前三步都过、第四步白屏,问题基本锁定在类型转换或 SQL 语法,跟环境无关,直接看控制台第一条Caused by就行。

4. 核心模块拆解:一次运动记录提交的完整链路

环境跑通只是开始。真正决定你能不能改这个项目的,是搞清楚“点一下保存”之后代码走了哪些地方。拿运动记录模块当样本,从 JSP 到 Servlet 再到 DAO,完整拆一遍。

4.1 JSP 表单:参数怎么传出去

前台的运动打卡页面(通常叫sport_add.jsp或addSport.jsp)就是一个标准 HTML 表单,外加 JSTL 回显当前登录用户:

<%-- WebContent/sport_add.jsp --%> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <form id="sportForm" action="${pageContext.request.contextPath}/sport?action=add" method="post"> <input type="hidden" name="userId" value="${sessionScope.loginUser.id}"/> <div class="form-group"> <label>运动类型</label> <select name="sportType" class="form-control"> <c:forEach items="${sportTypes}" var="t"> <option value="${t}">${t}</option> </c:forEach> </select> </div> <div class="form-group"> <label>运动时长(分钟)</label> <input type="number" name="duration" class="form-control" required/> </div> <div class="form-group"> <label>运动日期</label> <input type="date" name="sportDate" class="form-control" required/> </div> <textarea name="remark" class="form-control" rows="3"></textarea> <button type="submit" class="btn btn-primary">保存</button> </form>

几个细节先说清。action里的${pageContext.request.contextPath}不能省,省了以后一旦部署 context 不叫/,请求就会打到 Tomcat 根路径上变成 404,这是新手最常见的翻车方式。method="post"也必须写,用 get 提交中文备注会被容器按 ISO-8859-1 解码,进库就是乱码。userId放隐藏域而不是让用户填,取值来源是登录时塞进 session 的loginUser对象——这也是判断整套系统登录逻辑对不对的第一个观察点。

<input type="date">提交上来的格式是2024-05-01这样的字符串,Servlet 拿到的是String,转成java.sql.Date才能塞进数据库的date字段。这一步是所有 JavaWeb 项目里最爱出问题的地方。

4.2 Servlet 控制层:参数接收、校验与跳转

Servlet 是这条链路的中枢。按action参数分发是最常见的写法,一个 Servlet 管一个模块的增删改查:

// com/health/servlet/SportServlet.java @WebServlet("/sport") public class SportServlet extends HttpServlet { private final SportRecordDao sportDao = new SportRecordDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doPost(req, resp); // 列表页、删除都用 get,统一转 post 处理 } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 必须在取参数之前设置编码,否则中文备注必乱码 req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); String action = req.getParameter("action"); if ("add".equals(action)) { add(req, resp); } else if ("list".equals(action)) { list(req, resp); } else if ("delete".equals(action)) { delete(req, resp); } else { list(req, resp); // 兜底,避免 action 缺失导致空指针 } } private void add(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String userIdStr = req.getParameter("userId"); String durationStr = req.getParameter("duration"); String dateStr = req.getParameter("sportDate"); // 服务端校验不能省,前端 required 可以被绕过 if (userIdStr == null || durationStr == null || dateStr == null) { req.setAttribute("msg", "参数不完整,请重新填写"); req.getRequestDispatcher("/sport_add.jsp").forward(req, resp); return; } SportRecord r = new SportRecord(); r.setUserId(Integer.parseInt(userIdStr)); r.setSportType(req.getParameter("sportType")); r.setDuration(Integer.parseInt(durationStr)); // String -> java.sql.Date,格式必须与前端 date 控件一致 r.setSportDate(java.sql.Date.valueOf(dateStr)); r.setRemark(req.getParameter("remark")); // 卡路里按“每分钟 5 千卡”粗算,作者多写成固定系数 r.setCalorie(r.getDuration() * 5.0); boolean ok = sportDao.insert(r); if (ok) { resp.sendRedirect(req.getContextPath() + "/sport?action=list"); } else { req.setAttribute("msg", "保存失败,请稍后重试"); req.getRequestDispatcher("/sport_add.jsp").forward(req, resp); } } }

这段代码有三处值得记住的设计取舍。doGet直接转doPost,是因为列表和删除都走链接跳转,统一在doPost里按action分发能少写一套逻辑,代价是 get 请求也会走一遍setCharacterEncoding——好在无害。

java.sql.Date.valueOf(dateStr)是整条链路最容易炸的一行。它要求字符串严格是yyyy-MM-dd,前端换了格式、或者用户手输成2024/5/1,直接抛IllegalArgumentException,页面白屏且堆栈不友好。健壮写法是包一层 try-catch,用SimpleDateFormat解析后转java.sql.Date,解析失败给出提示而不是让异常冒到容器。这一点在改造时值得优先补上。

成功后用sendRedirect而不是forward,是为了避免用户刷新页面重复提交。这个细节很多改过的版本会丢掉,导致同一条运动记录被插两次,统计数字虚高。

4.3 DAO 层:JDBC 封装与 SQL 参数绑定

DAO 层负责把 Bean 变成 SQL。这份项目的写法是标准的PreparedStatement加手动结果集映射:

// com/health/dao/SportRecordDao.java public class SportRecordDao { // 新增一条运动记录 public boolean insert(SportRecord r) { String sql = "INSERT INTO sport_record(user_id, sport_type, duration, calorie, sport_date, remark) " + "VALUES(?, ?, ?, ?, ?, ?)"; // try-with-resources 保证连接和语句一定被关闭,避免连接泄漏 try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, r.getUserId()); ps.setString(2, r.getSportType()); ps.setInt(3, r.getDuration()); ps.setDouble(4, r.getCalorie()); ps.setDate(5, r.getSportDate()); // 参数必须是 java.sql.Date ps.setString(6, r.getRemark()); return ps.executeUpdate() > 0; } catch (SQLException e) { e.printStackTrace(); // 生产环境应换成日志框架 return false; } } // 按用户查列表,带简单分页 public List<SportRecord> findByUser(int userId, int page, int size) { String sql = "SELECT * FROM sport_record WHERE user_id = ? " + "ORDER BY sport_date DESC LIMIT ?, ?"; List<SportRecord> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, userId); ps.setInt(2, (page - 1) * size); // 偏移量,页码从 1 开始 ps.setInt(3, size); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { SportRecord r = new SportRecord(); r.setId(rs.getInt("id")); r.setUserId(rs.getInt("user_id")); r.setSportType(rs.getString("sport_type")); r.setDuration(rs.getInt("duration")); r.setCalorie(rs.getDouble("calorie")); r.setSportDate(rs.getDate("sport_date")); r.setRemark(rs.getString("remark")); list.add(r); } } } catch (SQLException e) { e.printStackTrace(); } return list; } }

参数说明几个要点。PreparedStatement的占位符从 1 开始数,setDate收的是java.sql.Date而不是java.util.Date,后者会编译不过——这也是很多人从 Servlet 里new java.util.Date()直接传进来时报错的根源。LIMIT ?, ?在 MySQL 里第一个参数是偏移量不是页码,所以写(page - 1) * size,少减这个 1,第一页的数据就永远看不到。

try-with-resources是这段代码里最值钱的写法。原生 JDBC 如果按老写法finally { conn.close(); },一旦prepareStatement抛异常,conn就漏了。用上它,连接、语句、结果集三层都会自动关,数据库连接数不会随着使用悄悄涨上去。如果你手上的包是老式写法,这是二次开发时优先重构的一处。

4.4 统计模块:按周聚合与 ECharts 数据组装

统计页是这类系统里含金量最高的部分,因为它把 SQL 聚合和前端图表串起来了。后台一般这么查:

// 统计某用户最近 7 天的运动总时长,用于折线图 public List<Map<String, Object>> statLast7Days(int userId) { String sql = "SELECT sport_date AS d, SUM(duration) AS total " + "FROM sport_record " + "WHERE user_id = ? AND sport_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) " + "GROUP BY sport_date ORDER BY sport_date"; List<Map<String, Object>> rows = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, userId); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Map<String, Object> m = new HashMap<>(); m.put("date", rs.getString("d")); m.put("total", rs.getInt("total")); rows.add(m); } } } catch (SQLException e) { e.printStackTrace(); } return rows; }

DATE_SUB(CURDATE(), INTERVAL 7 DAY)取的是滚动 7 天,而不是“本周一到周日”。两者在周日晚上看差别很大,做周报类功能时要想清楚是按自然周还是按滚动窗口,改 SQL 就行,不用动 Java。GROUP BY sport_date保证同一天多次运动会被合并成一条,否则折线图上一天会出现多个点。

结果集转成List<Map>而不是新建一个 VO 类,是因为字段太少不值得,直接序列化成 JSON 给前端最省事。Servlet 里用 Gson 或 fastjson 转一下,JSP 里拼进 ECharts 的series.data:

// 统计页内联脚本,data 由后端渲染进来 var dates = [], totals = []; // chartData 是 Servlet 用 request.setAttribute 传过来的 JSON 字符串 var chartData = ${requestScope.chartData}; chartData.forEach(function (item) { dates.push(item.date); totals.push(item.total); }); var chart = echarts.init(document.getElementById('sportChart')); chart.setOption({ xAxis: { type: 'category', data: dates }, yAxis: { type: 'value', name: '分钟' }, series: [{ type: 'line', data: totals, smooth: true }] });

这里chartData必须是合法 JSON 字符串,后端序列化时注意日期字段别被转成时间戳,否则 x 轴会显示一串数字。另外echarts.init要等 DOM 挂载完再调,放在<head>里直接执行会拿到null,画不出图。

5. 避坑与排查:这套老技术栈最容易翻车的几个地方

跑起来之后,真正的麻烦往往出现在改代码阶段。下面这几条都是我在同类项目上反复遇到的,按“现象 → 原因 → 解决”记下来,遇到时对照着看能省不少时间。

5.1 中文备注进库变成问号或乱码

现象:运动记录的备注在页面上正常,存进数据库用客户端一查全是???或者好这样的乱码。原因通常是三处编码不一致中的某一处:JSP 页面pageEncoding写的是ISO-8859-1;Servlet 没调req.setCharacterEncoding("UTF-8");数据库或表的字符集是latin1。排查顺序是从外到内,先SHOW CREATE TABLE sport_record看表的CHARSET,不是utf8mb4就直接ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4。表没问题再看 Servlet,setCharacterEncoding必须在第一次getParameter之前调用,写在后面等于没写。最后确认db.properties的 url 里带了useUnicode=true&characterEncoding=utf8。三处对齐,乱码必然消失。

5.2 启动报 ClassNotFoundException: com.mysql.cj.jdbc.Driver

现象:Tomcat 起得来,一登录就抛驱动找不到。原因有两个方向,一是WEB-INF/lib里根本没放mysql-connector-java的 jar,工程在 IDE 里编译过了但部署产物里没有;二是 jar 放了但版本和驱动类名对不上,5.1.x 的包叫com.mysql.jdbc.Driver,写成cj那个就加载不到。解决方法是先看lib目录里 jar 的实际版本号,5.1.x 就把Class.forName和jdbc.driver都改成不带cj的类名;8.0.x 保持cj。另外 IDEA 里要在Artifacts的WEB-INF/lib下确认 jar 已加入输出目录,只在 Module Dependencies 里加了不算数,这是“代码没错但就是跑不起来”的经典玄学。

5.3 系统用着用着开始报 Too many connections

现象:刚启动一切正常,点了几十次之后所有页面都卡住,日志里出现Too many connections或者请求一直挂着不返回。原因是没有连接池,DBUtil.getConnection()每次新建物理连接,而某些 DAO 方法在异常分支里忘了close(),连接只增不减,MySQL 的max_connections默认 151,很快打满。应急办法是重启 Tomcat 释放连接;根治要么把所有 DAO 改成try-with-resources,要么引一个连接池。用 Druid 或 HikariCP 都行,在DBUtil静态块里初始化DataSource,getConnection()改成从池里取,改动集中在一个文件里,半天能完成,收益是并发能力和稳定性直接上一个台阶。

5.4 保存运动记录时白屏,控制台报 IllegalArgumentException

现象:表单填完点保存,浏览器一片空白,Tomcat 控制台第一行是java.lang.IllegalArgumentException,往下追能到java.sql.Date.valueOf。原因是前端提交的日期字符串不是严格的yyyy-MM-dd,常见于浏览器降级、手动输入、或者从 Excel 粘贴进来的数据。解决是在 Servlet 里包一层解析,别直接valueOf:

// 健壮的日期解析,失败时回填提示而不是抛异常 private java.sql.Date parseDate(String s, HttpServletRequest req) { if (s == null || s.trim().isEmpty()) return null; try { // 先按标准格式解析,兼容 2024-5-1 这种不补零的写法 java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-M-d"); sdf.setLenient(false); // 关闭宽松模式,2024-02-31 会直接失败 return new java.sql.Date(sdf.parse(s.trim()).getTime()); } catch (java.text.ParseException e) { req.setAttribute("msg", "日期格式不正确,请重新选择"); return null; } }

setLenient(false)这一行很关键,默认宽松模式下2024-02-31会被悄悄转成 3 月 2 号,数据错了还看不出来。

5.5 部署后访问 404,日志里却没有任何异常

现象:Tomcat 启动日志干干净净,访问http://localhost:8080/项目名/就是 404。原因通常是三选一:工程目录名和访问路径不一致;web.xml里welcome-file-list配的首页文件名与实际文件对不上;或者 IDEA 的 Artifact 没配置war exploded,部署上去的是个空壳。排查方法很土但有效:直接去TOMCAT_HOME/webapps/项目名/底下ls一眼,看WEB-INF/classes里有没有编译后的.class,没有就是部署配置的问题,跟代码无关。另外 context path 如果配成了/,访问地址就不带项目名,很多人习惯性带上项目名反而 404。

6. 二次开发:把体重趋势做成可交互的折线图

跑通之后大多数人第一件想做的事是“加点自己的东西”。最典型的需求是把健康档案里的体重数据画成趋势图,顺便算个 BMI 分级。这块改造量不大,但能把“表 → DAO → Servlet → 前端图表”整条链路再走一遍,适合当第一个练手点。

思路分三步。先在HealthRecordDao里加一个按时间段查体重的方法,SQL 用record_date BETWEEN ? AND ?加ORDER BY record_date,返回List<HealthRecord>,别直接返回 JSON,保持 DAO 只产出对象。然后在 Servlet 里新增一个action=chart分支,把查出来的列表转成两个数组——日期数组和体重数组,用 Gson 序列化后setAttribute传给 JSP。最后在页面上用 ECharts 渲染,顺手把 BMI 参考线画上去。

BMI 的计算放在 Java 侧比放前端稳,因为身高存在user表里,前端拿不到完整数据:

// 在 Servlet 里组装图表数据,顺便算 BMI 分级 List<HealthRecord> records = healthDao.findByRange(userId, start, end); List<String> dates = new ArrayList<>(); List<Double> weights = new ArrayList<>(); // 身高从 session 里的登录用户取,单位 cm 转 m double heightM = loginUser.getHeight() / 100.0; double bmi = 0; if (heightM > 0 && !records.isEmpty()) { // 取区间内最后一次体重算当前 BMI double latestWeight = records.get(records.size() - 1).getWeight().doubleValue(); bmi = latestWeight / (heightM * heightM); } for (HealthRecord r : records) { dates.add(r.getRecordDate().toString()); weights.add(r.getWeight().doubleValue()); } Map<String, Object> payload = new HashMap<>(); payload.put("dates", dates); payload.put("weights", weights); payload.put("bmi", Math.round(bmi * 10) / 10.0); // 保留一位小数 req.setAttribute("chartJson", new Gson().toJson(payload)); req.getRequestDispatcher("/health_chart.jsp").forward(req, resp);

改造完怎么验证?我一般按数据边界测三组:只录一条体重时曲线应该是一个孤点而不是报错;跨月查询时 x 轴日期要连续,不能因为某天没数据就整体错位;身高字段为空的用户访问图表页,BMI 要显示“未填写身高”而不是NaN或除零异常。第三组最容易被忽略,因为初始化脚本里的测试账号往往身高是填好的。

还有一个值得顺手做的优化:把DBUtil里的直连换成连接池。改动只有静态块那几行,但统计页那种一次请求跑好几条 SQL 的场景,收益立竿见影。再往深了走,可以考虑把 Servlet 里重复的参数校验抽成一个BaseServlet,让每个模块继承它,action分发用反射调方法名,代码能瘦一半——不过那属于重构范畴,第一次改建议别动太大,先把一个功能完整跑通,信心比架构重要。

血泪经验是:每改一处就重启验证一次,别攒着三五处一起改再启动。这套老技术栈没有热部署的容错,堆栈信息又常常指向框架内部,一次改太多,出问题时根本分不清是哪一处引入的。从那以后我每次动这类原生 JavaWeb 项目,都强制自己“改一处、启一次、点一遍主流程”,慢是慢了点,但省下来的排查时间远不止这些。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 15:39:05

UN3480与UN3481锂电池运输:编号判定、标签选择与包装要求全解析

1. 先搞清楚这两个编号到底在说什么做锂电池运输这行的人&#xff0c;几乎都绕不开 UN3480 和 UN3481 这两个编号。但我在实际工作中发现&#xff0c;很多人对它们的理解停留在"一个是电芯一个是电池"这种模糊印象上&#xff0c;真到了贴标签、填单子的时候就开始犹豫…

作者头像 李华
网站建设 2026/10/29 15:37:31

Looped架构实战:LLM长文本长度泛化优势与部署陷阱

循环架构这几年在长文本场景里被反复提起&#xff0c;但真正把它跑通、跑稳的人并不多。我前后在两个推理服务里落地过基于循环块的模型结构&#xff0c;踩过的坑从显存爆炸到精度漂移都有。这篇就把“Looped架构在LLM长度泛化上的实战优势”和“部署时最容易翻车的陷阱”一次讲…

作者头像 李华
网站建设 2026/10/29 15:37:30

C语言数据类型深度解析:内存布局、隐式转换与实战避坑指南

C语言的数据类型这个话题&#xff0c;几乎每个写过C的人都被它“教育”过。我见过太多人写了几年业务代码&#xff0c;依然会在unsigned和signed混算时翻车&#xff0c;在float比较时踩雷&#xff0c;在char到底有没有符号这件事上栽跟头。这些坑不致命&#xff0c;但足够让人在…

作者头像 李华
网站建设 2026/10/29 15:37:25

Agent Skills工程化实践:基于GKE、Genkit与Gemini的架构设计

1. 从"skills"这个标题说起&#xff1a;一个被低估的工程概念 第一次看到"skills"这个标题&#xff0c;很多人会以为是某个技能清单或者学习路线图。但结合热搜词里的 Agent Skills、Google Cloud、GKE、Genkit、Gemini 来看&#xff0c;这里说的"ski…

作者头像 李华
网站建设 2026/10/29 15:37:01

YOLO26颈部结构改进:Hyper-YOLO模块提升小目标检测

1. 项目概述&#xff1a;YOLO26颈部结构改进方案在目标检测领域&#xff0c;YOLO系列算法始终保持着前沿地位。最近我在升级YOLO26模型时&#xff0c;发现其颈部结构&#xff08;Neck&#xff09;存在特征融合效率不足的问题&#xff0c;特别是在处理小目标和复杂场景时表现明显…

作者头像 李华
网站建设 2026/10/29 15:36:59

React性能优化:用caveman实现可控缓存与token智能刷新

1. “caveman”不是远古人&#xff0c;是React开发里一个被低估的性能优化暗号最近在几个前端技术群里&#xff0c;总有人贴出一段代码问&#xff1a;“这个caveman是什么鬼&#xff1f;是不是某个新出的UI库&#xff1f;”——其实根本不是。它既不是框架、也不是工具链&#…

作者头像 李华