ArticleJava 后端
MyBatis 和 MyBatis-Plus 到底差在哪?结合博客后端项目从 Mapper 层讲明白
本文结合 `study-blog` 后端项目,讲清楚原生 MyBatis 和 MyBatis-Plus 在 Mapper、Service、CRUD、分页、逻辑删除、自定义 SQL 等场景下的用法区别。
页面目录
注:本文由Codex GPT-5.5 xhigh 生成
一、引言:为什么要比较 MyBatis 和 MyBatis-Plus?
刚开始写后端的时候,很容易被一堆名词绕晕:
Controller
Service
Mapper
Entity
DTO
VO
XML
Wrapper
BaseMapper
ServiceImpl尤其是看到项目里的 Mapper 接口时,大概率会有一种灵魂发问:
public interface ArticleMapper extends BaseMapper<Article> {
}啊?这就完了?
SQL 呢?增删改查呢?select * from article where id = ? 呢?
先别急,这正是 MyBatis-Plus 最容易让初学者产生困惑的地方:它不是没有 SQL,而是把大量通用 SQL 封装好了。
本文不空讲概念,直接结合本博客后端项目来讲。当前项目使用的是 MyBatis-Plus,也就是常说的 MP。我们会先看项目里 MP 是怎么写的,再假设如果换成原生 MyBatis,大概需要写成什么样。
一句话先粗暴理解:
MyBatis:你写 SQL,我帮你执行。
MyBatis-Plus:常见 SQL 我先帮你写好,复杂 SQL 你再自己补。二、先看项目结构:Mapper 层到底在哪?
在这个博客后端项目里,整体调用链路大概是这样:
前端请求
↓
Controller 接收请求
↓
Service 处理业务逻辑
↓
Mapper 操作数据库
↓
MySQL项目中相关目录大概如下:
src/main/java/com/example/studyblog
├── controller
│ ├── admin
│ └── publicapi
├── service
│ └── impl
├── mapper
├── entity
├── dto
└── vo其中 Mapper 层就在:
src/main/java/com/example/studyblog/mapper比如:
ArticleMapper.java
ArticleTagMapper.java
ArticleTagRelMapper.java
ArticleCategoryMapper.java
StudyDiaryMapper.java
ProjectInfoMapper.java
ResourceItemMapper.java
SiteConfigMapper.java启动类里有一行非常关键的配置:
package com.example.studyblog;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.example.studyblog.mapper")
public class StudyBlogApplication {
public static void main(String[] args) {
SpringApplication.run(StudyBlogApplication.class, args);
}
}@MapperScan("com.example.studyblog.mapper") 的意思是:
请扫描 com.example.studyblog.mapper 包下面的所有 Mapper 接口,
并把它们注册到 Spring 容器里。如果没有这行配置,后面在 Service 里注入 ArticleMapper、SysUserMapper 的时候,Spring 就会一脸懵:你说的这个 Mapper 是谁?
三、Entity 对比:MyBatis 和 MP 都离不开实体类
不管用 MyBatis 还是 MyBatis-Plus,实体类都是绕不开的。
实体类的作用很简单:
数据库表中的一行数据 <-> Java 中的一个对象比如项目里的文章实体类:
package com.example.studyblog.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TableName("article")
@EqualsAndHashCode(callSuper = true)
public class Article extends BaseEntity {
private String title;
private String slug;
private String summary;
private String coverImage;
private String contentMd;
private String contentHtml;
private Long authorId;
private Long categoryId;
private String status;
private Integer isTop;
private Long viewCount;
private Integer wordCount;
private Integer sort;
private LocalDateTime publishTime;
}这里有一个 MP 的注解:
@TableName("article")它表示:
Article 这个 Java 类,对应数据库里的 article 表。如果使用原生 MyBatis,这个实体类也可以存在,只不过 @TableName 通常不是必需的。因为原生 MyBatis 更多是通过 XML 里的 SQL、resultMap 或字段别名来做映射。
换句话说:
MyBatis-Plus 更喜欢在实体类上写映射规则。
MyBatis 更喜欢在 XML 或 SQL 中写映射规则。四、公共字段:BaseEntity 里藏着 MP 的几个重点
项目中很多实体类都继承了 BaseEntity:
package com.example.studyblog.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public abstract class BaseEntity {
@TableId(type = IdType.AUTO)
private Long id;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private Integer isDeleted;
}这几个字段基本是后台管理系统里的老熟人:
id 主键
createdAt 创建时间
updatedAt 更新时间
isDeleted 是否删除重点看几个 MP 注解。
1. @TableId
@TableId(type = IdType.AUTO)
private Long id;意思是:
id 是主键,并且使用数据库自增策略。假设数据库中 article.id 是 AUTO_INCREMENT,那么插入文章时不需要手动指定 id,MySQL 会自动生成。
2. @TableField(fill = ...)
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;意思是:
createdAt 在插入时自动填充。
updatedAt 在插入和更新时自动填充。这就避免了每次新增数据都手动写:
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());3. @TableLogic
@TableLogic
@TableField(fill = FieldFill.INSERT)
private Integer isDeleted;这个注解表示逻辑删除。
所谓逻辑删除,就是删除时不是真的把数据从数据库里删掉,而是把某个字段改成删除状态。
比如:
is_deleted = 0 正常数据
is_deleted = 1 已删除数据这样做的好处是:数据还在,只是正常查询时查不到了。万一误删,至少还有机会找回来。不然真物理删除,删完就是删完,数据库可不会心疼你。
五、配置对比:MP 把很多通用规则集中配置了
项目中的 MP 配置在 application-dev.yml:
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
type-aliases-package: com.example.studyblog.entity
global-config:
db-config:
logic-delete-field: isDeleted
logic-not-delete-value: 0
logic-delete-value: 1
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl逐个看。
1. mapper-locations
mapper-locations: classpath*:/mapper/**/*.xml意思是:
如果项目里有 XML Mapper 文件,就去 classpath:/mapper 下面找。虽然当前项目主要用注解 SQL 和 MP 的 BaseMapper,但这个配置保留了 XML 扩展能力。
2. type-aliases-package
type-aliases-package: com.example.studyblog.entity意思是:
给 entity 包下的类注册别名。如果写 XML,就可以少写一点全限定类名。
3. 逻辑删除配置
logic-delete-field: isDeleted
logic-not-delete-value: 0
logic-delete-value: 1意思是:
逻辑删除字段叫 isDeleted。
0 表示未删除。
1 表示已删除。配合实体类里的:
@TableLogic
private Integer isDeleted;MP 就知道删除时应该怎么处理。
4. 下划线转驼峰
map-underscore-to-camel-case: true这个很重要。
数据库字段一般这样命名:
cover_image
content_md
publish_time
created_at
updated_at
is_deletedJava 字段一般这样命名:
coverImage
contentMd
publishTime
createdAt
updatedAt
isDeleted开启这个配置后,MyBatis / MP 就能自动理解:
cover_image <-> coverImage
content_md <-> contentMd
publish_time <-> publishTime不然你就要在 SQL 里疯狂写别名:
SELECT cover_image AS coverImage,
content_md AS contentMd,
publish_time AS publishTime
FROM article不是不能写,就是写多了容易怀疑人生。
六、Mapper 写法对比:MP 为什么可以这么短?
来看当前项目里的 ArticleMapper:
package com.example.studyblog.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.studyblog.entity.Article;
import com.example.studyblog.vo.common.ArchiveVO;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface ArticleMapper extends BaseMapper<Article> {
@Select("""
SELECT DATE_FORMAT(publish_time, '%Y-%m') AS archiveMonth, COUNT(*) AS count
FROM article
WHERE status = 'published' AND is_deleted = 0
GROUP BY DATE_FORMAT(publish_time, '%Y-%m')
ORDER BY archiveMonth DESC
""")
List<ArchiveVO> selectArchiveList();
}最关键的是这一句:
public interface ArticleMapper extends BaseMapper<Article>BaseMapper<Article> 是 MyBatis-Plus 提供的通用 Mapper。
它已经内置了很多基础方法,例如:
articleMapper.selectById(id);
articleMapper.selectList(wrapper);
articleMapper.selectOne(wrapper);
articleMapper.selectCount(wrapper);
articleMapper.insert(article);
articleMapper.updateById(article);
articleMapper.deleteById(id);所以对于普通 CRUD 来说,ArticleMapper 不需要自己写一堆方法。
如果换成原生 MyBatis 会怎样?
原生 MyBatis 中,Mapper 接口通常要自己声明方法:
package com.example.studyblog.mapper;
import com.example.studyblog.entity.Article;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface ArticleMapper {
Article selectById(@Param("id") Long id);
List<Article> selectList();
int insert(Article article);
int updateById(Article article);
int logicDeleteById(@Param("id") Long id);
}然后还要写 XML:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.studyblog.mapper.ArticleMapper">
<select id="selectById" resultType="com.example.studyblog.entity.Article">
SELECT id,
title,
slug,
summary,
cover_image,
content_md,
content_html,
author_id,
category_id,
status,
is_top,
view_count,
word_count,
sort,
publish_time,
created_at,
updated_at,
is_deleted
FROM article
WHERE id = #{id}
AND is_deleted = 0
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO article (
title,
slug,
summary,
cover_image,
content_md,
content_html,
author_id,
category_id,
status,
is_top,
view_count,
word_count,
sort,
publish_time,
created_at,
updated_at,
is_deleted
)
VALUES (
#{title},
#{slug},
#{summary},
#{coverImage},
#{contentMd},
#{contentHtml},
#{authorId},
#{categoryId},
#{status},
#{isTop},
#{viewCount},
#{wordCount},
#{sort},
#{publishTime},
#{createdAt},
#{updatedAt},
#{isDeleted}
)
</insert>
<update id="updateById">
UPDATE article
SET title = #{title},
slug = #{slug},
summary = #{summary},
cover_image = #{coverImage},
content_md = #{contentMd},
content_html = #{contentHtml},
author_id = #{authorId},
category_id = #{categoryId},
status = #{status},
is_top = #{isTop},
view_count = #{viewCount},
word_count = #{wordCount},
sort = #{sort},
publish_time = #{publishTime},
updated_at = #{updatedAt}
WHERE id = #{id}
AND is_deleted = 0
</update>
<update id="logicDeleteById">
UPDATE article
SET is_deleted = 1,
updated_at = NOW()
WHERE id = #{id}
AND is_deleted = 0
</update>
</mapper>可以看到,原生 MyBatis 当然也能完成这些操作,而且 SQL 非常清楚。
但问题是:基础 CRUD 会变得很啰嗦。
如果一个项目里有十几张表,每张表都写一遍 selectById、insert、updateById、logicDeleteById,那就不是写代码了,那是跟键盘进行体力劳动。
所以这里可以得到第一个核心区别:
MyBatis:基础 SQL 也要自己写,控制感强,但重复代码多。
MyBatis-Plus:基础 SQL 已经封装好,开发效率高,但要理解它的规则。七、Service 写法对比:MP 的 ServiceImpl 又帮了什么?
当前项目的文章 Service 实现类是这样写的:
package com.example.studyblog.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.studyblog.entity.Article;
import com.example.studyblog.mapper.ArticleMapper;
import com.example.studyblog.service.ArticleService;
import org.springframework.stereotype.Service;
@Service
public class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {
}关键是:
extends ServiceImpl<ArticleMapper, Article>这句话的意思是:
当前 Service 使用 ArticleMapper 操作 Article 实体。
并且继承 MP 提供的一批通用 Service 方法。继承之后,ArticleServiceImpl 里可以直接使用:
save(entity);
updateById(entity);
removeById(id);
getById(id);
getOne(wrapper);
list(wrapper);
page(page, wrapper);项目里的新增文章
当前项目新增文章的方法:
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(ArticleSaveRequest request, Long authorId) {
checkSlugUnique(request.getSlug(), null);
validateCategoryAndTags(request.getCategoryId(), request.getTagIds());
Article entity = new Article();
applySaveRequest(entity, request, authorId);
save(entity);
saveTagRelations(entity.getId(), request.getTagIds());
return entity.getId();
}这里最关键的是:
save(entity);它背后会调用 MP 封装好的插入逻辑,最终插入 article 表。
如果用原生 MyBatis
原生 MyBatis 中,Service 往往要直接注入 Mapper:
@Service
public class ArticleServiceImpl implements ArticleService {
private final ArticleMapper articleMapper;
public ArticleServiceImpl(ArticleMapper articleMapper) {
this.articleMapper = articleMapper;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(ArticleSaveRequest request, Long authorId) {
checkSlugUnique(request.getSlug(), null);
validateCategoryAndTags(request.getCategoryId(), request.getTagIds());
Article entity = new Article();
applySaveRequest(entity, request, authorId);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
entity.setIsDeleted(0);
articleMapper.insert(entity);
saveTagRelations(entity.getId(), request.getTagIds());
return entity.getId();
}
}对比一下:
MP:save(entity)
MyBatis:articleMapper.insert(entity)这并不是谁高级谁低级的问题,只是封装层次不同。
MP 在 Service 层又帮你包了一层,所以你可以少写一些重复的 Mapper 调用。
八、baseMapper 是什么?
在项目里可以看到这样的代码:
@Override
public List<ArchiveVO> getArchiveList() {
return baseMapper.selectArchiveList();
}这里的 baseMapper 是谁?
由于当前类继承了:
ServiceImpl<ArticleMapper, Article>所以 MP 会在 ServiceImpl 里帮你准备好当前实体对应的 Mapper,也就是:
baseMapper ≈ ArticleMapper因此:
baseMapper.selectArchiveList();就等价于在调用 ArticleMapper 中的:
List<ArchiveVO> selectArchiveList();可以简单记:
在 ArticleServiceImpl 中,baseMapper 就是 ArticleMapper。
在 StudyDiaryServiceImpl 中,baseMapper 就是 StudyDiaryMapper。
在 ProjectInfoServiceImpl 中,baseMapper 就是 ProjectInfoMapper。这就是 MP 约定带来的便利。
九、条件查询对比:Wrapper 和 XML 动态 SQL
当前项目大量使用了 LambdaQueryWrapper。
比如后台文章分页查询:
@Override
public PageResult<ArticleListVO> getAdminPage(ArticleQueryRequest request) {
Page<Article> page = page(new Page<>(request.getPageNum(), request.getPageSize()),
new LambdaQueryWrapper<Article>()
.like(StringUtils.hasText(request.getTitle()), Article::getTitle, request.getTitle())
.eq(request.getCategoryId() != null, Article::getCategoryId, request.getCategoryId())
.eq(StringUtils.hasText(request.getStatus()), Article::getStatus, request.getStatus())
.orderByDesc(Article::getUpdatedAt)
.orderByDesc(Article::getCreatedAt));
return buildArticlePageResult(
page.getRecords(),
page.getTotal(),
request.getPageNum(),
request.getPageSize()
);
}这里的:
new LambdaQueryWrapper<Article>()就是 MP 的条件构造器。
逐行看:
.like(StringUtils.hasText(request.getTitle()), Article::getTitle, request.getTitle())意思是:
如果 request.getTitle() 有文本,就拼接 title LIKE 条件。
如果没有文本,就不拼接这个条件。再看:
.eq(request.getCategoryId() != null, Article::getCategoryId, request.getCategoryId())意思是:
如果 categoryId 不为空,就拼接 category_id = ? 条件。整体大概会生成类似 SQL:
SELECT *
FROM article
WHERE is_deleted = 0
AND title LIKE ?
AND category_id = ?
AND status = ?
ORDER BY updated_at DESC, created_at DESC
LIMIT ?, ?这里有一个很舒服的点:
Article::getTitle
Article::getCategoryId
Article::getUpdatedAt它们不是字符串,而是方法引用。
如果字段改名,编译器更容易发现问题。相比直接写 "title"、"category_id",会更稳一点。
如果用原生 MyBatis
原生 MyBatis 里通常会写 Mapper 方法:
List<Article> selectAdminPage(@Param("title") String title,
@Param("categoryId") Long categoryId,
@Param("status") String status,
@Param("offset") Long offset,
@Param("pageSize") Long pageSize);
Long countAdminPage(@Param("title") String title,
@Param("categoryId") Long categoryId,
@Param("status") String status);然后 XML 写动态 SQL:
<select id="selectAdminPage" resultType="com.example.studyblog.entity.Article">
SELECT id,
title,
slug,
summary,
cover_image,
content_md,
content_html,
author_id,
category_id,
status,
is_top,
view_count,
word_count,
sort,
publish_time,
created_at,
updated_at,
is_deleted
FROM article
WHERE is_deleted = 0
<if test="title != null and title != ''">
AND title LIKE CONCAT('%', #{title}, '%')
</if>
<if test="categoryId != null">
AND category_id = #{categoryId}
</if>
<if test="status != null and status != ''">
AND status = #{status}
</if>
ORDER BY updated_at DESC, created_at DESC
LIMIT #{offset}, #{pageSize}
</select>
<select id="countAdminPage" resultType="java.lang.Long">
SELECT COUNT(*)
FROM article
WHERE is_deleted = 0
<if test="title != null and title != ''">
AND title LIKE CONCAT('%', #{title}, '%')
</if>
<if test="categoryId != null">
AND category_id = #{categoryId}
</if>
<if test="status != null and status != ''">
AND status = #{status}
</if>
</select>Service 里还要手动算偏移量:
long offset = (request.getPageNum() - 1) * request.getPageSize();
List<Article> records = articleMapper.selectAdminPage(
request.getTitle(),
request.getCategoryId(),
request.getStatus(),
offset,
request.getPageSize()
);
Long total = articleMapper.countAdminPage(
request.getTitle(),
request.getCategoryId(),
request.getStatus()
);可以看到:
MP 用 Wrapper 写动态条件。
MyBatis 用 XML 的 <if> 写动态条件。二者本质上都在拼 SQL,只是写法不同。
十、分页对比:MP 分页插件少写了什么?
项目中配置了 MP 分页插件:
package com.example.studyblog.common.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}配置之后,Service 中可以这样分页:
Page<Article> page = page(
new Page<>(request.getPageNum(), request.getPageSize()),
new LambdaQueryWrapper<Article>()
.eq(Article::getStatus, "published")
.orderByDesc(Article::getPublishTime)
);然后直接拿结果:
List<Article> records = page.getRecords();
long total = page.getTotal();
long current = page.getCurrent();
long size = page.getSize();这就很省事。
如果用原生 MyBatis
你通常要自己处理两个问题:
1. 查当前页数据
2. 查总条数也就是:
long offset = (pageNum - 1) * pageSize;
List<Article> records = articleMapper.selectPage(offset, pageSize);
Long total = articleMapper.countPage();XML:
<select id="selectPage" resultType="com.example.studyblog.entity.Article">
SELECT *
FROM article
WHERE is_deleted = 0
ORDER BY created_at DESC
LIMIT #{offset}, #{pageSize}
</select>
<select id="countPage" resultType="java.lang.Long">
SELECT COUNT(*)
FROM article
WHERE is_deleted = 0
</select>不是不能做,但每个分页接口都这么写,时间久了就会发现:怎么我每天都在写 LIMIT 和 COUNT(*)?
MP 的分页插件就是为了解决这种重复劳动。
十一、逻辑删除对比:MP 自动帮你加 is_deleted 条件
当前项目中,所有表基本都使用了逻辑删除字段:
is_deletedMP 配置如下:
mybatis-plus:
global-config:
db-config:
logic-delete-field: isDeleted
logic-not-delete-value: 0
logic-delete-value: 1实体中也有:
@TableLogic
@TableField(fill = FieldFill.INSERT)
private Integer isDeleted;项目删除文章的方法:
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getByIdOrThrow(id);
removeById(id);
articleTagRelMapper.hardDeleteByArticleId(id);
}重点看:
removeById(id);这行代码看起来像删除,实际上对于带 @TableLogic 的实体,MP 不会直接执行:
DELETE FROM article WHERE id = ?而是执行类似:
UPDATE article
SET is_deleted = 1
WHERE id = ?
AND is_deleted = 0并且普通查询时,MP 也会自动帮你考虑逻辑删除条件。
比如:
getById(id);正常情况下不会查出 is_deleted = 1 的数据。
如果用原生 MyBatis
删除要自己写:
<update id="logicDeleteById">
UPDATE article
SET is_deleted = 1,
updated_at = NOW()
WHERE id = #{id}
AND is_deleted = 0
</update>查询也要自己记得写:
<select id="selectById" resultType="com.example.studyblog.entity.Article">
SELECT *
FROM article
WHERE id = #{id}
AND is_deleted = 0
</select>分页也要写:
<select id="selectPage" resultType="com.example.studyblog.entity.Article">
SELECT *
FROM article
WHERE is_deleted = 0
ORDER BY created_at DESC
LIMIT #{offset}, #{pageSize}
</select>这里最容易出问题的地方是:忘记加 is_deleted = 0。
如果某个查询忘了加,那些已经逻辑删除的数据就可能又被查出来了。到时候你会发现:我不是删了吗?它怎么又活了?
所以逻辑删除这种高度重复、容易漏的规则,交给 MP 统一处理还是很舒服的。
十二、自动填充对比:createdAt、updatedAt 谁来管?
项目中有一个自动填充处理器:
package com.example.studyblog.common.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Component
public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
strictInsertFill(metaObject, "isDeleted", Integer.class, 0);
}
@Override
public void updateFill(MetaObject metaObject) {
strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
}配合实体类中的:
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private Integer isDeleted;MP 在插入时会自动填:
createdAt = 当前时间
updatedAt = 当前时间
isDeleted = 0更新时会自动填:
updatedAt = 当前时间所以新增文章时,业务代码只需要关心真正的业务字段:
Article entity = new Article();
entity.setTitle(request.getTitle());
entity.setSlug(request.getSlug());
entity.setSummary(request.getSummary());
entity.setContentMd(request.getContentMd());
entity.setContentHtml(request.getContentHtml());
save(entity);如果用原生 MyBatis
你可能要在 Java 里手动设置:
Article entity = new Article();
entity.setTitle(request.getTitle());
entity.setSlug(request.getSlug());
entity.setSummary(request.getSummary());
entity.setContentMd(request.getContentMd());
entity.setContentHtml(request.getContentHtml());
LocalDateTime now = LocalDateTime.now();
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
entity.setIsDeleted(0);
articleMapper.insert(entity);或者在 SQL 中写:
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO article (
title,
slug,
summary,
content_md,
content_html,
created_at,
updated_at,
is_deleted
)
VALUES (
#{title},
#{slug},
#{summary},
#{contentMd},
#{contentHtml},
NOW(),
NOW(),
0
)
</insert>这就是第二个很明显的区别:
MP 可以通过 MetaObjectHandler 统一处理自动填充。
MyBatis 通常需要在 Java 代码或 SQL 里手动处理。十三、自定义 SQL:MP 并不是不能写 SQL
有些人刚接触 MP 时,容易产生一个误解:
用了 MP,是不是就不能写 SQL 了?当然不是。
MP 只是帮你封装通用 CRUD,不代表复杂查询也要硬用 Wrapper 拼出来。
项目里的文章归档就是一个典型例子:
public interface ArticleMapper extends BaseMapper<Article> {
@Select("""
SELECT DATE_FORMAT(publish_time, '%Y-%m') AS archiveMonth, COUNT(*) AS count
FROM article
WHERE status = 'published' AND is_deleted = 0
GROUP BY DATE_FORMAT(publish_time, '%Y-%m')
ORDER BY archiveMonth DESC
""")
List<ArchiveVO> selectArchiveList();
}这段 SQL 做了几件事:
1. 用 DATE_FORMAT 把发布时间格式化成 年-月
2. 按月份分组
3. 统计每个月文章数量
4. 按月份倒序排序这种统计查询,用 SQL 表达非常清楚。如果非要用 Wrapper 硬拼,反而不舒服。
Service 中直接调用:
@Override
public List<ArchiveVO> getArchiveList() {
return baseMapper.selectArchiveList();
}再看一个物理删除例子:
package com.example.studyblog.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.studyblog.entity.ArticleTagRel;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
public interface ArticleTagRelMapper extends BaseMapper<ArticleTagRel> {
@Delete("DELETE FROM article_tag_rel WHERE article_id = #{articleId}")
int hardDeleteByArticleId(@Param("articleId") Long articleId);
}这里为什么不用逻辑删除?
因为 article_tag_rel 是文章和标签的关联表。更新文章标签时,常见做法就是:
先删除旧的关联关系
再插入新的关联关系项目中就是这么做的:
private void saveTagRelations(Long articleId, List<Long> tagIds) {
articleTagRelMapper.hardDeleteByArticleId(articleId);
if (CollectionUtils.isEmpty(tagIds)) {
return;
}
List<Long> distinctIds = new ArrayList<>(new LinkedHashSet<>(tagIds));
for (Long tagId : distinctIds) {
ArticleTagRel rel = new ArticleTagRel();
rel.setArticleId(articleId);
rel.setTagId(tagId);
articleTagRelMapper.insert(rel);
}
}这里同时体现了 MP 和自定义 SQL 的组合用法:
删除旧关系:自定义 SQL hardDeleteByArticleId
插入新关系:MP 的 insert这才是实际项目里比较健康的写法:能省的地方省,该写 SQL 的地方就写 SQL。
十四、完整流程对比:新增一篇文章到底发生了什么?
以后台新增文章为例,当前项目的 Controller 代码大概是:
@PostMapping
public Result<Long> create(@Valid @RequestBody ArticleSaveRequest request) {
return Result.success(articleService.create(request, LoginUserContext.getUserId()));
}进入 Service:
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(ArticleSaveRequest request, Long authorId) {
checkSlugUnique(request.getSlug(), null);
validateCategoryAndTags(request.getCategoryId(), request.getTagIds());
Article entity = new Article();
applySaveRequest(entity, request, authorId);
save(entity);
saveTagRelations(entity.getId(), request.getTagIds());
return entity.getId();
}再看 applySaveRequest:
private void applySaveRequest(Article entity, ArticleSaveRequest request, Long authorId) {
entity.setTitle(request.getTitle());
entity.setSlug(request.getSlug());
entity.setSummary(request.getSummary());
entity.setCoverImage(request.getCoverImage());
entity.setContentMd(request.getContentMd());
entity.setContentHtml(request.getContentHtml());
entity.setAuthorId(authorId);
entity.setCategoryId(request.getCategoryId());
entity.setStatus(request.getStatus());
entity.setIsTop(request.getIsTop() == null ? 0 : request.getIsTop());
entity.setSort(request.getSort() == null ? 0 : request.getSort());
entity.setWordCount(calculateWordCount(request.getContentMd()));
if (entity.getViewCount() == null) {
entity.setViewCount(0L);
}
if ("published".equals(request.getStatus())) {
if (request.getPublishTime() != null) {
entity.setPublishTime(request.getPublishTime());
} else if (entity.getPublishTime() == null) {
entity.setPublishTime(LocalDateTime.now());
}
} else {
entity.setPublishTime(request.getPublishTime());
}
}整体流程就是:
前端请求 POST /api/admin/articles
↓
AdminArticleController.create()
↓
ArticleServiceImpl.create()
↓
checkSlugUnique() 检查 slug 是否重复
↓
validateCategoryAndTags() 检查分类和标签是否存在
↓
applySaveRequest() 把 DTO 转成 Entity
↓
save(entity) 插入 article 表
↓
saveTagRelations() 插入 article_tag_rel 表
↓
返回文章 id其中:
save(entity)是 MP 提供的 Service 方法。
而:
articleTagRelMapper.insert(rel)是 MP 提供的 BaseMapper 方法。
这条链路里,MP 做了很多隐形工作:
1. 根据 Article 实体识别 article 表
2. 根据字段生成 INSERT SQL
3. 插入时自动填充 createdAt、updatedAt、isDeleted
4. 插入成功后回填自增 id
5. 逻辑删除字段默认设为 0如果用原生 MyBatis,这些细节大部分都要手动写在 Java 或 XML 里。
十五、再看删除文章:逻辑删除和物理删除同时存在
项目删除文章:
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getByIdOrThrow(id);
removeById(id);
articleTagRelMapper.hardDeleteByArticleId(id);
}这里其实有两种删除:
removeById(id):逻辑删除文章
hardDeleteByArticleId(id):物理删除文章和标签的关联关系为什么文章要逻辑删除?
因为文章是核心业务数据,误删后可能需要恢复。为什么关联关系可以物理删除?
因为 article_tag_rel 只是关系数据,文章标签更新时本来就可以重建。这说明 MP 不是让你无脑逻辑删除所有东西。
实际项目里要根据业务判断:
核心业务数据:适合逻辑删除。
中间关系数据:很多时候可以物理删除。
日志审计数据:通常不建议删除。
临时缓存数据:可以定期物理清理。十六、MyBatis 和 MP 的核心区别表
整理一下,二者区别大概如下:
| 对比点 | MyBatis | MyBatis-Plus |
|---|---|---|
| Mapper 写法 | 自己定义方法 | 继承 BaseMapper<T> 获得通用方法 |
| 基础 CRUD | 通常自己写 SQL | 内置 insert、selectById、updateById、deleteById |
| Service 层 | 自己注入 Mapper 并调用 | 可继承 ServiceImpl<M, T> 使用 save、page、removeById |
| 动态条件 | XML <if> / 注解 SQL | LambdaQueryWrapper、LambdaUpdateWrapper |
| 分页 | 自己写 LIMIT 和 COUNT(*) | Page<T> + 分页插件 |
| 逻辑删除 | 自己维护 is_deleted 条件 | @TableLogic + 全局配置 |
| 自动填充 | Java 手动 set 或 SQL 写 NOW() | MetaObjectHandler 自动填充 |
| 复杂 SQL | XML / 注解 SQL | 仍然可以 XML / 注解 SQL |
| SQL 控制感 | 很强 | 基础 SQL 被封装,复杂 SQL 可自定义 |
| 开发效率 | 基础 CRUD 较慢 | 基础 CRUD 很快 |
| 适合场景 | SQL 复杂、强控制、报表查询多 | 后台管理系统、CRUD 多、业务表多 |
十七、那是不是用了 MP 就不用学 MyBatis?
不是。
这一点非常重要。
MyBatis-Plus 不是一个凭空出现的新东西,它是 MyBatis 的增强工具。它底层仍然离不开 MyBatis。
所以正确的学习顺序应该是:
先理解 MyBatis 的基本思想:
Mapper 接口是什么
SQL 怎么映射到方法
参数怎么传
结果怎么映射成对象
XML 动态 SQL 怎么写
再学习 MyBatis-Plus:
BaseMapper 帮了什么
ServiceImpl 帮了什么
Wrapper 怎么拼条件
分页插件怎么用
逻辑删除怎么配置
自动填充怎么配置如果只会 MP,不懂 MyBatis,就容易出现一种情况:
简单 CRUD 写得飞快。
一到复杂 SQL、多表查询、动态统计,立刻卡住。这就像只会开自动挡,但完全不知道车为什么会动。平时没问题,一旦车出点毛病,就只能在路边沉思人生。
十八、这个项目为什么适合使用 MP?
回到本博客后端项目。
这个项目里有很多业务表:
article
article_category
article_tag
article_tag_rel
study_diary
project_info
resource_collection
resource_item
home_music
site_config
uploaded_file
sys_user
sys_user_refresh_token这些表都有大量相似操作:
新增
修改
删除
按 id 查询
分页查询
按条件查询
逻辑删除
创建时间
更新时间如果全部使用原生 MyBatis,当然可以,而且 SQL 会非常直观。
但是代价也很明显:
Mapper 方法会变多。
XML 会变多。
重复 SQL 会变多。
逻辑删除条件容易漏。
分页 count 容易重复写。
created_at / updated_at 要反复处理。所以这个项目选择 MyBatis-Plus 是很合理的:
基础 CRUD 交给 MP。
业务逻辑放在 Service。
复杂统计或特殊删除再写自定义 SQL。这也是很多 Spring Boot 后台管理系统的常见选择。
十九、小结
最后重新总结一下。
MyBatis 和 MyBatis-Plus 并不是谁淘汰谁的关系。
更准确地说:
MyBatis 是地基。
MyBatis-Plus 是在地基上搭好的常用工具间。如果你需要非常精细地控制 SQL,或者项目里有大量复杂查询,那么原生 MyBatis 的 XML 写法依旧非常重要。
如果你的项目里有大量标准 CRUD、分页、逻辑删除、自动填充,那么 MyBatis-Plus 会非常省事。
结合当前博客后端项目来看,MP 的作用主要体现在:
1. Mapper 继承 BaseMapper,减少基础 CRUD 代码。
2. Service 继承 ServiceImpl,直接使用 save、page、removeById 等方法。
3. LambdaQueryWrapper 让条件查询更简洁。
4. Page + 分页插件减少分页样板代码。
5. @TableLogic 自动处理逻辑删除。
6. MetaObjectHandler 自动填充 createdAt、updatedAt、isDeleted。
7. 复杂 SQL 仍然可以通过 @Select、@Delete 或 XML 自定义。所以,对于初学者来说,最好的理解方式不是背概念,而是盯住一条主线:
Controller 调 Service,
Service 调 Mapper,
Mapper 操作数据库,
MP 帮 Mapper 和 Service 少写重复代码。理解了这条线,再看 BaseMapper、ServiceImpl、LambdaQueryWrapper,就不会觉得它们是天书了。
它们本质上就是一句话:
把你本来要重复写很多遍的数据库操作,提前封装成通用工具。这就是 MyBatis-Plus 在这个项目中的意义。
