写点什么

高耦合: 为何代码一直被绑架?

用户头像
Jxin
关注
发布于: 2021 年 05 月 16 日
高耦合:为何代码一直被绑架?

一行代码,也是名片。

“高内聚低耦合”,是所有人都赞同的设计原则。但究竟什么样的代码呈现了“低内聚”、什么样的代码呈现了“高耦合” 、怎么做才能实现“高内聚低耦合”,这些问题始终悬而未决。因此,对于真正在一线工作的同学来说,“高内聚低耦合”很多时候只是一个墙上的口号。喊完后,每个人还是按照原本的编程习惯与风格,并不能真正指导任何行为的改变。接下来,我们就一起来尝试捋一捋“高耦合”。


什么是耦合?代码 A 和代码 B 一起放到箩筐 A 中,一旦代码 B 变化,代码 A 也要受牵连。因为对外的约定是箩筐,所以不论是代码 A 还是代码 B 发生了变化,整个箩筐 A 内所有的代码都有风险。解决方案也很简单,只需要将代码 A 放到箩筐 Aa,代码 B 放到箩筐 Ab,再将箩筐 Aa 和箩筐 Ab 放到箩筐 A 中。如此一来,代码 A 和代码 B 就隔离开了,代码 B 的变化,风险就收敛在了箩筐 Ab 中。


所以,解决耦合本身并不难。难点在于:

  1. 识别耦合的依据: 我如何区分出箩筐 A 中的代码 A 和代码 B;

  2. 隔离耦合的手段: 我如何将代码 A 和代码 B 隔离开来。


一个业务场景

在一个业务场景中,识别耦合的依据是: 变化的概率


Spring MVC 外放 http 接口的场景

在 Spring MVC 中,定义一个 Controller 存在三种方式:

  1. 基于注解,通过 @Controller + @RequesMapping 来实现;

  2. 基于接口,通过实现 Controller 接口 + xml 配置文件 来实现;

  3. 基于 sevlet 原生,通过 HttpServlet 接口 + xml 配置文件 来实现。


// 方法一:通过@Controller、@RequestMapping来定义@Controllerpublic class DemoController { @RequestMapping("/employname") public ModelAndView getEmployeeName() { // 执行代码 return ret; } }
// 方法二:实现Controller接口 + xml配置文件:配置DemoController与URL的对应关系public class DemoController implements Controller { @Override public ModelAndView handleRequest(HttpServletRequest req, HttpServletResponse resp) throws Exception { // 执行代码 return ret; }}
// 方法三:实现Servlet接口 + xml配置文件:配置DemoController类与URL的对应关系public class DemoServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { this.doPost(req, resp); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 执行代码 }}
复制代码

在应用启动的时候,Spring 容器会加载这些 Controller 类,并且维护 URL 与对应的处理函数的映射关系。当有请求到来的时候,DispatcherServlet 从 HanderMapping 中,查找请求 URL 对应的处理函数来执行请求。但是,不同方式定义的 Controller,其函数的定义(函数名、入参、返回值等)是不统一的。如果要调度这些函数,就必须感知具象的实现。调度伪代码如下:



Handler handler = handlerMapping.get(URL);if (handler instanceof Controller) { ((Controller)handler).handleRequest(...);} else if (handler instanceof Servlet) { ((Servlet)handler).service(...);} else if (hanlder 对应通过注解来定义的Controller) { 反射调用方法...}
复制代码

在调度类 DispatcherServlet 中,调度的逻辑代码(稳定)和被调度的处理函数的实现类(变化)的变化概率是不一样的。URL 映射处理函数的逻辑本不该变,但实现 Controller 的方式大概率会增加,比如采用 JSR 370 规范的 Jersey2 来实现(但基本不会减少,毕竟对于框架兼容是它的信用)。这时 DispatcherServlet 就被执行函数的实现类绑架了,每增加一个实现 Controller 的方式就要到 DispatcherServlet 添加一段 if-else 的代码。DispatcherServlet 的调度逻辑和处理函数的实现类出现了耦合的现象。


隔离耦合的手段是: 运用设计模式


我们再看下 Spring 的代码实现:

利用是适配器模式“统一多个类的接口设计”的能力对代码进行改造,让其满足开闭原则。定义了统一的接口 HandlerAdapter,并且对每种 Controller 定义了对应的适配器类。这些适配器类包括:AnnotationMethodHandlerAdapter、SimpleControllerHandlerAdapter、SimpleServletHandlerAdapter 等。


public interface HandlerAdapter { ModelAndView handle(HttpServletRequest var1, HttpServletResponse var2, Object var3) throws Exception;}
// 对应实现Controller接口的Controllerpublic class SimpleControllerHandlerAdapter implements HandlerAdapter { public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { return ((Controller)handler).handleRequest(request, response); }}
// 对应实现Servlet接口的Controllerpublic class SimpleServletHandlerAdapter implements HandlerAdapter { public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { ((Servlet)handler).service(request, response); return null; }}
// AnnotationMethodHandlerAdapter对应通过注解实现的Controller,
复制代码

在 DispatcherServlet 类中,我们就不需要区分对待不同的 Controller 对象了,统一调用 HandlerAdapter 的 handle() 函数就可以了。按照这个思路实现的伪代码如下所示:


// 之前的实现方式Handler handler = handlerMapping.get(URL);if (handler instanceof Controller) { ((Controller)handler).handleRequest(...);} else if (handler instanceof Servlet) { ((Servlet)handler).service(...);} else if (hanlder 对应通过注解来定义的Controller) { 反射调用方法...}
// 现在实现方式HandlerAdapter handlerAdapter = handlerMapping.get(URL);handlerAdapter.handle(...);
// 假如使用lambdaHandlerComsumer<ModelAndView, Req, Rsp> handlerComsumer = handlerMapping.get(URL);handlerAdapter.handle(...);
复制代码

如果引入 lambda,执行函数的适配可以更简洁(无需写 HandlerAdapter 适配接口,直接用 lambda 的方式声明/以前要用匿名类的方式实现)。执行函数的增强可以更简洁,直接 handlerComsumer.and(FilterComsumer)。lambda 的写法可读性更高,结构复杂度更低(类的数量相对少),更有利于项目维护。但如果团队都不习惯,团队成员对整洁代码的追求意愿较低,建议以团队风格为主。不论 lambda 好不好,团队达成共识的才是最合适的。


以前,有人说大部分设计模式的价值是代码扩展。今天,通过学习,我们看到它还有解耦的功效。它实现解偶的手段也很简单,就是依赖抽象的接口而非具象的实现,适配/代理/装饰者/观察/策略/责任链皆是如此。设计模式源于语言本身的缺陷,java 尝试用 lambda 来为自己买单,不知道你接不接受。

一个服务


在一个服务中,识别耦合的依据是:变化的方向


假如你要做一道好菜:


在你做菜的过程中,除了获取食材(草鱼),还需要确定食材是否优质,毕竟技术不够材料来凑。伪代码如下:

@Component@AllArgsConstructorpublic class DefCookService implements CookService {    private final FoodService foodService;    @Override    public Dishes cook1() {        // 1.准备器材        // 2.起锅烧油        // 3.放入食材           // 3.1获取优质的食材             final FoodDto 草鱼 = foodService.get("草鱼");             // 判断是否优质                 // 鱼的重量是否在 3~5 斤内                 // 鱼是否存活                 // 鱼的养殖年份是否在 1-2 年间                 // 生产的水域        // 4.放入调味        // 5.控制火候        // 6.出锅        return null;    }}
复制代码

如此,cook() 这个场景下,就包含了调度烹饪流程识别优质食材两块逻辑,前者属于烹饪的上下文,后者属于食材的上下文

优质食材的判定条件和标准会随着食材的不同、市场的行情而变化,草鱼的判定条件是上面的 4 种,鸡、鸭、猪、牛的判定条件却不相同;今年草鱼的市场行情好,优质的标准会高些,明年市场行情差,优质的标准又会低一些。这个变化属于食材上下文中对食材的采购和健康管理,是食材方向上的变化

cook() 这个场景是烹饪上下文逻辑,它能控制的只有烹饪流程上的变化。比如我做刺身,那就不需要起锅烧油;我用炒饭机器人做饭,那就只需要添加食材和调料,按下开机键。

两份逻辑在变化的方向上并不相同,将识别优质食材的逻辑放到调度烹饪流程中,调度烹饪流程的逻辑就被绑架了,每次识别优质食材的逻辑变动都会导致调度烹饪流程需要修改代码。调度烹饪流程识别优质食材出现了耦合的现象


隔离耦合的手段是:架设防腐层


为了隔绝识别优质食材调度烹饪流程的绑架,我们为获取优质食材的逻辑架设一层防腐层,伪代码如下:

@Component@AllArgsConstructorpublic class DefFoodServiceAcl implements FoodServiceAcl{    private final FoodService foodService;    private final FoodDtoConv foodDtoConv;    @Override    public FoodVal get(String uniqueKey) {        final FoodVal foodVal =                Optional.ofNullable(foodService.get(uniqueKey))                        .map(foodDtoConv::dto2val)                        .orElseThrow(() -> new RuntimeException("食材不存在"));
final boolean highGrade = isHighGrade(foodVal); foodVal.setHighGrade(highGrade); return foodVal; } private boolean isHighGrade(FoodVal foodVal){ // check return true; }}
@Datapublic class FoodVal { /**是否高品质*/ private boolean highGrade;}

复制代码

调度烹饪流程变成:

@Component@AllArgsConstructorpublic class DefCookService implements CookService {    private final FoodServiceAcl foodServiceAcl;    @Override    public Dishes cook2() {        // 1.准备器材        // 2.起锅烧油        // 3.放入食材        // 3.1获取优质的食材        final FoodVal 草鱼 = foodServiceAcl.get("草鱼");        final FoodVal 优质食材 = Optional.ofNullable(草鱼)                                        .filter(FoodVal::isHighGrade)                                        .orElseThrow(() -> new RuntimeException("优质食材不存在"));        // 4.放入调味        // 5.控制火候        // 6.出锅        return null;    }}
复制代码

如此一来,调度烹饪流程的逻辑就不再包含识别优质食材,能够维持自己变化方向唯一的稳定性。

虽然 DefFoodServiceAcl 里面还是要受食材上下文识别优质食材的侵扰,但这归属于上下文间逻辑划分低内聚的范涛,咱们下篇再聊。


当前的场景其实还可以进一步优化:

@Component@AllArgsConstructorpublic class DefFoodServiceAcl implements FoodServiceAcl{    private final FoodService foodService;    private final FoodDtoConv foodDtoConv;    @Override    public FoodVal getWithHighGrade(String uniqueKey) {        return Optional.ofNullable(foodService.get(uniqueKey))                        .map(foodDtoConv::dto2val)                        .filter(this::isHighGrade)                        .orElseThrow(() -> new RuntimeException("优质食材不存在"));    }    private boolean isHighGrade(FoodVal foodVal){        // check        return true;    }}
@Component@AllArgsConstructorpublic class DefCookService implements CookService { private final FoodServiceAcl foodServiceAcl;
@Override public Dishes cook3() { // 1.准备器材 // 2.起锅烧油 // 3.放入食材 // 3.1获取优质的食材 final FoodVal 优质草鱼 = foodServiceAcl.getWithHighGrade("草鱼"); // 4.放入调味 // 5.控制火候 // 6.出锅 return null; }}
复制代码

烹饪流程其实无需对食材是否优质做逻辑,只需确认是否可以获取到优质食材这个结果。这就引申出我对防腐层定义的认知。

工作中大部分小伙伴都认为防腐层就只是对一个外部接口做包装,接口上一对一包装,实体上一对一复制(甚至有不复制实体,直接饮用外部 dto 在内部业务逻辑代码中),不该参合业务逻辑。而我觉得不然,防腐层的定义是隔绝变化,变化不局限于外部接口和实体,还包含了本不属于当前上下文的逻辑,也就是变化方向上不一致的代码

架设防腐层时要识别好不同方向上的变化,然后基于自己的意图(当前变化方向需要感知到的内容)做抽象,最后再寻找外部接口来实现(具象)这个逻辑。如果外部接口能够满足需要,那么直接映射就好;如果不能,那就将差异的部分收敛在防腐层。

延伸

本文的核心是静态的编码实现,故以下内容仅提及不深究,感兴趣可以私下讨论:


  • 站在运行期的视角,识别耦合的依据是调用的频次,隔离的手段就为:限流绑定服务(信号量)或集群绑定服务(独立池)。

  • 站在企业运营的视角,识别耦合的依据是业务的价值,隔离手段同上。

回顾总结

什么是高耦合?大量变化概率变化方向不一样的代码交织在一起,呈现牵一发而动全身的窘境。


如何应对高耦合?在单个业务场景中,通过变化的概率识别耦合的代码,运用设计模式将代码分隔。1.8 以上的代码可以尝试用 Lambda 代替部分设计模式;在单个服务中,通过变化的方向识别耦合的代码,架设防腐层来将代码分隔。好的防腐层应该是基于意愿的抽象,出入参都应该是意愿抽象的新模型。无论是运用设计模式还是架设防腐层都是一种依赖抽象而非具象的实现手段,抽象不局限于接口和实体类,也包含业务逻辑。


不论这遍文章让你有什么感想,

都希望你能把它写在留言,

让我们共同成长。

如果这篇文章对你有所帮助,

也希望你能把它分享给你的朋友。

用户头像

Jxin

关注

极限编程小🐎农 2018.09.22 加入

你的每一行代码,都是你的名片。

评论

发布
暂无评论
高耦合:为何代码一直被绑架?