1. 三层设计模式到底在解决什么问题
1.1 没有三层的时候会怎样
我带过不少刚入行的新人,问他们“写一个用户增删查改需要几步”,大多数人的第一反应是:Controller里面写个方法,方法里直接拼SQL或者用MyBatis封装好的方法一把梭。等真正接手项目的时候会发现,这种“一把梭”的代码,上线第一个月还好,第二个月开始加需求,第三个月改BUG,第四个月就没人敢动了。
为什么?因为所有逻辑都揉在一起的时候,任何一个改动都可能引发连锁反应。比如你要改一个密码校验规则,表面上看只是改一个接口,但实际上这个接口里还掺杂着数据库字段的映射、SQL语句的拼接、日志记录、异常处理、权限判断,你根本说不清楚自己改的这行代码到底影响了什么。
说个最直观的例子。我用一个小餐馆来类比:后厨、收银、采购如果是一个人,小摊子确实能养活自己;但一旦客人多起来,一个人既要做菜又要算账还要进货,必然出乱子。代码也是一样,用户量小、逻辑简单的时候,全部写在一个类里确实“能跑”,但代码是给团队协作的,不是给你一个人自嗨的。你写完三个月后回来看代码,连自己都想不起来当时为什么这么写。
1.2 三层的职责边界与调用关系
三层设计模式,或者说三层架构,核心就八个字:各司其职,单向依赖。具体到Springboot项目里,通常就是这三层:
- Controller层(表现层):负责接收HTTP请求、解析参数、做基础参数校验、调用Service层、把结果封装成统一格式返回给前端。它不关心数据从哪里来、SQL怎么写。
- Service层(业务层):负责真正的业务逻辑。比如用户新增时要检查用户名是否重复、删除时要不要做关联数据的清理、更新时哪些字段允许为空。事务边界通常也画在这一层。
- Mapper层(数据访问层):负责和数据库打交道。最简单的CRUD就是写SQL、执行SQL、返回结果。它不应该包含任何业务判断,只提供数据读写的“原子操作”。
调用关系是固定的单向链路:Controller调用Service,Service调用Mapper,数据再原路返回。不允许反向调用,也不允许跨层调用,Service不能直接去写Controller的代码,Mapper也不能跳出来处理业务逻辑。
我见过的很多“看起来很乱”的项目,问题就出在边界模糊:有人在Controller里写业务判断,有人在Service里封装SQL查询条件,还有人直接在Mapper里写业务循环。规矩一旦打破,代码就会慢慢腐化。所以三层架构的价值不是多写几个类文件,而是用结构约束住整个团队的编码习惯。
1.3 为什么用Springboot做三层特别顺
其实三层架构不是Springboot发明的,在SSH、SSM时代就已经是主流了。但Springboot把三层架构的落地难度降到了“几乎为零”,这才是它最牛的地方。
在XML配置满天飞的老项目里,你要定义一个Service,得先去XML里注册Bean,再配扫描路径,还要处理各种依赖关系。Springboot的约定大于配置,让这些事全部自动化了。你用@RestController、@Service、@Mapper三个注解就能把三层定义清楚,容器启动的时候自动扫描、自动装配,依赖注入用@Autowired就完事了,完全不需要手动new。正因为这套机制太顺滑,新手反而容易忽略三层背后的设计思想——而懂设计思想的人,写出来的代码才是真正“能懂”的代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与项目初始化
2.1 版本选型怎么避坑
先聊一个很多新手栽过跟头的问题:Springboot版本选太高了。网上搜“springboot版本太高”能搜出一堆提问,这不是个例。Springboot 3.x之后,整个生态做了非常大的调整,其中最让人头疼的就是javax.*包名全部换成了jakarta.*。很多老教程里的import javax.servlet、javax.validation在Springboot 3.x里直接编译报错,MyBatis等第三方框架如果不升级到配套版本也会闹脾气。
我的建议很明确:如果你是刚学Springboot的新手,优先用Springboot 2.7.x + JDK8。这是我个人实测下来最稳的组合,网上教程覆盖度也最高,遇到问题一搜就有答案。Springboot 2.7是2.x系列的最终版本,既有长期维护,又不会动不动就整出编译错误。
具体版本对照表我整理一下:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 8 | 企业项目最常用的版本,生态兼容性最好 |
| Springboot | 2.7.x | 2.x系列最后一个稳定版本 |
| MyBatis Starter | 2.3.x | 配合Springboot 2.x使用,无需额外设置 |
| MySQL | 8.0+ | 注意serverTimezone时区配置 |
| Maven | 3.8+ | IDEA自带也行,用命令行建议单独装 |
2.2 搭建工程与核心依赖
我用IDEA的Spring Initializr创建项目,选好版本直接生成。这里给出一份可以照抄的pom.xml核心依赖,删掉不用的注释,剩下的都是必需项:
xml复制<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>user-crud-demo</artifactId>
<version>1.0.0</version>
<name>user-crud-demo</name>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
我自己习惯把mybatis-spring-boot-starter的版本显式写出来,因为Springboot的父工程并不管理它的版本,不写的话Maven会找不到或拉到奇怪版本。spring-boot-starter-web已经包含了内嵌的Tomcat和Spring MVC,所以不需要额外配置服务器。spring-boot-starter-validation是参数校验要用的,新手很容易漏。Lombok可以省掉一堆getter/setter,但不是必须的,你不喜欢完全可以用IDE自动生成代替。
2.3 数据库准备与配置
先建一张用户表。为了演示方便,字段不用太多,但该有的都有:主键、用户名、密码、邮箱、手机号、创建时间和更新时间。
sql复制CREATE TABLE `t_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) NOT NULL COMMENT '密码',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
我把表名故意取成t_user,是因为user在某些数据库方言里是保留字,容易踩坑。username字段加了唯一索引,这为后面Service层做用户名重复校验提供了数据库层面的保障。
然后配置application.yml。新手经常忽略两个地方:一是MySQL 8.0之后的驱动类和时区,二是MyBatis的驼峰映射。不配好的话,等下查询出来的create_time字段会是null,你找半天都找不出原因。
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/user_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: root123456
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
mybatis:
configuration:
map-underscore-to-camel-case: true
type-aliases-package: com.example.userdemo.entity
server:
port: 8080
serverTimezone=Asia/Shanghai是MySQL 8连接时必须的,不配大概率会报时区错误。useSSL=false是因为本地开发环境没有配置SSL证书,不加这个MySQL 8也会警告。map-underscore-to-camel-case这个配置极其重要,它让你在SQL里写的create_time能自动映射成Java实体类里的createTime,不用手动做字段别名。
3. 核心实现:把增删查改拆成三层来写
3.1 实体类与统一返回
先定义用户实体类。这个类对应数据库的一张表,字段名和表中的列名一一对应,Lombok帮我们生成getter、setter、构造器等。
java复制package com.example.userdemo.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class User {
private Long id;
private String username;
private String password;
private String email;
private String phone;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
这里有个小细节:我把时间字段用LocalDateTime而不是java.util.Date,这是JDK8之后的新时间API,配合Jackson序列化更稳妥。你在application.yml里配的date-format会作用于接口返回时的格式输出。
接下来是统一返回体。这一步很多人会觉得很“鸡肋”,但实际项目里几乎必配。没有统一返回体的话,前端对接接口时得面对各种千奇百怪的JSON结构,今天接口返回{code:0, data:{}},明天返回{success:true, result:[]},前端得为每个接口单独写一套解析逻辑,这就太痛苦了。
java复制package com.example.userdemo.common;
import lombok.Data;
@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
public static <T> Result<T> error(Integer code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
return result;
}
}
这个类的设计很直接:code表示业务状态码,message是给前端展示用的提示信息,data是真正的业务数据。成功时返回200,失败返回500,具体的业务错误码可以后续扩展。前端拿到这个结构,处理逻辑就统一了:先看code,200就展示data,不然就弹message。
3.2 数据访问层:Mapper
Mapper层又被称为数据访问层或持久层。我选择用MyBatis注解方式,不写XML文件,因为对于CRUD这种简单操作,注解方式更直观,代码量也少。
java复制package com.example.userdemo.mapper;
import com.example.userdemo.entity.User;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface UserMapper {
@Insert("INSERT INTO t_user(username, password, email, phone) " +
"VALUES(#{username}, #{password}, #{email}, #{phone})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(User user);
@Delete("DELETE FROM t_user WHERE id = #{id}")
int deleteById(Long id);
@Update("UPDATE t_user SET username = #{username}, password = #{password}, " +
"email = #{email}, phone = #{phone} WHERE id = #{id}")
int update(User user);
@Select("SELECT * FROM t_user WHERE id = #{id}")
User selectById(Long id);
@Select("SELECT * FROM t_user")
List<User> selectList();
}
@Options(useGeneratedKeys = true, keyProperty = "id")的意思是:插入成功后,把数据库自动生成的主键回填到传入的User对象中。这个操作非常实用,因为后续业务中经常需要在新增完拿到的用户ID,去关联其他表的数据。
为什么不直接用selectList倒序排一下?其实可以加一句ORDER BY id DESC,但为了保持基础CRUD的简洁,我这里先不加。真实业务里查询列表往往还涉及分页、条件筛选,那属于进阶内容,后面可以单独展开。
你可能注意到我每个方法都返回int类型,这是MyBatis的约定:insert返回受影响的行数,deleteById返回删除条数,update返回更新条数。Service层可以据此判断操作是否成功。
3.3 业务层:Service
Service层是三层架构里“含金量”最高的一层。它不直接写SQL,而是接收Controller传过来的参数,组织业务流程,调用Mapper完成数据操作,再把结果返回给Controller。
先定义接口,再写实现类。这是Java世界里最常见的做法,我推荐新手也养成这个习惯。接口定义了这个“服务”能干什么,实现类则具体去干。好处有几点:一是方便Spring AOP事务代理,二是团队协作时可以并行开发,三是以后要替换实现方案时,调用方完全不用改。
java复制package com.example.userdemo.service;
import com.example.userdemo.entity.User;
import java.util.List;
public interface UserService {
void addUser(User user);
void deleteUser(Long id);
void updateUser(User user);
User getUserById(Long id);
List<User> listUsers();
}
实现类里我加了实打实的业务判断:新增前检查用户名是否被占用,更新时排除本用户后检查用户名冲突。这些逻辑放在Controller里就错了,因为业务规则必须和接口解耦,将来不管是REST接口调用还是定时任务调用,这些规则都必须生效。
java复制package com.example.userdemo.service.impl;
import com.example.userdemo.entity.User;
import com.example.userdemo.mapper.UserMapper;
import com.example.userdemo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void addUser(User user) {
User existUser = userMapper.selectByUsername(user.getUsername());
if (existUser != null) {
throw new RuntimeException("用户名已被占用");
}
userMapper.insert(user);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteUser(Long id) {
User existUser = userMapper.selectById(id);
if (existUser == null) {
throw new RuntimeException("用户不存在");
}
userMapper.deleteById(id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateUser(User user) {
User existUser = userMapper.selectById(user.getId());
if (existUser == null) {
throw new RuntimeException("用户不存在");
}
User sameNameUser = userMapper.selectByUsername(user.getUsername());
if (sameNameUser != null && !sameNameUser.getId().equals(user.getId())) {
throw new RuntimeException("用户名已被占用");
}
userMapper.update(user);
}
@Override
public User getUserById(Long id) {
return userMapper.selectById(id);
}
@Override
public List<User> listUsers() {
return userMapper.selectList();
}
}
这里我在Mapper侧补了一个selectByUsername方法,注意新增和更新的用户名查重逻辑有区别:新增时直接查一次用户名是否存在;更新时必须排除自己,这是一个很容易被忽略的细节。你更新用户时如果不排除自己,就会拿自己的用户名去查,结果查出来就是自己,然后提示“用户名已占用”,这显然是错的。
@Transactional(rollbackFor = Exception.class)是事务注解。默认情况下Spring事务只在RuntimeException时回滚,像IOException这种受检异常是不会回滚的,所以需要强制指定rollbackFor = Exception.class,这也是面试里常问的点。
3.4 表现层:Controller
Controller层是最贴近HTTP协议的一层。我见过不少新手在这个类里堆了几百行代码,做一整套业务处理。其实Controller应该非常“薄”,只做四件事:接收参数、参数校验(简单的就交给注解)、调用Service、返回结果。
java复制package com.example.userdemo.controller;
import com.example.userdemo.common.Result;
import com.example.userdemo.entity.User;
import com.example.userdemo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.List;
@Validated
@RestController
@RequestMapping("/api/user")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/save")
public Result<Void> addUser(@Valid @RequestBody User user) {
userService.addUser(user);
return Result.success(null);
}
@DeleteMapping("/delete/{id}")
public Result<Void> deleteUser(@PathVariable("id") @NotNull(message = "用户ID不能为空") Long id) {
userService.deleteUser(id);
return Result.success(null);
}
@PutMapping("/update")
public Result<Void> updateUser(@Valid @RequestBody User user) {
userService.updateUser(user);
return Result.success(null);
}
@GetMapping("/get/{id}")
public Result<User> getUserById(@PathVariable("id") @NotNull(message = "用户ID不能为空") Long id) {
return Result.success(userService.getUserById(id));
}
@GetMapping("/list")
public Result<List<User>> listUsers() {
return Result.success(userService.listUsers());
}
}
接口设计我遵循了REST风格:新增用POST、删除用DELETE、更新用PUT、查询用GET。一个方法对应一个URL,清晰明了。@Valid @RequestBody会触发实体的参数校验,如果实体类字段上加了校验注解(比如@NotBlank),不合法时Spring会抛MethodArgumentNotValidException,防止脏数据进入Service层。
有人会问,新增和更新为什么不统一用/save一个接口?分开写的好处是语义更清楚,前后端对接时不会搞混。删除和查询的id走的是URL路径参数,更新和新增走的是JSON请求体,这个边界要分清。
这里有个细节:@Validated加在类上是为了处理@PathVariable上的校验注解,类上的@Valid只能处理@RequestBody的校验。两者配合才能覆盖全部入参校验,这也是很多人一开始踩坑的地方。
3.5 启动验证
启动Springboot应用,看到Started UserCrudDemoApplication后,就可以用Postman或者直接用curl做接口测试了。下面我用curl演示几个典型请求:
新增用户:
bash复制curl -X POST http://localhost:8080/api/user/save \
-H "Content-Type: application/json" \
-d '{"username":"zhangsan","password":"123456","email":"zhangsan@example.com","phone":"13800138000"}'
预期返回:
json复制{
"code": 200,
"message": "操作成功",
"data": null
}
查询用户列表:
bash复制curl http://localhost:8080/api/user/list
预期返回:
json复制{
"code": 200,
"message": "操作成功",
"data": [
{
"id": 1,
"username": "zhangsan",
"password": "123456",
"email": "zhangsan@example.com",
"phone": "13800138000",
"createTime": "2025-01-15 10:30:00",
"updateTime": "2025-01-15 10:30:00"
}
]
}
看到createTime字段能正常输出yyyy-MM-dd HH:mm:ss格式,说明你的application.yml配置和实体类映射都是对的。如果createTime是null,99%是map-underscore-to-camel-case没有配置,或者MySQL连接没配上serverTimezone。
更新的注意点:更新时必须把id也放在请求体里传过来,不然Service层查重时user.getId()是null,直接走到“用户不存在”的分支。我实际遇到过一个新人调试了半天,就是更新文档里漏传了id。
4. 高频问题与排查技巧实录
4.1 Springboot版本太高引发的连锁反应
这个我必须单独拿出来讲,因为最近问的人实在太多了。Springboot 3.x之后,包名从javax.*全部迁移到了jakarta.*,这意味着你在网上找到的很多教程代码直接复制过来是编译不过的。import javax.validation、import javax.servlet这些在老教程里随处可见的导入语句,在Springboot 3.x项目里会直接报“程序包不存在”。
如果你新建项目的时候选择了Springboot 3.x,最稳的办法是:要么把项目降级到2.7.x,用回JDK8;要么把所有第三方依赖也升级到支持jakarta的版本。对新手来说,降级比升级省事得多,别跟自己过不去。
另外还有一个“springboot 4.0找不到aop”的问题,本质也是版本过高导致的部分starter不再自动装配。遇到这种问题,第一反应应该是去查看Spring官方发布公告和版本兼容矩阵,而不是盲目改代码。
4.2 依赖注入失败与循环依赖
“Field userService in ... required a bean of type”是新手非常常见的报错。通常有三种原因:
- 实现类没有加
@Service注解,Spring容器里根本没有这个Bean。 - Controller所在包没有被主启动类扫描到。主启动类默认扫描它所在的包及其子包,你如果手贱把Controller放在别的包路径下,启动时就不会注册。
- 忘记写接口实现,只用了一个类但没实现接口。
排查这类问题有个固定套路:先看启动日志中是否打印了这个Bean的创建信息,再用IDEA的Beans窗口看容器里到底有没有注册这个Bean。我见过不少新人上来就把整个项目代码翻一遍找原因,效率极低。
还有一个隐藏的坑是循环依赖。Springboot 2.6版本之后默认禁止循环依赖,两个Service互相注入时启动会直接报错。以前很多老代码靠“A注入B、B注入A”的设计跑得好好的,升级Springboot版本后突然启动失败,就是这个原因。解决方法是重新设计依赖关系,把公共逻辑抽到第三层,或者用@Lazy延缓其中一个Bean的初始化时间。
4.3 事务失效的常见场景
关于@Transactional失效,我总结出四类高频场景,每一条都对应一个真实踩过的坑:
第一,方法不是public。Spring默认用CGLIB代理,私有或包私有方法无法被代理,事务注解形同虚设。第二,同类内部调用。比如UserServiceImpl里有个A方法调用了同一个类里的B方法,B方法上标了@Transactional,这个事务不会生效,因为调用没有经过Spring的代理对象。第三,异常被catch吞掉。事务回滚依赖异常抛出,如果你在方法内部把RuntimeException捕获并打印日志,Spring感知不到异常,事务自然不回滚。第四,数据库引擎不支持事务。MySQL的MyISAM引擎根本不支持事务,InnoDB才支持。
4.4 开发环境与配置提示问题
很多人在IDEA里写application.yml,输入spring.datasource.url的时候没有智能提示,以为是自己IDE坏了。其实IDEA是能提示Spring配置项的,前提是pom里要有对应的依赖。你把spring-boot-starter-web和mybatis-spring-boot-starter加进去,配置类在classpath里,IDEA才能读到配置元数据并给出提示。
如果加了依赖还是没提示,可以试试重新导入Maven项目(右键pom.xml -> Maven -> Reload Project),或者点击File -> Invalidate Caches清缓存重启IDEA。另外旧版的IDEA对Springboot配置元数据支持一般,建议升级到较新版本的IDEA。
4.5 高频问题速查表
| 报错或现象 | 根本原因 | 解决方法 |
|---|---|---|
package javax.validation does not exist |
Springboot 3.x的包名迁移 | 降级到2.7.x,或导入jakarta.validation |
required a bean of type 'UserService' |
实现类没加@Service/包扫描不到 |
检查注解和包路径 |
The dependencies of some of the beans in the application context form a cycle |
循环依赖 | 重构依赖关系,或使用@Lazy |
@Transactional没有生效 |
同类调用/异常被吞/非public方法 | 按4.3节逐条排查 |
create_time字段查出来是null |
驼峰映射没开启 | 配置map-underscore-to-camel-case: true |
IDEA里application.yml没有提示 |
缺少配置依赖或未重新导入 | 添加依赖并Reload Maven项目 |
| MySQL连接报时区错误 | 缺少serverTimezone |
连接串加serverTimezone=Asia/Shanghai |
| 更新用户时提示“用户名被占用” | 查重逻辑没排除自己 | 修改selectByUsername后的判断条件 |
5. 一些值得养成的编码习惯
按照上面这套结构写完,你的Springboot用户增删查改就算落地了。但既然标题叫“能懂”,我还想多说几句代码之外的东西。
三层设计模式不是让你多写几个类文件做做样子,而是让你的代码能够被未来的自己和团队伙伴轻松读懂。我在实际带项目时有一条硬性要求:Controller层不写业务、Service层不写SQL、Mapper层不写判断。这条规矩看似简单,但能坚持下来的团队,代码维护成本至少降低一半。
另外,写接口时优先定义接口再写实现,这个习惯越早养成越好。它带来的直接好处是:你想测试Service逻辑时,可以非常方便地用Mockito替换掉真实的Mapper实现;你以后想把MyBatis换成JPA时,Controller和Service层的代码一行都不用改,只需要重新实现Mapper层接口。
最后分享一个小技巧。第一次写完这套CRUD后,不要急着做下一个项目,先把你自己的代码读一遍,假装你是第一次看它的人。你会发现很多没写注释的地方、命名不够清晰的变量、可以抽出来的公共逻辑。把这些小问题顺手改掉,比多写一个接口更能提升代码质量。我当年就是这么一遍一遍打磨自己代码的,效果比看十遍教程都好。
希望这篇基于Springboot的用户增删查改和三层设计模式拆解,能帮你把基础打牢。后面我还会继续写分页查询、参数校验增强、统一异常处理这些进阶内容,把这些基础吃透后再看那些,会轻松得多。
