简介:这是基于Java Web的个人微博应用项目,适合正在学习Web原理、Java开发或需要课程设计参考的读者。项目围绕社交平台常见功能展开,包含会员管理、个人中心与消息发布管理等模块,能够帮助理清从用户注册登录、个人信息展示到动态发布和数据库存储的完整开发流程。
压缩包为zip格式,共包含71个文件,整体大小约3.33MB,文件类型以Java源码、JSP页面、CSS样式、XML配置以及少量jar包和class文件为主,结构较为清晰,便于导入IDE后对照查看项目目录与运行方式。其中还涉及Servlet处理请求、JSP动态页面、JDBC数据访问、前端样式设计等关键知识,适合作为Web开发入门实践参考。
目前已有223人学习,对于需要快速了解一个轻量级社交项目实现的同学,可以通过梳理模块划分与文件用途,快速定位关键代码,并在此框架上扩展或改造自己的功能需求。
1. 个人微博应用:一条从浏览器到数据库的 Web 请求全链路
某同学临近期末交 Web 课设,前后端加起来写了八百行,却连「登录之后 Session 存了什么、请求是怎么被 Servlet 接住的」都说不清楚。这份「个人微博应用」就是拿来治这个毛病的。它不是用 Spring Boot 把细节藏起来的黑匣子,而是把前端页面、Java 后端、MySQL 数据库三者用最朴素的方式串成一个完整闭环:浏览器发请求,Servlet 接收处理,JDBC 读写库,JSP 渲染回显。拆完这个项目,你能同时看明白 Web 原理课上的四个考点——HTTP 无状态、Session 机制、请求转发与重定向、三层架构分层。适合刚学完 Java Web 基础想找个完整项目练手的人,也适合要拿课设骨架去二次开发的同学。
2. 技术栈与目录结构:为什么是 Servlet、JSP 和 JDBC 的「无框架」组合
2.1 技术选型:无框架组合的价值
打开这份资源的第一眼,你可能会觉得「怎么还在用 Servlet + JSP」——这正是它值得拆的原因。标题写着「Web原理及应用」,教学项目的核心诉求是把每个环节暴露出来,而不是用框架把它们藏起来。Spring Boot 里一个@Controller注解就把请求映射、参数绑定、视图解析全包了,你很难看清一条请求到底经过了哪几道工序。而 Servlet 的doGet/doPost里,你亲手写request.getParameter()、亲手调session.setAttribute()、亲手把结果forward到 JSP,每一步都是 Web 原理课的考点本身。
这套组合的具体选型如下:
- Java 8 + Servlet 4.0,运行在 Tomcat 9 上
- JSP 2.3 做服务端渲染,配合 JSTL 标签库控制页面逻辑
- JDBC + Druid 连接池,操作 MySQL 5.7 / 8.0
- 前端用原生 HTML、CSS、JavaScript,数据交互走 fetch + JSON
提示:这不是生产级微服务架构,是一份「能把原理讲清楚」的课程项目。正因为它没有框架遮蔽,你反而能把三层架构、Session 管理、SQL 拼接这些细节逐个看透。对准备面试的人来说,这就是最合适的底稿。
2.2 目录结构:先看包再写代码
我拆项目有个习惯——先看目录结构,再看配置文件,最后才看代码。这份资源的 Maven 工程结构是标准的三层架构,层与层之间单向依赖,没有循环调用:
weibo/ ├── pom.xml ├── src/main/java/ │ ├── com.weibo.model/ # 实体类 User、Message │ ├── com.weibo.dao/ # 数据访问层,纯 JDBC 操作 │ │ ├── UserDao.java │ │ └── MessageDao.java │ ├── com.weibo.service/ # 业务逻辑层,校验和事务边界 │ │ ├── UserService.java │ │ └── MessageService.java │ ├── com.weibo.web.servlet/ # 控制层,接收请求、返回视图或 JSON │ │ ├── LoginServlet.java │ │ ├── RegisterServlet.java │ │ ├── PublishServlet.java │ │ ├── FeedServlet.java │ │ └── ProfileServlet.java │ ├── com.weibo.web.filter/ # 登录拦截 │ │ └── AuthFilter.java │ ├── com.weibo.util/ # DBUtil 连接池工具、JsonUtil 序列化工具 │ └── com.weibo.constant/ # 常量类,Session Key、参数名统一管理 └── src/main/webapp/ ├── login.jsp ├── register.jsp ├── home.jsp # 主页面,发微博 + 时间线 + 个人主页 ├── static/css/ # 样式文件 ├── static/js/ # 前端交互 fetch 脚本 └── WEB-INF/web.xml这个结构是给二次开发留了后路的:web 层的Servlet只做三件事——解析参数、调用 service、把结果交给 JSP 或 JSON;service 层承载业务规则,比如内容校验、分页逻辑;dao 层只写 SQL,不掺业务判断。如果你后面想加一个「关注」功能,只需要在 model 里加Follow实体、在 dao 里加FollowDao、在 service 里加新方法,然后在 Servlet 里接一个新接口——不需要改动任何已有代码。
2.3 依赖与配置:三个容易忽略的细节
pom.xml里依赖不多,但每个都有讲究:
<dependencies> <!-- Servlet 依赖,Tomcat 已自带,所以 scope 用 provided --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- MySQL 驱动,版本号必须和本地数据库匹配 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.30</version> </dependency> <!-- Druid 连接池,比 DriverManager.getConnection 省掉每次建连的开销 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency> <!-- Jackson,用来把 Java 对象转成 JSON 字符串返回给前端 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.13.3</version> </dependency> </dependencies>这里三个易错点:第一,servlet-api的 scope 必须是provided,如果漏了,打 war 包时会把 API 打进去,和 Tomcat 自带的类冲突;第二,MySQL 驱动版本要和你本机数据库一致,5.1.x和8.0.x的驱动类名、连接串写法都不一样;第三,不用 Spring,所以连接池得自己管好——DBUtil里通过静态代码块初始化 Druid 数据源,整个应用只建一个池子,多个线程共享连接。
web.xml里做了两件事:配置了 Druid 的WebStatFilter用来统计 SQL 执行,顺便把静态资源的请求排除掉;再声明了欢迎页指向login.jsp。项目的 Servlet 全部用@WebServlet("/api/login")注解注册,没有在 web.xml 里堆映射——因为注解更直观,路径和类名放在一起,排查问题少跳一层。
3. 核心模块拆解:一条微博从发布到刷进时间线的完整链路
3.1 登录与 Session 管理:权限拦截是 Web 应用的地基
登录功能是这个项目里「Web 原理」浓度最高的一段代码。登录成功之后,用户信息存进 Session,后续的发布微博、刷时间线、看个人主页,全靠 Session 里的 user 对象来识别「你是谁」。后端三个核心环节如下。
先看UserService.login()的校验逻辑:
public User login(String username, String password) { // 参数校验:空值和长度限制放在最外层 if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { return null; } // 调用 DAO 层,按用户名查用户 User user = userDao.findByUsername(username); // 比对密码:这里存的是 MD5 值,不是明文 if (user != null && user.getPassword().equals(MD5Util.md5(password))) { return user; } return null; }逻辑说明:service 层先做参数显式校验,避免空指针进入 SQL 层;findByUsername返回 null 表示用户不存在;再比对 MD5 摘要后的密码,与数据库中的 hash 值一致才算登录成功。这里用 MD5 是教学场景的习惯做法,生产项目至少要加盐或换 BCrypt。参数说明:username和password都来自前端表单,前端在提交前也做了一次非空校验,但后端校验才是真正的安全边界——前端校验只改善体验,不可信。
再看LoginServlet中登录成功后的 Session 处理:
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username"); String password = request.getParameter("password"); User user = userService.login(username, password); if (user != null) { // 登录成功:把用户对象放进 Session,后续请求都能读到 HttpSession session = request.getSession(); session.setAttribute(Constants.SESSION_USER, user); session.setMaxInactiveInterval(60 * 30); // 30 分钟过期 response.sendRedirect(request.getContextPath() + "/home.jsp"); } else { // 登录失败:转发回登录页并携带错误信息 request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } }逻辑说明:request.getSession()内部会取或创建 JSESSIONID,写入 Cookie 返回给浏览器;setMaxInactiveInterval控制 Session 存活时长,单位是秒;sendRedirect是重定向,浏览器会重新发起一次 GET 请求到/home.jsp,这一步跳转 URL 会变化,forward则不会。两者差别面试必考——重定向是两次请求,转发是一次请求内完成。参数说明:Constants.SESSION_USER是统一常量,不允许在代码里手写字符串 key,这是为了避免多个 Servlet 之间 key 不一致导致「登录了却拿不到用户」的玄学问题。
拦截器AuthFilter把所有需要登录才能访问的接口锁住:
@WebFilter("/api/*") public class AuthFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; HttpSession session = request.getSession(false); User user = session == null ? null : (User) session.getAttribute(Constants.SESSION_USER); if (user == null) { // 未登录:返回 401 JSON,前端根据状态码跳登录页 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); return; } chain.doFilter(req, res); } }逻辑说明:@WebFilter("/api/*")把范围定在/api前缀上,JSP 页面和静态资源不受拦截;request.getSession(false)传入 false 表示「没有 Session 就返回 null」,不会强行创建一个新 Session;拿到用户对象后放行,拿不到就直接返回 JSON。参数说明:前端fetch请求遇到 HTTP 401 或返回 JSON 里的code:401,就执行window.location.href = 'login.jsp'。这套前后端配合的拦截方式,比直接重定向到登录页更接近真实项目的接口风格。
3.2 发布微博:表单参数到 INSERT 语句的完整旅程
发布微博是整个应用的核心操作,链路不长但每个环节都有戏。前端是一个 JSON 的 POST 请求,后端PublishServlet接收后,先取 Session 用户,再校验内容长度,最后调用 service 层落库。
@WebServlet("/api/publish") public class PublishServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { // 设置请求编码,避免中文乱码,必须放在读取参数之前 request.setCharacterEncoding("UTF-8"); // 从 Session 中取当前登录用户 HttpSession session = request.getSession(false); User user = (User) session.getAttribute(Constants.SESSION_USER); // 读取参数:content 是前端传来的微博文本 String content = request.getParameter("content"); if (content == null || content.trim().isEmpty()) { writeJson(response, 400, "内容不能为空"); return; } if (content.trim().length() > 280) { writeJson(response, 400, "内容不能超过 280 字"); return; } // 调 service 层:这里负责 INSERT 语句的执行 MessageService messageService = new MessageService(); boolean success = messageService.publish(user.getId(), content.trim()); if (success) { writeJson(response, 200, "发布成功"); } else { writeJson(response, 500, "发布失败,请稍后重试"); } } }逻辑说明:编码设置必须在读取第一个参数之前调用,因为参数读取时已经按字节解析了;Session 用户在 Filter 里验证过非空,这里只需取出即可,不重复判断;service 层返回 boolean,Servlet 只负责把它翻译成 JSON 响应。参数说明:writeJson是自定义工具方法,基于 Jackson 往 response 里写 JSON 字符串。280 字的长度限制在 service 层也做了一遍——Servlet 层防的是非法请求,service 层防的是漏网之鱼,两层校验各管一段。
MessageService.publish里对应的就是一个带参数的 INSERT:
public boolean publish(Integer userId, String content) { String sql = "INSERT INTO message (user_id, content, create_time, like_count) VALUES (?, ?, NOW(), 0)"; return messageDao.executeUpdate(sql, userId, content) > 0; }逻辑说明:用占位符?而不是字符串拼接——这是防 SQL 注入的底线。虽然这里参数都是通过 PreparedStatement 预编译后传参的,不会拼接进 SQL 语法结构,但很多人写 JDBC 时图省事直接拼字符串,一旦 content 里出现单引号和分号就翻车。参数说明:NOW()由数据库生成时间,不依赖服务器时钟;like_count默认 0,后续点赞功能直接UPDATE message SET like_count = like_count + 1即可,不用先查后写。
3.3 时间线 Feed:为什么一条 SQL 能同时查出作者昵称
主页刷出来的时间线不是简单的表格查询,而是「微博 + 作者昵称」的联合数据。如果只查 message 表,前端显示昵称的时候还得逐条回查 user 表,那就是经典的 N+1 问题。这项目直接在 SQL 层面 JOIN 解决:
public List<FeedItem> findFeed(int page, int pageSize) { String sql = "SELECT m.id, m.content, m.create_time, m.like_count, " + "u.username, u.nickname " + "FROM message m " + "INNER JOIN user u ON m.user_id = u.id " + "ORDER BY m.create_time DESC " + "LIMIT ? OFFSET ?"; // 执行带参查询,将结果映射为 FeedItem 对象列表 return jdbcTemplate.query(sql, ps -> { ps.setInt(1, pageSize); ps.setInt(2, (page - 1) * pageSize); }, rowMapper); }逻辑说明:INNER JOIN user把 message 表和 user 表按user_id关联成一张宽表,一次查询拿到内容和作者信息,避免了逐条回查;ORDER BY create_time DESC保证最新的微博在最前面;LIMIT ? OFFSET ?是经典的分页方式。参数说明:page从 1 开始,OFFSET计算公式是(page-1) * pageSize,即第一页跳过 0 条、第二页跳过 pageSize 条。这套分页在数据量达到几十万条之后会有深分页性能问题,但课设场景完全够用——真到那个量级再说。
FeedItem 这个模型值得看一眼,它不是纯粹的数据库实体,而是「视图模型」(VO):
public class FeedItem { private Integer id; private String content; private Date createTime; private Integer likeCount; private String username; private String nickname; // getter/setter 省略 }逻辑说明:VO 和实体分离是 Web 应用的好习惯——message 表里没有nickname字段,但你需要在时间线上展示它,所以专门建一个FeedItem聚合查询结果。以后要加一个「这条微博是否已点赞」的字段,也是往 FeedItem 加属性,不影响实体类。这个设计对二次开发很关键:改查询不影响写入,改表结构不影响接口。
3.4 前端 fetch 与 JSON 接口:贴近真实开发的前后端交互
这份资源没有走传统的「JSP 里用 form 提交」老路,而是用 fetch + JSON,这也是它比一般课设项目更像真实工程的地方。前端 JavaScript 的发布微博代码:
async function publishMessage() { const content = document.getElementById('content').value.trim(); if (!content) { alert('内容不能为空'); return; } if (content.length > 280) { alert('内容不能超过 280 字'); return; } const formData = new URLSearchParams(); formData.append('content', content); const resp = await fetch('/api/publish', { method: 'POST', body: formData }); const data = await resp.json(); if (data.code === 200) { loadFeed(1); // 重新刷新时间线 document.getElementById('content').value = ''; } else { alert(data.message); } }逻辑说明:用URLSearchParams模拟表单提交,服务端的getParameter可以直接取到,请求头会自动加上application/x-www-form-urlencoded;fetch返回的是 Promise,和 JSP 里直接document.forms[0].submit()相比,页面不刷新就能拿到 JSON 数据;data.code是后端定义的业务状态码,HTTP 状态码和业务码分开,是前后端协作的标准姿势。注意点:请求头没有手动设置Content-Type,而是靠URLSearchParams自动携带application/x-www-form-urlencoded;charset=UTF-8,这样不会出现跨域预检问题。
// 加载时间线的 fetch 调用 async function loadFeed(page) { const resp = await fetch(`/api/feed?page=${page}&pageSize=10`); const data = await resp.json(); if (data.code === 200) { renderFeed(data.data.list); // data.data.list 是微博数组 } else if (data.code === 401) { window.location.href = '/login.jsp'; } }逻辑说明:时间线走 GET 请求,分页参数直接拼在 URL 查询串上;data.code===401时全局跳回登录页,对应AuthFilter里返回的 JSON。看这份代码,你顺带就把「HTTP 方法语义」「GET 与 POST 参数位置」两个 Web 原理知识点复习了:GET 的参数在 URL 上,有长度限制,适合查询;POST 的参数在请求体里,适合写操作和敏感数据传递。
4. 本地运行与调试:从空数据库到第一条微博落库
4.1 环境准备与数据库初始化
这份资源我建议在如下环境跑:JDK 8 或 11,Maven 3.6+,Tomcat 9,MySQL 5.7/8.0。IDE 没有强制要求,IDEA 或 Eclipse 都行,但后面调试那部分我用 IDEA 的 Artifact 部署方式讲,操作路径更直观。
数据库初始化是第一个容易卡住的点。项目根目录下应该有sql/init.sql,如果没有,按下面的建表语句手动执行:
CREATE DATABASE IF NOT EXISTS weibo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE weibo; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(50) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE message ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, content VARCHAR(280) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, like_count INT NOT NULL DEFAULT 0, CONSTRAINT fk_message_user FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO user (username, password, nickname) VALUES ('demo', 'e10adc3949ba59abbe56e057f20f883e', '演示用户');建表说明:字符集统一用utf8mb4而不是utf8,因为utf8mb4才能存下 Emoji 和生僻字——微博场景里颜文字和特殊符号很常见,用错字符集会看到「?」乱码;外键约束fk_message_user保证了 message 表的user_id一定指向存在的用户,删用户时也会因为有外键而报错——这是数据完整性保护,不是麻烦。密码字段存的是MD5("123456")的值e10adc...,也就是说演示账号的密码是123456,登录时后端算法自动比对 MD5 摘要。
注意:MySQL 8.0 的驱动类名和连接串和 5.x 不一样。8.0 用
com.mysql.cj.jdbc.Driver,连接串要加serverTimezone=Asia/Shanghai,否则报时区错误。这份源码的db.properties已经处理好了,你只需要改用户名和密码。
4.2 配置连接池与启动入口
进入资源包以后,先改src/main/resources/db.properties:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/weibo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4 jdbc.username=root jdbc.password=你的数据库密码 jdbc.initialSize=2 jdbc.maxActive=10参数说明:initialSize=2是启动时在连接池里创建 2 个空闲连接,maxActive=10是最大连接数,超出这个数的请求会排队。characterEncoding=utf8mb4要放在连接串里,否则中文会乱码。改完配置,启动方式有两种,选一种就行。
方式一,Maven 打 war 包丢进 Tomcat——这是最朴素的办法,适合不想配 IDEA 的同学:
mvn clean package # 输出 target/weibo.war cp target/weibo.war $TOMCAT_HOME/webapps/ # 启动 Tomcat $TOMCAT_HOME/bin/startup.sh # 浏览器打开 http://localhost:8080/weibo/login.jsp方式二,IDEA 里配置 Tomcat 的 Artifact:Run → Edit Configurations → 新建 Tomcat Server → Local,Deployment 里 Addweibo:war exploded,Application context 填/weibo,然后点运行。war exploded比打包成 war 好在哪里——JSP 或 Java 代码改了之后,IDEA 会自动热部署,不用每次重启 Tomcat,调试效率高很多。
4.3 调试技巧:把黑匣子打开一条缝
这套代码跑不起来的时候,多数人会觉得「玄学」。实际上只是把调试工具用错地方了。我一般按三个层次排查:
第一层,看后端日志。在MessageDao的查询方法里临时打印 SQL 和参数——不是打印在纸上,是打印到 IDEA 控制台。如果你在findFeed里看不到任何输出,说明请求根本没进到这个层,问题出在 Filter 拦截或 URL 写错。
第二层,看浏览器 Network 面板。按 F12 打开控制台找到 Network,刷新主页,逐个看请求的状态码:404表示路径写错,500表示后端异常,401表示没登录。点开请求详情还能看到 FormData 或 Query String 的参数列表——前端是不是把参数名传错了,一目了然。前端 JavaScript 报错则切到 Console 标签看调用栈。
第三层,断点调试全链路。在 IDEA 里把断点打在AuthFilter.doFilter第一行,然后刷新页面——你会发现请求先进 Filter,再进 Servlet,再到 Service,再到 DAO。这样你就亲眼看到了一条 HTTP 请求的完整旅程。整个过程不要怀疑「是不是环境问题」,先确认代码里哪一步没执行到,基本 5 分钟内能定位 80% 的问题。
5. 避坑指南:个人微博应用开发里最常见的五个问题
这份资源我拆过不止一遍,也帮别人排查过不少运行问题。下面五条是出现频率最高的,每条都是「现象 → 原因 → 解决」三步走,遇到同类问题时可以直接照方抓药。
坑一:POST 请求中文乱码
- 现象:通过
/api/publish发布微博,内容里的中文落库后变成???,或者前端 alert 弹出的错误信息全是乱码。 - 原因:Tomcat 8+ 默认用 UTF-8 解码请求体,但
db.properties连接串里没有指定characterEncoding,或者 Servlet 里在读取参数之后才调用setCharacterEncoding——编码设置必须在读取第一个参数之前,否则只对后续参数生效。 - 解决:连接串加上
characterEncoding=utf8mb4;Servlet 第一行就写request.setCharacterEncoding("UTF-8");数据库表字符集也统一为utf8mb4保持一致。三处都改完,乱码问题彻底消失。
坑二:登录成功后刷新又回到登录页
- 现象:输入正确密码跳转到了主页,但只要一刷新或新开标签页,马上被弹回
login.jsp。 - 原因:
AuthFilter从 Session 里取用户用的是 key"user",而LoginServlet里存的是Constants.SESSION_USER——如果这个常量值不一致,Session 里实际有用户对象但 Filter 取不到。另一个常见原因是浏览器禁用了 Cookie,导致 JSESSIONID 没有回传,Session 对不上。 - 解决:统一把 Session key 放到
Constants类里,杜绝手写字符串;浏览器设置里把 Cookie 允许打开;如果确实需要兼容禁用 Cookie 的场景,在LoginServlet跳转 URL 上通过response.encodeRedirectURL()把 JSESSIONID 拼进 URL。
坑三:连不上数据库,启动后请求报 500
- 现象:Tomcat 起来了,访问登录页正常,但输入账号后页面报 500,后台日志显示
Communications link failure或ClassNotFoundException: com.mysql.jdbc.Driver。 - 原因:MySQL 版本和驱动不匹配。如果本机是 MySQL 8.0,但
pom.xml里用的是mysql-connector-java 5.1.x,驱动名要改成com.mysql.jdbc.Driver且连接串不能带serverTimezone参数——但 5.1.x 又时区报错,一套配置两头堵。或者干脆是数据库服务没启动,端口 3306 没监听。 - 解决:确认
pom.xml版本、db.properties驱动类名、连接串时区参数三个地方匹配你本机的 MySQL 版本;用mysql -uroot -p手动连一次数据库,排除服务本身没起的问题;再检查jdbc.url里的端口如果改过,要和实际 MySQL 端口一致。
坑四:fetch 拿不到 JSON,控制台报解析错误
- 现象:前端代码
await resp.json()一直报SyntaxError: Unexpected token < in JSON,Network 面板里显示响应内容是 HTML 而不是 JSON。 - 原因:Servlet 在返回时没有设置
Content-Type: application/json;charset=UTF-8,Tomcat 默认按文本text/html返回了;另一个是后端代码里getWriter().write()之后又调用了sendRedirect,输出流被二次写入,响应体被污染。 - 解决:所有返回 JSON 的接口统一调用
writeJson工具方法——里面先setContentType("application/json;charset=UTF-8")再写数据,每个 Servlet 都用这个工具,避免重复犯低级错误。write和flush之后整个方法就 return,不再做任何跳转操作。
坑五:改了 CSS 或 JS 但浏览器不生效
- 现象:改了
static/css/weibo.css里的颜色,刷新页面还是旧的;清了浏览器缓存又好了,过一会又出现。 - 原因:浏览器对静态资源有缓存策略,同一个 URL 在过期前直接使用本地缓存,不会重新请求服务器。开发期间这是最烦人的「假难问题」——代码没问题,是浏览器在用旧文件。
- 解决:在 JSP 里引用静态资源的路径后面加版本号参数,比如
weibo.css?v=1.0.1,每次改动就递增版本号,URL 变了浏览器就会重新拉取。或者在调试期间临时在web.xml加一个 Cache-Control 设置为 no-cache 的 Filter,只影响开发环境,正式部署的时候再放开。
6. 二次开发思路:从微博壳子到可扩展的 Web 应用骨架
这份资源真正的价值在于改造空间。它把 Web 应用最常见的骨架——登录、发布内容、展示时间线、权限拦截——都搭好了,你想往上加东西,只需要顺着三层架构往里补模块。
第一个推荐的方向是「关注时间线」。目前主页FeedServlet显示的是全部用户的微博,你可以改成只显示当前登录用户关注的人。改动涉及三层:先在message表旁加一张follow表(id, user_id, follow_user_id, create_time),然后重构FeedServlet的查询逻辑,把原来不带条件的findFeed改成接受一组 userId 或直接 JOIN follow 表的方式:
-- 只看我关注的人的微博 SELECT m.id, m.content, m.create_time, m.like_count, u.username, u.nickname FROM message m INNER JOIN user u ON m.user_id = u.id INNER JOIN follow f ON f.follow_user_id = m.user_id WHERE f.user_id = ? ORDER BY m.create_time DESC LIMIT ? OFFSET ?实现时加一个开关参数——保持原来的「全部微博」和新增的「关注时间线」两条查询并存,通过Servlet里的type=all|follow区分。这样改造不破坏原有接口,前端加一个 tab 就能切换。这是业务型改造,验证方法是注册两个测试账号,A 关注 B,B 发一条微博,A 的主页只看到 B 的内容,同时 A 在「全部」tab 里仍能看到全站动态。
第二个方向是把 JSP 换成前后端分离。这份资源的所有/api接口已经返回 JSON 而不是返回 JSP 页面,把视图层替换成 Vue 或 React 的纯静态页面成本很低——你只需要保留api包下的 Servlet,把login.jsp、home.jsp换成index.html,用 fetch 调同样接口即可做到「同一个后端,两套前端」。验证方式也简单:先把静态页面放到 Tomcat 的webapps/ROOT下,如果所有接口跨域问题不存在(同域部署),登录、发布、刷时间线三个主流程跑通就算成功。
第三个方向是「点赞」功能。数据库里message表已经预留了like_count字段,后端只需要写一个/api/like接口,用UPDATE message SET like_count = like_count + 1 WHERE id = ?,前端在时间线每条微博上加一个按钮调这个接口。注意生产级做法是先查这条微博是否存在,再加乐观锁防止并发重复点赞,但在这个教学项目里,你先把主流程跑通,性能优化是后面的事。
从那以后,我每次拿到一个教学项目,都会先问自己三个问题:数据模型里有没有预留扩展字段?接口是不是都走 JSON 而不是绑定页面?前端能不能脱离开后端独立跑?如果答案都是肯定的,那这个项目就值得花时间细读——因为它的骨架允许你往上长新功能,而不是每次改造都要推倒重来。希望帮到你。
本文还有配套的精品资源,点击获取