刚开始接触 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这种无意义地址。
默认行为不能说完全没用,但它有两个明显的坏处:
- 它不区分敏感字段和普通字段,只要对象里面有
password、secret、idCard,就会一并输出。 - 它不关心日志输出诉求,比如你只想让排查问题时看到
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就没有意义了。所以正确顺序是:先排除原始字段,再用方法补一个脱敏值。
如果要在日志里同时看到邮箱前缀和后面的域名脱敏,也可以按同样的思路处理。我自己的习惯是:凡是能定位问题的信息保留一部分,凡是不能给人看的完整信息全部排除。这样可以兼顾日志可读性和安全合规。
整套改造步骤可以拆成三段:
- 排查当前项目里所有被日志直接打印的对象,列出敏感字段清单。
- 在实体类上使用
@ToString.Exclude排除原始敏感字段。 - 对排查必需的敏感字段,写一个
@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,日志链路里直接出现异常,排查问题的人反而要被这个异常再耽搁一阵。
解决循环引用有几种思路,我按优先级说一下:
- 双向关联的两端里至少选择一端排除。比如
Department里的employees字段标注@ToString.Exclude,日志里就不看完整员工列表了。 - 用
@ToString.Include方法输出摘要,比如员工列表改成输出employees.size()或每个员工的id列表。 - 如果确实需要输出关联对象的部分信息,不要直接输出整个对象,而是单独写方法去映射。
核心原则其实很简单: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,这样代码评审的人一眼就能看懂它是脱敏版本的输出,避免后续维护的人误以为字段没有脱敏而重复添加逻辑。
这套控制方式并不是只有安全合规才需要,普通项目里只要你在日志里吃过亏、被巨型日志拖累过排查效率,就值得尽早把规则定下来。先给实体类做减法,再给关键信息做加法,日志会变得干净很多。