MVVM + Services + Entity Framework 和依赖注入 vs 服务定位器

3

我有很多使用WPF和MVVM的系统。为了进行单元测试,我们向视图模型注入依赖项,但是我发现当在构造函数中注入依赖类时,我们无法控制其生命周期,例如Entity Framework DbContext。

一个简单的场景如下:

public class FooVM
{
    private readonly IBarService _barService;

    // Set in the UI via Databinding
    public string Name { get; set; }
    public string OtherName { get; set; }

    public FooVM(IBarService barService)
    {
        _barService = barService;
    }

    public void SaveFoo()
    {
        _barService.SaveFoo(Name);
    }

    public void SaveBar()
    {
        _barService.SaveBar(OtherName);
    }
}

public class BarService : IBarService
{
    private readonly IEntityContext _entityContext;

    public BarService(IEntityContext entityContext)
    {
        _entityContext = entityContext;
    }

    public void SaveFoo(string name)
    {
        // some EF stuff here
        _entityContext.SaveChanges();
    }

    public void SaveBar(string otherName)
    {
        // some EF stuff here
        _entityContext.SaveChanges();
    }
}

VM需要使用服务,因此已经将其注入,服务需要一个IEntityContext,因此也被注入了该对象。问题在于,当我们在VM中调用SaveFoo和SaveBar时,_entityContext对象在单个调用后就变得脏了。理想情况下,我们希望在每次调用后处理_entityContext对象。我发现唯一的解决方法是使用依赖注入来注入容器,然后按照以下方式调用代码:
public class FooVM
{
    private readonly IInjector _injector;

    // Set in the UI via Databinding
    public string Name { get; set; }
    public string OtherName { get; set; }

    public FooVM(IInjector injector)
    {
        _injector = injector;
    }

    public void SaveFoo()
    {
        var barService = _injector.GetUniqueInstance<IBarService>();
        barService.SaveFoo(Name);
    }

    public void SaveBar()
    {
        var barService = _injector.GetUniqueInstance<IBarService>();
        barService.SaveBar(OtherName);
    }
}

以这种方式,容器(IInjector)充当一个服务定位器的角色,这样做很好,但在单元测试中不太灵活。有更好的方法来管理这个吗?我知道这样做几乎使依赖注入的所有好处都失效了,但我找不到其他方法。
编辑:进一步的例子
假设您有一个带有两个按钮的窗口。其中一个服务通过依赖注入注入到其中。您单击按钮A并加载对象,然后对其进行修改并保存,但是由于某些原因(比如DbContext中的某些验证失败),此操作失败,您会看到一条友好的消息。
现在,您单击按钮2。它加载一个不同的对象并对其进行修改并尝试保存,但由于先前单击了按钮A,而且服务是相同的服务,并且具有相同的上下文,所以此操作将因为与单击按钮A时相同的原因而失败。

1
可能是重复问题:https://dev59.com/SWUp5IYBdhLWcg3w1qIi - Gert Arnold
6个回答

6

我的公司也从事与你所询问的相同业务,并且我们使用仓储和工作单元工厂模式来解决这个问题。

更简单的版本如下:

public class BarService : IBarService
{
    private readonly IEntityContextFactory _entityContextFactory;

    public BarService(IEntityContextFactory entityContextFactory)
    {
        _entityContextFactory = entityContextFactory;
    }

    public void SaveFoo(string name)
    {
        using (IEntityContext entityContext = _entityContextFactory.CreateEntityContext())
        {
            // some EF stuff here
            entityContext.SaveChanges();
        }
    }

    public void SaveBar(string otherName)
    {
        using (IEntityContext entityContext = _entityContextFactory.CreateEntityContext())
        {
            // some EF stuff here
            _entityContext.SaveChanges();
        }
    }
}

还有工厂:

public class EntityContextFactory : IEntityContextFactory
{
    private readonly Uri _someEndpoint = new Uri("http://somwhere.com");

    public IEntityContext CreateEntityContext()
    {
        // Code that creates the context.
        // If it's complex, pull it from your bootstrap or wherever else you've got it right now.
        return new EntityContext(_someEndpoint);
    }
}

您的IEntityContext需要实现IDisposable接口才能在这里使用"using"关键字,但这应该是您所需的要点。


我认为这可能是最好的方法。虽然它没有被注入,但你仍然不会忽视依赖项,至少只有一个依赖项。 - Ryan Amies
这里的想法是依赖关系被反转了。您仍然可以“注入”将用于访问上下文的对象,而服务本身没有任何对具体实现的引用。当您想要控制对象的生命周期,但不知道如何“创建”对象时,工厂确实是唯一干净的方法。有些人会拉取他们的服务定位器服务并以这种方式创建对象,但我并不是真正的粉丝,因为它将您与特定的服务定位器耦合在一起。 - JesseNewman19

1
你应该使用工厂方法创建每次的数据库上下文。如果你想使用 Autofac,它已经为此自动生成了工厂方法。你可以使用动态实例化来每次创建dbcontext。你可以使用受控生命周期来管理dbcontext的生命周期。如果你将两者结合起来,你将在每个方法中都拥有dbcontext,并且可以自己管理其生命周期(自行处理Dispose)。
当你进行测试时,你只需要注册IEntityContext的模拟实例即可。
public class BarService : IBarService
    {
        private readonly Func<Owned<IEntityContext>> _entityContext;

        public BarService(Func<Owned<IEntityContext>> entityContext)
        {
            _entityContext = entityContext;
        }

        public void SaveFoo(string name)
        {
            using (var context = _entityContext())
            {
                context.SaveChanges();
            }
        }

        public void SaveBar(string otherName)
        {
            using (var context = _entityContext())
            {
                context.SaveChanges();
            }
        }
    }

如果您想管理所有的dbcontexts生命周期,我们可以移除Owned并且我们可以注册您的上下文ExternallyOwned。这意味着autofac将不再处理该对象的生命周期。
builder.RegisterType<EntityContext>().As<IEntityContext>().ExternallyOwned();

那么你的字段和构造函数应该像这样:
private readonly Func<IEntityContext> _entityContext;

            public BarService(Func<IEntityContext> entityContext)
            {
                _entityContext = entityContext;
            }

1

1
你正在使用哪个依赖注入框架?使用Autofac时,你会遇到一种叫做LifeTimeScope的东西。其他框架可能也有类似的功能。

http://docs.autofac.org/en/latest/lifetime/index.html

基本上,您需要确定应用程序上的工作单元是什么(每个ViewModel实例?每个ViewModel操作?),并为每个UoW创建一个新的LifeTimeScope,并使用生命周期范围解决依赖项。根据您的实现方式,它可能看起来更像服务定位器,但它使得管理依赖关系的生命周期相对容易。(如果您将DBContext注册为PerLifeTimeScope,您可以确保在同一生命周期范围内解析的所有依赖项都将共享相同的dbcontext,并且不会被用于解析另一个生命周期范围的依赖项)。
此外,由于生命周期范围实现了接口,因此可以轻松地模拟以解析模拟服务以进行单元测试。

Ninject,但问题不在于DI框架提供的依赖项的生命周期,而在于您何时需要这些依赖项。 - Ryan Amies
@RyanAmies 嗯,据我所知,WPF没有提供任何机制来正确实现IoC。如果您想为每个操作解决依赖关系,则除了像第二个代码示例中手动执行之外,没有其他选择。但我仍建议使用对象范围而不是根容器来解决依赖关系。这里是Ninject的等效方法 https://github.com/ninject/ninject/wiki/Object-Scopes。 - andyroschy

1
正如@ValentinP所指出的那样,我也认为你正在走错路线,但原因不同。
如果您不想在DbContext实例中污染状态跟踪,以防止已在数据库查询期间检索的对象与其他操作一起被意外持久化,那么您需要重新设计应用程序并将业务逻辑拆分为两个逻辑层。一个层用于检索,另一个层用于持久化,每个层都会使用自己的DbContext实例,这样您永远不必担心已检索和操作的对象会与另一个操作一起被持久化(我假设这就是您提出问题的原因)。
这是一种被广泛接受的模式,称为命令查询责任分离或简称CQRS。请参见Martin Fowler的CQRS文章此Microsoft文章,其中包含代码示例。
使用此模式,您可以处理DbContext实例(直接或间接通过根拥有对象的Dispose)。 根据最新编辑进行编辑

这种情况解决了很多关于你试图实现什么的问题。

  1. 我坚持实施CQRS选项,因为我仍然相信它是适用的。
  2. 在应用程序中不使用长时间存活的DbContext实例是一种常见方法。在需要时创建一个,完成后将其销毁。创建/销毁DbContext对象本身的开销很小。然后,您应该重新附加任何修改过的模型/集合到新的DbContext中,以便您要持久化更改,没有理由从底层存储中重新检索它们。如果出现故障,则代码的入口点(无论是在服务层还是演示层)都应处理错误(显示消息,恢复更改等)。使用 TimeStamp/Rowversion 处理并发异常也是使用此方法正确的方式。此外,因为您使用了新的DbContext,所以您不必担心其他命令也可能在同一视图上执行,如果它们尝试执行独立的内容而失败。
你应该能够指定注入的每个对象的生命周期范围。对于你的,你可以指定为瞬态(这是默认值),并将其注入到适当的服务层构造函数中。每个实例应该只有一个所有者/根。如果你使用CQRS模式,这变得更容易管理。如果你使用类似DDD模式的东西,它会变得更加复杂,但仍然可行。或者你也可以在线程级别上指定生命周期范围,虽然我不建议这样做,因为如果你忘记了这一点,尝试添加一些并行编程或使用async/await模式而没有重新捕获原始线程上下文,这可能会引入很多意外的副作用。

你对我提问的原因的假设是错误的。分离检索/持久化代码没有问题。问题在于来自失败的持久化请求的污染。请参见我编辑后的问题,了解更多情况,但是你关于HttpRequests的评论让我得出结论,你没有阅读问题。 - Ryan Amies
@RyanAmies - 我不知道那个(httprequest)是怎么搞进去的,但我确实看了你的问题。感谢你对你想要实现的内容进行了澄清。 - Igor

0
  1. 我认为每次创建和处理DbContext都是一种不好的做法。这似乎会非常影响性能。
  2. 因此,你不想提取SaveChanges方法吗?它只会在DbContext上调用SaveChanges。
  3. 如果你无法这样做,我认为创建一个ContextFactory比使用服务定位器更好。我知道例如Windsor可以为给定接口自动生成工厂实现(http://docs.castleproject.org/Default.aspx?Page=Typed-Factory-Facility-interface-based-factories&NS=Windsor)。这对于语义和测试目的来说更好。重点在于透明的工厂接口,其实现基于IoC配置和生命周期策略。
  4. 最后,如果您不关心立即进行更改推送,您可以创建IDisposable DbContext包装器,在处理时保存更改。假设您正在使用某些请求/响应范例和每个请求的生命周期管理。

3
不创建和处理每个DbContext是不好的做法。工厂和服务定位器有什么不同? - Ryan Amies
好的,我不记得具体是什么了,但是“每次”指的是每个请求中创建上下文超过一次。工厂(使用自定义非泛型接口提供上下文创建方式)更适合,因为1)它有很强的语义,2)类依赖关系似乎更清晰,3)有时测试更方便。此外,工厂被认为是依赖项,所以这篇文章http://martinfowler.com/articles/injection.html就是讲述它的。Windsor自动生成的工厂是将自定义接口与容器能力结合起来的完美解决方案。 - Valentin P.
1
@RyanAmies - "工厂与服务定位器有何区别?" 使用工厂时,可以通过 Intellisense 查看对象构造函数,以确定需要配置的确切类型。使用服务定位器时,必须打开源代码并分析它才能确定所需的依赖项。考虑到您需要配置类的次数(DI、单元测试、集成测试等)乘以在每个类中使用服务定位器并忘记其依赖项的次数,这会导致大量应用程序配置的浪费人力工时。 - NightOwl888

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接