设计一个用户友好的Java库
1. 概述
Java 是开源世界的支柱之一。几乎每个 Java 项目都使用其他开源项目,因为没有人想重新发明轮子。然而,很多时候我们需要一个库来实现它的功能,但我们不知道如何使用它。我们遇到类似的事情:
- 所有这些“*Service”类是什么?
- 我如何实例化它,它需要太多的依赖项。什么是“latch”?
- 哦,我把它放在一起,但现在它开始抛出IllegalStateException。我究竟做错了什么?
问题在于,并非所有库设计师都考虑到他们的用户。大多数人只考虑功能和特性,但很少有人考虑 API 在实践中的使用方式,以及用户代码的外观和测试方式。
这篇文章提供了一些关于如何为我们的用户节省一些困难的建议——不,这不是通过编写文档来实现的。当然,可以就这个主题写一整本书(并且已经写了几本书);这些是我自己在几个库中工作时学到的一些关键点。
我将在这里使用两个库来举例说明这些想法:charles 和jcabi-github
2. 边界
这应该是显而易见的,但很多时候并非如此。在开始编写任何一行代码之前,我们需要对一些问题有一个明确的答案:需要哪些输入?我的用户将看到的第一堂课是什么?我们需要用户的任何实现吗?输出是什么?一旦清楚地回答了这些问题,一切都会变得更容易,因为库已经有了一个衬里,一个形状。
2.1.输入
这可能是最重要的话题。我们必须确保清楚用户需要向库提供什么才能使其工作。在某些情况下,这是一件非常微不足道的事情:它可能只是一个代表 API 的身份验证令牌的字符串,但它也可能是接口的实现,或抽象类。
一个非常好的做法是通过构造函数获取所有依赖项并保持简短,并使用一些参数。如果我们需要有超过三四个参数的构造函数,那么代码显然应该重构。如果使用方法注入强制依赖项,那么用户很可能会遇到概述中描述的第三个挫折。
此外,我们应该始终提供多个构造函数,为用户提供替代方案。让他们同时使用String和Integer或者不要将它们限制为FileInputStream,使用InputStream,这样他们就可以在单元测试等时提交ByteArrayInputStream。
例如,以下是我们可以使用 jcabi-github 实例化 Github API 入口点的几种方法:
Github noauth = new RtGithub();
Github basicauth = new RtGithub("username", "password");
Github oauth = new RtGithub("token");
简单,没有喧嚣,没有要初始化的阴暗配置对象。拥有这三个构造函数是有意义的,因为您可以在注销、登录时使用 Github 网站,或者应用程序可以代表您进行身份验证。当然,如果您未通过身份验证,某些功能将无法使用,但您从一开始就知道这一点。
作为第二个例子,下面是我们如何使用 charles,一个网络爬虫库:
WebDriver driver = new FirefoxDriver();
Repository repo = new InMemoryRepository();
String indexPage = "http://www.amihaiemil.com/index.html";
WebCrawl graph = new GraphCrawl(
indexPage, driver, new IgnoredPatterns(), repo
);
graph.crawl();
我相信这也是不言自明的。然而,在写这篇文章时,我意识到在当前版本中存在一个错误:所有构造函数都要求用户提供IgnoredPatterns的实例。默认情况下,不应忽略任何模式,但用户不必指定这一点。我决定把它留在这里,所以你看一个反例。我假设您会尝试实例化 WebCrawl 并想知道“ IgnoredPatterns是什么?!”
变量 indexPage 是开始抓取的 URL,driver 是要使用的浏览器(不能默认为任何内容,因为我们不知道运行机器上安装了哪个浏览器)。repo 变量将在下一节中解释。
因此,正如您在示例中看到的那样,请尽量保持简单、直观和不言自明。以这样一种方式封装逻辑和依赖关系,使用户在查看构造函数时不会摸不着头脑。
如果您仍有疑问,请尝试使用aws-sdk-java 向 AWS 发出 HTTP 请求:您将不得不处理所谓的 AmazonHttpClient,它在某处使用 ClientConfiguration,然后需要在其间使用 ExecutionContext。最后,您可能会执行您的请求并获得响应,但仍然不知道 ExecutionContext 是什么。
2.2. 输出
这主要用于与外部世界通信的库。在这里,我们应该回答“如何处理输出?”的问题。再次,一个相当有趣的问题,但很容易出错。
再看看上面的代码。为什么我们必须提供一个 Repository 实现?为什么 WebCrawl.crawl() 方法不只返回 WebPage 元素的列表?处理爬取的页面显然不是库的工作。它怎么会知道我们想用它们做什么呢?像这样的东西:
WebCrawl graph = new GraphCrawl(...);
List<WebPage> pages = graph.crawl();
没有比这更糟的了。如果抓取的站点碰巧有 1000 个页面,则可能会突然发生 OutOfMemory 异常 - 库将它们全部加载到内存中。有两种解决方案:
- 继续返回页面,但实现一些分页机制,其中用户必须提供开始和结束数字。或者
- 要求用户使用名为 export(List<WebPage>) 的方法实现接口,该算法将在每次达到最大页面数时调用
第二种选择是迄今为止最好的;它使双方的事情变得更简单,并且更易于测试。想想如果我们采用第一个,那么在用户端需要实现多少逻辑。像这样,指定了页面的存储库(将它们发送到数据库中或可能将它们写入磁盘),并且在调用方法 crawl() 后无需执行任何其他操作。
顺便说一下,上面输入部分的代码是我们为了获取网站内容而必须编写的所有内容(仍然在内存中,正如 repo 实现所说,但这是我们的选择——我们提供了这样的实现我们承担风险)。
总结本节:我们永远不应该将我们的工作与客户的工作完全分开。我们应该始终思考我们创建的输出会发生什么。就像卡车司机应该帮助拆包货物,而不是简单地在到达目的地时将它们扔掉。
3. 接口
始终使用接口。用户只能通过严格的合同与我们的代码进行交互。
例如,在jcabi-github库中,RtGithub 类是用户实际看到的唯一类:
Repo repo = new RtGithub("oauth_token").repos().get(
new Coordinates.Simple("eugenp/tutorials"));
Issue issue = repo.issues()
.create("Example issue", "Created with jcabi-github");
Repo repo = new RtRepo(...)
由于逻辑原因,上述情况是不可能的:我们不能直接在 Github 存储库中创建问题,可以吗?首先,我们必须登录,然后搜索 repo,然后我们才能创建问题。当然,上面的场景是允许的,但是用户的代码会被大量样板代码污染:RtRepo可能必须通过其构造函数获取某种授权对象,授权客户端并获取正确的 repo等等
接口还提供了易于扩展性和向后兼容性。一方面,我们作为开发人员必须尊重已经发布的合约,另一方面,用户可以扩展我们提供的接口——他可能会装饰它们或编写替代实现。
换句话说,尽可能抽象和封装。通过使用接口,我们可以以一种优雅且不受限制的方式做到这一点——我们强制执行架构规则,同时让程序员自由地增强或更改我们公开的行为。
要结束本节,请记住:我们的库,我们的规则。我们应该确切地知道客户代码的外观以及他将如何对其进行单元测试。如果我们不知道这一点,没有人愿意,我们的库只会为创建难以理解和维护的代码做出贡献。
4. 第三方
请记住,一个好的库是一个轻量级的库。您的代码可能会解决问题并且可以正常工作,但是如果 jar 将 10 MB 添加到我的构建中,那么很明显您很久以前就丢失了项目的蓝图。如果您需要很多依赖项,您可能试图涵盖太多功能,并且应该将项目分解为多个较小的项目。
尽可能透明,尽可能不要绑定到实际实现。想到的最好的例子是:使用 SLF4J,它只是一个用于记录的 API——不要直接使用 log4j,也许用户想使用其他记录器。
通过您的项目传递的文档库,并确保您不包含危险的依赖项,例如xalan或xml-apis(为什么它们很危险,本文不详述)。
这里的底线是:保持您的构建轻盈、透明,并始终知道您正在使用什么。它可以为您的用户节省比您想象的更多的麻烦。