🔊 开始写 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()))
.....
}
然而这样结构性重复的代码,在过往的代码工程中,随处可见,比比皆是。
这样的代码优化很简单,把判断是否包含的特定类提炼出来当成入参不就可以了。
那么这样做能达到复用的原因是什么呢?
不妨来分析一下影响这个方法变化的因素可能有哪些:
- 当前线程中 【当前】 是不稳定因素,因为可以不是当前线程,比如异步线程。
- 【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
:不具备个性化的色彩,属于非变化部分,稳定点 - 实参是变化的部分,可以在任何调用的地方传入任何类,不稳定
将不变与变化进行分离,上面的代码便具备了普适性,可复用。这一刻愿称为这个方法为工具方法
用最少的代码做最多的事情,这可能就是代码的艺术。
😎谈到这里,不妨我再激进一些......
更进一步
- java.lang.Thread#getStackTrace 查看源码并不会返回 null,因此不用校验 null
- 函数式编程,Objects::nonNull,判断空, 代码更简单
- Optional 减少 NPE。 findFirst 方法调用会返回 Optional
- 链式(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 等业务信息。这个类可以用以下方式改进:
- 修改类名,直接称为 RoleConverter 即可,去掉 Util
- 通过反射等方式等,修改成与业务无关性方法。
📜三、最后的总结
关注点分离:可以是一个方法,一个类,一个 module,甚至一个工程,将变和不变区分,让职责变得清晰,变得干净,这是一种指导思想。当我开始这样做以后,我发现代码职责更加清晰,更加清爽,更加方便扩展,甚至代码量也更少,甚至 Bug 也少。
编程思想
优秀代码的背后都蕴含着一些哲学的道理。编程有各种表达方法、千变万化的技巧,但这些形式的东西会不断地推陈出新的;但在其背后的编程思想却不会改变。
而这篇文章所有传达也不仅是关注点分离这个编程技巧,而更想传达的一种思想。因为编程思想才是那部分不变的,也是一直指导我编码的核心。或许这也一种关注点分离。
🍁🍁🍁有很多人说,能跑的代码就是好代码;虽如此,但对于有些情怀的我,还是想让自己的代码尽量体面。
🎉🎉🎉 此文章也献给那些年杭漂的自己。