Servlet+JSP+JDBC 个人微博应用全链路实战 简介面向Java Web初学者与课程实践者的个人微博应用完整项目包对应Web原理及应用实验场景围绕会员管理、个人中心与消息发布管理三个核心模块搭建可运行的小型社交平台。包内共71个文件、约3.33MB以java源文件与编译后的class为主配合jsp页面、xml配置、css样式与图片资源另有MySQL数据库文件及少量jar依赖覆盖前端界面、后端服务与数据存储的完整链路。已有224人学习下载。读者可据此梳理Servlet与JSP的请求处理流程、用户注册登录与权限校验思路、RESTful接口与CRUD设计方式以及微博动态的发布、展示与检索实现也可借助工程目录理解Eclipse项目的结构组织与配置文件作用适合作为课程实验参考、二次开发起点或面试前的实战复盘材料。1. 从一条动态的发布链路说起个人微博应用到底能干什么刚学完 Servlet 和 JSP 的人最常见的状态是每个知识点都懂但让你从零搭一个能注册、能登录、能发内容、能看列表的站手就停了。这份「Web原理及应用个人微博应用」就是用来补这个缺口的——它不是一个炫技项目而是把 HTTP 请求、会话管理、请求转发与重定向、JSP 作用域、JDBC 增删改查这几件事串成一条完整的个人微博应用链路前端是原生 HTML/CSS/JS 加 JSP 渲染后端是 Servlet Service DAO 三层数据落在 MySQL 里跑在 Tomcat 上。它能干的事很具体注册登录、发一条短动态、按时间倒序刷自己的主页、点进别人的主页看他的动态、关注和取关、删掉自己发错的那条、给动态写一条评论。适合两类人一类是要交课程设计或者做课程验收需要一个结构说得清、代码看得懂、答辩讲得出的完整工程另一类是自己写过几个 Servlet demo但从来没处理过登录态、分页、中文乱码这些真实问题想找个能跑通的参照物。这类不套框架的项目反而是理解 Web 原理最直接的路子。2. 技术栈与工程骨架一个 /post 请求从浏览器到 MySQL 的四层链路选型这块先说清楚因为很多人拿到项目第一反应是「怎么没用 Spring Boot」。这个项目的定位就是课程里的 Web 原理实践用 Servlet 3.1 JSP 2.3 JDBC MySQL 5.7/8.0 Tomcat 8.5/9前端不引入任何构建工具CSS 和 JS 直接script标签引。好处是所有东西都裸露在外面请求怎么进来、参数怎么取、会话存在哪、页面怎么转发全都没有容器帮你藏起来。2.1 不用框架的选择Servlet JSP JDBC 各自负责什么Servlet 负责接收 HTTP 请求、取参数、做基本校验、决定是转发到 JSP 还是重定向JSP 只负责渲染尽量不写 Java 业务代码取数据靠 request/session 作用域里现成的属性Service 层做业务编排比如「发动态前先检查内容长度和用户状态」以及开事务DAO 层里全是 SQL一条 SQL 一个方法不掺业务判断Model 是和表字段一一对应的 POJO。这个分层要说清边界否则写着写着就变成「Servlet 里直接调 DAO 再拼 HTML」改一个字段要翻五个文件。我自己的习惯是Servlet 里不允许出现Connection、PreparedStatementDAO 里不允许出现HttpServletRequest。这两条守住后面加功能才有地方下手。各层职责对照层典型类允许做的事禁止做的事controllerPostServlet取参、校验、调 service、转发/重定向写 SQL、开连接servicePostService业务规则、事务边界碰 request/responsedaoPostDao拼 SQL、映射 ResultSet判断登录态modelPost、User承载字段写业务方法2.2 目录结构与包划分controller / service / dao / model 四层怎么切工程按 Maven 的标准 webapp 结构组织即使你手动建 Dynamic Web Project也建议照着这个结构摆后面打包、换 IDE 都不折腾。weibo-web/ ├── src/main/java/com/example/weibo/ │ ├── controller/ # ServletLoginServlet、PostServlet、ProfileServlet、FollowServlet │ ├── service/ # 业务编排UserService、PostService、FollowService │ ├── dao/ # SQL 集中地UserDao、PostDao、FollowDao、CommentDao │ ├── model/ # 实体User、Post、Comment、PageResult │ ├── filter/ # EncodingFilter、LoginFilter │ └── util/ # JdbcUtils、PasswordUtil、DateUtil ├── src/main/resources/ │ ├── db.properties # 数据库连接参数不打进 jar只放 classpath │ └── schema.sql # 建表 初始化数据 └── src/main/webapp/ ├── WEB-INF/ │ ├── views/ # 所有 jsp 放这里外部不能直接访问 │ └── web.xml ├── static/css|js|img └── index.jsp # 唯一的入口跳转页JSP 放WEB-INF/views是关键一步。放在外面的话用户直接敲/views/post-list.jsp就能绕过 Servlet 拿到一个没有数据的空页面甚至看到页面里的调试信息。放进去之后只有服务端RequestDispatcher.forward()能到达访问路径和渲染逻辑就锁死了。2.3 请求注册与参数接收web.xml 和 WebServlet 两条路Servlet 注册有两条路web.xml里配servletservlet-mapping或者在类上打WebServlet注解。项目里两种都能看到注解的写法更省事但web.xml更适合需要统一调整顺序和初始化参数的场景。下面这个是发布动态的核心 ServletWebServlet(name postServlet, urlPatterns /post) public class PostServlet extends HttpServlet { // Service 无状态可以复用不要在这里存和请求相关的成员变量 private final PostService postService new PostService(); private final FollowService followService new FollowService(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 列表页?page1size10page 从 1 开始越界时回退到 1 int page parsePage(req.getParameter(page)); Long userId (Long) req.getSession().getAttribute(uid); req.setAttribute(pageResult, postService.timeline(userId, page, 10)); req.getRequestDispatcher(/WEB-INF/views/post-list.jsp).forward(req, resp); } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { Long userId (Long) req.getSession().getAttribute(uid); String content req.getParameter(content); String error postService.publish(userId, content); if (error ! null) { // 校验失败用 session 传一次性错误提示重定向回列表 req.getSession().setAttribute(flash, error); } // PRG发布完重定向刷新页面不会重复插入 resp.sendRedirect(req.getContextPath() /post); } private int parsePage(String raw) { try { int p Integer.parseInt(raw); return p 1 ? 1 : p; } catch (Exception e) { return 1; } } }urlPatterns /post是精确匹配别图省事写成/*那会把静态资源和 JSP 全部拦下来最后 CSS 加载不出来、图片 404排查半天以为是路径问题。doGet走转发是因为要带数据渲染 JSPdoPost走重定向是为了 PRGPost-Redirect-Get避免浏览器刷新触发重复提交。parsePage里对空值和负数做了兜底分页参数是最容易被手动改坏的地方。还有一点Servlet 默认是单实例多线程postService这种无状态对象做成员变量没问题但千万别把Connection、User这类跟单次请求绑定的东西挂到成员变量上否则两个用户同时发动态就会串数据这种 bug 在本地单人测试时永远复现不出来。3. 数据模型与持久层四张表撑起发布、关注和时间线微博类应用看着功能多剥掉界面其实就四张表用户、动态、关注关系、评论。表设计得好不好直接决定后面时间线查询是一条 SQL 还是三次嵌套循环。3.1 表结构设计user / post / follow / comment 的字段与索引CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL COMMENT 登录名唯一, password_hash CHAR(64) NOT NULL COMMENT SHA-256 加盐后的十六进制串, nickname VARCHAR(32) NOT NULL COMMENT 展示名, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像相对路径, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE post ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, content VARCHAR(500) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 0 正常 1 已删, PRIMARY KEY (id), KEY idx_user_time (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE follow ( id BIGINT NOT NULL AUTO_INCREMENT, follower_id BIGINT NOT NULL COMMENT 关注发起人, followee_id BIGINT NOT NULL COMMENT 被关注人, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_pair (follower_id, followee_id), KEY idx_followee (followee_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE comment ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, user_id BIGINT NOT NULL, content VARCHAR(200) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_post (post_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个字段值得单独说。password_hash用CHAR(64)存 SHA-256 十六进制串注册时hash sha256(salt password)盐存不存另说但一定不要明文入库——这是课程项目最容易被挑的毛病。post.deleted用逻辑删除而不是DELETE因为删除后评论和点赞的关联数据还在物理删除会留一堆孤儿行。follow上的uk_pair唯一索引是防重复关注的关键比如用户狂点关注按钮两次请求同时到达靠应用层先查后插是挡不住的数据库唯一约束才是最后一道闸。post的联合索引(user_id, created_at)对应「查某人主页按时间倒序」顺序不能反反过来对时间线查询基本没用。3.2 JDBC 工具类与 DAO 基类连接、释放、事务三件事JDBC 直连最容易翻车的不是 SQL 写错是连接泄漏本地跑一天没事部署到测试机两天后 Tomcat 报Too many connections。工具类就干三件事——拿连接、还连接、包一层事务。public final class JdbcUtils { private static final String URL; private static final String USER; private static final String PASSWORD; static { Properties p new Properties(); try (InputStream in JdbcUtils.class.getClassLoader() .getResourceAsStream(db.properties)) { p.load(in); URL p.getProperty(jdbc.url); USER p.getProperty(jdbc.user); PASSWORD p.getProperty(jdbc.password); Class.forName(p.getProperty(jdbc.driver)); } catch (Exception e) { // 静态块里失败就直接炸别拖到第一次查询才报错 throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } /** 在同一个连接上跑一段逻辑异常回滚正常提交最后一定归还 */ public static T T inTransaction(SqlFunctionT work) { Connection conn null; try { conn getConnection(); conn.setAutoCommit(false); T result work.apply(conn); conn.commit(); return result; } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignore) { } } throw new RuntimeException(e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignore) { } } } } FunctionalInterface public interface SqlFunctionT { T apply(Connection conn) throws Exception; } }db.properties里通常这么写jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/weibo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.userroot jdbc.password你的密码inTransaction的入参是Connection意味着 DAO 方法有两种签名一种自己拿连接只读查询一种接收外部连接参与事务。发布动态要同时写post表、可能还要更新用户动态计数这种就必须用后者否则两个 DAO 各拿一条连接事务根本管不住。finally里先把autoCommit恢复再close是因为如果用连接池还回去的连接会带着上一次的状态下一个拿到它的人就莫名处于事务里。至于连接池项目里可以直接DriverManager但稍微认真一点我会换成 Druid 或 HikariCP理由很朴素DriverManager每次查询都新建 TCP 连接一次时间线请求要查动态、查用户、查关注状态三次握手就够呛压测一轮下来延迟全在连接建立上。换池子只改getConnection()一个方法DAO 一行不动。3.3 时间线 SQL把「我关注的 我自己的」按时间合并个人主页好办WHERE user_id ?走索引就完了。麻烦的是首页时间线要显示「我自己发的 我关注的人发的」按时间倒序还要分页。SELECT p.id, p.content, p.created_at, u.nickname, u.avatar FROM post p JOIN user u ON u.id p.user_id WHERE p.deleted 0 AND (p.user_id ? OR p.user_id IN (SELECT followee_id FROM follow WHERE follower_id ?)) ORDER BY p.created_at DESC, p.id DESC LIMIT ? OFFSET ?ORDER BY created_at DESC, p.id DESC里的第二个排序字段不是多余DATETIME精确到秒批量插入的测试数据时间戳完全相同只按时间排翻页时会出现同一条记录在两页都出现或者干脆消失的玄学现象加主键兜底就能稳定。LIMIT ? OFFSET ?在数据量小的时候没感觉几千条以后越翻越慢因为 OFFSET 是先扫再丢。真要优化改成游标分页——记住上一页最后一条的created_at和id下次查WHERE (created_at, id) (?, ?)代价是不能跳页只能「加载更多」。分页结果用PageResultT装字段就四个list、page、size、total。total单独一条SELECT COUNT(*)别在列表 SQL 里用SQL_CALC_FOUND_ROWSMySQL 8 已经废弃了而且它在大表上比两条 SQL 还慢。DAO 里映射 ResultSet 时created_at用rs.getTimestamp(created_at).toLocalDateTime()别用getDate()那玩意会把时分秒吞掉页面上所有动态都显示成同一天。4. 前端与接口联调JSP 首屏渲染加原生 JS 异步发布前端这块没有框架全靠 JSP 拼首屏 原生fetch做局部刷新。听上去土但好处是每一行都能对应到具体的 HTTP 行为调起来心里有底。4.1 页面拆分include 指令拼出公共头部和侧栏页面结构就三个登录注册页、时间线列表页、个人主页。头部导航、侧边栏、底部脚本在每个页面都重复用 JSP 的静态包含拆出去% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 % % include file/WEB-INF/views/common/header.jspf % main classtimeline form idpublishForm classpublish-box methodpost action${pageContext.request.contextPath}/post textarea namecontent maxlength500 placeholder说点什么/textarea button typesubmit发布/button /form c:forEach items${pageResult.list} varpost article classpost-item>// ctx 由 JSP 注入window.APP_CTX ${pageContext.request.contextPath} const form document.getElementById(publishForm); const box form.querySelector(textarea); form.addEventListener(submit, async (e) { const content box.value.trim(); if (!content) { alert(内容不能为空); e.preventDefault(); return; } // 支持异步才拦截否则让浏览器走原生提交 if (!window.fetch) return; e.preventDefault(); const btn form.querySelector(button); btn.disabled true; // 防连点重复发布 try { const resp await fetch(window.APP_CTX /post, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded;charsetUTF-8 }, body: new URLSearchParams({ content }), credentials: same-origin // 带上 JSESSIONID否则后端拿不到登录态 }); if (resp.redirected || resp.ok) { location.reload(); // 简单起见整页刷新保证分页和总数一致 } else { alert(发布失败请稍后重试); } } catch (err) { alert(网络异常); } finally { btn.disabled false; } });Content-Type必须是application/x-www-form-urlencoded因为后端req.getParameter(content)只认这个格式和multipart/form-data你发application/json过去参数全是 null还查不出原因。credentials: same-origin也别省同源请求默认就会带 cookie但一旦你以后把前端拆出去跨域不加这行 Session 就断了。btn.disabled那个细节值得单独提从点击到响应回来有几百毫秒用户手快连点两下就是两条一模一样的动态。前端禁用按钮是第一层后端唯一约束或者短时间内的内容去重是第二层只做一层都不够。4.3 登录态与权限Session Filter 的两道闸登录成功后往 session 里塞uid和nickname之后每个需要登录的接口都从 session 取用户绝不信任前端传上来的userId——哪怕参数名藏得再深。WebFilter(urlPatterns {/post, /profile, /follow, /comment}) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; Object uid req.getSession().getAttribute(uid); if (uid ! null) { chain.doFilter(request, response); return; } String target req.getRequestURI(); String query req.getQueryString(); String back URLEncoder.encode(query null ? target : target ? query, UTF-8); resp.sendRedirect(req.getContextPath() /login?redirect back); } }过滤器只拦写操作和需要身份的页面登录页、注册页、静态资源不在urlPatterns里否则会出现「登录页自己把自己重定向到登录页」的死循环。redirect参数是为了登录完能回到原来那页URLEncoder编码不能少?id3tabpost里的不编码重定向后参数会被截断成两个。过滤器顺序也要注意编码过滤器必须排在登录过滤器前面否则重定向地址里的中文昵称会变乱码。用注解注册的多个 Filter执行顺序按类名字母序靠不住涉及顺序的就在web.xml里显式写filter-mapping先后一目了然。5. 常见问题排查本地跑不起来的 5 个坑这个项目本身不复杂但环境、编码、事务这三类问题能让它在你机器上「明明代码一样就是跑不起来」。下面五条是我见过的最高频的。5.1 环境与依赖类坑一页面能开一发动态就报ClassNotFoundException: com.mysql.cj.jdbc.Driver。现象是首页正常提交表单后 500日志里一长串找不到驱动类。原因通常是驱动 jar 只在 IDE 的 Build Path 里加了没放进WEB-INF/lib——IDE 编译时能找到部署到独立的 Tomcat 时类加载器只认WEB-INF/lib和 Tomcat 的lib于是运行时找不到。还有一种情况是版本对不上MySQL 8.x 的驱动类名是com.mysql.cj.jdbc.Driver还写老的com.mysql.jdbc.Driver会先警告再报错。解决办法两步把mysql-connector-java的 jar 复制到src/main/webapp/WEB-INF/lib下db.properties里写新类名重新部署再试。坑二控制台刷The server time zone value ... is unrecognized。现象是所有数据库操作全部失败异常信息里带时区字样。原因是驱动 8.x 默认要拿到服务端时区而 MySQL 返回的时区标识 Java 不认。改连接串就行在 URL 后面补serverTimezoneAsia/Shanghai同时顺手把useSSLfalse加上本地没开 SSL 的话每次连接都会刷一条警告日志里全是噪音真出问题的时候反而看不见。5.2 数据与编码类坑三发布的内容是中文存进数据库变成问号或者乱码。现象分两种页面上直接显示????或者显示成测试这样的串。前者说明数据库连接或列字符集不是utf8mb4后者说明某一段用了 ISO-8859-1 解码。排查顺序固定先看建表语句的DEFAULT CHARSETutf8mb4再看连接串的characterEncodingutf8再看EncodingFilter里request.setCharacterEncoding(UTF-8)是否在第一次getParameter之前执行——只要先取过参数后面再设编码就无效了这是 Servlet 规范定死的。解决办法是把编码过滤器配在web.xml最前面JSP 里有pageEncodingUTF-8响应头也设resp.setContentType(text/html;charsetUTF-8)。提示getParameter之后设编码无效这一条是中文乱码排查里最容易走弯路的地方先看过滤器顺序再看别处。坑四新增的数据列表里查不到重启后又出现了。现象很典型发完动态页面刷新看不到但用客户端连数据库能查到新行。原因是 Service 里开了setAutoCommit(false)某条分支没走到commit就返回了或者事务方法里 catch 住异常吞掉连接被归还但事务没提交MySQL 在连接断开时回滚。解决方式是所有事务统一走JdbcUtils.inTransaction不在业务方法里手写 commit/rollbackDAO 里用 try-with-resources 管PreparedStatement和ResultSet把Connection的关闭权交给事务方法谁开谁关。5.3 逻辑与并发类坑五刷新页面就多一条一样的动态。现象是发布成功后地址栏还停在 POST 的地址上按 F5 浏览器弹「确认重新提交表单」点确定就再插一条。原因是doPost里直接forward到列表页浏览器记住的最后一个请求还是 POST。解决办法就是前面代码里的 PRG处理完sendRedirect到doGet的地址。如果业务上不能容忍任何重复再加一次性 token——进页面时生成 UUID 塞 session表单带隐藏域提交时比对并立刻从 session 移除。还有一类不是重复提交是url-pattern写错导致的 404静态资源全挂在/static/下如果某个 Servlet 映射成了/*请求先被它接走CSS 返回的是一段 HTML浏览器控制台报 MIME 类型不对。看到样式丢了先去看web.xml和注解里的urlPatterns比翻 CSS 快得多。6. 二次开发把单机微博改成带关注流的小型社交应用拿到这个工程之后最值钱的改造方向不是换框架而是把「单表增删改查」补成「有真实数据关系」的功能。下面这几个改造按难度排前两个改动小、见效快适合先热身改造点涉及文件关键动作动态配图post加image_url、PostServlet加 multipart 分支用MultipartConfig接管上传文件存到 webapp 外用 Servlet 输出流读点赞新建like表唯一索引(post_id, user_id)点赞/取消用INSERT IGNORE和DELETE回传最新计数时间线游标分页PostDao.timeline把LIMIT OFFSET换成(created_at, id) (?, ?)换连接池JdbcUtils.getConnection引入 Druid初始化DataSource其余代码不动我拿「点赞」举例因为它把唯一约束、幂等、计数回传三件事一次性讲清楚了// LikeServlet#doPost点赞与取消都走同一个接口靠 action 参数区分 WebServlet(/like) public class LikeServlet extends HttpServlet { private final LikeService likeService new LikeService(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { long postId Long.parseLong(req.getParameter(postId)); long uid (Long) req.getSession().getAttribute(uid); boolean like 1.equals(req.getParameter(action)); // service 内部对同一对 (postId, uid) 做幂等重复点赞不报错、计数不变 int count likeService.toggle(postId, uid, like); resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write({\count\: count ,\liked\: like }); } }toggle里点赞用INSERT IGNORE INTO user_like(post_id, user_id) VALUES(?,?)取消用DELETE两条语句都靠唯一索引保证幂等用户狂点也不会把计数点炸。计数不建议每次COUNT(*)动态量大了以后这个查询会拖垮列表页可以在post表加一列like_count在同一事务里UPDATE ... SET like_count like_count 1读的时候直接取。验证方式也别靠肉眼。改造完至少跑三遍第一遍用两个浏览器或者一个正常窗口一个隐身窗口互相点赞确认计数正确第二遍在点赞接口上手动连点十次确认最终计数是 1第三遍把数据库连接数调到很小看有没有连接泄漏。这三遍走完功能基本就站得住了。从那以后我每次动这类项目的持久层都会先把「一个请求到底开了几条连接、每条在哪关的」在纸上画一遍再动手写代码——早年有一次上线前没画DAO 里嵌套调用各开各的连接压测十分钟数据库就满了那个下午的后悔药我到现在还记得味道。希望这些内容能帮你少走一段弯路。本文还有配套的精品资源点击获取