原文:Design Patterns and Best Practices in Java
协议:CC BY-NC-SA 4.0
贡献者:飞龙
本文来自【ApacheCN Java 译后编辑(MTPE)尽可能提高流程效率。
本章的目的是学习结构模式。结构模式是利用对象与类别之间的关系创建复杂结构的模式。大多数结构模式都是基于继承的。本章只关注以下内容 GOF 模式:
- 适配器模式
- 代理模式
- 桥接模式
- 装饰模式
- 复合模式
- 外观模式
- 享元模式
我们可能无法详细介绍其他已确定的结构模式,但值得理解。具体如下:
- :使用空接口标记特定类(如
Serializable
),因此,您可以根据接口名进行搜索。有关更多信息,请阅读第一篇文章 37 项 -乔舒亚使用标记界面定义类型·布洛赫的《Effective Java(二版)。 - :将类分组,实现软件模块的概念。模块化结构包种模式,Kirk knorenschild 对此进行了清晰的解释。Java9 模块就是这种模式的例子,请参考这个页面。
- :在运行时改变现有的对象接口。更多信息请访问这个页面。
- :这为不支持多重继承的语言增加了多重继承功能。Java8 通过添加默认方法来支持多种类型的继承。即便如此,双胞胎模式在某些情况下仍然有用。Java 在这个页面上,设计模式站点对很好的描述。
适配器模式
适配器模式为代码重用提供了解决方案;它将现有的旧代码适配/包装到新的接口中,这在设计原始代码时是未知的。1987 年,当 PS/2 当设计端口时,没有人认为它会连接到它 9 年后设计的 USB 总线。然而,我们仍然可以使用旧的 PS/2 键盘连接到我们最新的计算机 USB 端口。
适配器模式通常用于处理遗留代码,因为我们可以立即访问已测试的旧功能,包装现有代码并适应新的代码接口。这可以用于多个继承 Java8 可以实现中默认接口),也可以通过使用组合(旧对象成为类属性)来实现。适配器模式也被称为。
如果旧代码需要使用新代码,反之亦然,我们需要使用一个叫做双向适配器的特殊适配器来实现两个接口(旧接口和新接口)。
JDK 中的java.io.InputStreamReader
和java.io.OutputStreamWriter
类是适配器,因为它们会 JDK1.0 输入/输出流对象适应后期 JDK1.1 读写器对象的定义。
意图
其目的是将现有的旧接口应用于新的客户端接口。目标是尽可能重用旧的和测试过的代码,并自由更改新的接口。
实现
下面的 UML 图建模了新客户端代码与修改后代码之间的交互。通常在其他语言中使用多重继承来实现适配器模式 Java8 一开始这是可能的。我们将使用另一种适用于旧方法的方法 Java 版本;我们将使用聚合物。它比继承更有限,因为我们不能访问受保护的内容,只能访问适配器的公共接口:
以下参与者可以从实现图中区分:
Client
:代码客户端Adapter
:将调用转发给适配器的适配器类别Adaptee
:旧代码需要修改Target
:新接口需要支持
示例
下面的代码模拟 USB 总线中使用 PS/2 键盘。它定义了一个 PS/2 键盘(适配器) USB 设备接口(目标) PS2ToUSBAdapter(适配器)及使设备工作的连接线:
package gof.structural.adapter; import java.util.Arrays; import java.util.Collections; import java.util.List; class WireCap {
WireCap link = WireCap.LooseCap; private Wire wire; publicstatic WireCap LooseCap = new WireCap(null); public WireCap(Wire wire) {
this.wire = wire; }
publicvoid addLinkTo(WireCap link)
{
this.link = link;
}
public Wire getWire()
{
return wire;
}
public String toString()
{
if (link.equals(WireCap.LooseCap))
return "WireCap belonging to LooseCap";
return "WireCap belonging to " + wire + " is linked to " +
link.getWire();
}
public WireCap getLink()
{
return link;
}
}
顾名思义,WireCap
类模型是每根导线的两端。默认情况下,所有导线都是松的;因此,我们需要一种方法来发出信号。这是通过使用空对象模式来完成的,LooseCap
是我们的空对象(一个空替换,它不抛出NullPointerException
)。请看下面的代码:
class Wire
{
private String name;
private WireCap left;
private WireCap right;
public Wire(String name)
{
this.name = name;
this.left = new WireCap(this);
this.right = new WireCap(this);
}
publicvoid linkLeftTo(Wire link)
{
left.addLinkTo(link.getRightWireCap());
link.getRightWireCap().addLinkTo(left);
}
public WireCap getRightWireCap()
{
return right;
}
publicvoid printWireConnectionsToRight()
{
Wire wire = this;
while (wire.hasLinkedRightCap())
{
wire.printRightCap();
wire = wire.getRightLink();
}
}
public Wire getRightLink()
{
return getRightWireCap().getLink().getWire();
}
publicvoid printRightCap()
{
System.out.println(getRightWireCap());
}
publicboolean hasLinkedRightCap()
{
return !getRightWireCap().link.equals(WireCap.LooseCap);
}
public String getName()
{
return name;
}
public String toString()
{
return "Wire " + name;
}
}
Wire
类对来自 USB 或 PS/2 设备的电线进行建模。它有两端,默认情况下是松散的,如以下代码所示:
class USBPort
{
publicfinal Wire wireRed = new Wire("USB Red5V");
publicfinal Wire wireWhite = new Wire("USB White");
publicfinal Wire wireGreen = new Wire("USB Green");
publicfinal Wire wireBlack = new Wire("USB Black");
}
根据 USB 规范,USBPort 有四根导线:5V 红色、绿色和白色导线用于数据,黑色导线用于接地,如下代码所示:
interface PS2Device
{
staticfinal String GND = "PS/2 GND";
staticfinal String BLUE = "PS/2 Blue";
staticfinal String BLACK = "PS/2 Black";
staticfinal String GREEN = "PS/2 Green";
staticfinal String WHITE = "PS/2 White";
staticfinal String _5V = "PS/2 5V";
public List<Wire> getWires();
publicvoid printWiresConnectionsToRight();
}
class PS2Keyboard implements PS2Device
{
publicfinal List<Wire> wires = Arrays.asList(
new Wire(_5V),
new Wire(WHITE),
new Wire(GREEN),
new Wire(BLACK),
new Wire(BLUE),
new Wire(GND));
public List<Wire> getWires()
{
return Collections.unmodifiableList(wires);
}
publicvoid printWiresConnectionsToRight()
{
for(Wire wire : wires)
wire.printWireConnectionsToRight();
}
}
PS2Keyboard
是适配器。我们需要使用的是旧设备,如下代码所示:
interface USBDevice
{
publicvoid plugInto(USBPort port);
}
USBDevice
是目标接口。它知道如何与USBPort
接口,如下代码所示:
class PS2ToUSBAdapter implements USBDevice
{
private PS2Device device;
public PS2ToUSBAdapter(PS2Device device)
{
this.device = device;
}
publicvoid plugInto(USBPort port)
{
List<Wire> ps2wires = device.getWires();
Wire wireRed = getWireWithNameFromList(PS2Device._5V,
ps2wires);
Wire wireWhite = getWireWithNameFromList(PS2Device.WHITE,
ps2wires);
Wire wireGreen = getWireWithNameFromList(PS2Device.GREEN,
ps2wires);
Wire wireBlack = getWireWithNameFromList(PS2Device.GND,
ps2wires);
port.wireRed.linkLeftTo(wireRed);
port.wireWhite.linkLeftTo(wireWhite);
port.wireGreen.linkLeftTo(wireGreen);
port.wireBlack.linkLeftTo(wireBlack);
device.printWiresConnectionsToRight();
}
private Wire getWireWithNameFromList(String name, List<Wire>
ps2wires)
{
return ps2wires.stream()
.filter(x -> name.equals(x.getName()))
.findAny().orElse(null);
}
}
PS2ToUSBAdapter
是我们的适配器类。它知道如何布线,以便新的USBPort
仍然可以使用旧的设备,如下代码所示:
publicclass Main
{
publicstaticvoid main (String[] args)
{
USBDevice adapter = new PS2ToUSBAdapter(new PS2Keyboard());
adapter.plugInto(new USBPort());
}
}
输出如下:
正如预期的那样,我们的设备已连接到 USB 端口并准备好使用。所有接线都已完成,例如,如果 USB 端口将红线设置为 5 伏,则该值将到达键盘,如果键盘通过绿线发送数据,则该值将到达 USB 端口。
代理模式
每当您使用企业或 SpringBeans、模拟实例和实现 AOP 时,对具有相同接口的另一个对象进行 RMI 或 JNI 调用,或者直接/间接使用java.lang.reflect.Proxy
,都会涉及到代理对象。它的目的是提供一个真实对象的代理,具有完全相同的封装外形。它在调用之前或之后执行其他操作时将工作委托给它。代理类型包括:
- :将工作委托给远程对象(不同的进程、不同的机器),例如企业 bean。使用 JNI 手动或自动地使用 JNI 包装现有的非 Java 旧代码(例如,使用 SWIG 生成胶粘代码,参见这个页面)是一种远程代理模式,因为它使用句柄(C/C++ 中的指针)访问实际对象。
- :进行安全/权限检查。
- :使用记忆加速调用。最好的例子之一是 Spring
@Cacheable
方法,它缓存特定参数的方法结果,不调用实际代码,而是从缓存返回先前计算的结果。 - 。这些增加了方法的功能,比如记录性能度量(创建一个
@Aspect
,为所需的方法定义一个@Pointcut
,并定义一个@Around
通知)或者进行延迟初始化。
适配器和代理之间的主要区别在于代理提供完全相同的接口。装饰器模式增强了接口,而适配器改变了接口。
意图
其目的是为真实对象提供代理,以便更好地控制它。它是一个实际对象的句柄,其行为类似于它,因此使客户端代码使用它就像使用实际对象一样。
实现
下图对代理模式进行了建模。请注意,由于真实和代理主题都实现了相同的接口,因此它们可以互换:
我们可以在实现图中区分以下参与者:
Subject
:客户端使用的现有接口RealSubject
:真实对象的类ProxySubject
:代理类
示例
下面的代码模拟从 localhost EJB 上下文中查找 bean 的远程代理。我们的远程代理是在另一个 JVM 中运行的几何计算器。我们将使用工厂方法来制作代理和真实对象,以证明它们是可互换的。代理版本的计算时间更长,因为我们还模拟 JNI 查找部分并发送/检索结果。看看代码:
package gof.structural.proxy;
publicclass Main
{
publicstaticvoid main (String[] args) throws java.lang.Exception
{
GeometryCalculatorBean circle = GeometryCalculatorBeanFactory.
REMOTE_PROXY.makeGeometryCalculator();
System.out.printf("Circle diameter %fn",
circle.calculateCircleCircumference(new Circle()));
}
}
class Circle
{
}
interface GeometryCalculatorBean
{
publicdouble calculateCircleCircumference(Circle circle);
}
这是我们的主题,我们要实现的接口。模拟@RemoteInterface
和@LocalInterface
接口的建模,如下代码所示:
class GeometryBean implements GeometryCalculatorBean
{
publicdouble calculateCircleCircumference(Circle circle)
{
return 0.1f;
}
}
这是我们真正的主题,知道如何执行实际的几何计算,如以下代码所示:
class GeometryBeanProxy implements GeometryCalculatorBean
{
private GeometryCalculatorBean bean;
public GeometryBeanProxy() throws Exception
{
bean = doJNDILookup("remote://localhost:4447", "user",
"password");
}
private GeometryCalculatorBean doJNDILookup
(final String urlProvider, final String securityPrincipal, final
String securityCredentials)
throws Exception
{
System.out.println("Do JNDI lookup for bean");
Thread.sleep(123);//simulate JNDI load for the remote location
return GeometryCalculatorBeanFactory.LOCAL.
makeGeometryCalculator();
}
publicdouble calculateCircleCircumference(Circle circle)
{
return bean.calculateCircleCircumference(circle);
}
}
这是我们的代理主题。请注意,它没有业务逻辑;它在设法建立对它的句柄之后,将它委托给真正的主题,如以下代码所示:
enum GeometryCalculatorBeanFactory
{
LOCAL
{
public GeometryCalculatorBean makeGeometryCalculator()
{
returnnew GeometryBean();
}
},
REMOTE_PROXY
{
public GeometryCalculatorBean makeGeometryCalculator()
{
try
{
returnnew GeometryBeanProxy();
}
catch (Exception e)
{
// TODO Auto-generated catch block
e.printStackTrace();
}
returnnull;
}
};
publicabstract GeometryCalculatorBean makeGeometryCalculator();
}
以下输出显示代理成功链接到真实对象并执行所需的计算:
装饰器模式
有时我们需要在不影响现有代码的情况下,向现有代码添加或从现有代码中删除功能,有时创建子类是不实际的。在这些情况下,装饰器非常有用,因为它允许在不更改现有代码的情况下这样做。它通过实现相同的接口、聚合要修饰的对象、将所有公共接口调用委派给它,并在子类中实现新功能来实现这一点。将此模式应用于具有轻量级接口的类。在其他情况下,通过将所需的策略注入组件(策略模式)来扩展功能是更好的选择。这将保持特定方法的局部更改,而不需要重新实现其他方法。
装饰对象及其装饰器应该是可互换的。装饰器的接口必须完全符合装饰对象的接口。
因为它使用递归,所以可以通过组合装饰器来实现新功能。在这方面,它类似于复合模式,它将多个对象组合在一起,以形成作为一个对象的复杂结构。装饰器可以被视为护照上的一块玻璃或一张卡片(安装在一块玻璃和一张卡片之间的图片或照片),其中图片/照片本身就是装饰对象。另一方面,策略可以看作是艺术家在照片上的签名。
JScrollPane
swing 类是装饰器的一个示例,因为它允许在现有容器周围添加新功能,例如滚动条,并且可以多次执行,如下代码所示:
JTextArea textArea = new JTextArea(10, 50);
JScrollPane scrollPane1 = new JScrollPane(textArea);
JScrollPane scrollPane2 = new JScrollPane(scrollPane1);
意图
其目的是动态扩展现有对象的功能,而不更改其代码。它符合原始接口,并且能够通过使用组合(而不是子类化)在功能上扩展。
实现
下图对装饰器模式进行了建模。结果表明,扩展构件和修饰构件可以相互替换。装饰器可以递归地应用;它可以应用于现有的组件实现,但也可以应用于另一个装饰器,甚至应用于它自己。装饰器接口不是固定到组件接口的;它可以添加额外的方法,装饰器的子级可以使用这些方法,如图所示
我们可以在实现图中区分以下参与者:
Component
:抽象组件(可以是接口)ComponentImplementation
:这是我们要装饰的组件之一Decorator
:这是一个抽象的组件Decorator
ExtendedComponent
:这是添加额外功能的组件装饰器
示例
下面的代码显示了如何增强简单的打印 ASCII 文本,以打印输入的十六进制等效字符串,以及实际文本:
package gof.structural.decorator;
import java.util.stream.Collectors;
publicclass Main
{
publicstaticvoid main (String[] args) throws java.lang.Exception
{
final String text = "text";
final PrintText object = new PrintAsciiText();
final PrintText printer = new PrintTextHexDecorator(object);
object.print(text);
printer.print(text);
}
}
interface PrintText
{
publicvoid print(String text);
}
PrintText is the component interface:
class PrintAsciiText implements PrintText
{
publicvoid print(String text)
{
System.out.println("Print ASCII: " + text);
}
}
PrintASCIIText
是要装饰的构件。注意,它只知道如何打印ASCII
文本。我们想让它也以十六进制打印;我们可以使用下面的代码
class PrintTextHexDecorator implements PrintText
{
private PrintText inner;
public PrintTextHexDecorator(PrintText inner)
{
this.inner = inner;
}
publicvoid print(String text)
{
String hex = text.chars()
.boxed()
.map(x -> "0x" + Integer.toHexString(x))
.collect(Collectors.joining(" "));
inner.print(text + " -> HEX: " + hex);
}
}
PrintTextHexDecorator
是装饰师。也可应用于其它PrintText
元件。假设我们要实现一个组件PrintToUpperText
。我们可能仍然使用我们现有的装饰,使其打印十六进制以及。
以下输出显示当前功能(ASCII)和新添加的功能(十六进制显示):
桥接模式
在软件设计过程中,我们可能会面临一个问题,即同一个抽象可以有多个实现。这在进行跨平台开发时最为明显。例如 Linux 上的换行符换行符或 Windows 上存在注册表。需要通过运行特定操作系统调用来获取特定系统信息的 Java 实现肯定需要能够改变实现。一种方法是使用继承,但这会将子级绑定到特定接口,而该接口可能不存在于不同的平台上。
在这些情况下,建议使用桥接模式,因为它允许从扩展特定抽象的大量类转移到嵌套泛化,这是 Rumbaugh 创造的一个术语,在这里我们处理第一个泛化,然后处理另一个泛化,从而将所有组合相乘。如果所有子类都同等重要,并且多个接口对象使用相同的实现方法,那么这种方法就可以很好地工作。如果由于某种原因,大量代码被复制,这就表明这种模式不是解决特定问题的正确选择。
意图
其目的是将抽象与实现分离,以允许它们独立地变化。它通过在公共接口和实现中使用继承来实现这一点。
实现
下图显示了一个可能的网桥实现。请注意,抽象和实现都可以更改,不仅接口可以更改,实现代码也可以更改。例如,精化抽象可以利用只有SpecificImplementation
提供的doImplementation3()
:
我们可以在实现图中区分以下参与者:
Abstraction
:这是抽象组件Implementation
:这是抽象实现Refined
:这是具体组件SpecificImplementation
:这是具体实现
示例
下面的代码展示了一个电子邮件客户端,它使用了基于运行平台的实现。可以使用工厂方法模式对其进行增强,以创建特定的平台实现:
package gof.structural.bridge;
publicclass Main
{
publicstaticvoid main (String[] args)
{
new AllMessageClient(new WindowsImplementation())
.sendMessageToAll("abc@gmail.com", "Test");
}
}
interface PlatformBridge
{
publicvoid forwardMessage(String msg);
}
PlatformBridge
是我们的实现抽象类。它指定了每个实现需要提供什么—在我们的例子中,是转发文本给出的消息。以下两种实现(Windows 和 POSIX)都知道如何执行此任务:
class WindowsImplementation implements PlatformBridge { publicvoid forwardMessage(String msg) { System.out.printf("Sending message n%s nFrom the windows machine", msg)