在做个人博客后端时,我发现每个业务模块都在重复同样的代码:创建实体、校验参数、调用 Mapper、处理异常……于是我设计了 BaseServiceImpl,一个基于 MyBatis-Plus 和 MapStruct 的通用 CRUD 基类。
没有基类时,每个 Service 都长这样:
// system 模块
public void createUser(UserDTO dto) {
validator.validate(dto);
UserEntity entity = userConverter.dtoToEntity(dto);
if (!userMapper.insert(entity)) {
throw new RuntimeException("保存失败");
}
}
// article 模块(几乎一样的代码)
public void createArticle(ArticleDTO dto) {
validator.validate(dto);
ArticleEntity entity = articleConverter.dtoToEntity(dto);
if (!articleMapper.insert(entity)) {
throw new RuntimeException("保存失败");
}
}
代码重复度极高,修改一处就要改多处。
类签名用了 5 个泛型参数:
public abstract class BaseServiceImpl<
M extends BaseMapper<E>, // Mapper 类型
E, // Entity 类型
V, // VO 类型(响应)
D extends Identifiable<?>, // DTO 类型(请求,需包含 id)
C extends BaseConverter<D, E, V> // MapStruct 转换器
> extends ServiceImpl<M, E> implements IBaseService<E, V, D> {
继承自 MyBatis-Plus 的 ServiceImpl,同时实现自定义的 IBaseService 接口。
@Transactional(rollbackFor = Exception.class)
public Serializable saveByDto(D dto) {
validate(dto); // 1. JSR-303 参数校验
E entity = converter.dtoToEntity(dto); // 2. MapStruct 转换
preSave(entity); // 3. 预保存钩子(子类可重写)
if (!this.save(entity)) {
throw new OperationFailedException("实体保存失败", entity);
}
return getEntityId(entity); // 4. 通过反射返回主键
}
普通的更新实现有个隐患:直接把 DTO 转成 Entity 然后 update,如果 DTO 里某个字段没有传值(null),会把数据库里已有的值覆盖掉。
我的解决方案:先查后改,增量更新:
@Transactional(rollbackFor = Exception.class)
public boolean updateByDto(D dto) {
validate(dto);
Serializable id = dto.getId();
Assert.notNull(id, "更新失败:无法从 DTO 中获取主键 ID");
// 关键第一步:先从数据库加载完整 Entity
E entity = this.getById(id);
if (entity == null) {
throw new EntityNotFoundException(this.getEntityClass().getSimpleName(), id);
}
// 关键第二步:只把 DTO 中的非 null 字段更新到 Entity(增量更新)
converter.updateEntityFromDto(dto, entity);
preUpdate(entity); // 预更新钩子
boolean success = this.updateById(entity);
if (!success) {
// 可能是乐观锁 @Version 冲突
throw new OperationFailedException("实体更新失败,可能存在数据冲突", dto);
}
return true;
}
updateEntityFromDto 是 MapStruct 生成的合并方法:
// BaseConverter 接口
public interface BaseConverter<D, E, V> {
E dtoToEntity(D dto);
V entityToVo(E entity);
// 关键:将 DTO 的非 null 字段合并到已存在的 Entity 上
void updateEntityFromDto(D dto, @MappingTarget E entity);
}
MapStruct 在编译期自动生成实现,只更新 DTO 中不为 null 的字段,保留 Entity 中已有的其他字段。这正是"安全更新"的核心。
模板方法模式的典型应用:
// 子类可选择重写
protected void preSave(E entity) {
// 默认空实现
}
protected void preUpdate(E entity) {
// 默认空实现
}
文章服务重写了 preSave,在保存前做一系列处理:
// ArticleServiceImpl.java
@Override
protected void preSave(ArticleEntity entity) {
// 1. 没有封面图时,自动填充必应每日壁纸
if (StringUtils.isBlank(entity.getCoverImage())) {
String bingWallpaper = bingWallpaperService.getRandomWallpaper();
entity.setCoverImage(bingWallpaper);
}
// 2. 默认状态设为草稿
if (entity.getStatus() == null) {
entity.setStatus(ArticleStatus.DRAFT.getCode());
}
// 3. 从 SecurityContext 获取当前用户 ID 作为作者
Long currentUserId = SecurityUtils.getCurrentUserId();
entity.setAuthorId(currentUserId);
// 4. 责任链模式处理内容(XSS过滤 → Markdown转HTML → 生成目录 → 提取摘要)
ProcessResult result = contentProcessorChain.process(new ProcessResult(entity.getContent()));
entity.setContentHtml(result.getHtml());
entity.setTocJson(result.getTocJson());
entity.setSummary(result.getSummary());
}
这里文章模块还用到了责任链模式处理内容,每个处理器专注一件事:
XssFilterProcessor → MarkdownProcessor → TocProcessor → SummaryProcessor
一个技术细节:如何通用地获取各类实体的主键?
不能硬编码 entity.getId(),因为不同实体的主键字段名可能不同。我用了 MyBatis-Plus 的 TableInfoHelper:
protected Serializable getEntityId(E entity) {
TableInfo tableInfo = TableInfoHelper.getTableInfo(this.getEntityClass());
// 通过 MP 的元数据反射获取主键值,无需知道字段名
return (Serializable) tableInfo.getPropertyValue(entity, tableInfo.getKeyProperty());
}
子类只需继承,声明泛型,注入 converter:
@Service
public class ArticleServiceImpl
extends BaseServiceImpl<ArticleMapper, ArticleEntity, ArticleDetailVO, ArticleDTO, ArticleConverter>
implements IArticleService {
public ArticleServiceImpl(ArticleConverter converter, ...) {
super(converter); // 传入 MapStruct 转换器
// 其他依赖注入 ...
}
// 立刻获得全套 CRUD 能力,只需关注业务特有逻辑
}
ArticleServiceImpl 直接获得了:
saveByDto(dto) — 创建文章updateByDto(dto) — 安全更新文章getVoById(id) — 查询文章详情 VOpageVo(page, wrapper) — 分页查询removeById(id) — 删除文章batchSaveByDto(dtoList) — 批量创建文章模块只需要关注业务特有逻辑:发布文章、归档文章、向量搜索等。
| 特性 | 原生 MP ServiceImpl | BaseServiceImpl |
|---|---|---|
| 类型安全的 VO 返回 | ❌ | ✅ |
| 自动参数校验 | ❌ | ✅ |
| 安全增量更新 | ❌ | ✅ |
| MapStruct 集成 | ❌ | ✅ |
| 生命周期钩子 | ❌ | ✅ |
| 乐观锁异常处理 | ❌ | ✅ |
这个框架设计解决了 90% 项目中的通用 CRUD 痛点。关键决策:
这套框架我认为完全具备独立开源的价值。
在设计个人博客后端时,我面临一个经典抉择:用简单粗暴的单体应用,还是拥抱时髦的微服务?最终我选择了两者之间的 模块化单体架构(Modular Monolith),事实证明这是正确的决定。 为什么不用微服务? 微服务固然优秀,但对于个人项目来说成本太高: - 需要服务注册、配置中心、网关、链路追踪…… - 每个服务独立部署,运维复杂度指数级增长 - 分布式事务难以处理 - 单人开发,维护多个仓库效率...
Spring Security 的默认配置很简单:一条过滤链管所有请求。但在实际项目中,不同端点往往有完全不同的认证需求。本文介绍我在个人博客项目中实现的三链 SecurityFilterChain 架构。 问题背景 博客系统的端点可以分为三类: 1. 公开白名单:/actuator/health(运维监控)、/api/v1/articles(文章列表)、Swagger 文档 2.
博客系统的管理后台需要处理文章的增删改查、评论审核、文件上传、系统监控等功能。这类界面有一个共同特点:大量表单 + 大量数据展示 + 频繁的服务端交互。 本文记录我在管理后台中选用的技术栈,以及每个选型背后的理由。 --- 技术栈总览 | 层级 | 技术 | 版本 | 用途 | |:---|:---|:---|:---| | 框架 | Next.
从数据库到用户界面,这篇文章记录我在个人博客系统中每一层技术选型背后的思考。整个系统包含 4 个独立仓库,涵盖 Java 后端、两个 Next.js 前端、和 Docker 部署配置。 --- 一、整体架构图 ` 用户浏览器 │ ▼ Caddy(反向代理 + 自动 HTTPS) │ ├── chonkybird.com → Next.js 博客前端(SSR) ├── admin.
这篇文章记录我在开发个人博客系统全程中使用 AI 工具的真实体验——包括它在哪些环节显著提升了效率,在哪些地方需要谨慎,以及如何正确地使用 AI。 整个系统包含 Spring Boot 后端、两个 Next.js 前端、Docker Compose 部署配置,共 4 个独立仓库,全程有 AI 深度介入。