Spring Boot 应用
约 7915 字大约 26 分钟
2026-03-28
常见场景开发逻辑
新建项目的源头
项目初始化构建地址
Spring 2.7.x:https://start.aliyun.com/
Spring 新版:https://start.spring.io
Spring Boot 2 迁移 Spring Boot 3:Spring Boot 2.x 迁移到 Spring Boot 3.x
依赖管理
Maven/Gradle 依赖管理(多模块、BOM 版本控制),统一父 POM 或 Gradle BOM 管理依赖版本。
可采用依赖:spring-boot-dependencies,此处定义了很多组件的版本。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>关于 Maven 必须要遵守书写规范和顺序,下面是一个配置文件的模板
<project>
<modelVersion>4.0.0</modelVersion> <!-- 必须第一行 -->
<groupId>org.felixcjy</groupId>
<artifactId>spring-boot-components-integration</artifactId>
<version>0.1</version> <!-- 必须是固定值 -->
<packaging>pom</packaging> <!-- 父项目通常是 pom -->
<parent>...</parent>
<modules>...</modules>
<properties>...</properties>
<dependencyManagement>...</dependencyManagement>
<dependencies>...</dependencies>
<build>...</build>
</project>全局异常的引入和使用
全局异常通常用两个注解 @RestControllerAdvice 和 @ExceptionHandler
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import com.ly.cloud.common.Result;
import com.ly.cloud.exception.CloudException;
import com.ly.cloud.exception.biz.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import javax.validation.ConstraintViolationException;
import java.text.MessageFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 全局异常处理
*
* @author: Felix(蔡济阳)
* @since : 2025/5/7 09:44
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 参数校验异常 */
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public ResponseEntity<Map<String, Object>> methodArgumentNotValidException(MethodArgumentNotValidException e) {
String errors = e.getBindingResult().getFieldErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(";"));
Map<String, Object> result = new HashMap<>();
result.put("code", HttpStatus.BAD_REQUEST.value());
result.put("message", errors);
return ResponseEntity.badRequest().body(result);
}
/** 请求格式问题 */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, Object>> handleHttpMessageNotReadable(HttpMessageNotReadableException ex) {
Throwable cause = ex.getCause();
Map<String, Object> result = new HashMap<>();
result.put("code", HttpStatus.BAD_REQUEST.value());
if (cause instanceof InvalidFormatException) {
InvalidFormatException ife = (InvalidFormatException) cause;
if (Date.class.isAssignableFrom(ife.getTargetType())) {
result.put("message", "时间格式错误,请使用 yyyy-MM-dd HH:mm:ss");
return ResponseEntity.badRequest().body(result);
}
}
result.put("message", "请求体格式不正确");
return ResponseEntity.badRequest().body(result);
}
/** 参数类型异常 */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Map<String, Object>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
Map<String, Object> result = new HashMap<>();
result.put("code", HttpStatus.BAD_REQUEST.value());
if (ex.getRequiredType() != null && Date.class.isAssignableFrom(ex.getRequiredType())) {
result.put("message", "时间格式错误,请使用 yyyy-MM-dd HH:mm:ss");
} else {
result.put("message", "参数类型错误: " + ex.getName());
}
return ResponseEntity.badRequest().body(result);
}
/** 校验失败异常 */
@ExceptionHandler({ConstraintViolationException.class})
public ResponseEntity<Map<String, Object>> methodSimpleArgumentNotValidException(Exception ex) {
Map<String, Object> result = new HashMap<>();
result.put("code", HttpStatus.BAD_REQUEST.value());
result.put("message", "参数校验失败:" + ex.getMessage());
return ResponseEntity.badRequest().body(result);
}
/** 处理参数绑定异常 */
@ExceptionHandler(BindException.class)
@ResponseBody
public ResponseEntity<Result<Object>> bindException(BindException e) {
String errors =
e.getBindingResult().getFieldErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(";"));
return new ResponseEntity<>(Result.failure(errors, errors), HttpStatus.BAD_REQUEST);
}
/** 处理缺少请求参数异常 */
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseBody
public ResponseEntity<Result<Object>> missingServletRequestParameterException(MissingServletRequestParameterException e) {
log.warn(e.getMessage());
return new ResponseEntity<>(Result.failure(e.getMessage()), HttpStatus.BAD_REQUEST);
}
/** 处理请求方式不支持异常 */
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseBody
public ResponseEntity<Result<Object>> httpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
log.warn(e.getMessage());
return new ResponseEntity<>(Result.failure(e.getMessage()), HttpStatus.METHOD_NOT_ALLOWED);
}
/** 处理运行时业务异常(自定义) */
@ExceptionHandler(BusinessException.class)
@ResponseBody
public ResponseEntity<Result<Object>> businessException(BusinessException e) {
log.warn(e.getMessage());
return new ResponseEntity<>(Result.failure(e.getMessage()), HttpStatus.OK);
}
/** 处理运行时业务异常(自定义) */
@ExceptionHandler(CloudException.class)
@ResponseBody
public ResponseEntity<Result<Object>> cloudException(CloudException e) {
log.warn(e.getMessage(), e);
return new ResponseEntity<>(Result.failure(e.getMessage()), HttpStatus.OK);
}
/** 处理运行时参数异常 */
@ExceptionHandler(IllegalArgumentException.class)
@ResponseBody
public ResponseEntity<Result<Object>> illegalArgumentException(IllegalArgumentException e) {
log.warn(e.getMessage(), e);
return new ResponseEntity<>(Result.failure(MessageFormat.format("参数错误【{0}】", e.getMessage())), HttpStatus.OK);
}
/** 处理数据库操作异常 */
@ExceptionHandler(DataAccessException.class)
@ResponseBody
public ResponseEntity<Result<Object>> sqlException(DataAccessException e) {
log.error(e.getMessage(), e);
return new ResponseEntity<>(Result.failure("数据库异常"), HttpStatus.OK);
}
/** 处理未知异常 */
@ExceptionHandler(Exception.class)
@ResponseBody
public ResponseEntity<Result<Object>> exception(Exception e) {
log.error(e.getMessage(), e);
return new ResponseEntity<>(Result.failure(e.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR);
}
}处理复制对象问题
方法有很多,只需要关注 3 种即可。
- BeanUtils.copyProperties()(Spring)
- 简单易用、无需依赖;性能较低、深拷贝困难、字段名需一致
- MapStruct
- 编译期生成代码、性能极高;需要编写接口、添加注解处理器
- 手写转换
浅说一下 MapStruct
依赖
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
<scope>provided</scope>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.10.1</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>转换接口
@Mapper(componentModel = "spring")
public interface UserConverter {
UserDTO toDTO(UserEntity entity);
UserEntity toEntity(UserDTO dto);
}其他转换方法:
- ModelMapper
- Dozer
- Lombok @Builder + 构造
文件处理(MinIO)
文件校验/接收,请看:上传文件校验
文件流式返回
@GetMapping("/downloadRequiredMaterialFileById")
@Operation(summary = "所需材料:通过文件 ID 获取文件流,可用于下载")
public void downloadRequiredMaterialFileById(@RequestParam @NotBlank(message = "文件 ID 不能为空") String fileId,
HttpServletResponse response) {
processGuideService.downloadRequiredMaterialFileById(fileId, response);
}@Override
public void downloadRequiredMaterialFileById(String fileId, HttpServletResponse response) {
String minioFileName = processGuideRequiredMaterialsService.getById(fileId).getMaterialMinIOName();
downloadFile(minioFileName, response);
}
/** 文件类下载处理 */
private void downloadFile(String minioObjectName, HttpServletResponse response) {
try {
String originalFilename = extractOriginalFilename(minioObjectName);
InputStream inputStream = minioService.downloadFile(minioObjectName);
String fileExtension = FilenameUtils.getExtension(originalFilename).toLowerCase();
String contentType = Files.probeContentType(Paths.get("dummy." + fileExtension));
if (contentType == null) {
// 默认通用类型文件
contentType = "application/octet-stream";
}
response.setContentType(contentType);
response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(originalFilename, "UTF-8") + "\"");
IOUtils.copy(inputStream, response.getOutputStream());
response.flushBuffer();
inputStream.close();
} catch (IOException e) {
log.error("downloadFile: IO流出错出错:{}", e.getMessage(), e);
throw new BusinessException("IO流出错,请重试!");
} catch (Exception e) {
log.error("downloadFile: minioService 出错:{}", e.getMessage(), e);
throw new BusinessException("文件存储服务异常,请重试!");
}
}🚧 文件处理(RustFS)
组件整合
整合参数校验 Validation
依赖
Spring Boot 2.3+ 已经默认引入了 spring-boot-starter-validation(Hibernate Validator)。如果没有,可以手动加上:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>具体使用方法见:Controller 参数与校验
这里单独说一下异常处理。
参数校验失败会抛出 MethodArgumentNotValidException 或 ConstraintViolationException,推荐统一处理:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(err ->
errors.put(err.getField(), err.getDefaultMessage())
);
return ResponseEntity.badRequest().body(errors);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<?> handleConstraint(ConstraintViolationException ex) {
Map<String, String> errors = new HashMap<>();
ex.getConstraintViolations().forEach(v -> {
String field = v.getPropertyPath().toString();
errors.put(field, v.getMessage());
});
return ResponseEntity.badRequest().body(errors);
}
}整合数据连接池(druid)
网站:https://github.com/alibaba/druid?tab=readme-ov-file
1、导包
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>${druid-version}</version>
</dependency>2、配置
参考:https://github.com/alibaba/druid/wiki/DruidDataSource%E9%85%8D%E7%BD%AE
# 数据源配置
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 50MB
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.228.132:3306/components-integration?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: Fxsd1100
druid:
poolPreparedStatements: true
maxOpenPreparedStatements: 20
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
connectTimeout: 30000
# 配置网络超时时间
socketTimeout: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
webStatFilter:
enabled: true
statViewServlet:
enabled: true
# 设置白名单,不填则允许所有访问
allow:
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: felix
login-password: Fxsd1100
filter:
stat:
enabled: true
# 慢SQL记录
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: true
wall:
config:
multi-statement-allow: true整合 MyBatis-Plus
1、导包
<!-- 数据库: MyBatisPlus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<!-- 数据库: MyBatisPlus 分页插件-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
</dependency>2、配置
mybatis-plus:
db-type: MYSQL
mapper-locations: classpath*:mapper/**/*Mapper.xml
typeAliasesPackage: org.felixcjy.**.domain.entity
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl@Configuration
@MapperScan({"org.felixcjy.**.mapper*"})
public class MybatisPlusConfig {
@Autowired
private DataSource dataSource;
@Value("${mybatis-plus.db-type}")
private String dbType;
/**
* 注册 MyBatis-Plus 插件拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(resolveDbType()));
return interceptor;
}
/** 动态识别数据库类型 */
private DbType resolveDbType() {
try (Connection conn = dataSource.getConnection()) {
String url = conn.getMetaData().getURL().toLowerCase();
if (url.contains("mysql")) {
return DbType.MYSQL;
} else if (url.contains("oracle")) {
return DbType.ORACLE;
} else if (DbType.getDbType(dbType) != null) {
return DbType.getDbType(dbType);
} else {
throw new UnsupportedOperationException("不支持的数据库类型: " + url);
}
} catch (SQLException e) {
throw new RuntimeException("无法获取数据库连接,无法识别数据库类型", e);
}
}
}整合 MinIO
依赖
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
</dependency>spring:
# 需要限制文件的大小
servlet:
multipart:
max-file-size: 10MB
max-request-size: 50MB
# minio 配置
minio:
endpoint: http://127.0.0.1:9000
bucket-name: felix.bucket
access-key: gKTW8hg66sYvzauO2PjI
secret-key: WAeM79Fs4ID0hESXaD4QCaJUCtQOqVNBO3jOwQcP映射实体
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* @author: Felix(蔡济阳)
* @since : 2025/5/4 17:23
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioProperties {
private String endpoint;
private String accessKey;
private String secretKey;
private String bucketName;
private Boolean secure;
}配置类
import org.felixcjy.minio.properties.MinioProperties;
import io.minio.MinioClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: Felix(蔡济阳)
* @since : 2025/5/4 17:09
*/
@Configuration
public class MinioConfig {
@Bean
public MinioClient minioClient(MinioProperties properties) {
return MinioClient.builder()
.endpoint(properties.getEndpoint())
.credentials(properties.getAccessKey(), properties.getSecretKey())
.build();
}
}服务接口
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.util.List;
/**
* @author: Felix(蔡济阳)
* @since : 2025/5/4 17:19
*/
public interface MinIOService {
/** 判断 Bucket 是否存在 */
boolean bucketExists(String bucketName) throws Exception;
/** 创建 Bucket(不存在时) */
void createBucket(String bucketName) throws Exception;
/** 删除 Bucket(必须为空) */
void deleteBucket(String bucketName) throws Exception;
/** 获取所有 Bucket 名称 */
List<String> listAllBuckets() throws Exception;
/**
* 上传文件(自动生成或指定文件名)
*
* @param file Multipart 文件
* @param objectName 可选对象名(可为 null)
* @return 最终对象名
*/
String uploadFile(MultipartFile file, String objectName) throws Exception;
/**
* 使用流上传文件
*
* @param stream 文件流
* @param contentType 内容类型
* @param size 文件大小
* @param objectName 对象名称
* @return 对象名称
*/
String uploadFile(InputStream stream, String contentType, long size, String objectName) throws Exception;
/** 下载文件,返回 InputStream(可用于构造响应流) */
InputStream downloadFile(String objectName) throws Exception;
/**
* 获取对象的预签名访问 URL
*
* @param objectName 对象名称
* @param expires 过期时间(秒)
* @return URL 字符串
*/
String getPresignedUrl(String objectName, int expires) throws Exception;
/** 检查对象是否存在 */
boolean exists(String objectName) throws Exception;
/** 删除对象 */
void delete(String objectName) throws Exception;
/**
* 获取 Bucket 中对象列表(可按前缀过滤)
*
* @param prefix 前缀(可为 null)
* @param recursive 是否递归
*/
List<String> listObjects(String prefix, boolean recursive) throws Exception;
/** 获取对象的 Content-Type */
String getContentType(String objectName) throws Exception;
}服务实现
import io.minio.*;
import io.minio.errors.ErrorResponseException;
import io.minio.http.Method;
import io.minio.messages.Bucket;
import io.minio.messages.Item;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.felixcjy.minio.properties.MinioProperties;
import org.felixcjy.minio.service.MinIOService;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @author: Felix(蔡济阳)
* @since : 2025/5/4 17:30
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MinioServiceImpl implements MinIOService {
private final MinioClient minioClient;
private final MinioProperties properties;
@Override
public boolean bucketExists(String bucketName) throws Exception {
return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
}
@Override
public void createBucket(String bucketName) throws Exception {
if (!bucketExists(bucketName)) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
}
}
@Override
public void deleteBucket(String bucketName) throws Exception {
if (bucketExists(bucketName)) {
minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
}
}
@Override
public List<String> listAllBuckets() throws Exception {
List<Bucket> buckets = minioClient.listBuckets();
List<String> names = new ArrayList<>();
for (Bucket b : buckets) {
names.add(b.name());
}
return names;
}
@Override
public String uploadFile(MultipartFile file, String objectName) throws Exception {
return uploadFile(file.getInputStream(), file.getContentType(), file.getSize(),
objectName != null ? objectName : UUID.randomUUID() + "_" + file.getOriginalFilename());
}
@Override
public String uploadFile(InputStream stream, String contentType, long size, String objectName) throws Exception {
PutObjectArgs args = PutObjectArgs.builder()
.bucket(properties.getBucketName())
.object(objectName)
.stream(stream, size, -1)
.contentType(contentType)
.build();
minioClient.putObject(args);
return objectName;
}
@Override
public InputStream downloadFile(String objectName) throws Exception {
GetObjectArgs args = GetObjectArgs.builder()
.bucket(properties.getBucketName())
.object(objectName)
.build();
return minioClient.getObject(args);
}
@Override
public String getPresignedUrl(String objectName, int expires) throws Exception {
GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
.bucket(properties.getBucketName())
.object(objectName)
.method(Method.GET)
.expiry(expires, TimeUnit.SECONDS)
.build();
return minioClient.getPresignedObjectUrl(args);
}
@Override
public boolean exists(String objectName) throws Exception {
try {
minioClient.statObject(StatObjectArgs.builder()
.bucket(properties.getBucketName())
.object(objectName)
.build());
return true;
} catch (ErrorResponseException e) {
return false;
}
}
@Override
public void delete(String objectName) throws Exception {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(properties.getBucketName())
.object(objectName)
.build());
}
@Override
public List<String> listObjects(String prefix, boolean recursive) throws Exception {
Iterable<Result<Item>> results = minioClient.listObjects(ListObjectsArgs.builder()
.bucket(properties.getBucketName())
.prefix(prefix != null ? prefix : "")
.recursive(recursive)
.build());
List<String> objectNames = new ArrayList<>();
for (Result<Item> result : results) {
objectNames.add(result.get().objectName());
}
return objectNames;
}
@Override
public String getContentType(String objectName) throws Exception {
StatObjectResponse stat = minioClient.statObject(StatObjectArgs.builder()
.bucket(properties.getBucketName())
.object(objectName)
.build());
return stat.contentType();
}
}整合 Redis 缓存
依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>配置
spring:
redis:
host: 192.168.228.132
port: 6379
password: Fxsd1100
database: 0
lettuce:
pool:
max-active: 8 # 最大活跃连接数
max-idle: 8 # 最大空闲连接数
min-idle: 0 # 最小空闲连接数import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* 配置 RedisTemplate 序列化
*
* @author: Felix(蔡济阳)
* @since : 2025/3/9 08:57
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 设置 Key 的序列化为 String
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// 设置 Value 的序列化为 JSON
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}可选配置:启用 Spring Cache 缓存并配置 Redis 缓存管理器
package org.felixcjy.redis.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* 启用 Spring Cache 缓存并配置 Redis 缓存管理器
*
* @author: Felix(蔡济阳)
* @since : 2025/3/9 08:58
*/
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofMinutes(30));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}服务接口
/**
* Redis 服务接口
*
* @author: Felix(蔡济阳)
* @since : 2025/3/11 14:08
*/
public interface RedisService {
void setValue(String key, String value);
String getValue(String key);
boolean deleteKey(String key);
boolean setExpire(String key, long seconds);
/** 可选:带过期时间的设置方法 */
void setValueWithExpire(String key, String value, long seconds);
}服务实现
import lombok.AllArgsConstructor;
import org.felixcjy.redis.service.RedisService;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* Redis 服务实现类
*
* @author: Felix(蔡济阳)
* @since : 2025/3/11 14:10
*/
@Service
@AllArgsConstructor
public class RedisServiceImpl implements RedisService {
private final StringRedisTemplate redisTemplate;
@Override
public void setValue(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}
@Override
public String getValue(String key) {
return redisTemplate.opsForValue().get(key);
}
@Override
public boolean deleteKey(String key) {
return redisTemplate.delete(key);
}
@Override
public boolean setExpire(String key, long seconds) {
return redisTemplate.expire(key, seconds, TimeUnit.SECONDS);
}
@Override
public void setValueWithExpire(String key, String value, long seconds) {
redisTemplate.opsForValue().set(key, value, seconds, TimeUnit.SECONDS);
}
}整合 Swagger 3.0
配置
<!-- 工具: SpringDoc -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>${springdoc-openapi-ui.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-common</artifactId>
<version>${springdoc-openapi-common.version}</version>
</dependency># Swagger 页面路径
springdoc:
api-docs:
path: /v3/api-docs # 修改 API 文档路径(默认 /v3/api-docs)
swagger-ui:
path: /swagger-ui.html # 修改 Swagger UI 路径import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger 文档配置
*
* @author: Felix(蔡济阳)
* @since : 2025/3/10 09:28
*/
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("多模块 API 文档")
.version("1.0")
.description("Spring Boot 2.7.18 + OpenAPI 3"));
}
}案例
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
/**
* 用于演示 Swagger 使用<br/><br/>
* <a href="http://localhost:8080/v3/api-docs">OpenAPI JSON 描述</a><br/>
* <a href="http://localhost:8080/swagger-ui.html">Swagger UI 界面</a>
*
* @author: Felix(蔡济阳)
* @since : 2025/3/10 09:38
*/
@RestController
@RequestMapping("/api/swagger")
@Tag(name = "Swagger接口管理", description = "Swagger接口操作案例") // 接口分组标签
public class SwaggerController {
// 示例 DTO 类(假设定义在公共模块)
@Data
public static class UserDTO {
@Schema(description = "用户ID", example = "1001")
private Long id;
@Schema(description = "用户名", example = "张三")
private String name;
}
// 示例错误响应类
@Data
public static class ErrorResponse {
@Schema(description = "错误码", example = "404")
private int code;
@Schema(description = "错误信息", example = "用户不存在")
private String message;
}
@GetMapping("/{userId}")
@Operation(summary = "获取用户详情", // 接口简要描述
description = "根据用户ID查询用户详细信息" // 详细说明
)
@ApiResponse(responseCode = "200",
description = "成功获取用户",
content = @Content(schema = @Schema(implementation = UserDTO.class))
)
@ApiResponse(responseCode = "404",
description = "用户不存在",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
)
public UserDTO getUser(@PathVariable
@Parameter(description = "用户ID", required = true, example = "1001")
Long userId) {
// 业务逻辑
return new UserDTO();
}
@PostMapping
@Operation(summary = "创建用户")
@ApiResponse(responseCode = "201",
description = "用户创建成功"
)
public ResponseEntity<Void> createUser(@RequestBody
@Parameter(description = "用户数据", required = true)
UserDTO userDTO) {
// 业务逻辑
return ResponseEntity.status(HttpStatus.CREATED).build();
}
@GetMapping("/search")
@Operation(summary = "搜索用户")
public List<UserDTO> searchUsers(@RequestParam
@Parameter(description = "用户名关键字", example = "张")
String keyword,
@RequestParam(required = false, defaultValue = "1")
@Parameter(description = "页码", example = "1")
Integer page) {
// 业务逻辑
return Arrays.asList(new UserDTO());
}
@Hidden // 隐藏该接口
@GetMapping("/secret")
public String secretApi() {
return "秘密接口";
}
}🚧 替换为 Knife4J
整合 JWT
依赖
<!-- 安全:JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- 安全:JWT:使用 Jackson 处理 JSON -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency># jwt 路径鉴权
jwt:
intercept-paths:
- /openapi/*
white-list-paths:
- /api/token/getToken
- /actuator/healthimport lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* @author: Felix(蔡济阳)
* @since : 2025/5/23 10:31
*/
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
/** 需要拦截的路径 */
private List<String> interceptPaths;
/** 白名单路径(放行) */
private List<String> whiteListPaths;
}import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import java.util.Date;
/**
* @author: Felix(蔡济阳)
* @since : 2025/5/23 09:56
*/
public class JwtUtils {
// 自动生成密钥
private static final SecretKey SECRET_KEY = Jwts.SIG.HS256.key().build();
// 设置过期时间为 2 小时
private static final long EXPIRATION_MS = 2 * 60 * 60 * 1000L;
public static String generateToken(String clientId) {
return Jwts.builder()
.subject(clientId)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
.signWith(SECRET_KEY)
.compact();
}
public static String validateTokenAndGetClientId(String token) {
return Jwts.parser()
.verifyWith(SECRET_KEY)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
}import org.springframework.util.AntPathMatcher;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
/**
* @author: Felix(蔡济阳)
* @since : 2025/5/23 10:03
*/
public class JwtFilter implements Filter {
private final List<String> whiteListPaths;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
public JwtFilter(List<String> whiteListPaths) {
this.whiteListPaths = whiteListPaths;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String path = httpRequest.getRequestURI();
// 白名单路径直接放行
if (isWhiteListed(path)) {
chain.doFilter(request, response);
return;
}
String token = httpRequest.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
try {
String clientId = JwtUtils.validateTokenAndGetClientId(token);
httpRequest.setAttribute("clientId", clientId);
chain.doFilter(request, response);
return;
} catch (Exception e) {
((HttpServletResponse) response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "无效Token");
return;
}
}
((HttpServletResponse) response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "未提供Token");
}
private boolean isWhiteListed(String requestPath) {
for (String pattern : whiteListPaths) {
if (pathMatcher.match(pattern, requestPath)) {
return true;
}
}
return false;
}
}import com.ly.cloud.properties.JwtProperties;
import com.ly.cloud.security.JwtFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
/**
* @author: Felix(蔡济阳)
* @since : 2025/5/23 10:10
*/
@Configuration
public class WebConfig {
private final JwtProperties jwtProperties;
public WebConfig(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
}
@Bean
public FilterRegistrationBean<Filter> jwtFilterRegistration() {
FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
// 设置白名单
registrationBean.setFilter(new JwtFilter(jwtProperties.getWhiteListPaths()));
// 设置需要拦截的路径
for (String path : jwtProperties.getInterceptPaths()) {
registrationBean.addUrlPatterns(path);
}
registrationBean.setOrder(1);
return registrationBean;
}
}🚧 整合 Spring Security
整合消息队列
Kafka
引入依赖
<!-- 中间件:消息队列:kafka -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>配置文件
kafka:
send: true
bootstrap-servers: 172.22.43.119:9092
producer:
topic: transaction
batch-size: 16384
buffer-memory: 33554432
key-serializer: org.apache.kafka.common.serialization.StringSerializer
retries: 0
value-serializer: org.apache.kafka.common.serialization.StringSerializer
consumer:
topic: transaction
auto-commit-interval: 100
auto-offset-reset: earliest
enable-auto-commit: false
group-id: test-consumer-group
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer配置文件映射类
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* Kafka 配置属性绑定类,对应配置文件中 spring.kafka 节点。
* 支持 producer 和 consumer 两套配置,统一 bootstrapServers。
*
* @author: Felix(蔡济阳)
* @since : 2025/6/30 11:00
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "spring.kafka")
public class KafkaProperties {
/** 是否启用发送功能,方便在开发环境关闭 Kafka 发送 */
private boolean send;
/** 系统业务标识码,用于日志打印追踪 */
private String bisCode;
/** Kafka 服务地址 */
private String bootstrapServers;
/** 生产者配置 */
private ProducerConfig producer;
/** 消费者配置 */
private ConsumerConfig consumer;
@Data
public static class ProducerConfig {
/** 默认发送的 topic */
private String topic;
/** 重试次数,默认 0 */
private int retries = 0;
/** 批次大小,默认 16384 */
private int batchSize = 16384;
/** 缓冲区大小,默认 33554432 */
private int bufferMemory = 33554432;
/** key 序列化类 */
private String keySerializer;
/** value 序列化类 */
private String valueSerializer;
}
@Data
public static class ConsumerConfig {
/** 要监听的 topic */
private String topic;
/** 消费者组 ID */
private String groupId;
/** 是否自动提交 offset */
private boolean enableAutoCommit;
/** 自动提交间隔 */
private int autoCommitInterval;
/** offset 重置策略 earliest/latest */
private String autoOffsetReset;
/** 并发消费者线程数,必须大于 0,默认 1 */
private int concurrency = 1;
/** key 反序列化类 */
private String keyDeserializer;
/** value 反序列化类 */
private String valueDeserializer;
}
}配置生产者
import lombok.RequiredArgsConstructor;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* Kafka 生产者配置
*
* @author: Felix(蔡济阳)
* @since : 2025/6/30 11:06
*/
@Configuration
@RequiredArgsConstructor
public class KafkaProducerConfig {
private final KafkaProperties kafkaProperties;
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> config = new HashMap<>();
KafkaProperties.ProducerConfig p = kafkaProperties.getProducer();
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaProperties.getBootstrapServers());
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, p.getKeySerializer());
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, p.getValueSerializer());
config.put(ProducerConfig.BATCH_SIZE_CONFIG, Math.max(1, p.getBatchSize()));
config.put(ProducerConfig.BUFFER_MEMORY_CONFIG, Math.max(1024 * 1024, p.getBufferMemory()));
config.put(ProducerConfig.RETRIES_CONFIG, Math.max(0, p.getRetries()));
return new DefaultKafkaProducerFactory<>(config);
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}生产者服务
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
/**
* Kafka 生产者服务
*
* @author: Felix(蔡济阳)
* @since : 2025/6/30 11:09
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class KafkaProducerService {
private final KafkaTemplate<String, String> kafkaTemplate;
private final KafkaProperties kafkaProperties;
public void send(String key, String message) {
if (!kafkaProperties.isSend()) {
log.warn("[Kafka-SEND] disabled by config. skip. topic={}, key={}, message={}",
kafkaProperties.getProducer().getTopic(), key, message);
return;
}
String topic = kafkaProperties.getProducer().getTopic();
String bisCode = kafkaProperties.getBisCode();
long start = System.currentTimeMillis();
log.info("[Kafka-SEND] begin. bisCode={}, topic={}, key={}, message={}", bisCode, topic, key, message);
kafkaTemplate.send(topic, key, message).addCallback(
result -> {
if (result != null) {
RecordMetadata metadata = result.getRecordMetadata();
long cost = System.currentTimeMillis() - start;
log.info("[Kafka-SEND] success. topic={}, partition={}, offset={}, key={}, cost={}ms",
metadata.topic(), metadata.partition(), metadata.offset(), key, cost);
} else {
log.warn("[Kafka-SEND] RecordMetadata result is null.");
}
},
ex -> {
log.error("[Kafka-SEND] failed. topic={}, key={}, message={}, error={}",
topic, key, message, ex.getMessage(), ex);
}
);
}
}配置消费者
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.listener.ContainerProperties;
import java.util.HashMap;
import java.util.Map;
/**
* Kafka 消费者配置
*
* @author: Felix(蔡济阳)
* @since : 2025/6/30 16:09
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class KafkaConsumerConfig {
private final KafkaProperties kafkaProperties;
@Bean
public ConsumerFactory<String, String> consumerFactory() {
KafkaProperties.ConsumerConfig c = kafkaProperties.getConsumer();
Map<String, Object> config = new HashMap<>();
config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaProperties.getBootstrapServers());
config.put(ConsumerConfig.GROUP_ID_CONFIG, c.getGroupId());
config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, c.isEnableAutoCommit());
config.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, c.getAutoCommitInterval());
config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, c.getAutoOffsetReset());
config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, c.getKeyDeserializer());
config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, c.getValueDeserializer());
return new DefaultKafkaConsumerFactory<>(config);
}
@Bean(name = "kafkaListenerContainerFactory")
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
log.info("KafkaListenerContainerFactory 已启用");
KafkaProperties.ConsumerConfig c = kafkaProperties.getConsumer();
ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
int concurrency = Math.max(1, c.getConcurrency());
factory.setConcurrency(concurrency);
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
return factory;
}
}消费者监听
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;
/**
* Kafka 消费者监听
*
* @author: Felix(蔡济阳)
* @since : 2025/6/30 11:10
*/
@Component
@Slf4j
public class KafkaConsumerListener {
// 后续如果需要动态使用配置(如日志中打印配置、动态 topic 检查等)时可用,也可以后续用于 traceId、配置动态切换等扩展。
private final KafkaProperties kafkaProperties;
public KafkaConsumerListener(KafkaProperties kafkaProperties) {
this.kafkaProperties = kafkaProperties;
log.info("Listener KafkaConsumerListener 开始监听 topic={}, groupId={}",
kafkaProperties.getConsumer().getTopic(),
kafkaProperties.getConsumer().getGroupId());
}
@KafkaListener(
topics = "#{kafkaProperties.consumer.topic}",
groupId = "#{kafkaProperties.consumer.groupId}",
containerFactory = "kafkaListenerContainerFactory")
public void consume(ConsumerRecord<String, String> record, Acknowledgment ack) {
log.info("[Kafka-RECEIVE] topic={}, key={}, value={}", record.topic(), record.key(), record.value());
try {
// TODO: 替换为业务处理逻辑
log.debug("[Kafka-BUSINESS] handle key={}, value={}", record.key(), record.value());
ack.acknowledge();
} catch (Exception e) {
log.error("[Kafka-ERROR] handle failed. topic={}, key={}, error={}", record.topic(), record.key(), e.getMessage(), e);
}
}
}🚧 RabbitMQ
整合日志 Logback (Slf4j)
依赖
如果使用的是 Spring Boot 的 starter 依赖(如 spring-boot-starter-web),Logback 和 Slf4j 已自动集成,无需手动添加:
<!-- 使用 spring-boot-starter-web 已自动引入 logback-classic 和 slf4j-api -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>若没有,手动添加吧。
<!-- Spring: 日志服务 slf4j + logback -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>接下来就可以直接用了。
但是一般会自定义日志框架。
在 application.yml 同级创建文件 log-back-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 日志存放路径 -->
<property name="LOG_PATH" value="${LOG_PATH:-./logs}" />
<!-- 日志输出格式 -->
<property name="LOG.PATTERN" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
<property name="APP_NAME" value="${spring.application.name:-app}" />
<property name="LOG_PATH" value="${LOG_PATH:-${logging.file.path:-./logs}}" />
<property name="MAX_HISTORY" value="30" />
<property name="MAX_FILE_SIZE" value="10MB" />
<!-- 控制台输出 -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG.PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 滚动日志文件输出 -->
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/sys-info.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${LOG_PATH}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG.PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/sys-error.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${LOG_PATH}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG.PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>ERROR</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- 系统模块日志级别控制 -->
<logger name="com.felixcjy" level="info" />
<!-- Spring日志级别控制 -->
<logger name="org.springframework" level="warn" />
<root level="info">
<appender-ref ref="console" />
</root>
<!--系统操作日志-->
<root level="info">
<appender-ref ref="file_info" />
<appender-ref ref="file_error" />
</root>
</configuration>在 application.yml 中设置日志级别
logging:
file:
path: ./logs # 可设置为绝对路径
level:
root: INFO
com.felixcjy: DEBUG
org.springframework: WARN
org.springframework.web: DEBUG
org.springframework.transaction.interceptor: DEBUG
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG常见问题
代码规范
Java 常量声明规范顺序
推荐顺序如下:
private static final long MAX_FILE_SIZE = 2 * 1024 * 1024;历史原因:早期Java代码习惯把 static 放在前面(可能与C/C++风格有关),但后来逐渐演变为 static final。
一致性:final 修饰符在变量声明中通常更靠近类型(如 final int x;),而 static 是类级别的修饰符,放在 final 前面更符合逻辑分组。
Controller 参数 MultipartFile,到底能接收多少个文件?
一个,多个用 List
@Slf4j 注解
使用 @Slf4j 需要在项目中引入 Lombok 依赖,并且确保你的 IDE 支持 Lombok 插件。可简化开发过程。
相当于此代码:
private static final Logger log = LoggerFactory.getLogger(MyClass.class);有图片传递的接口,怎么设计?
场景挺常见的:一个表,包含普通字段和图片,怎么设计呢?
- 表单提交的接口,此接口拆分为两个接口
- 图片上传接口,纯图片上传而已。
- 表单字段新增接口,此接口只包含图片的标识。
- 表单查询接口,这里有两个区别点。
- 如果数据量较小,则一个接口全部返回接口
- 如果数据量较大,最好是拆分成两个接口,一个返回数据,一个返回图片。
🚧 Spring Boot 打包
Controller 参数与校验
有以下几种方案
- 简单校验
- 分组校验
- 自定义注解
- 使用不同的 DTO 类
- 逻辑程序校验
简单校验
简单参数校验需要在类上加注解 @Validated
@Data
public class ItemDTO {
@NotBlank(message = "ID 不能为空")
private String id;
@NotNull(message = "数量不能为空")
private Integer num;
}
@PostMapping("/submit")
public Result<?> submit(
@RequestBody
@NotEmpty(message = "列表不能为空")
List<@Valid ItemDTO> itemList
) {
return Result.success();
}
@PostMapping("/submit")
public Result<?> submit(@RequestBody @Valid ItemDTO dto) {
return Result.success();
}分组校验
此方法一定要注意 2 点
- 在 pom.xml 文件中,一定要指定版本号。
- 注意在 Controller 中使用的注解以及所在的包位置。
指定的版本号
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>2.7.18</version>
</dependency>注解
@PostMapping("/addIcon")
public Result<Object> addIcon(@Validated(GropuA.class) @RequestBody tempDTO dto) {
//xxx
}注解所在包
import org.springframework.validation.annotation.Validated;如果个方法都用同一个 DTO,但是规则不一样怎么办?答案是,用 Group
// 校验分组接口
public interface CreateGroup {}
public interface UpdateGroup {}
@Data
public class UserDTO {
@NotNull(message = "ID不能为空", groups = UpdateGroup.class)
private Long id;
@NotBlank(message = "用户名不能为空", groups = {CreateGroup.class, UpdateGroup.class})
private String username;
@NotBlank(message = "密码不能为空", groups = CreateGroup.class)
private String password;
}Controller 调用
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public ResponseEntity<String> createUser(@RequestBody @Validated(CreateGroup.class) UserDTO userDTO) {
// 创建逻辑
return ResponseEntity.ok("User created");
}
@PutMapping
public ResponseEntity<String> updateUser(@RequestBody @Validated(UpdateGroup.class) UserDTO userDTO) {
// 更新逻辑
return ResponseEntity.ok("User updated");
}
}当前,分组校验也是可以传递的。
例如,参数接收的是对象的时候,校验是可以
校验传递
如果校验参数是 RequestBody,而且有属性是对象的,那么就需要进行校验传递了,保证对象属性也会被校验,见例子:
@PatchMapping("/updateManufacturer")
@Operation(summary = "修改厂商")
public Result<Object> updateManufacturer(@Validated({UpdateValidation.class}) @RequestBody ManufacturerManagementDTO manufacturerManagementDTO) {
try {
manufacturerManagementService.updateManufacturer(manufacturerManagementDTO);
return Result.success();
} catch (Exception e) {
log.error("修改厂商:失败:{}", e.getMessage(), e);
return Result.failure("修改厂商:失败:" + e.getMessage());
}
}请注意,这里的注解是 @Valid
@Data
public class ManufacturerManagementDTO {
@Schema(description = "唯一ID")
@NotBlank(message = "唯一ID不能为空!", groups = {ManufacturerManagementControllerUpdateValidation.class})
private String id;
@Schema(description = "厂商名称")
@NotBlank(groups = {ManufacturerManagementControllerAddValidation.class, ManufacturerManagementControllerUpdateValidation.class}, message = "厂商名称不能为空!")
private String manufacturerName;
@Valid
@Schema(description = "处理负责人列表")
private List<ProcessorDTO> processorDTOList;
}@Data
public class ProcessorDTO {
@Schema(description = "处理负责人-用户帐号")
@NotBlank(groups = {AddValidation.class, UpdateValidation.class}, message = "处理负责人-用户帐号不能为空!")
private String userAccount;
}日期校验
分情况而定,请见如下接口代码。
@GetMapping("/getIconListByPage")
@Operation(summary = "分页查询图标列表", description = "单接口,返回所有内容,图片为 base64 格式")
public Result<Object> getIconListByPage(@RequestParam(defaultValue = "1")
@Parameter(description = "分页页码", example = "1")
int pageNum,
@RequestParam(defaultValue = "10")
@Parameter(description = "分页大小", example = "10")
int pageSize,
@RequestParam(required = false)
@Parameter(description = "图标名称")
String iconName,
@RequestParam(required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Parameter(description = "创建时间:开始时间", example = "2025-05-07 11:22:33")
LocalDateTime startTime,
@RequestParam(required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Parameter(description = "创建时间:结束时间", example = "2025-05-08 11:22:33")
LocalDateTime endTime) {
try {
} catch (Exception e) {
log.error("分页查询图标列表:失败:{}", e.getMessage(), e);
return Result.failure("分页查询图标列表:失败");
}
}@Data
public class A {
@NotNull(message = "结束时间不能为空", groups = {V1.class})
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") // 控制反序列化格式
private LocalDateTime endTime;
}数组校验
Java 后端代码分两种情况:
@DeleteMapping("/api/items")
public ResponseEntity<?> deleteItems(@RequestParam List<String> ids) {
// 批量删除逻辑
return ResponseEntity.ok("Deleted items: " + ids);
}@Data
public class IdsDTO {
@NotEmpty(message = "ID 列表不能为空")
@Valid // 激活每个元素的校验(如果元素是复杂对象)
private List<@NotNull(message = "ID 不能为空") Long> ids;
}调用方式:
GET /example?items=item1&items=item2&items=item3前端代码:
axios.delete('/api/items', {
params: { ids: ['1', '2', '3'] } // 自动转为 ids=1&ids=2&ids=3
})
.then(response => console.log(response.data));const params = new URLSearchParams();
params.append('ids', '1');
params.append('ids', '2');
params.append('ids', '3');
fetch(`/api/items?${params.toString()}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => console.log(data));上传文件校验
上传图片校验
@PostMapping("/uploadIconImage")
@Operation(summary = "上传图标图片", description = "上传后将返回该图标的图片唯一标识!将在新增表单中提交")
public ResponseEntity<Result<?>> uploadIconImage(@RequestParam("imageFile")
MultipartFile file) {
if (file.isEmpty()) {
log.error("上传图标图片:失败:图标文件为空!");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.failure("上传图标图片:失败:图标文件为空!"));
}
if (file.getSize() > MAX_FILE_SIZE * operationPlatformProperties.getIconFileSize()) {
log.error("上传图标图片:失败:文件大小不能超过 {} MB", operationPlatformProperties.getIconFileSize());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.failure("上传图标图片:失败:文件大小不能超过 " + operationPlatformProperties.getIconFileSize() + " MB"));
}
String contentType = file.getContentType();
boolean isValidType = false;
for (String allowedType : ALLOWED_CONTENT_TYPES) {
if (allowedType.equals(contentType)) {
isValidType = true;
break;
}
}
if (!isValidType) {
log.error("上传图标图片:失败:仅支持JPG/JPEG/PNG格式图片!");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.failure("上传图标图片:失败:仅支持JPG/JPEG/PNG格式图片!"));
}
return ResponseEntity.ok(Result.success(Xxx.uploadIconImage(file)));
}上传文件校验
@PostMapping("/uploadRequiredMaterials")
@Operation(summary = "所需材料:文件上传", description = "上传后将返回该文件的唯一标识!请务必保存。")
public ResponseEntity<Result<?>> uploadRequiredMaterials(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
log.error("所需材料:文件上传:文件为空!");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.failure("所需材料:文件上传:文件为空!"));
}
if (file.getSize() > MAX_FILE_SIZE * operationPlatformProperties.getRequiredMaterialsSize()) {
log.error("所需材料:文件上传:文件大小不能超过 {} MB", operationPlatformProperties.getRequiredMaterialsSize());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.failure("所需材料:文件上传:文件大小不能超过 " + operationPlatformProperties.getRequiredMaterialsSize() + " MB"));
}
String originalFilename = file.getOriginalFilename();
if (FilenameUtils.getExtension(originalFilename) == null) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.failure("所需材料:文件上传:文件名为空,无法获取扩展名"));
}
String fileExtension = FilenameUtils.getExtension(originalFilename).toLowerCase();
List<String> allowedExtensions = Arrays.asList("doc", "docx", "xls", "xlsx", "ppt", "pptx", "pdf", "txt", "jpg", "jpeg", "png");
if (!allowedExtensions.contains(fileExtension)) {
log.error("所需材料:文件上传:不支持的文件类型 {}", fileExtension);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.failure("所需材料:文件上传:仅支持 doc/docx/xls/xlsx/ppt/pptx/pdf/txt/jpg/jpeg/png 格式!"));
}
return ResponseEntity.ok(Result.success(Xxxservice.uploadRequiredMaterials(file)));
}🚧 自定义注解校验
关于 Minio 中的文件夹
在 MinIO 中,前缀(Prefix)确实被用作模拟文件夹结构的方式,但底层存储上并没有真正的“文件夹”概念。
MinIO 的底层存储(基于对象存储协议如 S3)是扁平化的,所有对象都直接存储在同一个“桶”(Bucket)中,没有真正的目录层级。前缀只是对象键的一部分,用于逻辑分组。文件名就是用来唯一标识文件的!
/files1/hi.png
/files1/hi-1.png
/files2/hi-2.png事务:自调用事务失效问题
Spring Boot 中,当类中某个方法调用了当前类中的另一个方法,会导致事务失效。
原因:
Spring 的事务是基于 AOP 代理机制实现的:
- Spring 在运行时为加了 @Transactional 的类生成一个代理类(可能是基于 JDK 或 CGLIB 的代理)。
- 当你通过 Spring 容器外部调用这个代理对象的方法时,Spring 会拦截这个调用并开启事务。
- 但自调用(this.methodB())绕过了代理对象,直接调用了原始对象方法,所以 Spring 根本感知不到这次调用,也就不会开启事务。
前端拖拽排序,后端怎么写接口?
接受一个对象 List,对象包含 id 和 orderNum 即可。
结构化数据与半结构化/动态数据的混合管理问题
其实就是包含固定字段 + 动态字段这样的数据该怎么办的问题。
- JSON 字段存储
- EAV 模型
说白了就是 JSON 存储方式和 数据库表的存储方式,如果不需要复杂的查询的话推荐用 JSON。
Spring Boot 2.x 迁移到 Spring Boot 3.x
参考官方文档:https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide
内存分页
直接看代码:
/**
* 分页工具
*
* @author: Felix(蔡济阳)
* @since : 2025/5/30 17:21
*/
public class PageUtil {
/**
* 对列表进行分页
*
* @param list 原始列表
* @param pageNo 页码(从 1 开始)
* @param pageSize 每页大小
* @param <T> 泛型
* @return 分页后的子列表
*/
public static <T> List<T> paginate(List<T> list, int pageNo, int pageSize) {
if (list == null || list.isEmpty()) {
return Collections.emptyList();
}
int total = list.size();
int fromIndex = Math.max(0, (pageNo - 1) * pageSize);
int toIndex = Math.min(fromIndex + pageSize, total);
if (fromIndex >= total) {
return Collections.emptyList(); // 页码超出范围
}
return list.subList(fromIndex, toIndex);
}
/**
* 对 List 进行内存分页,返回 MyBatis-Plus 的 IPage 格式
*
* @param fullList 全量数据列表
* @param pageNum 页码(从 1 开始)
* @param pageSize 每页大小
* @param <T> 列表元素类型
* @return 分页后的 IPage 对象
*/
public static <T> IPage<T> paginateListToIPage(List<T> fullList, long pageNum, long pageSize) {
if (fullList == null || fullList.isEmpty()) {
return new Page<>(pageNum, pageSize, 0);
}
int total = fullList.size();
int fromIndex = (int) ((pageNum - 1) * pageSize);
int toIndex = (int) Math.min(fromIndex + pageSize, total);
// 处理越界情况
if (fromIndex >= total || fromIndex < 0) {
return new Page<>(pageNum, pageSize, total);
}
List<T> pageRecords = fullList.subList(fromIndex, toIndex);
Page<T> page = new Page<>(pageNum, pageSize, total);
page.setRecords(pageRecords);
return page;
}
}版权所有
版权归属:FelixJY
