关于接口设计的一些思考
引子
做维护型工作,最大的收获也许就是知道什么叫做丑陋了。本文针对我遇到的一些接口设计问题,总结了如下一些经验分享给大家,希望我们能够吸取经验,对外提供最美的一面,即使我们的实现可能很丑,但是用户不关心也看不到,这就是封装的好处,哈哈。
1. 关于接口的粒度——应该提供应用无关的细粒度接口和应用相关的粗粒度接口
接口的粒度其实很大程度上是接口的职责问题。一般来说越细粒度的接口职责越内聚(偏向于Service),越粗粒度的接口职责越宽泛(偏向于Facade)。
笔者认为下面的做法是比较合理的:
- 对上层应用应该尽量提供粗粒度的接口,可以而且很多情况下是应用相关的。好处在于
- 提供更方便简介的接口,屏蔽多接口的协作细节。
- 避免细粒度接口可能导致的多次读取所带来的不必要的性能消耗。比如,第一个方法调用查询了数据库得到的对象,在接下来的接口中都可以被使用。
- 减少不必要的多次参数检查。
- 同时应该提供与具体应用无关的细粒度的接口,允许应用自己组装应用逻辑。好处在于
- 业务无关,粒度够细,有利于重用。
- 粒度细也就意味着职责清晰和内聚,利于解耦和维护。
- 利于单元测试
笔者认为接口一定要职责清晰,经常看到这样的代码,在一个for循环中"顺带"做了一些其他事情,原因是在这里作比较方便,否则需要重新遍历一次,影响性能。但是由于这顺带作的事情,往往会导致接口职责不单一,污染了接口,导致接口的复用性变差。所有应该尽量抵制诱惑,接口与类都应该是职责单一的。
再说接口的粒度,提供细粒度的接口有利于重用,就像积木块一样,应用可以根据需要自行组装。如果一开始就提供太粗粒度的接口,往往会有这样的情况,有些应用场景下我只需要做其中的几个步骤,这时候就容易导致重复类似的代码出现了。这种情况下,应该将粗粒度的接口进行分解,将通用的步骤封装成细粒度的接口(应该是应用无关),将粗粒度的接口暴露给上层应用(应该是应用相关的)。
使用粗粒度的接口可能带来的一个问题是返回情偏多,具体处理详见下面的关于返回值的讨论。
2. 关于接口的命名——使用面向场景的接口签名
接口名称应该尽量面向场景,这不仅是接口友好性的表现,另一方面也是避免内部逻辑外泄的重要措施。比如:现在是否通过AV认证,是否提交AV认证都没有相应的接口,而是提供了一个对AV_INFO_NEW表的查询操作接口,这样用户如果要检查是否已经提交AV认证信息,就必须这么做:
public static boolean hasUserSubmitAV(Integer companyId) { AvInfoNewDO avInfoNewDO = avInfoService.findAvInfoNewByCompanyId(companyId); if (avInfoNewDO == null || AvInfoStatusHelper.isAvinfoTransient(avInfoNewDO.getStatus())) { return false; } else { return true; } }
这就是内部业务逻辑的外泄,如果以后是否提交AV认证不是这么一个判断逻辑,就会导致大量的应用需要修改。另一方面,也是导致客户端很多重复代码。
目前为了方便使用spring的声明式事务处理,我们的service在配置上都是继承自intl-biz-datasource二方库中的transactionDefinition。
这本来是一件好事,但是带来的一个问题是Spring是根据方面签名进行AOP的,而父类定义的是CUD数据库操作类型的接口才拦截,这也导致了我们的接口看起来就象是一个CRUD接口。AOP不应该成为我们的一个限制,如果默认的AOP模式不能满足我们的需求,可以重载父类定义。这其实是很有必要的,在多service接口协作的过程中,可能需要不同的service接口有不用的事务传播类型。
另外,也可以考虑使用Anotation进行事务标注。
3. 关于接口的参数——最小粒度原则
现在很多接口都是这样动不动就是一个DO或者DTO对象作为参数,但是到了实现一看,发现其实就是用到DO/DTO的两三个字段而已。这种大对象作为参数实际上是一种非常不好的作法,特别是如果你只用到了大数据对象的一小部分字段而已。举个例子大家就比较容易理解我为什么对它如此深恶痛绝了:在com.alibaba.intl.biz.product.service.interfaces.ProductService获取产品独立detail页面URL的接口定义如下:
com.alibaba.intl.biz.product.service.interfaces.ProductService String getProductDetailUrl(URIBroker uriBroker, ProductSearchDTO product);
而在com.alibaba.intl.biz.product.service.impl.ProductServiceImpl中其实现只是用到了ProductSearchDTO的三个字段:getProductId(),getSubject(),getServiceType()。但是ProductSearchDTO这个类呢有十几个属性,并且还关联了一些DO对象。你说这样的一个接口给用户,它怎么知道应该填充这个DTO的那些字段呢?!这对内存空间也是一种浪费。 直接定义成这样的接口多简单: com.alibaba.intl.biz.product.service.interfaces.ProductService
String getProductDetailUrl(URIBroker uriBroker, Integer productId, String subject, ServiceType serviceType);
其实传递URIBroker给下层也是一个不合理的做法,但这不在我们这一次讨论范围内
什么情况下传递整个DO/DTO对象是合理有用的呢?笔者认为以下三种情况可以考虑:
- 参数太多(超过6个),可以考虑将这些参数封装成一个DO/DTO对象,
- 务必确保DO/DTO中的所有属性都被该接口使用到了。
- 作为内部实现,以pipeline处理方式填充DO/DTO对象。
4. 关于接口的返回值——如何处理多种返回情况
接口的处理结果可能有多个返回情况,特别是接口粒度越粗,返回情况就越多。如何将处理结果返回给客户,是一个需要好好考虑的问题。比如发布产品,如果将所有逻辑放在Service接口,那么接口必须支持多个返回结果,因为页面需要根据不同的结果进行不同的提示。比如:如果用户未提交AV认证,引导其先填写AV信息。如果用户发布的产品数超过了限度,提示之。如果用户类目失效,提示之。如果。。。
一般来说有以下两种方式:
-
一种定义一个ResultCode,即接口不只是返回true或者false,而是返回具体错误信息。页面层根据调用结果代码做相应的处理。但这会导致接口变得复杂,不好理解。
-
另一种方式是不通过返回值,而是定义自己的业务异常,通过抛出异常来告诉调用者结果。这会导致客户端好多try catch,不过我们的声明式事务控制也是要求Service接口抛出异常的。
建议是两者的结合。但是返回结果码是需要事先订下的,后来再加上相当于改变了接口签名。
5. 关于接口的可测性
好的接口不仅仅是对用户友好,还应该是对自己友好。其中很大方面表现在可测性上。简单来说,POJO对象可测性最高,所以尽量提供POJO参数接口。
在我们现在很多web层的util类中,web层对象到处走(甚至是web层框架特有对象),这导致可移植性,可测性和可复用性大大变差。比如WebUser虽然放在了ThreadLocal中,但是在biz和dal中获取一个webUser做相应的检查显然是有问题的,因为他的定义就是web层使用的。还有在web层的很多util和helper类中,大量传递webx特有对象——rundata和context,直接从rundata中过去参数,验证或者处理完又直接将结果塞入context中。特别是后者,看起来是方便,但是可测性就差好多了。因为你必须启动整个容器,才能测试。 这里举的例子主要都是针对web层的,其实biz层也是一样的道理。
6. 总结
面向对象设计最大的原则就是针对接口设计。可见接口的重要性,如果接口能够定义好,不仅便于自身维护,而且也导致上层应用不需要太多变动。想想Unix/Linux这么大的内核,也就200多个系统调用,非常稳定,没有太多变化,人家是面向过程的编程思想,能做到这样,确实有值得我们思考和学习的地方。希望我们以后在定义新接口的时候能够多思考一下。另外,我们的intl-biz-product实在太单薄了,考虑我们像会员线一样迁移到新的二方库中,新增的接口一律放在新二方库中,并且新增接口务必保证单元测试的完整性和可幂性。目前构想新的二方库结构应该是api和impl分开(为服务化作准备),dal和biz分开(经常看到很多forBOPS,forMoree的SQL...),biz层还要细分通用逻辑层(细粒度的,应用无关的接口)和业务门面层(粗粒度的,应用相关的),每一层可能都有相应的common/share包。当然。只是一个我个人的一些粗步的构想(跟海滔讨论过),欢迎大家提供建议。文档地址如下:http://b2b-doc.alibaba-inc.com/pages/viewpage.action?pageId=45513924
关于UT的幂等性
所谓UT的幂等性,即只要被测函数没有变化,跑N次这个UT应该都是一样的结果。要保证这一点,需要做到如下几点:
-
首先数据不能依赖于老数据,这意味着需要针对这个UT作相应的数据准备,可以使用DBUnit从数据库中导出数据,根据需要简单编辑一下。
-
其次还要保证数据的独立性,几个人同时操作一个数据库或者操作一个存在大量未知数据的数据库,将不能保证数据操作的正确性。要保证数据的独立性和隔离性,可以使用先清空,执行测试,最后再回滚的方式。另一种方式是每个人跑自己的测试数据库(可以使用内存数据库)。
-
第三个是减少外部函数的依赖。如果你的被测函数的结果依赖于另一个函数,那么你很难保证被测函数的幂等性。解决这个问题的一个方式是使用Mock对象。
有了上面这三点保证,我们就可以保证这个UT在如下的输入下必然有如下的输出,这就是幂等性。