news 2026/10/14 7:18:47

Lombok日志脱敏:@ToString.Exclude与Include控制toString输出

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Lombok日志脱敏:@ToString.Exclude与Include控制toString输出

刚开始接触 Lombok 的人,往往只记住了@Data能省掉 Getter/Setter,却忽略了它默认生成的toString()也会把对象输出成一个“全量快照”。我在一次联调环境的问题排查里,就看到过这样的日志:整个用户对象被logger.info("创建用户: {}", user)打了出来,密码、身份证号、手机号明文躺在日志文件里。虽然当时是测试环境,但这个习惯一旦沿用到生产,早晚要出事。所以后来我把@ToString.Exclude和@ToString.Include当成日志输出控制里的基本功,凡是进入日志链路的实体,都必须在toString()层面想清楚:哪些字段能给人看,哪些字段不能出现,哪些字段需要以脱敏或摘要的形式出现。这篇内容就是我基于实际项目经验整理的配置思路和避坑记录,适合正在做接口日志、审计日志、异常告警脱敏,或者想规范团队toString()输出习惯的 Java 开发者直接参考。

1. 日志泄露的隐患与默认 toString 的局限

1.1 一个最简单的日志泄密现场

Java 项目里最常见的输出对象方式,就是把业务对象直接放进 SLF4J 的占位符里:

log.info("创建用户成功: {}", user);

如果这个user是一个被@Data修饰的实体类,Lombok 会自动生成类似这样的toString():

UserProfile(userId=1001, mobile=13800138000, idCard=110101********1234, password=abc123, token=e9f5...)

看日志的人觉得方便,但问题也出在这个“方便”上。日志文件通常不会只存在服务器本地,它会进入日志采集器,被统一放到日志平台,可能被多人检索、被告警规则扫描、被备份系统归档。凡是原样输出一次,就等于把敏感信息复制了一份,之后无论怎么清理都无法保证彻底删除。

更隐蔽的问题在于,很多同事并不会意识到自己在打日志。他们只是顺手在代码里加了一句log.debug("当前请求参数: {}", request),然后整个请求 DTO 的内部字段就全部进了日志系统。等到安全审计问起来,才发现依赖组件、第三方接口返回体、嵌套对象里到处都有敏感字段。靠人肉提醒永远堵不住,必须从对象自身的toString()上做约束。

1.2 Lombok 默认生成的 toString 是什么样

Lombok 的@ToString并不是一个运行时反射工具,它在编译阶段就帮你把toString()方法生成到了 Class 文件里。默认行为是输出类名和所有非静态字段,字段格式为字段名=字段值,字段之间用逗号分隔。数组和集合会被展开输出,而不是打印一个内存地址。

这里有两个容易忽略的点:

  • static字段不会进入默认toString(),transient字段通常也不会出现在 Lombok 生成的输出里。
  • 数组类型会调用类似Arrays.deepToString的逻辑,保证数组内容能看到,而不只是[Ljava.lang.String;@1234这种无意义地址。

默认行为不能说完全没用,但它有两个明显的坏处:

  1. 它不区分敏感字段和普通字段,只要对象里面有password、secret、idCard,就会一并输出。
  2. 它不关心日志输出诉求,比如你只想让排查问题时看到id和status,它也照样把整个大集合倒出来。

所以控制日志输出,不是去改日志框架,也不是靠“打日志的时候注意一点”,而是让对象自己变成一个“日志友好”的结构。@ToString.Exclude负责做减法,把不该出现的字段摘掉;@ToString.Include负责做加法,把需要突出、需要自定义格式的内容显式加进输出。

2. 从字节码到执行效果:两个注解的底层机制

2.1 @ToString.Exclude 为什么能让字段“消失”

@ToString.Exclude可以直接标注在字段上,也可以标注在方法上。编译的时候,Lombok 生成toString()方法时会检查当前类的字段列表,发现标注了@ToString.Exclude的字段就直接跳过,不会在方法体里生成对应的拼接代码。

举个例子:

@Data public class LoginUser { private String userId; private String username; @ToString.Exclude private String password; @ToString.Exclude private String token; }

编译后生成的toString()逻辑近似于:

public String toString() { return "LoginUser(userId=" + this.userId + ", username=" + this.username + ")"; }

password和token并没有完全从对象里消失,它们仍然可以通过 Getter 读取,但在日志输出这条路径上不再出现。这正是控制日志输出最直接的手段。

这里有一个关键词需要强调:当前类。@ToString.Exclude只对当前类生成的toString()生效。如果当前对象里嵌套了另一个对象,而那个嵌套对象的类里也有敏感字段,当前类的toString()调用到子对象的toString()时,子对象的敏感字段还是会暴露。

比如:

@Data public class Order { private String orderNo; @ToString.Exclude private UserProfile user; }

这确实会让Order的toString()不输出user这个字段。但如果Order的toString()里输出了user,而UserProfile内部没有做任何排除,那user的密码照样会跟着日志出来。所以嵌套场景必须两层同时处理:要么当前类直接排除整个嵌套对象,要么嵌套对象自己的toString()也要脱敏。

2.2 @ToString.Include 的三个隐藏能力:命名、排序、自定义打印

@ToString.Include在 Lombok 1.18.2 之后变得非常实用。它可以标注在字段上,也可以标注在无参方法上。最基础的使用就是让字段参与输出:

@ToString.Include private String orderNo;

如果类上同时开了@ToString(onlyExplicitlyIncluded = true),那么只有标注了@ToString.Include的字段和方法才会进入输出,其它字段全部忽略。这个组合本质上是把toString()从默认的全量输出改成白名单模式,下面的代码就是典型例子:

@ToString(onlyExplicitlyIncluded = true) public class Order { @ToString.Include private String orderNo; @ToString.Include(rank = -10) private Long id; @ToString.Include(name = "customerId", rank = -9) private Long userId; @ToString.Exclude private String promotionNote; }

@ToString.Include的name属性可以给输出时的字段名改个名字。比如字段叫userId,但业务上更习惯叫customerId,日志里就会输出customerId=10086,这样对排查问题的人更友好。

rank属性控制字段在toString()输出里的先后顺序。数字小的排前面,数字大的排后面,没有标注rank的字段会按照声明顺序落在默认区间。日志输出稳定有序,对人工阅读和自动化解析都很有帮助。

真正灵活的是方法上的@ToString.Include。你可以在类里写一个自定义方法,方法返回值就是日志里要展示的内容。这个方法可以用参数做脱敏、截断、摘要,甚至从上下文里补充信息。

@ToString.Include(name = "traceId") public String currentTraceId() { return TraceContext.getTraceId(); }

这样当对象被打印时,日志里会多出一个traceId字段,排查分布式环境下的问题会方便很多。但要注意,toString()里不能执行耗时操作,更不能做数据库查询、远程调用、加锁之类的事。toString()可能被 IDE 调试器、异常堆栈、集合容器反复调用,一旦方法里带着重量级逻辑,线上性能会被无端拖垮。

3. 控制日志输出:从脱敏到生产排障的标准配置

3.1 黑名单还是白名单:两种控制策略怎么选

@ToString.Exclude是黑名单思路:默认全量输出,遇到排除项才拿掉。@ToString(onlyExplicitlyIncluded = true)加@ToString.Include是白名单思路:默认不输出,只有明确标注的字段才输出。

实际项目里我通常这样选:

  • 如果一个实体大部分字段都适合展示,只有少数敏感字段需要隐藏,用@ToString.Exclude最省事。
  • 如果这个对象是给外部接口用的 DTO、审计日志实体,或者字段数量多且不少带有内部实现细节,用白名单模式更安全。白名单模式下,新增字段默认不进入日志,团队新人就算忘了加脱敏注解,也不会造成新一轮泄露。

建议在团队规范里直接定一条规则:外部请求对象、响应对象、审计记录对象必须使用@ToString(onlyExplicitlyIncluded = true),其它实体类默认不要依赖 Lombok 的全量输出。这条规则可以在代码评审里人工把关,也可以结合静态检查工具去提前发现。

还要记住一件事:@ToString.Exclude、@ToString.Include只影响 Lombok 生成的toString(),它和 JSON 序列化完全是两码事。比如 Jackson 序列化里用的@JsonIgnore、@JsonProperty,并不会因为 Lombok 注解而改变行为。如果一个对象既要返回给前端,又要进日志,要分别检查两条路径上的注解,不能认为“日志里脱敏了,接口返回就安全了”。

下面是一个简单的对照表:

控制方式作用范围典型场景注意事项
@ToString.Exclude当前类字段/方法排除密码、Token、银行卡号不作用于嵌套对象的内部字段
@ToString.Include当前类字段/方法明确输出某个字段,或自定义脱敏方法方法必须无参,返回值作为日志内容
@ToString(onlyExplicitlyIncluded = true)整个类白名单模式,审计对象、请求 DTO未标注@Include的字段一律不进日志
@ToString(exclude={"a","b"})整个类旧版本项目,临时快速排除字段字段名写错时编译期不报错,容易漏掉

3.2 脱敏三段式改造:Exclude 掉原字段,Include 出脱敏值

日志脱敏这件事,只把敏感字段整体排除是最省事的,但也最影响排查效率。比如接口报错时,你想看用户手机号是否格式不对,结果日志里只有mobile=null或者干脆没有这个字段,排查一下子就被动了。更好的做法是:原始敏感字段用@ToString.Exclude排除,另外再通过@ToString.Include方法输出一个脱敏后的版本。

以UserVO为例:

@Getter @Builder public class UserVO { private String userId; private String mobile; private String email; @ToString.Exclude private String password; @ToString.Exclude private String idCard; @ToString.Include(name = "mobileMasked", rank = 20) public String mobileMasked() { if (mobile == null || mobile.length() < 7) { return ""; } return mobile.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"); } }

这样日志输出的结果类似:

UserVO(userId=1001, mobileMasked=138****8000, password=***)

不对,这里有个细节需要提醒:如果你不在@ToString.Exclude里排除原始mobile字段,mobile=13800138000还是会出现在日志里,那再写一个mobileMasked就没有意义了。所以正确顺序是:先排除原始字段,再用方法补一个脱敏值。

如果要在日志里同时看到邮箱前缀和后面的域名脱敏,也可以按同样的思路处理。我自己的习惯是:凡是能定位问题的信息保留一部分,凡是不能给人看的完整信息全部排除。这样可以兼顾日志可读性和安全合规。

整套改造步骤可以拆成三段:

  1. 排查当前项目里所有被日志直接打印的对象,列出敏感字段清单。
  2. 在实体类上使用@ToString.Exclude排除原始敏感字段。
  3. 对排查必需的敏感字段,写一个@ToString.Include方法输出脱敏值,并明确命名,例如mobileMasked、idCardMasked。

3.3 配合日志框架,把 toString 的性能影响降到最低

很多团队在讨论脱敏时只关注安全性,忽略了toString()本身也是一个性能点。当对象里的字段特别多,或者某个字段是超大集合时,toString()拼接出的字符串会非常长,占用大量堆内存,日志采集系统也要重复处理这些内容。

有几个经验值得直接落地:

  • 记录对象时使用参数化占位符{},不要用字符串拼接。log.info("user=" + user)在日志级别关闭时,字符串拼接也会执行,浪费 CPU;参数化方式更友好,不过传入对象时toString()仍会在日志框架真正格式化时被调用。
  • 对超大的集合字段,优先排除,或者用@ToString.Include方法只输出集合大小。
@ToString.Include(name = "itemCount") public int itemCount() { return items == null ? 0 : items.size(); }

这样日志里能看到itemCount=128,又不会因为打印上千条明细把日志文件撑爆。

  • 对非常热点的日志路径,先用if (log.isDebugEnabled())做一次防御。因为isDebugEnabled()本身开销很小,但能避免对象在不需要输出时被强制调用一次重量级toString()。
  • 不要把上下文查询塞进toString(),比如在@ToString.Include方法里拼接当前用户信息、当前 traceId 时可以,但如果你为了凑一个字段去查一次数据库,那就非常不明智。

4. 核心场景:循环引用、继承体系、大对象的坑

4.1 循环引用:toString 怎么把自己递归到爆栈

最经典的toString()事故,就是双向关联的实体互相引用。比如部门对象里有员工列表,员工对象里又指向部门对象:

@Data public class Department { private String id; private List<Employee> employees; } @Data public class Employee { private String id; private Department department; }

当有人打印Department时,它的toString()会去打印employees列表,而列表里的Employee又有一个department字段,于是Department的toString()再次触发,这就形成了无限递归。最终结果通常是StackOverflowError,日志链路里直接出现异常,排查问题的人反而要被这个异常再耽搁一阵。

解决循环引用有几种思路,我按优先级说一下:

  1. 双向关联的两端里至少选择一端排除。比如Department里的employees字段标注@ToString.Exclude,日志里就不看完整员工列表了。
  2. 用@ToString.Include方法输出摘要,比如员工列表改成输出employees.size()或每个员工的id列表。
  3. 如果确实需要输出关联对象的部分信息,不要直接输出整个对象,而是单独写方法去映射。

核心原则其实很简单:toString()的输出树一定要是 DAG,不能有环。只要能保证对象图里不会从 A 对象又绕回 A 对象,基本就不会因为递归打日志而出问题。

4.2 继承体系中的暴露面

继承场景里,@ToString.Exclude和@ToString.Include的行为没有那么直观。我在实际项目里遇到过两类问题:

  • 父类里定义了敏感字段,但子类的toString()输出并没有按预期排掉该字段。因为 Lombok 在生成子类的toString()时,默认不一定会拼上父类的字段列表;如果父类也标注了@ToString,不同版本和不同配置下,最终输出可能会因为callSuper参数不同而产生差异。
  • 子类覆盖了父类的字段,或者通过方法访问父类 getter 时,注解位置选错会导致脱敏失效。

如果你只是在一个项目里依赖默认行为,很容易被莫名其妙的字段输出坑到。最稳妥的解法,是在基础父类上统一开启白名单模式:

@ToString(onlyExplicitlyIncluded = true) public class BaseEntity { @ToString.Include private Long id; @ToString.Exclude private String deletedFlag; }

子类继承这个基础类时,也保持白名单模式,并在子类上显式声明要输出的字段。这样即使父类后来新增了字段,也不会自动进入日志。遇到需要拼上父类输出的时候,再显式设置callSuper = true,并验证最终效果,不要想当然。

4.3 大字段和大集合:日志里有几千个元素,谁看谁崩溃

业务实体中经常会出现大文本、图片 Base64、嵌套列表、权限树、策略配置这类字段。默认的toString()会把它们全部展开,后果是日志单条体积直接超过日志平台的单条上限,导致日志被截断,用户真正需要的信息反而看不到。

更好的做法是给大字段单独定义一个摘要方法:

@ToString.Include(name = "remark") public String remarkSummary() { if (remark == null) { return ""; } return remark.length() > 200 ? remark.substring(0, 200) + "..." : remark; }

这里有一个需要注意的边界:如果你在字段上标注了@ToString.Include,又在方法上也标注了@ToString.Include,并且两者输出名字一样,可能会造成命名冲突或者重复输出。建议要么只用字段本身,要么只用方法输出脱敏/截断版本,不要同时用两种方式控制同一个名字。

对于集合字段,我通常默认就做排除或摘要。因为日志排查里绝大多数情况只需要知道集合大小、是否有空、是否包含某个特定 ID,不想看全量内容。真需要全量内容时,再单开专门的调试日志,限定在 debug 级别,并确保数据量可控。

5. 常见问题与排查技巧实录

5.1 为什么 @ToString.Exclude 标注了还是能看到字段?

这种问题我见过很多次,通常不是注解本身不生效,而是下面几种原因:

  • 项目使用了低版本的 Lombok,旧版本对嵌套注解的支持不完整。升级到 1.18.2 以上基本能解决。
  • IDE 或构建工具存在增量编译缓存,注解改完没有重新编译。这种时候执行一次clean build,或者重启 IDE 的增量编译就能看到效果。
  • 注解标注在了 Getter 方法上,但 Lombok 生成toString()时仍然读取字段,并没有把 Getter 方法排除掉。@ToString.Exclude标注在字段上最可靠,标注在方法上时要确认方法名没有被其它字段拼接引用。
  • 当前对象把敏感字段放在嵌套子对象中,子对象自己的toString()没做排除。这种情况只能沿着对象图逐层排查。

5.2 字段被 Exclude 掉之后,排查问题看不到原值怎么办

这是很多人最后又改回默认或者偷偷注释掉@ToString.Exclude的原因。其实没必要。真到了需要看原值的排查时刻,可以选择临时关闭脱敏,或者直接从数据库、缓存、接口入参中取原值。更安全的方案是给需要展示原值的场景单独开一个 debug 级别日志,并且限定在测试环境或者指定开关下。

我自己的做法是:凡是脱敏字段,toString()里一律不给原值。需要原值排查时,在打断点看变量值或者用调试器查看,绝不让原值落进日志。因为日志一旦落盘,就没有回收机制了,风险不可控。

5.3 前后端联调时,toString 和 JSON 序列化要分开看

联调阶段最容易出现的认知混乱,是以为@ToString.Exclude会让字段也不返回给前端。实际上不会。它的作用范围只在toString()生成逻辑里,Jackson、Gson、Fastjson 这些序列化框架完全不会读这个注解。如果你想让接口不返回某个字段,需要给序列化框架加注解,比如@JsonIgnore。

反过来也是一样:用@JsonIgnore隐藏了字段,并不代表 Lombok 的toString()会隐藏它。因为@JsonIgnore是给 JSON 库看的,Lombok 不认识它。两条输出的控制机制互不替代,我见过不少刚好把这两者搞反的情况,最后以为脱敏已经做了,结果日志里照样明文输出。

5.4 日志里怎么加 traceId 这类上下文信息

对排障来说,对象字段本身还不够,最好能在toString()输出里带上 traceId、请求路径、当前操作人这类上下文信息。利用方法级的@ToString.Include就能实现,不污染业务字段:

@ToString.Include(name = "traceId") public String traceIdValue() { String traceId = TraceContext.get(); return traceId == null ? "-" : traceId; }

但要注意两点:第一,如果 TraceContext 是通过动态代理或者异步线程传递的,需要确保这个上下文在当前线程里真的可用,否则输出会一直是null,看起来像 bug。第二,不要把不该进日志的敏感信息放到 TraceContext 里,因为这里面的内容会被批量复制到每个日志条目的toString()输出中。

5.5 常见问题速查表

现象常见原因处理方案
嵌套子对象的敏感字段仍在日志里@ToString.Exclude只作用于当前类对子对象也做脱敏,或当前类排除整个嵌套字段
改了注解后日志没有变化增量编译缓存、Lombok 版本过旧执行 clean build,确认 Lombok 1.18.2+
同时用了 Include 和 Exclude,字段还是输出字段或方法存在重复定义只保留字段注解,或只保留方法脱敏输出
大集合把日志刷爆默认toString()展开所有集合元素集合字段加@ToString.Exclude,另写 Include 方法输出 size
接口返回字段和日志字段不一致Lombok 注解不控制 JSON 序列化根据输出框架单独加注解
对象产生循环递归,栈溢出双向引用未排除至少排除一侧关联字段,确保日志对象图无环
toString()输出顺序每次不稳定没有显式使用rank用@ToString.Include(rank=...)固定顺序

我在实际项目里还有一个小技巧是给日志 DTO 单独做一版toString()白名单,而不是直接复用领域实体。领域实体往往字段太多,而且会随着业务变化新增字段,直接在它上面控制很累。日志 DTO 只保留排查必需的字段,用@ToString(onlyExplicitlyIncluded = true)锁死输出范围,这样不管业务实体怎么膨胀,日志输出始终是稳定可控的。另一个经验是,@ToString.Include在方法上输出脱敏值时,方法命名尽量以字段名加后缀,比如mobileMasked,这样代码评审的人一眼就能看懂它是脱敏版本的输出,避免后续维护的人误以为字段没有脱敏而重复添加逻辑。

这套控制方式并不是只有安全合规才需要,普通项目里只要你在日志里吃过亏、被巨型日志拖累过排查效率,就值得尽早把规则定下来。先给实体类做减法,再给关键信息做加法,日志会变得干净很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 7:18:37

Python在线学习平台开发:从Django选型到视频防盗链

很多人觉得做一个python网络课程在线学习平台&#xff0c;最费劲的是写课程内容、录视频、找老师。我最初也这么想&#xff0c;可真把一个平台从零搭起来之后才发现&#xff0c;真正的工程量藏在那些用户根本看不见的地方&#xff1a;课程怎么分类、视频怎么防爬、播放进度怎么…

作者头像 李华
网站建设 2026/10/14 7:18:22

从零构建语种识别器:字符n-gram与朴素贝叶斯实战

接手一个多语客服工单分类需求的时候&#xff0c;我才意识到“语种识别”这个看似不起眼的小功能&#xff0c;其实是很多NLP工程链路的入口。拿一段文本自动判断它是中文、英文还是日文&#xff0c;听起来很基础&#xff0c;但真正要自己训练一个语种识别器&#xff0c;再通过F…

作者头像 李华
网站建设 2026/10/14 7:18:22

Fiddler免安装版抓包实战:配置、HTTPS解密与避坑指南

简介&#xff1a;Fiddler中文免安装版是一款面向Web开发、测试与安全评估人员的网络封包分析工具&#xff0c;基于Telerik出品的Fiddler 4.6汉化而来&#xff0c;无需安装即可直接运行&#xff0c;适合需要快速抓取HTTP/HTTPS流量、排查接口异常或分析页面加载性能的初中级开发…

作者头像 李华
网站建设 2026/10/14 7:18:15

在哪里购买SSL证书?常见渠道与申请方式说明

SSL证书主要有三种购买渠道——CA机构官网、云服务商平台和专业证书服务商。如果需要购买SSL证书&#xff0c;推荐优先考虑专业SSL证书服务商。这类平台的优势在于&#xff1a;集成多个CA品牌便于对比选型、提供免费安装部署服务、支持中文技术沟通。一、SSL证书的三类购买渠道…

作者头像 李华
网站建设 2026/10/14 7:18:11

AI存储底座为什么是HDD?从容量成本到工程实践

最近在给某跨平台系统的存储底座做容量评估&#xff0c;规划报表里那一串数字从TB一路涨到PB&#xff0c;最后不得不按EB量级去预留扩展空间。这种体验放在几年前很难想象&#xff0c;但AI存储进入PB/EB时代之后&#xff0c;一个很有意思的现象出现了&#xff1a;真正扛起海量数…

作者头像 李华
网站建设 2026/10/14 7:18:10

UVA11956 Brainfuck模拟题全解析:从指令到AC代码的完整指南

说真的&#xff0c;第一次看到“Brainfuck”这个题目名的时候&#xff0c;我以为是某个整蛊题——毕竟这门语言的名字就自带劝退属性&#xff0c;指令总共只有八个&#xff0c;却能把人绕得晕头转向。但UVA11956这道题其实一点都不吓人&#xff0c;它要你做的事非常单纯&#x…

作者头像 李华