依赖注入解决方案的碎片化与框架设计教训
1. 依赖注入解决方案的碎片化
如今,几乎每个应用程序都会采用某种形式的依赖注入(DI),也就是将部分对象的构建和连接责任交给配置好的库代码。这种形式多种多样,以 Java Servlet 框架为例,在
web.xml
中对 servlet 和过滤器的注册就体现了一种原始的依赖注入形式。
以下是一个
web.xml
配置文件示例:
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.4"
xmlns="http://java.sun.com/xml/ns/j2ee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee
http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd" >
<filter>
<filter-name>webFilter</filter-name>
<filter-class>
com.wideplay.example.RequestPrintingFilter
</filter-class>
</filter>
<filter-mapping>
<filter-name>webFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<servlet>
<servlet-name>helloServlet</servlet-name>
<servlet-class>
com.wideplay.example.servlets.HelloServlet
</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>helloServlet</servlet-name>
<url-pattern>/hi/*</url-pattern>
</servlet-mapping>
</web-app>
在这个配置文件中,配置了一个过滤器
com.wideplay.example.RequestPrintingFilter
和一个 HTTP servlet
com.wideplay.example.servlets.HelloServlet
。过滤器是
javax.servlet.Filter
的子类,其作用是通过打印请求内容来跟踪每个请求。使用
<filter>
标签进行配置,这与 Spring 的
<bean>
标签类似,例如:
<bean id="webFilter" class="com.wideplay.example.RequestPrintingFilter"/>
同样,
<servlet>
标签允许将
javax.servlet.http.HttpServlet
映射到字符串标识符和 URL 模式。不过,Servlet 框架与 Spring、Guice 等依赖注入器的本质区别在于,Servlet 框架不允许为 servlet 或过滤器配置任何依赖项。这是一个比较糟糕的情况,因为任何严肃的 Web 应用程序几乎都有很多依赖项。
这种配置对 servlet(和过滤器)的结构有相当大的限制:
- 一个 servlet 必须有一个公共的、无参数(零参数)的构造函数。
- 一个 servlet 类本身必须是公共的,并且是具体类型(而不是接口或抽象类)。
- 一个 servlet 的方法不能被拦截以修改行为。
- 一个 servlet 必须实际上具有单例作用域。
由于这些限制,测试单例且无参数构造函数的 servlet 非常痛苦。因此,在 Servlet 框架之上出现了许多替代的 Web 框架,旨在简化和缓解 Servlet 编程模型的限制所带来的问题,尤其是与依赖注入相关的问题。以下是一些 Java Web 框架及其依赖注入解决方案的比较:
| 库 | 开箱即用的解决方案 | 是否集成 Spring/Guice/其他? | 网站 |
| — | — | — | — |
| Apache Wicket | 自定义工厂,无 DI | 通过插件部分集成 | http://wicket.apache.org |
| Apache Struts2(原 WebWork) | 自定义工厂,无 DI | 根据插件完全集成 | http://struts.apache.org |
| Apache Tapestry 4 | Apache HiveMind | 通过 HiveMind 查找扩展集成 Spring | http://tapestry.apache.org/tapestry4.1 |
| Apache Tapestry 5 | TapestryIoC 注入器 | 通过扩展部分集成 | http://tapestry.apache.org |
| JavaServer Faces 1.2 | 内置,非常有限的 DI | 扩展集成 Spring 中的变量查找 | http://java.sun.com/javaee/javaserverfaces |
| Spring MVC | Spring IoC | 几乎与 Spring 绑定 | http://www.springframework.org |
| Google Sitebricks | Google Guice | 通过 Guice 的 Spring 模块完全集成 Spring | http://code.google.com/p/google-sitebricks |
从这个表格可以看出,目前没有一个标准的编程模型来将依赖注入器与基于 Servlet 的库集成。只有 Apache Struts2 和 Google Sitebricks 完全集成了 Guice 和 Spring。
其他类型的库情况也没有好多少。标准的 EJBs 编程模型提供了自己形式的依赖注入,支持直接字段注入和通过
javax.interceptor
库进行基本拦截。但直接字段注入很难测试,并且要求有零参数构造函数,这意味着无法控制作用域。所有无状态会话 EJB(服务对象)在方法调用期间存在,有状态会话 EJB 的生命周期虽然稍长,但也超出了直接控制范围,且只是稍微长一点。
造成这种糟糕情况的原因有很多,但本质上是因为我们最近才开始认识到可测试性、松耦合、作用域等依赖注入带来的好处的价值。而且,如何按照这些原则进行设计的意识仍在发展中。例如,Guice 诞生才两年多,PicoContainer 和 Spring 的现代版本出现得更晚。
2. 框架设计者的教训
对于框架设计者来说,如果能牢记一个基本原则:可测试性,那么大多数集成方面的关键问题都可以避免。这意味着框架所交互的每一段客户端和服务端代码都应该易于测试,这自然会引出松耦合、作用域和模块化设计等更广泛的概念。
框架设计中,用模拟对象替换库组件应该是容易实现的,并且可插拔功能不应给用户代码带来不必要的限制(例如,servlet 要求有公共的、无参数的构造函数)。
然而,在框架设计中,用模拟对象替换这一功能既至关重要又经常被忽视。虽然大多数框架本身经过了严格测试,但它们往往没有考虑到客户端代码也应该易于测试。这意味着在设计 API 和实现逻辑的同时,也要考虑客户端代码的设计。以这种思路设计的框架通常更易于使用和理解,因为集成点更少,而且这些集成点自然更简洁。
另一个重要问题是封装。如果暴露过多的公共类,客户就有可能开始扩展和使用这些类。当想要对框架的架构或内部设计进行重大更改时,如果用户与框架的多个部分紧密绑定,那么这个过程就会很困难。例如,Spring 框架的 SpringMVC 就存在这个问题,每个类都是公共的且非 final 的,这意味着用户可以绑定到任何代码,一旦代码更改就会破坏很多客户端应用程序。甚至 Spring 的许多模块也会扩展和使用框架的其他部分,这就是为什么人们发现很难在 Spring 注入器之外使用 Spring 模块。Spring Security 就是一个典型的例子,它的许多组件严重依赖 Spring 的生命周期系统和 JavaBeans 属性编辑器,这使得它很难移植到其他依赖注入系统中使用,甚至手动使用也很困难。
强迫用户为了实现功能而扩展框架基类也是有问题的,因为这会模糊依赖项和父类之间的界限。组合是更好的选择,因为在单元和集成测试中,委托对象可以很容易地用模拟对象或存根替换。而父类方法更难模拟,因此不利于测试。
这些问题大致可以分为三类:
- 那些阻止测试或使测试变得非常困难的问题。
- 那些通过破坏拦截和作用域来限制功能的问题。
- 那些使集成变得困难或不可能的问题(最严重的情况)。
这些问题并非不可避免或必要,特别是如果在框架的早期阶段就仔细考虑这些问题的话。接下来,我们将详细分析一个严重的反模式:刚性配置反模式,它会使测试和集成变得困难。
下面是刚性配置反模式的 mermaid 流程图:
graph TD;
A[配置开始] --> B[获取配置属性值];
B --> C{类是否存在};
C -- 是 --> D{类是否可实例化};
D -- 是 --> E[创建实例];
D -- 否 --> F[抛出异常];
C -- 否 --> F[抛出异常];
3. 刚性配置反模式
大多数框架都提供某种形式的行为定制,这是它们的主要吸引力之一。例如,Hibernate 允许使用各种数据库、连接池、事务策略等,将库的基本行为扩展到各种用例中。通常,这是通过使用外部资源束(如
.properties
或 XML 文件)来实现的。大多数配置通常是关于指定某种数量或类型的东西(如
max_connections=..
、
timeout=..
、
enable_logging=true
等),这种配置是合适的。
但有时库也提供可插拔服务,允许用户通过编写小的组件(通常遵循库的接口)并插入它们来定制行为。这些组件有时被称为插件,有时被称为扩展,但本质上是框架的用户提供的依赖项。当配置这些插件时,框架集成往往会出现问题。许多框架使用相同的配置机制(XML 文件或资源束)来指定插件,通常采用字符串名/值对的形式,例如:
extensibility.plugin=com.example.MyPlugin
extensibility.plugin
属性标识正在设置的插件组件,右边是用户提供的类的名称。从表面上看,这是一种简洁、易于理解的方法,能很好地指定所需的扩展。但仔细观察会发现,它存在几个问题,使得测试和集成既困难又繁琐。
3.1 类型不安全的配置反模式
最严重的问题可能是对类型安全的忽视。由于资源束以原始字符串形式存储,无法验证信息是否以适当的形式存在。很容易拼写错误或输入错误配置参数,甚至完全遗漏它。
以 OSCache 框架为例,它为应用程序提供实时缓存服务。OSCache 将其配置存储在名为
oscache.properties
的资源束中,该资源束在缓存控制器启动时加载并进行配置。以下是一个 OSCache 的示例配置文件:
cache.memory=false
cache.use.host.domain.in.key=true
cache.path=/tmp
cache.persistence.class=com.opensymphony.oscache.plugins.diskpersistence.DiskPersistenceListener
cache.algorithm=com.opensymphony.oscache.base.algorithm.LRUCache
cache.blocking=false
cache.capacity=100
cache.unlimited.disk=false
在这个配置文件中,中间有一行指定了要使用的缓存服务类型,这里使用的是 OSCache 自己的
DiskPersistenceListener
。问题很明显,在编写这个属性时,我们并不知道 OSCache 是否支持
DiskPersistenceListener
,编译器也不会检查。一个简单但常见的拼写错误(如
cache.persistence.class=com.opensymphony.oscache.plugins.diskpersistence.DiscPersistenceListener
)在编译时不会被检测到,直到运行时才会导致失败。
此外,即使拼写正确,除非该类实现了正确的接口,否则也可能无法按预期工作。例如,自定义的
MyPersistenceListener
类:
package com.example.oscache.plugins;
public class MyPersistenceListener {
...
}
如果将其配置为
cache.persistence.class=com.example.oscache.plugins.MyPersistenceListener
,这是不正确的,因为
MyPersistenceListener
没有实现 OSCache 的
PersistenceListener
接口。这个错误直到运行时 OSCache 引擎尝试实例化
MyPersistenceListener
并使用它时才会被发现。正确的代码应该是:
package com.example.oscache.plugins;
import com.opensymphony.oscache.base.persistence.PersistenceListener;
public class MyPersistenceListener implements PersistenceListener {
...
}
而且,如果拼写错误配置属性的左侧(如
cache.persistance.class=com.example.oscache.plugins.MyPersistenceListener
),不仅没有编译时检查,任何合理性检查也会完全错过这个错误。因为如果没有显式设置
cache.persistence.class
属性,它会自动使用默认值。在集成测试中,配置也不会抛出错误,因为可以随意设置任意数量的未知属性,资源束不会对此提出异议。这是一个糟糕的情况,即使格外小心也可能出错,还会导致程序员从以前的项目或教程中复制和粘贴已知的工作配置。
3.2 不必要的约束反模式
这种名/值属性映射会给用户代码,特别是我们一直在研究的插件代码带来奇怪的限制。插件使用完全限定的类名指定,例如:
cache.persistence.class=com.example.oscache.plugins.MyPersistenceListener
这意味着
MyPersistenceListener
必须实现
PersistenceListener
接口,并且必须有一个公共的、无参数的构造函数,且不抛出检查异常:
package com.example.oscache.plugins;
import com.opensymphony.oscache.base.persistence;
public class MyPersistenceListener implements PersistenceListener {
public MyPersistenceListener() {
}
...
}
这是因为 OSCache 使用反射来实例化
MyPersistenceListener
类。反射代码用于读取和操作类型未知的对象。在我们的例子中,OSCache 中没有源代码知道
MyPersistenceListener
,但为了使插件工作,它必须能被原始的 OSCache 代码使用。使用反射,OSCache 能够构造
MyPersistenceListener
(或属性中指定的任何类)的实例并用于持久化。以下是通过反射创建未知类实例的示例代码:
String className = config.getProperty("cache.persistence.class");
Class<?> listener;
try {
listener = Class.forName(className);
} catch (ClassNotFoundException e) {
throw new RuntimeException("failed to find specified class", e);
}
Object instance;
try {
instance = listener.getConstructor().newInstance();
} catch (InstantiationException e) {
throw new RuntimeException("failed to instantiate listener", e);
} catch (IllegalAccessException e) {
throw new RuntimeException("failed to instantiate listener", e);
} catch (InvocationTargetException e) {
throw new RuntimeException("failed to instantiate listener", e);
} catch (NoSuchMethodException e) {
throw new RuntimeException("failed to instantiate listener", e);
}
下面详细分析这个示例:
1. 首先,从配置属性中获取类的名称:
String className = config.getProperty("cache.persistence.class");
这是通过一个假设的
config
对象完成的,该对象从资源束中按名称返回值。然后将插件类的字符串值转换为
java.lang.Class
,以便通过反射访问底层类型:
Class<?> listener;
try {
listener = Class.forName(className);
} catch (ClassNotFoundException e) {
throw new RuntimeException("failed to find specified class", e);
}
Class.forName()
方法尝试通过完全限定名称定位并加载类。如果在应用程序的类路径中找不到该类,将抛出异常并异常终止。
2. 一旦类成功加载,就尝试通过获取其无参数构造函数并调用
newInstance()
方法来创建实例:
Object instance;
try {
instance = listener.getConstructor().newInstance();
} catch (InstantiationException e) {
throw new RuntimeException("failed to instantiate listener ", e);
} catch (IllegalAccessException e) {
throw new RuntimeException("failed to instantiate listener", e);
} catch (InvocationTargetException e) {
throw new RuntimeException("failed to instantiate listener", e);
} catch (NoSuchMethodException e) {
throw new RuntimeException("failed to instantiate listener", e);
}
创建实例可能失败的原因有四个,对应上述代码中的四个
catch
子句:
-
InstantiationException
:指定的类实际上是接口或抽象类。
-
IllegalAccessException
:当前方法的访问可见性不足以调用相关构造函数。
-
InvocationTargetException
:构造函数在完成之前抛出了异常。
-
NoSuchMethodException
:给定类没有无参数构造函数。
如果这四种情况都不发生,框架就可以正确创建和使用插件。从另一个角度看,这四个异常是对扩展框架的插件设计的四个限制。也就是说,如果要集成或扩展 OSCache(或任何使用这种可扩展性习惯用法的库),代码会受到以下四个限制:
- 必须创建并暴露一个具有公共可见性的具体类。
- 这个类必须有一个公共的构造函数。
- 这个类不应该抛出检查异常。
- 它必须有一个公共的、无参数(零参数)的构造函数。
综上所述,框架设计者在设计框架时,应该充分考虑可测试性、类型安全和避免不必要的约束,以提高框架的可维护性和可扩展性,减少集成和测试的困难。
4. 避免刚性配置反模式的建议
为了避免刚性配置反模式带来的问题,框架设计者可以采取以下一些建议:
4.1 增强类型安全
- 使用类型安全的配置接口 :框架可以提供类型安全的配置接口,而不是依赖于字符串键值对。例如,定义一个 Java 接口来表示配置选项,通过方法调用而不是字符串来设置和获取配置。
public interface CacheConfig {
boolean isMemoryCacheEnabled();
void setMemoryCacheEnabled(boolean enabled);
String getCachePath();
void setCachePath(String path);
// 其他配置选项
}
这样,在编译时就可以检查配置的正确性,避免拼写错误等问题。
- 使用注解进行配置 :注解可以提供类型安全的配置方式。例如,在 Spring 中,可以使用注解来配置 Bean 的属性。
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class MyCache {
@Value("${cache.memory}")
private boolean memoryCache;
@Value("${cache.path}")
private String cachePath;
// 其他代码
}
通过注解,可以在编译时检查配置的正确性,并且可以将配置与代码紧密关联。
4.2 减少不必要的约束
- 支持多种构造函数 :框架应该支持插件类使用不同的构造函数,而不仅仅局限于无参数构造函数。可以通过依赖注入框架来实现这一点,例如使用 Spring 或 Guice。
import com.google.inject.Inject;
public class MyPersistenceListener implements PersistenceListener {
private final SomeDependency dependency;
@Inject
public MyPersistenceListener(SomeDependency dependency) {
this.dependency = dependency;
}
// 其他代码
}
这样,插件类可以根据需要注入依赖项,而不需要使用反射来创建实例。
- 提供灵活的实例化方式 :框架可以提供灵活的实例化方式,例如使用工厂模式。用户可以通过实现工厂接口来创建插件实例。
public interface PersistenceListenerFactory {
PersistenceListener createPersistenceListener();
}
public class MyPersistenceListenerFactory implements PersistenceListenerFactory {
@Override
public PersistenceListener createPersistenceListener() {
return new MyPersistenceListener();
}
}
框架可以通过配置文件指定使用哪个工厂来创建插件实例,这样可以避免反射带来的限制。
5. 总结与展望
依赖注入解决方案的碎片化是当前软件开发中面临的一个重要问题。不同的框架和库采用不同的依赖注入方式,导致集成困难、可测试性差等问题。框架设计者应该牢记可测试性原则,避免刚性配置反模式,提高框架的可维护性和可扩展性。
以下是一个总结性的表格,对比了良好的框架设计和刚性配置反模式的特点:
| 方面 | 良好的框架设计 | 刚性配置反模式 |
| — | — | — |
| 可测试性 | 易于测试,支持模拟对象替换 | 测试困难,存在诸多限制 |
| 类型安全 | 提供类型安全的配置方式 | 忽视类型安全,易出现拼写错误 |
| 约束性 | 减少不必要的约束,支持多种构造函数 | 存在过多约束,要求无参数构造函数等 |
| 集成性 | 易于与其他框架集成 | 集成困难,限制了框架的扩展性 |
未来,随着软件开发的发展,我们期望看到更多遵循良好设计原则的框架和库。这些框架将更加注重可测试性、类型安全和灵活性,为开发者提供更好的开发体验。同时,开发者也应该更加关注框架的设计质量,选择合适的框架来构建自己的应用程序。
在实际开发中,我们可以按照以下步骤来选择和使用框架:
1.
评估框架的可测试性
:检查框架是否支持模拟对象替换,是否易于编写单元测试和集成测试。
2.
检查类型安全
:了解框架的配置方式,是否存在类型安全问题。
3.
查看约束条件
:确定框架对用户代码的约束是否合理,是否会影响开发效率和代码质量。
4.
考虑集成性
:评估框架与其他常用框架的集成难度,是否能够满足项目的需求。
通过以上步骤,我们可以选择到更适合项目的框架,提高开发效率和软件质量。
下面是一个选择框架的 mermaid 流程图:
graph TD;
A[开始选择框架] --> B{评估可测试性};
B -- 是 --> C{检查类型安全};
B -- 否 --> D[放弃该框架];
C -- 是 --> E{查看约束条件};
C -- 否 --> D;
E -- 合理 --> F{考虑集成性};
E -- 不合理 --> D;
F -- 易于集成 --> G[选择该框架];
F -- 集成困难 --> D;
总之,依赖注入和框架设计是软件开发中非常重要的方面。我们应该不断学习和实践,掌握良好的设计原则,以应对日益复杂的软件开发挑战。通过合理的框架选择和设计,我们可以构建出更加健壮、可维护和可扩展的软件系统。
超级会员免费看
397

被折叠的 条评论
为什么被折叠?



