27从"杂乱无章"到"规范统一":SSM整合与前后端联调的涅槃之路
27从"杂乱无章"到"规范统一":SSM整合与前后端联调的涅槃之路
"前端又报错了!"、"这个接口返回格式不对!"、"跨域问题又来了!"——这是我在瑞吉外卖项目联调阶段的日常。当后端自认为完美的代码,在前端眼中却是一团乱麻时,我才明白:写代码容易,写"好用"的代码难。

一、SSM整合:从"各自为战"到"三军会师"
1.1 我的第一次整合尝试:灾难现场
刚开始整合SSM时,我的项目结构是这样的:
text
src/main/java/
├── com.reggie.controller
│ └── OrderController.java // SpringMVC扫描
├── com.reggie.service
│ └── OrderServiceImpl.java // Spring扫描
├── com.reggie.mapper
│ └── OrderMapper.java // MyBatis扫描
└── com.reggie.entity
└── Order.java
配置类:
java
// Spring配置类
@Configuration
@ComponentScan("com.reggie") // 扫描所有!问题来了...
@EnableTransactionManagement
public class SpringConfig {
@Bean
public DataSource dataSource() {
// 数据源配置
}
}
// SpringMVC配置类
@Configuration
@ComponentScan("com.reggie.controller") // 也扫描Controller
@EnableWebMvc
public class SpringMvcConfig {
// 视图解析器等配置
}
// MyBatis配置
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) {
// MyBatis配置
}
问题爆发:
- Bean重复创建:Controller被Spring和SpringMVC各创建一次
- 事务不生效:Service层事务配置混乱
- 循环依赖:A依赖B,B又依赖A
- 启动报错:各种
BeanCurrentlyInCreationException
1.2 正确的SSM整合姿势
关键原则: Spring管Service和Dao,SpringMVC只管Controller

java
// 1. Spring配置类:排除Controller
@Configuration
@ComponentScan(
value = "com.reggie",
excludeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = Controller.class
)
)
@EnableTransactionManagement
@PropertySource("classpath:jdbc.properties")
public class SpringConfig {
@Value("${jdbc.driver}")
private String driver;
@Value("${jdbc.url}")
private String url;
@Value("${jdbc.username}")
private String username;
@Value("${jdbc.password}")
private String password;
@Bean
public DataSource dataSource() {
DruidDataSource ds = new DruidDataSource();
ds.setDriverClassName(driver);
ds.setUrl(url);
ds.setUsername(username);
ds.setPassword(password);
ds.setInitialSize(5);
ds.setMaxActive(10);
return ds;
}
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
// 2. SpringMVC配置类:只扫描Controller
@Configuration
@EnableWebMvc
@ComponentScan(
value = "com.reggie.controller",
includeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = Controller.class
),
useDefaultFilters = false // 重要!不使用默认过滤器
)
public class SpringMvcConfig implements WebMvcConfigurer {
// 配置静态资源映射
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/front/**")
.addResourceLocations("classpath:/front/");
registry.addResourceHandler("/backend/**")
.addResourceLocations("classpath:/backend/");
}
// 配置视图解析器
@Bean
public InternalResourceViewResolver viewResolver() {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/views/");
resolver.setSuffix(".jsp");
return resolver;
}
// 配置消息转换器(JSON)
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
converter.setObjectMapper(new ObjectMapper());
converters.add(converter);
}
}
// 3. MyBatis配置类
@Configuration
public class MyBatisConfig {
@Bean
public SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) throws IOException {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
// 设置MyBatis配置
org.apache.ibatis.session.Configuration configuration =
new org.apache.ibatis.session.Configuration();
configuration.setMapUnderscoreToCamelCase(true); // 下划线转驼峰
configuration.setLogImpl(StdOutImpl.class); // 打印SQL
factoryBean.setConfiguration(configuration);
// 设置类型别名
factoryBean.setTypeAliasesPackage("com.reggie.entity");
// 设置Mapper XML位置
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
factoryBean.setMapperLocations(resolver.getResources("classpath:mapper/*.xml"));
return factoryBean;
}
@Bean
public MapperScannerConfigurer mapperScannerConfigurer() {
MapperScannerConfigurer scanner = new MapperScannerConfigurer();
scanner.setBasePackage("com.reggie.mapper");
scanner.setSqlSessionFactoryBeanName("sqlSessionFactory");
return scanner;
}
}
// 4. Web应用初始化(web.xml配置的Java版)
public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
// Spring配置类(父容器)
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class[] { SpringConfig.class, MyBatisConfig.class };
}
// SpringMVC配置类(子容器)
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class[] { SpringMvcConfig.class };
}
// DispatcherServlet的映射路径
@Override
protected String[] getServletMappings() {
return new String[] { "/" };
}
// 配置过滤器(字符编码、CORS等)
@Override
protected Filter[] getServletFilters() {
CharacterEncodingFilter encodingFilter = new CharacterEncodingFilter();
encodingFilter.setEncoding("UTF-8");
encodingFilter.setForceEncoding(true);
CorsFilter corsFilter = new CorsFilter();
return new Filter[] { encodingFilter, corsFilter };
}
}
1.3 容器层次结构:父子容器的秘密
理解这个很重要:
java
// 启动时Spring容器的创建过程
1. 创建父容器(SpringConfig + MyBatisConfig)
↓
2. 加载Service、Repository、DataSource、TransactionManager等Bean
↓
3. 创建子容器(SpringMvcConfig),设置父容器为步骤1的容器
↓
4. 加载Controller、Interceptor、ViewResolver等Bean
↓
5. Controller可以引用父容器的Bean(如Service)
↓
6. Service不能引用子容器的Bean(如Controller)
// 验证代码
@SpringJUnitConfig(classes = {SpringConfig.class, SpringMvcConfig.class})
public class ContainerTest {
@Autowired
private ApplicationContext applicationContext;
@Test
public void testContainerHierarchy() {
// 获取当前容器
System.out.println("当前容器: " + applicationContext);
// 获取父容器
ApplicationContext parent = applicationContext.getParent();
System.out.println("父容器: " + parent);
// 在子容器中获取父容器的Bean
OrderService orderService = applicationContext.getBean(OrderService.class);
System.out.println("子容器获取Service: " + orderService);
// 在父容器中获取子容器的Bean(会失败)
try {
OrderController controller = parent.getBean(OrderController.class);
System.out.println("父容器获取Controller: " + controller);
} catch (Exception e) {
System.out.println("父容器无法获取Controller: " + e.getMessage());
}
}
}
二、统一结果封装:从"千姿百态"到"整齐划一"

2.1 前端开发的血泪史
前端同事的吐槽:"你们的接口,有的返回{success: true, data: {}},有的返回{code: 200, result: {}},有的直接返回数据,还有的返回HTML!"
我的混乱接口:
java
@RestController
public class ChaosController {
// 版本1:直接返回数据
@GetMapping("/v1/orders")
public List<Order> listOrders() {
return orderService.list();
}
// 版本2:返回Map
@GetMapping("/v2/orders")
public Map<String, Object> listOrdersV2() {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("data", orderService.list());
return result;
}
// 版本3:返回字符串
@PostMapping("/v3/orders")
public String createOrder(@RequestBody Order order) {
try {
orderService.save(order);
return "success";
} catch (Exception e) {
return "error: " + e.getMessage();
}
}
// 版本4:返回ResponseEntity
@PutMapping("/v4/orders/{id}")
public ResponseEntity<?> updateOrder(@PathVariable Long id, @RequestBody Order order) {
try {
order.setId(id);
orderService.update(order);
return ResponseEntity.ok().build();
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("更新失败: " + e.getMessage());
}
}
}
前端处理这种接口的痛苦:
javascript
// 前端需要为每个接口写不同的处理逻辑
async function fetchOrders(version) {
try {
let response;
if (version === 1) {
response = await fetch('/v1/orders');
return await response.json(); // 直接就是数据
} else if (version === 2) {
response = await fetch('/v2/orders');
const result = await response.json();
if (result.success) {
return result.data;
} else {
throw new Error('请求失败');
}
} else if (version === 3) {
response = await fetch('/v3/orders', { method: 'POST' });
const text = await response.text();
if (text === 'success') {
return true;
} else {
throw new Error(text);
}
}
// ... 还有其他版本
} catch (error) {
// 错误处理也各不相同
console.error('获取订单失败:', error);
}
}
2.2 统一响应格式的设计
设计原则:
- 固定格式:所有接口返回相同结构
- 包含状态码:明确表示成功/失败
- 包含消息:给用户/开发者的提示
- 包含数据:实际业务数据
- 包含时间戳:便于调试和日志追踪
统一响应类:
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 状态码
* 200: 成功
* 400: 客户端错误
* 401: 未认证
* 403: 无权限
* 404: 资源不存在
* 500: 服务器错误
*/
private Integer code;
/**
* 提示信息
*/
private String msg;
/**
* 数据
*/
private T data;
/**
* 时间戳
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime timestamp;
/**
* 成功响应(无数据)
*/
public static <T> Result<T> success() {
return success(null);
}
/**
* 成功响应(有数据)
*/
public static <T> Result<T> success(T data) {
return Result.<T>builder()
.code(200)
.msg("success")
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
/**
* 成功响应(自定义消息)
*/
public static <T> Result<T> success(String msg, T data) {
return Result.<T>builder()
.code(200)
.msg(msg)
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
/**
* 错误响应
*/
public static <T> Result<T> error(String msg) {
return error(500, msg);
}
/**
* 错误响应(自定义状态码)
*/
public static <T> Result<T> error(Integer code, String msg) {
return Result.<T>builder()
.code(code)
.msg(msg)
.data(null)
.timestamp(LocalDateTime.now())
.build();
}
/**
* 快捷方法
*/
public static <T> Result<T> ok() {
return success();
}
public static <T> Result<T> ok(T data) {
return success(data);
}
public static <T> Result<T> ok(String msg, T data) {
return success(msg, data);
}
/**
* 判断是否成功
*/
public boolean isSuccess() {
return code != null && code == 200;
}
}
状态码枚举(更规范):
java
public enum ResultCode {
SUCCESS(200, "操作成功"),
FAILED(500, "操作失败"),
VALIDATE_FAILED(400, "参数校验失败"),
UNAUTHORIZED(401, "暂未登录或token已经过期"),
FORBIDDEN(403, "没有相关权限"),
NOT_FOUND(404, "资源不存在");
private final int code;
private final String message;
ResultCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
使用统一响应的Controller:
java
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping
public Result<List<OrderVO>> list() {
List<Order> orders = orderService.list();
List<OrderVO> orderVOs = orders.stream()
.map(this::convertToVO)
.collect(Collectors.toList());
return Result.success(orderVOs);
}
@GetMapping("/{id}")
public Result<OrderVO> getById(@PathVariable Long id) {
Order order = orderService.getById(id);
if (order == null) {
return Result.error(ResultCode.NOT_FOUND.getCode(), "订单不存在");
}
return Result.success(convertToVO(order));
}
@PostMapping
public Result<String> create(@Valid @RequestBody OrderCreateDTO dto) {
orderService.create(dto);
return Result.success("创建成功");
}
@PutMapping("/{id}")
public Result<String> update(@PathVariable Long id, @Valid @RequestBody OrderUpdateDTO dto) {
dto.setId(id);
orderService.update(dto);
return Result.success("更新成功");
}
@DeleteMapping("/{id}")
public Result<String> delete(@PathVariable Long id) {
orderService.delete(id);
return Result.success("删除成功");
}
}
前端处理统一了:
javascript
// 统一的请求处理函数
async function request(url, options = {}) {
try {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
...options
});
const result = await response.json();
if (result.code === 200) {
return result.data;
} else if (result.code === 401) {
// 跳转到登录页
window.location.href = '/login';
return Promise.reject(new Error('请先登录'));
} else {
// 显示错误消息
showError(result.msg);
return Promise.reject(new Error(result.msg));
}
} catch (error) {
showError('网络请求失败');
return Promise.reject(error);
}
}
// 使用示例
async function fetchOrders() {
const orders = await request('/orders');
// 直接使用orders数据
renderOrders(orders);
}
async function createOrder(orderData) {
await request('/orders', {
method: 'POST',
body: JSON.stringify(orderData)
});
showSuccess('订单创建成功');
}
三、统一异常处理:从"500错误页"到"友好提示"

3.1 没有异常处理时的恐怖场景
用户看到的页面:
text
HTTP Status 500 – Internal Server Error
Type Exception Report
Message: No value present
Description: The server encountered an unexpected condition that prevented it from fulfilling the request.
Exception: java.util.NoSuchElementException: No value present
com.reggie.service.OrderServiceImpl.getById(OrderServiceImpl.java:45)
com.reggie.controller.OrderController.getById(OrderController.java:28)
...
用户反馈:"我看到一堆英文,吓死我了!"
3.2 全局异常处理器
java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 处理业务异常
*/
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.error("业务异常: {}", e.getMessage(), e);
return Result.error(e.getCode(), e.getMessage());
}
/**
* 处理参数验证异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
log.error("参数验证异常: {}", e.getMessage());
// 提取所有错误信息
List<String> errors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.toList());
String errorMessage = String.join(", ", errors);
return Result.error(ResultCode.VALIDATE_FAILED.getCode(), errorMessage);
}
/**
* 处理未授权异常
*/
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Result<Void> handleAuthenticationException(AuthenticationException e) {
log.error("认证异常: {}", e.getMessage());
return Result.error(ResultCode.UNAUTHORIZED.getCode(), "请先登录");
}
/**
* 处理权限不足异常
*/
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Result<Void> handleAccessDeniedException(AccessDeniedException e) {
log.error("权限异常: {}", e.getMessage());
return Result.error(ResultCode.FORBIDDEN.getCode(), "没有操作权限");
}
/**
* 处理SQL异常
*/
@ExceptionHandler(SQLException.class)
public Result<Void> handleSQLException(SQLException e) {
log.error("SQL异常: {}", e.getMessage(), e);
// 判断是否为唯一约束冲突
if (e.getMessage().contains("Duplicate entry")) {
String[] split = e.getMessage().split(" ");
String value = split[2].replace("'", "");
return Result.error(ResultCode.VALIDATE_FAILED.getCode(), value + "已存在");
}
return Result.error(ResultCode.FAILED.getCode(), "数据库操作失败");
}
/**
* 处理所有未捕获的异常
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常: ", e);
// 生产环境返回友好提示,不暴露具体错误
if (isProduction()) {
return Result.error(ResultCode.FAILED.getCode(), "系统异常,请稍后重试");
}
// 开发环境返回详细错误
return Result.error(ResultCode.FAILED.getCode(), "系统异常: " + e.getMessage());
}
private boolean isProduction() {
String env = System.getProperty("spring.profiles.active");
return "prod".equals(env);
}
}
自定义业务异常:
java
public class BusinessException extends RuntimeException {
private Integer code;
public BusinessException(String message) {
super(message);
this.code = 500; // 默认错误码
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public BusinessException(ResultCode resultCode) {
super(resultCode.getMessage());
this.code = resultCode.getCode();
}
public BusinessException(String message, Throwable cause) {
super(message, cause);
this.code = 500;
}
public Integer getCode() {
return code;
}
}
Service层抛出异常:
java
@Service
public class OrderServiceImpl implements OrderService {
@Override
public Order getById(Long id) {
Optional<Order> optional = orderRepository.findById(id);
return optional.orElseThrow(() ->
new BusinessException(ResultCode.NOT_FOUND.getCode(), "订单不存在")
);
}
@Override
@Transactional
public void create(OrderCreateDTO dto) {
// 检查用户是否存在
User user = userRepository.findById(dto.getUserId())
.orElseThrow(() -> new BusinessException("用户不存在"));
// 检查商品库存
Product product = productRepository.findById(dto.getProductId())
.orElseThrow(() -> new BusinessException("商品不存在"));
if (product.getStock() < dto.getQuantity()) {
throw new BusinessException("商品库存不足");
}
// 创建订单
Order order = convertToEntity(dto);
orderRepository.save(order);
// 扣减库存
product.setStock(product.getStock() - dto.getQuantity());
productRepository.save(product);
}
}
四、拦截器:从"每个方法校验"到"统一拦截"
4.1 登录校验的演变
最初:在每个Controller方法里校验
java
@RestController
public class OrderController {
@GetMapping("/orders")
public List<Order> list(HttpServletRequest request) {
// 每个方法都要写这段代码
Long userId = (Long) request.getSession().getAttribute("user");
if (userId == null) {
throw new AuthenticationException("请先登录");
}
return orderService.listByUser(userId);
}
@PostMapping("/orders")
public Order create(@RequestBody Order order, HttpServletRequest request) {
// 这里又要写一遍
Long userId = (Long) request.getSession().getAttribute("user");
if (userId == null) {
throw new AuthenticationException("请先登录");
}
order.setUserId(userId);
return orderService.create(order);
}
// 其他方法也要写...
}
使用拦截器后:
java
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 判断请求是否为HandlerMethod(Controller方法)
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 2. 检查是否有@LoginRequired注解
HandlerMethod handlerMethod = (HandlerMethod) handler;
LoginRequired loginRequired = handlerMethod.getMethodAnnotation(LoginRequired.class);
// 3. 如果没有@LoginRequired注解,直接放行
if (loginRequired == null) {
return true;
}
// 4. 检查登录状态
Long userId = (Long) request.getSession().getAttribute("user");
if (userId == null) {
// 设置响应状态码和内容类型
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");
// 返回JSON错误信息
Result<Void> result = Result.error(ResultCode.UNAUTHORIZED.getCode(), "请先登录");
response.getWriter().write(JSON.toJSONString(result));
return false;
}
// 5. 将用户ID存储到ThreadLocal
UserContext.setCurrentUserId(userId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清理ThreadLocal,防止内存泄漏
UserContext.removeCurrentUserId();
}
}
// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
// 可以添加更多属性,如需要的权限等
}
// ThreadLocal工具类
public class UserContext {
private static final ThreadLocal<Long> THREAD_LOCAL = new ThreadLocal<>();
public static void setCurrentUserId(Long userId) {
THREAD_LOCAL.set(userId);
}
public static Long getCurrentUserId() {
return THREAD_LOCAL.get();
}
public static void removeCurrentUserId() {
THREAD_LOCAL.remove();
}
}
// Controller变得简洁
@RestController
public class OrderController {
@GetMapping("/orders")
@LoginRequired // 只需要加一个注解
public Result<List<Order>> list() {
Long userId = UserContext.getCurrentUserId(); // 从ThreadLocal获取
List<Order> orders = orderService.listByUser(userId);
return Result.success(orders);
}
@PostMapping("/orders")
@LoginRequired
public Result<Order> create(@RequestBody OrderCreateDTO dto) {
Long userId = UserContext.getCurrentUserId();
dto.setUserId(userId);
Order order = orderService.create(dto);
return Result.success(order);
}
// 公开接口,不需要登录
@GetMapping("/public/orders/{id}")
public Result<Order> getPublicOrder(@PathVariable Long id) {
// 这个方法不需要@LoginRequired注解
Order order = orderService.getById(id);
return Result.success(order);
}
}
配置拦截器:
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**") // 拦截所有路径
.excludePathPatterns( // 排除不需要登录的路径
"/login",
"/register",
"/public/**",
"/front/**", // 静态资源
"/backend/**",
"/error"
);
// 可以添加更多拦截器
registry.addInterceptor(new LogInterceptor())
.addPathPatterns("/**");
registry.addInterceptor(new PerformanceInterceptor())
.addPathPatterns("/**");
}
}
4.2 拦截器链的执行顺序
java
// 1. 日志拦截器
@Component
@Order(1)
public class LogInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
log.info("请求开始: {} {}", request.getMethod(), request.getRequestURI());
request.setAttribute("startTime", System.currentTimeMillis());
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
// 在Controller方法执行后,视图渲染前执行
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
long startTime = (Long) request.getAttribute("startTime");
long duration = System.currentTimeMillis() - startTime;
log.info("请求结束: {} {} 耗时: {}ms", request.getMethod(), request.getRequestURI(), duration);
}
}
// 2. 登录拦截器
@Component
@Order(2)
public class LoginInterceptor implements HandlerInterceptor {
// ... 登录校验逻辑
}
// 3. 权限拦截器
@Component
@Order(3)
public class PermissionInterceptor implements HandlerInterceptor {
// ... 权限校验逻辑
}
// 执行顺序:
// LogInterceptor.preHandle → LoginInterceptor.preHandle → PermissionInterceptor.preHandle
// → Controller方法
// → PermissionInterceptor.postHandle → LoginInterceptor.postHandle → LogInterceptor.postHandle
// → 视图渲染
// → LogInterceptor.afterCompletion → LoginInterceptor.afterCompletion → PermissionInterceptor.afterCompletion
五、前后端联调:从"鸡同鸭讲"到"默契配合"
5.1 跨域问题:前端无法访问后端API
错误现象:
text
Access to fetch at 'http://localhost:8080/api/orders' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
解决方案:
java
// 1. 使用@CrossOrigin注解(不推荐,每个Controller都要加)
@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "http://localhost:3000")
public class OrderController {
// ...
}
// 2. 全局配置(推荐)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 所有接口
.allowedOrigins("http://localhost:3000", "http://localhost:8080")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
// 3. 使用CorsFilter(更灵活,支持更多配置)
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("http://localhost:3000");
config.addAllowedOrigin("http://localhost:8080");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
5.2 文件上传:前端传文件,后端接收

前端代码:
html
<!-- 前端表单 -->
<form id="uploadForm">
<input type="file" name="file" id="fileInput">
<button type="submit">上传</button>
</form>
<script>
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert('请选择文件');
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'avatar'); // 可以传其他参数
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// 注意:不要设置Content-Type,浏览器会自动设置multipart/form-data
});
const result = await response.json();
if (result.code === 200) {
alert('上传成功');
console.log('文件地址:', result.data);
} else {
alert('上传失败: ' + result.msg);
}
} catch (error) {
alert('上传失败: ' + error.message);
}
});
</script>
后端代码:
java
@RestController
@RequestMapping("/api/upload")
@Slf4j
public class UploadController {
// 配置上传文件大小限制
@Bean
public MultipartConfigElement multipartConfigElement() {
MultipartConfigFactory factory = new MultipartConfigFactory();
factory.setMaxFileSize(DataSize.ofMegabytes(10)); // 单个文件最大10MB
factory.setMaxRequestSize(DataSize.ofMegabytes(50)); // 总请求最大50MB
return factory.createMultipartConfig();
}
@PostMapping
public Result<String> upload(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "type", defaultValue = "default") String type) {
// 1. 验证文件
if (file.isEmpty()) {
return Result.error("文件不能为空");
}
// 2. 验证文件类型
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
List<String> allowedExtensions = Arrays.asList(".jpg", ".jpeg", ".png", ".gif");
if (!allowedExtensions.contains(suffix.toLowerCase())) {
return Result.error("不支持的文件类型");
}
// 3. 验证文件大小
if (file.getSize() > 10 * 1024 * 1024) { // 10MB
return Result.error("文件大小不能超过10MB");
}
try {
// 4. 生成文件名(防止重复)
String filename = UUID.randomUUID().toString() + suffix;
// 5. 根据类型选择存储路径
String uploadPath;
if ("avatar".equals(type)) {
uploadPath = "/uploads/avatar/";
} else {
uploadPath = "/uploads/default/";
}
// 创建目录
File dir = new File(uploadPath);
if (!dir.exists()) {
dir.mkdirs();
}
// 6. 保存文件
File dest = new File(uploadPath + filename);
file.transferTo(dest);
// 7. 返回文件访问地址
String fileUrl = "/files" + uploadPath + filename;
return Result.success(fileUrl);
} catch (IOException e) {
log.error("文件上传失败", e);
return Result.error("文件上传失败");
}
}
// 处理文件上传异常
@ExceptionHandler(MaxUploadSizeExceededException.class)
public Result<Void> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
return Result.error("文件大小超过限制");
}
@ExceptionHandler(MultipartException.class)
public Result<Void> handleMultipartException(MultipartException e) {
return Result.error("文件上传异常: " + e.getMessage());
}
}
// 配置静态资源映射,让上传的文件可以被访问
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 映射上传文件目录
registry.addResourceHandler("/files/**")
.addResourceLocations("file:/uploads/")
.setCachePeriod(3600);
// 映射前端静态资源
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/");
}
}
5.3 接口文档:前后端的"合同"
使用Swagger生成API文档:
java
// 1. 添加依赖
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
// 2. 配置Swagger
@Configuration
@EnableOpenApi
public class SwaggerConfig {
@Bean
public Docket api() {
return new Docket(DocumentationType.OAS_30)
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("com.reggie.controller"))
.paths(PathSelectors.any())
.build()
.globalResponses(HttpMethod.GET, globalResponseMessages())
.globalResponses(HttpMethod.POST, globalResponseMessages())
.globalResponses(HttpMethod.PUT, globalResponseMessages())
.globalResponses(HttpMethod.DELETE, globalResponseMessages());
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("瑞吉外卖API文档")
.description("瑞吉外卖系统接口文档")
.version("1.0")
.contact(new Contact("开发团队", "https://example.com", "dev@example.com"))
.build();
}
private List<Response> globalResponseMessages() {
return Arrays.asList(
new ResponseBuilder()
.code("200")
.description("成功")
.representation(MediaType.APPLICATION_JSON)
.apply(r -> r.model(m -> m.referenceModel(rm -> rm.key(k -> k.qualifiedModelName(q -> q.name("Result").namespace("com.reggie.common"))))))
.build(),
new ResponseBuilder()
.code("400")
.description("参数错误")
.build(),
new ResponseBuilder()
.code("401")
.description("未认证")
.build(),
new ResponseBuilder()
.code("403")
.description("无权限")
.build(),
new ResponseBuilder()
.code("500")
.description("服务器错误")
.build()
);
}
}
// 3. 在Controller中使用注解
@RestController
@RequestMapping("/api/orders")
@Api(tags = "订单管理")
public class OrderController {
@Autowired
private OrderService orderService;
@GetMapping
@ApiOperation(value = "获取订单列表", notes = "分页获取订单列表")
@ApiImplicitParams({
@ApiImplicitParam(name = "page", value = "页码", defaultValue = "1", dataType = "int", paramType = "query"),
@ApiImplicitParam(name = "size", value = "每页条数", defaultValue = "10", dataType = "int", paramType = "query")
})
public Result<PageResult<OrderVO>> list(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size) {
PageResult<OrderVO> result = orderService.page(page, size);
return Result.success(result);
}
@PostMapping
@ApiOperation(value = "创建订单", notes = "创建新的订单")
@ApiImplicitParams({
@ApiImplicitParam(name = "dto", value = "订单信息", required = true, dataType = "OrderCreateDTO")
})
public Result<Long> create(@Valid @RequestBody OrderCreateDTO dto) {
Long orderId = orderService.create(dto);
return Result.success(orderId);
}
@GetMapping("/{id}")
@ApiOperation(value = "获取订单详情", notes = "根据ID获取订单详情")
@ApiImplicitParams({
@ApiImplicitParam(name = "id", value = "订单ID", required = true, dataType = "Long", paramType = "path")
})
public Result<OrderVO> getById(@PathVariable Long id) {
OrderVO order = orderService.getById(id);
return Result.success(order);
}
}
// 4. 访问Swagger UI
// http://localhost:8080/swagger-ui/index.html
DTO对象也需要注解:
java
@Data
@ApiModel(value = "OrderCreateDTO", description = "创建订单请求参数")
public class OrderCreateDTO {
@ApiModelProperty(value = "用户ID", required = true, example = "123")
@NotNull(message = "用户ID不能为空")
private Long userId;
@ApiModelProperty(value = "商品ID", required = true, example = "456")
@NotNull(message = "商品ID不能为空")
private Long productId;
@ApiModelProperty(value = "数量", required = true, example = "2")
@NotNull(message = "数量不能为空")
@Min(value = 1, message = "数量必须大于0")
private Integer quantity;
@ApiModelProperty(value = "备注", example = "不要辣")
private String remark;
}
六、项目部署:从"本地运行"到"生产环境"
6.1 多环境配置
yaml
# application.yml
spring:
profiles:
active: @spring.profiles.active@ # Maven占位符,打包时替换
# application-dev.yml (开发环境)
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/reggie_dev
username: root
password: 123456
redis:
host: localhost
port: 6379
logging:
level:
com.reggie: DEBUG
# application-test.yml (测试环境)
server:
port: 8081
spring:
datasource:
url: jdbc:mysql://test-db:3306/reggie_test
username: test_user
password: test_password
redis:
host: test-redis
port: 6379
logging:
level:
com.reggie: INFO
# application-prod.yml (生产环境)
server:
port: 8080
compression:
enabled: true
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
min-response-size: 1024
spring:
datasource:
url: jdbc:mysql://prod-db:3306/reggie_prod?useSSL=true&serverTimezone=Asia/Shanghai
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 10
connection-timeout: 30000
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
password: ${REDIS_PASSWORD}
timeout: 3000
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
logging:
file:
name: logs/application.log
logback:
rollingpolicy:
max-file-size: 10MB
max-history: 30
total-size-cap: 1GB
6.2 部署脚本
bash
#!/bin/bash
# deploy.sh
set -e # 遇到错误立即退出
# 配置
APP_NAME="reggie"
APP_PORT=8080
JAR_PATH="target/$APP_NAME.jar"
LOG_DIR="/var/log/$APP_NAME"
CONFIG_DIR="/etc/$APP_NAME"
BACKUP_DIR="/opt/backup/$(date +%Y%m%d_%H%M%S)"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
echo -e "${GREEN}=== 开始部署 $APP_NAME ===${NC}"
# 1. 备份旧版本
if [ -f "$JAR_PATH" ]; then
echo "备份旧版本..."
mkdir -p $BACKUP_DIR
cp $JAR_PATH $BACKUP_DIR/
fi
# 2. 停止旧服务
echo "停止旧服务..."
systemctl stop $APP_NAME.service 2>/dev/null || true
# 3. 复制配置文件
echo "复制配置文件..."
mkdir -p $CONFIG_DIR
cp src/main/resources/application-prod.yml $CONFIG_DIR/application.yml
# 4. 创建日志目录
echo "创建日志目录..."
mkdir -p $LOG_DIR
chmod 755 $LOG_DIR
# 5. 创建systemd服务文件
echo "创建服务文件..."
cat > /etc/systemd/system/$APP_NAME.service << EOF
[Unit]
Description=$APP_NAME Service
After=network.target mysqld.service redis.service
[Service]
Type=simple
User=appuser
WorkingDirectory=/opt/$APP_NAME
ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar $APP_NAME.jar --spring.config.location=file:$CONFIG_DIR/application.yml
ExecStop=/bin/kill -15 \$MAINPID
Restart=always
RestartSec=10
StandardOutput=append:$LOG_DIR/app.log
StandardError=append:$LOG_DIR/error.log
# 限制资源使用
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=multi-user.target
EOF
# 6. 复制JAR文件
echo "复制JAR文件..."
mkdir -p /opt/$APP_NAME
cp $JAR_PATH /opt/$APP_NAME/
# 7. 设置权限
echo "设置权限..."
chown -R appuser:appuser /opt/$APP_NAME $LOG_DIR $CONFIG_DIR
chmod 755 /opt/$APP_NAME/$APP_NAME.jar
# 8. 重新加载systemd配置
echo "重新加载systemd配置..."
systemctl daemon-reload
# 9. 启动服务
echo "启动服务..."
systemctl start $APP_NAME.service
systemctl enable $APP_NAME.service
# 10. 检查服务状态
echo "检查服务状态..."
sleep 5
if systemctl is-active --quiet $APP_NAME.service; then
echo -e "${GREEN}✅ 服务启动成功${NC}"
# 检查端口是否监听
if netstat -tlnp | grep ":$APP_PORT" > /dev/null; then
echo -e "${GREEN}✅ 端口 $APP_PORT 监听正常${NC}"
else
echo -e "${RED}❌ 端口 $APP_PORT 未监听${NC}"
exit 1
fi
# 检查健康接口
HEALTH_URL="http://localhost:$APP_PORT/actuator/health"
if curl -s $HEALTH_URL | grep -q '"status":"UP"'; then
echo -e "${GREEN}✅ 健康检查通过${NC}"
else
echo -e "${RED}❌ 健康检查失败${NC}"
exit 1
fi
else
echo -e "${RED}❌ 服务启动失败${NC}"
echo "查看日志: tail -f $LOG_DIR/error.log"
exit 1
fi
echo -e "${GREEN}=== 部署完成 ===${NC}"
七、如果重来一次,我会...
7.1 更早建立开发规范
java
// 1. 统一的包结构
com.reggie
├── common # 公共模块
│ ├── config # 配置类
│ ├── constant # 常量
│ ├── exception # 异常类
│ ├── result # 统一返回结果
│ └── utils # 工具类
├── controller # 控制器层
├── service # 服务层
│ ├── impl # 服务实现
│ └── dto # 服务层DTO
├── mapper # 数据访问层
└── entity # 实体类
// 2. 统一的命名规范
// Controller: XxxController
// Service: XxxService (接口), XxxServiceImpl (实现)
// Mapper: XxxMapper
// Entity: XxxEntity (数据库表对应)
// DTO: XxxDTO (数据传输对象)
// VO: XxxVO (视图对象)
// 3. 统一的日志规范
@Slf4j
public class OrderService {
public void createOrder(OrderCreateDTO dto) {
log.info("开始创建订单, 参数: {}", dto);
try {
// 业务逻辑
log.debug("订单创建中...");
} catch (Exception e) {
log.error("订单创建失败, 参数: {}, 异常: {}", dto, e.getMessage(), e);
throw e;
}
log.info("订单创建成功, 订单ID: {}", orderId);
}
}
7.2 更重视接口设计
java
// 不好的设计:参数混乱,职责不清
@PostMapping("/order")
public Result<?> createOrder(
@RequestParam(required = false) Long userId,
@RequestParam(required = false) Long productId,
@RequestParam(required = false) Integer quantity,
@RequestParam(required = false) String address,
@RequestParam(required = false) String phone) {
// 参数校验分散在业务逻辑中
if (userId == null) {
return Result.error("用户ID不能为空");
}
// ...
}
// 好的设计:使用DTO,职责清晰
@PostMapping("/orders")
public Result<Long> createOrder(@Valid @RequestBody OrderCreateDTO dto) {
// 参数校验通过@Valid自动完成
Long orderId = orderService.create(dto);
return Result.success(orderId);
}
// DTO包含完整的参数校验
@Data
public class OrderCreateDTO {
@NotNull(message = "用户ID不能为空")
private Long userId;
@NotNull(message = "商品ID不能为空")
private Long productId;
@NotNull(message = "数量不能为空")
@Min(value = 1, message = "数量必须大于0")
private Integer quantity;
@NotBlank(message = "地址不能为空")
private String address;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
}
7.3 更早引入监控和告警
java
// 监控切面
@Aspect
@Component
@Slf4j
public class MonitorAspect {
@Autowired
private MeterRegistry meterRegistry;
@Pointcut("execution(* com.reggie.controller..*(..))")
public void controllerPointcut() {}
@Around("controllerPointcut()")
public Object monitor(ProceedingJoinPoint joinPoint) throws Throwable {
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
String metricName = "controller." + className + "." + methodName;
Timer.Sample sample = Timer.start(meterRegistry);
try {
Object result = joinPoint.proceed();
sample.stop(Timer.builder(metricName)
.tag("result", "success")
.register(meterRegistry));
return result;
} catch (Exception e) {
sample.stop(Timer.builder(metricName)
.tag("result", "error")
.tag("exception", e.getClass().getSimpleName())
.register(meterRegistry));
throw e;
}
}
}
// 健康检查端点
@RestController
@RequestMapping("/actuator")
public class HealthController {
@Autowired
private DataSource dataSource;
@GetMapping("/health")
public Map<String, Object> health() {
Map<String, Object> health = new LinkedHashMap<>();
health.put("status", "UP");
health.put("timestamp", System.currentTimeMillis());
// 检查数据库
Map<String, Object> dbHealth = new LinkedHashMap<>();
try {
Connection conn = dataSource.getConnection();
dbHealth.put("status", conn.isValid(2) ? "UP" : "DOWN");
conn.close();
} catch (Exception e) {
dbHealth.put("status", "DOWN");
dbHealth.put("error", e.getMessage());
}
health.put("database", dbHealth);
// 系统信息
Runtime runtime = Runtime.getRuntime();
Map<String, Object> systemInfo = new LinkedHashMap<>();
systemInfo.put("memory.used", (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024 + "MB");
systemInfo.put("memory.total", runtime.totalMemory() / 1024 / 1024 + "MB");
systemInfo.put("memory.max", runtime.maxMemory() / 1024 / 1024 + "MB");
systemInfo.put("cpu.cores", runtime.availableProcessors());
systemInfo.put("uptime", ManagementFactory.getRuntimeMXBean().getUptime() + "ms");
health.put("system", systemInfo);
return health;
}
}
结语:从"能跑就行"到"专业级项目"
SSM整合的过程,让我从一个只会写单个功能的程序员,成长为一个能够设计完整系统的工程师。我学到的不仅仅是技术,更是工程思维:
- 分层架构:清晰的职责划分,便于维护和扩展
- 统一规范:团队协作的基础,减少沟通成本
- 异常处理:用户体验的关键,系统的稳定性保障
- 监控告警:线上运维的眼睛,及时发现问题
- 部署运维:项目的最后一公里,决定项目成败
现在回头看那些混乱的接口和随意的异常处理,就像看到新手厨师乱放调料——虽然也能吃,但谈不上美味。
软件开发最重要的不是写出能跑的代码,而是写出容易维护、容易扩展、容易协作的代码。 好的架构就像好的交通规则,虽然前期制定需要时间,但能让整个系统运行得更顺畅。
与所有正在从"功能实现"向"系统设计"转变的程序员共勉:不要只满足于写出能跑的代码,要追求写出"优雅"的代码。
知识点测试
读完文章了?来测试一下你对知识点的掌握程度吧!
评论区
使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。
如果评论系统无法加载,请确保:
- 您的网络可以访问 GitHub
- giscus GitHub App 已安装到仓库
- 仓库已启用 Discussions 功能