简介:这是一份面向Java Web入门与课程实践学习者的「Web原理及应用:个人微博应用」完整项目包,适合正在学习前端与Java Web整合开发、需要参照一个可运行小型社交应用来理解分层结构与模块划分的读者,可用于课程实验对照、功能拆解与二次练习。压缩包共71个文件,约3.33MB,以22个java源文件与24个class编译文件为主体,另有5个jsp页面、3个xml配置、2个css样式表与3个jar依赖包,并包含Eclipse工程配置文件、数据库文件及图片资源,覆盖会员管理、个人中心与消息发布管理等核心模块,可据此观察Servlet与JSP的请求处理流程、前端页面与样式组织方式以及工程目录结构。该资源已有224人学习下载,可作为理解Web请求响应机制、体会前后端协作与工程配置的实践样本,帮助读者把HTTP、HTML、CSS与Java后端处理串联起来对照理解。
1. 从一份 Java Web 课设源码说起:个人微博应用能跑通哪些环节
很多人对“Web 原理及应用”这门课的记忆,是背 HTTP 状态码和画三层架构图。直到要交一个能注册登录、能发帖、能看关注流、能点赞的个人微博应用,才发现那些知识点是散的:请求怎么进到 Servlet、会话靠什么维持、前端怎么把后端的列表渲染出来,全靠自己拼。这份源码包做的事很朴素:用一个 Java Web 单体应用,把请求响应、会话管理、JDBC 读写、前端渲染串成一条真正能跑起来的链路。它适合正在做课程设计的人照着复现,也适合纯前端转后端的开发者,用来搞明白一次点击到底穿过了哪些层、哪一层最容易翻车。下面按我实际拆包的顺序讲,重点放在参数怎么配、失败时看哪里。
2. 拆开这份源码:四层分层与五张表的数据模型
2.1 目录结构与分层边界
拿到包先别看代码,先看目录。这类个人微博应用的典型结构是标准的 Maven war 工程,src/main/java下按职责分包,src/main/webapp放页面和静态资源,WEB-INF/views里是 JSP,WEB-INF/lib或pom.xml里挂 JDBC 驱动和 JSTL。我一般先确认三件事:有没有pom.xml(决定能不能脱离 IDE 一键打包)、web.xml的版本号(决定能不能用@WebServlet注解)、静态资源是不是放在WEB-INF外面(放里面浏览器直接访问不到,这是新手最容易忽略的一点)。
| 层 | 典型包名 | 职责 | 常见的越界行为 |
|---|---|---|---|
| 控制层 | controller/servlet | 取参、校验、调服务、决定跳转还是返回 JSON | 直接在 Servlet 里写 SQL |
| 服务层 | service | 业务规则、事务边界、组装视图数据 | 把HttpServletRequest往下传 |
| 数据层 | dao | SQL 执行、结果集映射、连接释放 | 在这里做权限判断 |
| 模型层 | model/entity | 纯 POJO,字段与表列对应 | 塞进 session 或做业务计算 |
| 公共层 | util/filter | 连接池、编码过滤器、登录拦截 | 过滤器里吞异常不打日志 |
为什么这份资源选择“原生 Servlet + JSP”而不是上来就 Spring Boot?因为课设的验收环境往往是一台装好 Tomcat 的机器,只要一个 war 丢进webapps就能跑,不需要额外端口、不需要额外启动类。代价是你得自己处理连接池、编码、事务,这些恰恰是“Web 原理”这门课最该弄明白的部分。常见做法是把连接池封装成一个DBUtil单例,启动时读jdbc.properties,用ThreadLocal或者干脆每次从连接池取连接、用 try-with-resources 归还。
分层是否干净的判断标准很实际:把controller包整个删掉,服务层和 DAO 层还能不能编译通过。能,说明耦合度是合格的;不能,说明业务逻辑漏到 Servlet 里了,后面加一个“转发微博”功能就得改三处。
2.2 五张表撑起一个微博
数据模型是这份源码里最值得先读的部分。一个能演示的个人微博,最少需要用户、微博、关注关系、点赞关系四张业务表;再加一张会话或令牌表可选。建表时有两个参数必须一开始就定对:字符集用utf8mb4而不是utf8(后者在 MySQL 里其实是三字节,存表情符号会直接报错,而微博内容恰恰最爱带表情),时间字段用DATETIME并统一由应用层或数据库默认值生成,别混用时间戳。
CREATE DATABASE IF NOT EXISTS weibo_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE TABLE t_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL, password_hash CHAR(64) NOT NULL, -- SHA-256(明文 + salt) 的十六进制 salt CHAR(16) NOT NULL, nickname VARCHAR(32) NOT NULL, avatar VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_post ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, content VARCHAR(280) NOT NULL, -- 280 字符上限,服务端必须再校验一次 origin_id BIGINT UNSIGNED DEFAULT NULL COMMENT '转发时指向原微博', status TINYINT NOT NULL DEFAULT 1 COMMENT '1 正常 0 已删除', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_time (user_id, created_at), KEY idx_time (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_follow ( follower_id BIGINT UNSIGNED NOT NULL COMMENT '关注者', followee_id BIGINT UNSIGNED NOT NULL COMMENT '被关注者', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (follower_id, followee_id), -- 联合主键天然去重 KEY idx_followee (followee_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_like ( user_id BIGINT UNSIGNED NOT NULL, post_id BIGINT UNSIGNED NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, post_id), KEY idx_post (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;设计上有三处取舍值得说。第一,关注表和点赞表用联合主键而不是自增 id,是为了让“重复关注”“重复点赞”在数据库层面直接被拒掉,不依赖应用层先查再插——先查再插在并发下必然出重复。第二,t_post建了(user_id, created_at)复合索引,因为个人主页按人查、按时间倒序是最热的查询;再单独建(created_at)支撑全局时间线。第三,status用逻辑删除而不是物理删除,转发场景下原微博被删、转发还在,物理删除会把关系打断,这是很多课设演示时“转发卡片变空白”的根因。
content用VARCHAR(280)在 utf8mb4 下是 280 个字符而非字节,中文和表情都按一个字符算,符合直觉。但要注意 MySQL 行长度限制,如果后面想加VARCHAR(2000)的正文再配多个索引,可能撞上 65535 字节的行上限,那时要改成TEXT并把索引只建在前缀上。
2.3 一次“发微博”请求穿过的四层
把链路拉直看一遍,比读十页架构文档有用。浏览器提交表单 → 编码过滤器设 UTF-8 → 登录拦截器判断 session → Servlet 取参校验 → Service 开事务 → DAO 执行 insert 并回填主键 → 返回 JSON 或重定向到列表页。下面这段是控制层的真实写法,重点看三行注释所在的位置。
@WebServlet("/post/publish") public class PublishServlet extends HttpServlet { private final PostService postService = new PostService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 编码必须早于任何 getParameter,否则参数已经按 ISO-8859-1 解完了 req.setCharacterEncoding("UTF-8"); resp.setContentType("application/json;charset=UTF-8"); User login = (User) req.getSession().getAttribute("loginUser"); if (login == null) { resp.setStatus(401); resp.getWriter().write("{\"code\":401,\"msg\":\"not login\"}"); return; } String content = req.getParameter("content"); // 2. 服务端二次校验:长度按字符算,不是字节 if (content == null || content.trim().isEmpty() || content.trim().length() > 280) { resp.getWriter().write("{\"code\":400,\"msg\":\"content invalid\"}"); return; } // 3. 讲师演示时最爱问的一句:这里为什么不用自己拼 SQL long id = postService.publish(login.getId(), content.trim(), req.getParameter("originId")); resp.getWriter().write("{\"code\":0,\"id\":" + id + "}"); } }服务层负责事务和副作用,比如发布成功后给粉丝推送一条站内通知、更新用户的微博计数。这里最容易写错的是把事务开在 DAO 里:同一个业务要写两张表时,两次commit之间出错就会留下脏数据。正确做法是 Service 拿到连接、关掉自动提交、执行完统一提交,异常时回滚,最后在 finally 里把连接还给池子而不是close(池化连接的close语义是归还,但自己 new 出来的Connection一定要真关)。
参数这一层还有两个容易忽略的点。originId传空字符串和传null是两回事,Long.parseLong("")会直接抛NumberFormatException,所以要么在 Servlet 里判空,要么用包装类型接收后统一转。返回体里带上新微博的id,前端才能做“局部插入”而不是整页刷新,这是后面第 4 章做异步化的前提。
3. 把环境跑起来:JDK、Tomcat、MySQL 的版本对齐
3.1 版本组合与踩坑概率
这类老派 Java Web 工程对版本极其敏感,尤其是javax.servlet与jakarta.servlet的分水岭。跑不起来的情况里,八成不是代码错,是版本没对齐。下面这张表是我在几台机器上试出来的组合,从左到右踩坑概率递增。
| 组件 | 稳妥组合 | 可用但要注意 | 高风险 |
|---|---|---|---|
| JDK | 8 | 11(需确认编译级别) | 17+(javax.*全部失效) |
| Tomcat | 8.5 | 9.0 | 10+(Servlet 5.0,包名换成jakarta) |
| MySQL | 5.7 / 8.0 | 8.0 需用com.mysql.cj.jdbc.Driver | 5.5(时区与默认字符集差异) |
| 连接池 | Druid / HikariCP | DBCP2 | 手写静态连接(并发即死) |
判断依据很直接:看pom.xml里 servlet-api 的groupId是javax.servlet还是jakarta.servlet,前者最高只能配 Tomcat 9。另外 servlet-api 的 scope 必须是provided,打进 war 里会和容器自带的包冲突,表现为启动时报ClassCastException或者监听器不生效,这个坑后面第 5 章还会细说。
3.2 建库、建用户与连接配置
不要用 root 连应用,这是习惯问题也是演示时的加分项。先跑建库脚本,再单独建一个只有增删改查权限的账号。
CREATE USER 'weibo_app'@'127.0.0.1' IDENTIFIED BY 'change_me'; GRANT SELECT, INSERT, UPDATE, DELETE ON weibo_demo.* TO 'weibo_app'@'127.0.0.1'; FLUSH PRIVILEGES; -- 演示用初始数据,密码字段留空表示首次登录强制改密 INSERT INTO t_user (username, password_hash, salt, nickname) VALUES ('demo', REPEAT('0', 64), '0000000000000000', '演示账号');连接串的参数比用户名密码重要得多,逐项说明:useUnicode=true&characterEncoding=utf8保证驱动侧按 UTF-8 编解码,注意这里写utf8即可,驱动会映射;serverTimezone不配会在 MySQL 8 上直接抛时区异常,配了才能正常读写DATETIME;useSSL=false是本地演示省掉证书告警;allowPublicKeyRetrieval=true在 MySQL 8 默认加密插件下不加会连不上。
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/weibo_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=weibo_app jdbc.password=change_me jdbc.pool.maxActive=20 jdbc.pool.maxIdle=8 jdbc.pool.maxWaitMillis=3000maxActive=20是给课设演示机器的保守值,本地 Tomcat 默认 200 线程,池子开太大会在压测时把数据库连接数打满,报Too many connections。我一般按“最大线程数 × 单次请求平均占用连接时长”倒推,演示场景 20 足够,真实场景要看 QPS 再调。
3.3 打包、部署与首登验证
打包部署这段建议写成脚本,别依赖 IDE 的绿色三角,因为答辩机器上不一定有你的 IDE。
export JAVA_HOME=/opt/jdk8 export CATALINA_HOME=/opt/tomcat8 cd /path/to/weibo-demo mvn clean package -DskipTests # 产物在 target/*.war $CATALINA_HOME/bin/shutdown.sh 2>/dev/null rm -rf $CATALINA_HOME/webapps/ROOT $CATALINA_HOME/webapps/ROOT.war cp target/weibo-demo.war $CATALINA_HOME/webapps/ROOT.war # 部署为根路径 $CATALINA_HOME/bin/startup.sh tail -f $CATALINA_HOME/logs/catalina.out部署成ROOT.war是为了让访问路径是http://127.0.0.1:8080/而不是http://127.0.0.1:8080/weibo-demo/,页面里的绝对路径才不会全错。如果坚持用工程名做上下文,那前端所有fetch和form action都必须带${pageContext.request.contextPath}前缀,或者统一用一个全局变量注入,漏一处就是 404。
验证顺序也有讲究,别一上来就点登录。先访问一个静态页确认容器活着,再访问一个只查库的测试接口确认数据源通了,最后才走登录。日志里看到连接池初始化完成、没有Communications link failure,基本就成功了一半;剩下一半是编码和 session,第 5 章专门说。
4. 前端怎么改:从 JSP 直出到 Ajax 局部刷新
4.1 列表渲染与分页参数
原始版本的列表是 JSP 里嵌 JSTL 循环,够用但不好改样式。理解它的关键是分页参数的约定:页码从 1 开始,每页条数有默认值和上限,总数单独查一次。三个参数必须在服务端收口,不能让前端随便传,否则size=100000一次就把内存打爆。
-- 时间线:自己 + 关注的人,按时间倒序;limit 由服务端计算,不接受前端原始值 SELECT p.id, p.content, p.created_at, u.id AS uid, u.nickname, u.avatar FROM t_post p JOIN t_user u ON u.id = p.user_id WHERE p.status = 1 AND (p.user_id = #{uid} OR p.user_id IN (SELECT followee_id FROM t_follow WHERE follower_id = #{uid})) ORDER BY p.created_at DESC, p.id DESC LIMIT #{offset}, #{size};offset = (page - 1) * size,size在服务端强制夹在 1 到 50 之间。加p.id DESC作为第二排序键是为了在created_at相同(同一秒发多条)时顺序稳定,否则翻页会出现“同一条重复出现、另一条永远看不到”的玄学现象。深分页在数据量上万后会明显变慢,因为LIMIT 100000, 10要扫描前十万行,改进方式是记住上一页最后一条的created_at + id做游标查询,这个技巧第 6 章会给。
前端渲染这一层,我的建议是保留 JSP 直出作为“不依赖 JS 也能看”的兜底,再叠一层 Ajax 做增量。因为课设演示环境经常遇到浏览器插件拦截脚本,纯 JS 渲染会白屏,而 JSP 出来的 HTML 至少内容在。
4.2 发布框:字符校验与防重复提交
发布框看着简单,实际是前端交互里问题最集中的地方。三件事必须做:空内容拦截、字符数按字符算并给剩余提示、防止用户连点导致重复插入。前两件是体验,第三件是数据一致性。
const box = document.querySelector('#publish-box'); const btn = document.querySelector('#publish-btn'); const ctx = document.body.dataset.ctx || ''; // 由 JSP 注入 contextPath let submitting = false; btn.addEventListener('click', async () => { const content = box.value.trim(); if (!content) return toast('内容不能为空'); if ([...content].length > 280) return toast('超过 280 字符'); // 用展开按码点计数 if (submitting) return; // 第一道闸:同一页面内的连点 submitting = true; btn.disabled = true; try { const res = await fetch(ctx + '/post/publish', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: new URLSearchParams({ content, token: window.__FORM_TOKEN__ }) }); const data = await res.json(); if (data.code === 0) { prependCard(data.id, content); // 局部插入,不整页刷新 box.value = ''; updateCounter(); } else { toast(data.msg || '发布失败'); } } catch (e) { toast('网络异常,请重试'); } finally { submitting = false; btn.disabled = false; } });token是服务端渲染页面时写进 session 的一次性令牌,提交后立刻失效,配合数据库唯一约束才能挡住“双击 + 网络重发”这种情况。只用前端disabled是不够的:用户按 F5 重发上一次请求(表单重复提交的经典场景),或者用接口工具直接调,前端那道闸形同虚设,这也是为什么服务端必须再校验一次。
字符计数这里有个细节:content.length数的是 UTF-16 码元,一个 emoji 可能算 2,用户会觉得“明明没到 280 就提示超了”。用[...content].length按码点算更接近直觉,而数据库VARCHAR(280)在 utf8mb4 下也是按字符算,两边口径才一致。
4.3 点赞与关注的异步化
点赞是典型的高频写操作,改造要点有两个:前端做节流、后端靠唯一约束兜底。前端不是防止“恶意”,而是防止用户手抖连点产生十条请求;后端唯一约束才是数据正确性的最后一道防线。
@WebServlet("/like/toggle") public class LikeServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { User login = (User) req.getSession().getAttribute("loginUser"); resp.setContentType("application/json;charset=UTF-8"); if (login == null) { resp.setStatus(401); return; } long postId; try { postId = Long.parseLong(req.getParameter("postId")); // 参数非法直接退出,别抛到容器 } catch (NumberFormatException e) { resp.getWriter().write("{\"code\":400}"); return; } // INSERT IGNORE + DELETE 的组合,靠联合主键保证幂等 boolean liked = new LikeService().toggle(login.getId(), postId); long count = new LikeService().countByPost(postId); resp.getWriter().write("{\"code\":0,\"liked\":" + liked + ",\"count\":" + count + "}"); } }toggle的语义是“已赞则取消、未赞则点赞”,返回当前状态和最新计数,前端只改那一个数字和按钮样式,不刷新列表。这里返回计数而不是让前端自己+1,是为了避免多端不同步时出现“我这里显示 5、别人那里显示 7”的尴尬。代价是每次点赞多一次 count 查询,量大的时候可以把计数缓存在内存里定期落库,但课设阶段没必要,先保证正确。
关注按钮同理,只是它影响的列表更大,关注成功后通常要把首页时间线重置到第一页,否则新关注的人的微博不会立刻出现,用户会以为没生效。这个交互细节比代码本身更容易被忽略。
5. 避坑与排查:五个真实翻车现场
5.1 请求链路上的三个编码与状态问题
现象一:数据库里存进去的中文全是问号,页面上也是问号。原因通常是三处编码不一致中的任意一处:数据库/表的字符集不是utf8mb4、JDBC 连接串缺characterEncoding、request.setCharacterEncoding("UTF-8")在getParameter之后才调用。第三处最隐蔽,因为代码看着有那行,位置错了等于没写。解决顺序是先用SHOW CREATE TABLE t_post确认表字符集,再抓一次连接的character_set_client,最后把编码收进一个Filter,在doFilter第一行设置,保证任何 Servlet 之前就生效。
现象二:登录成功,点两下就退回登录页。原因多半是 session 丢了:可能是web.xml里配了过短的session-timeout,可能是部署路径变了导致 cookie 的 path 对不上,也可能是前端用fetch时没带凭证——跨路径请求默认不带 cookie,需要显式credentials: 'same-origin'。排查方法是打开浏览器开发者工具看JSESSIONID这个 cookie 是否存在、Path 是不是当前上下文,再对一遍服务端getSession(false)的返回值。
现象三:接口返回 200,页面一片空白。这种多半是返回了 JSON 但前端按 HTML 解析,或者反过来。检查response.setContentType是否在getWriter()之前调用——一旦取了 writer,content-type 就定型了,之后再设无效。另外PrintWriter写完后如果没有 flush,某些容器会截断响应体,表现为“接口通了但数据只有一半”。
5.2 数据一致性上的两个坑
现象四:同一条微博的点赞数越点越多,或者列表里出现重复的关注记录。原因是没有唯一约束,应用层“先查后插”在并发下必然失效。解决是补上联合主键或唯一索引,写入改成INSERT IGNORE(或ON DUPLICATE KEY UPDATE),让数据库来做幂等。补索引前要先清理历史重复数据,否则加约束会直接失败,清理语句用DELETE t1 FROM t_like t1 JOIN t_like t2 ON t1.user_id=t2.user_id AND t1.post_id=t2.post_id AND t1.created_at > t2.created_at这类自连接,删之前一定先SELECT一遍确认。
现象五:分页总数和实际条数对不上,最后一页是空的。根因是 count 查询和 list 查询的 where 条件不一致——list 加了status = 1,count 忘了加;或者 list 用了关注关系过滤,count 只按用户过滤。解决办法是把条件抽成一个方法拼 where 片段,两处复用。另一种情况是COUNT(*)用了LEFT JOIN导致行数放大,改成COUNT(DISTINCT p.id)或者干脆把 count 拆成独立的不带 join 的查询更快。
提示:这五个现象里有四个都能靠“先看日志、再看数据库、最后看浏览器网络面板”这三步定位,顺序别反。先改代码猜原因,往往越改越乱。
6. 进阶:用一条命令把整条链路验一遍
答辩或交付前最怕的不是功能少,是“昨天还好好的,今天打不开”。我习惯写一个冒烟脚本,把注册、登录、发帖、点赞、关注、翻页串成一条命令,跑完看输出就知道哪一环断了。核心是用curl的 cookie 罐维持会话,参数全部显式写出来,不依赖浏览器缓存。
#!/usr/bin/env bash set -e BASE="http://127.0.0.1:8080" JAR=$(mktemp) # 临时 cookie 文件,模拟同一个浏览器会话 curl -s -c "$JAR" -b "$JAR" -X POST "$BASE/user/login" \ -d "username=demo&password=change_me" | grep -q '"code":0' curl -s -c "$JAR" -b "$JAR" -X POST "$BASE/post/publish" \ --data-urlencode "content=冒烟测试 $(date +%s)" | grep -q '"code":0' curl -s -c "$JAR" -b "$JAR" "$BASE/post/list?page=1&size=10" | grep -q '"code":0' curl -s -c "$JAR" -b "$JAR" -X POST "$BASE/like/toggle" -d "postId=1" | grep -q '"liked"' echo "全部通过" rm -f "$JAR"-c写、-b读是同一个 cookie 文件的关键,漏掉任何一个都会变成“每次请求都是新会话”,登录永远不生效——这是我在排查“接口工具里能调通、浏览器里不行”时反复踩过的点。--data-urlencode而不是直接-d,是因为中文内容不编码会变成乱码,服务端收到的就是一堆字节。
验证方法上再补两条。一是把page参数故意传成0和-1,看服务端是纠正到第一页还是直接报错,这个边界决定前端要不要做额外兜底;二是连续发两条同一秒的微博,确认翻页时不重不漏,能过这条,(created_at, id)的双排序键才算是真的写对了。至于深分页,数据量上千之后可以把LIMIT offset, size换成游标方式:上一页最后一条的时间和 id 一起回传,WHERE (created_at, id) < (?, ?),性能和页码无关,改造成本也就是一个参数。
从那以后我每次交付前都强制走一遍这个脚本,不管改动多小,哪怕只改了一个 CSS 类名——因为它拦下过三次“上线才发现连接池配置被覆盖”的翻车。希望帮到你。
本文还有配套的精品资源,点击获取