Servlet+JSP博客系统:从Maven工程到Tomcat部署的完整实践
2026/9/16 13:52:05 网站建设 项目流程

简介:这是一套基于Servlet+Maven+HTML/CSS/JavaScript实现的个人博客系统完整源码包,附带数据库设计文件,适合Java Web初学者、毕业设计选题者以及希望从零理解博客类产品研发流程的开发者。项目涵盖了Servlet请求处理与会话管理、Maven依赖配置与生命周期、JDBC数据库访问、RESTful风格API、前端页面布局与异步交互等核心知识点,实际包含用户注册登录、文章发布与编辑、评论互动、分类归档等典型功能模块,可帮助读者建立从前端表单提交到后端业务处理再到数据库持久化的完整认知。压缩包共1121个文件,类型以JS、HTML、CSS为主,另有Java源码、SQL脚本、Maven工程配置、静态资源与可直接部署的WAR包,合计约24.15MB,目录结构规范,便于按需检索学习。已有117人学习浏览,可直接用于课程设计、项目实战或毕业设计参考,也是理解Servlet与前端三件套协作方式的实用样例。

1. 一个 Servlet + JSP 博客系统到底在解决什么问题

很多刚接触 Java Web 的开发者第一次拿到这类博客系统源码时,第一反应是「这不就是个老掉牙的 CRUD 吗」。但如果从工程角度拆开看,它恰好把 Servlet、Maven、前端三件套和关系型数据库之间的协作关系完整串了起来,而这几条链路恰恰是现在很多框架封装之后大家很少再能亲手碰到的部分。这个项目没有 Spring Boot 的自动装配,没有 MyBatis 的动态代理,一切请求解析、路由分发、参数封装、数据库读写都是显式写在代码里的,反而是理解 Java Web 底层运行机制的很好标本。对于正在补 Servlet 基础、准备面试容器相关知识点,或者需要一个能改能跑的课设模板的人来说,这套源码配合数据库文件比看一百页文档更直观。

2. Maven 工程结构与 Servlet 请求分发链路

2.1 先看清 pom.xml 里到底依赖了什么

用 IDE 打开项目后第一件事不是跑代码,而是先看pom.xml。这个项目的依赖管理方式决定了它能在什么环境里编译、打出来的包能不能被 Tomcat 正确加载。常见做法是最小化依赖集合,也就是只声明 Servlet API、JSP API 和 JDBC 驱动,其他工具类能省则省。

<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.26</version> </dependency> </dependencies>

这段配置里有几个需要说清楚的细节。javax.servlet-api的 scope 是provided,含义是这个依赖在编译时需要,但最终打包进 WAR 时会被排除掉,因为 Tomcat 自己已经内置了 Servlet 容器实现,如果强行打进包里反而可能和容器版本冲突。mysql-connector-java是运行时驱动,必须跟着 WAR 走,否则部署到没有这个 jar 的机器上会直接报ClassNotFoundException: com.mysql.cj.jdbc.Driver。注意这里用的是com.mysql.cj.jdbc.Driver而不是老版本的com.mysql.jdbc.Driver,这是 MySQL 8.x 驱动和 5.x 驱动最直观的区别。

2.2 Servlet 生命周期与请求分发方式

项目的核心是若干个继承自HttpServlet的类,比如UserServletBlogServlet。很多人对 Servlet 的理解停留在「重写 doGet 和 doPost」,但这里面有一个关键点:Servlet 实例是单例的,由 Tomcat 创建和管理,开发者写的类只是提供了处理逻辑的模板。生命周期从构造方法开始,然后是init()初始化,之后每一个 HTTP 请求都会触发service()方法,由它按照请求类型分流到doGetdoPost,最后容器销毁时调用destroy()

下面是这个博客项目里典型的 Servlet 路由写法,按照action参数做方法级分发,而不是为每个功能单独写一个 Servlet。项目里出现UserDao.classBlogDao.class这种反编译产物,说明源码里 DAO 层是独立存在的,Servlet 只做 HTTP 层的解析和响应。

@WebServlet("/blog") public class BlogServlet extends HttpServlet { private BlogDao blogDao = new BlogDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("list".equals(action)) { List<Blog> blogs = blogDao.findAll(); req.setAttribute("blogs", blogs); req.getRequestDispatcher("/index.jsp").forward(req, resp); } else if ("detail".equals(action)) { int id = Integer.parseInt(req.getParameter("id")); Blog blog = blogDao.findById(id); req.setAttribute("blog", blog); req.getRequestDispatcher("/blogDetail.jsp").forward(req, resp); } else { resp.sendError(404); } } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String action = req.getParameter("action"); if ("add".equals(action)) { String title = req.getParameter("title"); String content = req.getParameter("content"); blogDao.insert(title, content); resp.sendRedirect(req.getContextPath() + "/blog?action=list"); } } }

这段代码展示了两个值得注意的点。第一,@WebServlet("/blog")是 Servlet 3.0 之后引入的注解式注册方式,省去了在web.xml里写<servlet><servlet-mapping>两段配置的麻烦,如果项目里的 Servlet 都用了注解,那意味着运行环境至少要支持 Servlet 3.0 规范,也就是 Tomcat 7 以上。第二,doGet里处理查询请求后用的是forward而不是redirectforward是服务器内部跳转,地址栏不变,request域里的属性可以直接在 JSP 中通过 EL 表达式取到;doPost里新增成功后用的redirect则是为了避免表单重复提交的问题,浏览器会发起一次全新的 GET 请求。这两种跳转方式的语义差异是这个项目里最值得花时间理解的地方。

2.3 Maven 目录结构和打包时容易踩的坑

博客项目的标准 Maven 目录结构是这样的:

src/main/java Java 源码 src/main/resources 配置文件 src/main/webapp JSP、HTML、CSS、JS、web.xml

mvn clean package打包后生成 WAR 文件,放到 Tomcat 的webapps目录下即可。但这里有一个新手常犯的错误:把数据库配置文件db.properties放在src/main/java下面,编译后虽然会出现在 classes 目录里,但会被 IDE 和 Maven 的过滤规则处理得行为不一致。更稳妥的做法是放在src/main/resources下,然后通过ClassLoader.getResourceAsStream读取,因为这样打包后文件会在 WEB-INF/classes 下,不依赖相对路径。

配置 Maven 时还容易卡在仓库下载上,国内访问中央仓库经常超时,一般都会在settings.xml里配置阿里云镜像。

<mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <name>阿里云公共仓库</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>

这段配置的作用是把所有从中央仓库拉取的依赖请求转发到阿里云的镜像地址,速度提升非常明显。如果用的是 JDK 8 以上版本,还需要检查 maven-compiler-plugin 的 source 和 target,否则默认编译级别可能是 1.5,导致List<Blog>这类泛型语法直接编译失败,报错信息是「无效的目标发行版」。

3. 数据库表设计与 JDBC 数据访问层

3.1 博客系统的表结构怎么划分

个人博客系统一般最少需要三张表:用户表、文章表、评论表。文章和用户是多对一关系,评论和文章是多对一关系。建表 SQL 是这个项目里自带的部分,直接导入数据库即可,但理解字段为什么这么设计同样重要。

CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(64) NOT NULL, `nickname` VARCHAR(50), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `blog` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `title` VARCHAR(200) NOT NULL, `content` TEXT, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT `fk_blog_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `comment` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `blog_id` INT NOT NULL, `username` VARCHAR(50), `content` VARCHAR(500), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT `fk_comment_blog` FOREIGN KEY (`blog_id`) REFERENCES `blog`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字段设计上有一个容易忽略但很重要的点:user表里只存了usernamepassword,没有存用户的个人简介、头像地址这类扩展信息,原因很简单,博客系统的核心是文章内容,用户信息只要满足登录鉴权即可,把表设计得足够窄,可以减少后续查询时的 IO 开销。blog.contentTEXT类型而不是VARCHAR,是因为VARCHAR在 MySQL 里的最大长度受行大小限制,存一篇几千字的博客很容易触顶,TEXT类型则可以存储 64KB 的文本,配合utf8mb4字符集对中文支持也友好。外键约束在这个项目里是真实存在的,但实际开发中,尤其是数据量上来之后,很多团队会主动去掉外键,把约束逻辑放到应用层去做,为的是减少数据库在插入和删除时的额外校验开销。不过作为课程设计和入门项目,保留外键能让表关系更直观。

3.2 JDBC 连接与 DAO 层封装思路

数据访问层在博客项目里的呈现方式通常是BlogDaoUserDao两个类,里面全是手写的 JDBC 代码。连接获取方式一般有两种:一种是每个 DAO 方法里直接DriverManager.getConnection,另一种是抽一个DBUtil工具类统一管理连接和释放。这个项目里如果能看到DBUtil.class相关文件,那就是第二种方式。

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/blog_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }

这里的serverTimezone=Asia/Shanghai参数很关键,MySQL 8.x 驱动默认要求指定时区,不写的话连接时会直接报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized的异常,这个报错里乱码部分其实是「中国标准时间」的 GBK 编码被当成 UTF-8 解析导致的。useSSL=false是因为本地开发环境一般没有配置 SSL 证书,MySQL 8.x 驱动默认尝试建立 SSL 连接,不关掉会多一次握手开销,而且可能因为证书问题抛警告。characterEncoding=utf8保证中文字符在传输过程中不会被转成乱码。

3.3 PreparedStatement 为什么是必须的

DAO 层查询文章列表的代码是这个项目里最高频出现的片段:

public List<Blog> findAll() { List<Blog> blogs = new ArrayList<>(); String sql = "SELECT id, user_id, title, content, create_time FROM blog ORDER BY create_time DESC"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Blog blog = new Blog(); blog.setId(rs.getInt("id")); blog.setUserId(rs.getInt("user_id")); blog.setTitle(rs.getString("title")); blog.setContent(rs.getString("content")); blog.setCreateTime(rs.getTimestamp("create_time")); blogs.add(blog); } } catch (SQLException e) { e.printStackTrace(); } return blogs; }

这段代码使用了 try-with-resources 语法,ConnectionPreparedStatementResultSet三个资源会在 try 块结束后自动关闭,省去了在 finally 里手动close()的样板代码。PreparedStatement相比Statement的优势不只是预编译,更关键的是它使用?占位符传参,可以避免 SQL 注入。比如用户登录时,如果用字符串拼接 SQL,输入admin' or '1'='1就能绕过密码校验,而用ps.setString(1, username)传参后,参数会被当成纯粹的字符串值,单引号是数据而不是 SQL 语法。项目里搜索文章功能如果用到了LIKE查询,应该写成WHERE title LIKE ?然后传入"%" + keyword + "%",而不是拼进 SQL 里。

这个小节涉及的 Java 源码在实际运行中对应数据库连接与查询的核心链路,也是理解BlogDao.class反编译产物内容的基础,很多同学拿到的源码压缩包里同时有.java.class文件,说明作者在打包时把编译输出也一起放进去了,不影响项目本身的运行和阅读。

4. 前端页面渲染与 JavaScript 交互改造

4.1 HTML + CSS 的页面骨架与博客卡片布局

博客系统的前端页面是这个项目里最直观的部分,主要文件是index.jsp(或index.html)、blogDetail.jsplogin.jsp和注册页面。需要注意的是,如果用的是 JSP 后缀,本质上还是 HTML 结构,但里面可以嵌入 Java 代码片段和 JSTL 标签。这个项目的页面布局一般会是一个顶部导航栏、左侧或中间的文章列表区域、右侧的个人信息和分类栏。

CSS 负责的是视觉呈现,其中博客列表的卡片式布局是最常见的实现目标。这里给出一段典型的卡片样式和鼠标移入交互效果:

.blog-card { border: 1px solid #e1e4e8; border-radius: 8px; padding: 20px; margin-bottom: 16px; transition: box-shadow 0.3s ease, transform 0.3s ease; } .blog-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); transform: translateY(-2px); } .blog-card .title { font-size: 20px; font-weight: 600; color: #24292e; } .blog-card .meta { font-size: 13px; color: #6a737d; margin-top: 8px; } .blog-card .title a { text-decoration: none; color: inherit; }

这里的transition属性定义了box-shadowtransform两个属性在发生变化时的过渡动画时长,配合:hover伪类选择器实现了鼠标移入时卡片轻微浮起的效果。伪类选择器与普通类选择器的区别在于,它匹配的是元素的特定状态而不是静态的 class 属性值,hover就是鼠标悬停状态,还有active(点击按下)、focus(获得焦点)等状态。transform: translateY(-2px)把整个卡片向上平移 2 像素,视觉上产生立体感。transitionease是缓动函数,表示先快后慢的过渡节奏,0.3s是过渡持续时长,太短了动画会显得生硬,太长了会影响操作反馈的及时性。

4.2 JavaScript 表单校验与异步交互改造

JavaScript 在这个项目里承担的职责一般有两个递进层次。第一个层次是表单校验,比如登录页和发表文章页,在提交前先检查用户名是否为空、密码长度是否达标、文章标题是否填了。这个逻辑用原生 JS 写并不复杂,但要注意在onsubmit事件里返回false才能阻止表单提交。

const form = document.getElementById('loginForm'); form.addEventListener('submit', function (event) { const username = document.getElementById('username').value.trim(); const password = document.getElementById('password').value.trim(); if (username === '') { alert('用户名不能为空'); event.preventDefault(); return; } if (password.length < 6) { alert('密码长度不能少于6位'); event.preventDefault(); return; } });

value.trim()的作用是去除用户输入首尾的空白字符,避免用户只输入空格时校验失效。event.preventDefault()是阻止浏览器执行默认的表单提交动作,这样当校验不通过时页面不会刷新,用户能站在原地看到提示并修改。需要注意的是,前端校验只能提升用户体验,它不能替代后端的鉴权校验,因为请求完全可以通过 Postman 之类的工具直接构造,绕过页面脚本,所以UserServlet的逻辑里不论前端是否校验,后端都必须重新做一遍参数判空和长度校验。

第二个层次是 AJAX 异步加载,很多改进版的博客系统会把「删除文章」「点赞」「加载更多评论」这类操作改为异步请求。用原生的fetchAPI 就可以实现,不需要引入 jQuery 或其他框架:

async function deleteBlog(id) { const confirmed = confirm('确定要删除这篇博客吗?'); if (!confirmed) return; const response = await fetch('blog?action=delete&id=' + id, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); if (response.ok) { const result = await response.json(); if (result.code === 1) { document.getElementById('blog-' + id).remove(); } else { alert(result.message); } } }

这段代码里的fetch默认返回的是一个 Promise,await会等待 HTTP 响应到达后继续向下执行。response.json()是把后端返回的 JSON 字符串解析成 JavaScript 对象,对应后端BlogServlet里应该返回类似{"code":1,"message":"删除成功"}的结构。这里需要注意的一个坑是:后端如果返回的是 JSP 页面而不是 JSON,response.json()会直接解析失败抛异常,所以在实际项目中,处理异步请求的 Servlet 方法应该使用resp.setContentType("application/json;charset=UTF-8")然后out.write(jsonString),而不能forward到 JSP。document.getElementById('blog-' + id).remove()是 DOM 操作,服务器端数据库删除成功后,前端直接从页面移除对应的卡片元素,这样就实现了无刷新更新页面,用户体验比整页重新加载流畅很多。

4.3 CSS 伪类选择器与 JS 的差异化应用场景

把 CSS 和 JS 放在一起看,能更清楚它们的边界在哪里。CSS 伪类选择器负责的是「视觉状态反馈」,比如鼠标移入卡片时变色、输入框获得焦点时边框高亮、分页按钮在当前页时显示不同底色。这些效果不需要 JS 参与,浏览器在渲染时就完成了状态匹配。而 JS 处理的是「业务逻辑和动态数据」,比如从后端拉取文章列表、根据用户权限决定是否显示删除按钮、计算阅读时间并格式化显示。

项目里面阅读时间这个细节可以写一个纯 JS 逻辑来处理,因为文章内容长度是后端渲染进 HTML 里的,前端拿到后统计文字数量再换算成分钟数,就不需要后端额外存字段:

const content = document.getElementById('blogContent'); const text = content.innerText; const minutes = Math.max(1, Math.ceil(text.length / 200)); document.getElementById('readTime').textContent = '预计阅读 ' + minutes + ' 分钟';

Math.ceil是向上取整,保证任何非空文章至少显示阅读时间为 1 分钟。中等阅读速度大约每分钟 200 字到 300 字,这里取 200 作为换算基准比较保守。这个逻辑如果放在后端用 Java 做也可以,但每请求一次文章详情就统计一次文字长度其实没有必要,纯前端计算把 CPU 开销分摊到了用户浏览器上,也减少了服务端的响应时间。

5. Tomcat 部署常见报错与「能跑」之后的改进入手点

5.1 启动到访问全链路排错

拿到源码后第一次部署,最常见的错误几乎都集中在几个固定环节,按访问顺序排查效率最高。

404 错误,先确认访问路径。项目如果部署在webapps/blog目录下,那访问地址应该是http://localhost:8080/blog/,后面的/blog是上下文路径,对应 WAR 包的文件名。如果直接把项目文件放在了webapps/ROOT下,那就直接用http://localhost:8080/访问。这一步搞混了,404 是必然的。

ClassNotFoundException: com.mysql.cj.jdbc.Driver,说明 MySQL 驱动 jar 没有出现在WEB-INF/lib目录下。用 IDEA 的 Artifacts 配置时注意勾选Include in project build,或者重新执行mvn clean package让依赖打进 WAR。检查方法是解压 WAR 文件,直接看WEB-INF/lib下面有没有 mysql 开头的 jar 包。

数据库连接超时,典型报错是Communications link failureAccess denied for user。前者一般是数据库没启动、端口不是 3306、或者连接串里的 IP 写错,后者是用户名密码对不上。连接串里serverTimezone参数写错也会导致连接失败,报错信息会明确提示Unrecognized time zone

Tomcat 端口占用,启动时重复报Port 8080 required by Tomcat v9.0 Server at localhost is already in use。Windows 下用netstat -ano | findstr 8080查出 PID,然后taskkill /F /PID 进程号。注意改了 Tomcat 端口后,数据库连接串里的localhost:3306不需要改,因为那是 MySQL 的端口,和 Tomcat 不是同一个东西。

5.2 从一个能跑的博客到稍微工程化的改造

这个博客系统作为模板跑通之后,有价值的改动方向其实比功能迭代更值得做。第一个是引入日志,现在代码里全是e.printStackTrace(),改成 SLF4J + Logback 之后,错误输出会带上时间戳和堆栈层级,排错效率能提升一个量级。第二个是数据库连接池,当前DBUtil每次请求都新建一个物理连接,改成 HikariCP 或 Druid 后,连接复用会让并发下的响应时间明显下降。

HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/blog_db?useSSL=false&serverTimezone=Asia/Shanghai"); config.setUsername("root"); config.setPassword("123456"); config.setMaximumPoolSize(10); config.setMinimumIdle(2); HikariDataSource dataSource = new HikariDataSource(config);

maximumPoolSize是连接池允许的最大连接数,minimumIdle是空闲时保持的最小连接数。这两个参数不是越大越好,连接数超过数据库的处理能力后反而会堆积等待,10 和 2 的组合对于个人博客这种低并发场景已经足够,实际压测后可以按观察到的活跃连接数再调整。

最后一个值得做的改动是把密码明文改成 MD5 加盐或 BCrypt 哈希存储。当前项目里user表直接存的是明文密码,这在本地课设里没什么问题,但放到任何一个公开环境都等于裸奔。改动点集中在UserDao的注册和登录两个方法,登录时把用户输入的密码做同样的哈希变换后去数据库比对,数据库里永远不存还原原文的信息。这个改动不涉及前端页面,只改后端逻辑,是这个项目所有改造中投入产出比最高的一项。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询