27、依赖注入解决方案的碎片化与框架设计教训

依赖注入解决方案的碎片化与框架设计教训

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;

总之,依赖注入和框架设计是软件开发中非常重要的方面。我们应该不断学习和实践,掌握良好的设计原则,以应对日益复杂的软件开发挑战。通过合理的框架选择和设计,我们可以构建出更加健壮、可维护和可扩展的软件系统。

内容概要:本文详细介绍了一个基于Java和Vue的联邦学习隐私保护推荐系统的设计实现。系统采用联邦学习架构,使用户数据在本地完成模型训练,仅上传加密后的模型参数或梯度,通过中心服务器进行联邦平均聚合,从而实现数据隐私保护协同建模的双重目标。项目涵盖完整的系统架构设计,包括本地模型训练、中心参数聚合、安全通信、前后端解耦、推荐算法插件化等模块,并结合差分隐私同态加密等技术强化安全性。同时,系统通过Vue前端实现用户行为采集个性化推荐展示,Java后端支撑高并发服务日志处理,形成“本地训练—参数上传—全局聚合—模型下发—个性化微调”的完整闭环。文中还提供了关键模块的代码示例,如特征提取、模型聚合、加密上传等,增强了项目的可实施性工程参考价值。 适合人群:具备一定Java和Vue开发基础,熟悉Spring Boot、RESTful API、分布式系统或机器学习相关技术,从事推荐系统、隐私计算或全栈开发方向的研发人员。 使用场景及目标:①学习联邦学习在推荐系统中的工程落地方法;②掌握隐私保护机制(如加密传输、差分隐私)模型聚合技术的集成;③构建高安全、可扩展的分布式推荐系统原型;④实现前后端协同的个性化推荐闭环系统。 阅读建议:建议结合代码示例深入理解联邦学习流程,重点关注本地训练全局聚合的协同逻辑,同时可基于项目架构进行算法替换功能扩展,适用于科研验证工业级系统原型开发。
源码来自:https://pan.quark.cn/s/a4b39357ea24 遗传算法 - 简书 遗传算法的理论是根据达尔文进化论而设计出来的算法: 人类是朝着好的方向(最优解)进化,进化过程中,会自动选择优良基因,淘汰劣等基因。 遗传算法(英语:genetic algorithm (GA) )是计算数学中用于解决最佳化的搜索算法,是进化算法的一种。 进化算法最初是借鉴了进化生物学中的一些现象而发展起来的,这些现象包括遗传、突变、自然选择、杂交等。 搜索算法的共同特征为: 首先组成一组候选解 依据某些适应性条件测算这些候选解的适应度 根据适应度保留某些候选解,放弃其他候选解 对保留的候选解进行某些操作,生成新的候选解 遗传算法流程 遗传算法的一般步骤 my_fitness函数 评估每条染色体所对应个体的适应度 升序排列适应度评估值,选出 前 parent_number 个 个体作为 待选 parent 种群(适应度函数的值越小越好) 从 待选 parent 种群 中随机选择 2 个个体作为父方和母方。 抽取父母双方的染色体,进行交叉,产生 2 个子代。 (交叉概率) 对子代(parent + 生成的 child)的染色体进行变异。 (变异概率) 重复3,4,5步骤,直到新种群(parentnumber + childnumber)的产生。 循环以上步骤直至找到满意的解。 名词解释 交叉概率:两个个体进行交配的概率。 例如,交配概率为0.8,则80%的“夫妻”会生育后代。 变异概率:所有的基因中发生变异的占总体的比例。 GA函数 适应度函数 适应度函数由解决的问题决定。 举一个平方和的例子。 简单的平方和问题 求函数的最小值,其中每个变量的取值区间都是 [-1, ...
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值