返回 金丹・SSM 三式凝丹

27从"杂乱无章"到"规范统一":SSM整合与前后端联调的涅槃之路

博主
大约 34 分钟

27从"杂乱无章"到"规范统一":SSM整合与前后端联调的涅槃之路

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

image-20260202185505017

一、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配置
}

问题爆发:

  1. Bean重复创建:Controller被Spring和SpringMVC各创建一次
  2. 事务不生效:Service层事务配置混乱
  3. 循环依赖:A依赖B,B又依赖A
  4. 启动报错:各种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());
        }
    }
}

二、统一结果封装:从"千姿百态"到"整齐划一"

image-20260202190508922

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 统一响应格式的设计

设计原则:

  1. 固定格式:所有接口返回相同结构
  2. 包含状态码:明确表示成功/失败
  3. 包含消息:给用户/开发者的提示
  4. 包含数据:实际业务数据
  5. 包含时间戳:便于调试和日志追踪

统一响应类:

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错误页"到"友好提示"

image-20260202190540109

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 文件上传:前端传文件,后端接收

image-20260202191510049

前端代码:

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整合的过程,让我从一个只会写单个功能的程序员,成长为一个能够设计完整系统的工程师。我学到的不仅仅是技术,更是工程思维

  1. 分层架构:清晰的职责划分,便于维护和扩展
  2. 统一规范:团队协作的基础,减少沟通成本
  3. 异常处理:用户体验的关键,系统的稳定性保障
  4. 监控告警:线上运维的眼睛,及时发现问题
  5. 部署运维:项目的最后一公里,决定项目成败

现在回头看那些混乱的接口和随意的异常处理,就像看到新手厨师乱放调料——虽然也能吃,但谈不上美味。

软件开发最重要的不是写出能跑的代码,而是写出容易维护、容易扩展、容易协作的代码。 好的架构就像好的交通规则,虽然前期制定需要时间,但能让整个系统运行得更顺畅。

与所有正在从"功能实现"向"系统设计"转变的程序员共勉:不要只满足于写出能跑的代码,要追求写出"优雅"的代码。

知识点测试

读完文章了?来测试一下你对知识点的掌握程度吧!

评论区

使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。

如果评论系统无法加载,请确保:

  • 您的网络可以访问 GitHub
  • giscus GitHub App 已安装到仓库
  • 仓库已启用 Discussions 功能