原创

List.copyOf 固定了哪些内容:订单明细快照的三层边界

把一份订单明细保存为 List.copyOf(lines),随后原订单调了数量,历史明细里的数量也变了。列表没有增加或删除元素,却仍然算不上业务快照:它固定了元素引用的序列,没有复制这些引用指向的对象状态。

因此“列表不能修改”至少要拆开看:结构是否可以改变,列表是否跟随原容器变化,元素自己的状态是否还会变化。它们不是同一条保证。下面用同一份源数据,比较只读视图、浅复制和面向展示的值投影。

import java.util.*;

public class SnapshotBoundary {
    static final class Line {
        final String sku;
        int quantity;
        Line(String sku, int quantity) { this.sku = sku; this.quantity = quantity; }
        @Override public String toString() { return sku + ":" + quantity; }
    }
    record LineView(String sku, int quantity) {}
    public static void main(String[] args) {
        Line first = new Line("A", 1);
        List<Line> source = new ArrayList<>();
        source.add(first);
        List<Line> view = Collections.unmodifiableList(source);
        List<Line> shallow = List.copyOf(source);
        List<LineView> values = source.stream()
                .map(x -> new LineView(x.sku, x.quantity)).toList();
        source.add(new Line("B", 2));
        System.out.println("sizes=" + view.size() + "," + shallow.size() + "," + values.size());
        first.quantity = 7;
        System.out.println("view=" + view);
        System.out.println("shallow=" + shallow);
        System.out.println("values=" + values);
        try { shallow.add(new Line("C", 3)); }
        catch (UnsupportedOperationException e) { System.out.println("structure=locked"); }
        try { List.copyOf(Arrays.asList(first, null)); }
        catch (NullPointerException e) { System.out.println("null=rejected"); }
        if (values.get(0).quantity() != 1 || shallow.get(0).quantity != 7)
            throw new AssertionError("snapshot boundary changed");
    }
}

把代码保存为 SnapshotBoundary.java,Java 17 下运行 java SnapshotBoundary.java,得到:

sizes=2,1,1
view=[A:7, B:2]
shallow=[A:7]
values=[LineView[sku=A, quantity=1]]
structure=locked
null=rejected

三个 sizes 分别对应只读视图、List.copyOf 和值投影。源列表加上 B 后,只读视图能看到它;两个新列表的结构保持原样。接下来修改 A 的 quantity,浅复制列表仍持有同一个 Line,于是也看到 7;LineView 保存了投影时的字符串和整数值,保持 1。

这里的 record 有意只使用不可变字符串和基本类型。record 并不会自动递归冻结成员;若把可变 Line 或数组塞进 record,只是把同一问题移到另一层。快照边界要逐字段说明,不能靠类名里带 Snapshot 或使用某个语法形式就结束判断。

先确定快照是给谁看的

展示订单只需要 SKU 和数量,值投影足够,也避免把领域对象里的其他可变字段泄露给调用方。若快照还要用于结算或审计,就应额外保存对应业务需要的价格、版本和规则标识;单纯复制容器不能替代这些数据。字段选择取决于快照的用途,而不是“所有属性都深拷贝一遍”这个机械目标。

对于对象图,盲目递归复制也有成本和语义问题。两个明细可能引用同一个商品对象,图里还可能有循环;到底保留共享身份还是拆成独立值,必须先决定。这里用有限字段的不可变值作为出口,使合同可以直接用断言表达,避免为一个读取接口引入通用对象图克隆工具。

程序还验证 List.copyOf 拒绝 null 元素。这与有些只读包装的接收范围不同,迁移时不能把它当作完全等价替换。若历史数据允许 null,应先决定 null 表示缺失、占位还是坏数据,再在边界处理;随手过滤可能改变明细位置和数量。

从草稿导出一份能保存的版本

接到订单确认页时,可以让可变Draft只在自己的同步边界内修改,再导出一份带版本的值列表。下面给明细增加备注列表,故意把嵌套复制问题放回来:LineValue构造时复制notes,Snapshot构造时复制明细序列,所有留下的元素都是字符串、整数和不可修改的值列表。

保存 OrderSnapshots.java 后在 Java 17 运行。先保存版本一,再修改草稿数量与备注,检查旧快照有没有漂移:

import java.util.*;
public class OrderSnapshots {
    static final class Line {
        final String sku;
        int quantity;
        final List<String> notes;
        Line(String sku,int quantity,List<String> notes) {
            this.sku=sku;this.quantity=quantity;this.notes=new ArrayList<>(notes);
        }
    }
    record LineValue(String sku,int quantity,List<String> notes) {
        LineValue { notes=List.copyOf(notes); }
    }
    record Snapshot(long version,List<LineValue> lines) {
        Snapshot { lines=List.copyOf(lines); }
    }
    static final class Draft {
        private final List<Line> lines=new ArrayList<>();
        private long version;
        private Line find(String sku) { return lines.stream().filter(x->x.sku.equals(sku)).findFirst().orElseThrow(); }
        synchronized void add(String sku,int quantity,List<String> notes) {
            if (sku==null || sku.isBlank() || quantity<1 || quantity>999) throw new IllegalArgumentException("invalid line");
            Line candidate=new Line(sku,quantity,List.copyOf(notes));
            long next=Math.incrementExact(version);
            lines.add(candidate);version=next;
        }
        synchronized void quantity(String sku,int quantity) {
            if (quantity<1 || quantity>999) throw new IllegalArgumentException("invalid quantity");
            Line line=find(sku);long next=Math.incrementExact(version);
            line.quantity=quantity;version=next;
        }
        synchronized void note(String sku,String text) {
            Objects.requireNonNull(text);Line line=find(sku);long next=Math.incrementExact(version);
            line.notes.add(text);version=next;
        }
        synchronized Snapshot snapshot() {
            List<LineValue> values=lines.stream().map(x->new LineValue(x.sku,x.quantity,x.notes)).toList();
            return new Snapshot(version,values);
        }
    }
    public static void main(String[] args) {
        Draft draft=new Draft();draft.add("A",2,List.of("fragile"));
        Snapshot saved=draft.snapshot();
        draft.quantity("A",9);draft.note("A","priority");
        Snapshot current=draft.snapshot();
        System.out.println("saved version="+saved.version()+", quantity="+saved.lines().get(0).quantity()+", notes="+saved.lines().get(0).notes());
        System.out.println("current version="+current.version()+", quantity="+current.lines().get(0).quantity()+", notes="+current.lines().get(0).notes());
        try { saved.lines().get(0).notes().add("changed"); }
        catch(UnsupportedOperationException e) { System.out.println("nested mutation=rejected"); }
        long before=draft.snapshot().version();
        try { draft.quantity("A",0); }
        catch(IllegalArgumentException e) { System.out.println("invalid change kept version="+(before==draft.snapshot().version())); }
        if(saved.lines().get(0).quantity()!=2 || saved.lines().get(0).notes().size()!=1) throw new AssertionError("snapshot drift");
    }
}
saved version=1, quantity=2, notes=[fragile]
current version=3, quantity=9, notes=[fragile, priority]
nested mutation=rejected
invalid change kept version=true

原草稿达到版本三,旧快照仍保留数量二和原备注。尝试修改旧快照的备注会被拒绝,无效数量也不会消耗版本。与最开始仅做List.copyOf的浅复制相比,这里明确复制了业务实际需要的第二层可变列表,而不是宣称已经递归克隆所有未来字段。

snapshot与所有草稿修改都通过同一个对象监视器,读取数量、备注和版本发生在一个一致边界内。草稿的明细容器和查找方法保持private,也没有向调用方暴露内部Line,所以调用方不能经由返回值绕过同步直接修改草稿。若从已有项目接入,应复用它所属聚合或服务的并发边界;只给snapshot加synchronized,却让其他代码无锁改字段,不会得到这份保证。

版本号用来解释这份值来自哪个草稿状态,不自动防止旧页面覆盖新数据。提交确认时仍要把期望版本与当前版本核对,并由实际持久化机制原子执行更新。这里只完成内存快照导出,没有伪装成数据库乐观锁或完整结算事务。

新增字段时也要重新审视复制范围。BigDecimal这类不可变数值可以共享,byte数组、Date或其他可变明细则需要专门投影或防御复制;带文件句柄、连接或锁的资源,更不应该因为“整单快照”就被复制到页面模型中。把值出口限制在能解释的数据上,新增需求时更容易发现遗漏。

本例仍保留原始SKU字符串,而不是在快照过程中做大小写或编码规范化。若订单身份规则要求规范化,应在草稿接收边界完成并保存明确结果;导出快照最好只描述当时已确定的业务值。一个读取动作顺手改写身份,会让旧版本与当前版本之间的差异更难追踪。

复制动作发生在哪个时刻

即使返回的值最终不可变,读取源对象的过程也可能与写线程交错。先读 SKU 再读数量,不会自动获得一个跨字段的一致版本;遍历源 ArrayList 同时发生结构写入,也不属于安全快照协议。需要在所属聚合的同步边界内构建值对象,或从已有不可变版本投影,而不能把 copyOf 当作并发控制。

发布这份值列表同样需要正确的线程间可见性机制。单线程示例只验证结构和引用关系,不能证明任意共享变量赋值都建立了安全发布。服务可通过受控缓存、volatile 引用或同步边界发布完整不可变版本,具体选择取决于它已有的并发设计。

List.copyOf 也不承诺总是分配一个新列表对象;输入满足条件时可以复用现有不可变容器。代码因此只检查数据合同,不比较返回值是否与源对象同一个引用。需要可独立修改的新容器时,应该明确创建可变副本;需要稳定历史值时,应该保存值本身。一个“copy”字样覆盖不了这两种不同用途。

正文到此结束
评论插件初始化中...
Loading...
本文目录