写代码这件事,迈入第七个年头才有了一些心得(第一章 关注点分离)

2,941 阅读8分钟

写代码这件事,迈入第七个年头才有了一些心得(第一章 关注点分离)

🔊 有很多人说,能跑的代码就是好代码;虽如此,但对于有些情怀的我,还是想让自己的代码尽量体面。

image.png

本文主要讲解关注点分离在代码中的实践。

✒️一、开胃菜案例

🍧🍧🍧用代码的优化例子来作为开胃菜🍧🍧🍧

代码背景

先了解一下代码的背景

  • 定义一个权限校验的注解 @Permission
  • PermissionAspect 是 @Permission 的注解的切面解析类
  • 只要判断当前线程栈有 PermissionAspect 类的调用,即判断之前使用过 @Permission 注解,即有做权限的校验

由此背景诞生出来了一个静态方法:即当前方法栈帧是否包含 PermissionAspect 类的调用。

优化案例围绕下面这段代码展开,需要先读懂它。

/**
 * 当前方法栈帧是否包含 PermissionAspect 类的调用
 */
public static boolean isCurrentStackTraceContainPermission2() {
    // 当前方法栈帧数组
    StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
    if (stackTrace != null && stackTrace.length == 0) {
        return false;
    }
    // 遍历栈帧数组是否包含 PermissionAspect 类的调用
    for (StackTraceElement stackTraceElement : stackTrace) {
        String className = stackTraceElement.getClassName();
        if (className != null) {
            // 包含 PermissionAspect 即返回 true
            if (className.equals(PermissionAspect.class.getName())) {
                return true;
            }
        }
    }
    return false;
}

下图展示当前方法栈的结构, 当前线程经过的方法调用都会被压入到当前方法栈里面。

image.png

🍀接下来进入优化阶段.....🍀

变与不变

目标:想让这个方法能够被复用!

当前方法只能判 PermissionAspect 类是否有调用过;要扩展适配 XxxFilter, 显然上面方法不能被使用; 因为它就是用来处理 PermissionAspect 类的。于是复制代码,修改一下类即完成:

public static boolean isCurrentStackTraceContainXxxFilter() {
  .......
  if (className.equals(XxxFilter.class.getName()))
  .....
}

然而这样行为,在过往的代码工程中,随处可见,比比皆是。

image.png

要复用也简单,把判断是否包含的特定类提炼出来当成入参。

那么这样做能达到复用的原因是什么呢?

不妨来分析一下影响这个方法变化的因素原因:

【PermissionAspect】这个点,因为有可能是其他类。

把格局抬一抬;其实是在分析事物内在变化与不变的本质。

image.png

于是根据刚刚的分析,做如下优化:

// 设置参数为 Object
public static boolean isCurrentStackTraceContainObjectInvoke(Object object) {
  .......
  // 设置为通用性 object
  if (className.equals(object.getClass().getName()))
  .....
}
// 调用
isCurrentStackTraceContainObjectInvoke(new PermissionAspect());

用 Object 类型来代表所有类,这样这个方法就具有通用性了。但创建对象不方便,比如这个类是一个 final 类。在分析后确定需要的是 class 类型

泛型应用

使用泛型进行改造,不用再考虑创建对象了。

// 泛型调用
public static <T> boolean isCurrentStackTraceContainClassInvoke(Class<T> invokeClazz) {
    StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
    if (stackTrace != null && stackTrace.length == 0) {
        return false;
    }
    for (StackTraceElement stackTraceElement : stackTrace) {
        String className = stackTraceElement.getClassName();
        if (className != null) {
            // 泛型判断
            if (className.equals(invokeClazz.getName())) {
                return true;
            }
        }
    }
    return false;
}

那么为什么可以用泛型来达到复用这个目的呢?

使用泛型的那一刻,又做了一件将变化与不变元素分开的事情:即将形参和实参进行内在的分离。

  • 形参Class<T> invokeClazz:不具备个性化的色彩,属于非变化部分,稳定点
  • 实参是变化的部分,可以在任何调用的地方传入任何类,不稳定

这一刻愿称为这个方法为工具方法

image.png

用最少的代码做最多的事情,这可能就是代码的艺术。

😎谈到这里,不妨我再激进一些......

更进一步

  1. java.lang.Thread#getStackTrace 查看源码并不会返回 null,因此不用校验 null
  2. 函数式编程,Objects::nonNull,判断空, 代码更简单
  3. Optional 减少 NPE。 findFirst 方法调用会返回 Optional
  4. 链式(stream)更加优雅
public static<T> boolean isCurrentStackTraceContainClassInvoke(Class<T> invokeClazz) {
   return  Arrays.asList(Thread.currentThread().getStackTrace()).stream()
            .map(element -> element.getClassName())
            .filter(Objects::nonNull)
            .map(className -> className.equals(invokeClazz.getName()))
            .filter(needTrue -> needTrue)
            .findFirst()
            .orElse(Boolean.FALSE);
}

到这里,开胃菜就结束了。通过这个案例的分析优化,引入本篇的主题:

关注点分离, 将那些变化的点和那些不变的点分离开来,并区别对待。

📝二、 关注点分析

关注点分离,下面是我的理解,也是指导我进行编码的基础思想:

  • 变与不变
  • 动与静

也可以将其理解为功能代码和业务代码分离。

图形演示

将业务性和功能性模块组合成长方形;可把长方形理解为聚合而成的方法、类、模块等。演示分析、拆解、组合的过程。

第一步:分析功能性的和非功能性

image.png

第二步:剥离

image.png

第三步:重组和复用

image.png

功能性模块可以跟其他非功能性模块组合,从而应用更多的场景。

理解、分析、剥离、拆分和重组。关注点分离,让职责也更清晰、更纯粹。

将这个思想应用到代码中,接下来看代码演示。

代码演示

下面代码是判断用户名和密码不为空,代码很简单。

public void validateLogin(String userName, String password) {
    if (userName == null || "".equals(userName)) {
        throw new IllegalArgumentException("UserName cannot be empty");
    }
    
    if (password == null || "".equals(password)) {
        throw new IllegalArgumentException("UserName cannot be empty");
    }
}

这种判空的条件,非常常见。接下来,可以把 userName、和 password 当成占位符 $param$,它是变化的部分。

判断姓名不能为空的代码:

if ($param1$ == null || "".equals($param1$)) {
    throw new IllegalArgumentException( $param2$ + " cannot be empty");
}

判断密码不能为空的代码:

if ($param3$ == null || "".equals($param3$)) {
    throw new IllegalArgumentException($param4$ + "cannot be empty");
}

这种结构是不是一致的呢?试着将图形化为思想,应用到代码中。

功能代码,具有业务无关性,普适性,可复用性业务代码,变化性、不确定性
image.pngimage.png

将代码进行重构,如下所示:

public void validateLogin(String userName, String password) {
    
    checkNotEmpty(userName, "UserName");

    checkNotEmpty(password, "Password");
}

// 功能性
public void checkNotEmpty(String param, String emptyTitleTips) {
    if (param == null || "".equals(param)) {
        throw new IllegalArgumentException(emptyTitleTips + " cannot be empty");
    }
}

checkNotEmpty(..) 代码具有无关性, 称为工具代码。

这个代码和 Guava Preconditions 断言代码不是一样的功能吗?

变动的部分和不变的进行抽取剥离,那些好用的工具类,就是从日常写的代码里面剥离出来的。

就是这种思想一直影响着我。当我的 util 被别人调用,SDK 被别人集成的,我觉得这个思想方法是正确的,也是合理的,也是值得被推崇的。

image.png

实际案例

这是一个稍微复杂的案例,故事又得从很久前说起,在新的业务系统里面,有一个校验权限的代码。在其切面中做业务判断,导致无法进行扩展。

伪代码:判断角色、判断资源菜单。(只需理解代码耦合即可,不必细究)

@Around(value = "pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {  
    // 权限校验
    MethodSignature signature = (MethodSignature)joinPoint.getSignature();
    Object[] args = joinPoint.getArgs();
    // 自定义注解
    PermissionCheck check = signature.getMethod().getDeclaredAnnotation(PermissionCheck.class);

    // ============================== 业务代码 ===================
    // 角色判断
    User user = UserHolder.getUser();
    List<Role> roleInfoBOS = xxxx
    boolean isAdminRole = xxx
    // 资源菜单判断
    String[] menuResources = check.menuResources();
    boolean hasPerm = xxx
    //  业务 id 判断
    String bizId = (String)args[1];
    boolean isBizPerm = isBizPerm(bizId);  
    if ((isAdminRole || hasPerm) && isBizPerm) {
    // ============================== 业务代码 ===================
        return joinPoint.proceed();
    } else {
        throw new XXXException(xxx);
    }
}

耦合的代码,让要扩展的同学很焦虑,因为在原有基础上修改代码会让他们觉得痛苦,可能会因为修改而导致线上 BUG。

接着案例,分析一下,注解由切面类做路由,不做业务处理,业务处理通过扩展实现。

image.png

public class AuthorizedCheckAspect {
    // 切面做路由,寻找各自 CheckHandlerService 做校验
    @Autowired
    private List<CheckHandlerService> checkHandlerServiceList;
    
    @Around(value = "@annotation(com.xxx.auth.AuthorizedCheck) " +
            "|| @annotation(com.xxx.AuthorizedChecks)")
    public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
       
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();
        
        AuthorizedCheck[] authorizedChecks = method.getAnnotationsByType(AuthorizedCheck.class);
        
        // 寻找匹配的 CheckHandlerService 进行执行校验
        Arrays.stream(authorizedChecks)
                .map(AuthorizedCheck::value)
                .map(enumVar -> checkHandlerServiceList.stream()
                        .filter(checkHandlerService -> checkHandlerService.match() == enumVar)
                        .findFirst()
                ) .filter(Optional::isPresent)
                .map(Optional::get)
                .forEach(checkHandlerService -> checkHandlerService.doCheck(pjp));
     
       return pjp.proceed();
    }
}

CheckHandlerService 是接口类。方法之间的调用不依赖具体实现。

public interface CheckHandlerService<T> {

    /**
     * 资源校验。如果不通过直接抛出异常
     *
     * @param t
     * @return
     */
    void doCheck(T t);


    /**
     * 匹配资源的类型
     *
     * @return
     */
    CheckResourceEnum match();
}

通过分离后,扩展容易。

反向例子

再来一个反向案例。 下面是一个类之间的转,将 RoleBO 转成 Role

public class RoleConverterUtil {
    public static Role convertBo2Role(RoleBO rb){
        Role role = new Role();
        role.setRoleCode(rb.getRoleCode());
        role.setRoleName(rb.getRoleName());
        role.setDataPermission(rb.getDataPermission());
        role.setFunPermission(rb.getFunPermission());
        return role;
    }
}

但作为一个工具 util 类,应该具有业务无关性。这个方法中却出现了和业务相关的 role、data、function 等业务信息。这个类可以用以下方式改进:

  1. 修改类名,直接称为 RoleConverter 即可,去掉 Util
  2. 通过反射等方式等,修改成与业务无关性方法。

📜三、最后的总结

关注点分离:可以是一个方法,一个类,一个 module,甚至一个工程,将变和不变区分,让职责变得清晰,变得干净,这是一种指导思想。

当我开始这样做以后,我发现代码职责更加清晰,更加清爽,更加方便扩展,甚至代码量也更少,甚至 Bug 也少。

编程思想

优秀代码的背后都蕴含着一些哲学的道理。编程有各种表达方法、千变万化的技巧,但这些形式的东西会不断地推陈出新的;但在其背后的编程思想却不会改变。

而这篇文章所有传达也不仅是关注点分离这个编程技巧,而更想传达的一种思想。因为编程思想才是那部分不变的,也是一直指导我编码的核心;或许这也一种关注点分离。

image.png

🎉🎉🎉 此文章也献给那些年杭漂的自己。