1. 从"用户列表"这个需求说起:为什么它值得单独拿出来做
很多人看到"用户信息列表展示"这几个字,第一反应是:这不就是查一下数据库,然后循环打印吗?有什么好讲的。我刚开始接触Web开发的时候也是这么想的,直到真正动手用Servlet+JSP+MySQL这套组合去实现一个"前后端分离"的列表页,才发现里面藏着不少门道。
这个项目的核心目标很明确:把数据库里存储的用户信息,通过后端接口读取出来,再由前端页面渲染成一张清晰、可读的列表。听起来简单,但它几乎涵盖了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或者Gson,Gson更轻量,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字段用TINYINT,0表示禁用,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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字符集一定要用utf8mb4,别用utf8。MySQL里的utf8其实是个"假utf8",最多只支持3字节,存不了emoji和一些特殊字符,utf8mb4才是真正的4字节UTF-8。这个坑我在早期项目里踩过,用户昵称里带个特殊符号就插入失败,排查半天才发现是字符集的问题。
2.3 造一批测试数据的实用技巧
开发阶段没有真实数据,得自己造。手动INSERT几条太慢,我一般写一个存储过程或者直接用SQL批量生成。比如用循环插入100条测试数据,用户名用"user"加序号拼接,邮箱用用户名加域名后缀。这样列表页一打开就有数据,分页效果也能直观看到。
造数据的时候有个小技巧:故意造几条字段为空的记录,比如email为NULL、phone为空的,这样能测试前端对空值的处理是否健壮。真实环境里数据不可能都规规矩矩,提前把这些边界情况覆盖到,上线后才不会手忙脚乱。
3. 后端接口的编写:Servlet如何优雅地吐出JSON
3.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 } }这里有个细节值得说:连接串里最好加上useSSL=false和serverTimezone=Asia/Shanghai这两个参数。不加useSSL=false,MySQL 8.0会打印一堆SSL警告;不加时区配置,存进去的时间可能和实际差8小时,因为驱动默认用UTC时区。这两个问题我都遇到过,排查起来还挺费劲。
3.2 用户实体类与数据访问层的职责划分
实体类User就是一个标准的JavaBean,字段和数据库表一一对应,提供getter和setter方法。别小看这个类,它是后端各层之间传递数据的载体。字段类型要和数据库类型匹配:id用Long,username用String,create_time用java.util.Date或者LocalDateTime。
数据访问层我单独抽一个UserDao类,专门负责和数据库打交道。里面至少要有两个方法:一个是查询用户列表(支持分页),一个是查询总记录数。为什么分页查询和总数查询要分开?因为分页只需要当前页的数据,而前端要显示"共X条记录",需要总数。两个查询的SQL不一样,分开写更清晰。
查询列表的SQL用LIMIT关键字实现分页,LIMIT offset, size,offset是起始位置,size是每页条数。offset的计算方式是(当前页码 - 1) * 每页条数。这个计算很容易搞错,我见过有人直接用页码乘每页条数,结果第一页就跳过了前N条数据。总数查询用SELECT COUNT(*),返回一个long值。
3.3 Servlet接收请求参数并返回JSON的完整流程
Servlet是整个后端的大脑,它接收前端发来的请求,调用Dao拿数据,再把数据转成JSON返回。这里我用一个UserListServlet来处理列表请求。
首先在doGet方法里设置响应类型:response.setContentType("application/json;charset=UTF-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;charset=UTF-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(); List<User> users = dao.getUserList(page, pageSize); long total = dao.getUserCount(); Map<String, 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发起异步请求
前端页面我不用任何框架,就用原生HTML+JavaScript,这样能看清每一步在做什么。发起请求用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.000+00: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标签里加URIEncoding="UTF-8"。
响应内容乱码是因为浏览器不知道返回的是什么编码。设置response.setContentType("application/json;charset=UTF-8")就能解决,这一行同时告诉浏览器内容类型和字符集。
数据库存储乱码则是建表时字符集没设对。前面强调过用utf8mb4,如果表已经建好了,可以用ALTER TABLE语句修改字符集。连接串里也可以加characterEncoding=utf8参数,确保驱动用正确的编码传输数据。
5.3 空值与异常数据的兜底策略
真实数据里空值很常见,前端不做处理就会显示"null"或者直接报错。我的做法是在渲染时对每个可能为空的字段做判断,为空就显示占位符。除了前面说的email和phone,status字段也要处理,如果数据库里存了非0非1的值,前端判断逻辑要能兜住。
后端这边,Dao层查询时如果结果集为空,返回一个空的List而不是null,这样前端遍历时不会报错。Servlet里对参数解析也要做保护,如果前端传的page不是数字,Integer.parseInt会抛异常,要用try-catch包起来,解析失败就用默认值。
还有一类问题是数据量。如果用户表里有几十万条数据,一次性查出来会撑爆内存。所以分页不是可选项,是必须的。而且即使用了分页,也要给pageSize设一个上限,比如最大100,防止有人传个pageSize=999999把服务器拖垮。
6. 分页查询的性能优化与SQL注入防护
6.1 LIMIT分页在数据量大时的性能陷阱
LIMIT offset, size这种分页方式在数据量小的时候没问题,但当offset很大时,性能会急剧下降。比如LIMIT 1000000, 10,MySQL会先扫描前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和每页条数pageSize,offset的计算统一放在后端。这样职责清晰,不会出现两边都算、算法还不一致的情况。
排查这类问题时,我习惯在关键位置打日志,把接收到的参数和计算出的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. 从能跑到好用:几个值得继续打磨的方向
这套Servlet+JSP+MySQL的实现跑通之后,其实还有不少可以继续优化的空间。我列几个我自己后续会去做的方向,供你参考。
第一个是接口的健壮性。目前只处理了正常的查询,如果数据库挂了、SQL写错了、参数格式不对,返回的错误信息还不够友好。可以统一封装一个异常处理机制,把不同类型的异常映射成对应的错误码和提示信息,前端根据错误码做不同的展示。
第二个是前端体验的优化。现在每次翻页都是整表刷新,可以加一个加载中的动画,让用户知道数据正在请求。表格可以加上排序功能,点击表头按某个字段排序。还可以加搜索框,按用户名模糊查询。这些功能都不复杂,但能让页面从"能用"变成"好用"。
第三个是代码结构的整理。目前Dao、Servlet、工具类都还比较分散,可以考虑引入一个简单的MVC分层,把业务逻辑从Servlet里抽出来放到Service层。这样Servlet只负责接收请求和返回响应,业务逻辑集中在Service,职责更清晰。虽然这个项目规模小,但养成分层的习惯,对以后做更大的项目有好处。
第四个是安全性。除了SQL注入,还要考虑XSS攻击。前端渲染用户输入的内容时,如果直接拼接到HTML里,恶意脚本可能被执行。解决办法是对输出到页面的内容做转义,把尖括号、引号等特殊字符替换成HTML实体。这个项目里用户名是用户可控的,所以这个防护不能省。
我在实际做这类列表页的时候,最大的体会是:把基本功能跑通只是开始,真正体现水平的是对边界情况的处理。空值、异常、大数据量、并发、安全,这些才是区分"能跑"和"可靠"的关键。希望这篇内容能帮你少走一些我当年走过的弯路。