C# 中理解和实现服务定位器模式的初学者教程






4.77/5 (7投票s)
在本文中,我们将尝试理解服务定位器模式。
引言
在本文中,我们将尝试理解服务定位器模式。我们还将实现一个人为的实现来演示服务定位器模式。
背景
每当我们有一个场景,其中一个类提供某些功能,而另一个类想要使用此功能时,最简单的方法就是在客户端类中实例化提供服务的类并使用它。例如,类A
想要调用类B
的一个方法,我们可以在A
中简单地拥有一个B
的对象,并在需要时调用它的方法。代码看起来会是这样的。
public class B
{
public void DoTaskOne()
{
Console.WriteLine("B.DoSomething");
}
}
public class A
{
private B b;
public A()
{
b = new B();
}
public void GetOneDone()
{
b.DoTaskOne();
}
}
这种将类实例包含在其他类中的方法会奏效,但也有一些缺点。第一个问题是每个类都需要知道它想要使用的所有其他类。这将使应用程序成为维护的噩梦。此外,上述方法将增加类之间的耦合。
从最佳实践的角度来看,当我们设计类时,应该牢记依赖倒置原则。依赖倒置原则指出,更高级别的模块应始终依赖于抽象,而不是直接依赖于更低级别的模块。因此,我们应该始终以这种方式设计我们的类,使其总是依赖于接口或抽象类,而不是其他具体类。
所以我们在上面例子中看到的类将发生变化。我们首先需要有一个接口,A
可以使用该接口来调用DoTaskOne
。类B
应该实现这个接口。新的类将看起来像这样。
interface IDoable
{
void DoTaskOne();
}
public class B : IDoable
{
public void DoTaskOne()
{
Console.WriteLine("B.DoSomething");
}
}
public class A
{
private IDoable doable;
public A()
{
// How to create the doable object here???
// doable = new B();
// This seems wrong
}
public void GetOneDone()
{
doable.DoTaskOne();
}
}
上面的代码展示了完美设计的类,其中更高级别的模块依赖于抽象,而更低级别的模块实现这些抽象。但是等等……我们如何创建一个B
的对象呢?我们是否仍然应该像在之前的代码中那样做,即在A
的构造函数中调用new B
?但这不就违背了松散耦合的整个目的吗?
对这个问题的第一个回答是实现工厂模式。由于工厂模式完全抽象了从客户端类创建类的责任,我们可以有一个工厂类,它可以创建IDoable
类型的具体实例,并且类A
可以使用该工厂来获取IDoable
的具体实现,在这种情况下是类B
。所以有了工厂实现,我们的代码看起来会是这样的。
public class DoableFactory
{
public B GetConcreteDoable()
{
return new B();
}
}
// Constructor of A
public A()
{
DoableFactory factory = new DoableFactory();
doable = factory.GetConcreteDoable();
}
现在我们不会在本文中讨论工厂模式的细节,但我强烈建议您在继续之前熟悉这个模式。您可以在这里找到更多关于工厂模式的信息:C# 工厂模式理解与实现[^]
为什么我们要谈论工厂模式?
所以有人可能会想,为什么我们在谈论工厂模式,而我们的目标是谈论服务定位器。嗯,这个问题的答案在于,从调用代码的角度来看,这两种模式是相同的。通过使用工厂,我们解决了以下问题。
- 我们颠倒了客户端和服务类之间的控制。
- 我们的客户端代码依赖于抽象而不是实际的服务实现,即松散耦合。
- 我们有一个方法可以使用工厂类将具体实现与接口关联起来。
但是,在决定采用工厂模式之前,还有一些问题需要考虑。由于工厂类返回所请求对象的新实例,因此我们需要问几个问题。
- 构造的成本是多少?如果这个类在我们工厂内部创建非常昂贵,让客户端使用工厂并根据需要创建任意数量的实例是否是个好主意?
- 当客户端已经有一个现成的实例可供使用时,应该怎么做?即,我们不想实例化一个新类,而是返回对象的一个现有实例。
- 所有权呢?由于工厂类将新实例返回给调用客户端,因此调用代码/类拥有该实例。如果所有权在于其他人(这一点有些多余,因为前一点也一样,即返回其他人拥有的现有实例)。
现在,如果我们看看上面的问题,我们可能会发现自己需要一些不同于工厂的东西,它不是返回新实例,而是从系统中返回一个现有的对象实例。而这正是服务定位器发挥作用的地方。
使用代码
因此,从上述讨论中可以清楚看出,当我们想要“定位”一个现有的对象/服务并将其返回给调用者时,我们需要服务定位器而不是工厂。并且客户端代码不拥有返回的对象/服务,而只是使用它。
从这次讨论中可以清楚看出,我们需要创建一个服务定位器类,它能够:
- 允许应用程序为给定的契约(接口)注册具体实现(对象/服务)。
- 让调用客户端代码通过契约(接口)获取具体实现(对象/服务)。
让我们通过一个非常人为的例子来尝试看看它的实际效果。
我们将要处理的例子是一个简单的音频文件管理器,它允许用户管理给定的音频文件。这个应用程序的一部分是能够播放正在管理的文件。我们将这样实现:我们将有一个ApplicationFacade
类,它将处理来自表示层(在本例中是控制台)的播放请求。然后,该外观将使用IPlaybackService
接口来请求播放。假设当前的播放是由DirectX处理的,并且我们有一个用于通过DXPlaybackService
播放音频文件的具体类。但是,我们希望应用程序具有可扩展性,以便以后支持其他播放引擎(例如LibVLCPlaybackService
)。
现在,在这种情况下使用服务定位器的原因是,创建播放服务是一项昂贵的操作,我们不希望应用程序在请求操作时能够创建实例,因为这会在用户发出播放请求和实际播放之间造成延迟。此外,我们希望在我们的应用程序中只有一个引擎实例,该实例将被返回给调用代码。
让我们看看这个应用程序是如何设计的。让我们从查看封装音频文件的模型开始。
public class AudioFile
{
public string Title { get; set; }
public string FilePath { get; set; }
}
现在我们有了保存音频文件的的数据结构,让我们看看所有播放服务应该遵循的接口。
public interface IPlaybackService
{
void PlayFile(string filePath);
}
现在让我们尝试创建将使用DirectX播放音频文件(给定文件路径)的具体服务类。
public class DXPlaybackService : IPlaybackService
{
public void PlayFile(string filePath)
{
Console.WriteLine("DirectX is being used to play {0}", filePath);
}
}
现在,我们已经有了所有与播放相关的数据结构和服务,让我们尝试以这种方式编写我们的应用程序外观类,使其使用服务定位器来查找播放服务的具体实例并使用它来执行播放。
public class ApplicationFacade
{
AudioFile m_Audiofile = null;
IPlaybackService service = null;
public ApplicationFacade(AudioFile file)
{
m_Audiofile = file;
}
public void Play()
{
Console.WriteLine("Requesting playback for {0}", m_Audiofile.Title);
service = ServiceLocator.GetService < IPlaybackService >();
if(service != null)
{
service.PlayFile(m_Audiofile.FilePath);
}
}
}
我们还没有创建ServiceLocator
类。所以让我们创建一个简单的服务定位器,它能够通过接口注册和检索具体服务。
public class ServiceLocator
{
static Dictionary < string, object > servicesDictionary = new Dictionary < string, object >();
public static void Register < T >(T service)
{
servicesDictionary[typeof(T).Name] = service;
}
public static T GetService < T >()
{
T instance = default(T);
if(servicesDictionary.ContainsKey(typeof(T).Name) == true)
{
instance = (T) servicesDictionary[typeof(T).Name];
}
return instance;
}
}
这样,我们就拥有了一个初步的服务定位器实现。现在让我们尝试模拟应用程序的用户交互来让这一切生效。当用户运行应用程序时,应该发生以下情况。
- 我们将在应用程序启动时创建可用的播放服务。
- 我们将把该服务注册到我们的服务定位器类。
- 用户将选择要播放的音频文件。
- ApplicationFacade将在后台使用服务定位器来获取具体服务的句柄并播放音频文件。
以下代码模拟了以上所有提到的步骤。
static void Main(string[] args)
{
// Lets use the DirectX service to play the file
IPlaybackService playbackService = new DXPlaybackService();
// First let us register our audio playback service with service locator
ServiceLocator.Register < IPlaybackService >(playbackService);
// Lets try now to mimic an audio file playback
// Let the user select a file
AudioFile file = new AudioFile
{
Title = "Dummy File",
FilePath = "C:\\DummyFile.wav"
};
// Lets instantiate our application facade passing the audio file to it
ApplicationFacade facade = new ApplicationFacade(file);
// Lets Emulate the user request for playback
facade.Play();
Console.ReadLine();
}
当我们运行应用程序时,我们可以看到DirectX将被用于播放我们的音频。
现在,假设稍后我们决定使用LibVlc进行播放并为其编写服务。
public class LibVlcPlaybackService : IPlaybackService
{
public void PlayFile(string filePath)
{
Console.WriteLine("LibVlc is being used to play {0}", filePath);
}
}
现在唯一需要更改的是创建LibVlcPlaybackService
的实例并将其注册到我们的服务定位器。以下代码显示了我们模拟代码的那个版本。
static void Main(string[] args)
{
// Lets use the LinVlc service to play the file
IPlaybackService playbackService = new LibVlcPlaybackService();
// First let us register our audio playback service with service locator
ServiceLocator.Register < IPlaybackService >(playbackService);
// Lets try now to mimic an audio file playback
// Let the user select a file
AudioFile file = new AudioFile
{
Title = "Dummy File",
FilePath = "C:\\DummyFile.wav"
};
// Lets instantiate our application facade passing the audio file to it
ApplicationFacade facade = new ApplicationFacade(file);
// Lets Emulate the user request for playback
facade.Play();
Console.ReadLine();
}
现在,当我们运行应用程序时,我们可以看到LibVlc被用于播放我们的音频。
这里需要注意的重要一点是,我们不必更改调用者代码,即ApplicationFacade
来使用新服务。
有趣的点
这里需要记住的重要一点是,当我们想要定位并向调用者返回一个现有的对象/服务实例,并且不希望调用者拥有返回对象的拥有权时,应该优先使用服务定位器而不是工厂。在我们的例子中,我们在代码中创建和注册新服务,但这只是为了演示目的。我们可以将新服务创建在单独的DLL中,并将服务注册放在配置文件中(服务定位器负责使用配置文件来确定要使用的服务)。或者,我们可以像某些ORM和IoC容器那样,允许用户在应用程序启动时显式注册服务。本文是从初学者的角度撰写的。希望它有所帮助。
历史
- 2016年1月20日 - 初版