news 2026/9/11 21:47:12

Spring Boot个人博客系统实战:从零搭建四层架构

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot个人博客系统实战:从零搭建四层架构

简介:这是一套基于Spring Boot开发的完整个人博客系统源码,面向Java初学者、课程设计学生及毕业设计开发者,解决从零搭建功能完备博客系统的实践难题。资源包含544个文件,涵盖96个Java业务逻辑类、116个MyBatis映射XML、56个Thymeleaf前端模板HTML、24个JS交互脚本及16个CSS样式文件,辅以MySQL建表SQL、配置properties与README说明,包体大小9.86MB,结构清晰、分层明确,便于理解MVC架构与前后端协作流程。已有1948人学习下载,适合作为Spring Boot项目实战范例。读者可直接导入IDEA(JDK1.8+MySQL 8.0)运行,快速掌握用户端(博客展示、归档、友链、关于)与管理端(文章发布、评论审核、分类标签、文件上传、系统设置)双模块开发要点,并通过预览中的Controller、VoExample、工具类(如DateKit、TaleUtils)深入学习RESTful接口设计与通用组件封装实践。

1. 为什么一个“基于 Spring Boot 的个人博客系统”仍是 Java 后端新人最值得动手的练手项目?

不是因为功能多炫酷,而是它精准卡在「够用、可控、可延展」的黄金交点上:它不依赖第三方 SaaS 服务,所有核心链路——用户注册登录、文章 CRUD、分类标签管理、富文本编辑、静态资源托管、数据库增删改查——全部由你亲手串联;它天然适配 Spring Boot 四层架构(Controller-Service-Repository-Entity),让你在写@PostMapping的同时,自然理解分层职责边界;它对数据库的要求明确且典型(MySQL 或 H2),既需要建表设计(用户表、文章表、分类表、评论表),又必须处理一对多(用户→文章)、多对多(文章↔标签)等真实关系;更重要的是,它不涉及分布式事务或高并发压测,但会真实暴露@Transactional失效、N+1 查询、JSON 序列化循环引用、文件上传路径配置错误等高频问题。如果你刚学完 MyBatis 和 Thymeleaf,这个项目就是你把零散知识点焊成肌肉记忆的第一块钢板——它不教你“怎么成为架构师”,但它能让你清楚说出:“我写的每行代码,落在哪一层、调了哪个 SQL、返回了什么 JSON”。


2. 从零初始化:用 Spring Initializr 搭建符合四层架构规范的脚手架

2.1 选型依据:为什么放弃 JPA 而坚持 MyBatis-Plus + MySQL 原生组合

Spring Boot 官方推荐 JPA,但对个人博客这类以查询为主、需精细控制 SQL 的场景,MyBatis-Plus 更具实操优势:

  • 可调试性:SQL 直接写在 XML 或@Select注解中,出错时能立刻定位到具体语句,而非陷入 Hibernate 的org.hibernate.exception.GenericJDBCException黑盒;
  • 灵活性:博客首页需按发布时间倒序、按分类筛选、按关键词模糊搜索,这些动态条件用 MyBatis 的<if>标签组合清晰直观,JPA Criteria API 则易写成嵌套冗长的CriteriaBuilder链式调用;
  • 性能感知:MyBatis-Plus 的Page<T>分页插件底层仍走LIMIT offset, size,而某些 JPA 实现(如早期 Hibernate)在offset过大时会全表扫描,新人难以察觉。

提示:不要用 H2 内存数据库做最终部署。它适合单元测试(启动快、无依赖),但无法模拟 MySQL 的GROUP BY排序行为、TEXT字段索引限制、datetime时区处理等真实约束,建议开发阶段即用 Docker 启动 MySQL 8.0 容器。

2.2 初始化工程:通过命令行快速生成标准目录结构

执行以下命令(需提前安装spring-boot-cli):

spring init \ --dependencies=web,thymeleaf,mybatis-plus-boot-starter,mysql,validation,lombok,devtools \ --package-name=com.example.blog \ --name=personal-blog-system \ --type=maven-project \ --java-version=17 \ personal-blog-system

该命令生成的目录严格遵循 Spring Boot 目录规范:

  • src/main/java/com/example/blog/下自动创建controller/service/mapper/entity/config/子包;
  • src/main/resources/包含application.yml(非 properties,YAML 支持层级缩进,更易读)和mapper/文件夹(存放 XML 映射文件);
  • src/main/resources/static/托管 CSS/JS,templates/托管 Thymeleaf HTML 模板。
2.2.1 关键配置项解析:application.yml 中必须显式声明的 3 个参数
spring: datasource: url: jdbc:mysql://localhost:3306/blog_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false # 开发阶段禁用模板缓存,修改 HTML 立即生效 suffix: .html mybatis-plus: mapper-locations: classpath:mapper/**/*.xml # 指向 XML 文件位置,避免扫描失败 configuration: map-underscore-to-camel-case: true # 自动将 user_name → userName,省去 @Results 映射
  • serverTimezone=Asia/Shanghai是 MySQL 8.0+ 强制要求,否则datetime字段插入为0000-00-00 00:00:00
  • map-underscore-to-camel-case: true让 MyBatis-Plus 自动转换下划线命名字段(如create_timecreateTime),避免在每个@Select中手动AS
  • mapper-locations必须精确到**/*.xml,若写成mapper/*.xml则子包(如mapper/article/ArticleMapper.xml)无法被加载。

2.3 数据库建表:用 DDL 脚本定义博客核心实体关系

在 MySQL 中执行以下建表语句(已适配 MySQL 8.0+):

-- 用户表 CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL, -- BCrypt 加密后长度约 60 `email` VARCHAR(100), `avatar_url` VARCHAR(255), `status` TINYINT DEFAULT 1, -- 1:启用, 0:禁用 `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 分类表 CREATE TABLE `category` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL, `description` VARCHAR(200), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 文章表 CREATE TABLE `article` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL, `content` LONGTEXT NOT NULL, -- 存储富文本 HTML `summary` VARCHAR(500), -- 文章摘要,用于列表页预览 `user_id` BIGINT NOT NULL, `category_id` BIGINT, `status` TINYINT DEFAULT 1, -- 1:发布, 0:草稿, -1:删除 `view_count` INT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE, FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON DELETE SET NULL ); -- 标签表 CREATE TABLE `tag` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL UNIQUE ); -- 文章-标签关联表(多对多) CREATE TABLE `article_tag` ( `article_id` BIGINT NOT NULL, `tag_id` BIGINT NOT NULL, PRIMARY KEY (`article_id`, `tag_id`), FOREIGN KEY (`article_id`) REFERENCES `article`(`id`) ON DELETE CASCADE, FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON DELETE CASCADE );

注意:article.content使用LONGTEXT而非TEXT,因富文本编辑器(如 TinyMCE)生成的 HTML 可能超 65535 字节;ON DELETE CASCADE确保删除文章时自动清理关联的article_tag记录,避免外键残留。


3. 核心功能落地:实现文章发布与分类检索的完整闭环

3.1 Controller 层:定义 RESTful 接口并校验请求参数

@RestController @RequestMapping("/api/articles") @RequiredArgsConstructor public class ArticleController { private final ArticleService articleService; // 发布新文章(POST /api/articles) @PostMapping public Result<Article> createArticle(@Valid @RequestBody ArticleCreateDTO dto) { Article saved = articleService.create(dto); return Result.success(saved); } // 按分类 ID 查询文章列表(GET /api/articles?categoryId=1&page=1&size=10) @GetMapping public Result<Page<Article>> listByCategory( @RequestParam(required = false) Long categoryId, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { Page<Article> result = articleService.listByCategory(categoryId, page, size); return Result.success(result); } }
  • @Valid触发ArticleCreateDTO中的@NotBlank@Size等注解校验,失败时自动返回400 Bad Request
  • @RequestParam(defaultValue = "1")避免page参数为空导致PageRequest.of(0, size)报错(Spring Data Page 索引从 0 开始,但用户习惯从 1 开始);
  • Result<T>是自定义统一响应包装类,包含codemessagedata字段,强制前端处理业务异常(如code=5001表示分类不存在)。
3.1.1 DTO 设计原则:为什么不用 Entity 直接接收前端参数

ArticleCreateDTOArticleEntity 必须分离:

@Data public class ArticleCreateDTO { @NotBlank(message = "标题不能为空") @Size(max = 200, message = "标题不能超过200字") private String title; @NotBlank(message = "内容不能为空") private String content; private String summary; private Long categoryId; @NotNull(message = "请选择分类") private Long userId; // 前端传入当前登录用户ID,后端不信任前端token }
  • userId不从 JWT Token 解析,而由前端在请求头或 body 中显式传递,后端校验其有效性(防止越权);
  • @Size校验title长度,避免VARCHAR(200)字段被超长字符串截断;
  • summary允许为空,由后端逻辑自动生成(取content前 200 字符),DTO 中不加@NotBlank

3.2 Service 层:封装业务逻辑与事务边界

@Service @RequiredArgsConstructor public class ArticleService { private final ArticleMapper articleMapper; private final CategoryMapper categoryMapper; private final ArticleTagMapper articleTagMapper; @Transactional(rollbackFor = Exception.class) public Article create(ArticleCreateDTO dto) { // 1. 校验分类是否存在 if (dto.getCategoryId() != null && categoryMapper.selectById(dto.getCategoryId()) == null) { throw new BusinessException("分类不存在"); } // 2. 构建 Article 实体 Article article = new Article(); article.setTitle(dto.getTitle()); article.setContent(dto.getContent()); article.setSummary(StringUtils.substring(dto.getContent(), 0, 200)); // 截取摘要 article.setUserId(dto.getUserId()); article.setCategoryId(dto.getCategoryId()); article.setStatus(1); // 默认发布状态 // 3. 插入文章主表 articleMapper.insert(article); // 4. 处理标签(假设 dto.tagNames = ["Java", "Spring"]) if (CollectionUtils.isNotEmpty(dto.getTagNames())) { List<Tag> tags = tagMapper.selectBatchTags(dto.getTagNames()); List<ArticleTag> relations = tags.stream() .map(tag -> new ArticleTag(article.getId(), tag.getId())) .collect(Collectors.toList()); articleTagMapper.insertBatch(relations); } return article; } }
  • @Transactional保证插入文章和关联标签原子性,任一环节失败则全部回滚;
  • StringUtils.substring()来自 Apache Commons Lang,安全截取字符串(String.substring()遇空指针直接抛异常);
  • selectBatchTags()是自定义批量查询方法,避免 N+1:先查出所有标签 ID,再一次性INSERT INTO article_tag,而非对每个标签循环insert

3.3 Mapper 层:用 XML 实现动态 SQL 与关联查询

ArticleMapper.xml中定义按分类查询的动态 SQL:

<select id="listByCategory" resultType="com.example.blog.entity.Article"> SELECT a.id, a.title, a.summary, a.view_count, a.create_time, c.name AS categoryName FROM article a LEFT JOIN category c ON a.category_id = c.id <where> a.status = 1 <!-- 只查已发布文章 --> <if test="categoryId != null and categoryId != 0"> AND a.category_id = #{categoryId} </if> </where> ORDER BY a.create_time DESC LIMIT #{page}, #{size} </select>
  • <where>标签自动处理AND前缀,避免WHERE AND ...语法错误;
  • #{page}使用预编译参数防 SQL 注入,LIMIT #{page}, #{size}page = (pageNum-1)*size,由 MyBatis-Plus 的Page对象自动计算;
  • resultType指向Article实体,c.name AS categoryName通过 MyBatis 的驼峰映射规则自动注入到Article.categoryName字段(需在application.yml中开启map-underscore-to-camel-case)。

4. 前端集成与文件上传:Thymeleaf 模板渲染与富文本图片存储

4.1 Thymeleaf 模板:用 fragment 复用头部与分页组件

templates/layout.html定义公共布局:

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title th:text="${title} ?: '个人博客'">博客首页</title> </head> <body> <header th:fragment="header"> <nav> <a href="/">首页</a> <a href="/categories">分类</a> <a href="/admin/login">后台</a> </nav> </header> <main th:fragment="content"> <!-- 页面内容占位 --> </main> <footer th:fragment="footer"> <p>&copy; 2024 个人博客系统</p> </footer> </body> </html>

templates/index.html继承并填充内容:

<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout}"> <head> <title>博客首页</title> </head> <body> <div layout:fragment="content"> <h1>最新文章</h1> <div th:each="article : ${page.records}"> <h2 th:text="${article.title}">文章标题</h2> <p th:text="${article.summary}">摘要</p> <small>发布时间:<span th:text="${#dates.format(article.createTime, 'yyyy-MM-dd')}">2024-01-01</span></small> <a th:href="@{/article/{id}(id=${article.id})}">阅读全文</a> </div> <!-- 分页组件 --> <div th:replace="~{fragments/pagination :: pagination(page=${page}, url='/')}"></div> </div> </body> </html>
  • th:each遍历Page<Article>.records#dates.format()格式化日期,避免在 Java 层拼接字符串;
  • th:replace引入fragments/pagination.html,实现分页链接动态生成(/page/2/page/3)。

4.2 富文本图片上传:本地存储方案与路径安全处理

@Controller @RequestMapping("/upload") @RequiredArgsConstructor public class UploadController { private final String uploadDir = "src/main/resources/static/uploads/"; @PostMapping("/image") @ResponseBody public Result<String> uploadImage(@RequestParam("file") MultipartFile file) { try { // 1. 校验文件类型 String contentType = file.getContentType(); if (!"image/jpeg".equals(contentType) && !"image/png".equals(contentType)) { return Result.fail("仅支持 JPG/PNG 格式"); } // 2. 生成唯一文件名(避免中文乱码与冲突) String originalName = file.getOriginalFilename(); String extension = StringUtils.substringAfterLast(originalName, "."); String fileName = UUID.randomUUID().toString() + "." + extension; // 3. 保存到 static/uploads/ 目录(Thymeleaf 可直接访问) Path uploadPath = Paths.get(uploadDir); if (!Files.exists(uploadPath)) { Files.createDirectories(uploadPath); } Path targetPath = uploadPath.resolve(fileName); file.transferTo(targetPath); // 4. 返回可访问的 URL(注意:static 目录下资源无需 controller 映射) return Result.success("/uploads/" + fileName); } catch (IOException e) { return Result.fail("上传失败:" + e.getMessage()); } } }
  • static/uploads/是 Spring Boot 默认静态资源路径,返回/uploads/xxx.jpg即可通过浏览器直接访问;
  • UUID.randomUUID()保证文件名全局唯一,防止恶意覆盖(如上传../web.xml);
  • transferTo()getBytes()更省内存,适合大文件;
  • 前端 TinyMCE 配置images_upload_url: "/upload/image",即可自动调用此接口。

5. 生产就绪关键配置:解决数据库连接池、日志与 Actuator 安全问题

5.1 HikariCP 连接池调优:3 个必设参数防止连接耗尽

application.yml中添加:

spring: datasource: hikari: connection-timeout: 30000 # 获取连接超时时间(毫秒) maximum-pool-size: 20 # 最大连接数,根据 MySQL max_connections 设置 minimum-idle: 5 # 最小空闲连接数,避免频繁创建销毁 idle-timeout: 600000 # 空闲连接存活时间(毫秒) max-lifetime: 1800000 # 连接最大生命周期(毫秒),小于 MySQL wait_timeout
  • max-lifetime必须小于 MySQL 的wait_timeout(默认 28800 秒),否则连接被 MySQL 主动断开后,HikariCP 无法感知,后续请求报Connection closed
  • maximum-pool-size不宜过大:MySQL 默认max_connections=151,若应用集群有 3 个实例,则单实例设50较安全;
  • idle-timeout配合minimum-idle,确保低峰期释放多余连接,节省数据库资源。

5.2 日志分级与 SQL 输出:用 logback-spring.xml 精确控制 MyBatis 日志

src/main/resources/logback-spring.xml配置:

<?xml version="1.0" encoding="UTF-8"?> <configuration> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <!-- 仅在 dev 环境输出 MyBatis SQL --> <springProfile name="dev"> <logger name="com.example.blog.mapper" level="DEBUG" additivity="false"> <appender-ref ref="CONSOLE"/> </logger> </springProfile> <root level="INFO"> <appender-ref ref="CONSOLE"/> </root> </configuration>
  • com.example.blog.mapper包下的日志级别设为DEBUG,可看到Preparing: SELECT * FROM article WHERE id = ?及绑定参数;
  • springProfile标签确保生产环境(prod)不输出 SQL,避免敏感信息泄露;
  • additivity="false"防止日志重复打印(父 logger 已输出)。

5.3 Actuator 安全加固:关闭未授权访问风险端点

management: endpoints: web: exposure: include: health,info,metrics,prometheus # 仅暴露必要端点 endpoint: health: show-details: when_authorized # 健康检查详情需认证 security: roles: ACTUATOR # 需 ACTUATOR 角色才能访问
  • exposure.include显式声明开放端点,*全部开放是严重安全隐患;
  • show-details: when_authorized防止/actuator/health泄露数据库连接状态等内部信息;
  • 结合 Spring Security,为/actuator/**路径配置hasRole('ACTUATOR'),并通过UserDetailsService加载管理员账号。

提示:若使用 Spring Boot 3.x,需额外添加spring-boot-starter-actuator依赖,并确认management.endpoints.web.base-path=/actuator(默认值)。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 21:47:07

ASP.NET招投标系统源码解压部署与二次开发实战指南

简介&#xff1a;基于ASP.NET的招投标系统毕业设计源码&#xff0c;面向使用C#语言从事网站开发的初学者、即将进行毕业设计的高校学生以及企业信息化建设人员&#xff0c;系统围绕招标公告发布、投标文件提交、评标过程跟踪等核心业务&#xff0c;展示了一套具备完整流程的Web…

作者头像 李华
网站建设 2026/9/11 21:46:53

C#上位机可拖拽ROI实现:坐标换算、手柄绘制与像素读取

简介&#xff1a;这是面向C# WinForm开发者的图像显示与图形绘制示例工程&#xff0c;解决在图像控件中实时显示、缩放平移、绘制和调整ROI区域以及读取鼠标位置坐标与RGB值等问题。工程基于SnsPictureBox自定义控件&#xff0c;输入接口支持Bitmap、byte[]和IntPtr地址&#x…

作者头像 李华
网站建设 2026/9/11 21:46:45

MapReduce 核心原理与调优实战:从分片到 Shuffle 的完整链路解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华