☰
Java 设计模式实战:Abstract Document(抽象文档)模式——在类型安全下实现动态属性与灵活数据树
2026/10/2 1:38:34 网站建设 项目流程
  • 示例工程
  • 教程

【免费下载链接】java-design-patterns

Design patterns implemented in Java

项目地址:https://gitcode.com/GitHub_Trending/ja/java-design-patterns
点击查看免费下载

Abstract Document(抽象文档)是一种结构型设计模式,它通过"弱类型键值存储 + 强类型 trait 视图"的组合,让 Java 这种强类型语言能够像动态语言一样随时向对象树添加新属性,同时保留编译期的类型安全。本文以 abstract-document 模块的完整源码与测试为佐证,从意图、核心抽象、trait 接口、Car 实战示例、适用场景到收益取舍,带你彻底掌握该模式。

模式的意图(Intent)

在强类型语言中处理层次化、树状的数据结构往往很繁琐:每种节点都要定义一套固定的字段,新增属性意味着改类、改构造器、改序列化逻辑。Abstract Document 模式的核心意图是:

使用动态属性(key-value 存储)获得弱类型语言的灵活性,同时通过 trait(特质接口)保持类型安全。

通俗地说,该模式允许对象在"不知道自己拥有哪些属性"的情况下被附加属性。数据以Map<String, Object>的形式松散存放,而对外访问则通过一组小而专一的 trait 接口以静态、类型安全的方式呈现。正如维基百科对 Abstract Document 模式的定义:

它是一种面向对象的结构型设计模式,用于把对象组织在松散类型的键值存储中,并通过类型化视图(typed views)暴露数据。其目的是在强类型语言中让组件之间获得高度的灵活性——对象树可以在运行时动态新增属性,同时不丧失类型安全支持。模式利用 traits 把类的不同属性拆分为不同接口。

真实世界类比:一辆"零件不确定"的汽车

文档用汽车作为示例:一辆车由多个零件组成,但我们事先并不知道某辆具体车到底拥有全部零件,还是只拥有其中一部分。车是动态的、极其灵活的——这正是 Abstract Document 模式要解决的场景。

  • 某些属性(如MODEL、PRICE)是静态的、一定存在的;
  • 另一些属性(如PARTS列表)是动态的、数量与结构不确定的;
  • 每个零件(Part)自身又拥有TYPE、MODEL、PRICE等属性,构成一棵递归的树。

用一句话概括:Abstract Document 模式让对象在不知情的情况下被附加属性。

核心抽象:Document 接口与 AbstractDocument 基类

模式的地基是Document接口与AbstractDocument抽象类,源码位于 Document.java 与 AbstractDocument.java。

Document接口只定义三个操作,覆盖了"单值读写"与"子文档遍历"两大能力:

public interface Document { /** * Puts the value related to the key. * * @param key element key * @param value element value * @return Void */ Void put(String key, Object value); /** * Gets the value for the key. * * @param key element key * @return value or null */ Object get(String key); /** * Gets the stream of child documents. * * @param key element key * @param constructor constructor of child class * @return child documents */ <T> Stream<T> children(String key, Function<Map<String, Object>, T> constructor); }

三个方法的职责非常清晰:

方法签名作用
putVoid put(String key, Object value)向属性 Map 写入键值;返回Void以允许接口默认方法链式使用
getObject get(String key)按键读取原始值,不存在时返回null(注意接口约定返回Object,由上层 trait 负责转型)
children<T> Stream<T> children(String key, Function<Map<String, Object>, T> constructor)按键取出"子文档列表",并用构造器函数把每个子 Map 转换为类型化对象,返回Stream<T>

AbstractDocument提供了基于Map的默认实现,它用Objects.requireNonNull强制校验属性 Map 非空(源码 AbstractDocument.java):

public abstract class AbstractDocument implements Document { private final Map<String, Object> documentProperties; protected AbstractDocument(Map<String, Object> properties) { Objects.requireNonNull(properties, "properties map is required"); this.documentProperties = properties; } @Override public Void put(String key, Object value) { documentProperties.put(key, value); return null; } @Override public Object get(String key) { return documentProperties.get(key); } @Override public <T> Stream<T> children(String key, Function<Map<String, Object>, T> childConstructor) { return Stream.ofNullable(get(key)) .filter(Objects::nonNull) .map(el -> (List<Map<String, Object>>) el) .findAny() .stream() .flatMap(Collection::stream) .map(childConstructor); } }

children是模式中最精妙的一段逻辑,可以逐层拆解其流水线(源码 AbstractDocument.java):

  1. Stream.ofNullable(get(key)):把可能为null的原始值包装成空流或单元素流,天然处理"该键不存在"的情况;
  2. filter(Objects::nonNull):过滤掉null,即使键存在但值为空也不报错;
  3. map(el -> (List<Map<String, Object>>) el):把原始值强制转型为子文档列表——这是该实现中唯一一处"信任数据格式"的强转点;
  4. findAny().stream():把Optional再转回流,避免空 Optional;
  5. flatMap(Collection::stream):摊平多个子文档;
  6. map(childConstructor):用调用方传入的构造器引用(如Part::new)把每个子Map构造成类型化对象。

AbstractDocument还重写了toString(),遍历所有属性拼成类名[键 : 值, ...]的调试友好格式(源码 AbstractDocument.java),方便日志输出。

trait 视图:让动态存储"看起来是静态的"

光有 Map 存取还不够——直接写car.get("MODEL")容易拼错键名、丢失类型。模式的第二步是用trait 接口为特定属性提供类型安全的访问视图。仓库中用枚举Property统一管理键名(源码 Property.java):

/** Enum To Describe Property type. */ public enum Property { PARTS, TYPE, PRICE, MODEL }

每个 trait 都是一个继承Document的接口,通过default方法内部调用get(...)完成取值与转型,并把结果包装为Optional:

public interface HasType extends Document { default Optional<String> getType() { return Optional.ofNullable((String) get(Property.TYPE.toString())); } } public interface HasPrice extends Document { default Optional<Number> getPrice() { return Optional.ofNullable((Number) get(Property.PRICE.toString())); } } public interface HasModel extends Document { default Optional<String> getModel() { return Optional.ofNullable((String) get(Property.MODEL.toString())); } } public interface HasParts extends Document { default Stream<Part> getParts() { return children(Property.PARTS.toString(), Part::new); } }

对应源码:HasType.java、HasPrice.java、HasModel.java、HasParts.java。

观察要点:

  • 单值属性(TYPE、PRICE、MODEL)返回Optional<T>:键缺失或值为null时返回Optional.empty(),调用方用orElseThrow()/orElse(default)显式决策,规避空指针;
  • 集合属性(PARTS)返回Stream<Part>:直接复用children(...),键缺失时得到空流,可放心forEach;
  • trait 是可组合的:一个实体可以实现任意多个 trait,按需"点亮"自己拥有的属性视图,这正是模式"分离关注点"的精髓。

组装实体:Car 与 Part

有了地基与 trait,实体类变得异常简洁——它们几乎不需要任何业务逻辑,只声明"我拥有哪些视图"并转发构造参数(源码 Car.java 与 Part.java):

/** Car entity. */ public class Car extends AbstractDocument implements HasModel, HasPrice, HasParts { public Car(Map<String, Object> properties) { super(properties); } } /** Part entity. */ public class Part extends AbstractDocument implements HasType, HasModel, HasPrice { public Part(Map<String, Object> properties) { super(properties); } }

注意Car实现了HasModel, HasPrice, HasParts,而Part实现了HasType, HasModel, HasPrice——两棵不同层级、不同属性集合的节点,共享同一套AbstractDocument存储机制,无需任何继承耦合。

完整实战:构造一辆三层结构的汽车

程序入口在 App.java,完整演示了"零件 Map → 车辆 Map → Car 对象 → 递归遍历"的全流程:

public static void main(String[] args) { LOGGER.info("Constructing parts and car"); var wheelProperties = Map.of( Property.TYPE.toString(), "wheel", Property.MODEL.toString(), "15C", Property.PRICE.toString(), 100L); var doorProperties = Map.of( Property.TYPE.toString(), "door", Property.MODEL.toString(), "Lambo", Property.PRICE.toString(), 300L); var carProperties = Map.of( Property.MODEL.toString(), "300SL", Property.PRICE.toString(), 10000L, Property.PARTS.toString(), List.of(wheelProperties, doorProperties)); var car = new Car(carProperties); LOGGER.info("Here is our car:"); LOGGER.info("-> model: {}", car.getModel().orElseThrow()); LOGGER.info("-> price: {}", car.getPrice().orElseThrow()); LOGGER.info("-> parts: "); car.getParts() .forEach( p -> LOGGER.info( "\t{}/{}/{}", p.getType().orElse(null), p.getModel().orElse(null), p.getPrice().orElse(null))); }

运行主类(仓库为 Maven 多模块结构,可通过根目录的./mvnw执行abstract-document模块;Windows 下用mvnw.cmd)后的程序输出:

Constructing parts and car Here is our car: -> model: 300SL -> price: 10000 -> parts: wheel/15C/100 door/Lambo/300

这段代码值得逐行体会:

  • wheelProperties、doorProperties是弱类型的Map,键名来自Property枚举,值可以是String或Long;
  • carProperties的PARTS键直接放入List.of(wheelProperties, doorProperties),形成树状嵌套;
  • 读取端car.getModel()、car.getPrice()、car.getParts()全部走 trait 接口,强类型且无需关心底层 Map 细节;
  • 零件通过p.getType().orElse(null)取值,缺属性也不会抛异常。

类图:traits 与领域对象的关系

下图展示了该模式在仓库中的类结构——Document作为统一抽象,AbstractDocument提供实现,HasType/HasPrice/HasModel/HasParts四个 trait 从Document派生,Car与Part组合继承多个 trait:

对应 PlantUML 源文件位于 abstract-document.urm.puml,可自行生成/查看高分辨率版本。

测试验证:模式行为的边界保障

仓库的测试代码直接印证了模式的关键行为边界,值得阅读 AbstractDocumentTest.java 与 DomainTest.java:

  • 读写与更新:shouldPutAndGetValue、shouldUpdateExistingValue验证put/get语义,同键覆盖即更新;
  • 子文档流:shouldRetrieveChildren验证children(...)能把 List转换为对象流并计数;shouldRetrieveEmptyStreamForNonExistingChildren验证键不存在时返回空流而非异常;
  • 嵌套文档:shouldPutAndGetNestedDocument验证任意对象(包括另一个Document实例)都可作为属性值嵌套;
  • 空属性防御:shouldHandleExceptionDuringConstruction验证构造时传入nullMap 会抛出NullPointerException(来自Objects.requireNonNull);
  • 领域组装:DomainTest.shouldConstructCar验证Car能容纳两个空 Map 零件并正确返回数量为 2 的零件流。

这些测试精确刻画了模式的两大承诺:动态性(运行时增删属性)与健壮性(缺失键不崩溃)。

何时使用 Abstract Document 模式

从文档与源码可以总结出以下适用信号:

  • 需要立即(运行时)添加新属性:如配置文件、插件化数据结构,属性集不固定;
  • 想把领域组织成树状结构:文档/零件/子文档递归嵌套,且每层属性集合不同;
  • 希望系统更松散耦合:数据访问与具体格式解耦,新增一种"文档类型"不必改动既有存储逻辑;
  • 文档/实体的属性结构持续演进:共享属性(如创建时间、作者、价格)与独有属性(如视频时长、图片分辨率)并存。

更具体的应用场景包括:内容管理系统(文章/图片/视频属性各异)、文件系统(文档/图片/音频/目录统一管理)、电商平台(实物/数字商品/订阅)、医疗记录系统(人口学/病史/检验结果/处方)、配置管理、教育平台(文本/视频/测验/作业)与项目管理工具(待办/里程碑/问题单)等。

收益与代价(Trade-offs)

收益(Benefits):

  • 灵活性:兼容多变、演进的文档结构与属性集合;
  • 可扩展性:动态添加新属性而无需破坏既有代码——加一个 trait、加一个枚举值即可;
  • 可维护性:关注点分离(存储 vs 视图),代码更清晰、更易适配;
  • 可复用性:类型化视图(trait)可被多个实体复用,访问特定属性类型时无需重复逻辑。

代价(Trade-offs):

  • 复杂度:需要为属性定义接口与视图,增加实现开销,简单场景下显得"过度设计";
  • 性能:相比直接字段访问,Map 查找 + 强转 + Optional 包装会带来轻微性能损耗,不适合对单次访问延迟极敏感的热路径;
  • 类型安全是"约定式"的:children内部的强转依赖调用方传入正确格式的 List,若数据源不可信,需在构造时校验。

小结

Abstract Document 模式给出了一个优雅的折中:存储层用弱类型的 Map 换取无限动态性,访问层用 trait 接口换回强类型的编译期安全。Document/AbstractDocument是地基,Property枚举统一键名,HasXxxtrait 提供类型化视图,Car/Part通过接口组合自由拼装属性集。如果你正在设计配置体系、内容模型或任何属性不断演进的树状数据,可以复用本模块的源码骨架与测试范式,快速落地。

延伸阅读参考:该模式概念源自《Pattern-Oriented Software Architecture Volume 4: A Pattern Language for Distributed Computing (v. 4)》,并可与 Martin Fowler 关于"处理属性(Dealing with Properties)"的思路互相印证。

  • 示例工程
  • 教程

【免费下载链接】java-design-patterns

Design patterns implemented in Java

项目地址:https://gitcode.com/GitHub_Trending/ja/java-design-patterns
点击查看免费下载

相关推荐

上一篇:推荐:Obsidian PDF++,革新你的PDF学习与研究体验
下一篇:【亲测免费】 项目推荐:Komorebi——为Linux带来生动壁纸的神器

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询