类应如何向用户传达其实现的方法子集?


12

情境

Web应用程序IUserBackend使用以下方法定义用户后端接口

  • getUser(uid)
  • createUser(uid)
  • deleteUser(uid)
  • setPassword(uid,密码)
  • ...

不同的用户后端(例如LDAP,SQL等)实现此接口,但并非每个后端都能做所有事情。例如,一个具体的LDAP服务器不允许该Web应用程序删除用户。因此LdapUserBackend实现的类IUserBackend将不会实现deleteUser(uid)

具体的类需要向Web应用程序传达允许Web应用程序与后端用户进行的操作。

已知解决方案

我已经看到了一个解决方案,其中IUserInterface具有一种implementedActions方法,该方法返回一个整数,该整数是操作按位与所请求的操作进行“与”运算的结果:

function implementedActions(requestedActions) {
    return (bool)(
        ACTION_GET_USER
        | ACTION_CREATE_USER
        | ACTION_DELTE_USER
        | ACTION_SET_PASSWORD
        ) & requestedActions)
}

哪里

  • ACTION_GET_USER = 1
  • ACTION_CREATE_USER = 2
  • ACTION_DELETE_USER = 4
  • ACTION_SET_PASSWORD = 8
  • .... = 16
  • .... = 32

等等

因此,Web应用程序根据需要设置位掩码,并implementedActions()使用布尔值回答是否支持它们。

意见

在我看来,这些位操作看起来像C时代的遗物,就干净的代码而言,不一定易于理解。

什么是现代的(更好?)模式,该类用于传达其实现的接口方法的子集?还是上面的“位运算方法”仍然是最佳实践?

如果重要的话:PHP,尽管我正在寻找面向对象语言的通用解决方案


5
通用解决方案是拆分接口。完全IUserBackend不应包含该deleteUser方法。那应该是IUserDeleteBackend(或任何您想称呼它的)一部分。需要删除用户的IUserDeleteBackend代码将带有的参数,不需要该功能的代码将使用IUserBackend并且不会对未实现的方法造成任何麻烦。
Bakuriu

3
一个重要的设计考虑因素是动作的可用性是否取决于运行时环境。是所有不支持删除的LDAP服务器吗?还是那是服务器配置的属性,并且可能随着系统重启而改变?LDAP连接器应该自动发现这种情况,还是应该要求更改配置以插入具有不同功能的其他LDAP连接器?这些事情对哪些解决方案可行具有重大影响。
塞巴斯蒂安·雷德尔

@SebastianRedl是的,我没有考虑到这一点。我实际上需要运行时的解决方案。由于我不想使非常好的答案无效,因此我提出了一个新的问题,重点放在运行时上
专员

Answers:


24

从广义上讲,您可以在此处采用两种方法:测试和抛出或通过多态进行合成。

测试并抛出

这是您已经描述的方法。通过某种方式,您可以向该类的用户指示是否实现了某些其他方法。可以使用单个方法和按位枚举(如您所述)或通过一系列supportsDelete()etc方法来完成。

然后,如果supportsDelete()return false,则调用deleteUser()可能导致NotImplementedExeption抛出该结果,或者该方法什么都不做。

这很简单,因此在某些人中很流行。但是,许多人(包括我本人在内)认为这违反了Liskov的替代原则(SOLID中的L),因此不是一个很好的解决方案。

通过多态性合成

这里的方法是将IUserBackend工具视为过于钝化。如果类不能总是实现该接口中的所有方法,则将接口分解为更集中的部分。因此,您可能会有: IGeneralUser IDeletableUser IRenamableUser ... 换句话说,所有后端可以实现的所有方法都将进入,IGeneralUser并且您为只有一部分操作者可以执行的每个操作创建一个单独的接口。

这样,LdapUserBackend就不会实现IDeletableUser,您可以使用(使用C#语法)之类的测试来进行测试:

if (backend is IDeletableUser deletableUser)
{
    deletableUser.deleteUser(id);
}

(我不确定PHP中用于确定实例是否实现接口以及随后如何转换为该接口的机制,但是我确定该语言是否等效)

这种方法的优点是,它充分利用了多态性,使您的代码符合SOLID原则,而且在我看来,它的方式更加优雅。

缺点是它很容易变得笨拙。例如,如果由于每个具体的后端都有稍微不同的功能而最终不得不实现许多接口,那么这不是一个好的解决方案。因此,我只是建议您根据自己的判断来判断这种方法是否适合您,并在可能的情况下使用它。


4
+1为SOLID设计考虑。总是很高兴以不同的方式显示答案,这将使代码的清洁度不断提高!
卡勒布

2
在PHP中将是if (backend instanceof IDelatableUser) {...}
Rad80 '18

您已经提到违反LSP。我同意,但要稍加补充:如果输入值使其无法执行操作,例如,在Divide(float,float)方法中传递0作为除数,则Test&Throw有效。输入值是可变的,并且异常覆盖了可能执行的一小部分。但是,如果您根据实现类型进行抛出,那么它无法执行就是一个事实。该例外涵盖所有可能的输入,而不仅仅是它们的一部分。这就像在一个世界总是湿透的世界上,在每个湿透的地板上贴上“湿地板”的标志。
平坦

不抛出类型的原则有一个例外(双关语是无意的)。对于C#,即为NotImplementedException。此异常用于临时中断,即尚未开发但要开发的代码。这与最终确定给定的类永远不会对给定的方法做任何事情是一样的,即使开发完成之后。
平坦

谢谢你的回答。我实际上需要一个运行时解决方案,但未能在我的问题中强调它。由于我不想让您的答案太夸张,因此我决定提出一个新问题
问题人员

5

目前的情况

当前设置违反了接口隔离原则(SOLID中的I)。

参考

根据Wikipedia的说法,接口隔离原则(ISP)规定,不应强迫任何客户端依赖其不使用的方法。接口隔离原则是由Robert Martin在1990年代中期制定的。

换句话说,如果这是您的界面:

public interface IUserBackend
{
    User getUser(int uid);
    User createUser(int uid);
    void deleteUser(int uid);
    void setPassword(int uid, string password);
}

然后,实现此接口的每个类都必须利用该接口列出的每个方法。没有例外。

试想一下是否有一种通用方法:

public void HaveUserDeleted(IUserBackend backendService, User user)
{
     backendService.deleteUser(user.Uid);
}

如果要真正做到这一点,以使实际上只有一些实现类能够删除用户,则此方法有时会在您的面前夸张起来(或什么也不做)。那不是一个好的设计。


您建议的解决方案

我已经看到了一个解决方案,其中IUserInterface具有一个ImplementedActions方法,该方法返回一个整数,该整数是按位与操作与所请求的操作进行“与”运算的结果。

您本质上想要做的是:

public void HaveUserDeleted(IUserBackend backendService, User user)
{
     if(backendService.canDeleteUser())
         backendService.deleteUser(user.Uid);
}

我忽略我们如何精确地确定给定的类是否能够删除用户。不管它是布尔值还是位标志,都无所谓。一切都归结为一个二进制答案:可以删除用户,是还是否?

这样可以解决问题,对吗?好吧,从技术上讲,确实如此。但是现在,您违反了Liskov替代原则(SOLID中的L)。

放弃了相当复杂的Wikipedia解释,我在StackOverflow上找到了一个不错的示例。注意“坏”示例:

void MakeDuckSwim(IDuck duck)
{
    if (duck is ElectricDuck)
        ((ElectricDuck)duck).TurnOn();

    duck.Swim();
}

我认为您在这里看到了相似之处。这是应该处理的方法抽象对象(IDuckIUserBackend),但由于被盗用一流的设计,它被强制为第一把手 具体实现(ElectricDuck,确保它不是一个IUserBackend类不能删除用户)。

这违背了开发抽象方法的目的。

注意:此处的示例比您的案例更容易解决。对于该示例,只需在方法内部ElectricDuck启用转弯即可。两只鸭子仍然可以游泳,因此功能结果相同。Swim()

您可能想要做类似的事情。不要。您不仅可以假装删除用户,而且实际上有一个空的方法主体。尽管从技术角度来看这确实可行,但是它使得无法知道您的实现类在被要求执行某项操作时是否会真正执行某项操作。这是无法维护代码的温床。


我建议的解决方案

但是您说过,实现类仅可能处理其中一些方法(并且是正确的)。

举个例子,假设这些方法的每种可能组合都有一个实现它的类。它涵盖了我们所有的基地。

解决方案是拆分接口

public interface IGetUserService
{
    User getUser(int uid);
}

public interface ICreateUserService
{
    User createUser(int uid);
}

public interface IDeleteUserService
{
    void deleteUser(int uid);
}

public interface ISetPasswordService
{
    void setPassword(int uid, string password);
}

请注意,您可能已经在我的答案的开头看到了这一点。该接口分离原则的名字已经揭示了这一原则的目的是让你隔离接口到足够的程度。

这使您可以根据需要混合和匹配接口:

public class UserRetrievalService 
              : IGetUserService, ICreateUserService
{
    //getUser and createUser methods implemented here
}

public class UserDeleteService
              : IDeleteUserService
{
    //deleteUser method implemented here
}

public class DoesEverythingService 
              : IGetUserService, ICreateUserService, IDeleteUserService, ISetPasswordService
{
    //All methods implemented here
}

每个类都可以决定自己想做的事情,而不必每次都破坏接口的约定。

这也意味着我们不需要检查某个类是否能够删除用户。每个实现该IDeleteUserService接口的类都可以删除用户= 不违反Liskov替换原理

public void HaveUserDeleted(IDeleteUserService backendService, User user)
{
     backendService.deleteUser(user.Uid); //guaranteed to work
}

如果有人试图传递未实现的对象IDeleteUserService,则程序将拒绝编译。这就是为什么我们喜欢具有类型安全性的原因。

HaveUserDeleted(new DoesEverythingService());    // No problem.
HaveUserDeleted(new UserDeleteService());        // No problem.
HaveUserDeleted(new UserRetrievalService());     // COMPILE ERROR

脚注

我以极端为例,将接口分成尽可能小的块。但是,如果您的情况有所不同,则可以摆脱更大的障碍。

例如,如果每个可以创建用户的服务始终能够删除用户(反之亦然),则可以将这些方法保留为单个接口的一部分:

public interface IManageUserService
{
    User createUser(int uid);
    void deleteUser(int uid);
}

这样做不是分离较小的块,这没有技术优势。但这将使开发稍微容易些,因为它需要更少的电镀。


+1用于通过接口支持的行为来拆分接口,这是接口的全部目的。
格雷格·伯格哈特

谢谢你的回答。我实际上需要一个运行时解决方案,但未能在我的问题中强调它。由于我不想让您的答案太夸张,因此我决定提出一个新问题
问题人员

@问题官员:对于这些情况,运行时评估很少是最佳选择,但确实有这样做的情况。在这种情况下,您要么创建一个可以调用的方法,但是可能最终什么都不做(调用它TryDeleteUser来反映这一点)。或者,如果有可能但有问题的情况,则您有方法故意抛出Exception。使用CanDoThing()DoThing()方法可以工作,但需要外部呼叫者使用两个呼叫(否则将受到惩罚),这不太直观,也不那么优雅。
平坦

0

如果要使用更高级别的类型,则可以使用您选择的语言使用set类型。希望它提供了一些语法糖来进行集合相交和子集确定。

这基本上就是Java对EnumSet所做的事情(减去语法糖,但是,它是Java)


0

在.NET世界中,您可以使用自定义属性装饰方法和类。这可能与您的情况无关。

在我看来,您的问题可能与更高级别的设计有关。

如果这是UI功能,例如“编辑用户”页面或组件,那么如何屏蔽掉不同的功能?在这种情况下,“测试并抛出”将是一种效率不高的方法。它假定在​​加载每个页面之前,您对每个函数运行了一个模拟调用,以确定该小部件或元素应被隐藏还是以不同的方式呈现。或者,您拥有一个网页,该网页基本上会迫使用户通过手动的“测试并抛出”来发现可用的内容,无论您采用哪种编码途径,因为在弹出警告显示之前,用户不会发现不可用的内容。

因此,对于UI,您可能需要研究如何进行功能管理,并将可用的实现方式与之相关联,而不是让选定的实现方式来驱动可以管理的功能。您可能需要查看用于组合功能依赖项的框架,并在域模型中将功能明确定义为实体。这甚至可以与授权联系在一起。本质上,可以将基于授权级别来确定功能是否可用可以扩展到确定是否实际实现功能,然后高级UI“功能”可以具有到功能集的显式映射。

如果这是Web API,则随着功能的不断扩展,必须支持“管理用户” API或“用户” REST资源的多个公共版本,因此可能会使总体设计选择复杂化。

因此,总而言之,在.NET世界中,您可以使用各种反射/属性方式来预先确定哪些类实现了什么,但是无论如何,似乎真正的问题将在您对该信息进行的处理中。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.