接口隔离和单一职责原则的困境

Interface segregation and single responsibility principle woes

我正在尝试遵循 接口隔离单一职责 原则,但是我对如何实现它感到困惑一起。

这里有几个接口的示例,我将其拆分为更小、更直接的接口:

public interface IDataRead
{
    TModel Get<TModel>(int id);
}

public interface IDataWrite
{
    void Save<TModel>(TModel model);
}

public interface IDataDelete
{        
    void Delete<TModel>(int id);
    void Delete<TModel>(TModel model);
}

我稍微简化了它(有一些 where 条款妨碍了可读性)。

目前我正在使用 SQLite,然而,这种模式的美妙之处在于,如果我选择不同的数据存储,它有望让我有机会更好地适应变化方法,例如 Azure

现在,我对每个接口都有一个实现,下面是每个接口的简化示例:

public class DataDeleterSQLite : IDataDelete
{
    SQLiteConnection _Connection;

    public DataDeleterSQLite(SQLiteConnection connection) { ... }

    public void Delete<TModel>(TModel model) { ... }
}

... 

public class DataReaderSQLite : IDataRead
{
    SQLiteConnection _Connection;

    public DataReaderSQLite(SQLiteConnection connection) { ... }

    public TModel Get<TModel>(int id) { ... }
}

// You get the idea.

现在,我在将它们整合在一起时遇到了问题,我确定总体思路是创建一个使用 接口 [=48] 的 Database class =] 而不是 classes(真正的实现)。所以,我想到了这样的事情:

public class Database
{
    IDataDelete _Deleter;
    ...

    //Injecting the interfaces to make use of Dependency Injection.
    public Database(IDataRead reader, IDataWrite writer, IDataDelete deleter) { ... }
}

这里的问题是IDataReadIDataWriteIDataDelete接口应该如何暴露给客户端?我应该重写重定向到界面的方法吗?像这样:

//This feels like I'm just repeating a load of work.
public void Delete<TModel>(TModel model)
{
    _Deleter.Delete<TModel>(model);
}

突出显示我的评论,这看起来有点愚蠢,我费了很大的劲才将 classes 分成漂亮的、分离的实现,现在我将它们重新组合到一个 mega- class.

我可以将接口公开为属性,如下所示:

public IDataDelete Deleter { get; private set; }

这感觉好一点,但是,不应期望客户必须经历决定他们需要使用哪个接口的麻烦。

我是否完全忽略了这里的重点?求助!

继续这个例子,如果你想根据接口的组合来定义一个对象的能力,那么分解每种操作的力量就是。

因此,您可以拥有仅获取的内容,以及获取、保存和删除的内容,以及仅保存的内容。然后,您可以将它们传递给其方法或构造函数仅调用 ISaves 或其他内容的对象。这样一来,他们就不必担心知道某些东西是如何保存的,只担心它会保存,这是通过接口公开的 Save() 方法调用的。

或者,您可能有这样一种情况,其中数据库实现所有接口,但随后它被传递给只关心触发写入、读取或更新等的对象——所以当它被传递给该对象,它作为适当的接口类型传递,并且执行其他操作的能力不会暴露给消费者。

考虑到这一点,您的应用程序很可能不需要此类功能。您可能不会从不同的来源中提取数据,并且需要抽象出一种在它们之间调用 CRUD 操作的通用方法,这是第一个要解决的问题,或者需要将数据库作为数据源的概念解耦,因为与支持 CRUD 操作的对象相反,这是第二个要解决的问题。所以请确保这是为了满足需求而采用的,而不是为了遵循最佳实践——因为这只是采用某种实践的一种方式,但它是否 "best" 只能在内部确定它正在解决的问题的上下文。

Am I completely missing the point here? Help!

我不认为你完全错过了它,你在正确的轨道上,但在这种情况下走得太远了。您所有的 CRUD 功能都完全相互关联,因此它们属于一个单一的界面, 承担单一的责任。如果您的界面暴露了 CRUD 功能和一些其他职责,那么在我看来,重构为单独的界面是一个很好的选择。

如果作为您功能的使用者,我必须为插入、删除等实例化不同的 类,我会来找您。

不是一个真正的答案,但我想在这里放的比评论允许的更多。感觉就像您在使用存储库模式,因此您可以使用 IRepository 将其全部包装起来。

interface IRepository
{
    T Get<TModel>(int id);
    T Save<TModel>(TModel model);
    void Delete<TModel>(TModel model);
    void Delete<TModel>(int id);
}

现在您可以像上面那样拥有一个具体的数据库:

class Database : IRepository
{
    private readonly IDataReader _reader;
    private readonly IDataWriter _writer;
    private readonly IDataDeleter _deleter;

    public Database(IDataReader reader, IDataWriter writer, IDataDeleter deleter)
    {
        _reader = reader;
        _writer = writer;
        _deleter = deleter;
    }

    public T Get<TModel>(int id) { _reader.Get<TModel>(id); }

    public T Save<TModel>(TModel model) { _writer.Save<TModel>(model); }

    public void Delete<TModel>(TModel model) { _deleter.Delete<TModel>(model); }

    public void Delete<TModel>(int id) { _deleter.Delete<TModel>(id); }
}

是的,从表面上看它是一个不必要的抽象,但它有很多好处。正如@moarboilerplate 所说的那样,不要让 "best" 实践妨碍交付产品。您的产品决定了您需要遵循哪些原则以及产品要求的抽象级别。

下面是继续使用上述方法的一个快速好处:

class CompositeWriter : IDataWriter
{
    public List<IDataWriter> Writers { get; set; }

    public void Save<TModel>(model)
    {
        this.Writers.ForEach(writer =>
        {
            writer.Save<TModel>(model);
        });
    }
}

class Database : IRepository
{
    private readonly IDataReader _reader;
    private readonly IDataWriter _writer;
    private readonly IDataDeleter _deleter;
    private readonly ILogger _logger;

    public Database(IDataReader reader, IDataWriter writer, IDataDeleter deleter, ILogger _logger)
    {
        _reader = reader;
        _writer = writer;
        _deleter = deleter;
        _logger = logger;
    }

    public T Get<TModel>(int id)
    {
        var sw = Stopwatch.StartNew();

        _writer.Get<TModel>(id);

        sw.Stop();

        _logger.Info("Get Time: " + sw. ElapsedMilliseconds);
    }

    public T Save<TModel>(TModel model)
    {
         //this will execute the Save method for every writer in the CompositeWriter
         _writer.Save<TModel>(model);
    }

    ... other methods omitted
}

现在您可以有地方来增强功能。上面的示例显示了如何使用不同的 IDataReader 并为它们计时,而不必向每个 IDataReader 添加日志记录和计时。这也展示了如何拥有一个复合 IDataWriter,它实际上可以将数据存储到多个存储中。

所以,是的,抽象确实带有一些管道,它可能感觉好像不需要,但根据您的项目的生命周期,这可以在未来为您节省大量的技术债务。

当我们谈论接口隔离时,(甚至对于单一职责)我们谈论的是使实体执行一组逻辑上相关的操作并组合在一起形成一个有意义的完整实体。

想法是,class 应该能够从数据库中读取实体,并用新值更新它。但是,class 不应该能够获取罗马的天气信息并更新纽约证券交易所的股票价值!

为读取、写入、删除创建单独的接口有点极端。 ISP 并没有严格规定在接口中只放置一个操作。理想情况下,一个可以读取、写入、删除的接口构成了一个完整的(但不会因不相关的操作而变得笨重)的接口。在这里,一个界面中的操作应该是related而不是dependent

所以,按照惯例,你可以有一个像

这样的界面
interface IRepository<T>
{
    IEnumerable<T> Read();
    T Read(int id);
    IEnumerable<T> Query(Func<T, bool> predicate);
    bool Save(T data);
    bool Delete(T data);
    bool Delete(int id);
}

您可以将此接口传递给客户端代码,这对他们来说完全有意义。它可以与遵循一组基本规则的任何类型的实体一起使用(例如,每个实体都应该由整数 id 唯一标识)。

此外,如果您的 Business/Application 层 class 仅依赖于此接口,而不是实际实现 class,就像这样

class EmployeeService
{
    readonly IRepository<Employee> _employeeRepo;

    Employee GetEmployeeById(int id)
    {
        return _employeeRepo.Read(id);
    }

    //other CRUD operation on employee
}

然后您 business/application class 将完全独立于数据存储基础架构。您可以灵活地选择您喜欢的任何数据存储,并通过实现此接口将它们插入代码库。

您可以 OracleRepository : IRepository and/or MongoRepository : IRepository 并在需要时通过 IoC 注入正确的。

The question here is how should I expose the IDataRead, IDataWrite, and IDataDelete interfaces to the client?

如果您创建这些接口,那么您已经将它们暴露给了客户端。客户端可以将其用作使用 Dependency Injection.

注入到消费 classes 的依赖项

I went to a lot of trouble to separate the classes into nice, separated implementations and now I'm bring it all back together in one mega-class.

ISP 是关于分离接口而不是实现。在你的事业中,你甚至可以在一个 class 中实现这些接口,因为这样你就可以在你的实现中实现高凝聚力。客户甚至不知道你在一个 class.

中实现了这些接口
public class Database : IDataRead, IDataWrite, IDataDelete
{
}

这可能类似于以下内容:

public interface IRepository : IDataRead, IDataWrite, IDataDelete
{
}

但是,你不应该这样做,因为你失去了坚持 ISP 的优势。您分离了接口并创建了另一个聚合其他接口的接口。因此,每个使用 IRepository 接口的客户端仍然被迫实现所有接口。这有时称为 interface soup anti-pattern.

however, the client shouldn't be expected to have to go through the hassle of deciding which interface they need to use.

实际上,我认为您在这里漏掉了重点。客户必须知道他想做什么,以及 ISP 告诉我们不应该强迫客户使用他不需要的方法。


在您展示的示例中,当您遵循 ISP 时,很容易创建不对称数据访问。这是 CQRS 架构中熟悉的概念。想象一下,您想要将读取与写入分开。要实现这一点,实际上您不需要修改现有代码(多亏了您也遵守 OCP)。您需要做的是提供 IDataRead 接口的新实现并将此实现注册到您的 Dependency Injection 容器

当我设计存储库时,我总是从阅读写作.

的角度思考

这意味着我目前正在使用这些接口:

/// <summary>
/// Inform an underlying data store to return a set of read-only entity instances.
/// </summary>
/// <typeparam name="TEntity">The entity type to return read-only entity instances of.</typeparam>
public interface IEntityReader<out TEntity> where TEntity : Entity
{
    /// <summary>
    /// Inform an underlying data store to return a set of read-only entity instances.
    /// </summary>
    /// <returns>IQueryable for set of read-only TEntity instances from an underlying data store.</returns>
    IQueryable<TEntity> Query();
}

/// <summary>
/// Informs an underlying  data store to accept sets of writeable entity instances.
/// </summary>
/// <typeparam name="TEntity"></typeparam>
public interface IEntityWriter<in TEntity> where TEntity : Entity
{
    /// <summary>
    /// Inform an underlying data store to return a single writable entity instance.
    /// </summary>
    /// <param name="primaryKey">Primary key value of the entity instance that the underlying data store should return.</param>
    /// <returns>A single writable entity instance whose primary key matches the argument value(, if one exists in the underlying data store. Otherwise, null.</returns>
    TEntity Get(object primaryKey);

    /// <summary>
    /// Inform the underlying  data store that a new entity instance should be added to a set of entity instances.
    /// </summary>
    /// <param name="entity">Entity instance that should be added to the TEntity set by the underlying data store.</param>
    void Create(TEntity entity);

    /// <summary>
    /// Inform the underlying data store that an existing entity instance should be permanently removed from its set of entity instances.
    /// </summary>
    /// <param name="entity">Entity instance that should be permanently removed from the TEntity set by the underlying data store.</param>
    void Delete(TEntity entity);

    /// <summary>
    /// Inform the underlying data store that an existing entity instance's data state may have changed.
    /// </summary>
    /// <param name="entity">Entity instance whose data state may be different from that of the underlying data store.</param>
    void Update(TEntity entity);
}

/// <summary>
/// Synchronizes data state changes with an underlying data store.
/// </summary>
public interface IUnitOfWork
{
    /// <summary>
    /// Saves changes tot the underlying data store
    /// </summary>
    void SaveChanges();
}

有人可能会说 IEntityWriter 有点矫枉过正,可能会违反 SRP,因为它既可以创建也可以删除实体,而 IReadEntities 是一个有漏洞的抽象,因为没有可以完全实施 IQueryable<TEntity> - 但仍未找到 完美 方式。

为了Entity Framework,我实现了所有这些接口:

internal sealed class EntityFrameworkRepository<TEntity> : 
    IEntityReader<TEntity>, 
    IEntityWriter<TEntity>, 
    IUnitOfWork where TEntity : Entity
{
    private readonly Func<DbContext> _contextProvider;

    public EntityFrameworkRepository(Func<DbContext> contextProvider)
    {
        _contextProvider = contextProvider;
    }

    public void Create(TEntity entity)
    {
        var context = _contextProvider();
        if (context.Entry(entity).State == EntityState.Detached)
        {
            context.Set<TEntity>().Add(entity);
        }
    }

    public void Delete(TEntity entity)
    {
        var context = _contextProvider();
        if (context.Entry(entity).State != EntityState.Deleted)
        {
            context.Set<TEntity>().Remove(entity);
        }  
    }

    public void Update(TEntity entity)
    {
        var entry = _contextProvider().Entry(entity);
        entry.State = EntityState.Modified;
    }

    public IQueryable<TEntity> Query()
    {
        return _contextProvider().Set<TEntity>().AsNoTracking();
    }

    public TEntity Get(object primaryKey)
    {
        return _contextProvider().Set<TEntity>().Find(primaryKey);
    }

    public void SaveChanges()
    {
        _contextProvider().SaveChanges();
    }
}

然后我的命令处理程序依赖 IWriteEntities<MyEntity>,查询处理程序依赖 IReadEntities<MyEntity>。实体的保存(使用 IUnitOfWork)是通过使用 IoC 的装饰器模式完成的。