写 Java 代码这件事,花三年才有了一些心得(第一章 关注点分离)


🔊 开始写 Java 生产代码还是毕业后的三年,也就是第二份工作的开始,时光荏苒,又一个三年过去了,我也才慢慢在写代码这件事情上有了一些心得。

⏱️ 写于 2023.11.01 00::23 杭州。

(●ˇ∀ˇ●)我将尽量以图文并茂、理论和案例相结合的方式,来展示我对代码的理解。

文章可能涉及重构、抽象、编程思想、函数式编程、泛型、设计模式、面向对象原则等知识点,但最终想谈的却还是思想。

✒️一、开胃菜案例

🍧🍧🍧用一个代码的优化例子来作为文章的开胃菜🍧🍧🍧

代码背景

先了解一下代码存在的背景

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

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

由于优化案例围绕下面这段代码展开,因此有必要先读懂它。

Java 复制代码
/**
 * 当前方法栈帧是否包含 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;
}

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

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

变与不变

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

当前方法只能判 PermissionAspect 类是否有调用过;如果需要判断当前方法栈是否调用过 XxxFilter 过滤器, 显然上面方法不能被使用; 因为它就是用来处理 PermissionAspect 类的。于是复制一下代码,将关键变化点代码修改一下即可上菜,如下:

Java 复制代码
public static boolean isCurrentStackTraceContainXxxFilter() {
  .......
  if (className.equals(XxxFilter.class.getName()))
  .....
}

然而这样结构性重复的代码,在过往的代码工程中,随处可见,比比皆是。

这样的代码优化很简单,把判断是否包含的特定类提炼出来当成入参不就可以了。

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

不妨来分析一下影响这个方法变化的因素可能有哪些:

  1. 当前线程中 【当前】 是不稳定因素,因为可以不是当前线程,比如异步线程。
  2. 【PermissionAspect】 也是不稳定因素,因为有可能是其他类。

Thread.currentThread().getStackTrace() 获取当前方法栈。在异步方法调用时,也是需要通过这个才能获取当前方法栈帧,它不是一个纯正的不稳定点。Thread 作为参数来获取栈帧并不合适。 于是变化的因素就是第 2 点。

那么把格局抬一抬;我这是在分析事物内在变化与不变,再看他们的本质呢🤔

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

Java 复制代码
// 设置参数为 Object
public static boolean isCurrentStackTraceContainObjectInvoke(Object object) {
  .......
  // 设置为通用性 object
  if (className.equals(object.getClass().getName()))
  .....
}

// 调用
isCurrentStackTraceContainObjectInvoke(new PermissionAspect());

用 Object 类型来代表所有类,这样这个方法就具有通用性了;但是我们需要在调用的时候创建一个对象;如果这个类是一个 final 类,则不方便创建对象。再分析,再分析,其实需要的是 class 类,再优化优化。

泛型应用

使用泛型进行改造,代码如下,这样改造后就变得很通用了,也不用担心创建对象的烦恼了。

Java 复制代码
// 泛型调用
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:不具备个性化的色彩,属于非变化部分,稳定点
  • 实参是变化的部分,可以在任何调用的地方传入任何类,不稳定

将不变与变化进行分离,上面的代码便具备了普适性,可复用。这一刻愿称为这个方法为工具方法

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

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

更进一步

  1. java.lang.Thread#getStackTrace 查看源码并不会返回 null,因此不用校验 null
  2. 函数式编程,Objects::nonNull,判断空, 代码更简单
  3. Optional 减少 NPE。 findFirst 方法调用会返回 Optional
  4. 链式(stream)更加优雅
Java 复制代码
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()))
            .findFirst()
            .orElse(Boolean.FALSE);
}

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

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

📝二、 关注点分析

关注点分离,概念有很多解释。下面是我的理解,也是指导我进行编码的基础思想,概况如下:

  • 变与不变
  • 动与静

为了举例,也可以将其理解为功能代码和业务代码分离;但本质上面两个点才是我的指导思想的核心

图形演示

用图形来比喻,将业务性和功能性模块组合成长方形;可把长方形理解为被各种业务和功能聚合而成的方法、类、模块等。演示一下分析、拆解、组合的过程。

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

第二步:剥离

第三步:重组和复用

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

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

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

代码演示

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

JAVA 复制代码
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$,它是变化的部分。

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

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

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

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

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

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

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

Java 复制代码
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 被别人集成的,我觉得这个思想方法是正确的,也是合理的,也是值得被推崇的。

实际案例

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

伪代码:判断角色、判断资源菜单。(只需理解这段代码表示的是业务和功能耦合了,不方便做扩展就可以了,不必细究)

Java 复制代码
@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。

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

Java 复制代码
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 是接口类。方法之间的调用不依赖具体实现。具体实现扩展

Java 复制代码
public interface CheckHandlerService<T> {

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


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

通过分离后,扩展容易。

反向例子

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

Java 复制代码
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 也少。

编程思想

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

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

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

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

相关推荐
J不A秃V头A26 分钟前
IntelliJ IDEA中设置激活的profile
java·intellij-idea
DARLING Zero two♡29 分钟前
【优选算法】Pointer-Slice:双指针的算法切片(下)
java·数据结构·c++·算法·leetcode
小池先生40 分钟前
springboot启动不了 因一个spring-boot-starter-web底下的tomcat-embed-core依赖丢失
java·spring boot·后端
CodeClimb43 分钟前
【华为OD-E卷-木板 100分(python、java、c++、js、c)】
java·javascript·c++·python·华为od
odng1 小时前
IDEA自己常用的几个快捷方式(自己的习惯)
java·ide·intellij-idea
CT随1 小时前
Redis内存碎片详解
java·开发语言
brrdg_sefg1 小时前
gitlab代码推送
java
hanbarger1 小时前
mybatis框架——缓存,分页
java·spring·mybatis
cdut_suye2 小时前
Linux工具使用指南:从apt管理、gcc编译到makefile构建与gdb调试
java·linux·运维·服务器·c++·人工智能·python
苹果醋32 小时前
2020重新出发,MySql基础,MySql表数据操作
java·运维·spring boot·mysql·nginx