开篇词
毫无疑问微服务架构是目前最主流的大型互联网应用系统架构方式,因为一个大型系统被拆分为若干个子应用,故子应用之间相互调用进行数据读写这件事情变得像呼吸一样普遍。每个一个程序员都能够写代码实现一个RPC服务的调用,但不同的实现方式体现着程序员的不同境界,今天就来探讨一下调用一个RPC服务的三重境界。
初阶:平铺直叙
先上结论:平铺直叙的方式没有隔离技术复杂度与业务复杂度,随着时间的推移,很难从代码中还原业务场景。
简单需求
假定现在有这么一个很简单的需求:你需要根据交易单号查询到交易单,解析交易单获取到该笔交易单对应的收货地址信息,根据收货地址信息查询快递资源信息并返回。你可能不假思索地就写出如下代码。
public class ExpressBizService {@Autowiredprivate TradeRpcService tradeRpcService;@Autowiredprivate ResourceRpcService resourceRpcService;public ResourceInfo queryExpressResourceByTradeId(Long tradeId) {SingleQueryResultDO order = tradeRpcService.getOrderById(tradeId);LogisticsOrderDO logisticsOrder = order.getLogisticsOrder();String city = logisticsOrder.getCity();String area = logisticsOrder.getArea();String town = logisticsOrder.getTown();return resourceRpcService.queryResourceByAddressInfo(city, area, town);;}
}
但是RPC调用和本地调用相比,RPC调用是不可控的,第一要有网络开销,第二服务是别人提供的,万一被人挂了直接抛异常了怎么办?
那就加个try catch呗,把异常catch并处理一下。于是代码变成了这样:
public class ExpressBizService {@Autowiredprivate TradeRpcService tradeRpcService;@Autowiredprivate ResourceRpcService resourceRpcService;public ResourceInfo queryExpressResourceByTradeId(Long tradeId) {try {SingleQueryResultDO order = tradeRpcService.getOrderById(tradeId);LogisticsOrderDO logisticsOrder = order.getLogisticsOrder();String city = logisticsOrder.getCity();String area = logisticsOrder.getArea();String town = logisticsOrder.getTown();return resourceRpcService.queryResourceByAddressInfo(city, area, town);} catch (Throwable t) {// 网络异常进行重试if (t instanceof NetworkException) {throw new RetryException(t);}// 其他异常不重试return null;}}
}
加个缓存
上线运行了几天时间,你发现RPC查询速度太慢了,调用交易域提供的服务查询交易单需要15ms左右,导致queryExpressResourceByTradeId
方法需要20ms左右。而业务预期是10ms左右,那怎么办?加缓存呗!
- 先查缓存,若命中缓存,则直接从缓存中解析地址信息,
- 若未命中缓存,则先查询交易单,再写缓存,再查询资源域并返回结果
- 处理缓存相关的异常
于是代码又进化成了这样:
public class ExpressBizService {@Autowiredprivate TradeRpcService tradeRpcService;@Autowiredprivate ResourceRpcService resourceRpcService;@Autowiredprivate RedisDataSource redisDataSource;public ResourceInfo queryExpressResourceByTradeId(Long tradeId) {try {String json = resourceRpcService.get(tradeId);// 若命中缓存,则直接从缓存中解析地址信息, if (StringUtils.isNotBlank(json)) {LogisticsOrderDO cache = JSONObject.parseObject(json, LogisticsOrderDO.class);return resourceRpcService.queryResourceByAddressInfo(cache.getCity(), cache.getArea(), cache.getTown());} else {//若未命中缓存,则先查询交易单,再写缓存,再查询资源域并返回结果SingleQueryResultDO order = tradeRpcService.getOrderById(tradeId);LogisticsOrderDO logisticsOrder = order.getLogisticsOrder();String city = logisticsOrder.getCity();String area = logisticsOrder.getArea();String town = logisticsOrder.getTown();resourceRpcService.put(tradeId, JSONObject.toJSONString(logisticsOrder));return resourceRpcService.queryResourceByAddressInfo(city, area, town);}} catch (Throwable t) {// RPC网络异常进行重试if (t instanceof NetworkException) {throw new RetryException(t);}// Redis异常进行重试if(t instanceof RedisBusyException){throw new RetryException(t);}// json解析异常要记录原因,秋后算账if(t instanceof JsonParseException){log.error("JsonParseException"+tradeId);}// 其他异常不重试return null;}}
}
加个鉴权
代码又在线上跑了几天,然后你收到一封交易域发来的邮件,因为数据安全原因,将不再提供明文的收货地址信息,需要先查询加密的收货地址信息,然后根据AK SK去解密出明文收货地址信息。
得嘞~这又得改动。于是代码又变成了这样:
public class ExpressBizService {@Autowiredprivate TradeRpcService tradeRpcService;@Autowiredprivate TradeDecryptRpcService tradeDecryptRpcService;@Autowiredprivate ResourceRpcService resourceRpcService;@Autowiredprivate RedisDataSource redisDataSource;private final String APP_KEY = "xxx";private final String SECRET_KEY = "xxx";public ResourceInfo queryExpressResourceByTradeId(Long tradeId) {try {String json = resourceRpcService.get(tradeId);// 若命中缓存,则直接从缓存中解析地址信息,然后查询资源域并返回结果if (StringUtils.isNotBlank(json)) {LogisticsOrderDO cache = JSONObject.parseObject(json, LogisticsOrderDO.class);return resourceRpcService.queryResourceByAddressInfo(cache.getCity(), cache.getArea(), cache.getTown());} else {//若未命中缓存,则先查询交易单,再写缓存,再查询资源域并返回结果SingleQueryResultDO order = tradeRpcService.getOrderById(tradeId);// 查询加密的物流信息EncryptedLogisticsOrderDO encryptedLogisticsOrderDO = order.getEncryptedLogisticsOrderDO();// 解密物流信息DecryptedLogisticsOrderDO decryptedLogisticsOrderDO = tradeDecryptRpcService.decryptLogisticsOrderDO(APP_KEY,SECRET_KEY,encryptedLogisticsOrderDO);// 写缓存resourceRpcService.put(tradeId, JSONObject.toJSONString(decryptedLogisticsOrderDO));String city = decryptedLogisticsOrderDO.getCity();String area = decryptedLogisticsOrderDO.getArea();String town = decryptedLogisticsOrderDO.getTown();// 查询资源并返回结果return resourceRpcService.queryResourceByAddressInfo(city, area, town);}} catch (Throwable t) {// RPC网络异常进行重试if (t instanceof NetworkException) {throw new RetryException(t);}// Redis异常进行重试if(t instanceof RedisBusyException){throw new RetryException(t);}// json解析异常要记录原因,秋后找负责写缓存的同学算账if(t instanceof JsonParseException){log.error("JsonParseException"+tradeId);}// 其他异常不重试return null;}}
}
模型变了
这时候你又发现,代码怎么这么红呢?原因是因为之前缓存的明文的地址信息LogisticsOrderDO,现在交易域改动了,LogisticsOrderDO没有了,变成了EncryptedLogisticsOrderDO。代码里之前依赖的LogisticsOrderDO找不到了,所以代码一片红。所以又得适配交易域模型的变化。
于是写缓存的代码又要修改:
// 若命中缓存,则直接从缓存中解析地址信息,然后查询资源域并返回结果if (StringUtils.isNotBlank(json)) {DecryptedLogisticsOrderDO cache = JSONObject.parseObject(json, DecryptedLogisticsOrderDO.class);return resourceRpcService.queryResourceByAddressInfo(cache.getCity(), cache.getArea(), cache.getTown());}
腐化的味道
至此这段代码已经不能一眼看出最初的业务逻辑是什么了。而且这是在最初的业务逻辑特别简单的情况下,实际情况下业务逻辑可不会那么简单呀。后续如果有什么技术上面的变更,比如增加各种各样的灰度逻辑;为了应对高并发场景增加限流逻辑或者对交易提供的服务进行熔断;或者为了提高接口的处理效率将此接口变为批量接口,然后使用多线程查询交易单和资源信息,那代码会进一步复杂。
技术上变完了,业务逻辑也可能会变,业务做大了,各种By特殊场景的定制逻辑也来了,比如通用场景下 根据交易单收货地址信息查询资源域的快递资源信息,特殊case1 根据发货单地址信息查询资源域的快递资源信息,特殊case 2……。
技术在变复杂,业务也在变复杂,假设技术的复杂度为N1,业务的复杂度为N2。那么整个代码的复杂度不是N1+N2而是N1*N2。如果没有及时的治理与重构,那么后面的同学只能干一些屎上雕花的活了。
中阶:封装变化
先上结论:适配器与防腐层。隔离技术复杂度与业务复杂度,隔离自己领域的模型与外部领域的模型。
定义模型,隔离依赖
我们再来看一遍这个需求:需要根据交易单号查询到交易单,解析交易单获取到该笔交易单对应的收货地址信息,根据收货地址信息查询快递资源信息并返回。
查询交易单并解析是一个行为,是一个技术手段,其目的在于获取收货地址信息。就相当于坐火车去北京,坐火车是一个去北京的方式,但坐火车或者可以分为坐卧铺、坐硬座、站票,甚至可以不做火车,坐飞机、坐轮船、坐火箭🚀。但不管怎样,北京这一目的地是不变的。如同上面一样,查交易单可以直接查询、可以先查缓存再查交易单、也可以先查缓存再查加密后的交易单再解密,但最后都为为了获取到收货地址信息。行为是多变的,目的是相对稳定的(那如果整个需求都改了那就没办法了)。
所以,真正需要的是什么?是收货地址信息。在中国 省、市、区、街道这四个字段已经能描述一个较为准确的地址了,而且是不变的。虽然收货地址这一模型是稳定的,但获取收货地址的方式是多变的,那我们就把变化的部分封装起来,把变化控制在最小范围,不让其扩散污染到其他的代码。以此来保持主要业务逻辑代码是整洁清晰易于维护的。
这时候可以写一个包装类(Wrapper),把查询交易单这个技术动作进行包装(封装技术细节)以及把交易单模型转化为业务所需的地址模型(防止外部模型污染内部业务逻辑)。同样地也可以对查询缓存的技术动作以及缓存模型进行封装。查询交易单,查询缓存都是为了获取地址信息地址,那就抽象出一个地址仓库(Repository)把获取地址这段逻辑进行封装。(坐火车,坐飞机都是去北京,所以可以抽象出:乘坐交通工具去北京,后面如果想要打车去北京也可以说是乘坐交通工具去北京)所以从交易单、缓存查询地址的逻辑的代码结构会变成这样:
后面如果获取地址的方式再发生变化,只需要修改包装类以及仓库即可,不会影响到上层的业务逻辑。
打破封装,破窗效应
这个时候代码结构合理,模型内聚,ExpressBizService.java
又恢复了一眼就能看懂业务逻辑的状态,然后因为业务调整,这块代码由小A交给小B同学了,小B同学接手后了一个新需求是根据快递信息(快递公司品牌、快递公司编码)查询快递资源详细信息并返回,而且快递信息就在交易单上面。因为是紧急需求,本着怎么来怎么快的原则,新同学没做抽象没搞封装直接又把tradeRpcService.java
放进了ExpressBizService.java
,(技术复杂度与业务复杂度又糅合在了一起)小B同学干了一段时间离职了,小C同学又接手了这段代码。这时候需求变更来了:
小A时代的逻辑:你需要根据交易单号查询到交易单,解析交易单获取到该笔交易单对应的收货地址信息,根据收货地址信息查询快递资源信息
小C时代的逻辑:你需要根据交易单号查询到交易单,解析交易单获取到该笔交易单对应的收货地址信息,根据收货地址信息查询快递资源信息。但是在预售场景下需要使用预售交易单。解析预售交易单获取到该笔交易对应的收货地址信息,根据收货地址信息查询快递资源信息。(假定针对预售场景创建一张预售单据是合理的,此处只做举例使用)。而tradeRpcService.java
的queryPreSaleOrder()
方法查询预售交易单。
小C一看,tradeRpcService.java
已经在ExpressBizService.java
里面了呀。那我直接调用queryPreSaleOrder
然后再处理就行了呗。于是即使ExpressBizService.java
的queryExpressResourceByTradeId()
方法已经做到了结构合理、模型内聚,因为ExpressBizService.java
被污染了导致queryExpressResourceByTradeId()
方法也不能幸免。(风平浪静的大海掀起波涛,海上的小船怎么能独善其身呢?)这就是破窗效应。
破窗效应是社会学概念之一,也可以理解为“窗户理论”或“破窗理论”。它最早由美国学者威尔逊·凯利(Wilson Kelling)和乔治·凯利(George Kelling)于1982年提出。
破窗效应的基本观点是,如果一个社区或环境中存在一个被破坏的窗户,如果不及时修复,其他人很可能会受到影响,导致整个环境的质量逐渐下降。这是因为人们会认为这个社区或环境已经被人放任不管,没有秩序和规则,从而产生更多的破坏行为。
破窗效应主要强调环境的外在形象和秩序对个体行为产生的影响。它认为,人们的行为受到周围环境的影响。在一个有序、整洁、规范的社区中,人们更有可能遵守法律和规则,保持社会秩序。相反,在一个杂乱、破坏、缺乏秩序的环境中,人们更容易产生不良行为、违法行为甚至破坏行为。
后面的故事也很容易被猜到,出于性能考虑需要对于预售交易单加缓存,出于安全合格考虑,预售交易单上面的用户信息也需要加密解密,然后代码又走上了腐坏的道路……
高阶:依赖倒置
先上结论:高层代码定义标准,底层代码实现标准。
高层代码指的是那些容易变动的业务代码,底层代码指的较为稳定的基础代码(访问数据库、缓存、调用外域HSF接口)。在逻辑层面高层代码虽然要依赖底层代码对数据进行读写,但是数据的模型以及对数据操作的行为是高层代码所定义的。
落实到代码层面是这样的:高层代码和底层代码分别位于两个模块之中,高层代码在应用层,底层代码在基础设施层。数据模型以及对数据的操作抽象成接口,且接口放在应用层,底层代码要实现接口,则基础设施层需要依赖应用层,这样应用层就不能再依赖基础设施层了(循环依赖,编译报错)。所以后面接手的同学很难去打破前人的合理设计。
这样在编码过程中,高层代码是不可能感知到底层代码的模型与行为的,因为已经通过接口隔离了。从而让高层代码专注业务逻辑,底层代码专注技术细节。程序运行时高层代码通过接口注入真正的底层实现类,在逻辑上完成调用底层代码。
正常情况下调用顺序和依赖顺序应该是一致,要调用基础设施层的代码就应该在POM文件里明确依赖基础设施层。但是在这里却是基础设施层依赖了应用层,这就是依赖倒置。通过依赖倒置就能实现高层代码定标准,底层代码做实现,同时两个层次的代码通过接口隔离,互不干涉影响。
面向过程vs面向对象
结论:有面向对象的思维才能写出面向对象的代码,即高内聚、低耦合、易拓展、易维护的代码。
初阶与高阶看似是写代码的不同,实际上是背后思维方式的不同。 初阶是面向过程,高阶是面向对象。
从最开始学习Java之时,我们都知道Java是一门面向对象的语言,关于面向对象的理论大家都是滚瓜烂熟,信手拈来,但是当真正写代码的时候,却是基于贫血模型写了一段又一段冗长的面向过程的脚本代码。没有抽象,没有封装,也没有内聚,几经转手之后就变成了开发同学们所憎恶的“屎山代码”。
只具备面向过程思维的程序员,当面对一个需求时,首先考虑的是具体的实现步骤,先干嘛再干嘛最后干嘛。如果一开始就陷入了细节,那么产出的代码一定是没有抽象的过程式的脚步代码。
面向过程的思维方式在应对简单的业务逻辑之时毫无问题,但是当业务逻辑变复杂,且由多个程序员共同开发实现的时候,代码大概率会变得复杂难以维护。因为面向过程的代码,它描述的都是细节。人脑善于思考善于逻辑推理,但不善于存储,也就是说我们拥有一个非常牛逼的CPU,但是一级缓存、二级缓存、三级缓存以及内存都少得可怜。因此通读面向过程的代码很容易就迷失在细节里面,尤其是遇到多层方法嵌套的场景,不知不觉就陷入代码的泥潭里面。
换句话说,从抽象到具体,从概要到详细是容易理解的,但是从具体到抽象、从详细到概要是比较困难的。举个极端例子:你能很容易地根据E=M*C^2算出来一个苹果的能量(抽象-->具体),但是让你从复杂的现实世界推到出这个方程能做到吗?(具体-->抽象)
所以我们需要具备面向对象的思维方式来应对复杂的业务需求,当遇到一个问题时首先考虑的是这个问题域里面有哪些对象?这些对象有哪些属性以及行为?这些对象直接的关系如何?业务代码如何编排这些对象来解决问题?这种思维方式会引导者我们去分解问题,去不断剖析问题的本质,最终产出的代码也是从抽象到具体的。
具体的代码大家都会写,那么抽象的代码怎么写呢?其实业界也早就有了答案,简单来说就四个字“面向对象”。复杂来说就是solid原则、各种设计模式、领域驱动设计……首先要学会这些知识,然后尝试应用知识,应用之后再反过来体悟已学过的知识,如此反复螺旋式地不断加深理解,最终方能游刃有余地驾驭“面向对象”这把屠龙宝刀,在代码的江湖里面独占一席之地。