尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

SpringBoot + MySQL 构建古诗词学习网站:数据建模与查询优化实践

发布时间:2026/9/23 19:49:50

资讯中心
01
ARTICLE

SpringBoot + MySQL 构建古诗词学习网站:数据建模与查询优化实践

SpringBoot + MySQL 构建古诗词学习网站:数据建模与查询优化实践
简介基于 JavaSpringBoot MySQL 构建的古诗词学习网站完整课程设计项目面向 Java Web 方向初学者、毕业设计及课设学生集中解决古诗词检索、分类浏览、详情查看、收藏评论、用户分享与后台管理等多类需求。资源共 596 个文件、约 39.9MB以 Java 源码、前端 HTML/CSS/JS 页面、XML/Properties 配置、SQL 数据库脚本以及大量项目截图为主其中 png 设计图和 jpg 素材有 200 余个便于直接查看页面效果XML、iml、project 等为项目工程与运行配置SQL 脚本用于初始化诗词、诗人、朝代等数据表。项目覆盖用户端和管理员端两大模块用户可浏览不同类别、朝代诗词进入详情页查看全文、注释与翻译支持在线收藏、评论、上传分享、个人信息修改及关键字搜索管理员可管理诗词、诗人、朝代、类别、用户、收藏评论、通知及审核分享内容。已有 950 人学习下载。从内容预览可见后端按 Poetry、Dynasty、Collection、Comment、Notice 等模块分层实现类职责清晰适合作为课程设计参考或 SpringBoot 整合 MySQL 的练手项目可直接导入运行并二次扩展。1. 古诗词学习网站为什么选 SpringBoot MySQL 当底座去年给课程设计做的一个古诗词学习网站核心功能是浏览唐宋诗词、按标题或内容搜索、按作者和朝代筛选。当时在技术选型上没太犹豫SpringBoot 负责 REST APIMySQL 存结构化数据前端用 React 做交互因为这三个组合在中小型项目里成熟度最高资料多、排错容易。原生的 Servlet JDBC 也能做但配置数据库连接、事务、JSON 序列化要写太多样板代码课程设计的周期根本耗不起。SpringBoot 的自动配置让项目能在十分钟内跑起来JPA 又能把建表和对象映射省掉大半。这个组合对刚接触前后端分离的开发者也很友好后端只暴露接口前端不碰 SQL分工清晰。下面从后端建模讲起把接口设计、前端对接、查询优化这些实际开发中一定绕不开的细节过一遍。2. SpringBoot 后端工程搭建与 MySQL 数据建模2.1 初始化 SpringBoot 项目与依赖选型我用 Spring Initializr 生成基础工程Java 版本选了 8SpringBoot 用的 2.7.x。这个版本对应稳定网上踩坑资料多不会像 3.x 那样把 javax 换成 jakarta 导致老教程对不上。依赖选择了 Spring Web、Spring Data JPA、MySQL Driver、Lombok在pom.xml里最关键的是这几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency选 JPA 而不是 MyBatis是因为诗词这类场景的关联关系简单主要就是诗词对诗人和分类的多对一映射JPA 的ManyToOne加OneToMany就能直接操作对象不需要手写 XML。如果你习惯 MyBatis 的动态 SQL也可以换但我的建议是课程设计优先求快JPA 的自动建表和 CRUD 省掉的时间足够多写两个页面。依赖加好后application.yml里配置 MySQL 连接注意 MySQL 8 的驱动类名和时区设置spring: datasource: url: jdbc:mysql://localhost:3306/poetry_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true这里的ddl-auto: update会按实体自动建表或更新列开发期很方便但生产环境绝对要关掉改成validate或手动迁移。show-sql: true会把 Hibernate 生成 SQL 打印到控制台排查查询条件对不对就看这里。2.2 诗词表结构设计与 JPA 实体映射诗词库除了主表 poem还应该有 author 和 category 两张附属表。诗词表保存标题、正文、朝代、作者 ID、分类 ID以及浏览量。实际建表我用预防式设计把索引和默认值先在 DDL 里定好CREATE TABLE author ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, dynasty VARCHAR(20) DEFAULT 未知 ); CREATE TABLE category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE poem ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, content TEXT NOT NULL, author_id BIGINT, category_id BIGINT, view_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (author_id) REFERENCES author(id), FOREIGN KEY (category_id) REFERENCES category(id) );这里把title定义为VARCHAR(100)因为古诗词标题一般不会超过 50 字但现代人的创作或扩展内容可能长一些。content用TEXT一首诗全文最长不过几百字TEXT 足够。view_count用来做简单热度排序后面接口会用到。对应 JPA 实体我写了三个类。Poem 需要关联 Author 和 Category注意使用 Lombok 减少 getter/setterEntity Table(name poem) Data public class Poem { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String title; Column(columnDefinition TEXT) private String content; ManyToOne(fetch FetchType.LAZY) JoinColumn(name author_id) private Author author; ManyToOne(fetch FetchType.LAZY) JoinColumn(name category_id) private Category category; private Integer viewCount; }关联关系使用FetchType.LAZY延迟加载获取诗词列表时不会把作者对象也查出来减少 SQL 条数。但这样做有个坑在 Controller 层直接返回 Poem 对象Jackson 序列化 LAZY 关联时会因为 session 关闭而报错后面章节会说怎么处理。2.3 Repository 层与自定义查询Spring Data JPA 的 Repository 接口不需要写实现类只要方法名符合规范框架会自动生成查询。诗词列表和搜索场景我定义了这些方法public interface PoemRepository extends JpaRepositoryPoem, Long { ListPoem findByTitleContaining(String keyword); ListPoem findByAuthorId(Long authorId); ListPoem findByCategoryId(Long categoryId); ListPoem findByOrderByViewCountDesc(Pageable pageable); Query(select p from Poem p left join fetch p.author left join fetch p.category where p.title like %:keyword% or p.content like %:keyword%) ListPoem searchByKeyword(Param(keyword) String keyword); }findByTitleContaining会自动生成LIKE %keyword%的 SQL适合单字段模糊查询。但如果同时匹配标题和内容用方法名解析规则会冗长我直接写了Query自定义 JPQL。注意这里用了left join fetch它能一次性把 author 和 category 查出来避免在 JSON 序列化时出现懒加载异常。3. React 前端页面与 SpringBoot API 对接实战3.1 前端脚手架与页面结构前端用create-react-app初始化配了 react-router-dom 做路由跳转。实际目录结构按组件拆分不要把页面逻辑全塞在 App.js 里src/ ├── api/ │ └── request.js ├── components/ │ ├── PoemList.js │ ├── PoemDetail.js │ └── SearchBar.js ├── pages/ │ ├── HomePage.js │ └── AuthorPage.js └── App.jsapi目录封装 axios 实例components放可复用组件pages放路由页面。这样拆分的好处是搜索引擎收录时 URL 对应到具体页面后续加功能不会牵一发动全身。3.2 Axios 请求封装与跨域处理不封装 axios 的话每个组件都要写axios.get(http://localhost:8080/api/...)一旦后端地址变化就要全局替换。我习惯单独建一个 request 模块设置 baseURL 和拦截器import axios from axios; const request axios.create({ baseURL: http://localhost:8080/api, timeout: 5000, headers: { Content-Type: application/json } }); request.interceptors.response.use( response response.data, error { if (error.response error.response.status 404) { console.error(请求资源不存在); } return Promise.reject(error); } ); export default request;浏览器访问 3000 端口后端在 8080 端口必然触发跨域。解决方案不需要前端代理后端加一个全局 CORS 配置最直接Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:3000) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }allowedOrigins要写实际的前端地址开发环境下是 localhost:3000上线换成域名。allowCredentials(true)表示允许携带 Cookie如果做登录功能这个必须设置。注意如果用了 Spring SecurityCORS 配置还需要在 Security 的过滤链里也配一遍否则会被拦截器挡在门外。3.3 诗词列表与详情页实现列表页的核心逻辑是进入页面时从/api/poem/list拉取数据然后渲染卡片点击卡片跳转详情。组件里用了useState存数据useEffect在组件挂载后触发请求import React, { useState, useEffect } from react; import request from ../api/request; import PoemCard from ../components/PoemCard; function PoemList() { const [poems, setPoems] useState([]); const [loading, setLoading] useState(true); useEffect(() { fetchPoems(); }, []); async function fetchPoems() { try { const data await request.get(/poem/list?page0size10); setPoems(data.content || data); } catch (err) { console.error(加载失败, err); } finally { setLoading(false); } } if (loading) return div加载中.../div; return ( div classNamepoem-grid {poems.map(poem ( PoemCard key{poem.id} poem{poem} / ))} /div ); } export default PoemList;这里的data.content是 Spring Data 默认分页结构里的列表字段我后端统一返回了Page对象所以前端取 content。如果后端直接返回数组这里就要改成data。两种方式都可以但全项目要统一不然到处出现undefined.map报错。loading状态的作用是防止在数据没回来时渲染空列表尤其是打开 DevTools 的 Network 看请求速度慢的时候效果明显。4. 核心功能落地关键词搜索、分类筛选与分页4.1 后端分页与排序接口设计古诗词网站的内容量大一次性返回所有诗词对 MySQL 是灾难接口必须支持分页。Spring Data JPA 提供了Pageable参数Controller 里直接接收RestController RequestMapping(/api/poem) public class PoemController { private final PoemRepository poemRepository; public PoemController(PoemRepository poemRepository) { this.poemRepository poemRepository; } GetMapping(/list) public PagePoemDto list(RequestParam(defaultValue 0) int page, RequestParam(defaultValue 10) int size, RequestParam(required false) String keyword, RequestParam(required false) Long categoryId) { Pageable pageable PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, createTime)); return searchWithFilters(keyword, categoryId, pageable) .map(this::toDto); } }这里page从 0 开始size默认 10前端要显示“第 X 页”时需要把页码加 1。Sort.by(Sort.Direction.DESC, createTime)是展示最新上传的诗词如果做热门推荐可以改成viewCount降序。接着看条件组合private PagePoem searchWithFilters(String keyword, Long categoryId, Pageable pageable) { if (keyword ! null !keyword.isBlank() categoryId ! null) { return poemRepository.findByTitleContainingOrContentContainingAndCategoryId(keyword, keyword, categoryId, pageable); } if (keyword ! null !keyword.isBlank()) { return poemRepository.findByTitleContainingOrContentContaining(keyword, keyword, pageable); } if (categoryId ! null) { return poemRepository.findByCategoryId(categoryId, pageable); } return poemRepository.findAll(pageable); }注意方法名findByTitleContainingOrContentContainingAndCategoryId执行时Spring Data JPA 会把And和Or按从左到右组合实际 SQL 是(title LIKE ? OR content LIKE ?) AND category_id ?。这种写法在条件简单时很直观条件一多就乱我一般直接用Query配合 SpEL 写动态查询后面会提到。4.2 前端搜索与分页联动搜索框每次按键都触发接口请求的话MySQL 会扛不住尤其是用户输入“春”就查一次“春风”又查一次。我在前端加了一个 300ms 的防抖函数等用户停止输入后再发起请求function useDebounce(value, delay 300) { const [debouncedValue, setDebouncedValue] useState(value); useEffect(() { const timer setTimeout(() setDebouncedValue(value), delay); return () clearTimeout(timer); }, [value, delay]); return debouncedValue; }然后在列表页里这样用const [keyword, setKeyword] useState(); const debouncedKeyword useDebounce(keyword); const [page, setPage] useState(0); useEffect(() { fetchList(); }, [debouncedKeyword, page]); async function fetchList() { const params { page, size: 10 }; if (debouncedKeyword) { params.keyword debouncedKeyword; } const data await request.get(/poem/list, { params }); setPoems(data.content); setTotalPages(data.totalPages); }防抖的效果是用户连续输入“春”“春风”“春雨”时只会对“春雨”这个最终词发起一次请求减少后端 QPS。useEffect的依赖数组里放debouncedKeyword和page任何一方变化都会重新拉数据分页跳转和搜索修改共用一套逻辑代码不会重复。分页按钮我直接用 Ant Design 或自己写页码核心是点击时调用setPage(newPage)组件重新渲染触发请求。4.3 冷门坑JPA 方法名查询与 NULL 条件冲突用方法名查询时如果参数为 null框架默认不会帮你排除这个条件。比如先写findByAuthorId(Long authorId)传入 null 时执行 SQL 会变成WHERE author_id null永远查不到数据。类似地Containing关键字传空字符串会查出全部数据和 null 混在一起时前端容易出逻辑错误。我一般用Query写 JPQL 或原生 SQL配合script标签如果是 MyBatis 则用where解决。JPA 没有动态 SQL 的 XML 标签但可以用Query的:keyword参数配合like空串返回全部Query(select p from Poem p where (:keyword is null or :keyword or p.title like %:keyword% or p.content like %:keyword%)) PagePoem search(Param(keyword) String keyword, Pageable pageable);这个 JPQL 的意思是当:keyword为 null 或空字符串时去掉匹配条件返回所有记录。注意 JPQL 里%:keyword%是把%直接写在字符串里参数绑定:keyword会被替换为实际值。这样即使前端传来的keyword没有经过处理也不会因为 null 而出错。如果你用原生 SQL同理写成WHERE (:keyword IS NULL OR :keyword OR p.title LIKE CONCAT(%, :keyword, %))这个写法比在 Service 层用if判断分支简洁性能上也没有额外负担因为参数化查询会复用执行计划。4.4 关联字段的查询按作者名找诗用户有时不记得诗名但记得作者比如搜索“苏轼”。如果只查 poem 表得先查出作者 ID 再查诗前端要发两次请求。更好的方案是后端提供一次关联查询接口Query(select p from Poem p join p.author a where a.name :authorName) PagePoem findByAuthorName(Param(authorName) String authorName, Pageable pageable);这样前端传参authorName苏轼即可。注意这里join p.author如果不加fetch返回的 poem 列表里的 author 对象是懒加载在事务外访问会抛异常。我一般在 Repository 方法上直接EntityGraph(attributePaths {author})或者用join fetch。EntityGraph的好处是不影响 JPQL只要方法名被解析成对应查询自动加载指定关联。5. 性能优化与生产级排错索引、连接池与日志5.1 给 MySQL 表和查询加索引诗词表数据量上万后模糊搜索会全表扫描。最基础的优化是在 WHERE 和 JOIN 的列上建索引ALTER TABLE poem ADD INDEX idx_title (title); ALTER TABLE poem ADD INDEX idx_author_id (author_id); ALTER TABLE poem ADD INDEX idx_category_id (category_id);idx_title对title列的精确匹配有用但LIKE %春风%这种前置模糊查询用不上索引只能全表扫。真正解决全文搜索要靠全文索引或 Elasticsearch但课程设计里如果数据量不过十万给content建FULLTEXT索引配合MATCH ... AGAINST已经够用ALTER TABLE poem ADD FULLTEXT INDEX ft_content (content); SELECT * FROM poem WHERE MATCH(content) AGAINST(春风 WITH QUERY EXPANSION);用全文索引要注意查询语法MATCH的搜索词默认分词中文分词效果取决于 MySQL 的 ngram 解析器。MySQL 8 默认支持 ngram可以在建表时指定CREATE TABLE poem ( content TEXT, FULLTEXT KEY ft_content (content) WITH PARSER ngram ) ENGINEInnoDB;加了全文索引后把搜索接口从LIKE改成MATCH响应时间从几百毫秒降到几十毫秒。但要注意MATCH对短词不友好搜索“春风”没问题搜索单个字“风”可能被忽略这时还是要回退到LIKE。5.2 HikariCP 连接池配置与慢查询日志SpringBoot 默认使用 HikariCP 连接池但默认配置对生产环境不敏感。我在application.yml里调整了连接池参数spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000maximum-pool-size设为 20是兼顾数据库并发和 MySQL 默认最大连接数的折中。minimum-idle保持 5 个空闲连接避免瞬间流量打过来时才慢慢建连。connection-timeout是 30 秒超过这个时间拿不到连接就报错防止线程无限等下去。如果上线后看到HikariPool-1 - Connection is not available, request timed out报错优先检查这个值是否太小以及应用是否泄漏了连接。慢查询排查用 MySQL 自带的日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;然后查看慢查询日志文件tail -f /var/log/mysql/slow.log记得在开发阶段用EXPLAIN SELECT ...分析执行计划看type是ALL表示全表扫描ref或eq_ref说明用到了索引。5.3 常见启动失败和 404 排查清单我整理了一个排查表遇到问题时先按这个顺序过一遍错误现象可能原因解决办法启动报Access denied for user用户名或密码错误检查application.yml的 datasource 用户名密码以及 MySQL 用户权限启动报Unknown database数据库不存在先执行CREATE DATABASE poetry_db DEFAULT CHARSET utf8mb4请求接口返回 404Controller 的RequestMapping与前端路径不一致打开控制台看 axios 实际请求 URL对照后端的/api/poem/list返回 JSON 报No serializer found for class org.hibernate.proxy...LAZY 关联且没有配置 DTO不要直接返回 Entity返回 DTO 或使用JsonIgnore配合DTOPort 8080 already in use端口被占用lsof -i:8080查 PIDkill -9 PID或改server.port最后一个踩坑点是 Java 实体里createTime字段的类型。如果数据库是DATETIME实体用LocalDateTimeJackson 默认序列化为数组格式前端拿不到字符串时间。加一个配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai或是在字段上JsonFormat(pattern yyyy-MM-dd HH:mm:ss)。这问题隐蔽接口返回数据前端解析不了的时候先看 DevTools 的 Response 内容是不是一堆数组数字。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。