1. 从用户列表这个需求说起为什么它值得单独拿出来做很多人看到用户信息列表展示这几个字第一反应是这不就是查一下数据库然后循环打印吗有什么好讲的。我刚开始接触Web开发的时候也是这么想的直到真正动手用ServletJSPMySQL这套组合去实现一个前后端分离的列表页才发现里面藏着不少门道。这个项目的核心目标很明确把数据库里存储的用户信息通过后端接口读取出来再由前端页面渲染成一张清晰、可读的列表。听起来简单但它几乎涵盖了Web开发中最基础也最关键的几个环节——数据库连接、后端请求处理、数据格式转换、前端异步渲染。把这套流程跑通一遍你对一个请求从浏览器发出到页面显示数据的完整链路就有了实感。我之所以强调前后端分离是因为这里有一个很容易被忽略的认知误区。传统JSP开发模式下Java代码和HTML是混在一起的页面里嵌着% %脚本片段后端直接把数据塞进request域JSP负责渲染。这种方式上手快但页面逻辑和业务逻辑纠缠不清改一个样式可能碰到Java代码维护起来很头疼。而前后端分离的思路是后端只负责提供数据接口返回JSON格式的结果前端页面通过异步请求拿到数据再用JavaScript动态生成表格。两者职责清晰各管各的。这套方案适合谁我认为有三类人特别值得动手做一遍。第一类是刚学完Servlet和JSP基础语法、还没做过完整项目的学生这个项目规模适中不会让你一开始就陷入复杂框架的泥潭。第二类是从传统JSP开发想转向接口化思维的开发者通过这个练习能直观感受到后端只吐数据的好处。第三类是需要快速搭建一个内部管理页面原型的从业者这套技术栈部署简单不依赖重型框架一台普通服务器就能跑起来。接下来我会把整个实现过程拆开从环境准备、数据库设计、后端接口编写到前端页面渲染、跨域处理、分页优化再到实际开发中踩过的坑一步步讲清楚。每个环节我都会说明为什么这么做而不是只丢一段代码让你抄。毕竟能跑通的代码网上一搜一大把但知道为什么这样写、换个场景该怎么调整才是真正值钱的部分。2. 环境搭建与数据库表结构设计地基没打好后面全是坑2.1 开发环境的选择与版本搭配这套技术栈对环境的要求其实不高但版本搭配有讲究。我推荐用JDK 8或JDK 11这两个版本在Servlet容器上的兼容性最稳。Tomcat选8.5或9.0系列别一上来就追最新的Tomcat 10因为Tomcat 10之后Servlet API的包名从javax.servlet变成了jakarta.servlet很多老教程和依赖库还是旧包名混用会直接报ClassNotFoundException新手很容易在这里卡半天。MySQL用5.7或8.0都行8.0的默认认证插件变了连接时如果驱动版本太老会报Unable to load authentication plugin的错误解决办法是要么升级mysql-connector-java到8.0以上要么在连接串里加参数调整认证方式。IDE用IntelliJ IDEA或者Eclipse都可以IDEA对Web项目的支持更顺手一些。依赖方面核心就三个servlet-api注意scope设为provided因为Tomcat本身自带、mysql-connector-java、以及处理JSON的库。JSON库我习惯用Jackson或者GsonGson更轻量API也简单适合这种小项目。如果你用Maven管理依赖pom里加上对应的坐标就行。提示servlet-api的scope一定要设成provided否则打包时会把servlet-api打进war包部署到Tomcat时和容器自带的版本冲突会出现各种莫名其妙的错误。2.2 用户表的结构设计与字段考量数据库表设计看着简单但字段类型和约束的选择直接影响后续开发的顺畅度。我设计的用户表包含这几个核心字段主键id、用户名username、邮箱email、手机号phone、注册时间create_time、状态status。主键id用BIGINT配合AUTO_INCREMENT不用INT是因为用户量大的时候INT可能不够用虽然这个项目用不上但养成习惯没坏处。username设VARCHAR(50)并加唯一索引防止重复注册。email用VARCHAR(100)phone用VARCHAR(20)——注意手机号一定要用字符串类型别用数字类型因为手机号可能以0开头而且数字类型存不下前导零还可能有精度问题。create_time用DATETIME类型默认值设为CURRENT_TIMESTAMP这样插入数据时不用手动传时间。status字段用TINYINT0表示禁用1表示正常用数字比用字符串省空间查询也快。CREATE TABLE user_info ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, email VARCHAR(100) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字符集一定要用utf8mb4别用utf8。MySQL里的utf8其实是个假utf8最多只支持3字节存不了emoji和一些特殊字符utf8mb4才是真正的4字节UTF-8。这个坑我在早期项目里踩过用户昵称里带个特殊符号就插入失败排查半天才发现是字符集的问题。2.3 造一批测试数据的实用技巧开发阶段没有真实数据得自己造。手动INSERT几条太慢我一般写一个存储过程或者直接用SQL批量生成。比如用循环插入100条测试数据用户名用user加序号拼接邮箱用用户名加域名后缀。这样列表页一打开就有数据分页效果也能直观看到。造数据的时候有个小技巧故意造几条字段为空的记录比如email为NULL、phone为空的这样能测试前端对空值的处理是否健壮。真实环境里数据不可能都规规矩矩提前把这些边界情况覆盖到上线后才不会手忙脚乱。3. 后端接口的编写Servlet如何优雅地吐出JSON3.1 数据库连接工具类的封装思路直接在Servlet里写JDBC代码是最省事的但也是最难维护的。每次请求都加载驱动、建立连接、关闭连接代码重复不说性能也差。我的做法是封装一个数据库连接工具类把驱动加载、连接获取、资源关闭这些操作集中管理。工具类的核心是一个静态代码块在类加载时读取数据库配置并注册驱动。配置信息我建议放在properties文件里而不是硬编码在Java代码中这样换环境时只改配置文件不用重新编译。获取连接的方法返回一个Connection对象关闭资源的方法接收ResultSet、Statement、Connection三个参数按顺序关闭每个关闭操作都包在try-catch里避免一个关闭失败影响后面的。public class DBUtil { private static String url; private static String username; private static String password; static { try { Properties props new Properties(); props.load(DBUtil.class.getClassLoader() .getResourceAsStream(db.properties)); Class.forName(props.getProperty(driver)); url props.getProperty(url); username props.getProperty(username); password props.getProperty(password); } catch (Exception e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(ResultSet rs, Statement stmt, Connection conn) { // 依次关闭各自try-catch } }这里有个细节值得说连接串里最好加上useSSLfalse和serverTimezoneAsia/Shanghai这两个参数。不加useSSLfalseMySQL 8.0会打印一堆SSL警告不加时区配置存进去的时间可能和实际差8小时因为驱动默认用UTC时区。这两个问题我都遇到过排查起来还挺费劲。3.2 用户实体类与数据访问层的职责划分实体类User就是一个标准的JavaBean字段和数据库表一一对应提供getter和setter方法。别小看这个类它是后端各层之间传递数据的载体。字段类型要和数据库类型匹配id用Longusername用Stringcreate_time用java.util.Date或者LocalDateTime。数据访问层我单独抽一个UserDao类专门负责和数据库打交道。里面至少要有两个方法一个是查询用户列表支持分页一个是查询总记录数。为什么分页查询和总数查询要分开因为分页只需要当前页的数据而前端要显示共X条记录需要总数。两个查询的SQL不一样分开写更清晰。查询列表的SQL用LIMIT关键字实现分页LIMIT offset, sizeoffset是起始位置size是每页条数。offset的计算方式是(当前页码 - 1) * 每页条数。这个计算很容易搞错我见过有人直接用页码乘每页条数结果第一页就跳过了前N条数据。总数查询用SELECT COUNT(*)返回一个long值。3.3 Servlet接收请求参数并返回JSON的完整流程Servlet是整个后端的大脑它接收前端发来的请求调用Dao拿数据再把数据转成JSON返回。这里我用一个UserListServlet来处理列表请求。首先在doGet方法里设置响应类型response.setContentType(application/json;charsetUTF-8)。这一步很关键不设置的话浏览器可能按HTML解析返回的内容中文还会乱码。然后从request里获取分页参数page和pageSize给个默认值防止前端不传。接着调用Dao查询数据和总数把结果封装成一个Map或者自定义的Result对象包含code、message、data、total这几个字段。用Gson把对象转成JSON字符串通过response的Writer写回去。写完之后记得flush和close。整个流程里异常处理不能省数据库查询可能抛SQLException要捕获并返回一个错误状态的JSON而不是让页面直接报500错误。protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType(application/json;charsetUTF-8); int page Integer.parseInt(request.getParameter(page) null ? 1 : request.getParameter(page)); int pageSize Integer.parseInt(request.getParameter(pageSize) null ? 10 : request.getParameter(pageSize)); UserDao dao new UserDao(); ListUser users dao.getUserList(page, pageSize); long total dao.getUserCount(); MapString, Object result new HashMap(); result.put(code, 200); result.put(message, success); result.put(data, users); result.put(total, total); PrintWriter out response.getWriter(); out.write(new Gson().toJson(result)); out.flush(); out.close(); }注意Servlet的URL映射建议用注解WebServlet(/api/user/list)比在web.xml里配置直观得多。路径里带上/api前缀是个好习惯方便后续区分接口请求和静态资源请求。4. 前端页面的数据渲染从异步请求到表格生成4.1 用原生fetch发起异步请求前端页面我不用任何框架就用原生HTMLJavaScript这样能看清每一步在做什么。发起请求用fetchAPI它比老式的XMLHttpRequest写法简洁很多而且基于Promise处理异步逻辑更自然。页面加载时调用一个loadUserList函数里面用fetch请求后端接口传入page和pageSize参数。fetch返回的response需要调用.json()方法解析成JavaScript对象。解析出来的数据里data字段是用户数组total是总记录数。拿到数据后遍历数组为每个用户生成一行表格HTML拼接到tbody里。这里有个性能上的小细节不要用字符串拼接的方式一行行往DOM里插那样每次插入都会触发页面重排。更好的做法是先把所有行的HTML拼成一个完整字符串最后一次性设置给tbody的innerHTML。数据量小的时候差别不明显但养成这个习惯数据量大了就能看出优势。function loadUserList(page, pageSize) { fetch(/api/user/list?page${page}pageSize${pageSize}) .then(res res.json()) .then(result { if (result.code 200) { renderTable(result.data); renderPagination(result.total, page, pageSize); } }) .catch(err console.error(请求失败, err)); } function renderTable(users) { let html ; users.forEach(u { html tr td${u.id}/td td${u.username}/td td${u.email || -}/td td${u.phone || -}/td td${formatTime(u.create_time)}/td td${u.status 1 ? 正常 : 禁用}/td /tr; }); document.querySelector(#userTable tbody).innerHTML html; }注意上面代码里对email和phone做了空值处理用|| -当字段为空时显示一个短横线而不是显示null或undefined。这种细节看着小但直接影响页面的专业感。4.2 时间格式化的处理方案后端返回的时间如果是时间戳或者ISO格式的字符串直接显示会很难看比如2024-01-15T08:30:00.00000:00这种。前端需要把它格式化成2024-01-15 08:30:00这样易读的形式。我写了一个简单的formatTime函数用Date对象解析时间字符串然后分别取出年月日时分秒补零后拼接。补零用String(num).padStart(2, 0)这是ES6的方法比手写if判断简洁。如果项目对时间格式要求更复杂可以考虑引入dayjs这类轻量库但这个小项目自己写几行就够了。还有一个时区问题要注意。如果后端返回的是UTC时间前端格式化时要转成本地时区。用new Date(isoString)解析后getHours()等方法返回的是本地时区的小时数所以直接取就行。但如果后端返回的是已经格式化好的字符串那就没有时区转换的余地了。我倾向于后端返回标准的时间格式把格式化的活儿交给前端这样更灵活。4.3 分页组件的实现逻辑分页是列表页的标配。我的实现思路是根据总记录数和每页条数算出总页数然后生成上一页、页码按钮、下一页三部分。当前页码高亮显示第一页时上一页禁用最后一页时下一页禁用。总页数的计算用Math.ceil(total / pageSize)向上取整。比如总共95条每页10条就是10页。页码按钮不用全部显示如果页数很多可以只显示当前页附近的几个页码加上首尾页。这个项目数据量不大我直接全部显示逻辑简单。点击页码按钮时重新调用loadUserList传入对应的页码。这里要注意防止重复请求用户快速点击多个页码时可能发出多个请求返回顺序不确定导致页面显示错乱。简单的处理方式是加一个loading标志请求发出后置为true返回后置为false请求期间忽略新的点击。5. 跨域、编码与空值处理那些让页面看起来不对的细节5.1 前后端分离后的跨域问题与解决前后端分离后如果前端页面和后端接口不在同一个域名和端口下浏览器会拦截请求报CORS跨域资源共享错误。开发阶段最常见的情况是前端用某个开发服务器跑在8080端口后端Tomcat跑在8081端口端口不同就构成跨域。解决办法是在后端响应头里加上允许跨域的字段。在Servlet里设置response.setHeader(Access-Control-Allow-Origin, *)星号表示允许任何来源。如果只允许特定域名把星号换成具体域名。对于简单的GET请求加这一个头就够了。如果涉及POST请求或者自定义请求头浏览器会先发一个OPTIONS预检请求后端需要单独处理OPTIONS请求返回允许的方法和头信息。response.setHeader(Access-Control-Allow-Origin, *); response.setHeader(Access-Control-Allow-Methods, GET, POST, OPTIONS); response.setHeader(Access-Control-Allow-Headers, Content-Type);提示生产环境不建议用星号应该指定具体的前端域名否则任何网站都能调用你的接口存在安全隐患。开发阶段图方便可以用星号上线前记得改掉。5.2 中文乱码的三种典型场景与修复中文乱码是Java Web开发的老朋友了几乎每个人都会遇到。它可能出现在三个地方请求参数乱码、响应内容乱码、数据库存储乱码。请求参数乱码发生在POST请求里Servlet默认用ISO-8859-1解码请求体。解决办法是在读取参数之前调用request.setCharacterEncoding(UTF-8)。注意这行代码必须放在获取任何参数之前放在后面就无效了。GET请求的参数乱码则和Tomcat的配置有关需要在server.xml的Connector标签里加URIEncodingUTF-8。响应内容乱码是因为浏览器不知道返回的是什么编码。设置response.setContentType(application/json;charsetUTF-8)就能解决这一行同时告诉浏览器内容类型和字符集。数据库存储乱码则是建表时字符集没设对。前面强调过用utf8mb4如果表已经建好了可以用ALTER TABLE语句修改字符集。连接串里也可以加characterEncodingutf8参数确保驱动用正确的编码传输数据。5.3 空值与异常数据的兜底策略真实数据里空值很常见前端不做处理就会显示null或者直接报错。我的做法是在渲染时对每个可能为空的字段做判断为空就显示占位符。除了前面说的email和phonestatus字段也要处理如果数据库里存了非0非1的值前端判断逻辑要能兜住。后端这边Dao层查询时如果结果集为空返回一个空的List而不是null这样前端遍历时不会报错。Servlet里对参数解析也要做保护如果前端传的page不是数字Integer.parseInt会抛异常要用try-catch包起来解析失败就用默认值。还有一类问题是数据量。如果用户表里有几十万条数据一次性查出来会撑爆内存。所以分页不是可选项是必须的。而且即使用了分页也要给pageSize设一个上限比如最大100防止有人传个pageSize999999把服务器拖垮。6. 分页查询的性能优化与SQL注入防护6.1 LIMIT分页在数据量大时的性能陷阱LIMIT offset, size这种分页方式在数据量小的时候没问题但当offset很大时性能会急剧下降。比如LIMIT 1000000, 10MySQL会先扫描前1000010条记录然后丢弃前1000000条只返回最后10条。这个扫描过程非常耗时。优化的思路是避免使用大offset。一种方案是用游标分页也就是记住上一页最后一条记录的id查询时用WHERE id lastId LIMIT size。这种方式不管翻到多少页查询速度都一样快。但它有个限制只能按id顺序翻页不能直接跳到第N页。另一种方案是先用子查询或者覆盖索引定位到起始id再关联查询。比如SELECT * FROM user_info WHERE id (SELECT id FROM user_info ORDER BY id LIMIT 1000000, 1) LIMIT 10。子查询走覆盖索引比直接扫描全表快。这个项目数据量不大用普通LIMIT就够了。但你要知道这个坑的存在等数据量上来了知道往哪个方向优化。6.2 预编译语句如何挡住SQL注入SQL注入是最经典的安全漏洞。如果查询SQL是用字符串拼接出来的比如SELECT * FROM user_info WHERE username username 攻击者输入一个带引号和SQL片段的用户名就能改变SQL的语义甚至删库。防御的核心是使用PreparedStatement也就是预编译语句。SQL里用问号占位参数通过set方法传入。驱动会把参数当作纯数据来处理不会解析成SQL语法。这样即使参数里包含引号、分号、注释符也只会被当成普通字符。String sql SELECT * FROM user_info LIMIT ?, ?; PreparedStatement ps conn.prepareStatement(sql); ps.setInt(1, offset); ps.setInt(2, pageSize); ResultSet rs ps.executeQuery();注意LIMIT后面的参数也可以用占位符但有些老版本的MySQL驱动不支持如果报错就改成字符串拼接但要确保拼接的是经过校验的整数不是用户输入的原始字符串。6.3 连接池的引入时机与配置要点每次请求都新建数据库连接在高并发下会成为瓶颈。连接池预先创建一批连接放在池子里请求来了直接取用完还回去省去了频繁建立和关闭连接的开销。常用的连接池有Druid、HikariCP、C3P0。HikariCP性能最好配置也简单Spring Boot默认就用它。Druid功能丰富自带监控页面适合需要观察SQL执行情况的场景。这个小项目用哪个都行我倾向HikariCP因为够轻够快。配置连接池时几个关键参数要调好最大连接数根据数据库能承受的并发来定一般设20到50最小空闲连接数设5到10保证随时有可用连接连接超时时间设30秒左右避免请求一直卡着还有连接的有效性检测防止拿到已经失效的连接。引入连接池后DBUtil的getConnection方法改成从池子里取close方法改成归还连接而不是真正关闭。代码改动不大但性能提升明显。7. 实测中踩过的坑与排查思路7.1 接口返回200但页面空白一次完整的排查过程有一次我写完接口用浏览器直接访问URL能看到JSON数据但前端页面就是一片空白。打开浏览器控制台发现fetch请求成功了状态码200但表格里什么都没有。排查第一步看控制台有没有JavaScript报错。结果发现一个Uncaught TypeError: Cannot read property forEach of undefined。这说明result.data是undefined。但接口明明返回了data字段啊。第二步在fetch的then回调里打印result发现结构和我预期的不一样。原来后端返回的JSON被包了一层实际数据在result.data.data里。问题出在后端封装Result对象时我把List放进了data字段但前端解析时又取了一次data层级搞错了。第三步统一前后端的数据结构约定。后端固定返回{code, message, data, total}前端固定从result.data取列表从result.total取总数。约定好之后这类问题就不会再出现了。这个坑给我的教训是前后端对接时数据结构一定要提前约定清楚最好写个简单的接口文档哪怕只是几行注释。口头约定或者靠猜迟早出问题。7.2 分页参数传递错误导致的数据消失还有一次第一页数据显示正常点第二页就空了。检查发现是分页参数的计算出了问题。我在前端算offset时用了page * pageSize但后端期望的是页码page然后后端自己算offset。结果前端传了offset后端又把它当页码用算出来的offset大得离谱自然查不到数据。解决方法是明确分工前端只传页码page和每页条数pageSizeoffset的计算统一放在后端。这样职责清晰不会出现两边都算、算法还不一致的情况。排查这类问题时我习惯在关键位置打日志把接收到的参数和计算出的offset都打印出来。一看日志就知道是哪一步算错了。日志不用多高级System.out.println在开发阶段就够用上线前再换成正式的日志框架。7.3 浏览器缓存导致的数据不更新测试的时候改了数据库里的数据刷新页面却发现显示的还是旧数据。一开始以为是后端没查到新数据后来发现是浏览器缓存了GET请求的响应。GET请求默认会被浏览器缓存尤其是响应头里没有设置缓存控制字段的时候。解决办法是在请求URL后面加一个时间戳参数比如?t${Date.now()}让每次请求的URL都不一样浏览器就不会用缓存了。或者在后端设置响应头Cache-Control: no-cache告诉浏览器不要缓存。开发阶段我建议直接禁用浏览器缓存在开发者工具的Network面板里勾选Disable cache。这样调试的时候省心不用每次都手动清缓存。7.4 数据库连接未关闭导致的连接耗尽项目跑了一段时间后突然所有请求都卡住报Too many connections。查下来是数据库连接没有正确关闭。有些分支路径下代码抛了异常跳过了close语句连接就一直被占用积累到MySQL的最大连接数就爆了。解决办法是把close操作放在finally块里确保无论是否发生异常都会执行。更好的做法是用try-with-resources语法Java 7之后支持资源会在try块结束时自动关闭不用手动写close。try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { // 执行查询 } catch (SQLException e) { e.printStackTrace(); }用了连接池之后这个问题会更隐蔽因为连接是归还给池子而不是真正关闭。如果忘记归还池子里的连接会被耗尽表现和上面一样。所以用连接池时归还连接的操作同样要放在finally里。8. 从能跑到好用几个值得继续打磨的方向这套ServletJSPMySQL的实现跑通之后其实还有不少可以继续优化的空间。我列几个我自己后续会去做的方向供你参考。第一个是接口的健壮性。目前只处理了正常的查询如果数据库挂了、SQL写错了、参数格式不对返回的错误信息还不够友好。可以统一封装一个异常处理机制把不同类型的异常映射成对应的错误码和提示信息前端根据错误码做不同的展示。第二个是前端体验的优化。现在每次翻页都是整表刷新可以加一个加载中的动画让用户知道数据正在请求。表格可以加上排序功能点击表头按某个字段排序。还可以加搜索框按用户名模糊查询。这些功能都不复杂但能让页面从能用变成好用。第三个是代码结构的整理。目前Dao、Servlet、工具类都还比较分散可以考虑引入一个简单的MVC分层把业务逻辑从Servlet里抽出来放到Service层。这样Servlet只负责接收请求和返回响应业务逻辑集中在Service职责更清晰。虽然这个项目规模小但养成分层的习惯对以后做更大的项目有好处。第四个是安全性。除了SQL注入还要考虑XSS攻击。前端渲染用户输入的内容时如果直接拼接到HTML里恶意脚本可能被执行。解决办法是对输出到页面的内容做转义把尖括号、引号等特殊字符替换成HTML实体。这个项目里用户名是用户可控的所以这个防护不能省。我在实际做这类列表页的时候最大的体会是把基本功能跑通只是开始真正体现水平的是对边界情况的处理。空值、异常、大数据量、并发、安全这些才是区分能跑和可靠的关键。希望这篇内容能帮你少走一些我当年走过的弯路。