在 Java_web_demo 完整版的基础上,把 Data.java 里那 3 本写死的书换成 MySQL 里真正的 10 条数据。
分 6 步,每步都能单独提交、单独回退。每步的答案都在对应章节里,可以直接照抄。
这一步不写代码,先建立地图。搞清楚「谁负责什么」,后面 6 步才不会乱。
| 环节 | 改造前(静态数据) | 改造后(数据库) |
|---|---|---|
| 数据在哪 | Data.java 里 new 出来的 ArrayList | MySQL 的 book_db.book 表 |
| 有几条 | 3 条,写死,重启也不变 | 10 条,改库就变,重启还在 |
| 谁来取 | Controller 直接 Data.getData() | Service 调 Mapper,Mapper 发 SQL |
| 能不能改 | 不能(改了重启就还原) | 能(借阅会写库) |
点一下任意一层,看它的职责。绿色标签的是本次新增的。
| 步骤 | 改哪个文件 | 这一步解决什么问题 | 要填几处 |
|---|---|---|---|
| 1 | pom.xmlapplication.properties | 加依赖、配数据源、建库建表 —— 先能连上 | 4 |
| 2 | Book.java | 让 Book 类对上 book 表:表名、主键、无参构造器 | 4 |
| 3 | BookMapper.javaTeacherApplication.java | 让 MyBatis 给接口生成实现,白拿单表 CRUD | 1(+1 选做) |
| 4 | BookService.java | 用 Wrapper 拼条件 —— 本节课最难的地方 | 3 |
| 5 | BookController.java | 把 Controller 的数据来源从内存换成数据库 | 4 |
| 6 | BookService / BookController / books.html | 借阅真正写库 + 重定向刷新 | 3 |
MyBatis-Plus 不生成表,它只负责「类 ↔ 表」的映射和 SQL 的拼装。
所以第 1 步必须先手动建库建表。这里的顺序容易搞反:加完依赖就以为表已经有了。
这一步一行业务代码都不写。目标只有一个:应用启动时不报数据库连接错误。
sql/book_db.sql,把库、表、10 条数据建出来application.properties 里填 4 个值:driver、url、username、passwordSQL 脚本在 sql/book_db.sql,命令行直接跑:
mysql -u root -p < sql/book_db.sql
脚本会建出 book_db 库、book 表,并插入 10 条数据。跑完应该能看到:
# ① 驱动类名。MySQL 8 固定是这个,照抄 spring.datasource.driver-class-name= # ② 连接地址:jdbc:mysql://主机:端口/库名?参数 spring.datasource.url= # ③ 你本机 MySQL 的账号 spring.datasource.username= # ④ 你本机 MySQL 的密码 spring.datasource.password= # 这两项照抄即可 mybatis-plus.configuration.map-underscore-to-camel-case=true mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
# ① 驱动类名 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # ② 连接地址(注意 serverTimezone 和 useSSL 这两个参数) spring.datasource.url=jdbc:mysql://127.0.0.1:3306/book_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true # ③ 账号 spring.datasource.username=root # ④ 密码(改成你自己的) spring.datasource.password=123456 mybatis-plus.configuration.map-underscore-to-camel-case=true mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
顺便说清这两个 MyBatis-Plus 配置各管什么:
| 配置 | 作用 |
|---|---|
map-underscore-to-camel-case | 表字段 press_name 自动对上实体 pressName。本表字段没有下划线,但生产库几乎都有,养成习惯 |
log-impl=StdOutImpl | 把真正执行的 SQL 打到控制台。后面每一步都要盯着它看 —— 这是理解 MyBatis-Plus 最有效的窗口 |
坑 1 · 时区:url 里漏了 serverTimezone=Asia/Shanghai,启动报 The server time zone value '…' is unrecognized。
坑 2 · SSL:MySQL 8 默认要求 SSL,漏了 useSSL=false 会报 Public Key Retrieval is not allowed 或握手失败。
坑 3 · 库名:book_db 写错成 bookdb,报 Unknown database 'bookdb' —— 注意是「库不存在」,不是「表不存在」。
坑 4 · 驱动版本:MySQL 8 的库配了 5.x 的驱动 com.mysql.jdbc.Driver,报 Loading class ... deprecated 甚至连不上。5.x 的库才用那个老类名。
mysql -u root -p -e "SELECT COUNT(*) FROM book_db.book" 返回 10Started TeacherApplication,没有 Communications link failure / Access deniedhttp://127.0.0.1:8080/ 页面正常 —— 说明应用起来了(此时还没接库,页面数据仍是旧的 3 本)库和表都有了,但 MyBatis-Plus 还不知道「Book 这个类 = 哪张表」。这一步就解决这件事。
@TableName("book")@TableId(type = IdType.AUTO)public Book() {}| 写法 | 作用 | 不写会怎样 |
|---|---|---|
@TableName("book") | 告诉 MP 这个类对应 book 表 | 默认按类名找 Book 表,报 Table 'book_db.Book' doesn't exist |
@TableId(type = IdType.AUTO) | 声明主键字段,且由数据库自增生成 | MP 会按字段名 id 猜主键(能猜到),但插入时用雪花 ID 而不是数据库自增 |
public Book() {} | 让 MyBatis 能反射创建对象 | 直接启动失败,见下面的坑 |
// TODO 1:import 这三个 import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.IdType; // TODO 2:给它加 @TableName("book") @TableName("book") public class Book { // TODO 3:给它加 @TableId(type = IdType.AUTO) @TableId(type = IdType.AUTO) private Integer id; private String name; private String author; private String press; private Integer status; // TODO 4:补无参构造器 ← 最容易忘、后果最直接的一个空 public Book() { } public Book(Integer id, String name, String author, String press, Integer status) { ... } }
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; @TableName("book") public class Book { @TableId(type = IdType.AUTO) private Integer id; private String name; private String author; private String press; private Integer status; public Book() { } public Book(Integer id, String name, String author, String press, Integer status) { ... } }
坑 1(会报错):没有无参构造器。MyBatis 从 ResultSet 取到一行后,是先反射 new 出一个空对象、再逐个调 setter 填值。这个类原来只有 5 参构造器,而 Java 的规则是「一旦自己写了构造器,就不再送你默认无参构造器」,于是启动就报 No constructor found 之类。加个 public Book() {} 就好。
坑 2(不报错,更阴):status 的类型。表里是 TINYINT,实体必须用 Integer;如果还留着 String,页面里 th:if="${book.status == '0'}" 会永远不成立 —— 不报错,表格里状态列一片空白,能查半天。
另外有两处必须同步改,否则页面会是空白:
// Data.java:静态数据的状态值跟着改类型 new Book(1, "楚辞", "屈原", "中国文联出版社", "0") new Book(1, "楚辞", "屈原", "中国文联出版社", 0) <!-- books.html:Thymeleaf 比较也要用数字 --> <th:block th:if="${book.status == '0'}"> <th:block th:if="${book.status == 0}">
No constructor found/book/list 仍然是 3 本书 —— 正常,还没接库MyBatis-Plus 最省事的地方就在这一步:一个空接口,白拿 6 个方法。
mapper/BookMapper.java,让它继承 BaseMapper<Book> (这一行直接给出,原因见下)TeacherApplication 上加 @MapperScan("com.dashuju.teacher.mapper")import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.dashuju.teacher.pojo.Book; public interface BookMapper extends BaseMapper<Book> { // 空的。真的什么都没有。 }
这一行直接给出,是因为第 4 步的 ServiceImpl<BookMapper, Book> 依赖它 —— BookMapper 不继承 BaseMapper,第 4 步编译就过不去。
就这一行,你得到了:
| 方法 | 等价 SQL |
|---|---|
insert(book) | INSERT INTO book (...) VALUES (...) |
deleteById(1) | DELETE FROM book WHERE id = 1 |
updateById(book) | UPDATE book SET ... WHERE id = ? |
selectById(1) | SELECT ... FROM book WHERE id = 1 |
selectList(wrapper) ← 本案例用这个 | SELECT ... FROM book WHERE ... |
selectCount(wrapper) | SELECT COUNT(*) FROM book WHERE ... |
// TODO:加这个注解 + import import org.mybatis.spring.annotation.MapperScan; @SpringBootApplication @MapperScan("com.dashuju.teacher.mapper") public class TeacherApplication { public static void main(String[] args) { SpringApplication.run(TeacherApplication.class, args); } }
import org.mybatis.spring.annotation.MapperScan; @SpringBootApplication @MapperScan("com.dashuju.teacher.mapper") public class TeacherApplication { public static void main(String[] args) { SpringApplication.run(TeacherApplication.class, args); } }
为什么要它?因为 BookMapper 是接口、没有实现类。MyBatis 启动时会用 JDK 动态代理给它生成一个实现,但前提是要知道「去哪个包找接口」。
启动时报:
No qualifying bean of type 'com.dashuju.teacher.mapper.BookMapper' available
或者 Consider defining a bean of type ... in your configuration。
另一种写法是给每个 Mapper 接口单独加 @Mapper 注解,接口多了不如 @MapperScan 省事。
@MapperScan 注释掉再启动,看一眼那个 No qualifying bean 报错前面三步都是「配置和骨架」。从这里开始写真正的逻辑 —— 也是最容易写错的地方。
LambdaQueryWrapperlistAll():查全部,按 id 升序search(name, author):名称 或 作者模糊匹配一个什么都不加的 Wrapper,就等于 SELECT * FROM book:
public List<Book> listAll() { return list(new LambdaQueryWrapper<Book>().orderByAsc(Book::getId)); }
上面是控制台输出,由 mybatis-plus.configuration.log-impl 这个配置打印。
需求是「名称包含关键字 或 作者包含关键字」。但 Wrapper 默认用 AND 连接,这是最容易写错的地方。
| 写法 | 生成的 SQL | 结果 |
|---|---|---|
qw.like(Book::getName, name) |
WHERE name LIKE ? AND author LIKE ? |
错。变成「与」了:名称含「西」且作者含「余华」→ 0 条 |
qw.like(Book::getName, name) |
WHERE name LIKE ? OR author LIKE ? |
能查出来,但没有括号,将来再加条件就串错 |
qw.and(w -> { … })里面用 .or() 连 |
WHERE ((name LIKE ? OR author LIKE ?)) |
对。括号把这一段包住,安全 |
假设以后要加一个条件「只查可借阅的」:
WHERE name LIKE ? OR author LIKE ? AND status = 0
因为 AND 优先级比 OR 高,它会被解释成:
name LIKE ? OR (author LIKE ? AND status = 0)
结果是「名字里带西的书,不管借没借出,全都查出来了」。加括号就是避免这个。
public List<Book> search(String name, String author) { LambdaQueryWrapper<Book> qw = new LambdaQueryWrapper<>(); boolean hasName = StringUtils.hasText(name); boolean hasAuthor = StringUtils.hasText(author); if (hasName || hasAuthor) { // TODO:这里要写成 qw.and(w -> { ... }) // 里面:只有 name 就 w.like(Book::getName, name) // 只有 author 就 w.like(Book::getAuthor, author) // 两个都有就中间补 w.or() } // TODO:qw.orderByAsc(Book::getId); return list(qw); return null; }
public List<Book> search(String name, String author) { LambdaQueryWrapper<Book> qw = new LambdaQueryWrapper<>(); boolean hasName = StringUtils.hasText(name); boolean hasAuthor = StringUtils.hasText(author); if (hasName || hasAuthor) { qw.and(w -> { // ← 括号由此而来 if (hasName) { w.like(Book::getName, name); } if (hasAuthor) { if (hasName) { w.or(); // ← OR 由此而来 } w.like(Book::getAuthor, author); } }); } qw.orderByAsc(Book::getId); return list(qw); }
按错误写法写,第二条会变成 WHERE name LIKE ? AND author LIKE ?,结果只有 0 行。
(( 两个左括号 和 OR这一步的看点是:Controller 的结构几乎不用改。换数据源之所以这么轻松,是因为中间隔了一层 Service。
BookService 和 @AutowiredBookService/book/list 里把 listAll() 的结果放进 model,键名 "books"/book/search 里把 search() 的结果放进 model,键名同样是 "books"动手之前,先把旧的静态数据字段删掉 —— 就两行:
ArrayList<Book> books = Data.getData(); import com.dashuju.teacher.pojo.Data;
// TODO 1:import BookService 和 @Autowired @Controller @RequestMapping("book") public class BookController { // TODO 2:注入 BookService // @Autowired private BookService bookService; @RequestMapping("list") public String findBook(Model model, HttpSession session) { session.setAttribute("user", new User("zhangsan", "ADMIN")); // 别删,模板要用 // TODO 3:model.addAttribute("books", bookService.listAll()); return "books"; } @RequestMapping("search") public String searchBook(Book book, Model model, HttpSession session) { session.setAttribute("user", new User("zhangsan", "ADMIN")); // TODO 4:model.addAttribute("books", bookService.search(book.getName(), book.getAuthor())); return "books"; } }
import com.dashuju.teacher.service.BookService; import org.springframework.beans.factory.annotation.Autowired; @Controller @RequestMapping("book") public class BookController { @Autowired private BookService bookService; @RequestMapping("list") public String findBook(Model model, HttpSession session) { session.setAttribute("user", new User("zhangsan", "ADMIN")); model.addAttribute("books", bookService.listAll()); return "books"; } @RequestMapping("search") public String searchBook(Book book, Model model, HttpSession session) { session.setAttribute("user", new User("zhangsan", "ADMIN")); model.addAttribute("books", bookService.search(book.getName(), book.getAuthor())); return "books"; } }
坑 1 · 键名写错。Model 键名必须是 "books",因为 books.html 里是 th:each="book : ${books}"。写成 "bookList" 不会报错,页面只是渲染出一张空表。
坑 2 · 忘了登录。/book/** 不在 LoginFilter 的白名单里,直接访问会被 302 打回登录页。测试前先访问 /dologin?username=tom&password=123。
/book/list,能看到 10 本书(不再是 Data 里那 3 本)SELECT id,name,author,press,status FROM book ORDER BY id ASC前面全是「读」。这一步第一次 UPDATE 数据库,同时解决一个所有初学者都会踩的问题:改完数据,页面为什么不刷新?
BookService.borrow(id):查出来 → 判状态 → 改 1 → 写回BookController 的 /book/find/{id}:调 borrow,返回 redirect:/book/listbooks.html:把 $.get(...) 改成整页跳转public boolean borrow(Integer id) { // TODO:① Book book = getById(id); 查不到 return false // TODO:② 判状态:null 或 != 0 都 return false(已借出不能再借) // TODO:③ book.setStatus(1); return updateById(book); return false; }
public boolean borrow(Integer id) { Book book = getById(id); if (book == null) { return false; // 书不存在 } if (book.getStatus() == null || book.getStatus() != 0) { return false; // 1=借阅中 2=归还中,都不许再借 } book.setStatus(1); return updateById(book); // UPDATE book SET ... WHERE id = ? }
为什么先 getById 再整行更新,而不是 new 一个只带 id 和 status 的对象?因为 MyBatis-Plus 只更新非 null 字段,只传两个字段在简单场景能跑通,但一旦需要状态校验、乐观锁就会出错 —— 先查、改、整行写回,逻辑最不容易错。
@RequestMapping("find/{id}") public String findBook(@PathVariable("id") Integer id) { // TODO 5:boolean ok = bookService.borrow(id); 打印结果 // TODO 6:返回 redirect:/book/list(不是 "books"!) return "books"; }
@RequestMapping("find/{id}") public String findBook(@PathVariable("id") Integer id) { boolean ok = bookService.borrow(id); System.out.println("申请借阅图书的id:" + id + " → " + (ok ? "借阅成功" : "借阅失败(已借出或不存在)")); return "redirect:/book/list"; }
因为 return "books" 是让 Thymeleaf 立刻用当前 Model 渲染这个模板 —— 而这时候 Model 里根本没有 books(这个方法一行 model.addAttribute 都没写)。结果页面是一张空表。
改成 redirect:/book/list 后,流程变成:
浏览器 GET /book/find/1 → 服务器 302 → 浏览器 GET /book/list → Controller 重新查库 → 渲染
这就是 PRG 模式(Post/Redirect/Get)。它还有个副作用好处:用户按 F5 刷新时,浏览器重发的是那个 GET,不会重复提交借阅。
<script>
function findBookById(id) {
// TODO 7:改成 location.href = '/book/find/' + id;
$.get("/book/find/" + id)
}
</script>
<script>
function findBookById(id) {
location.href = '/book/find/' + id;
}
</script>
$.get 是在后台悄悄发一个请求,页面停在原地不动。数据库里状态已经改成「借阅中」了,但表格还显示「可借阅」—— 看起来就像功能坏了。整页跳转才能亲眼看到那一行从可借阅变成借阅中、按钮变灰。
/book/list → 10 本书,「楚辞」是「可借阅」SELECT status FROM book WHERE id=1 → 1这套流程里 90% 的报错都在这张表里。建议课前自己先踩一遍。
| 现象 | 原因 | 怎么办 |
|---|---|---|
Failed to determine a suitable driver class | url 或 driver-class-name 没填 / 填错 | 检查第 1 步的 4 个配置,注意不是 spring.datasource.data-url |
The server time zone value ... is unrecognized | url 缺 serverTimezone | 加 serverTimezone=Asia/Shanghai |
Public Key Retrieval is not allowed | MySQL 8 要 SSL | 加 useSSL=false&allowPublicKeyRetrieval=true |
Unknown database 'xxx' | 库没建 / 库名写错 | 先跑 sql/book_db.sql |
Access denied for user 'root' | 账号密码错 | 先在命令行 mysql -u root -p 试一次 |
Table 'book_db.Book' doesn't exist | 漏了 @TableName("book") | 类名 Book ≠ 表名 book,必须显式写 |
No constructor found ... Book | 实体没有无参构造器 | 加 public Book() {} |
No qualifying bean of type '...BookMapper' | 漏了 @MapperScan | 启动类加注解,或接口加 @Mapper |
| 页面一行数据都没有,但也不报错 | ① Model 键名不是 books② status 还是 String,跟 '0' 比较 | 两个都不报错,只能靠对照代码查 |
| 检索结果比预期少(AND 而不是 OR) | 两个 like 之间没写 .or() | 看控制台 SQL 里有没有 OR |
| 点借阅后页面空白 | return "books" 但 Model 里没数据 | 改成 redirect:/book/list |
| 点借阅后页面没反应 | 用了 $.get | 改成 location.href |
访问 /book/list 跳回登录页 | 没登录,被 LoginFilter 拦了 | 先 /dologin,或用 F12 看是不是 302 |
| 控制台看不到任何 SQL | log-impl 没配 | 加 mybatis-plus.configuration.log-impl=...StdOutImpl |
① 库连上了吗(第 1 步)→ ② 表名对上了吗(第 2 步)→ ③ Mapper 注册了吗(第 3 步)→ ④ SQL 长什么样(看控制台)→ ⑤ Model 里有没有数据 → ⑥ 页面拿到没有