编辑
2025-09-02
中间件-Nginx
00

在 Nginx 中,可以使用 限流(Rate Limiting)功能来控制进入服务器的请求数量。Nginx 提供了两个主要的限流模块:

limit_req模块:限制请求的速率(单位时间内请求的次数)。

limit_conn 模块:限制并发连接数(每个 IP 地址允许的最大并发连接数)。

这两个模块可以组合使用,用于不同的限流需求,比如限制单位时间内的请求数或并发连接数。

limit_req 模块:限制请求速率

limit_req 模块可以限制特定 IP 地址的请求频率。

例如,限制每秒钟一个 IP 地址只能发出一定数量的请求。

配置示例:

http { # 定义一个限流区域 limit_req_zone$binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s; server { listen80; server_name example.com; location / { # 使用上面定义的限流区域,限制请求频率 limit_req zone=req_limit_per_ip burst=20 nodelay; # 处理正常请求 root /usr/share/nginx/html; index index.html; } } }

解释:limit_req_zone定义一个名为 req_limit_per_ip 的限流区域。

$binary_remote_addr 是客户端的 IP 地址,以二进制格式存储,可以减少内存消耗。

rate=10r/s 限制每秒最多 10 次请求。

zone=req_limit_per_ip:10m 表示存储在内存中,并且该区域的大小是 10MB,可以存储多个 IP 地址的限流信息。

limit_req在具体的 location 配置中启用限流,使用定义好的 req_limit_per_ip 区域。

burst=20 允许一定的请求突发,即在瞬间请求量超过正常限速时,最多可以允许 20 个请求超出限速。

nodelay 表示一旦超出请求限制,就立即拒绝请求,而不是延迟处理。

limit_conn 模块:限制连接数limit_conn 模块限制每个客户端(每个客户端都有 IP 地址)的并发连接数。这对于防止单个客户端占用过多服务器资源非常有效。

配置示例:

http { # 定义每个客户端(IP 地址)的最大连接数为 1 limit_conn_zone$binary_remote_addr zone=conn_limit_per_ip:10m; server { listen80; server_name example.com; location / { # 限制每个 IP 的最大连接数 limit_conn conn_limit_per_ip 1; # 处理正常请求 root /usr/share/nginx/html; index index.html; } } }

解释:

limit_conn_zone定义了一个名为 conn_limit_per_ip 的区域,用于存储每个 IP 地址的连接信息。

zone=conn_limit_per_ip:10m 指定了 10MB 的存储空间。

limit_conn在 location 配置中启用连接限制,限制每个 IP 地址只能有一个并发连接。

组合使用 limit_req 和 limit_conn我们可以同时使用 limit_req 和 limit_conn 来限制请求速率和并发连接数,从而实现更严格的流量控制。

配置示例:

http { # 定义请求频率限制区域 limit_req_zone$binary_remote_addr zone=req_limit_per_ip:10m rate=5r/s; # 定义并发连接数限制区域 limit_conn_zone$binary_remote_addr zone=conn_limit_per_ip:10m; server { listen80; server_name example.com; location / { # 限制请求速率,每秒最多 5 次请求 limit_req zone=req_limit_per_ip burst=10 nodelay; # 限制并发连接数,每个 IP 最多允许 1 个连接 limit_conn conn_limit_per_ip 1; # 处理正常请求 root /usr/share/nginx/html; index index.html; } } }

解释:

limit_req_zone限制每个 IP 地址的请求频率,每秒最多 5 次请求。

burst=10 允许 10 次请求的突发。

limit_conn_zone限制每个 IP 地址的最大并发连接数为 1。

其他常见配置选项burst表示允许的请求突发数量。例如,burst=10 允许在短时间内超过规定的请求速率(如每秒 10 次请求),但是请求数不会超过突发限制。nodelay表示一旦请求超过了速率限制,则立即返回 503 错误,而不是延迟处理。限流时返回的状态码当请求超过了限流限制,Nginx 会返回 503 Service Unavailable 错误。我们可以通过以下配置定制返回的错误页面:

http { limit_req_zone$binary_remote_addr zone=req_limit_per_ip:10m rate=5r/s; server { listen80; server_name example.com; location / { limit_req zone=req_limit_per_ip burst=10 nodelay; # 设置请求超限时返回的页面 error_page503 /custom_503.html; root /usr/share/nginx/html; index index.html; } } }

在这个配置中,当请求超过限制时,Nginx 会返回自定义的 503 页面 /custom_503.html。如何查看访问日志中的 503 错误   

通过以下命令行查看:搜索日志中 503 字符 

grep " 503 " /var/log/nginx/access.log
编辑
2025-08-29
JAVA-Springboot
00

前言

假设你的系统里有100万个用户,然后你要轮询重试的获取每个用户的身份信息, 如果你还在使用SpringRetry和GuavaRetry 之类的这种单任务的同步重试框架,那你可能到猴年马月也处理不完, 即使加再多的机器和线程也是杯水车薪, 而Fast-Retry正是为这种场景而生

Fast-Retry

一个高性能的多任务重试框架,支持百万级任务的异步重试、以及支持编程式和注解声明式等多种使用方式、 也支持自定义结果重试逻辑。

What is this?

与主流的Spring-Retry, Guava-Retry等单任务同步重试框架不同,Fast-Retry是一个支持异步重试框架,支持异步任务的重试、超时等待、回调。

Spring-Retry, Guava-Retry均无法支持大批量任务的重试,即使加入线程池也无法解决,因为实际每个重试任务都是单独的同步逻辑,然后会会占用过多线程资源导致大量任务在等待处理,随着任务数的增加,系统吞吐量大大降低,性能指数级降低,而Fast-Retry在异步重试下的性能是前者的指数倍。

下图是三者的性能对比

测试线程池: 8个固定线程

单个任务逻辑: 轮询5次,隔2秒重试一次,总耗时10秒

未测预计公式: 当我们使用线程池的时候, 一般线程池中 总任务处理耗时 = 任务数/并发度 x 单个任务重试耗时

d2a628826101e393776e7d6eaecc98ec.png

a92cd7edf360611d03817b71cfd3c2c7.jpg

可以看到即使是处理100万个任务,Fast-Retry的性能也比Spring-Retry和Guava-Retry处理在50个任务时的性能还要快的多的多属实降维打击,这么快的秘密在于除了是异步,重要的是当别人在重试间隔里休息的时候,Fast-Retry还在不停忙命的工作着。

即使抛开性能不谈, SpringRetry使用繁琐,不支持根据结果的进行重试,GuavaRetry虽然支持,但是又没有提供注解声明式的使用。

快速开始

引入依赖

<dependency> <groupId>io.github.burukeyou</groupId> <artifactId>fast-retry-all</artifactId> <version>0.2.0</version> </dependency>

有以下三种方式去构建我们的重试任务

使用重试队列

RetryTask就是可以配置我们重试任务的一些逻辑,比如怎么重试,怎么获取重试结果,隔多久后重试,在什么情况下重试。它可以帮助我们更加自由的去构建重试任务的逻辑。但如果只是简单使用,强烈建议使用FastRetryBuilder 或者 @FastRetry注解

RetryQueue就是一个执行和调度我们重试任务的核心角色,其在使用上与线程池的API方法基本一致

ExecutorService executorService = Executors.newFixedThreadPool(8); RetryQueue queue = new FastRetryQueue(executorService); RetryTask<String> task = new RetryTask<String>() { int result = 0 ; // 下一次重试的间隔 @Override public long waitRetryTime() { return2000; } // 执行重试,每次重试回调此方法 @Override public boolean retry() { return ++result < 5; } // 获取重试结果 @Override public String getResult() { return  result + ""; } }; CompletableFuture<String> future = queue.submit(task); log.info("任务结束 结果:{}",future.get());

使用FastRetryBuilder

底层还是使用的RetryQueue去处理, 只是帮我们简化了构建RetryTask的逻辑

RetryResultPolicy<String> resultPolicy = result -> result.equals("444"); FastRetryer<String> retryer = FastRetryBuilder.<String>builder() .attemptMaxTimes(3) .waitRetryTime(3, TimeUnit.SECONDS) .retryIfException(true) .retryIfExceptionOfType(TimeoutException.class) .exceptionRecover(true) .resultPolicy(resultPolicy) .build(); CompletableFuture<String> future = retryer.submit(() -> { log.info("重试"); //throw new Exception("test"); //int i = 1/0; if (0 < 10){ thrownew TimeoutException("test"); } return"444"; }); String o = future.get(); log.info("结果{}", o);

使用@FastRetry注解

底层还是使用的RetryQueue去处理, 只是帮我们简化了构建RetryTask的逻辑,并且与Spring进行整合能对Spring的bean标记了FastRetry注解的方法进行代理, 提供了重试任务注解声明式的使用方式

  • 依赖Spring环境,所以需要在Spring配置类加上@EnableFastRetry注解启用配置 , 这个@FastRetry注解的使用才会生效

  • 如果将结果类型使用CompletableFuture包装,自动进行异步轮询返回,否则同步阻塞等待重试结果。(推荐)

下面定义等价于 RetryQueue.execute方法

// 如果发生异常,每隔两秒重试一次 @FastRetry(retryWait = @RetryWait(delay = 2)) public String retryTask(){ return "success"; }

下面定义等价于 RetryQueue.submit方法,支持异步轮询

@FastRetry(retryWait = @RetryWait(delay = 2)) public CompletableFuture<String> retryTask(){ return CompletableFuture.completedFuture("success"); }

自定义重试注解

如果不喜欢或者需要更加通用化的贴近业务的重试注解,提供一些默认的参数和处理逻辑,可以自行定义一个重试注解并标记上 @FastRetry 并指定factory,然后实现 AnnotationRetryTaskFactory 接口实现自己的构建重试任务的逻辑即可。@FastRetry默认实现就是:FastRetryAnnotationRetryTaskFactory

使用建议

无论是使用以上哪种方式去构建你的重试任务,都建议使用异步重试的方法,即返回结果是CompletableFuture的方法, 然后使用CompletableFuturewhenComplete方法去等待异步重试任务的执行结果。

原文: https://mp.weixin.qq.com/s/vCOalIbb1rqAIonl9kx31A

编辑
2025-08-20
JAVA-Springboot
00

SpringDoc详解

告别Swagger UI!一款更适合 SpringBoot 的API文档新选择

SpringDoc是什么

SpringDoc 是一个专为 Spring Boot 应用设计的库,能够自动生成符合 OpenAPI 3 规范的 API 文档。它通过扫描项目中的控制器、方法注解及配置,动态生成 JSON/YAML/HTML 格式的文档,并提供交互式界面(如 Swagger UI)供开发者查看和测试 API

与Swagger的关系

Swagger 作为 OpenAPI 规范的前身,贡献了 API 设计理念并推动了 OpenAPI 的标准化。其核心工具 Swagger UI 用于展示交互式文档。

SpringDoc 并非 Swagger 的替代品,而是基于 OpenAPI 3 规范的实现工具,并天然集成 Swagger UI 作为文档展示界面

为什么要选择SpringDoc

在SpringDoc面世之前,Spring生态中集成实现Swagger的技术为SpringFox,SpringFox与Swagger之间的协作关系如下

SpringFox

• 代码扫描:SpringFox 在运行时扫描 Spring MVC 控制器(如 @RestController)、方法注解(如 @RequestMapping)以及 Swagger 专用注解(如 @ApiOperation),提取接口的路径、参数、响应等信息

• 生成 OpenAPI 规范文档: 将扫描结果转换为符合 Swagger 2.0 或 OpenAPI 3.0 规范的 JSON 文件

• 集成 Spring 生态,提供 Docket 配置类,支持自定义接口扫描范围(如包路径、URL 匹配规则)和文档信息(如标题、版本、作者)

Swagger

• 可视化文档渲染:将 SpringFox 生成的 JSON 文件解析为交互式网页,通过浏览器访问(如 http://localhost:8080/swagger-ui.html)

• 提供接口列表、参数说明、请求示例,并支持在线测试 API(可直接发送请求并查看响应)

• 标准化规范支持:Swagger 定义 OpenAPI 规范(原 Swagger 规范),为 API 描述提供统一标准(如接口路径、请求方法、数据类型),SpringFox 生成的 JSON 文件完全遵循此规范,确保与其他 Swagger 工具(如 Swagger Editor)兼容

协作流程

• 开发阶段:开发者在 Spring 控制器中添加 Swagger 注解(如 @Api、@ApiParam),描述接口细节

• 运行时:SpringFox 扫描代码并生成 JSON 文档

• 展示阶段:Swagger UI 读取 JSON 文件,渲染为可视化界面供团队使用

在2020时,由于SpringFox官方基本停止维护,不再发布新版本或修复问题,再加上他无法适配 Spring Boot 2.6+ 及 3.x 版本,导致与新版本 Spring 生态冲突(如路径匹配失效、注解不兼容)。以及配置的复杂性导致他逐渐退出市场

转而由更新的技术–SpringDoc接过接力棒,SpringDoc完美支持 Spring Boot 2.6+ 及 3.x(含 JDK 17+),并且原生支持OpenAPI 3 规范,除此之外,如果不需要特殊的复杂配置,甚至可以零配置,仅需引入一个依赖,即可实现开箱即用,还有他直接使用 JSR-303 规范注解(如 @Schema、@Parameter),替代 SpringFox 的专属注解(如 @ApiModel),降低了开发人员的学习成本

正式发布

最小化配置使用

不多说废话了,下面我们正式开始,首先我们先介绍最简单,最小化的引入以及使用方式

第一步:引入Jar包

<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.5.0</version> <!-- 建议使用最新版本 --> </dependency>

第二步:配置配置文件

正常使用SpringDoc,或多或少都会进行一些配置文件的配置,但是由于这里是进行最小化配置,所以这里不进行配置文件配置,仅仅介绍几个重要配置的默认项,给大家一个基础印象,方便大家理解后面运行时为什么要这样做,当然,如果不感兴趣的小伙伴也可以直接跳过,跟着步骤走,并不影响使用

# application.yml springdoc: # SpringDoc的API包扫描路径,如果不配置,SpringDoc 自动扫描整个项目类路径,它会自动识别所有 @RestController、@RequestMapping 等注解,生成 API 文档 packages-to-scan:com.example.controller swagger-ui: # 是否开启swagger界面,依赖OpenApi,默认为true,如果要开启需要OpenApi同时开启 enabled:true # 内置 Swagger UI 的访问路径,默认值为/swagger-ui/index.html path:/swagger-ui/index.html # 指定OpenAPI文档的URL(注意这里一定要与api-docs.path保持一致,否则会请求失败) url:/v3/api-docs # 是否禁用Swagger UI自带的示例接口(如 Petstore 等默认接口),默认值为false,仅展示当前项目的 API disable-swagger-default-url:false api-docs: # 是否启用OpenAPI文档端点,默认为true enabled:true # OpenAPI 3规范的文档访问路径,默认值为/v3/api-docs path: /api-docs

第三步:添加一个配置类,用于设置Swagger-UI页面的一些基础信息的展示

@Configuration @OpenAPIDefinition(info = @Info( title = "项目API文档", version = "1.0", description = "SpringBoot项目接口文档" )) public class SpringDocConfig { // 无需额外配置,注解已定义基本信息 }

到这一步:其实已经可以访问页面,观看效果了(访问链接为:http://localhost:8080/swagger-ui/index.html, 注意如果上方配置文件修改了,这里要替换为对应的链接,我这里没有修改所以使用默认链接),只是项目如果没有任何controller,这里会展示空页面,如下:

6c3223fad0916a13020ffa379880c423.png

第四步:在需要显示的Controller方法上加上注解,用于给方法添加备注,如下会展示不加注解与加注解的区别

不加注解:

@RestController @RequestMapping("/main") public class MainController { @GetMapping("/index") public String index(String str1) { return "请求成功"; } }

2e51100eb4b2fa87072fb7cef4bc8f5e.png

添加注解

@RestController @RequestMapping("/main") @Tag(name = "演示controller", description = "演示controller") public class MainController { @GetMapping("/index") @Operation(summary = "演示方法", description = "演示方法的注释") public String index( @Parameter(description = "参数1", required = true) String str1) { return "请求成功"; } }

4e95dc7e641ea621ae1bac3e0e14394c.png

至此为止,SpringDoc的最小化使用已经全部完成(请注意,以上所有配置生效的前提是,当前Spring项目未添加任何过滤器、拦截器,以及未使用SpringSecurity等安全框架,否则,仅仅进行最小化配置是无法运行的,因为一些默认配置可能会被拦截,如果需要更复杂配置,请继续往下看)

SpringDoc中简单分组配置(包含编程式配置与声明式配置)

上方仅仅只是展示了SpringDoc的最基础用法,接下来,我们展示SpringDoc的一种常用用法:分组,先上效果图,让大家了解是个什么功能

a5f69fabcf27ab6c0342bb1783c76919.png

接下来开始进行详细配置:

方式一:编程式配置(灵活性高、扩展性强、调试友好)

@Configuration @OpenAPIDefinition(info = @Info( title = "项目API文档", version = "1.0", description = "SpringBoot项目接口文档" )) publicclassSpringDocConfig { /** * 商品分组的配置(使用请求路径扫描的方式进行配置) * @return org.springdoc.core.models.GroupedOpenApi * @author ren * @date 2025/07/06 17:17 */ @Bean public GroupedOpenApi productGroup() { // 使用路径匹配方式:仅包含 /api/product/** 下的接口 return GroupedOpenApi.builder() .group("商品模块") .pathsToMatch("/api/product/**") // 路径匹配 .build(); } /** * 会员分组的配置(使用包扫描的方式进行配置) * @return org.springdoc.core.models.GroupedOpenApi * @author ren * @date 2025/07/06 17:17 */ @Bean public GroupedOpenApi userGroup() { // 使用包扫描方式:扫描 com.ren.main.controller.member 包下的所有接口 return GroupedOpenApi.builder() .group("用户模块") .packagesToScan("com.ren.main.controller.member") // 包扫描 .build(); } }

这种配置方式的原理是通过添加GroupedOpenApi类型的Bean,项目启动时,SpringDoc会寻找环境中是否存在GroupedOpenApi类型的Bean,如果存在,则会创建分组进行展示

注意:

• 一旦这里配置了分组方式展示,那么在application.yml配置文件中配置的springdoc.packages-to-scan就会失效,因为SpringDoc的扫描机制,分组配置的扫描路径优先级大于配置文件配置的扫描优先级 • 路径扫描有两种方式,一种是根据请求路径进行扫描,一种是根据包路径进行扫描,上方都有进行配置 • 如果多个分组中有重合的路径,也就是说一个接口在多个分组配置的路径中都能扫描到,那么这个接口会存在于多个分组中 以下展示我的代码结构,方便大家理解:

1a827c9129a9c30b10918f790344dd05.png

ebfccd8be4e5be05861802a52a0a7f71.png

fc173b0944558c9638341e6eed9058b9.png

7db6db0dd7909e448948cfdf6ae74c5a.png

大家会发现,我的目录中有一个MainController的内容,在页面中没有展示了,这是为什么呢?原因我上面提过了,因为分组的配置会覆盖默认配置与配置文件配置,而分组配置中由于没有包含MainController的内容,所以,MainController的内容没有地方展示了

那么如果想要展示出这个文件中的接口该怎么办呢?很简单,在分组中再加一个默认分组,用于展示所有接口内容即可,如下

@Configuration @OpenAPIDefinition(info = @Info( title = "项目API文档", version = "1.0", description = "SpringBoot项目接口文档" )) publicclassSpringDocConfig { /** * 默认分组 * @return org.springdoc.core.models.GroupedOpenApi * @author ren * @date 2025/07/06 17:38 */ @Bean public GroupedOpenApi defaultGroup() { return GroupedOpenApi.builder() .group("默认分组") .pathsToMatch("/**") // 路径匹配 .build(); } /** * 商品分组的配置(使用请求路径扫描的方式进行配置) * @return org.springdoc.core.models.GroupedOpenApi * @author ren * @date 2025/07/06 17:17 */ @Bean public GroupedOpenApi productGroup() { // 使用路径匹配方式:仅包含 /api/product/** 下的接口 return GroupedOpenApi.builder() .group("商品模块") .pathsToMatch("/api/product/**") // 路径匹配 .build(); } /** * 会员分组的配置(使用包扫描的方式进行配置) * @return org.springdoc.core.models.GroupedOpenApi * @author ren * @date 2025/07/06 17:17 */ @Bean public GroupedOpenApi userGroup() { // 使用包扫描方式:扫描 com.ren.main.controller.member 包下的所有接口 return GroupedOpenApi.builder() .group("用户模块") .packagesToScan("com.ren.main.controller.member") // 包扫描 .build(); } }

配置后展示的内容

9d5aa8e6232baf4d3643bfee9889a21c.png

看上面的图,MainController的内容展示在这里了,同时ProductController和MemberController的内容也展示在这里了,这是为什么呢,原因我上面说过了,如果一个请求被多个分组扫描到,那么他会展示在多个分组中

方式二:声明式配置(配置集中管理、可使用多配置文件进行环境隔离)

springdoc: group-configs: -group:'默认分组' paths-to-match:'/**' -group:'商品模块' paths-to-match:'/api/product/**' -group:'用户模块' packages-to-scan: 'com.ren.main.controller.member'

9cc97d396831e88f6da74c84b8a73610.png

如上:效果与方式一相同

如果项目中重写了WebMvcConfigurer的addResourceHandlers方法,所需进行的处理

WebMvcConfigurer

WebMvcConfigurer 是 Spring MVC 的配置中枢,用于定制化 Spring MVC 的各种行为。它不是过滤器或拦截器,而是一个配置接口(类似汽车的仪表盘),让你调整 Spring MVC 的运行方式。

他所拥有的方法

• addInterceptors(registry):注册拦截器

• addCorsMappings(registry):配置跨域权限

• addResourceHandlers(registry):指定静态资源路径

• addViewControllers(registry):设置简易页面跳转

• configureMessageConverters(list):定制JSON/XML解析器

• configurePathMatch(configurer):调整URL匹配规则

• addArgumentResolvers(list):自定义请求参数处理器

• addReturnValueHandlers(list):自定义返回值处理器

• configureContentNegotiation(configurer):内容协商配置(响应格式协商)

如果我们重写了WebMvcConfigurer的addResourceHandlers方法,那么原本Spring自己默认配置的所有的静态资源的指向路径就全都会失效,于是就需要我们自己去配置指定

@Configuration publicclassResourcesConfigimplementsWebMvcConfigurer { @Override publicvoidaddResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/swagger-ui/**") .addResourceLocations("classpath:/META-INF/resources/webjars/springdoc-openapi-ui/") .setCacheControl(CacheControl.maxAge(5, TimeUnit.HOURS).cachePublic()); } }

按照如上设置后,SpringDoc将恢复正常(注意新版的SpringDoc和老版的SpringFox配置有所区别,这里只展示新版SpringDoc的配置方法)

SpringSecurity

如果项目引入了SpringSecurity需要进行的处理

由于项目引入了SpringSecurity,导致如果项目不经过认证无法访问系统资源,我们就需要在SpringSecurity的配置文件中放开SpringDoc相关的静态资源的拦截,如下

@Configuration @EnableWebSecurity @EnableMethodSecurity publicclassSecurityConfig { /* * 配置过滤器链 * @param http * @return org.springframework.security.web.SecurityFilterChain * @author ren * @date 2025/04/17 21:30 */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http)throws Exception { http.authorizeHttpRequests(auth -> auth // 允许 OPTIONS 方法通过 .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 静态资源,可匿名访问 .requestMatchers(request -> { Stringpath= request.getServletPath(); return (request.getMethod().equals("GET") && ( "/".equals(path) || path.endsWith(".html") || path.endsWith(".css") || path.endsWith(".js"))); }).permitAll() .requestMatchers("/swagger-ui/**", "/*/api-docs/**", "/swagger-resources/**", "/webjars/**", "/druid/**") .permitAll() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated() ); return http.build(); } }

添加如上配置后,即可放开SpringSecurity的认证限制

原文:https://mp.weixin.qq.com/s/PuFK34hBjog_ZycjeAfnIA

编辑
2025-08-20
JAVA-Springboot
00

DTO、DO、VO,你是怎么转换的?

DTO、DO、VO这些东西看着复杂,核心就俩字:省事。

先说说DTO、DO、VO到底解决啥问题

刚开始写代码,总是一个实体类用到底:查数据库用它,接口入参用它,返回给前端还⽤它。直到有一次,产品突然说要大改接口返回字段,改着改着发现不对劲——数据库实体(比如UserDO)里有密码、创建时间这些敏感字段,之前直接返回给前端了!更坑的是,因为实体类被Service、Controller、前端共用,改一个字段得全链路检查,差点没改崩。

DTO、DO、VO分层,本质是给不同层划清界限:

DO(Data Object):和数据库表一一对应,只在Dao层和Service层之间用,里面全是数据库字段(比如user_id、password、create_time)。

DTO(Data Transfer Object):前端传给后端的参数载体,比如用户登录时传的username和password,只包含接口需要的字段,多余的一概不要。

VO(View Object):后端返回给前端的结果,会根据前端需求“裁剪”DO里的字段,比如隐藏密码,只返回username、nickname这些前端需要展示的。

这么一分层,好处立马就显出来了。就像我之前遇到的产品大改:Service层逻辑全重写了,查的表都换了,但因为Controller层只认DTO和VO,我只要保证入参DTO和返回VO的格式不变,前端完全不用改,Swagger文档也没动——这就是解耦的威力。

MyBatis-Plus 代码生成器 + MapStruct + SpringDoc(OpenAPI/Swagger)

下面我将为你提供一个集成了 MyBatis-Plus 代码生成器、MapStruct 和 SpringDoc(OpenAPI/Swagger)的完整方案,使用模板配置方式实现。

1.首先添加必要的依赖(包含 SpringDoc)

<!-- MyBatis-Plus 相关依赖 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-generator</artifactId> <version>3.5.3.1</version> </dependency> <!-- 模板引擎 --> <dependency> <groupId>org.apache.velocity</groupId> <artifactId>velocity-engine-core</artifactId> <version>2.3</version> </dependency> <!-- MapStruct --> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>1.5.3.Final</version> </dependency> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>1.5.3.Final</version> <scope>provided</scope> </dependency> <!-- SpringDoc (OpenAPI/Swagger) --> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.1.0</version> </dependency> <!-- 数据库驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> <scope>runtime</scope> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.24</version> <optional>true</optional> </dependency>

2. SpringDoc 配置类

import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SpringDocConfig { @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .info(new Info() .title("API文档") .version("1.0.0") .description("系统API接口文档") .contact(new Contact().name("开发团队").email("dev@example.com")) .license(new License().name("Apache 2.0").url("https://www.apache.org/licenses/LICENSE-2.0"))); } }

3. 代码生成器配置(集成 SpringDoc 注解)

import com.baomidou.mybatisplus.generator.FastAutoGenerator; import com.baomidou.mybatisplus.generator.config.OutputFile; import com.baomidou.mybatisplus.generator.engine.VelocityTemplateEngine; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class CodeGenerator { // 数据库连接信息 private static final String URL = "jdbc:mysql://localhost:3306/your_database?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USERNAME = "root"; private static final String PASSWORD = "password"; // 父包名 private static final String PARENT_PACKAGE = "com.example.demo"; // 模块名 private static final String MODULE_NAME = "system"; // 作者 private static final String AUTHOR = "your name"; // 需要生成的表名 private static final List<String> TABLE_NAMES = Collections.singletonList("sys_user"); // XML文件输出路径 private static final String XML_OUTPUT_DIR = System.getProperty("user.dir") + "/src/main/resources/mapper/" + MODULE_NAME; public static void main(String[] args) { generateCode(); } public static void generateCode() { FastAutoGenerator.create(URL, USERNAME, PASSWORD) // 全局配置 .globalConfig(builder -> { builder.author(AUTHOR) // 作者 .outputDir(System.getProperty("user.dir") + "/src/main/java") // 输出路径 .commentDate("yyyy-MM-dd") // 注释日期 .disableOpenDir() // 生成后不打开文件夹 .fileOverride(); // 覆盖已生成文件 }) // 包配置 .packageConfig(builder -> { builder.parent(PARENT_PACKAGE) // 父包名 .moduleName(MODULE_NAME) // 模块名 .entity("entity") // 实体类包名 .service("service") // service包名 .serviceImpl("service.impl") // serviceImpl包名 .mapper("mapper") // mapper包名 .xml("mapper.xml") // mapper.xml包名 .controller("controller") // controller包名 .other("convert") // MapStruct转换器包名 .pathInfo(Collections.singletonMap(OutputFile.xml, XML_OUTPUT_DIR)); // XML路径 }) // 策略配置 .strategyConfig(builder -> { builder.addInclude(TABLE_NAMES) // 生成的表 .addTablePrefix("sys_") // 表前缀,生成实体时去掉前缀 // 实体类策略 .entityBuilder() .enableLombok() // 开启lombok .enableChainModel() // 开启链式模式 .enableTableFieldAnnotation() // 开启字段注解 // Controller策略 .controllerBuilder() .enableRestStyle() // 开启restful风格 .enableHyphenStyle() // 开启连字符风格 // Service策略 .serviceBuilder() .formatServiceFileName("%sService") .formatServiceImplFileName("%sServiceImpl"); }) // 自定义模板配置,生成DTO、VO、MapStruct转换器 .templateEngine(new VelocityTemplateEngine()) .injectionConfig(consumer -> { List<com.baomidou.mybatisplus.generator.config.po.TableInfo> tableInfos = consumer.getInjectionConfig().getTableInfoList(); List<com.baomidou.mybatisplus.generator.config.FileOutConfig> fileOutConfigs = new ArrayList<>(); // 生成DTO fileOutConfigs.add(new com.baomidou.mybatisplus.generator.config.FileOutConfig("/templates/dto.java.vm") { @Override public String outputFile(com.baomidou.mybatisplus.generator.config.po.TableInfo tableInfo) { return System.getProperty("user.dir") + "/src/main/java/" + PARENT_PACKAGE.replace(".", "/") + "/" + MODULE_NAME + "/dto/" + tableInfo.getEntityName() + "DTO.java"; } }); // 生成VO fileOutConfigs.add(new com.baomidou.mybatisplus.generator.config.FileOutConfig("/templates/vo.java.vm") { @Override public String outputFile(com.baomidou.mybatisplus.generator.config.po.TableInfo tableInfo) { return System.getProperty("user.dir") + "/src/main/java/" + PARENT_PACKAGE.replace(".", "/") + "/" + MODULE_NAME + "/vo/" + tableInfo.getEntityName() + "VO.java"; } }); // 生成MapStruct转换器 fileOutConfigs.add(new com.baomidou.mybatisplus.generator.config.FileOutConfig("/templates/convert.java.vm") { @Override public String outputFile(com.baomidou.mybatisplus.generator.config.po.TableInfo tableInfo) { return System.getProperty("user.dir") + "/src/main/java/" + PARENT_PACKAGE.replace(".", "/") + "/" + MODULE_NAME + "/convert/" + tableInfo.getEntityName() + "Convert.java"; } }); consumer.getInjectionConfig().setFileOutConfigList(fileOutConfigs); }) // 模板配置 .templateConfig(builder -> { builder.mapperXml("/templates/mapper.xml.vm") .entity("/templates/entity.java.vm") .service("/templates/service.java.vm") .serviceImpl("/templates/serviceImpl.java.vm") .mapper("/templates/mapper.java.vm") .controller("/templates/controller.java.vm"); }) .execute(); } }

4. 模板文件(包含 SpringDoc 注解)

在src/main/resources/templates目录下创建以下模板文件:

1. entity.java.vm(实体类模板,带 Swagger 注解)

package ${package.Entity}; #foreach($pkg in ${table.importPackages}) import ${pkg}; #end import lombok.Data; import io.swagger.v3.oas.annotations.media.Schema; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; /** * ${table.comment!} * * @author ${author} * @date ${date} */ @Data @TableName("${table.name}") @Schema(description = "${table.comment!}实体类") public class ${entity} { #foreach($field in ${table.fields}) #if(${field.keyFlag}) #set($keyPropertyName = ${field.propertyName}) #end @TableId(value = "${field.name}", type = ${field.idType}) @Schema(description = "${field.comment!}") private ${field.propertyType} ${field.propertyName}; #end #if(${entityLombokModel}) #else #foreach($field in ${table.fields}) public ${field.propertyType} get${field.capitalName}() { return ${field.propertyName}; } public void set${field.capitalName}(${field.propertyType} ${field.propertyName}) { this.${field.propertyName} = ${field.propertyName}; } #end #end }

2. dto.java.vm(DTO 模板,带 Swagger 注解)

package ${package.Parent}.${moduleName}.dto; import lombok.Data; import java.io.Serializable; import io.swagger.v3.oas.annotations.media.Schema; #foreach($field in $table.fields) import ${field.type}; #end /** * ${table.comment!}数据传输对象 * * @author ${author} * @date ${date} */ @Data @Schema(description = "${table.comment!}数据传输对象") public class ${table.entityName}DTO implements Serializable { private static final long serialVersionUID = 1L; #foreach($field in $table.fields) @Schema(description = "${field.comment!}") private ${field.propertyType} ${field.propertyName}; #end }

3. vo.java.vm(VO 模板,带 Swagger 注解)

package ${package.Parent}.${moduleName}.vo; import lombok.Data; import java.io.Serializable; import io.swagger.v3.oas.annotations.media.Schema; #foreach($field in $table.fields) import ${field.type}; #end /** * ${table.comment!}视图对象 * * @author ${author} * @date ${date} */ @Data @Schema(description = "${table.comment!}视图对象") public class ${table.entityName}VO implements Serializable { private static final long serialVersionUID = 1L; #foreach($field in $table.fields) @Schema(description = "${field.comment!}") private ${field.propertyType} ${field.propertyName}; #end }

4. controller.java.vm(控制器模板,带 Swagger 注解)

package ${package.Controller}; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import java.util.List; import ${package.Service}.${table.serviceName}; import ${package.Entity}.${entity}; import ${package.Parent}.${moduleName}.vo.${table.entityName}VO; import ${package.Parent}.${moduleName}.dto.${table.entityName}DTO; import ${package.Parent}.${moduleName}.convert.${table.entityName}Convert; /** * ${table.comment!}控制器 * * @author ${author} * @date ${date} */ @RestController @RequestMapping("/${moduleName}/${table.entityPath}") @Tag(name = "${table.comment!}接口", description = "${table.comment!}相关操作") public class ${table.controllerName} { @Resource private ${table.serviceName} ${table.entityName?uncapitalize}Service; /** * 查询详情 */ @GetMapping("/{id}") @Operation(summary = "查询详情", description = "根据ID查询${table.comment!}详情") public ${table.entityName}VO getById( @Parameter(description = "主键ID", required = true) @PathVariable Long id) { ${entity} entity = ${table.entityName?uncapitalize}Service.getById(id); return ${table.entityName}Convert.INSTANCE.toVO(entity); } /** * 列表查询 */ @GetMapping("/list") @Operation(summary = "列表查询", description = "查询${table.comment!}列表") public List<${table.entityName}VO> list() { List<${entity}> entityList = ${table.entityName?uncapitalize}Service.list(); return ${table.entityName}Convert.INSTANCE.toVOList(entityList); } /** * 新增 */ @PostMapping @Operation(summary = "新增", description = "新增${table.comment!}") public boolean save( @Parameter(description = "${table.comment!}信息", required = true) @RequestBody ${table.entityName}DTO dto) { ${entity} entity = ${table.entityName}Convert.INSTANCE.toEntity(dto); return ${table.entityName?uncapitalize}Service.save(entity); } /** * 修改 */ @PutMapping @Operation(summary = "修改", description = "修改${table.comment!}") public boolean updateById( @Parameter(description = "${table.comment!}信息", required = true) @RequestBody ${table.entityName}DTO dto) { ${entity} entity = ${table.entityName}Convert.INSTANCE.toEntity(dto); return ${table.entityName?uncapitalize}Service.updateById(entity); } /** * 删除 */ @DeleteMapping("/{id}") @Operation(summary = "删除", description = "根据ID删除${table.comment!}") public boolean removeById( @Parameter(description = "主键ID", required = true) @PathVariable Long id) { return ${table.entityName?uncapitalize}Service.removeById(id); } }

5. convert.java.vm(MapStruct 转换器模板)

package ${package.Parent}.${moduleName}.convert; import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; import ${package.Entity}.${table.entityName}; import ${package.Parent}.${moduleName}.dto.${table.entityName}DTO; import ${package.Parent}.${moduleName}.vo.${table.entityName}VO; import java.util.List; /** * ${table.entityName}对象转换器 * * @author ${author} * @date ${date} */ @Mapper public interface ${table.entityName}Convert { ${table.entityName}Convert INSTANCE = Mappers.getMapper(${table.entityName}Convert.class); /** * DO转DTO */ ${table.entityName}DTO toDTO(${table.entityName} entity); /** * DTO转DO */ ${table.entityName} toEntity(${table.entityName}DTO dto); /** * DO转VO */ ${table.entityName}VO toVO(${table.entityName} entity); /** * DO列表转DTO列表 */ List<${table.entityName}DTO> toDTOList(List<${table.entityName}> entityList); /** * DO列表转VO列表 */ List<${table.entityName}VO> toVOList(List<${table.entityName}> entityList); }

5. 使用说明

修改CodeGenerator中的数据库连接信息、包名等配置 运行CodeGenerator的main方法,生成所有代码 启动项目后,访问 SpringDoc 的 Swagger UI 界面:http://localhost:8080/swagger-ui/index.html

生成的代码将包含:

带有@Schema注解的实体类、DTO 和 VO

带有@Tag、@Operation、@Parameter等注解的 Controller

自动生成的 MapStruct 转换器

完整的 MyBatis-Plus 服务层和数据访问层代码

这种集成方式实现了:

数据库表到实体类的自动映射

DO、DTO、VO 之间的类型安全转换

API 文档的自动生成和可视化

标准 CRUD 接口的快速开发

通过一次配置,即可生成符合规范的代码架构,极大提高开发效率。

编辑
2025-08-15
JAVA-Log
00

Java 日志从入门到精通:告别日志混乱

1 日志的核心概念

日志级别

用于区分日志的重要程度,不同框架的级别定义略有差异,但核心级别一致。从高到低通常包括:ERROR、WARN、INFO、DEBUG、TRACE

日志门面

定义日志操作的标准接口,不涉及具体实现,实现日志接口与实现的解耦。典型代表是 SLF4J

日志实现

具体的日志输出方案,负责日志的格式化、输出目的地管理等。常见的有 Logback、Log4j2、JUL(Java Util Logging)

日志桥接器

用于适配旧的日志框架到新的日志门面。例如log4j-over-slf4j可以将 Log4j 的日志输出到 SLF4J。

2 主流日志框架对比

框架名称特点性能推荐指数
LogbackSLF4J 作者开发,原生支持 SLF4J,配置灵活,性能优秀★★★★★
Log4j2Log4j 的升级版,支持异步日志,性能极佳,功能丰富极高★★★★★
JULJDK 内置,无需额外依赖,功能简单★★★☆☆
Log4j经典框架,但已停止维护,存在安全漏洞★☆☆☆☆

选型建议:新项目优先选择SLF4J + Logback或SLF4J + Log4j2组合。其中 Logback 配置更简洁,适合中小型项目;Log4j2 异步性能更优,适合高并发场景。

3 SLF4J 实战:Java 日志的 “标准接口”

SLF4J(Simple Logging Facade for Java)作为日志门面的事实标准,几乎所有主流 Java 框架都采用它作为日志输出接口。掌握 SLF4J 的正确用法,是写出规范日志的第一步。

3.1 SLF4J 的设计理念

SLF4J 采用门面模式(Facade Pattern),为各种日志实现框架提供统一的接口。其核心优势在于:

解耦

业务代码只依赖 SLF4J 接口,不依赖具体日志实现,方便后期切换日志框架。

简洁

接口设计简洁明了,学习成本低。

扩展性

支持各种日志实现框架,通过绑定不同的实现包即可切换。

3.2 SLF4J 核心 API 详解

SLF4J 的核心 API 非常简单,主要包括Logger接口和LoggerFactory类。

3.2.1 获取 Logger 实例

通过LoggerFactory.getLogger()方法获取 Logger 实例,推荐使用当前类的Class对象作为参数,便于日志分类。

importorg.slf4j.Logger; importorg.slf4j.LoggerFactory; public class OrderService{ // 正确:使用当前类的Class对象获取Logger private static final Logger logger = LoggerFactory.getLogger(OrderService.class); // 错误:不建议使用字符串作为名称,不利于日志分类 // private static final Logger badLogger = LoggerFactory.getLogger("OrderService"); }

阿里巴巴规约要求:Logger 对象必须是private static final修饰的,避免频繁创建 Logger 实例,同时保证线程安全。

3.2.2 日志级别使用指南

SLF4J 定义了 5 个常用日志级别,每个级别对应一个输出方法,使用时需根据场景选择合适的级别。

public class LogLevelDemo{ private static final Logger logger =LoggerFactory.getLogger(LogLevelDemo.class); public void processOrder(Long orderId){ // TRACE:最详细的日志,通常用于开发调试,生产环境禁用 logger.trace("开始处理订单,进入processOrder方法,参数:orderId={}", orderId); try{ // DEBUG:详细的调试信息,用于开发和测试环境,生产环境可选择性开启 logger.debug("验证订单有效性,orderId={}", orderId); validateOrder(orderId); // INFO:关键业务流程节点,生产环境必须开启,记录重要操作 logger.info("订单验证通过,开始支付流程,orderId={}", orderId); payOrder(orderId); // WARN:不影响系统运行但需要关注的异常情况 if(isOrderTimeout(orderId)){ logger.warn("订单支付超时,将自动取消,orderId={}", orderId); cancelOrder(orderId); } }catch(OrderNotFoundException e){ // ERROR:影响业务流程的错误,必须记录完整堆栈信息 logger.error("处理订单失败,订单不存在,orderId={}", orderId, e); } } // 以下为示例方法,实际业务中需根据需求实现 private void validateOrder(Long orderId){} private void payOrder(Long orderId){} private boolean isOrderTimeout(Long orderId){returnfalse;} private void cancelOrder(Long orderId){} }

级别使用原则:

ERROR

影响用户操作的错误,如订单创建失败、支付异常等。

WARN

不影响当前操作但需要注意的情况,如参数不规范、资源即将耗尽等。

INFO

核心业务流程节点,如用户登录、订单提交成功等。

DEBUG

开发调试用的详细信息,如方法调用参数、返回值等。

TRACE

比 DEBUG 更详细的日志,如循环内部的变量变化等。

3.2.3 日志消息格式化技巧

SLF4J 支持使用{}作为占位符,自动替换为参数值,相比字符串拼接有明显优势。

public class LogFormatDemo{ private static final Logger logger =LoggerFactory.getLogger(LogFormatDemo.class); public void userLogin(String username,String ip){ // 正确:使用占位符,性能更优,代码更简洁 logger.info("用户登录成功,用户名:{},IP地址:{}", username, ip); // 错误:字符串拼接在日志级别未启用时仍会执行拼接操作,浪费性能 // logger.info("用户登录成功,用户名:" + username + ",IP地址:" + ip); // 正确:多个参数时按顺序对应占位符 logger.debug("用户登录验证,尝试次数:{},耗时:{}ms",3,150); // 正确:支持任意类型参数,自动调用toString()方法 User user =newUser("zhangsan",25); logger.info("用户信息:{}", user); } static class User{ private String name; private int age; public User(String name,int age){ this.name = name; this.age = age; } @Override public String toString(){ return "User{name='"+ name +"', age="+ age +"}"; } } }

性能优势:当日志级别未启用时(例如在生产环境关闭 DEBUG 级别),占位符方式不会执行参数的字符串转换操作,而字符串拼接会始终执行,造成性能浪费。

3.2.4 异常日志的正确姿势

异常日志是排查问题的关键,必须记录完整的堆栈信息,同时补充足够的上下文。

public class ExceptionLogDemo{ private static final Logger logger =LoggerFactory.getLogger(ExceptionLogDemo.class); public void transferMoney(Long fromUserId,Long toUserId,BigDecimal amount){ try{ // 业务逻辑... throw new InsufficientBalanceException("余额不足"); }catch(InsufficientBalanceException e){ // 正确:将异常对象作为最后一个参数传入,会自动打印堆栈信息 logger.error("转账失败,转出用户:{},转入用户:{},金额:{}",fromUserId, toUserId, amount, e); // 错误:只打印异常消息,丢失堆栈信息,无法定位问题位置 // logger.error("转账失败:" + e.getMessage()); // 错误:异常对象未作为参数传入,堆栈信息不会打印 // logger.error("转账失败,用户:{},原因:{}", fromUserId, e.getMessage()); }catch(Exception e){ // 正确:通用异常捕获,记录详细上下文 logger.error("转账发生未知错误,转出用户:{},转入用户:{},金额:{}",fromUserId, toUserId, amount, e); } } static class InsufficientBalanceException extends Exception{ public InsufficientBalanceException(String message){ super(message); } } }

异常日志原则:

永远不要只打印异常消息(e.getMessage()),必须打印完整堆栈。

异常对象必须作为最后一个参数传递给日志方法。

补充足够的上下文信息(如用户 ID、订单号等),方便问题定位。

3.3 SLF4J 与日志实现的绑定

SLF4J 本身不实现日志功能,需要绑定具体的日志实现框架。以SLF4J + Logback组合为例,讲解如何在 Maven 项目中配置依赖。

3.3.1 Maven 依赖配置

<!-- SLF4J API --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>2.0.9</version> </dependency> <!-- Logback核心依赖 --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.4.8</version> </dependency> <!-- 可选:Logback访问日志模块 --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-access</artifactId> <version>1.4.8</version> </dependency>

依赖冲突解决: 当项目中存在多个日志框架时,可能会出现依赖冲突。可通过mvn dependency:tree命令查看依赖树,使用exclusion排除冲突依赖。

<!-- 排除冲突的日志依赖 --> <dependency> <groupId>某第三方框架</groupId> <artifactId>第三方框架 artifactId</artifactId> <version>版本号</version> <exclusions> <exclusion> <groupId>log4j</groupId> <artifactId>log4j</artifactId> </exclusion> <exclusion> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> </exclusion> </exclusions> </dependency>

4 Logback 配置详解:打造灵活高效的日志输出

Logback 作为 SLF4J 的原生实现,具有配置灵活、性能优秀、功能丰富等特点。掌握 Logback 的配置技巧,能让日志系统更贴合业务需求。

4.1 Logback 配置文件结构

Logback 的配置文件通常命名为logback.xml或logback-spring.xml(Spring Boot 项目),放在src/main/resources目录下。其核心结构包括:

<configuration>

根元素,包含整个配置。

<appender>

定义日志输出目的地,如控制台、文件等。

<logger>

定义特定包或类的日志行为。

<root>

根 Logger,所有 Logger 的默认配置。

4.2 基础配置示例:控制台 + 文件输出

以下是一个基础的 Logback 配置,实现日志同时输出到控制台和文件,并按级别过滤。

<?xml version="1.0" encoding="UTF-8"?> <configurationscan="true"scanPeriod="30 seconds"debug="false"> <!-- 上下文名称,用于区分不同应用的日志 --> <contextName>java-log-demo</contextName> <!-- 定义变量,方便后续引用 --> <propertyname="LOG_HOME"value="./logs"/> <propertyname="FILE_NAME"value="app"/> <propertyname="ENCODING"value="UTF-8"/> <!-- 控制台输出Appender --> <appendername="CONSOLE"class="ch.qos.logback.core.ConsoleAppender"> <!-- 日志格式 --> <encoderclass="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>${ENCODING}</charset> </encoder> <!-- 过滤器:只输出INFO及以上级别日志 --> <filterclass="ch.qos.logback.classic.filter.ThresholdFilter"> <level>INFO</level> </filter> </appender> <!-- 普通文件输出Appender --> <appendername="FILE"class="ch.qos.logback.core.FileAppender"> <!-- 日志文件路径 --> <file>${LOG_HOME}/${FILE_NAME}.log</file> <!-- 日志格式 --> <encoderclass="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>${ENCODING}</charset> </encoder> <!-- 追加模式,true表示日志追加到文件末尾 --> <append>true</append> </appender> <!-- 根Logger配置 --> <rootlevel="INFO"> <appender-refref="CONSOLE"/> <appender-refref="FILE"/> </root> </configuration>

4.3 滚动日志配置:避免日志文件过大

当日志文件不断增长时,需要通过滚动策略将大文件分割成多个小文件,方便管理和归档。

4.3.1 按时间滚动的 Appender

<!-- 按时间滚动的Appender(每天生成一个日志文件) --> <appendername="ROLLING_FILE_DAILY"class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 当前日志文件路径 --> <file>${LOG_HOME}/${FILE_NAME}_daily.log</file> <!-- 滚动策略:按时间滚动 --> <rollingPolicyclass="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- 归档文件命名格式,%d{yyyy-MM-dd}表示每天一个文件 --> <fileNamePattern>${LOG_HOME}/${FILE_NAME}_%d{yyyy-MM-dd}.log</fileNamePattern> <!-- 日志文件保留天数 --> <maxHistory>30</maxHistory> <!-- 总日志大小限制,超过后删除旧文件 --> <totalSizeCap>10GB</totalSizeCap> </rollingPolicy> <!-- 日志格式 --> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>${ENCODING}</charset> </encoder> </appender>

4.3.2 按大小和时间混合滚动的 Appender

<!-- 按大小和时间混合滚动的Appender --> <appender name="ROLLING_FILE_SIZE_AND_TIME" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/${FILE_NAME}_size_time.log</file> <!-- 滚动策略:时间+大小混合 --> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 归档文件命名格式:每天一个目录,每个文件最大100MB --> <fileNamePattern>${LOG_HOME}/%d{yyyy-MM-dd}/${FILE_NAME}_%i.log</fileNamePattern> <!-- 每个文件的最大大小 --> <maxFileSize>100MB</maxFileSize> <!-- 日志文件保留天数 --> <maxHistory>30</maxHistory> <!-- 总日志大小限制 --> <totalSizeCap>20GB</totalSizeCap> </rollingPolicy> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern> <charset>${ENCODING}</charset> </encoder> </appender>

4.4 日志格式自定义:包含关键信息

日志格式的设计直接影响日志的可读性和实用性,一个好的日志格式应包含必要的上下文信息。

4.4.1 常用转换符说明

转换符含义示例
%d日期时间%d{yyyy-MM-dd HH:mm
.SSS} → 2023-10-01 15:30:22.123
%thread线程名[http-nio-8080-exec-1]
%level日志级别INFO, ERROR
%loggerLogger 名称com.example.service.OrderService
%msg日志消息用户登录成功
%n换行符平台无关的换行
%C类名OrderService
%M方法名processOrder
%L行号45
%X{key}MDC 中的键值%X{traceId} → a1b2c3d4

4.4.2 推荐的日志格式

<!-- 开发环境日志格式:包含详细调试信息 --> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}(%C:%M:%L) - %msg%n</pattern> <!-- 生产环境日志格式:包含关键上下文,简洁高效 --> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} %X{traceId} - %msg%n</pattern>

4.5 按包名 / 类名配置日志级别

实际开发中,可能需要为不同的包或类设置不同的日志级别。例如对第三方框架设置 WARN 级别,避免日志过多;对自己的业务包设置 DEBUG 级别,方便调试。

<!-- 对Spring框架设置WARN级别,减少日志输出 --> <logger name="org.springframework" level="WARN" additivity="false"> <appender-refref="CONSOLE"/> <appender-refref="ROLLING_FILE_DAILY"/> </logger> <!-- 对MyBatis设置DEBUG级别,查看SQL执行情况 --> <logger name="org.apache.ibatis" level="DEBUG" additivity="false"> <appender-refref="CONSOLE"/> <appender-refref="ROLLING_FILE_DAILY"/> </logger> <!-- 对业务包设置INFO级别,生产环境默认级别 --> <logger name="com.example.business" level="INFO" additivity="false"> <appender-refref="CONSOLE"/> <appender-refref="ROLLING_FILE_DAILY"/> </logger> <!-- 对特定类设置DEBUG级别,方便调试 --> <logger name="com.example.business.service.OrderService" level="DEBUG" additivity="false"> <appender-refref="CONSOLE"/> <appender-refref="ROLLING_FILE_DAILY"/> </logger> <!-- 根Logger配置 --> <root level="INFO"> <appender-refref="CONSOLE"/> <appender-refref="ROLLING_FILE_DAILY"/> </root>

additivity 属性: 设置为false表示当前 Logger 的日志不会传递给父 Logger,避免日志重复输出。

4.6 异步日志配置:提升系统性能

同步日志在高并发场景下可能成为性能瓶颈,因为日志输出(尤其是文件 IO)是阻塞操作。Logback 的异步日志可以将日志输出操作放入单独的线程,不阻塞业务线程。

<!-- 异步日志Appender --> <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"> <!-- 队列大小,默认256,高并发场景可适当增大 --> <queueSize>1024</queueSize> <!-- 当队列满时,是否阻塞生产者线程,false表示丢弃日志 --> <neverBlock>false</neverBlock> <!-- 引用实际的Appender --> <appender-refref="ROLLING_FILE_DAILY"/> </appender> <!-- 在Logger中引用异步Appender --> <rootlevel="INFO"> <appender-refref="CONSOLE"/> <appender-refref="ASYNC"/> </root>

异步日志注意事项:

控制台输出不建议使用异步日志,因为控制台 IO 本身性能较差。

异步日志的队列大小需根据业务并发量调整,过小可能导致日志丢失。

结合neverBlock=false和适当的队列大小,可以在保证性能的同时减少日志丢失风险。

5 Java 日志最佳实践:避坑指南与规范建议

多年的开发经验表明,80% 的日志问题都是由于不规范的使用习惯导致的。掌握这些最佳实践,能让你的日志系统更专业、更高效。

5.1 日志级别使用规范

禁止使用 ERROR 级别记录正常业务异常

例如用户输入错误、订单不存在等预期内的异常,应使用 WARN 级别。ERROR 级别只用于记录影响系统运行的错误,如数据库连接失败、缓存服务宕机等。

// 正确:用户输入错误属于预期内异常,使用WARN级别 if(StringUtils.isEmpty(username)){ logger.warn("用户注册失败,用户名为空"); returnResult.fail("用户名不能为空"); } // 正确:系统错误使用ERROR级别 try{ dbConnection = dataSource.getConnection(); }catch(SQLException e){ logger.error("获取数据库连接失败", e); returnResult.error("系统繁忙,请稍后再试"); }

避免过度使用 DEBUG 级别

DEBUG 级别日志应只在开发和测试环境启用,生产环境默认关闭。在高频调用的方法中(如接口调用、数据转换),应减少 DEBUG 日志输出。

5.2 日志内容规范

日志内容应包含关键上下文信息

一条有价值的日志应包含 “谁(用户 ID)在什么时间做了什么操作(功能模块)结果如何”。例如记录用户登录日志时,应包含用户名、IP 地址、登录时间、登录结果。

// 正确:包含完整上下文信息 logger.info("用户登录成功,用户名:{},IP地址:{},登录时间:{},耗时:{}ms",username, ip,newDate(), costTime); // 错误:缺少关键信息,无法定位具体用户 // logger.info("用户登录成功");

禁止在日志中包含敏感信息

用户密码、银行卡号、身份证号等敏感信息严禁记录到日志中。可以通过脱敏处理保留必要信息,同时保护用户隐私。

// 正确:密码进行脱敏处理 logger.info("用户登录尝试,用户名:{},密码:{}",username,maskPassword(password)); // 错误:日志中包含明文密码 // logger.info("用户登录尝试,用户名:{},密码:{}", username, password); // 密码脱敏方法示例 private String maskPassword(String password){ if(StringUtils.isEmpty(password)){ return ""; } return "******" + password.substring(Math.max(0, password.length()-2)); }

5.3 性能优化建议

避免在日志中执行耗时操作

日志参数中的方法调用应避免包含耗时操作,因为即使日志级别未启用,这些方法也会被执行。

// 错误:日志参数中执行了耗时的JSON序列化操作 logger.debug("订单信息:{}",JSON.toJSONString(order)); // 正确:使用条件判断,只有当DEBUG级别启用时才执行耗时操作 if(logger.isDebugEnabled()){ logger.debug("订单信息:{}",JSON.toJSONString(order)); }

使用占位符而非字符串拼接

如前文所述,占位符方式在日志级别未启用时不会执行参数的字符串转换,性能更优。

5.4 异常日志处理规范

异常日志应只记录一次

在异常传递过程中,应避免多次记录同一异常的日志。通常在异常最终处理处记录一次即可,中间传递过程中无需重复记录。

// Service层:只抛出异常,不记录日志 public Order getOrder throws OrderNotFoundException (Long orderId){ Order order = orderMapper.selectById(orderId); if(order ==null){ throw new OrderNotFoundException("订单不存在,orderId="+ orderId); } return order; } // Controller层:最终处理异常,记录日志 @GetMapping("/orders/{orderId}") public Result<Order> getOrder(@PathVariableLong orderId){ try{ Order order = orderService.getOrder(orderId); return Result.success(order); }catch(OrderNotFoundException e){ // 只在此处记录一次日志 logger.warn(e.getMessage()); returnResult.fail(e.getMessage()); } }

自定义异常应包含足够的上下文信息

自定义异常类应设计必要的字段,记录异常相关的上下文数据,方便问题排查。

// 正确:自定义异常包含关键信息字段 public class OrderException extends RuntimeException{ private Long orderId; private String userId; public OrderException (String message,Long orderId,String userId){ super(message); this.orderId = orderId; this.userId = userId; } // getter方法 public Long getOrderId(){ return orderId;} public String getUserId(){ return userId;} } // 使用自定义异常 logger.error("订单处理失败",newOrderException("库存不足", orderId, userId));

5.5 分布式系统日志实践

在微服务、分布式系统中,日志分散在多个服务实例中,传统的单机日志查看方式已无法满足需求。需要通过日志追踪集中收集来解决。

5.5.1 使用 MDC 实现日志追踪

MDC(Mapped Diagnostic Context)是 SLF4J 提供的映射诊断上下文,可在多线程环境中记录上下文信息(如 traceId、userId),并在日志中输出。

public class MdcDemo{ private static final Logger logger =LoggerFactory.getLogger(MdcDemo.class); // 生成全局唯一的traceId private String generateTraceId(){ return UUID.randomUUID().toString().replace("-",""); } public void processRequest(String userId){ // 将traceId和userId放入MDC MDC.put("traceId",generateTraceId()); MDC.put("userId", userId); try{ logger.info("开始处理请求"); validateUser(userId); doBusiness(); logger.info("请求处理完成"); }catch(Exception e){ logger.error("请求处理失败", e); }finally{ // 清除MDC中的数据,避免线程复用导致的信息污染 MDC.clear(); } } private void validateUser(String userId){ logger.debug("验证用户有效性"); // 业务逻辑... } private void doBusiness(){ logger.debug("执行核心业务逻辑"); // 业务逻辑... } }

在 Logback 配置中添加 MDC 字段的输出:

<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} traceId=%X{traceId} userId=%X{userId} - %msg%n</pattern>

输出日志效果:

2023-10-01 16:20:30.123 [http-nio-8080-exec-1] INFO  com.example.MdcDemo traceId=a1b2c3d4e5f6 userId=zhangsan - 开始处理请求

5.5.2 日志集中收集方案

分布式系统推荐使用ELK 栈(Elasticsearch + Logstash + Kibana)进行日志集中管理:

Elasticsearch

存储日志数据,提供全文检索能力。

Logstash

收集、过滤、转换日志数据。

Kibana

可视化日志数据,提供查询、分析界面。

集成步骤:

在应用中配置日志输出为 JSON 格式,方便 Elasticsearch 解析。

使用 Filebeat(轻量级日志收集器)收集服务器上的日志文件。

配置 Logstash 接收 Filebeat 的数据,进行过滤和转换。

将处理后的日志数据存入 Elasticsearch。

通过 Kibana 创建索引模式,查询和分析日志。

6 日志分析与监控:让日志成为系统的 “预警雷达”

日志不仅是问题排查的工具,更能通过分析和监控提前发现系统潜在风险,做到防患于未然。

6.1 关键日志指标监控

通过监控以下日志指标,可以及时发现系统异常:

ERROR 级别日志数量

突然增加可能预示系统出现故障。

接口响应时间日志

超过阈值的请求占比升高,可能存在性能问题。

第三方服务调用失败日志

如支付接口、短信接口失败率升高,需及时处理。

6.2 日志告警配置

结合监控工具(如 Prometheus + Grafana),可以为关键日志指标配置告警:

当 ERROR 日志 5 分钟内超过 10 条时,发送短信告警。

当接口响应时间超过 1 秒的请求占比超过 5% 时,发送邮件告警。

6.3 常见日志分析场景

用户行为分析

通过分析用户登录、下单、支付等日志,统计用户活跃度、转化率等指标。

性能瓶颈定位

通过分析方法调用耗时日志,找出系统中的性能瓶颈。

异常模式识别

通过分析历史异常日志,识别异常发生的规律和模式,提前优化。

7 总结:打造专业的 Java 日志系统

Java 日志系统的构建是一个 “细节决定成败” 的过程,它看似简单,实则蕴含着丰富的技术细节和最佳实践。一个优秀的日志系统应该具备以下特点:

清晰的日志级别

根据业务场景选择合适的日志级别,避免级别滥用。

完整的上下文信息

日志内容应包含足够的上下文,方便问题定位。

合理的输出策略

结合同步 / 异步日志、滚动策略,平衡性能和可靠性。

规范的日志格式

统一日志格式,包含关键标识(如 traceId),便于集中分析。

完善的安全措施

避免敏感信息泄露,保护用户隐私和系统安全。