- 示例工程
- 教程
【免费下载链接】java-design-patterns
Design patterns implemented in Java
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); }三个方法的职责非常清晰:
| 方法 | 签名 | 作用 |
|---|---|---|
put | Void put(String key, Object value) | 向属性 Map 写入键值;返回Void以允许接口默认方法链式使用 |
get | Object 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):
Stream.ofNullable(get(key)):把可能为null的原始值包装成空流或单元素流,天然处理"该键不存在"的情况;filter(Objects::nonNull):过滤掉null,即使键存在但值为空也不报错;map(el -> (List<Map<String, Object>>) el):把原始值强制转型为子文档列表——这是该实现中唯一一处"信任数据格式"的强转点;findAny().stream():把Optional再转回流,避免空 Optional;flatMap(Collection::stream):摊平多个子文档;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 - 嵌套文档:
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
相关推荐
Java 设计模式实战:Abstract Document 抽象文档模式——用类型安全的方式动态管理树状属性结构
Java 设计模式实战:Abstract Document 抽象文档模式——用类型安全的方式动态管理树状属性结构 本篇技术指南以 java design pat
示例工程教程Amplication `@amplication/util/git` 实战指南:GitClientService 统一封装与多 Git 平台接入
Amplication @amplication/util/git 实战指南:GitClientService 统一封装与多 Git 平台接入 导读 @ampl
示例工程教程Abstract Document 抽象文档模式实战:在强类型 Java 中实现动态属性扩展(java-design-patterns 源码详解)
Abstract Document 抽象文档模式实战:在强类型 Java 中实现动态属性扩展(java design patterns 源码详解) Abstra
示例工程教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考