你为什么要使用接口? - Janos Pasztor


我从很多人那里收到了同样的问题:为什么你甚至会使用接口?
当涉及到接口时,人们倾向于认为他们唯一的用途是当你有多个实现时,你可以轻松地将它们切换出来。然而,大多数人在他们的应用程序中没有特定功能的多个实现。那么为什么你会使用接口呢?在我们的IDE中的所有重构工具都很强大之后我们可以在以后介绍它们......

合同,而不是接口
大多数人都认为接口是这样的:

interface ContentAuthorizer {
    boolean authorize(String userId, String contentId);
}

此接口描述了必须实现的方法签名,但没有指示此方法应该如何操作,或者(取决于您的语言)是否接受空值以及抛出哪些异常。因此,实际记录预期行为几乎没有作用。
正如许多人,尤其是质疑接口有用性的人所认识到的那样,这并不是特别有用。如果我们没有多个ContentAuthorizer实现,这没有用。
相反,我想提倡改变哲学。不要将接口视为签名强制执行,而应将其视为合同。他们应该描述执行方必须如何表现以及使用方应注意什么(例如,哪些例外需要捕捉)。
因此,编写上述接口的更好方法是:

interface ContentAuthorizer {
    /**
     * Decide if a certain user can access a certain piece of content and
     * return true if the user is allowed to access the content.
     *
     * @param userId    the ID of the user requesting access. Must not be null
     *                  and must contain a valid user ID
     * @param contentId the ID of the content that access is requested to.
     *                  Must not be null and must contain a valid content ID.
     *
     * @return true if the user is allowed to access, false otherwise.
     *
     * @throws InvalidUserId    if the userId parameter is null or of an
     *                          invalid format.
     * @throws NoSuchUser       if the user specified with the ID is not
     *                          found.
     * @throws InvalidContentId if the contentId parameter is null or of
     *                          an invalid format.
     * @throws NoSuchContent    if the content specified with the ID is
     *                          not found.    
     */

    boolean authorize(String userId, String contentId);
}

哇,这是一个像这样的小功能的很多文字!但是,如果你看一下,我们定义了行为而不是签名。在实施之前,我们考虑了所有失败案例并定义了正确的错误处理。
如果你没有这样做,你有什么机会懒得做正确的例外处理,只是处理一切NullPointerException或者一个InvalidParameterException?有什么机会能找出底层代码抛出的异常?
合同的目的是定义一个内部API,您可以在不考虑底层实现的情况下使用它。就像一份写得很好的法律文件,它确切地说明了各方应该如何表现。

测试
现在,让我们更进一步。让我们假设您不仅需要一个好的结构,而且还想测试您的应用程序。正如前面所讨论的写的可能比较容易测试之一是单元测试。
单元测试被称为是因为它测试的单元(或类在我们的例子)隔离。这是什么意思?我们假设我们要测试一个这样的控制器:

class BlogPostController {
    public ViewModel getLatestBlogPosts() {
        //...
    }
}

这个控制器显然有一些依赖关系,我们当然会注入这些依赖关系

class BlogPostController {
    private BlogPostFetchBusinessLogic blogPostFetchbusinessLogic;
    //...

    public BlogPostController(
        BlogPostFetchBusinessLogic blogPostFetchbusinessLogic
       
//...
    ) {
        this.blogPostFetchbusinessLogic = blogPostFetchbusinessLogic;
       
//...
    }
    
   
//...
}

我们现在有两种情况:要么BlogPostFetchBusinessLogic是接口,要么是实际的实现。让我们来看看两种情况下我们的测试结果如何。首先是接口:

class BlogPostControllerTest {
    private BlogPostController createController() {
        return new BlogPostController(
            new FakeBlogPostFetchBusinessLogic()
        );
    }
    
    class FakeBlogPostFetchBusinessLogic implements BlogPostFetchBusinessLogic {
        //...
    }
}

因此,我们传递了一个实际的,简化的获取业务逻辑实现。这种虚假的业务逻辑没有其他依赖关系,因此为了测试的目的而实例化它相当容易。
现在,当我们实例化实际实现时,相同的代码是如何的?

class BlogPostControllerTest {
    private BlogPostController createController() {
        return new BlogPostController(
            new BlogPostFetchBusinessLogic(
                new BlogPostStorage(
                    new DatabaseConnectionFactory(
                        //database parameters
                    )
                )
            )
        );
    }
}

从本质上讲,您正在引入并测试整个应用程序,而不仅仅是控制器。如果任何底层有问题,那么在单元测试中也会出现故障。请记住,单元测试的目的是 准确地指出问题所在。如果30个测试失败,因为您在存储层的某处出现了一个错误,或者您的数据库不可用,那么在追踪问题时这不会非常有用。
总而言之,如果您编写单元测试,则确实有多个相同接口的实现。

代理模式
当你将接口作为工具中的工具包含在你的工具库中时,你也可以用它做很漂亮的技巧。假设您有一个从远程API获取一些数据的类。或者,更准确地说,让我们使用一个接口:

interface MyRemoteDataFetcher {
    /**
     * Fetch the remote data set by ID. As the remote data set is immutable, the method MAY return a cached version.
     *
     * ...
     */

    MyRemoteDataSet fetchRemoteData(String dataId);
}

正如您所看到的,接口描述得很好,实际上可以在本地缓存数据,因此不需要每次都重新获取,因为它无论如何都不会被修改。
如果我们现在决定将获取和缓存逻辑全部放在一个严重违反 单一责任原则的类中。所以,我们可以使用这样的接口:

class MyRemoteDataFetcherImpl implements MyRemoteDataFetcher {
    public MyRemoteDataSet fetchRemoteData(String dataId) {
        //fatch
    }
}

class MyCachingProxyRemoteDataFetcherImpl implements MyRemoteDataFetcher {
    private MyRemoteDataFetcher actualFetcher;
    
    public MyCachingRemoteDataFetcherImpl(
        MyRemoteDataFetcher actualFetcher
    ) {
        this.actualFetcher = actualFetcher;
    }
    
    public MyRemoteDataSet fetchRemoteData(String dataId) {
       
//Use the actual fetcher to fetch if the data is not cached
    }
}

提示:通常,您希望将实现称为更具描述性的内容,例如嵌入实现正在使用的库。一个很好的例子是UnirestRemoteDataFetcher和InMemoryCachingRemoteDataFetcher。
如您所见,接口的一个实现正使用另一个实现。然后我们可以配置我们的依赖注入器将它们链接在一起,让应用程序缓存数据。这样我们就不会违反SRP,如果我们以后决定放入缓存逻辑,我们也不必触及我们的fetcher实现。
告!根据合同的精神,只有在合同允许的情况下才应添加缓存!如果您在没有上层期望的情况下添加缓存,则可能会破坏应用程序!

结论
这里围绕接口列举了几个用例,但我希望你能将它作为一个非常有用的工具包含在你的工具库中。提前考虑并定义内部API可以为您节省大量时间。