ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JSP+Servlet+MySQL客户管理系统开发:三层架构与全流程避坑指南

JSP+Servlet+MySQL客户管理系统开发:三层架构与全流程避坑指南 简介基于JSP和Servlet的客户管理系统是一套适合Java Web初学者及课程设计场景的完整项目源码覆盖登录、客户、线索、交易、联系人、市场、系统管理、数据统计等核心功能模块。资源包共六百九十七个文件压缩包大小约二十三点零六兆包含源代码、页面文件、配置文件、样式脚本、数据库脚本及演示动图等类型其中源码实现业务逻辑页面负责界面展示配置与脚本支撑项目运行整体结构清晰便于直接导入开发工具使用。已有约一百六十九人次学习下载。通过该项目可以系统掌握基于浏览器服务器架构下的业务开发流程理解前端框架与后台服务之间的交互方式同时参考开发工具的部署配置和数据库初始化脚本快速搭建运行环境。适合作为毕业设计、课程实训或就业项目的入门模板。1. 基于JSPServletMySQL的客户管理系统为什么这类“老项目”仍值得完整做一遍如果你刚学完JavaWeb正想找一个能把JSP、Servlet、MySQL串起来的完整项目练手客户管理系统几乎是绕不开的第一个综合案例。它不涉及复杂的分布式、中间件或微服务却能把前端页面、后端控制逻辑、数据库交互这三层完整地串成一条业务链路登录验证、客户信息的增删改查、分页列表、会话管理。做完它你对“一个请求从浏览器发起到数据库返回结果”的整个过程会有具象的认知。这套技术栈在今天的生产环境里已经不是主流选型但作为理解Web应用“地基”的教材级组合它依然有效。因为当你用框架封装好的ORM和模板引擎时很多底层细节被隐藏了而手写一遍Servlet和JDBC你能清楚地看到请求怎么进来、连接怎么打开、SQL怎么执行、结果怎么渲染回页面。这篇文章就按我实际做过的一个版本把设计思路、建库脚本、核心代码和踩过的坑完整讲一遍目标是你照着写能跑通跑通之后知道怎么改、怎么排查问题。2. 三层结构与请求流转先看懂这套架构再动手写代码2.1 MVC落在JSPServlet上的真实形态客户管理系统虽然小但结构上必须按经典的三层来拆否则代码全堆在Servlet里后期改一个字段就要翻半天。三层分别是表示层JSP页面、控制层Servlet、数据层DAOJDBC。实体类JavaBean在三层之间传递数据。常见做法是JSP只负责展示和收集参数不写业务逻辑Servlet接收请求、调用业务方法、根据结果决定跳转到哪个页面DAO封装所有SQL操作返回的结果要么是单个实体对象要么是List集合。这样每一层都能独立修改而不影响其他层。这里有一个关键区别值得新手注意JSP里尽量不要写Java脚本片段% %页面上的客户列表用${}表达式配合JSTL标签来遍历。我在第一个版本里直接在JSP里写% for(...) %页面混着一堆HTML和Java代码浏览器渲染没问题但改样式时非常痛苦而且别人接手这种页面基本是灾难。如果你用的是纯JSP而没引入JSTL建议在web.xml对应的依赖配置里把JSTL支持加上后面写列表页会顺手很多。2.2 一个请求从浏览器到数据库的完整路径把请求流转过程吃透后面排查问题时思路会清晰很多。以“管理员登录”为例完整的调用链是浏览器向/login发送POST请求携带用户名和密码参数。Tomcat根据web.xml中的Servlet映射找到处理登录的LoginServlet。LoginServlet调用UserDao.findByUsernameAndPassword()方法。UserDao通过JDBCUtils.getConnection()获取连接执行预编译SQL查询。查询结果封装成User对象返回给Servlet。Servlet判断结果如果查到了把用户信息存入HttpSession重定向到customer_list.jsp如果没查到请求转发到login.jsp并携带错误提示。Tomcat将最终的HTML响应回传给浏览器。这个链路里最容易搞混的是重定向sendRedirect和请求转发forward的区别重定向会让浏览器重新发起一个新的HTTP请求地址栏会变化适合登录成功后防止表单重复提交请求转发是服务端内部跳转浏览器感知不到地址栏不变适合把请求数据带到下一个JSP。如果登录成功后你用forward到列表页用户刷新页面时表单可能会重新提交一次这是很多“登录逻辑没问题但行为诡异”的根源。2.3 目录结构与关键配置文件项目结构建议按Maven的标准布局来建即使你最终用IDE直接导出的Web项目也尽量保持以下分包习惯src/main/java ├─ com.customer.entity # 实体类Customer, User ├─ com.customer.dao # 数据访问接口和实现CustomerDao, CustomerDaoImpl ├─ com.customer.service # 业务层可选项目小也可以直接Servlet调DAO ├─ com.customer.servlet # 所有Servlet ├─ com.customer.util # JDBC工具类 src/main/webapp ├─ WEB-INF/web.xml # Servlet映射、欢迎页面配置 ├─ login.jsp ├─ customer_list.jsp ├─ customer_add.jsp └─ customer_edit.jsp为什么一定要分dao和servlet而不让Servlet直接写JDBC因为客户管理系统的典型操作远不止“查询所有客户”还包括“按姓名模糊搜索”“分页查询”“新增时校验手机号重复”等。这些逻辑如果散落在多个Servlet里代码重复率会很高。DAO把这些SQL收拢在一个类里后续加一个字段只需要改动一个方法。web.xml中需要配置的Servlet映射是每个Servlet一条servlet和servlet-mapping如果用的是Servlet 3.0的Tomcat也可以用WebServlet注解省去XML配置。我个人习惯用注解因为文件少但如果你在部署时遇到404第一反应就应该是看一下注解的urlPatterns跟JSP表单里的action是否完全一致——包括大小写、斜杠位置。3. 从建库到建工程数据库表设计与JDBC连接层的落地细节3.1 客户表和用户表字段别贪多但该有的约束不能省客户管理系统的核心数据就两张表用户表管理员账号和客户表。我第一次设计时只建了客户表后来发现新增“每个销售只能看自己的客户”这样的权限过滤时才意识到租户维度的字段应该从一开始就留好。我这里给出一个参考建表脚本。字段数量控制在够用程度注释写清楚主键用自增字符集统一用utf8mb4避免录入生僻字或表情符号时出现乱码CREATE DATABASE customer_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE customer_db; CREATE TABLE sys_user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 密码建议MD5或SHA-256存储, real_name VARCHAR(50) COMMENT 真实姓名, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT系统用户表; CREATE TABLE customer ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL COMMENT 所属用户ID用于数据隔离, name VARCHAR(100) NOT NULL COMMENT 客户姓名, phone VARCHAR(20) COMMENT 联系电话, email VARCHAR(100) COMMENT 邮箱, address VARCHAR(200) COMMENT 联系地址, remark VARCHAR(500) COMMENT 备注, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_customer_user FOREIGN KEY (user_id) REFERENCES sys_user(id) ) ENGINEInnoDB COMMENT客户信息表;这里把user_id放进客户表是为了给后续“按用户查看自己的客户”留扩展点。如果你只是为了交作业这个字段可以先不加但因为后续章节我要讲数据权限过滤所以建表时就把外键关系建好。外键在数据量小时能保证一致性但如果你以后想把系统改成高并发写入建议去掉外键约束把关联校验放到应用层这是另一个阶段的话题当前按教学场景保留即可。密码字段用的VARCHAR(64)存的是摘要值而不是明文。很多入门项目直接把明文密码怼进数据库自己本地玩没关系但如果你打算把系统给别人演示这一步是底线。3.2 JDBC工具类的写法连接别在每次请求里写重复代码JDBC连接的管理是这类项目的重灾区。合理做法是写一个JDBCUtils工具类统一负责驱动的加载、连接的创建、资源的关闭。下面是基于连接池的实现思路以Druid为例获取连接和归还连接都由连接池托管业务代码只面对Connection接口package com.customer.util; import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.io.InputStream; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Properties; public class JDBCUtils { private static DataSource ds; static { try { Properties prop new Properties(); InputStream is JDBCUtils.class.getClassLoader() .getResourceAsStream(druid.properties); prop.load(is); ds DruidDataSourceFactory.createDataSource(prop); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt ! null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }druid.properties文件放到src/main/resources目录下内容是连接参数其中几个关键参数需要根据你本机的MySQL配置调整driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/customer_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai usernameroot password你的密码 initialSize5 maxActive10 maxWait3000useSSLfalse这行建议保留。MySQL 8.0以上版本默认开启SSL相关配置不加这行某些环境下连接时会报SSL connection error具体表现和解决办法我在第5章的避坑清单里细讲。serverTimezoneAsia/Shanghai是为了解决时区报错MySQL驱动8.0对时区敏感不指定的话会抛Server returns invalid timezone异常。使用连接池和直接用DriverManager.getConnection()的区别在于连接池复用了物理连接避免频繁创建和销毁数据库连接带来的性能开销同时close()方法归还连接而不是真正关闭这是连接池与普通JDBC的语义差异。3.3 用PreparedStatement而不是Statement不只是防注入客户管理系统的SQL里经常要拼接用户输入的姓名、电话等条件。如果使用Statement做字符串拼接遇到输入 or 11这类内容SQL语义会被改变这就是SQL注入的标准姿势。正确做法是全部用PreparedStatement参数用?占位public ListCustomer findByName(String keyword, int userId) { ListCustomer list new ArrayList(); String sql SELECT * FROM customer WHERE user_id ? AND name LIKE ? ORDER BY create_time DESC; try (Connection conn JDBCUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, userId); ps.setString(2, % keyword %); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Customer c new Customer(); c.setId(rs.getInt(id)); c.setName(rs.getString(name)); c.setPhone(rs.getString(phone)); c.setEmail(rs.getString(email)); c.setAddress(rs.getString(address)); c.setRemark(rs.getString(remark)); c.setCreateTime(rs.getTimestamp(create_time)); list.add(c); } } } catch (SQLException e) { e.printStackTrace(); } return list; }PreparedStatement有两个作用第一个是安全参数被预编译后输入内容只会被当作字符串字面量处理第二个是性能对于同一个SQL结构反复执行多次的场景预编译语句可以减少SQL解析的开销。这里LIKE模糊查询的参数写法要注意%和?不能拼在同一个字符串里要先在Java侧拼好再setString。排序这里顺便说一句列表页通常要按创建时间倒序展示新客户ORDER BY create_time DESC是常规选择。如果你需要按姓名排序注意中文字段排序依赖数据库字符集排序规则utf8mb4_general_ci下基本按拼音排序够用。4. 核心模块实现登录会话、客户信息CRUD与页面跳转的完整链路4.1 登录模块会话管理、密码校验与登录状态拦截登录是所有后续操作的前置条件。实现登录Servlet时除了验证用户名密码是否正确还要考虑“登录后如何保护其他页面”。只做验证不做拦截系统等于没做登录。拦截通过Filter实现这是Servlet规范自带的组件不需要额外引入框架WebFilter(/*) public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); if (uri.contains(login) || uri.endsWith(.js) || uri.endsWith(.css)) { chain.doFilter(req, resp); return; } HttpSession session request.getSession(false); if (session ! null session.getAttribute(user) ! null) { chain.doFilter(req, resp); } else { response.sendRedirect(request.getContextPath() /login.jsp); } } }过滤器里request.getSession(false)的意思是如果当前没有会话不要创建新会话直接返回null。这样可以避免未登录用户访问页面时凭空生成一堆无意义的Session对象。request.getContextPath()是为了兼容项目部署在不同上下文路径下写死/customer/login.jsp在换部署环境时会404。登录Servlet的核心代码中密码校验逻辑按顺序做即可先按用户名查用户再比对密码摘要。使用HttpSession存用户信息时我习惯把整个User对象放进去而不是只存用户名——后续页面要显示当前用户的realName时直接从Session取值不用再查一次数据库。这里有个常见设计失误要提醒登录成功之后到底用sendRedirect还是forward我在第2章提过这里用sendRedirect更合适。因为forward会让浏览器地址栏停留在/login用户按F5刷新时会重复提交登录表单。换成sendRedirect后地址栏变成customer_list页面刷新就是重新请求列表页不会再次触发登录动作。4.2 客户列表分页查询与搜索条件拼接客户列表是系统里访问量最大的页面。如果一次把全部数据查出来渲染数据量一旦过千页面加载会明显变慢内存和带宽都被浪费。分页查询是这类系统必备能力。分页的实现分成两步查询总记录数然后查询当前页的数据。SQL上就是LIMIT ? OFFSET ?MySQL 8.0里也可以用LIMIT ? , ?注意参数顺序是“偏移量, 返回行数”public ListCustomer findByPage(int userId, String keyword, int page, int pageSize) { ListCustomer list new ArrayList(); String sql SELECT * FROM customer WHERE user_id ? AND name LIKE ? ORDER BY create_time DESC LIMIT ?, ?; try (Connection conn JDBCUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, userId); ps.setString(2, % keyword %); ps.setInt(3, (page - 1) * pageSize); ps.setInt(4, pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Customer c new Customer(); // 字段映射省略与前面一致 list.add(c); } } } catch (SQLException e) { e.printStackTrace(); } return list; } public int countByCondition(int userId, String keyword) { String sql SELECT COUNT(*) FROM customer WHERE user_id ? AND name LIKE ?; // 执行查询并返回总数 }分页参数page和pageSize从前端传入常规设置是page从1开始pageSize固定为10。偏移量的计算是(page - 1) * pageSize很多人写错这一行导致第二页和第一页数据重复。另外一个容易漏的点是分页条上的“上一页/下一页”要保留当前的搜索关键词。如果你用a hrefcustomerList?page2下一页/a翻页后搜索条件就丢了。正确做法是把关键词拼进链接customerList?page2keyword${keyword}。这个细节关乎体验但经常被忽略。COUNT(*)在MySQL中的执行代价从性能角度说可以接受表里有索引时走覆盖索引扫描不用太担心。真正需要注意的是不要在WHERE条件的字段上做函数运算比如WHERE DATE(create_time) ?会让索引失效正确写法是WHERE create_time ? AND create_time ?。4.3 新增与编辑客户表单提交、参数校验与数据回显新增客户和编辑客户在业务上是两套逻辑但页面结构高度相似。新增的页面是空表单编辑的页面需要先把数据库里的值回显到表单中。新增的Servlet处理逻辑是这样的接收表单参数做基础校验姓名不能为空、手机号格式是否符合然后调用DAO插入记录最后重定向回列表页。校验环节要注意服务端一定要校验不能只看JSP页面的required属性——那只是前端提示绕过页面直接构造HTTP请求就能跳过。WebServlet(/customerAdd) public class CustomerAddServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { request.setCharacterEncoding(UTF-8); String name request.getParameter(name); String phone request.getParameter(phone); // email、address、remark 类似 HttpSession session request.getSession(); User user (User) session.getAttribute(user); Customer c new Customer(); c.setUserId(user.getId()); c.setName(name); c.setPhone(phone); CustomerDao dao new CustomerDaoImpl(); boolean ok dao.insert(c); if (ok) { response.sendRedirect(request.getContextPath() /customerList?page1); } else { request.setAttribute(error, 新增失败请检查输入); request.getRequestDispatcher(/customer_add.jsp).forward(request, response); } } }方法开头的request.setCharacterEncoding(UTF-8)一定要有否则POST表单里的中文会乱码。这里补充说明一下Tomcat 8.0以上版本对GET请求的URI编码默认就是UTF-8但对POST请求体的编码仍然默认不是所以setCharacterEncoding是每个POST处理Servlet必须写的如果你有多个Servlet建议直接写一个编码过滤器统一处理比每个Servlet都写一遍更省心。编辑操作的实现逻辑多一个步骤编辑页的Servlet先根据id查出客户记录存入request域再转发到customer_edit.jsp。JSP页面的表单value${customer.name}回显数据。提交时走另一个Servlet做更新。新增和编辑两个Servlet不要写成同一个虽然代码相似度很高但职责分离后改动互不影响调试更方便。5. 新手最容易踩的坑编码、路径、连接泄漏与SQL注入的几个典型翻车现场5.1 中文乱码页面乱码、请求乱码、数据库乱码三层混战现象页面显示“客户姓名”变成一串问号或者数据库里存进去的是乱码。原因乱码的本质是字符集不一致。JSP页面本身是UTF-8浏览器请求是UTF-8但Tomcat接收请求时如果没指定编码POST请求体按ISO-8859-1解码数据库连接URL里没设置characterEncodingutf8时JDBC驱动按数据库默认字符集传送字符串数据库表用的latin1字符集则直接存不进去。解决这三层要全部统一。JSP页面顶部设置% page contentTypetext/html;charsetUTF-8 languagejava %表单POST的Servlet里执行request.setCharacterEncoding(UTF-8)JDBC连接的URL带上useUnicodetruecharacterEncodingutf8建库时统一用utf8mb4。挨个排查到位乱码基本消失。一次修改多处是必须的只改一处一定不够。5.2 数据库连接未释放导致“Too many connections”现象应用跑一段时间后报Too many connectionsTomcat里所有请求都卡住。原因最常见的不是连接池参数太小而是代码里没有在finally块中关闭连接。如果DAO里查询出异常try-with-resources没有正确使用Connection就没有归还到连接池连接池的最大连接数很快被耗尽。解决检查所有DAO方法确保Connection、PreparedStatement、ResultSet都在finally里关闭。Java 7可以用try-with-resources语法自动关闭简单且不容易漏。连接池的maxActive参数也要设置合理值比如10到20之间不要盲目调大——连接池本身是复用的正常运维下10个连接足够支撑几十个并发用户的小型系统。5.3 部署后访问404或CSS丢失的路径问题现象本地IDE运行时一切正常打成WAR包部署到Tomcat后页面能打开但样式全丢或者请求报404。原因路径问题。页面里如果用了绝对路径写死/css/style.css实际部署上下文路径是/customer那么浏览器解析的CSS路径就变成了/css/style.css找不到文件。解决所有URL一律用${pageContext.request.contextPath}拼前缀。pageContext.request.contextPath在运行时动态获取当前应用的上下文路径比如link relstylesheet href${pageContext.request.contextPath}/css/style.cssServlet里的重定向和转发也要记得用request.getContextPath()拼前缀。我见过最多的404场景就是这里开发时部署在根路径上下文是/没暴露换一台机器部署在自定义路径下就全部失效。5.4 MySQL 8.0驱动连接报SSL错误或时区错误现象启动Tomcat后第一次访问数据库相关页面时抛SSL connection error或Server returns invalid timezone。原因MySQL Connector/J 8.0默认启用SSL连接而本地MySQL通常没配置SSL证书所以握手失败时区则是驱动要求数据库连接指定serverTimezone。解决在JDBC URL上追加参数urljdbc:mysql://localhost:3306/customer_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue解决的是MySQL 8.0使用caching_sha2_password认证插件时客户端无法自动获取公钥的问题。换了驱动版本或换了MySQL版本之后最有效的排查方式就是看堆栈的第一行里面明确写着缺哪个参数。这类问题属于“配置一次就再也遇不到”的类型但第一次遇到时确实容易卡很久——我的经验是优先把驱动源码里的MysqlConnectionProperties相关知识过一遍理解每个参数的作用而不是盲目搜答案。5.5 登录后刷新页面重复提交表单现象登录成功后浏览器地址栏停在/login按F5页面不断弹出“重复提交”提示。原因登录Servlet用的是请求转发到主页而不是重定向。转发不改变地址栏用户刷新时会把最后一次表单POST请求重新发送一遍。解决登录成功后改成response.sendRedirect(request.getContextPath() /customerList?page1)。这一条建议作为固定写法记住凡是“处理完数据后需要跳到另一个页面”的场景都用重定向不要用转发。唯一适合转发的是“查询数据后带着结果去渲染页面”的场景比如编辑页面的数据回显。6. 让项目从“能跑”变成“能交”分页优化、日志排查与几个值得加的功能一个客户管理系统做到“登录CRUD分页”结构上已经完整。但如果这个项目要交出去或者你想用它作为面试时拿得出手的作品还需要做几件锦上添花的事。第一件把DAO层打印的SQL和参数梳理清楚。用System.out.println打印SQL是入门阶段的常态但你换成Logger之后排查问题会顺手很多。打日志的位置放在DAO层入参和异常捕获处日志级别设为DEBUG上线时调到INFO这个习惯比任何调试技巧都实用。第二件优化列表页的查询。刚才的分页是基于LIMIT的普通分页数据量在几千条时没问题。如果客户数到了十万级以上深分页翻到第100页时LIMIT 990, 10会扫描大量偏移行。此时可以改成“记住上一页最后一条ID”的键集分页keyset paginationSQL形如SELECT * FROM customer WHERE user_id ? AND id ? ORDER BY id DESC LIMIT 10;页面上通过点击“上一页”时回传当前页最小的ID翻页性能与页码无关。这个优化不复杂但能体现出你理解分页方案在不同数据量下的取舍属于加分项。第三件加入客户导出Excel的功能。实际运维中客户管理系统的用户几乎一定会提“我要把列表导出成Excel”。常见做法是通过Apache POI生成xlsx文件设置响应头的Content-Disposition为attachment。这个功能的技术难度不高但涉及文件下载响应头设置、临时文件清理做完之后对“Servlet如何响应二进制流”会有更深的理解。导出文件名中的中文需要做URL编码否则部分浏览器下载的文件名乱码String fileName URLEncoder.encode(客户列表.xlsx, UTF-8); response.setHeader(Content-Disposition, attachment; filename fileName);最后说一个我自己踩过的坑导出功能刚上线时发现下载的文件偶尔打不开排查后是response.getOutputStream()没有flush和close部分数据停留在缓冲区。处理的细节是把流的关闭放在finally里并且先flush再close。这跟JDBC连接的关闭逻辑一模一样说到底都是资源管理的习惯问题。做这类项目我的体感是先跑通再优化别在第一天就想着把代码写成教科书级别。我第一次写的版本里DAO方法和JSP页面变量命名都不够规范但跑通之后我对请求生命周期、会话机制、连接池这些概念的理解比看十遍书都牢。等你把基础功能做完再回头重构你会发现能改的地方特别多——这个“回头看”的过程本身就是技术提升最快的时候。希望这篇笔记能帮你把项目一次跑通少走我当年走过的弯路。本文还有配套的精品资源点击获取
返回列表