使用代理实现 Null 对象模式






4.50/5 (6投票s)
本文展示了如何使用代理动态生成 Null Object。
引言和动机
每个 Java 程序员一定都经历过无数次由于令人沮丧的 NullPointerException
导致的系统崩溃或类似问题。为了保护我们的软件免受此问题的影响并使其更安全,我们通常会在代码中填充检查,以防止调用 null 变量,迫使我们将一个简单的类似以下的方法进行翻译:
public String getProjectManagerName() {
return getProject().getManager().getName();
}
改为这样。
public String getProjectManagerName() {
Project project = getProject();
if (project == null) return “”;
Manager manager = project.getManager();
if (manager == null) return “”;
return manager.getName();
}
如果您只需要在一两个地方使用这种方法,那么这种方法是可以的,但如果您需要在整个软件中重复使用它,它会通过充斥不必要的代码来大大降低代码的可读性和可扩展性。此外,这种空值逻辑无法为新代码提供保护,如果程序员忘记包含它,相同的问题可能会再次发生。
这个问题的重要性足以促使像 Groovy 这样更现代的语言引入安全解引用运算符(?.)。使用此构造,以前的方法可以安全地重写如下:
public String getProjectManagerName() {
return getProject()?.getManager()?.getName();
}
Null Object 模式如何解决问题
虽然 Java 语言没有提供安全解引用运算符,但仍然可以通过所谓的 Null Object 模式来实现相同的结果。根据 Wikipedia
“Null Object 是一个具有定义好的中立(“null”)行为的对象。”
换句话说,给定类的 Null Object 具有与该类兼容的类型(通过扩展它或实现通用接口),并在没有更具意义的行为时提供默认(“null”)行为。例如,上述代码片段中涉及的 Project
和 Manager
类的 Null Object 版本可以实现如下:
public class NullProject extends Project {
public Manager getManager() {
return new NullManager();
}
}
public class NullManager extends Manager {
public String getName() {
return “”;
}
}
当然,请注意,Null Object 必须始终返回另一个 Null Object,以保持调用序列的安全。这样(前提是 getProject()
方法在应返回 null 时返回 NullProject
的实例),我们 getProjectManagerName()
的第一个版本就变得安全了,再次消除了第二个版本中引入的所有检查的需要。
在我看来,尽管此解决方案有效且允许编写更清晰的代码,但它有一个主要缺陷:它迫使我们为域模型中的每个类实现、维护和测试一个 Null Object 版本。而且,它以一种更微妙的方式未能为新代码提供保护:如果程序员忘记在其 Null Object 版本中重写一个方法,您将面临 NullPointerException
的风险。
使用代理实现 Null Object 模式
为了消除这些最后的问题,最好通过代理动态生成 Null Object 类,而不是静态实现它们。为了研究这个最后的解决方案,我实现了一个名为 NullObjectProxy
的类,它附带在此文章中,以及一个更好地说明如何使用它的测试类。这个代理的核心当然在于它的 invoke()
方法,该方法定义了拦截对 Null Object 的调用时执行的操作。
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String methodName = method.getName();
if (methodName.equals("getNullClass") &&
(args == null || args.length == 0)) return clazz;
// it delegates the hashCode and the equals methods to the proxy class
if (methodName.equals("hashCode") &&
(args == null || args.length == 0))
return clazz.hashCode();
if (methodName.equals("equals") && args.length == 1
&& args[0] instanceof NullObject)
return ((NullObject)args[0]).getNullClass() == clazz;
// it checks if a special return value has been defined for this method call
Method mockedMethod = getMockedMethod(method);
if (mockedMethod != null) return mockedMethodValuesMap.get(mockedMethod);
Class<?> returnedType = method.getReturnType();
if (returnedType == Void.TYPE) return null;
// if the returned type is an interface
// it returns a Null Object for that interface
if (returnedType.isInterface()) return nullObjectOf(returnedType);
// it checks if has been defined a default value for this returned type
try {
return NullObjectProxy.class.getMethod("null" +
returnedType.getSimpleName() + "Value").invoke(NullObjectProxy.class);
} catch (Exception e) { }
// it tries to instance an object of the given return
// type but invoking its empty constructor (if any)
try {
return returnedType.newInstance();
} catch (Exception e) { }
// if all the former strategies fail just return null
return null;
}
通过将要模拟的接口类传递给静态构造函数,该类允许动态实例化一个实现 Null Object 模式的代理,用于任何给定的接口。
public static <T> T nullObjectOf(Class<T> clazz);
此代理保证了任何调用序列的安全性,因为对它的每个方法调用(在可能的情况下)都会返回另一个 Null Object,该 Null Object 又会模拟方法本身返回的类型。这样,为了使原始的非检查实现 getProjectManagerName()
方法安全,当 getProject()
调用本应返回 null 时,只需返回 nullObjectOf(Project.class)
即可。
总而言之,此解决方案允许使用 Null Object 模式,将程序员从为域模型中的每个类编写特定的 Null Object 实现的负担中解放出来。请注意,此实现使用了 Java 语言提供的标准代理机制,因此目前只能与接口一起使用,但通过使用 cglib 库,可以轻松地修改它以使其与任何非 final
类一起使用。在任何情况下,您都无法为 final
类实现 Null Object 模式,因为根据定义,您不允许扩展它。
如前所述,当被调用方法返回的声明类型不是接口时,Null Object 代理无法生成另一个 Null Object。在这种情况下,代理会尝试返回有意义的值,应用两种策略。首先,它检查是否存在与请求的类型兼容的预定义默认返回值,特别是,它为任何 Number
返回 0,为 boolean
返回 false,为 char
返回一个空格,为 String
返回一个空 String
,为 Date
返回表示当前系统时间的 Date
。然后,如果以上都不适用,它会尝试实例化一个给定返回类型的对象,但调用其空构造函数(如果存在)。最后,如果这两种策略都失败了,唯一能做的就是避免 ClassCastException
,那就是返回 null。
尽管这些默认返回值通常在 10 次中有 9 次是正确的,但有时它们在形式上可能是错误的。例如,虽然 Null Object Collection 在对其调用 size()
方法时返回 0 是合理的,但当对其调用 isEmpty()
方法时返回 false 至少听起来很奇怪。通过调用 setMockedMethod()
来覆盖特定方法的默认行为,可以解决最后一个问题。例如,要指示代理在 isEmpty()
方法在 Null Object Collection 上被调用时返回 true,只需通过调用以下方法重新配置它即可。
setMockedMethod(Collection.class, "isEmpty", true);