news 2026/10/2 1:56:29

Java 设计模式之 Abstract Document(抽象文档模式):在强类型语言中实现动态属性与类型安全的平衡

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java 设计模式之 Abstract Document(抽象文档模式):在强类型语言中实现动态属性与类型安全的平衡
  • 示例工程
  • 教程

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

Design patterns implemented in Java

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

导读

Abstract Document 是 java-design-patterns 项目中收录的结构型(Structural)设计模式之一,其核心思想是:让对象以“键值对(key-value)存储”的方式持有属性,同时通过一组名为trait(特质)的接口对外暴露**类型安全(type-safe)**的静态访问视图。本文以 localization/vi/abstract-document/README.md 为主线,结合仓库中 abstract-document 模块的完整源码与测试,讲解该模式的目标、类结构、逐行实现、运行效果与适用场景。读完本文,你将能独立实现一个既能在运行时动态增删属性、又能保持编译期类型安全的 Java 数据模型。

一、模式目标:兼得“动态”与“类型安全”

Java 是一门强类型语言,属性通常在编译期就已固定。而 Abstract Document 模式的**目标(Mục tiêu)**正是:

在享受无类型(弱类型、动态)语言灵活性的同时,保留强类型语言的类型安全特性。

一句话概括(A simple way)这个模式的做法:

允许向对象附加属性,而对象本身完全不必知道这些属性的存在。

维基百科对该模式的经典描述(原文文档中引用)进一步明确了它的定位:

这是一种面向对象的结构型设计模式,用于将对象组织成弱类型的键值存储,并通过**类型化视图(typed views)**暴露数据。其目的是在强类型语言中实现组件之间的高度灵活性——可以在运行时(on the fly)向对象树添加新属性,同时不丢失类型安全的支持。模式利用 trait 将类的不同属性分离到不同接口中。

从仓库源码的类注释可以看到同样的表述,见 AbstractDocument.java:

The Abstract Document pattern enables handling additional, non-static properties. This pattern uses concept of traits to enable type safety and separate properties of different classes into set of interfaces.

即:核心矛盾是“运行时灵活性”与“编译期安全”的冲突,而模式给出的解药是——底层用 Map 存储一切,上层用接口(trait)定义访问方法。

二、现实场景:为什么需要动态属性

原文文档用汽车举例:

一辆汽车由许多零部件组成。但我们事先并不知道某一辆具体的车到底包含哪些部件——是全部,还是只有其中一部分。可以说,这些汽车是动态的、极其灵活的。

这个例子精确地刻画了该模式的适用场景:实体集合具有共性,但每个实体的属性集在运行时才确定。图书馆系统中纸质书、电子书、有声书各具不同的字段(页数、文件大小、时长)是同一个道理——用一组公共属性 + 各自的扩展属性来建模,而不是为每种书各写一个死板的类。

三、类结构:三层架构拆解

原文文档给出了类图:abstract-document.png("Abstract Document Traits and Domain")。

Abstract Document 模式的 Traits 与领域类图

结合源码 abstract-document/src,整个模式的类结构可分为三层:

1. 核心抽象层:Document接口与AbstractDocument抽象类

包路径com.iluwatar.abstractdocument,位于 Document.java 与 AbstractDocument.java。

public interface Document { Void put(String key, Object value); Object get(String key); <T> Stream<T> children(String key, Function<Map<String, Object>, T> constructor); }

接口只声明三个方法:put写入属性、get读取属性、children递归取出子文档流。AbstractDocument负责实现它们:

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); } // toString() 亦已实现…… }

逐点说明这层设计的精妙之处:

  • 存储即 Map:所有属性以Map<String, Object>保存,天然支持任意类型、任意键,这就是“无类型 / 动态”的一面。
  • 构造约束:构造函数用Objects.requireNonNull(properties, "properties map is required")强制属性表非空,测试 AbstractDocumentTest.java 中的shouldHandleExceptionDuringConstruction正是验证传入null会抛出NullPointerException。
  • children的管道式实现:先把get(key)包进Stream.ofNullable过滤空值,再强转为List<Map<String, Object>>,取第一个元素展平为流,最后用传入的childConstructor函数把每份属性表构造为子对象。注意findAny().stream()保证了当键不存在或值不是列表时返回空流而非报错——对应测试 shouldRetrieveEmptyStreamForNonExistingChildren。
  • toString附加实现:AbstractDocument还重写了toString(),将属性表格式化为类名[键 : 值, ...]形式(见 AbstractDocument.java),测试shouldIncludePropsInToString对其做了断言。

2. 属性枚举层:Property枚举

Property.java 集中定义属性键,避免魔法字符串散落各处:

public enum Property { PARTS, TYPE, PRICE, MODEL }

在后续代码中统一通过Property.TYPE.toString()等方式取键名,枚举常量即成为 Map 键的“字典”。

3. trait 视图层:四个接口

包路径com.iluwatar.abstractdocument.domain,每个接口都继承Document,并用default 方法把 Map 中的原始值包装成类型化视图:

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); } }

这里的“trait”正是维基百科定义中提到的核心机制:每个接口负责一种属性的类型化访问。Optional<T>的返回值让缺失属性变得显式、安全,调用方可以链式使用orElseThrow()/orElse(null)处理缺省情况;getParts()则复用children管道,将子属性表逐个构造成Part对象。

4. 领域实体层:Car与Part

两个实体都只是“继承 + 组合 trait”的极薄类:

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

对应源码:Car.java、Part.java。Car组合HasModel/HasPrice/HasParts三个 trait,Part组合HasType/HasModel/HasPrice。注意领域类本身没有声明任何字段、没有实现任何访问方法——所有“看起来是静态方法”的getModel()、getParts()都来自 trait 的 default 方法,这就是“对象不知道属性存在”的具象体现。

从 UML 源文件 abstract-document.urm.puml 中可以看到:AbstractDocument实现Document,Car、Part继承AbstractDocument并分别实现对应 trait 接口,四个 trait 接口又全部继承Document,形成完整的菱形继承体系。

四、完整示例:构造一辆“动态汽车”

原文文档给出了从零件到整车的完整构造过程,仓库中的入口类 App.java 与之一一对应:

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))); }

值得注意的实现细节:

  • 嵌套结构即对象树:carProperties的PARTS键值是一个List.of(wheelProperties, doorProperties),这正是“树形层级数据”的天然表达;Car是第一层节点,Part是第二层子节点,且两层共用完全相同的 Map 驱动方式,可以继续无限嵌套。
  • Map.of 的限制:示例使用Map.of构造属性表,它最多支持 10 对键值且不可为 null;当属性更多时改用HashMap或Map.ofEntries即可。
  • 运行时增删属性:由于底层就是 Map,调用car.put("color", "red")即可在运行时动态追加属性,而Car类本身无需任何改动——这正是“on the fly 添加属性”的直接验证。

运行后的输出(原文文档与 App 源码一致):

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

类型化的getModel().orElseThrow()返回String,getPrice().orElseThrow()返回Number,getParts()返回Stream<Part>——调用方拿到的是强类型视图,而非原始 Object,类型安全由此保证。

五、何时使用 Abstract Document 模式

原文文档的“应用(Ứng dụng)”一节给出了三条判据,满足其一即可考虑该模式:

  1. 有在运行时(on the fly)添加新属性的需求;
  2. 希望以树形结构灵活组织业务数据;
  3. 希望系统耦合更松散(loose coupling)。

结合英文版 README(abstract-document/README.md)的扩展论述,以下真实场景与之一一呼应:

  • 内容管理系统(CMS):文章、图片、视频共享“创建时间/作者/标签”,又各有“图片分辨率/视频时长”等专属字段;
  • 文件系统:文档、图片、音频、目录共享“文件大小/创建时间”,又各有“分辨率/时长”等扩展属性;
  • 电商平台:实物商品、数字下载、订阅共享“名称/价格/描述”,又各有“运费/下载链接”等差异字段;
  • 医疗档案:人口学信息、病史、检验结果、处方共享“患者 ID/出生日期”,各自又有专属数据;
  • 配置管理:不同类型配置元素各有属性集,但需要统一一致的访问与操作方式;
  • 教育平台 / 项目管理工具:不同形态的学习材料、不同类型的任务(待办、里程碑、缺陷)都存在“共性 + 个性”的属性结构。

共同特征可以概括为:属性结构多样化且在演进(diverse and evolving attribute structures)、动态添加属性是常态、数据访问需要与具体格式解耦、可维护性与灵活性至关重要。

六、收益与代价

原文文档在“收益与取舍”维度给出的结论,也完全符合本仓库实现:

收益(Benefits)

  • 灵活性(Flexibility):容纳各种不同的文档结构与属性;
  • 可扩展性(Extensibility):动态新增属性而不破坏既有代码——只需向 trait 体系追加一个新接口,或直接向 Map 写入新键;
  • 可维护性(Maintainability):trait 将“属性访问”按关注点分离,代码整洁且易适配;
  • 可复用性(Reusability):类型化视图使同一套访问逻辑可以在多个实体间复用(如HasModel同时服务于Car与Part)。

代价(Trade-offs)

  • 复杂度(Complexity):需要为每个属性定义接口与视图,带来额外的实现开销;
  • 性能(Performance):相比直接字段访问,多了一层 Map 查找、类型转换与 Stream 管道开销;对于高频热路径需要权衡(从children的实现可推断,每次调用都会经过ofNullable → filter → map → findAny → flatMap → map整条管道)。

七、如何运行与验证

本仓库为 Maven 多模块工程,abstract-document是独立子模块,可直接运行入口类验证模式行为:

# 在仓库根目录运行该模块的入口类(App.main) ./mvnw -pl abstract-document compile exec:java \ -Dexec.mainClass="com.iluwatar.abstractdocument.App" # 或运行该模块的全部单元测试 ./mvnw -pl abstract-document test

测试目录 abstract-document/src/test/java/com/iluwatar/abstractdocument 中的三个测试类覆盖了模式的关键行为:

  • AbstractDocumentTest:put/get读写、子文档流数量、缺失键返回空流、toString包含键值、空属性表抛NPE、嵌套文档存取、值覆盖更新;
  • DomainTest:针对Car/Part领域实体的类型化视图访问;
  • AppTest:入口类冒烟测试。

八、参考资料

原文文档给出的参考文献(外部链接请自行查阅原文档或相关书籍):

  • Wikipedia: Abstract Document Pattern
  • Martin Fowler: Dealing with properties
  • Pattern-Oriented Software Architecture Volume 4: A Pattern Language for Distributed Computing (v. 4)

仓库内可继续深入研读的还包括英文版 abstract-document/README.md(含更详尽的适用场景与收益取舍分析)、类图源文件 abstract-document.urm.puml 以及完整源码目录 abstract-document/src/main/java/com/iluwatar/abstractdocument。

总结

Abstract Document 模式用一句话可以概括为:底层一张 Map,上层一堆 trait 接口。它把“动态属性存储”交给Map<String, Object>,把“类型安全访问”交给继承Document的 default 方法接口,再由具体实体(Car、Part)通过多重实现自由组合所需视图。当你的业务模型属性高度动态、结构呈树形且需要松散耦合时,这个模式提供了一条不牺牲类型安全的灵活路径——正如仓库中的汽车示例所示,一辆车到底有没有某个零件、有哪些零件,完全可以等到运行时再决定。

  • 示例工程
  • 教程

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

Design patterns implemented in Java

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

相关推荐

上一篇:Gifski内存占用优化:处理4K视频时避免Mac卡顿的实用技巧
下一篇:ComfyUI 视频生成插件入门:从一条文字到一段视频的完整路径

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 1:55:10

Win10产品密钥查找原理与安全提取实战指南

1. 项目概述&#xff1a;为什么Win10产品密钥查找这件事&#xff0c;比你想象中更值得深挖Win10怎么查找产品密钥&#xff1f;这个问题看似简单&#xff0c;但背后藏着Windows激活机制、系统安全边界、硬件绑定逻辑和用户数据主权的多重博弈。我做系统部署和企业IT支持十多年&a…

作者头像 李华
网站建设 2026/10/2 1:54:56

基于C#与MySQL的仓库管理系统实战:从建库到入库单落地的完整路径

简介&#xff1a;这份资源是一套基于C#与MySQL数据库开发的仓库管理系统完整项目包&#xff0c;面向学习C#面向对象编程、数据库设计及企业级应用开发的学生与开发者&#xff0c;可用于课程设计、毕业设计或自学实践。压缩包共163个文件&#xff0c;约1.22MB&#xff0c;以49个…

作者头像 李华