Java record 紧凑构造器:参数校验与规范化赋值的正确写法

用 record 承载请求参数、配置项、值对象时,最容易踩的坑不是「怎么少写 getter」,而是「构造时该不该校验、怎么校验、规范化后的值怎么写回去」。很多人第一次写紧凑构造器(compact constructor)时写出了 this.name = name.trim();,编译器直接报 error: cannot assign a value to f

用 record 承载请求参数、配置项、值对象时,最容易踩的坑不是「怎么少写 getter」,而是「构造时该不该校验、怎么校验、规范化后的值怎么写回去」。很多人第一次写紧凑构造器(compact constructor)时写出了 this.name = name.trim();,编译器直接报 error: cannot assign a value to final variable name;也有人把校验逻辑塞进静态工厂方法,结果 new 出来的对象绕过了全部检查。

这篇把紧凑构造器的语义讲透:它到底在什么时候执行、为什么不能给字段赋值、规范化赋值该落在哪里、异常类型与消息怎么设计、和 Bean Validation 怎么配合,以及序列化、反射、继承(record 不能继承)等边角情况下的真实报错与解决路径。所有示例基于 JDK 17+,在 JDK 21 上验证通过。

先搞清楚:紧凑构造器不是「无参构造器」

record 的构造器有三种写法,语义完全不同,混用就会出问题。

1. 规范构造器(canonical constructor)

参数列表与 record 组件完全一致的构造器。你可以显式写出来,此时必须给每个字段赋值:

public record User(String name, int age) {
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

2. 紧凑构造器(compact constructor)

只写 public User { ... },省略参数列表。编译器会把组件参数隐式传进来,并在你的代码块之后自动插入 this.name = name; this.age = age;。关键点:

  • 块内可以直接使用与组件同名的参数变量(它们是方法参数,可以重新赋值)。
  • 块内不能写 this.name = ...,因为字段是 final,且赋值由编译器在块尾统一完成。
  • 块内允许 return;(无返回值形式),用于提前结束——但通常不建议,会跳过后续校验。

3. 非规范构造器(委托构造器)

参数列表不同的构造器,第一条语句必须是 this(...) 调用其它构造器。最终一定落到规范构造器(或紧凑构造器)上,所以校验不会被绕过。

public record User(String name, int age) {
    public User(String name) {
        this(name, 0); // 必须第一句,且必然走紧凑构造器
    }

    public User {
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("age out of range: " + age);
        }
    }
}

这三者的关系可以用一句话概括:所有实例化路径最终都汇聚到紧凑构造器,所以把校验与规范化放在紧凑构造器里,覆盖面最大。

为什么 this.field = … 会编译失败

在紧凑构造器里写 this.name = name;,javac 会给出:

error: cannot assign a value to final variable name
        this.name = name;
            ^

原因:record 组件对应的字段隐式是 private final,而紧凑构造器在语言层面被定义为「不负责赋值的钩子」——赋值由编译器生成。你在块内改的只是参数变量的值,编译器随后用参数变量去初始化字段。因此规范化赋值的正确姿势是==================

public record User(String name, int age) {
    public User {
        name = name == null ? "" : name.strip(); // 改参数变量,不是 this.name
        if (name.isEmpty()) {
            throw new IllegalArgumentException("name must not be blank");
        }
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("age out of range: " + age);
        }
    }
}

注意 name = ... 这一行:左边没有 this.,它是对参数的重新赋值,编译器在块尾执行 this.name = name; 时用的就是新值。这一点容易看错——它不是给字段赋值。

等价写法:如果显式写规范构造器,就必须自己写 this.name = name.strip(); 并逐个字段赋值,此时 this. 是合法的,但漏写任何一个字段都会编译报错(variable name might not have been initialized)。紧凑构造器省掉的正是这份样板。

用 this(...) 做二次规范化是坑

有人想通过委托给另一个构造器来实现默认值,但 record 不允许在紧凑构造器里调用 this(...):

error: constructor is not canonical, to invoke this(?) the first statement must be this()

准确地说,紧凑构造器体内禁止显式调用 this(...)。要做默认值填充,就在块内给参数变量赋新值即可:

public record PageQuery(int page, int size) {
    public PageQuery {
        page = Math.max(page, 1);
        size = size <= 0 ? 10 : Math.min(size, 200);
    }
}

校验逻辑该写多重:真实边界条件清单

record 常用于 DTO 与域内值对象,两者对校验的要求不同。给出可复用的检查项:

组件类型 必检项 规范化建议
String null、空白(isBlank())、长度上限、格式(邮箱/手机号) strip();可以不接受 null 并用 "" 兜底,但更推荐直接拒绝 null
原语 int/long 范围、是否为 0 表示「未设置」(原语无法区分未设置与 0,需要可空时用包装类型) 不要用负数当哨兵值,除非文档写清
包装类型 Integer/Long null 是否允许 null 与 0 语义不同,别默认转 0
集合 List/Set/Map null、元素是否为 null、大小上限 用 List.copyOf(...) 做防御性拷贝并顺带拒绝 null 元素
Date/Instant/LocalDate 是否为未来时间、区间顺序 即时类型统一到 UTC,用 Instant 而非 Date
枚举 null null 直接抛异常,别默默用默认枚举
嵌套 record null 嵌套 record 自身校验已保障,父级只需判空

防御性拷贝:List.copyOf 与 null 元素

List.copyOf 会返回不可变副本,并且遇到 null 元素直接抛 NullPointerException,这正好当免费校验用:

import java.util.List;

public record Order(String id, List<String> items) {
    public Order {
        if (id == null || id.isBlank()) {
            throw new IllegalArgumentException("id must not be blank");
        }
        id = id.strip();
        if (items == null) {
            throw new IllegalArgumentException("items must not be null");
        }
        if (items.isEmpty()) {
            throw new IllegalArgumentException("items must not be empty");
        }
        items = List.copyOf(items); // 不可变 + 拒绝 null 元素
    }
}

如果不做拷贝,调用方持有原 List 引用并后续修改,record 声称的不可变性就是假的。这是 record 使用中最常见的逻辑漏洞。

报错信息示例(元素为 null 时):

Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.ImmutableCollections.listCopy(ImmutableCollections.java:110)
    at Order.<init>(Order.java:14)

这个 NPE 没有消息,对排查不友好。若需要明确信息,自己先遍历判空再拷贝:

for (String item : items) {
    if (item == null || item.isBlank()) {
        throw new IllegalArgumentException("items contains blank element");
    }
}
items = List.copyOf(items);

异常类型与消息设计

构造器是对象创建的入口,抛出的异常类型决定调用方怎么处理:

  • IllegalArgumentException:参数值非法(格式、范围、空白)。这是默认选择。
  • NullPointerException:仅用于「不允许 null」的场景,可用 Objects.requireNonNull(x, "x must not be null"),消息里带上组件名。
  • 自定义异常(如 ValidationException):只有在你需要携带字段名等结构化信息、或需要被统一异常处理器捕获时才用。record 构造器抛自定义受检异常会污染所有调用点,慎用。

消息要带组件名 + 非法值,别只写「参数错误」:

public record Email(String value) {
    private static final java.util.regex.Pattern P =
            java.util.regex.Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");

    public Email {
        if (value == null) {
            throw new NullPointerException("value must not be null");
        }
        value = value.strip().toLowerCase(java.util.Locale.ROOT);
        if (!P.matcher(value).matches()) {
            throw new IllegalArgumentException("value is not a valid email: " + value);
        }
    }
}

顺带说明 toLowerCase(Locale.ROOT):避免土耳其语环境下 I 转成 ı 的经典 bug(toLowerCase() 用默认 Locale)。这类「规范化」恰恰是紧凑构造器的强项。

紧凑构造器 vs Bean Validation:谁能替代谁

Spring Boot 场景下很多人习惯用 @NotBlank、@Min,于是问:record 里还要不要写紧凑构造器?

结论:两者职责不同,不要互相替代。

  • 紧凑构造器是语言级不变量,只要对象被创建就成立,与框架无关,单元测试直接 new 就能验证。
  • Bean Validation 是框架级校验,依赖 Hibernate Validator 在 @Valid 触发时执行,直接 new 不校验。

对 DTO 建议:注解负责「给用户看的错误提示」,紧凑构造器负责「不可能存在的状态」。注意 record 组件上的注解要加 @Target 允许的传播方式,写法和普通类不同:

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

public record CreateUserRequest(
        @NotBlank(message = "用户名不能为空") String name,
        @Min(value = 0, message = "年龄不能小于 0") int age) {

    public CreateUserRequest {
        // 硬不变量:即使不经过 @Valid 也不能构造出非法对象
        if (age > 150) {
            throw new IllegalArgumentException("age must be <= 150, but was " + age);
        }
    }
}

早期 JDK 15/16 预览阶段,record 组件上的注解默认不会传播到字段/构造器参数/访问器,需要写 @Target({ElementType.RECORD_COMPONENT, ...})。从 JDK 16 起,如果注解的 @Target 不含 RECORD_COMPONENT,编译器会把它自动应用到对应的字段、构造器参数和访问器上(JLS 8.10.3)。也就是说 jakarta.validation 的注解可以直接贴在组件上正常参与校验,但如果你自定义注解并只写了 ElementType.FIELD,行为会和预期不同,排查时看反编译结果最可靠:

javap -v -p CreateUserRequest.class | grep -A3 Record

踩坑:CompactConstructor 抛异常被全局异常处理器吞掉

常见现象:接口返回 500 而不是 400,堆栈里能看到 IllegalArgumentException。原因是构造器抛出的异常发生在 @RequestBody 反序列化阶段,Spring 默认把它包装成 HttpMessageNotReadableException,你的 @ExceptionHandler(IllegalArgumentException.class) 匹配不到。

解决办法是显式处理外层异常:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<String> onUnreadable(HttpMessageNotReadableException e) {
        Throwable root = e.getMostSpecificCause();
        String msg = root.getMessage() == null ? "请求体格式错误" : root.getMessage();
        return ResponseEntity.badRequest().body(msg);
    }
}

注意:Jackson 反序列化 record 时会调用规范构造器,参数名依赖 -parameters 编译参数或 jackson-module-parameter-names。如果没开启,会看到:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Cannot construct instance of `com.example.CreateUserRequest`
(no Creators, like default constructor, exist):
cannot deserialize from Object value (no delegate- or property-based Creator)

解决:Maven 编译插件加 <compilerArg>-parameters</compilerArg>,或 spring-boot-maven-plugin 默认已带 -parameters;同时注册 ParameterNamesModule。这是 record 用于 Web 层最高频的报错。

进阶:紧凑构造器里的几个隐蔽陷阱

陷阱 1:校验顺序导致 NPE 而不是友好消息

public record Range(int from, int to, String label) {
    public Range {
        if (from > to) {
            throw new IllegalArgumentException("from > to");
        }
        label = label.strip(); // label 为 null 时这里抛 NPE
    }
}

NPE 来自 label.strip(),消息为空。把判空放在使用之前,或者直接用 Objects.requireNonNull。

陷阱 2:equals/hashCode 依赖规范化后的字段

record 生成的 equals 基于字段值。如果两个对象分别是 "a" 和 " a ",但构造时 strip 了,它们就相等——这通常是想要的。但若你只在访问器里做规范化(name() { return name.strip(); }),字段值仍是原始值,equals 会判不等,导致 Set 去重失效。规范化必须发生在构造阶段。

陷阱 3:在紧凑构造器里调用可被子类覆盖的方法——record 没有子类,但仍有坑

record 是 final 的,不存在继承覆盖问题。但如果你在构造器里把 this 传给外部(注册监听器、加入静态缓存),会造成「未完成初始化对象逸出」。紧凑构造器同样要遵守这条铁律。

陷阱 4:反序列化框架绕过紧凑构造器

部分老版本序列化库(如某些 JSON 库在 Field 反射模式下)可能用 Unsafe 或反射写 final 字段。JDK 17 起对 final 字段的反射写入有限制,典型报错:

java.lang.IllegalAccessException: Can not set final java.lang.String field
com.example.User.name to java.lang.String

升级到支持 record 的库版本(Jackson 2.12+、Gson 2.10+),让它们走规范构造器。不要试图用 setAccessible(true) 强行写 final 字段。

陷阱 5:紧凑构造器里抛异常打断 JIT 内联?

不必担心。IllegalArgumentException 的构造路径本就是冷路径,JDK 的 Objects.checkIndex 等 API 都采用同样模式。校验成本远低于一次对象分配。

完整可运行示例

把上面的要点合成一个可以直接 javac 运行的文件(JDK 17+,无第三方依赖):

import java.util.List;
import java.util.Locale;
import java.util.Objects;

public class RecordDemo {

    public record Email(String value) {
        public Email {
            Objects.requireNonNull(value, "value must not be null");
            value = value.strip().toLowerCase(Locale.ROOT);
            if (!value.contains("@") || value.startsWith("@") || value.endsWith("@")) {
                throw new IllegalArgumentException("invalid email: " + value);
            }
        }
    }

    public record User(String name, Email email, List<String> tags) {
        public User {
            if (name == null || name.isBlank()) {
                throw new IllegalArgumentException("name must not be blank");
            }
            name = name.strip();
            if (name.length() > 32) {
                throw new IllegalArgumentException("name too long: " + name.length());
            }
            Objects.requireNonNull(email, "email must not be null");
            if (tags == null) {
                throw new IllegalArgumentException("tags must not be null");
            }
            for (String t : tags) {
                if (t == null || t.isBlank()) {
                    throw new IllegalArgumentException("tags contains blank element");
                }
            }
            tags = List.copyOf(tags);
        }

        public User(String name, String email) {
            this(name, new Email(email), List.of());
        }
    }

    public static void main(String[] args) {
        User u = new User("  alice  ", " ALICE@Example.COM ");
        System.out.println(u.name());          // alice
        System.out.println(u.email().value()); // alice@example.com
        System.out.println(u.tags());          // []

        try {
            new User("   ", "a@b.com");
        } catch (IllegalArgumentException e) {
            System.out.println("caught: " + e.getMessage());
        }

        try {
            new User("bob", "not-an-email");
        } catch (IllegalArgumentException e) {
            System.out.println("caught: " + e.getMessage());
        }

        User x = new User("alice", "alice@example.com");
        System.out.println("equals after normalize: " + u.equals(x)); // true
        System.out.println("hashCode equal: " + (u.hashCode() == x.hashCode()));
    }
}

编译与运行:

javac --release 17 RecordDemo.java
java RecordDemo

输出:

alice
alice@example.com
[]
caught: name must not be blank
caught: invalid email: not-an-email
equals after normalize: true
hashCode equal: true

注意最后两行:两个「看起来不同」的输入因规范化而相等,这正是把规范化放进紧凑构造器、而不是放进访问器的价值所在。

小结

  • 紧凑构造器是唯一汇聚点,校验和规范化都应该放这里;所有委托构造器最终都会走到它。
  • 块内改的是参数变量,不是字段;写 this.name = name; 必然编译失败(cannot assign a value to final variable)。
  • 集合组件要用 List.copyOf / Set.copyOf 做防御性拷贝,否则不可变性是假的。
  • 异常类型默认用 IllegalArgumentException,null 用 Objects.requireNonNull 并带上组件名,消息里包含非法值。
  • 和 Bean Validation 分工:注解管提示,构造器管不变量。
  • Web 层记得开 -parameters,并处理 HttpMessageNotReadableException,否则 400 会变成 500 或反序列化失败。

FAQ

Q1:紧凑构造器里能给参数赋默认值吗?

能,直接给参数变量赋新值,例如 size = size <= 0 ? 10 : size;。但不能调用 this(...),紧凑构造器内禁止显式委托。

Q2:为什么我写了紧凑构造器,Sonar 仍报「字段可能为 null」?

多半是你只做了 if (x == null) x = ""; 却没在类型上体现。静态分析工具不追踪参数重赋值。改用 Objects.requireNonNull 或把可空性写进方法签名使用 Optional(注意:record 组件用 Optional 会有序列化争议,多数团队倾向拒绝 null)。

Q3:record 能同时有紧凑构造器和规范构造器吗?

不能。二者是同一个构造器的两种写法,同时写会报 error: constructor User(String,int) is already defined in record User。

Q4:紧凑构造器里抛异常,会影响 equals 和 hashCode 吗?

不影响。异常路径下对象不产生,与 equals/hashCode 无关;但要注意规范化后字段值的差异会改变相等语义,这正是想要的行为。

Q5:JDK 版本对紧凑构造器有什么限制?

record 在 JDK 14 预览、JDK 16 正式(JEP 395)。JDK 16 之前用 --enable-preview。JDK 21 的 record 模式(record patterns)与紧凑构造器无冲突,可以放心用于 instanceof 解构。

Q6:为什么 Jackson 反序列化 record 报 no Creators, like default constructor, exist?

缺 -parameters 编译参数,或 Jackson 版本低于 2.12。加编译参数并确保 jackson-databind >= 2.12,通常还需要注册 ParameterNamesModule。