【问题标题】:Removing singletons from large .NET codebase从大型 .NET 代码库中删除单例
【发布时间】:2011-11-01 00:50:31
【问题描述】:

上下文:

(注意:在下文中,我使用“项目”来指代面向单个客户或特定市场的软件可交付成果的集合。我不是指“项目”,因为它在 Visual Studio 中用于指在解决方案中构建单个 EXE 或 DLL 的配置。)

我们有一个相当大的系统,由三层组成:

  1. 包含跨项目共享的代码的层
  2. 包含在项目中的不同应用程序之间共享的代码的层
  3. 包含特定于项目中特定应用程序或网站的代码的层。

前两层内置在 DLL 程序集中。顶层是各种 EXE 和/或 .aspx Web 应用程序。

IIRC,我们有许多不同的项目使用这种模式。所有四个共享第 1 层(尽管版本通常略有不同,由 VCS 管理)。他们每个人都有自己的第 2 层。他们每个人都有自己的一组可交付成果,范围可以从一个网站、一个网站和一个后台服务,到我们最大和最复杂的(以及我们的面包和黄油)。业务),其中包括五个独立的 Web 应用程序、20 多个控制台应用程序/后台服务、三个或四个独立的 Web 服务、六个桌面 GUI 应用程序等。

我们打算将尽可能多的代码推送到第 1 层和第 2 层,以避免在顶层重复逻辑。我们已经做到了这一点。

第 1 层和第 2 层中的每一层都生成三个可交付成果,一个包含与 Web 无关的代码的 DLL,一个包含与 Web 相关的代码的 DLL,以及一个包含单元测试的 DLL。

问题:

编写较低级别是为了广泛使用单例。

第 1 层中的非 Web DLL 包含用于处理 INI 文件、日志记录、自定义构建的对象关系映射器(用于处理数据库连接等)的类。所有这些都使用单例。

当我们开始在网络上构建东西时,所有这些单例都成了问题。不同的用户会访问网站、登录并开始做不同的事情。他们会做一些生成查询的事情,这将导致调用单例 ORM 以获取新的数据库连接,该连接将访问单例配置对象以获取连接字符串,然后将要求连接执行询问。并且在查询中连接将访问单例记录器以记录生成的 SQL 语句,记录器将访问单例配置对象以获取当前用户名,以便将其包含在日志中,如果其他人已登录与此同时,单例配置对象将拥有不同的当前用户。真是一团糟。

所以当我们开始使用此代码库编写 Web 应用程序时,我们所做的是创建一个单例工厂类,它本身就是一个单例。其他每一个单例都有一个公共静态 instance() 方法,该方法一直在调用私有构造函数。相反,公共静态 instance() 方法获得了对单例工厂对象的引用,然后在其上调用一个方法来获得对相关类的单个实例的引用。

换句话说,我们现在有一个单独的类维护一个静态引用,而不是有十几个类,每个类维护一个单独的静态引用,并且它维护一个引用的对象包含十几个对其他以前的单例类。

现在我们只需要处理一个单例。在它的公共静态 instance() 方法中,我们添加了一些特定于 Web 的逻辑。如果我们有一个 HTTPContext 并且该上下文在其会话中具有工厂的实例,我们将从会话中返回该实例。如果我们有一个 HTTPContext,并且它的会话中没有工厂,我们将构建一个新工厂并将其存储在会话中,然后返回它。如果我们没有 HTTPContext,我们只需构建一个新工厂并返回它。

用于此的代码放置在我们从 Page、WebControl 和 MasterPage 派生的类中,然后我们在更高级别的代码中使用我们的类。

对于 .aspx Web 应用程序来说,这很好用,用户登录并维护会话。它适用于在这些 Web 应用程序中运行的 .asmx Web 服务。但它有真正的局限性。

特别是,它在没有会话的情况下不起作用。我们感到压力要提供服务于更大用户群的网站——可能有成千上万的用户动态地访问它们。到目前为止,我们的用户一直是非常典型的桌面业务用户。他们登录我们的网站,并在一天的大部分时间里呆在其中,使用我们的网络应用程序作为桌面应用程序的替代品。一个给定的客户可能有多达 6 个可能使用我们网站的用户,而我们有 1000 或更多的客户,它们加起来并不会造成那么大的负担。但我们目前的架构不会扩展到那个。

我们还遇到了 ASP.NET MVC 比 .aspx Web 表单更适合构建 Web UI 的情况。我们正在探索构建与独立 WFC Web 服务通信的移动应用程序。虽然在这两种情况下,看起来都可以在有会话的环境中运行它们,但看起来相当严重地限制了它们的灵活性和性能。

所以,我们确实在寻找消除这些单例的方法。

我真正想要的:

我正在尝试设想一系列重构,最终将导致结构更好、更灵活的架构。在我们的情况下,我可以很容易地看到 IoC 框架的优势。

但事情是这样的——从我所看到的 IoC 框架来看,它们需要通过构造函数参数从外部提供它们的依赖项。例如,我的记录器类需要我的配置类的一个实例,从中获取当前用户。目前,它是使用 config 类上的 public static instance() 方法来获取它。要使用 IoC 框架,我需要将其作为构造函数传递。

换句话说,从我的角度来看,第一个也是不可避免的任务是更改每个使用这些单例的类,以便将单例工厂作为构造函数参数。这是大量的工作。

举个例子,我刚刚花了一个下午在 1 级图书馆做这件事,看看它做了多少工作。我最终更改了 1300 多行代码。 2级库会更差。

那么,还有其他选择吗?

【问题讨论】:

    标签: asp.net singleton


    【解决方案1】:

    通常,您应该尝试将上下文信息包装到它自己的实例中,并提供一个静态访问器方法来引用它。例如,考虑HttpContext 及其通过HttpContext.Current 在Web 应用程序中的任何位置都可用。

    您应该尝试设计类似的东西,以便从当前上下文中返回实例,而不是返回单例实例。这样,您无需更改引用这些静态方法的使用者代码(例如Logger.Instance())。

    我通常将记录器、当前用户、配置、安全权限等信息汇总到应用程序上下文中(如果需要,可以是多个类)。 AppContext.Current 静态方法返回当前上下文。方法实现类似于

    public interface IContextStorage
    {
            // Gets the stored context
            AppContext Get();
    
            // Stores the context, context can be null
            void Set(AppContext context);
    }
    
    public class AppContext
    {
        private static IContextStorage _storageProvider, _defaultStorageProvider;
    
        public static AppContext Current
        {
        get
        {
           var value = _storageProvider.Get();
           // If context is not available in storage then lookup
           // using default provider for worker (threadpool) therads.
           if (null == value && _storageProvider != _defaultStorageProvider
            && Thread.CurrentThread.IsThreadPoolThread)
           {
            value = _defaultStorageProvider.Get();
           }
           return value;
        }
        }
    
      ...
    }
    

    IContextStorage 实现是特定于应用程序的。静态变量_storageProvider 在应用程序启动时被注入,而_defaultStorageProvider 是一个查看当前调用上下文的简单实现。

    应用上下文创建发生在多个阶段 - 例如,配置等全局信息在应用程序启动时被读取和缓存,而用户和安全等特定信息则在身份验证阶段形成。一旦所有信息都可用,就会创建实际实例并将其存储到应用程序特定的存储位置。例如,桌面应用程序将使用单例实例,而 Web 应用程序可能会将实例存储到会话状态中。对于 Web 应用程序,您可能在每个请求开始时都有逻辑,以确保初始化上下文。

    对于可扩展的 Web 应用程序,您可以有一个存储提供程序,它将上下文实例存储到缓存中,如果缓存中不存在,则重新构建它。

    【讨论】:

    • 我们现在正在做类似的事情,在我们的网络应用程序上使用 HTTPContext,将我们的“单例”存储在会话中。这适用于每个会话对象。但是要迁移到无会话 Web 应用程序和/或服务,我们需要每个请求对象,除非我们阻止重新进入,否则我看不出这将如何与 AppContext 一起工作——而且我们做不到。 (或者,更确切地说,在我们可以的情况下,我们可以使用会话。)
    • @JeffDege,上下文的范围将决定存储位置,这就是存储提供程序被外部化的原因 - 您可以有一个存储提供程序从调用上下文中查找上下文(即每个请求存储)。但是,您是否需要为每个请求创建上下文实例应该取决于您在上下文中放入的内容和实例创建的成本。
    • 继续...例如,在我的一个项目中,控制台应用程序、Web UI 和 WCF 服务使用相同的上下文基础结构。 WCF 服务正在使用将其存储在ServiceContext(每个请求)中的提供程序。但是,上下文存储了用户信息和访问权限,因此创建每个请求的成本很高 - 因此每个用户身份都汇集了上下文实例。基础设施代码将从池中获取实例并与服务上下文相关联。
    • 明白了。一种 IContextStorage 可能使用静态变量,一种可能将 AppContext 存储在 HttpContext.Current.Session 中,另一种可能存储在 HttpContext.Current.Items 中。我不太了解的是您如何在应用启动时注入不同的 _storageProvider 实例。
    • @JeffDege,我使用一个简单的公共静态方法AppContext.Init 来注入存储提供程序。该方法在 web 应用程序中的 application_start 中很早就被调用,控制台应用程序中的 main 方法等。另外,请注意,上下文是相关上下文信息的包装器,您可以有多个上下文(例如 UserContext、SecurityContext 等)浮动并且每个都可以有不同的存储提供者。
    【解决方案2】:

    我建议从实施“穷人的 DI”模式开始。这是您在类中定义两个构造函数的地方,一个接受依赖项实例 (IoC),另一个默认构造函数用于新建它们(或调用单例)。

    通过这种方式,您可以逐步引入 IoC,并且仍然可以使用默认构造函数进行其他所有操作。最终,当您在大多数地方都使用 IoC 时,您可以开始删除默认构造函数(和单例)。

    public class Foo {
        public Foo(ILogger log, IConfig config) {
            _logger = log;
            _config = config;
        }
    
        public Foo() : this(Logger.Instance(), Config.Instance()) {}
    }
    

    【讨论】:

    • 我的一部分想要把整个事情拆散,并完全消除对这些单例的依赖。您的方法使这变得可行,尽管仍有很多工作要做。不过,有一件事 - 我会将无参数构造函数包装在 #if/#endif 编译指令中,这样我就可以将它们全部打开和关闭。切换出静态 instance() 方法,重新编写底部直到所有内容都编译并测试干净,构建所有无参数构造函数,但使用相同的标志切换。然后把它们都打开,看看这个层,然后是更高的层,是否仍然编译并通过测试。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2013-04-02
    • 2010-10-08
    • 2016-12-27
    • 1970-01-01
    • 1970-01-01
    • 2023-03-11
    • 1970-01-01
    相关资源
    最近更新 更多