本篇是"前端工程师转后端"系列第 5 篇。第 4 篇我们读懂了 MySQL 表结构,知道
mall_product、mall_category、mall_product_sku、mall_order等表分别保存什么。现在进入 Java 后端访问数据库的第一层:MyBatis-Plus。你可以先把它理解成"让 Java 对象和数据库表互相映射,并提供常用 CRUD 能力的工具"。本篇不要求你提前懂 MyBatis 或 ORM,会用前端熟悉的 API client、TypeScript interface、数组 filter / map 来类比。
说明:本文仍然基于当前fullstack-mall后端工程。前端代码均为"前端侧示意代码",用于帮助理解概念,不代表仓库存在真实前端源码。
1. 这篇解决什么问题
前面我们已经能从接口追到 Controller,也能读懂数据库表。但中间还有一段你必须掌握的链路:Java 代码如何查询 MySQL?
如果不用任何框架,后端访问数据库大概会写很多重复代码:创建连接、写 SQL、设置参数、执行查询、遍历结果集、把每一列手动塞到 Java 对象里、处理异常、关闭连接。真实项目当然不能每个接口都重复写这些样板。
当前项目使用 MyBatis-Plus,它出现在 backend/service/pom.xml 的依赖中,也有专门配置:
text
backend/service/src/main/java/com/example/fullstackmall/service/config/MybatisPlusConfig.java
商品相关数据库访问主要在这些文件:
text
backend/service/src/main/java/com/example/fullstackmall/service/product/entity/ProductEntity.java
backend/service/src/main/java/com/example/fullstackmall/service/product/mapper/ProductMapper.java
backend/service/src/main/java/com/example/fullstackmall/service/product/service/ProductDbService.java
分类相关数据库访问主要在:
text
backend/service/src/main/java/com/example/fullstackmall/service/category/entity/CategoryEntity.java
backend/service/src/main/java/com/example/fullstackmall/service/category/mapper/CategoryMapper.java
backend/service/src/main/java/com/example/fullstackmall/service/category/service/CategoryDbService.java
本篇解决下面几个问题:
- MyBatis-Plus 是什么,和 SQL、JDBC、ORM 有什么关系;
- Entity 如何映射数据库表;
@TableName、@TableId、@TableField分别解决什么问题;- Mapper 为什么可以只继承
BaseMapper<ProductEntity>就拥有常用数据库方法; ServiceImpl<ProductMapper, ProductEntity>提供了哪些常用能力;lambdaQuery()、Wrappers.lambdaQuery()、LambdaQueryWrapper如何表达查询条件;Page<ProductEntity>如何和前端分页参数对应;- DbService 和 Facade 的职责边界;
- 如何从
GET /api/products一路追到 MyBatis-Plus 查询; - 初学 MyBatis-Plus 最容易踩哪些坑。
学完本篇,你应该能读懂当前项目商品列表、商品详情、分类查询的大部分数据库访问代码;也能理解为什么后端不是在 Controller 里直接拼 SQL。
2. 用前端知识类比:API client、类型模型和数据访问封装
前端项目里,你可能会封装一个 API client:
ts
// 前端侧示意代码
export async function queryProducts(params: ProductQueryRequest) {
return http.get<ApiResponse<PageResponse<ProductSummaryResponse>>>('/api/products', { params })
}
组件不会直接写所有 Axios 细节,而是调用封装好的函数。这样组件只关心"我要商品列表",不关心 baseURL、拦截器、错误处理、响应解析。
后端访问数据库也类似。Controller 不应该直接写 SQL,Facade 不应该到处拼接数据库条件。当前项目把数据库访问集中到 ProductDbService,它再依赖 ProductMapper,ProductMapper 基于 MyBatis-Plus 操作 ProductEntity 对应的 mall_product 表。
可以这样类比:
| 前端概念 | 后端 MyBatis-Plus 概念 | 当前项目例子 |
|---|---|---|
| TypeScript interface | Java Entity / DTO | ProductEntity、ProductQueryRequest |
| Axios API client | Mapper / DbService | ProductMapper、ProductDbService |
| 请求参数对象 | QueryWrapper 条件 | LambdaQueryWrapper<ProductEntity> |
| 分页参数 | Page<>(pageNum, pageSize) |
queryPage(...) |
| response data | Entity / Response DTO | Page<ProductEntity> 转 PageResponse<ProductSummaryResponse> |
| 组件调用 service | Controller / Facade 调 DbService | ProductFacade 调 productDbService |
但有一个重要区别:前端 API client 是调用远程 HTTP,后端 Mapper 是操作数据库。前端请求失败多是网络、权限或接口错误;后端数据库操作失败可能涉及 SQL 语法、表结构、索引、约束、事务、连接池、并发锁。
本章先把 MyBatis-Plus 当成一个"数据库 API client 生成器"来理解。它根据 Entity 和 Mapper,帮我们提供很多常见 CRUD 方法,让我们少写重复 SQL,同时仍然保留 SQL 思维。
3. 后端核心概念讲解
3.1 JDBC、MyBatis、MyBatis-Plus 的关系
先不要被名字吓到,可以分层理解:
text
JDBC = Java 访问数据库的底层标准接口
MyBatis = 帮你写 SQL 映射和结果映射的持久层框架
MyBatis-Plus = 在 MyBatis 基础上增强常用 CRUD、分页、Wrapper 等能力
如果把数据库访问比作前端发请求:JDBC 像最底层的 XMLHttpRequest 或 fetch 能力;MyBatis 像你封装了一层请求和响应映射;MyBatis-Plus 像在这层基础上又提供了常用 CRUD 工具函数、分页插件、条件构造器。
MyBatis-Plus 不等于"不需要懂 SQL"。它能帮你少写样板代码,但你仍然要知道它生成的查询大概对应什么 SQL。否则你看到 wrapper.eq(...).like(...).orderByDesc(...) 时,只会觉得像魔法。
3.2 Entity:数据库表在 Java 里的映射对象
当前商品 Entity 是:
java
@Data
@TableName("mall_product")
public class ProductEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("category_id")
private Long categoryId;
private String title;
private String subtitle;
private String description;
@TableField("status_code")
private String statusCode;
@TableField("created_by")
private Long createdBy;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
private LocalDateTime updatedAt;
}
它对应第 4 篇讲过的 mall_product 表。Entity 是"持久化对象",主要面向数据库,不是给前端直接看的 Response,也不是前端可以随便传的 Request。
@Data 来自 Lombok,帮我们生成 getter、setter、toString 等方法。你在代码里看到 product.getTitle()、product.setStatusCode(...),这些方法不是手写的,而是 Lombok 在编译阶段生成的。
@TableName("mall_product") 告诉 MyBatis-Plus:这个 Java 类对应数据库里的 mall_product 表。如果没有这个注解,框架可能根据类名推导表名,但真实项目最好显式标明,阅读更清楚。
3.3 @TableId:主键如何映射
商品表主键是:
sql
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT
PRIMARY KEY (id)
Entity 中对应:
java
@TableId(value = "id", type = IdType.AUTO)
private Long id;
value = "id" 表示数据库列名是 id。type = IdType.AUTO 表示主键由数据库自增生成。创建商品时,代码先创建一个 ProductEntity,调用 productDbService.save(product),MyBatis-Plus 插入数据库后会把生成的 ID 回填到 product.id。
这解释了第三篇提到的事情:创建商品 Request DTO 里不应该有 id。因为 ID 不是外部输入,而是数据库生成的可信身份。
前端类比:列表渲染的 key 应该来自后端稳定 ID,而不是数组下标。数据库主键就是这个稳定 ID 的源头。
3.4 @TableField 与下划线转驼峰
数据库字段常用下划线:category_id、status_code、created_at。Java 字段常用驼峰:categoryId、statusCode、createdAt。
Entity 中有:
java
@TableField("category_id")
private Long categoryId;
@TableField("status_code")
private String statusCode;
同时,当前 application.yml 里配置:
yaml
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
这表示 MyBatis-Plus 支持下划线到驼峰的映射。显式 @TableField 能让字段对应关系更清晰,尤其适合初学者读代码。
不要把数据库字段名和 Java 字段名混乱使用。写 SQL 时看数据库列名,写 Java Lambda Wrapper 时看 Java getter,例如 ProductEntity::getStatusCode。MyBatis-Plus 会根据映射关系生成对应 SQL。
3.5 Mapper:表操作入口
当前商品 Mapper 很短:
java
public interface ProductMapper extends BaseMapper<ProductEntity> {
}
第一次看到你可能会疑惑:里面什么方法都没有,怎么查数据库?关键在 BaseMapper<ProductEntity>。MyBatis-Plus 提供了通用方法,例如按 ID 查询、插入、更新、删除、按条件查询等。ProductMapper 继承后,就拥有了操作 ProductEntity 对应表的基础能力。
前端类比:你引入一个通用 CRUD client,只要告诉它资源类型是 Product,它就能提供 getById、list、save 等方法。不同的是,后端 Mapper 操作的是数据库表,不是 HTTP 资源。
Mapper 接口本身如何被 Spring 发现?当前项目有配置:
java
@Configuration
@MapperScan("com.example.fullstackmall.service.**.mapper")
public class MybatisPlusConfig { ... }
@MapperScan 会扫描 mapper 包下的接口,为它们创建代理对象。你没有看到 new ProductMapper(),是因为 Spring 和 MyBatis-Plus 在运行时生成并注入了 Mapper 代理。
3.6 DbService:数据库访问服务
当前商品数据库访问服务是:
java
@Service
public class ProductDbService extends ServiceImpl<ProductMapper, ProductEntity> {
...
}
ServiceImpl<ProductMapper, ProductEntity> 是 MyBatis-Plus 提供的通用 Service 实现。它内部持有 baseMapper,并提供 getById、save、updateById、page、lambdaQuery、listByIds 等方法。
为什么项目还要写 ProductDbService,而不是在 Facade 里直接用 Mapper?因为 DbService 可以封装数据库访问相关规则:
- 查不到商品就抛业务异常;
- 公开查询强制限定
ON_SALE; - 后台查询允许按请求状态筛选;
- 查询条件统一 trim;
- 分页排序统一;
- 更新副标题时只更新指定字段;
- 把数据库层异常转换为更稳定的业务语义。
DbService 不是 Controller,也不是 Facade。它关注"如何访问数据库",而不是"一个业务用例完整流程是什么"。
3.7 Facade、DbService、Mapper 的边界
当前商品创建链路大致是:
Controller 负责 HTTP;Facade 负责编排业务;DbService 负责数据库访问规则;Mapper 负责把 Java 操作落到 MySQL。这个分层对前端同学很重要,因为你可能习惯在一个页面 service 函数里完成很多事情,但后端业务复杂后必须拆分职责。
例如创建商品时,Facade 要校验分类是否启用、获取当前管理员、设置商品状态、保存商品、删除缓存、组装响应。这些不是单纯数据库查询,所以不应该全部放到 Mapper 或 DbService。
3.8 Wrapper:用 Java 表达 SQL 条件
ProductDbService.requirePublishedById 是:
java
public ProductEntity requirePublishedById(Long productId) {
ProductEntity product = lambdaQuery()
.eq(ProductEntity::getId, productId)
.eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name())
.one();
if (product == null) {
throw productNotFound();
}
return product;
}
它大致对应 SQL:
sql
SELECT *
FROM mall_product
WHERE id = ?
AND status_code = 'ON_SALE'
LIMIT 1;
eq 是 equals,表示等值条件。ProductEntity::getId 是 Java 方法引用,MyBatis-Plus 通过它知道你要操作 id 字段。one() 表示期望查出一条记录。
前端类比:
ts
// 前端侧示意代码
const product = products.find(item =>
item.id === productId && item.statusCode === 'ON_SALE'
)
但数据库查询不是前端数组查询。前端数组数据已经在内存里,数据库查询会生成 SQL 发给 MySQL,MySQL 再利用索引和执行计划查数据。
3.9 分页查询:Page 与 LambdaQueryWrapper
queryPage 是本章最值得细读的方法:
java
private Page<ProductEntity> queryPage(ProductQueryRequest request, ProductStatus forcedStatus) {
LambdaQueryWrapper<ProductEntity> wrapper = Wrappers.lambdaQuery(ProductEntity.class);
String keyword = normalizeKeyword(request.getKeyword());
if (StringUtils.hasText(keyword)) {
wrapper.and(condition -> condition
.like(ProductEntity::getTitle, keyword)
.or()
.like(ProductEntity::getDescription, keyword));
}
if (request.getCategoryId() != null) {
wrapper.eq(ProductEntity::getCategoryId, request.getCategoryId());
}
if (forcedStatus != null) {
wrapper.eq(ProductEntity::getStatusCode, forcedStatus.name());
}
wrapper.orderByDesc(ProductEntity::getCreatedAt)
.orderByDesc(ProductEntity::getId);
return page(new Page<>(request.getPageNum(), request.getPageSize()), wrapper);
}
它做了几件事:
- 创建一个查询条件 wrapper;
- 对关键字做 trim;
- 如果关键字非空,就按标题或描述模糊查询;
- 如果分类 ID 非空,就按分类过滤;
- 如果强制状态非空,就按状态过滤;
- 按创建时间倒序,再按 ID 倒序;
- 根据
pageNum和pageSize执行分页查询。
这段 Wrapper 大致对应 SQL:
sql
SELECT *
FROM mall_product
WHERE (title LIKE CONCAT('%', ?, '%') OR description LIKE CONCAT('%', ?, '%'))
AND category_id = ?
AND status_code = ?
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?;
实际 SQL 会由 MyBatis-Plus 和分页插件生成,参数也会根据哪些条件存在而变化。比如没有 keyword,就不会有 LIKE 条件;没有 categoryId,就不会有分类条件。
这和前端对象过滤很像,但要注意 SQL 的条件组合。wrapper.and(condition -> condition.like(...).or().like(...)) 是为了把标题和描述的 OR 包在一组括号里。否则复杂条件很容易出现逻辑优先级错误。
3.10 公开查询为什么强制 ON_SALE
ProductDbService 有两个方法:
java
public Page<ProductEntity> queryAdminPage(ProductQueryRequest request) {
return queryPage(request, request.getStatus());
}
public Page<ProductEntity> queryPublishedPage(ProductQueryRequest request) {
return queryPage(request, ProductStatus.ON_SALE);
}
后台查询使用请求中的状态条件,管理员可以查看草稿、上架、下架。公开查询强制状态为 ON_SALE,不能让客户端通过 query 参数绕过商品可见性规则。
这正是前端转后端必须理解的安全边界。前端页面可以不展示草稿商品入口,但用户可以手写 URL:
bash
curl 'http://localhost:8080/api/products?status=DRAFT'
如果后端直接相信 request.getStatus(),匿名用户就可能查到草稿商品。当前项目通过 queryPublishedPage(request) 把状态强制为 ON_SALE,这是正确的后端兜底。
3.11 更新:LambdaUpdateWrapper
updateSubtitle 方法是:
java
public ProductEntity updateSubtitle(Long productId, String subtitle) {
LocalDateTime now = LocalDateTime.now();
LambdaUpdateWrapper<ProductEntity> wrapper = Wrappers.lambdaUpdate(ProductEntity.class)
.eq(ProductEntity::getId, productId)
.set(ProductEntity::getSubtitle, subtitle)
.set(ProductEntity::getUpdatedAt, now);
int affectedRows = baseMapper.update(null, wrapper);
if (affectedRows == 0) {
throw productNotFound();
}
return requireById(productId);
}
它大致对应:
sql
UPDATE mall_product
SET subtitle = ?, updated_at = ?
WHERE id = ?;
这里没有先查出整个 Entity 再保存,而是用 update wrapper 只更新副标题和更新时间。affectedRows 表示影响行数。如果为 0,说明没有这个商品 ID,于是抛 PRODUCT_NOT_FOUND。
前端类比:修改一个对象的局部字段,不一定要把整个对象重新提交。PATCH 接口通常也是局部更新。后端数据库更新也可以只更新必要列,减少误改其他字段的风险。
3.12 分页插件和乐观锁插件
当前 MybatisPlusConfig 注册了两个插件:
java
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
分页插件会帮助 page(...) 方法生成 MySQL 分页 SQL,例如 LIMIT。没有分页插件,Page 参数可能无法按预期生效。
乐观锁插件用于根据 @Version 生成版本条件。当前商品表没有 version 字段,但 SKU 表有 version,后续库存并发章节会讲乐观锁如何防止并发覆盖。这里先知道:MyBatis-Plus 插件是通过拦截 SQL 执行过程增强功能,不是业务代码里手写每一条 SQL。
@MapperScan("com.example.fullstackmall.service.**.mapper") 则负责扫描 Mapper。没有扫描,Spring 容器里就没有 Mapper Bean,DbService 也无法正常使用底层 Mapper。
3.13 常用 CRUD 方法先建立词汇表
MyBatis-Plus 的 API 很多,初学阶段不需要一次背完。你先把当前项目可能遇到的常用词汇表建立起来就够了。
save(entity) 通常对应插入一条记录。创建商品时,Facade 组装 ProductEntity,再调用 productDbService.save(product),最终写入 mall_product。如果主键策略是自增,插入成功后数据库生成的 ID 会回填到 Entity。前端类比就是提交表单后,后端返回带 ID 的新对象。
getById(id) 对应按主键查一条。ProductDbService.requireById 就是在 getById 外面加了一层"查不到抛业务异常"。很多后端团队都会封装 requireXxx 方法,因为业务代码经常需要"必须存在,否则报错"的语义。
updateById(entity) 对应按 Entity 主键更新。它适合你已经拿到完整 Entity,并且明确要按 ID 保存当前对象状态的场景。当前商品状态变更中,先查出商品,再修改 statusCode 和 updatedAt,然后调用 updateById(product)。
baseMapper.update(null, wrapper) 对应按条件局部更新。当前修改副标题接口只更新 subtitle 和 updated_at,并用 id 作为条件。这种方式不会把整个 Entity 的所有字段都提交给数据库,适合 PATCH 类局部修改。
listByIds(ids) 对应按一批 ID 查询多条。CategoryDbService.findCategoryMap 使用它批量查分类,避免 N+1。前端类比就是先收集所有 categoryId,再一次性请求字典,而不是每个商品单独请求一次。
page(page, wrapper) 对应分页查询。它不仅查当前页记录,还会提供 total、pages 等分页元信息。前端分页组件需要的不只是列表,还需要总数和页数,后端分页对象就是为这个场景服务的。
lambdaQuery() 和 Wrappers.lambdaQuery(...) 都用于构造查询条件。你可以先把它们理解成"SQL WHERE / ORDER BY 的 Java 表达"。等你熟悉后,再学习更复杂的 select、group、exists、自定义 SQL。
3.14 为什么推荐 Lambda Wrapper,而不是字符串字段名
MyBatis-Plus 也支持用字符串写字段名,例如某些写法可能是 eq("status_code", "ON_SALE")。但当前项目大量使用 ProductEntity::getStatusCode 这种 Lambda 方法引用。它的好处是:
- 字段重命名时更容易被 IDE 和编译器发现;
- 不容易把数据库列名、Java 字段名写混;
- 阅读时能直接跳转到 Entity 字段;
- 和 Java 类型系统结合更紧密;
- 对前端转后端同学更友好,因为它像 TypeScript 里的类型安全访问。
前端类比:如果你在 TypeScript 里写 item.statusCode,字段不存在时编译器会提醒;如果你写字符串 'status_code',拼错了可能运行时才发现。后端也是同样道理。Lambda Wrapper 不是绝对避免所有错误,但它比到处写字符串更安全。
当然,SQL 最终仍然使用数据库列名。ProductEntity::getStatusCode 只是 Java 侧表达,MyBatis-Plus 会根据映射关系转换成 status_code。所以你要同时熟悉两套命名:Java 代码里看驼峰,SQL 表里看下划线。
3.15 one()、list()、page() 的语义差异
初学者经常把查询结果类型弄混。one() 表示期望一条记录,返回 Entity 或 null。适合按主键、唯一字段、强唯一条件查询。比如公开商品详情按 ID 和状态查,期望最多一条。
list() 表示返回多条记录,结果是 List<Entity>。适合按一组条件查所有匹配记录,但要小心数据量。如果条件范围很大,直接 list() 可能拉出大量数据。后端列表接口通常不要直接 list 全部,而要分页。
page() 表示分页查询,返回 Page<Entity>。它同时包含 records、total、current、size、pages 等信息。前端表格分页、商品列表、订单列表都应该优先考虑 page,而不是 list。
这和前端 API 设计也一致:详情接口返回一个对象,字典接口可能返回一个小列表,业务列表接口通常返回分页对象。不同返回类型表达不同业务预期。
3.16 DbService 中抛业务异常是否合理
你可能会问:ProductDbService 是数据库访问层,为什么会抛 BusinessException?这涉及分层取舍。严格分层下,持久层可能只返回 null 或底层异常,由业务层转换。但当前项目的 DbService 不只是裸 Mapper,它是"数据库访问 Service",封装了和数据库查询紧密相关的业务语义,例如商品不存在、分类不存在、公开商品必须上架。
这样的好处是调用方更简单:Facade 可以调用 requireById、requirePublishedById、requireEnabledCategory,不用每次重复写 null 判断和错误码。坏处是 DbService 需要知道一些业务错误码。当前项目规模适中,这种取舍是可以接受的。
前端类比:你可以在底层 fetch 只返回原始 Response,也可以在 API client 中把 404 转成 ProductNotFoundError。只要团队约定清楚,关键是不要让同一种错误在不同地方用不同方式表达。
3.17 Entity 字段为什么有时是 String,有时是枚举
ProductEntity.statusCode 是 String,而 ProductDetailResponse.status 是 ProductStatus 枚举。数据库表里 status_code 是 VARCHAR(20),所以 Entity 直接用 String 保存数据库编码。对外响应时,Facade 用 ProductStatus.valueOf(product.getStatusCode()) 转成枚举。
这种设计让数据库存储和接口表达分开。数据库保存稳定编码,Java 业务层可以用枚举提升可读性和类型安全。你以后也可能看到另一种做法:Entity 字段直接使用枚举,并通过 TypeHandler 映射数据库字符串。两种都可以,关键是团队统一。
初学阶段先记住:数据库状态字段通常是字符串编码或数字编码;Java 业务代码最好不要到处散落魔法字符串;对外接口要给前端稳定、可理解的状态值。
3.18 从前端 filter 思维升级到数据库条件思维
前端同学很容易把查询想成数组过滤:先拿全部商品,再 filter、sort、slice。在小数据量演示里这当然能工作,但后端不能这么做。后端查询应该尽量让数据库完成过滤、排序、分页,只把当前页必要数据传回 Java。
原因很简单:数据库可能有几十万、几百万条商品或订单。把全部数据查到 Java 内存再过滤,会浪费网络、内存和 CPU,还可能拖垮服务。SQL 的 WHERE、ORDER BY、LIMIT,以及索引,都是为了让数据在数据库层就被缩小范围。
所以你读 queryPage 时,不要只看 Java 链式 API,要在脑子里翻译成 SQL:哪些条件进入 WHERE,哪些字段用于 ORDER BY,分页如何 LIMIT,哪些索引可能被用上。这个能力是从前端转后端的关键分水岭。
4. 在本项目中对应哪些文件
| 文件 | 本章关注点 |
|---|---|
backend/service/src/main/java/com/example/fullstackmall/service/config/MybatisPlusConfig.java |
Mapper 扫描、分页插件、乐观锁插件 |
backend/service/src/main/resources/application.yml |
下划线转驼峰、全局主键策略配置 |
backend/service/src/main/java/com/example/fullstackmall/service/product/entity/ProductEntity.java |
mall_product 表对应 Entity |
backend/service/src/main/java/com/example/fullstackmall/service/product/mapper/ProductMapper.java |
商品 Mapper,继承 BaseMapper |
backend/service/src/main/java/com/example/fullstackmall/service/product/service/ProductDbService.java |
商品数据库访问封装、查询条件、分页、更新 |
backend/service/src/main/java/com/example/fullstackmall/service/category/entity/CategoryEntity.java |
mall_category 表对应 Entity |
backend/service/src/main/java/com/example/fullstackmall/service/category/mapper/CategoryMapper.java |
分类 Mapper |
backend/service/src/main/java/com/example/fullstackmall/service/category/service/CategoryDbService.java |
分类查询、启用校验、批量查 Map |
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductFacade.java |
业务层如何调用 DbService 并组装 Response DTO |
sql/01_schema.sql |
Entity 字段对应的真实数据库表结构 |
建议你一边打开 SQL,一边打开 Entity,对照字段读。不要孤立看 Java 类,也不要孤立看 SQL 表。
5. Mermaid 图:MyBatis-Plus 分层关系
这张图的重点是:MyBatis-Plus 不应该直接出现在 Controller 的思维里。Controller 面向 HTTP;Entity / Mapper 面向数据库;Facade 负责把这些层连接成业务用例。
6. 逐段读源码
6.1 读 ProductEntity
ProductEntity 的类注释写着"mall_product 表对应的持久化对象"。这句话已经说明它的定位:它不是 Request,也不是 Response,而是数据库表的 Java 表达。
@TableName("mall_product") 对应第 4 篇的商品表。字段 categoryId 对应 category_id,statusCode 对应 status_code,createdBy 对应 created_by。标题、描述、副标题等字段因为名称较简单,可以依靠默认映射。
这里有一个重要注释:商品标题是"可变的当前值,不是订单中的历史快照"。这和第 4 篇订单项快照对应。Entity 代表当前商品表,而不是历史订单事实。后续订单项会有自己的 Entity。
createdBy 的注释强调"来自可信登录态,而不是前端任意指定"。这再次连接第三篇的 Request DTO 边界:外部输入不能随便决定数据库字段。
6.2 读 ProductMapper
ProductMapper 只有一行继承:
java
public interface ProductMapper extends BaseMapper<ProductEntity> {
}
这就是 MyBatis-Plus 最常见的写法。只要 Entity 和表映射正确,通用 Mapper 就能提供基础 CRUD。项目后续如果需要复杂 SQL,可以在 Mapper 中新增自定义方法,配合注解或 XML。但当前商品基础查询大量使用 MyBatis-Plus Wrapper,不需要手写 Mapper 方法。
初学者不要因为 Mapper 为空就忽略它。它是 MyBatis-Plus 生成数据库代理的入口,也是 DbService 的泛型参数之一。没有 Mapper,就没有底层表操作能力。
6.3 读 ProductDbService.requireById
java
public ProductEntity requireById(Long productId) {
ProductEntity product = getById(productId);
if (product == null) {
throw productNotFound();
}
return product;
}
getById 来自 ServiceImpl。它根据主键查询一条记录。这个方法额外做了"查不到就抛业务异常"的封装。
为什么不让调用方自己判断 null?因为商品不存在是一个稳定业务场景,应该统一转换为 PRODUCT_NOT_FOUND,最终由 GlobalExceptionHandler 返回给前端。把这个规则放在 DbService 里,Facade 调用时更简单。
前端类比:API client 可以把某些通用错误转成统一错误对象,而不是让每个组件都写一遍相同判断。
6.4 读 requirePublishedById
java
ProductEntity product = lambdaQuery()
.eq(ProductEntity::getId, productId)
.eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name())
.one();
它不仅按 ID 查,还要求状态是 ON_SALE。公开商品详情调用的是这个方法,所以草稿或下架商品即使数据库里存在,也不会被公开接口返回。
这段代码非常适合作为 MyBatis-Plus 入门例子:
lambdaQuery()创建查询;eq添加等值条件;- 方法引用避免硬编码字符串字段名;
one()执行并返回一条;- 查不到转业务异常。
对应 SQL 心智模型是:
sql
SELECT * FROM mall_product
WHERE id = ? AND status_code = 'ON_SALE'
LIMIT 1;
6.5 读 queryAdminPage 和 queryPublishedPage
这两个方法共用 queryPage,但传入状态不同:
java
public Page<ProductEntity> queryAdminPage(ProductQueryRequest request) {
return queryPage(request, request.getStatus());
}
public Page<ProductEntity> queryPublishedPage(ProductQueryRequest request) {
return queryPage(request, ProductStatus.ON_SALE);
}
这就是同一套数据库查询条件在不同业务入口下的差异。后台入口相信管理员可以按状态筛选;公开入口强制上架状态。
不要把这个逻辑放给前端处理。前端可以隐藏状态筛选器,但不能阻止用户手动拼参数。后端通过 DbService 强制条件,才是真正安全。
6.6 读 queryPage 的关键字条件
java
String keyword = normalizeKeyword(request.getKeyword());
if (StringUtils.hasText(keyword)) {
wrapper.and(condition -> condition
.like(ProductEntity::getTitle, keyword)
.or()
.like(ProductEntity::getDescription, keyword));
}
normalizeKeyword 会 trim,StringUtils.hasText 判断是否有有效文本。只有关键字非空才加模糊查询条件。and(... like title or like description ...) 表示标题或描述包含关键字。
前端类比:
ts
// 前端侧示意代码
const keyword = form.keyword?.trim()
if (keyword) {
list = list.filter(item =>
item.title.includes(keyword) || item.description?.includes(keyword)
)
}
但后端不要真的把全部商品查出来再用 Java 过滤,而是把条件交给数据库。数据库有索引、分页、执行计划,适合做数据筛选。
6.7 读分类条件和状态条件
java
if (request.getCategoryId() != null) {
wrapper.eq(ProductEntity::getCategoryId, request.getCategoryId());
}
if (forcedStatus != null) {
wrapper.eq(ProductEntity::getStatusCode, forcedStatus.name());
}
这两个条件都只有在值存在时才添加。它体现了动态查询:同一个接口可以支持可选筛选条件。用户不选分类,就不按分类过滤;公开查询一定有 ON_SALE;后台查询只有传状态时才按状态过滤。
动态查询是后端列表接口常见需求。MyBatis-Plus Wrapper 的优势就是让动态条件比手写字符串 SQL 更安全、更易读。
6.8 读排序和分页
java
wrapper.orderByDesc(ProductEntity::getCreatedAt)
.orderByDesc(ProductEntity::getId);
return page(new Page<>(request.getPageNum(), request.getPageSize()), wrapper);
按 createdAt 倒序,最新商品在前;再按 id 倒序,是为了在创建时间相同的情况下有稳定排序。分页查询如果排序不稳定,用户翻页时可能看到重复或遗漏记录。
new Page<>(request.getPageNum(), request.getPageSize()) 对应前端传来的分页参数。MyBatis-Plus 的分页插件会把它转换为数据库分页 SQL,并返回 Page<ProductEntity>,里面有当前页、每页大小、总数、总页数、记录列表。
随后 ProductFacade.toPageResponse(...) 会把 Page<ProductEntity> 转成对外的 PageResponse<ProductSummaryResponse>。这一步很重要:Entity 不直接返回给前端。
6.9 读分类 DbService 的批量查询
CategoryDbService.findCategoryMap:
java
public Map<Long, CategoryEntity> findCategoryMap(Collection<Long> categoryIds) {
if (categoryIds == null || categoryIds.isEmpty()) {
return Collections.emptyMap();
}
return listByIds(categoryIds).stream()
.collect(Collectors.toMap(CategoryEntity::getId, Function.identity()));
}
ProductFacade.toPageResponse(...) 会先从商品列表中收集所有分类 ID,然后一次性查询分类 Map,再组装响应。这是为了避免 N+1 查询。
什么是 N+1?假设商品列表有 10 条,如果每条商品都单独查一次分类,就是 1 次查商品 + 10 次查分类。数据一多,数据库压力会明显增加。批量查询分类后建 Map,就变成 1 次查商品 + 1 次查分类。
前端类比:不要在列表渲染每一行时都发一个接口查字典,而是先批量拿到字典,再用 Map 映射。后端也是同样思想。
6.10 读 Facade 如何把 Entity 转 Response
ProductFacade 里有:
java
private ProductDetailResponse toDetailResponse(ProductEntity product, CategoryEntity category) {
return new ProductDetailResponse(
product.getId(),
product.getCategoryId(),
category.getName(),
product.getTitle(),
product.getSubtitle(),
product.getDescription(),
ProductStatus.valueOf(product.getStatusCode()),
product.getCreatedBy(),
product.getCreatedAt(),
product.getUpdatedAt()
);
}
这里完成了 Entity 到 Response DTO 的转换。categoryName 来自 CategoryEntity,商品状态从数据库字符串转换成 ProductStatus 枚举。
这一步再次说明:数据库 Entity 不等于响应对象。Response 可以组合多个 Entity,也可以隐藏内部字段,也可以把数据库编码转换成更适合前端理解的枚举。
6.11 读更新副标题的完整链路
修改副标题链路是:
text
PATCH /api/admin/products/{id}/subtitle
→ ProductAdminController.updateSubtitle
→ ProductFacade.updateSubtitle
→ ProductDbService.updateSubtitle
→ MyBatis-Plus baseMapper.update
→ mall_product.subtitle / updated_at
→ 删除商品详情缓存
→ 重新查询分类并组装 ProductDetailResponse
这个链路很适合作为前端转后端练习。前端看到的是一个 PATCH 请求,后端实际经历了参数校验、业务编排、数据库局部更新、影响行数判断、缓存失效、响应组装。
如果更新影响行数为 0,说明商品不存在,DbService 会抛出 PRODUCT_NOT_FOUND。如果数据库更新成功,Facade 再删除缓存,避免前端下次看到旧副标题。缓存细节后面会讲,但你现在要先知道:数据库成功是事实,缓存只是加速副本。
7. 本地运行 / 如何观察 MyBatis-Plus 行为
7.1 编译确认 Entity / Mapper 没问题
修改 Entity、Mapper、DbService 后,先运行:
bash
cd backend
mvn -pl service -am compile
Java 是静态类型语言,很多字段名、泛型、方法引用错误会在编译期暴露。比如你把 ProductEntity::getStatusCode 写错,编译就可能失败。
7.2 启动服务并请求商品列表
bash
cd backend
mvn -pl service -am spring-boot:run -Dspring-boot.run.profiles=local
另一个终端请求:
bash
curl 'http://localhost:8080/api/products?pageNum=1&pageSize=10&keyword=Java'
你可以把这个请求和 queryPage 对照:pageNum、pageSize 进入 Page;keyword 进入 like title or like description;公开查询强制 ON_SALE。
7.3 用 SQL 验证查询条件
如果使用 MySQL,可以手写 SQL 对照:
sql
SELECT id, title, description, status_code, created_at
FROM mall_product
WHERE status_code = 'ON_SALE'
AND (title LIKE '%Java%' OR description LIKE '%Java%')
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 0;
如果 SQL 结果和接口不一致,优先检查 profile。local profile 使用 H2 内存数据,默认 profile 使用 MySQL。你查 MySQL,但服务连 H2,就会产生错觉。
7.4 验证详情只查上架商品
bash
curl 'http://localhost:8080/api/products/1'
curl 'http://localhost:8080/api/products/2'
根据种子数据,商品 1 可能是上架,商品 2 可能是草稿。公开详情调用 requirePublishedById,所以草稿商品即使存在于数据库,也不应该被公开接口返回。
这就是 MyBatis-Plus 查询条件和业务规则的结合:.eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name()) 不只是技术代码,而是商品可见性规则。
7.5 验证更新副标题
管理员接口可能需要登录,后续安全章节会讲 token。这里先看 SQL 层变化。如果你通过接口或测试触发了副标题更新,可以用 SQL 验证:
sql
SELECT id, subtitle, updated_at
FROM mall_product
WHERE id = 1;
你应该看到 subtitle 更新,updated_at 也更新。对应代码在 LambdaUpdateWrapper 的两个 .set(...)。
7.6 建议的阅读和调试顺序
当你遇到一个数据库相关接口问题时,建议按固定顺序排查。第一步看 Controller,确认请求路径、method、参数位置是否正确;第二步看 Facade,确认业务入口调用了哪个 DbService 方法;第三步看 DbService,确认 Wrapper 条件、分页参数、排序字段是否符合预期;第四步看 Entity,确认 Java 字段和数据库列是否正确映射;第五步看 SQL 表结构,确认字段类型、索引、约束、默认值;第六步直接用 SQL 客户端查询数据库,确认真实数据是否存在。
这个顺序能避免你在错误层面浪费时间。例如接口查不到商品,不一定是 MyBatis-Plus 坏了,可能是公开查询强制 ON_SALE,而数据库里的商品是 DRAFT。分页结果为空,也不一定是分页插件问题,可能是你启用了 local profile,实际连的是 H2,不是你正在查看的 MySQL。更新成功但前端仍然看到旧值,也不一定是 SQL 没执行,可能是 Redis 缓存没有失效或浏览器缓存了旧响应。
前端调试有 Network、Console、组件状态、接口响应;后端调试也要有 Controller 参数、业务方法、数据库条件、真实 SQL 数据、日志 traceId。不要只盯着一层。全栈能力的核心不是会更多工具,而是能把一次请求从浏览器一路追到数据库,再从数据库结果一路解释回前端页面。如果你每次都能说清"这个字段从哪个请求来、经过哪个 Java 对象、落到哪张表、又如何被查出来返回",你的后端阅读能力就已经真正起步了。后续学习 Redis、事务和安全时,也要沿用这种证据链思维,而不是孤立背框架 API。先定位边界,再确认数据,再解释代码,最后验证结果。这就是从会调用接口走向会设计和排查后端接口的关键路径,也是一名全栈工程师必须建立的基本能力,需要在真实工程里反复持续刻意练习。
8. 常见错误
错误 1:把 Entity 直接返回给前端
Entity 面向数据库,Response DTO 面向接口契约。直接返回 Entity 会泄露内部字段,也会让数据库结构变化影响前端契约。当前项目通过 toDetailResponse、toSummaryResponse 做转换,是更好的做法。
错误 2:把 Request DTO 当 Entity 保存
前端传来的 Request DTO 是外部输入白名单,不能直接当 Entity。创建商品时,后端要补充状态、创建人、创建时间、更新时间,还要 trim 文本、校验分类,这些都不是前端能决定的。
错误 3:以为 MyBatis-Plus 可以替代 SQL 基础
Wrapper 看起来像 Java 链式调用,但最终仍然生成 SQL。你必须能把 .eq、.like、.orderByDesc、page 翻译成大概 SQL,否则遇到性能或结果错误就无法排查。
错误 4:公开查询相信前端传入的状态
公开商品接口必须强制 ON_SALE,不能让客户端传 status=DRAFT 查草稿。当前项目通过 queryPublishedPage(request) 传入 ProductStatus.ON_SALE 做后端兜底。
错误 5:动态条件没有判断空值
如果不判断 keyword 是否有文本,就可能生成无意义的 LIKE '%%'。如果不判断 categoryId 是否为空,就可能生成错误条件。动态查询一定要清楚哪些条件可选。
错误 6:排序不稳定导致分页重复
只按创建时间排序,如果多条记录时间相同,分页可能不稳定。当前项目再按 ID 倒序,能提高稳定性。真实项目分页排序要格外注意。
错误 7:列表组装产生 N+1 查询
遍历每个商品时都查一次分类,会导致大量 SQL。当前项目先批量查询分类 Map,再组装响应,是避免 N+1 的典型做法。
错误 8:Mapper 没有被扫描
如果忘记 @MapperScan 或包路径不匹配,Mapper 代理不会被创建,启动时可能注入失败。当前项目扫描 com.example.fullstackmall.service.**.mapper。
错误 9:更新后忘记缓存失效
数据库更新成功后,如果商品详情缓存还在,前端可能继续看到旧数据。当前 ProductFacade.updateSubtitle 在 MySQL 修改成功后删除缓存。缓存章节会深入讲这个问题。
错误 10:忽略影响行数
更新副标题时 baseMapper.update 返回影响行数。如果是 0,说明没有更新到任何商品,不能假装成功。当前代码会抛 PRODUCT_NOT_FOUND。
9. 本章小练习
练习 1:对照 Entity 和表字段
打开:
text
sql/01_schema.sql
backend/service/src/main/java/com/example/fullstackmall/service/product/entity/ProductEntity.java
回答:
status_code对应 Java 哪个字段?created_by对应 Java 哪个字段?- 主键 ID 由谁生成?
- 哪些字段没有显式
@TableField,为什么仍然能映射?
练习 2:把 Wrapper 翻译成 SQL
把下面代码翻译成你能理解的 SQL:
java
lambdaQuery()
.eq(ProductEntity::getId, productId)
.eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name())
.one();
练习 3:解释公开查询和后台查询差异
阅读 queryAdminPage 和 queryPublishedPage,回答:
- 哪个方法允许使用请求里的状态?
- 哪个方法强制
ON_SALE? - 为什么不能把这个判断交给前端?
练习 4:查找 N+1 防护
打开 ProductFacade.toPageResponse(...) 和 CategoryDbService.findCategoryMap(...),回答:
- 商品列表为什么要批量查询分类?
- 如果每个商品单独查一次分类,会产生什么问题?
- 前端开发中有没有类似"列表里每行单独请求"的性能问题?
练习 5:写一个查询需求的 Wrapper 思路
假设要查询某个分类下的已上架商品,并按创建时间倒序,你会用哪些 Wrapper 条件?不用写完全正确代码,只要写出:
eq哪些字段;orderByDesc哪些字段;- 是否需要分页。
练习 6:解释更新副标题链路
用自己的话说明:
text
PATCH /api/admin/products/{id}/subtitle
从 Controller 到 Facade,再到 ProductDbService.updateSubtitle,最后到数据库更新和缓存删除的流程。
10. 本篇总结
这一篇我们把第 4 篇的 SQL 表结构和 Java 后端代码连接起来了。你现在应该知道:
- MyBatis-Plus 是基于 MyBatis 的增强工具,能减少常用 CRUD 样板代码;
- Entity 是数据库表在 Java 里的持久化对象;
@TableName指定表名,@TableId指定主键和生成策略,@TableField指定列名映射;- Mapper 继承
BaseMapper<Entity>后拥有基础数据库操作能力; @MapperScan负责扫描 Mapper 接口并创建代理;- DbService 继承
ServiceImpl<Mapper, Entity>后拥有getById、save、page、lambdaQuery等常用方法; LambdaQueryWrapper用 Java 链式 API 表达 SQL 条件;- 分页查询使用
Page<>(pageNum, pageSize),并依赖分页插件生成分页 SQL; - 公开商品查询必须强制
ON_SALE,不能相信前端传参; - Facade 负责把 Entity 转成 Response DTO,并编排分类查询、缓存失效等业务步骤;
- 避免 N+1 查询是后端列表接口的重要性能意识。
前端转后端学习 MyBatis-Plus,最重要的不是背 API,而是建立三层映射:数据库表字段、Java Entity 字段、接口 Response 字段。只要这三层清楚,你就能沿着 Controller → Facade → DbService → Mapper → MySQL 的链路读懂大多数接口。
11. 下一章预告
下一篇是第 6 篇:登录、JWT 与 Spring Security:从前端 token 到后端认证授权链路。
我们会讲:
- 登录接口如何校验用户名和密码;
- 密码为什么保存 BCrypt 哈希;
- JWT 是什么,为什么前端要保存 token;
- Spring Security Filter 如何在 Controller 前拦截请求;
- 401 和 403 的区别;
- 管理员接口为什么不能只靠前端隐藏按钮;
CurrentUserService如何为业务代码提供可信用户 ID。
学完下一篇,你就能理解为什么本项目的管理员创建商品接口不能随便调用,以及 createdBy 为什么必须来自后端登录上下文。