简介:面向Java Web学习者的个人微博应用项目,围绕会员管理、个人中心、消息发布等核心模块,系统展示Web原理与前后端开发全流程,适合课程设计、毕业设计及Servlet/JSP初学者参考,是一次完整的Web应用实践。压缩包内共71个文件,以22个Java源码、24个class编译文件、5个JSP页面为主,辅以XML配置、CSS样式、Jar依赖及数据库文件等,整体仅3.33MB,包含Eclipse工程配置文件,导入后即可运行调试。目前已有223人学习。借助完整的工程目录,读者可对照学习用户注册登录、数据持久化、接口设计、实时推送等核心知识,还可参考代码中的权限管理、单元测试与搜索思路,是一份帮助串联Web应用前后端技术的实用案例。
1. 前端选 Java Web 方向后,第一次把浏览器请求打到后端时,最先撞上的不是代码 bug,而是一个反直觉的事实:HTTP 本身完全“健忘”
你刚注册完账号,刷新一次页面,却发现时间线里出现了别人发的微博,而且页面右上角还挂着你自己的头像。浏览器明明只是往服务器发了一个普通的 GET 请求,服务器是怎么在“没有记忆”的 HTTP 协议上认出你、知道你关注了谁、该给你推什么内容的?答案不是某个魔法字段,而是 Cookie、Session、数据库查询、Servlet 调度这四件事串成的一条完整链路。搞不懂这条链路,前端写的是“页面死不死”,后端写的则是“登录态丢不丢、数据乱不乱、连接池崩不崩”。
个人微博应用恰好是把这些机制全部走一遍的最小完整案例:注册登录涉及会话管理,发微博涉及数据写入,时间线涉及查询与排序,异步刷新涉及 JSON 序列化。把这套机制拆开,你会看到一个请求从浏览器到数据库再回到浏览器的完整旅程——这也是为什么值得专门写一篇文章把它讲透。
2. 一次微博刷新背后:从 TCP 连接、线程池到 Servlet 的完整调度链
2.1 先看清请求的“底层路径”:不是 url 直连 Java 方法
很多初学者以为浏览器地址栏里输入 index.jsp,服务器就会“直接执行那个文件”。实际上,请求要先经过一层网络协议栈、一个监听端口、一个线程池,最后才由 Servlet 容器交到你的代码手里。
我一般会这么理解:Servlet 容器(例如 Tomcat)启动后,会在某个端口上持续 accept TCP 连接。浏览器发来的 GET /index.jsp HTTP/1.1 请求是一段纯文本,容器把这段文本解析成请求行、请求头、请求体,封装成一个 HttpServletRequest 对象,然后从线程池里捞出一个空闲线程,在这个线程上执行匹配到的组件代码。
这里有个隐蔽但关键的点:容器处理请求的工作线程是复用的。你写的代码里如果让线程 sleep,或者在一个请求里反复创建数据库连接然后不关,消耗的不是“这一个请求”,而是整个应用的吞吐能力。后面避坑环节里连接耗尽的问题,根源就在这里。
2.2 URL 映射与 Filter 链:谁先碰这个请求
请求进入容器后,会先经过一层 Filter 链,再到达 Servlet。理解先后顺序很重要:Filter 的执行发生在 Servlet 的 service 方法之前,因此编码设置、登录校验这类横切逻辑通常会放在 Filter 里,而不是在每个 Servlet 里重复写。
一个典型个人微博项目的 web.xml 或注解配置里,会声明类似这样的映射:
@WebFilter(urlPatterns = {"/api/*", "/index.jsp"}) public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }chain.doFilter 是关键:它表示把请求继续向后传递。如果忘了调用这一行,请求就会在这里被“吞掉”,后面的 Servlet 永远执行不到,前端表现就是接口一直 pending。
再往下,容器根据请求路径找到对应的 Servlet。对于 /api/login 这个路径,容器会在映射关系里找到 LoginServlet,然后调用它的 service 方法,service 再根据 HTTP 方法分发到 doGet 或 doPost。所以 doPost 不是“你写的入口”,而是整个调度链上最靠近业务逻辑的那一层。
2.3 Servlet 三兄弟的生命周期:init、service、destroy
Servlet 不是“每个请求新建一个实例”,恰恰相反,同一个 Servlet 在容器里通常只有一个实例,被所有请求线程共享。这是新人最容易踩的并发思维误区。
- init 方法:容器启动或首次请求时执行一次,适合做资源初始化。
- service 方法:每个请求都会执行,但它内部按方法类型分发给 doGet/doPost。
- destroy 方法:容器关闭时执行一次,适合释放资源。
这里就出现一个并发隐患:如果在一个 Servlet 里定义了成员变量并用它存某个登录用户的身份信息,那么用户 A 的请求和用户 B 的请求会同时读写同一个变量。正确做法是,把每个请求、每个用户特有的数据放进 request 作用域、session 作用域或方法局部变量,而不是 Servlet 成员变量。
一个真实场景的登录 Servlet 大致长这样:
@WebServlet("/api/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserServiceImpl(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); if (user != null) { HttpSession session = req.getSession(true); session.setAttribute("currentUserId", user.getId()); resp.sendRedirect(req.getContextPath() + "/index.jsp"); } else { req.setAttribute("loginError", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } }注意这里的两个跳转方式,差别非常微妙:sendRedirect 是让浏览器再发一次新请求,地址栏会变成 /index.jsp;forward 是服务器内部直接改渲染目标,地址栏保持 /api/login 不变。登录失败用 forward,能把错误信息通过 request 属性带给登录页;登录成功用 redirect,能避免用户刷新页面时把“登录提交”再次重放。这段代码里的 UserService 是业务层,它的内部会调 DAO 去查数据库,这就是后面章节里时间线查询的入口。
3. 会话机制:Cookie 里的 JSESSIONID、服务端 Map 与“假装记住你”
3.1 HttpSession 到底存在哪
上一章登录成功时调用 req.getSession(true),这里发生了两件事:服务器创建或找到一条会话记录,同时在响应头里写一个 Set-Cookie。会话记录本身是一个服务端的内存对象,本质是一个键值结构,存着这个会话的创建时间、最后访问时间、失效时间,以及你往里塞的各种 attribute。
Cookie 里存的是会话的唯一标识,大多数容器默认叫 JSESSIONID。浏览器收到后,后续每次请求都会把这个 Cookie 原样带回。服务器一看 JSESSIONID,就能从自己的会话集合里找到对应的 HttpSession 对象,然后把“当前登录的是谁”这个信息取出来。
丢登录态最常见的原因就是这条链路某一段断了:
| 环节 | 常见坑 | 表现 |
|---|---|---|
| Cookie 未设置 HttpOnly | 前端脚本能读到 JSESSIONID,XSS 后可被窃取 | 会话劫持 |
| Cookie 未设置 Path | 应用上下文路径变化后 Cookie 带不过来 | 登录失效 |
| Session 超时 | 超过 maxInactiveInterval 无访问,容器自动清理 | 突然要重新登录 |
| 容器重启 | 内存会话全部消失 | 重启后全部掉线 |
3.2 登录校验 Filter:为什么每个受保护页面都要过一道关卡
微博应用中,发微博、看时间线、改头像这些操作都不允许匿名访问。如果每个 Servlet 都手写一遍“检查 session 里有没有 currentUserId”,代码会瞬间失控。标准做法是做一个统一的登录校验 Filter,只让登录过的请求通过。
@WebFilter(urlPatterns = {"/api/postWeibo", "/api/timeline", "/index.jsp"}) public class LoginCheckFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq = (HttpServletRequest) req; HttpSession session = httpReq.getSession(false); if (session != null && session.getAttribute("currentUserId") != null) { chain.doFilter(req, resp); } else { HttpServletResponse httpResp = (HttpServletResponse) resp; httpResp.sendRedirect(httpReq.getContextPath() + "/login.jsp"); } } }这里专门用了 getSession(false):如果当前没有会话,直接返回 null,而不是新造一个。很多人写成 getSession(true),导致未登录用户每次访问受保护页面都会被服务器创建一个新会话,服务器内存里堆满“幽灵会话”。登录校验的严谨程度,可以从这一个参数上看出来。
3.3 密码不能明文存:加盐哈希与登录流程中容易被跳过的那一步
个人微博项目里最常见的重灾区是密码存储。有人直接明文存,有人用 MD5 存一次就当作加密。MD5 确实“不是明文”,但它经不起彩虹表和暴力碰撞。真正常见且可靠的思路是加盐哈希:每个用户生成一个随机盐,把盐拼到密码后面,再进行多次哈希,然后盐和哈希结果一起入库。
public class PasswordUtil { private static final int ITERATIONS = 10000; public static String hashWithSalt(String rawPassword, String salt) throws Exception { byte[] result = rawPassword.getBytes("UTF-8"); for (int i = 0; i < ITERATIONS; i++) { byte[] combined = new byte[result.length + salt.getBytes("UTF-8").length]; System.arraycopy(result, 0, combined, 0, result.length); System.arraycopy(salt.getBytes("UTF-8"), 0, combined, result.length, salt.length()); result = MessageDigest.getInstance("SHA-256").digest(combined); } return bytesToHex(result); } }这里的迭代次数不是随便写的。每次循环都把上一步的摘要结果再和盐一起重新哈希,目的是把计算成本抬高到“暴力破解一个密码需要足够长时间”的量级。与此同时,登录验证时也要重复同样次数的哈希再比对,所以登录接口天然会比其他接口慢一些,这是安全换来的可接受代价。
从这一章能看到整个登录态的本质:服务器并没有“记住你”,它只是保存了一条状态记录,并通过一个不可伪造的标识和浏览器保持约定。你刷新一次页面,看到自己的头像和别人的微博,其实是 session 里的 userId 被拿去查了关注关系、查了时间线,最终拼出了“好像记得你是谁”的完整体验。
4. 时间线是怎么“算”出来的:feed 表结构、拉取式查询与分页索引
4.1 核心表结构:用户表、微博表、关注关系表
个人微博应用的数据模型围绕三个核心实体:用户、微博内容、关注关系。微博表是业务的主体,关注关系决定时间线范围。建表 SQL 里,索引设计决定了查询上限。
CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, salt VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL ); CREATE TABLE weibo ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, content VARCHAR(280) NOT NULL, created_at DATETIME NOT NULL, INDEX idx_user_time (user_id, created_at DESC), INDEX idx_time (created_at DESC) ); CREATE TABLE follow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, follow_user_id BIGINT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_user_follow (user_id, follow_user_id) );这里有两个索引细节值得展开。idx_user_time 支撑“查某个人的所有微博并按时间倒序”这种个人主页查询;idx_time 支撑“全站最新微博”这种公共时间线查询。当 create_at 被加上后,MySQL 可以直接按索引顺序倒着读,避免做文件排序。
4.2 拉取式时间线:为什么这种小项目不选择“推送”
时间线设计有两条路:拉取模式和推送模式。拉取模式是当你请求时间线时,现场去查你关注的人最近发了什么;推送模式是发微博时,系统立刻把这条微博复制到所有粉丝的收件箱里。
个人微博项目用拉取有两个现实原因。一是用户量小,关注关系简单,查询开销完全扛得住;二是推送模式要维护“每个用户的收件箱表”,每次发微博都是一次扇出写入,粉丝多时要写入几千条数据,还要处理事务一致性问题。拉取模式把开销从“写时”转移到了“读时”,代码简单,失误率低。你手上这份资源里如果用 JDBC 手写 SQL 查询,走的一定是拉取模式,因为它不需要引入消息队列或异步任务。
4.3 时间线 SQL:JOIN 与分页的正确姿势
查询时间线时,最直观的写法是查出所有关注对象的 id,再去微博表里查这些人的微博。但逐条查会产生 N+1 次 SQL。正确做法是用子查询加 JOIN 一次性查出完整结果:
SELECT w.id, w.content, w.created_at, u.username FROM weibo w JOIN user u ON u.id = w.user_id WHERE w.user_id IN ( SELECT follow_user_id FROM follow WHERE user_id = ? ) ORDER BY w.created_at DESC LIMIT 20 OFFSET 0;这条 SQL 的执行顺序和人们通常以为的不一样:不是先执行 IN 子查询,再把结果拿去 JOIN,而是优化器会尝试先把 follow 表按 user_id 定位,再通过索引找对应的 weibo 行。这里占位符 ? 必须用 PreparedStatement 传参,不能直接字符串拼接,否则一个包含单引号的微博内容就能把整个查询语法打碎,这就是 SQL 注入的典型入口。
分页参数 LIMIT 20 OFFSET 0 表达的是“每页 20 条,跳过 0 条”。页码越大,OFFSET 值越大,MySQL 需要先把前面所有行全部扫出来再丢弃,这就是深翻页变慢的根源。资源 DAO 层如果提供了“上一页最后一条微博 id”这种参数,走的其实是键集分页,比如 WHERE w.created_at < ? ORDER BY w.created_at DESC LIMIT 20,它能直接走索引而不需要扫已读过的数据,两种分页方式的性能差距在数据量过万后会非常明显。
DAO 层把 SQL 查询结果封装成对象的常见思路是:先取 ResultSet,再逐行构建 Weibo 对象,组装成 List 返回给 Service 层,Service 层再把时间列表交给 Servlet 响应输出。这一层封装虽然代码量大,却是 JDBC 应用的骨架。如果资源里使用了连接池配置,那连接的生命周期管理就是下一章要说的重灾区。
5. 前端 Java Web 避坑清单:乱码、连接耗尽、异步数据拿不到、会话丢失
5.1 中文乱码:编码设置的位置比想象中更早
现象:注册时输入“我喜欢写代码”,存入数据库变成“?????”,页面显示一串问号。
原因:Servlet 容器解析请求体时默认按 ISO-8859-1 读取字节流。你调用 req.getParameter 的时候,参数已经被按错误编码解码了,后面无论怎么 new String 转码,都只能靠“先还原字节再重解”这步补救,而这个还原过程一旦遇到不可逆字符就彻底失败。
解决:在最先接触请求的 Filter 里,于读取任何参数之前执行 req.setCharacterEncoding("UTF-8"),同时给 JDBC 连接 URL 加上 characterEncoding=UTF-8,建表时把字符集设为 utf8mb4。三处缺一不可:Filter 少了,请求参数报错;URL 少了,数据库写入报错;表字符集少了,存储和展示不一致。
5.2 数据库连接不释放:一次忘记 close 引发的雪崩
现象:应用刚启动一切正常,运行二十分钟后,随便一个接口都卡住不动,重启后恢复,过一段时间又卡死。
原因:Java 代码里每次用 DriverManager.getConnection 创建连接,用完后没有 close。数据库的最大连接数被占满后,新的请求只能排队等超时。更隐蔽的是,即使写了 close,如果出现在 try 块中途抛异常的分支上,close 根本执行不到。
解决:统一使用连接池(例如手动实现一个池,或直接依赖容器数据源),并在 finally 里释放连接。连接的 close 方法在池化场景下不是真正断开数据库连接,而是“归还”到池中,所以每个 Request 的处理都必须保证归还,否则池里的可用连接会越借越少。从 Java 7 开始的 try-with-resources 写法能把释放逻辑收敛到一行代码中,是这类资源里最值得优先改造的点。
5.3 Ajax 拿不到 JSON:返回类型和响应头各错一半
现象:前端用 fetch 或 XHR 异步请求 /api/timeline,控制台报解析错误,后端日志没异常,MySQL 查询也正常执行。
原因:Servlet 往 response 里写 JSON 字符串时,既没有设置 Content-Type,又顺手返回了一个 JSP 页面。浏览器收到的是 text/html 类型,把它当网页处理,前端却按 JSON 解析,自然解析失败。另一个常见原因是在 Servlet 里写了 resp.sendRedirect,把异步请求整个重定向到登录页,前端拿到的是登录页的 HTML 片段。
解决:返回 JSON 的接口统一设置 resp.setContentType("application/json;charset=UTF-8"),用第三方工具完成对象到 JSON 字符串的序列化,同时避免在异步接口里使用重定向。前端 fetch 里也要加上请求头声明,并在 then 里判断 res.ok 再 res.json()。两个方向各退半步,问题就能定位到具体哪一侧。
5.4 Session 突然丢失登录态:容器重启和浏览器策略各有一套逻辑
现象:后端代码一改,重启应用服务器,所有用户被踢下线;用户用无痕窗口打开应用,每次都要重新登录。
原因:HttpSession 默认存在容器内存里,不是存在数据库或磁盘文件中。容器一重启,内存清空,所有 JSESSIONID 对应的会话记录全部消失。无痕窗口则是浏览器不保留会话级 Cookie 的表现,因为容器默认没有给 JSESSIONID 设置 Max-Age,Cookie 只存活在浏览器进程内存里,窗口一关就没了。
解决:先分清楚这是“预期行为”还是“产品缺陷”。个人微博项目里,容器重启导致掉线可以接受,因为会话数据量小、重建成本低。如果产品要求“记住我”七天免登录,就要用持久化的登录令牌方案:登录成功后生成一个随机 token 存数据库,把 token 写进带 Max-Age 的 Cookie 里,后续请求用 token 换取用户身份。这个方案绕开了 Session 内存存储的边界,代价是每次请求多一次数据库查询。
5.5 时间线深翻页越来越慢:LIMIT/OFFSET 的代价被低估
现象:前几页的微博秒开,翻到三十页后接口耗时从 80ms 涨到 800ms。
原因:数据库要 LIMIT 20 OFFSET 580,就得从最新的那条开始,一路数过前六百条,再丢掉前五百八十条。翻得越深,数据库做的工作越多,而且这些被丢掉的行没法走索引加速。
解决:改用键集分页。前端把当前页最后一条微博的时间或 id 传回后端,SQL 改成 WHERE w.created_at < ? ORDER BY w.created_at DESC LIMIT 20。这个条件下,数据库能直接利用索引定位到指定位置,随后顺序读取,不管翻到多深,扫描范围都固定在一页大小内。注意时间字段在有数据重复时可能出现同一秒内多条记录被跳过的问题,稳妥做法是使用 id 作为排序键,因为 id 在主键索引里天然单调递增、单调且唯一。
6. 从这门课的设计取舍再看现代的 Java Web:手写链路的价值在框架封装之后反而更清晰
普通 Web 开发者现在写接口,习惯是框架一键生成:前端发请求,后端写一个 controller 方法,加几个注解,返回 JSON,完事。看起来和 Servlet 时代天差地别,但对照前几章的链路,你会发现 Spring 这类框架做的每一件事,都是在用更抽象的方式重新表达 Servlet 规范里已有的机制:@RequestMapping 替代了 web.xml 里的 URL 映射,拦截器替代了 Filter 链,JdbcTemplate 替你把 PreparedStatement 的创建、参数绑定、ResultSet 遍历收进了一行代码,而控制器方法拿到 HttpSession 参数的过程,本质上仍是容器在调用链里解析并注入会话对象。
所以我一直觉得,先手写一遍 Servlet、Service、DAO 三层,再去看框架,效率是最高的。你踩过的乱码、连接池耗尽、会话丢失、分页变慢,每一个坑对应的都是框架底层帮你封装的某个组件。框架不是魔法,它是把你的血泪经验固化成了一批默认配置。遇到复杂的异常时,如果脑子里没有这条手写链路,就只能对着堆栈瞎猜。
验证这套理解最直接的方法:打开浏览器开发者工具,观察从输入 URL 到页面渲染完成的网络请求时间线,找到那个异步加载时间线的请求,点开它的请求头,找到 Cookie 里的 JSESSIONID,再去服务器日志里找到那次请求对应的 SQL 日志,你可以亲眼看到 session 里的 userId 被拼进了哪个查询条件。
从那以后,我每接手一个 Java Web 项目,第一件事不是跑起来,而是先把它的请求链路图画一遍:哪个 Filter 先执行、哪个 Servlet 处理、Session 从哪里写入又在何处读、数据库连接的生命周期覆盖在哪个调用范围内。这张图画清楚了,项目里百分之八十的诡异问题都有了定位方向。希望这份从请求到会话再到数据的完整链路拆解,能帮你在跑通这份资源时,少走几步当年我走过的弯路。
本文还有配套的精品资源,点击获取