写点什么

抽象

用户头像
落英亭郎
关注
发布于: 2020 年 05 月 12 日
抽象

抽象这个东西,说起来很抽象,其实很简单。



WHAT

抽象是什么?按维基百科的说法:“在计算机科学中,抽象化(英语:Abstraction)是将数据与程序以它的语义来呈现出它的外观,但是隐藏起它的实现细节。”这个定义也许还有些“抽象”,举几个例子来看,它其实简单。



“做技术、如艺术”,计算机中的“抽象”与艺术中的“抽象”颇有异曲同工之妙



“抽象”在我们的日常工作和生活中比比皆是。例如,我们经常会说,“我是一个开发”,“这事儿你得找产品”,这里的“开发”、“产品”,都是一种抽象。它们定义了“开发要写设计、写代码、写单测”、“产品要写ppt、写word、写excel”这样一种“语义外观”,但是它们自己并不会写代码或写文档,这些实现细节隐藏在“职位”之下、由具体的“员工”来完成。



看到职位,我们就能知道这是做什么的;但具体怎么做?只有实际干活的员工最清楚。



在技术上,这样的例子更是俯拾皆是。例如,Slf4j提供了一个日志的抽象,它定义了“怎么打印日志”这个“语义外观”,但是它隐藏了实际打印日志的实现细节——是log4j、还是logback?使用Slf4j时我们是不知道的。还有,Jdbc的Driver、Connection和Statement定义了“怎么操作数据库”这个“语义外观”,但是它也没有实际去操作数据库。这些实现细节是由抽象之下的具体实现来处理的。



怎么打印日志?Slf4j会告诉你:logger.info("a={}",a)。但Slf4j不会告诉你,这个logger到底是Log4j还是Logback。



在我们的业务系统中,“抽象”的实例也随处可见。最常见的,一个设计良好的接口就是一个业务的抽象,它定义了这项业务支持哪些操作。

例如,我们有一个短信签约接口定义了submit、sendCode、submitCode三个方法,本质上就是定义了“短信签约操作有三个步骤”这样一个业务抽象。至于每个步骤都是如何实现的,这是底层逻辑的事情了——实际上,这三个步骤的底层用的是同一个方法。

又如,我们有一个冻结订单的接口,定义了frozenFlow、frozenLimit、forzenTransport三个方法,这也就是定义了“冻结一笔订单必须冻结Flow、Limit和Transport这三类数据”这样一个业务抽象。至于这三类数据具体如何冻结么——就我们系统来说,有的业务是直接逻辑删除,有的业务是把数据回滚到初始状态,有的业务则干脆不需要处理Transport数据——这又是底层逻辑需要考虑的事情了。



所以,抽象是什么?抽象就是这样一个东西:它告诉了你自己能做什么、但不告诉你它是怎么做的



就像抽象艺术:就算明白告诉你这是艺术,你也不明白它怎么就成了艺术了




WHY

设计出一个好的抽象,除了能隐藏底层实现之外,还有什么好处吗?我们为什么要在“抽象”这虚的东西上下功夫呢?



借用另一篇文章的话来说:抽象设计得越好,代码就越简单易用;代码可替代性就越好;可扩展性就越好。



简单易用

为什么说抽象设计得越好、代码就越简单易用呢?



因为一个好的抽象设计隐藏了它的底层实现,使得我们在使用它的时候,不需要关注底层的细节。



就好比开自动挡的车时不用关心离合换挡的事儿,开起来当然比手动挡要简单方便啦。

手动还是自动?这是一个问题。



例如,我们看看下面这个接口:

public interface QueryService{
public Bean queryFromRemote(long id);
public Bean queryFromLocal(long id);
}



这个接口提供了两个方法,两个方法的入参、出参都是一模一样的,区别只在于方法名——以及名字所暗示的,是从“远程”查询、还是从“本地”查询。如果调用方在使用时,确实需要区分数据来源,这个设计倒也无可厚非。但是,实际上调用这两个方法时,所有的代码都是这个样子的:

Bean bean = queryService.queryFromLocal(id);
if(bean == null){
bean = queryService.queryFromRemote(id);
}
if(bean == null){
throw new Excepton();
}



这样的代码出现了至少五次。啰嗦吗?啰嗦。麻烦吗?麻烦。闻着臭吗?臭。为什么每次调用这个接口时都要这么写呢?因为这个接口把自己底层的实现——是从远程获取数据、还是从本地获取数据——暴露出来了。换句话说,这个接口的抽象设计得不够好。如果我们把这个接口这样设计:

public interface QueryService{
public Beean query(long id);
}



顺便,底层这样实现:

public class QueryServiceImpl{
public Bean query(log id ){
Bean bean = queryFromLocal(id);
if(bean == null){
bean = queryFromRemote(id);
}
if(bean == null){
throw new RuntimeException();
}
return bean;
}
}



那么,我们就可以这样调用这个接口了:

Bean bean = queryService.query(id);



这样重新设计/实现过之后,使用起来是不是简单、方便多了?这就是良好的抽象设计的第一个优点。



可替代性

为什么说抽象设计得好,代码的可替代性就越好呢?



这同样是因为一个好的抽象设计隐藏了它的底层实现,无论我们怎么更换实现细节,只要对外抽象不变,调用方都不受影响。



这就好比我们去银行柜台取钱:只要能把钱正确取出来,柜员是男是女、是胖是瘦、甚至于是活人还是机器,这都无所谓。

看看,是不是哪个妹子都OK?



我参与设计过一套账务系统。在这个系统中,我们把所有账户间的转账操作全部抽象为下面这个接口。这个接口所表达的业务含义是:从账户from向账户to转入金额amount元,记账科目是type。

public interface AccountService{
public void trans(Account from, Account to, Money amount, TransType type);
}



在这个接口的“语义外观”下,我们更换过很多种底层实现方式:单边账、双边账、会计科目记账;同步操作、异步操作、批量操作;等等等等。没有一次变更影响到了接口调用方,最终找到了既能满足所有业务功能、又提高了处理性能的最佳方案。这就是在好的抽象设计下的代码可替代性带来的好处。



有成功的经验,自然也有失败的教训。我写过一套Java操作Excel文件的工具,底层用的是POI组件。这套工具的核心接口大概是这个样子的:

public interface ExcelService<T>{
public List<T> read(HSSFWorkbook workBook);
}



从这个接口的“语义外观”来看,这个模块的功能,简单来说就是传入一个Excel文件、并把其中的数据解析为一组对象T。



但是,这个接口存在一个很大的问题在于:接口的底层实现——也就是HSSFWorkbook——被暴露出来了。这就导致了这个模块只能解析2003版的Excel文件,面对用户上传的2007版Excel文件,它就无能为力了。



不仅如此,如果要把工具升级到2007版,所有调用方都必须跟着一起改:在我们的系统里,这意味着要多修改二十多处代码、多回归测试几十个功能。其中的困难可想而知。



如果这个接口设计得更好——例如把入参改成FilePath或者InputStream,它的底层代码的可替代性就更高,重构、优化、需求变更时需要修改的地方就更少。改得越少,开发的工作量、加班量就越少,出bug的几率也会更少。

可扩展性

为什么说抽象设计得好,代码的可扩展性就越好呢?



这和可替代性有相似之处:根子上还是因为一个好的抽象设计能隐藏它的底层实现。



就像家里给小孩儿炖汤;妈妈去厨房尝了一勺,然后多撒了一把葱花;姥姥又去尝了一勺,然后多加了点姜片;奶奶又去尝了一勺,然后多加了点花椒……在全家人的不断“扩展”下,最后留给小孩儿的虽然只剩一勺汤,但这一勺绝对是浓缩的精华哈哈哈。



举一个实际业务场景中的例子。我们系统中有一个查银行卡列表的接口,客户端查到列表后,需要根据不同的场景来展示或“置灰”某些卡。例如,划扣场景下,不支持自动划扣的卡就必须置灰;解绑定场景下, 跟某些业务绑定的卡就必须置灰;业务绑卡场景下,已经跟该业务绑定的卡就必须置灰……等等等等。



我们为这个业务所设计的抽象和相关的实现代码是这样子的:

/** 核心业务接口 */
public interface CardListService{
List<Card> query(long userId, Scene scene)
}
/** 最主要的实现是这样的 */
public class CardListServiceImpl{
private Map<Scene, CardListService> serviceMap;
public List<Card> query(long userId, Scene scene){
return serviceMap.get(scene).query(userId, scene);
}
}
/** 返回字段是这样的 */
public class Card{
// 客户端根据这个字段的值来判断当前银行卡是展示还是置灰
private boolean enabled;
// 其它卡号、银行名等字段,和accessor略去
}
/** 入参是这样的 */
public enum Scene{
DEDUCT,
UN_BIND,
BIND;
}



服务端根据客户端传入的Scene来判断这些卡是否应当展示,并返回对应的enabled字段值。而客户端不需要关注List<Card>中的银行卡是不是支持自动划扣、是不是和某个业务绑定,只需要关注返回结果中的enabled字段:enabled为true,则允许用户选择这张卡;enabled为false,则只展示这张卡、但不允许用户点选。



而且,无论哪个Scene下要增加逻辑,或者要增加新的Scene,都只需要服务端做出修改,客户端是不需要变的。而且即使是服务端,需要修改或增加的代码量也不大,非常简单。



简单易用、可替代和可扩展这些特性,对于业务系统来说,有时其重要性甚至比对技术中间件还要高。



业务系统的一个重要特点,就是业务需求在不停变化、频繁变化:今天需求是这样,明天就推翻不做了,后天又重新把需求翻出来出来,大后天再改一版……如果系统的设计和实现被需求牵着鼻子走,那开发就有改不完的代码、加不完的班了。



好好地设计一套业务抽象,让系统和代码简单易用、易于替换、易于扩展,才有可能在少修改代码、甚至不修改代码的基础上去满足多变的业务需求。开发才能从业务代码中释放出来,去提升自己、优化系统。



不加班、不秃头




HOW

怎样设计一个好的抽象呢?其实我们已经有很多方法论/工具箱了:高内聚/低耦合、封装/继承/多态、SOLID、设计模式……等等等等,不一而足。只不过以前讨论它们的时候,更多地是在“就事论事”地讨论它们自身,而并没有考虑到它们与“抽象”的关系。怎样从业务抽象的角度去理解和应用这些方法和工具、又怎样运用它们来建立良好的业务抽象呢?我们下回分解吧。



个人公众号:景昕的花园



发布于: 2020 年 05 月 12 日阅读数: 47
用户头像

落英亭郎

关注

还未添加个人签名 2017.12.11 加入

还未添加个人简介

评论

发布
暂无评论
抽象