发布时间:2023-10-16 18:30
对于程序员来说,我们通常知道很多概念,例如组件、模块、系统、框架、架构等,而本文我们重点说 框架。
Spring 作为一个框架,目的也是:简化开发 ,只不过在简化开发的过程中 Spring 做了一个特别的设计,那就是 Bean管理,这也是 Spring 的设计核心,而 Bean 生命周期管理的设计巧妙的 解耦 了 Bean 之间的关系。
因此 Spring 核心特性就是 解耦 和 简化。
Spring 框架图示展示得很清晰,基本描绘出 Spring 框架的核心:
简单说,就是 Spring 设计了一个 核心容器 Core Container,这里头主要就是管理 Bean 生命周期,然后为了服务这些业务 Bean ,引入了 Core , Context , SpEL 等工具到核心容器中。然后在核心容器基础上,又为了把更多的能力集成进来,例如为了拓展 数据访问 能力加入了 JDBC 、ORM 、OXM 、JMS 、Transactions 等,为了拓展 Web 能力加入了 WebSocket 、Servlet、Web、Portlet 等,其中为了把 RequestMapping 或 Servlet 等这些使用集成到业务 Bean 上,引入了 AOP ,包括还有引入(最终是提供) Aspects、Instrumentation、Messageing 等增强方式。
所以仔细一看,Spring 就是把像数据库访问、Web支持、缓存、消息发送等等这些能力集成到业务 Bean 上,并提供一些测试支持。总结来说理解 Spring 就两点:
基本体现的就是两个核心特性,一个解耦、一个简化。
Bean管理 本身就是在做 解耦,解除耦合,这个解耦指 Bean 和 Bean 之间的关联关系,Bean 之间通过接口协议互相串联起来的,至于每个接口有多少个实现类,那都不会有任何影响,Bean 之间只保留单点通道,通过接口相互隔离,关系都交给 Spring 管理,这样就避免了实现类和实现类之间出现一些耦合,就算方法增减了、引用变更了也不至于互相污染。
功能增强 本身就是在做 简化,例如声明式简化,像声明式编程,使用者只需要告诉框架他要什么,不用管框架是如何实现的。另外简化方面还有 约定优于配置 (当然这个确切的说是 SpringBoot 里的设计),约定优于配置其实就是约定好了无需去做复杂的配置,例如你引入一个什么组件或能力就像 redis 或 kafka,你不需要提前配置,因为 springboot 已经为你默认配置,开箱即用。
因此 Spring 框架特性怎么理解?就 解耦 和 简化 。
而 SpringBoot,简单理解就是在 Spring 框架基础上添加了一个 SPI 可拓展机制 和 版本管理,让易用性更高,简化升级。
而 SpringCloud,简单理解就是,由于 SpringBoot 的 依赖 可以被很好的管理,拓展 可以被可插拔的拓展,因此在 SpringBoot 基础上集成了很多跟微服务架构相关的能力,例如集成了很多组件,便有了 SpringCloud 全生态。
基本了解了 Spring 特性之后,我们回到 Spring 的核心设计 IoC 与 AOP 。
我们说了 Spring 的其一特性是 解耦,那到底是使用什么来解耦?
控制反转(Inversion of Control,缩写为 IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。其中最常见的方式叫做依赖注入(Dependency Injection,简称 DI),还有一种方式叫“依赖查找”(Dependency Lookup,EJB 和 Apache Avalon 都使用这种方式)。通过控制反转,对象在被创建的时候,由一个调控系统内所有对象的外界实体将其所依赖的对象的引用传递给它。也可以说,依赖被注入到对象中。
简单来说,就是原本 Bean 与 Bean 之间的这种互相调用,变成了由 IoC 容器去统一调配。如果没使用 IoC 容器统一管理业务 Bean,你的应用在部署、修改、迭代的时候,业务 Bean 是会侵入代码实现并互相调用的。
IoC 容器是面向 迭代 起作用,如果你的应用就 不存在迭代 的情况,即系统是万年不变的,那没必要引入 IoC,因为你每引入一项技术,都势必会增加复杂度,所以额外引入 IoC 也一样会增加你整体应用的复杂度,所以假如 不存在迭代,大可直接写死A类引用B类,B类又写死引用C类,无需引入 IoC。一定要理解每一项技术背后是为了解决什么问题,同时在做架构设计的时候记住两个原则:合适 、简单。当然,实际上我们大部分应用是 持续迭代 的,在类实现上、互相引用上、甚至接口协议上都有可能变化,所以一般引入 IoC 是合适的(如果是接口协议变化,即参数或返回值发生变化,那还是需要改动类间的代码的)。
具体的,IoC 相当于是把 Bean 实例的创建过程交给 Spring 管理,无论是通过 XML、JavaConfig,还是注解方式,最终都是把实例化的工作交给 Spring 负责,之后 Bean 之间通过接口相互调用,而实例化过程中就涉及到 注入,无论采用什么方式来实例化 Bean,注入 的类别就两种:
setter注入:
构造器注入:
而这两种方式的注入方式都使用了 反射。
了解反射相关类以及含义:
java.lang.reflect 包提供了许多反射类,用于获取或设置实例对象。简单来说,反射能够:
IoC 和 反射,只是把 Bean 的实例创建处理完,而后续还有 功能增强,功能增强靠的就是 AOP。
AOP全名 Aspect-Oriented Programming ,中文直译为面向切面编程,当前已经成为一种比较成熟的编程思想,可以用来很好的解决应用系统中分布于各个模块的交叉关注点问题。在轻量级的J2EE中应用开发中,使用AOP来灵活处理一些具有 横切性质 的系统级服务,如事务处理、安全检查、缓存、对象池管理等,已经成为一种非常适用的解决方案。
当我们要进行一些日志记录、权限控制、性能统计等时,在传统应用程序当中我们可能在需要的对象或方法中进行编码,而且比如权限控制、性能统计大部分是重复的,这样代码中就存在大量 重复代码,即使有人说我把通用部分提取出来,那必然存在调用还是存在重复,像性能统计我们可能只是在必要时才进行,在诊断完毕后要删除这些代码;还有日志记录,比如记录一些方法访问日志、数据访问日志等等,这些都会渗透到各个要访问方法中;还有权限控制,必须在方法执行开始进行审核,想想这些是多么可怕而且是多么无聊的工作。如果采用 Spring,这些日志记录、权限控制、性能统计从业务逻辑中分离出来,通过 Spring 支持的面向切面编程,在需要这些功能的地方动态添加这些功能,无需渗透到各个需要的方法或对象中;有人可能说了,我们可以使用“代理设计模式”或“包装器设计模式”,你可以使用这些,但还是需要通过编程方式来创建代理对象,还是要 耦合 这些代理对象,而采用 Spring 面向 切面 编程能提供一种更好的方式来完成上述功能,一般通过 配置 方式,而且不需要在现有代码中添加任何额外代码,现有代码专注业务逻辑。
所以,AOP 以横截面的方式插入到主流程中,Spring AOP 面向切面编程能帮助我们无耦合的实现:
AOP 其实就是从应用中划分出来了一个切面,然后在这个切面里面插入一些 “增强” ,最后产生一个增加了新功能的 代理对象,注意,是代理对象,这是Spring AOP 实现的基础。这个代理对象只不过比原始对象(Bean)多了一些功能而已,比如 Bean预处理、Bean后处理、异常处理 等。 AOP 代理的目的就是 将切面织入到目标对象。
前面我们说 IoC 的实现靠反射,然后解耦,那 AOP 靠啥实现?
AOP,简单来说就是给对象增强一些功能,我们需要看 Java 给我们预留了哪些口或者在哪些阶段,允许我们去织入某些增强功能。
我们可以从几个层面来实现AOP。
按照类别分类,基本可以理解为:
类别 |
原理 |
优点 |
缺点 |
静态AOP |
在编译期,切面直接以字节码的形式编译到目标字节码文件中 |
对系统无性能影响 |
灵活度不够 |
动态AOP |
在运行期,目标类加载后,为接口动态生成代理类,将切面织入到代理类中 |
动态代理方式,相对于静态AOP更加灵活 |
切入的关注点需要实现接口,对系统有一点性能影响 |
动态字节码生成 |
在运行期,目标类加载后,动态构建字节码文件生成目标类的 子类,将切面逻辑加入到子类中 |
没有接口也可以织入 |
扩展类的实例方法为final时,则无法进行织入。性能基本是最差的,因为需要生成子类嵌套一层,spring用的cglib就是这么搞的,所以性能比较差 |
自定义类加载器 |
在运行期,在字节码被自定义类加载器加载前,将切面逻辑加到目标字节码里,例如阿里的Pandora |
可以对绝大部分类进行织入 |
代码中如果使用了其他类加载器,则这些类将不会被织入 |
字节码转换 |
在运行期,所有类加载器加载字节码前,进行拦截 |
可以对所有类进行织入 |
- |
当然,理论上是越早织入,性能越好,像 lombok,mapstruct 这类静态AOP,基本在编译期之前都修改完,所以性能很好,但是灵活性方面当然会比较差,获取不到运行时的一些信息情况,所以需要权衡比较。
当然我整理了一份详细的脑图,可以直接在网页上打开。
《脑图:Java实现AOP思路》:
www.processon.com/embed/62333…
发生在 编译期,通过 Pluggable Annotation Processing API 修改源码。
在 javac 进行编译的时候,会根据源代码生成抽象语法树(AST),而 java 通过开放 Pluggable Annotation Processing API 允许你参与修改源代码,最终生成字节码。典型的代表就是 lombok。
发生在 运行期,于 字节码加载后,类、方法已经都被加载到方法区中了。
典型的代表就是 JDK Proxy。
public static void main(String[] args) {
// 需要代理的接口,被代理类实现的多个接口,都必须在这里定义
Class[] proxyInterface = new Class[]{IBusiness.class,IBusiness2.class};
// 构建AOP的Advice,这里需要传入业务类的实例
LogInvocationHandler handler = new LogInvocationHandler(new Business());
// 生成代理类的字节码加载器
ClassLoader classLoader = DynamicProxyDemo.class.getClassLoader();
// 织入器,织入代码并生成代理类
IBusiness2 proxyBusiness =
(IBusiness2)Proxy.newProxyInstance(classLoader, proxyInterface, handler);
// 使用代理类的实例来调用方法
proxyBusiness.doSomeThing2();
((IBusiness)proxyBusiness).doSomeThing();
}
复制代码
其中代理实现 InvocationHandler 接口,最终实现逻辑在 invoke 方法中。生成代理类之后,只要目标对象的方法被调用了,都会优先进入代理类 invoke 方法,进行增强验证等行为。
public class LogInvocationHandler implements InvocationHandler{
private Object target; // 目标对象
LogInvocationHandler(Object target){
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 执行原有逻辑
Object rev = method.invoke(target,args);
// 执行织入的日志,你可以控制那些方法执行切入逻辑
if (method.getName().equals(\"doSomeThing2\")){
// 记录日志
}
return rev;
}
}
复制代码
当然动态代理相对也是性能差,毕竟也多走了一层代理,每多走一层就肯定是越难以优化。
虽然,动态代理在运行期通过接口动态生成代理类,这为其带来了一定的灵活性,但这个灵活性却带来了两个问题:
关于动态代理的详细原理和流程,推荐阅读《一文读懂Java动态代理》。
发生在 运行期,于 字节码加载后 ,生成目标类的子类,将切面逻辑加入到子类中,所以使用Cglib实现AOP不需要基于接口。
此时类、方法同样已经都被加载到方法区中了。
典型的代表就是 Cglib(底层也是基于ASM操作字节码), Cglib 是一个强大的,高性能的 Code 生成类库,它可以在运行期间扩展Java类和实现Java接口,它封装了 Asm,所以使用 Cglib 前需要引入 Asm 的jar。
public static void main(String[] args) {
byteCodeGe();
}
/**
* 动态字节码生成
*/
public static void byteCodeGe() {
//创建一个织入器
Enhancer enhancer = new Enhancer();
//设置父类
enhancer.setSuperclass(Business.class);
//设置需要织入的逻辑
enhancer.setCallback(new LogIntercept());
//使用织入器创建子类
IBusiness2 newBusiness = (IBusiness2) enhancer.create();
newBusiness.doSomeThing2();
}
/**
* 记录日志
*/
public static class LogIntercept implements MethodInterceptor {
@Override
public Object intercept(
Object target,
Method method,
Object[] args,
MethodProxy proxy) throws Throwable {
//执行原有逻辑,注意这里是invokeSuper
Object rev = proxy.invokeSuper(target, args);
//执行织入的日志
if (method.getName().equals(\"doSomeThing\")) {
System.out.println(\"recordLog\");
}
return rev;
}
}
复制代码
Spring 默认采取 JDK 动态代理 机制实现 AOP,当动态代理不可用时(代理类无接口)会使用 CGlib 机制,缺点是:
发生在 运行期,于 字节码加载前,在类加载到JVM之前直接修改某些类的 方法,并将 切入逻辑 织入到这个方法里,然后将修改后的字节码文件交给虚拟机运行。
典型的代表就是 javasist,它可以获得指定方法名的方法、执行前后插入代码逻辑。
Javassist是一个编辑字节码的框架,可以让你很简单地操作字节码。它可以在运行期定义或修改Class。使用Javassist实现AOP的原理是在字节码加载前直接修改需要切入的方法。这比使用Cglib实现AOP更加高效,并且没太多限制,实现原理如下图:
我们使用系统类加载器启动我们自定义的类加载器,在这个类加载器里加一个类加载监听器,监听器发现目标类被加载时就织入切入逻辑,我们再看看使用Javassist 实现 AOP 的代码:
/***启动自定义的类加载器****/
//获取存放CtClass的容器ClassPool
ClassPool cp = ClassPool.getDefault();
//创建一个类加载器
Loader cl = new Loader();
//增加一个转换器
cl.addTranslator(cp, new MyTranslator());
//启动MyTranslator的main函数
cl.run(\"jsvassist.JavassistAopDemo$MyTranslator\", args);
复制代码
// 类加载监听器
public static class MyTranslator implements Translator {
public void start(ClassPool pool) throws
NotFoundException, CannotCompileException {
}
/**
* 类装载到JVM前进行代码织入
*/
public void onLoad(ClassPool pool, String classname) {
if (!\"model$Business\".equals(classname)) {
return;
}
//通过获取类文件
try {
CtClass cc = pool.get(classname);
//获得指定方法名的方法
CtMethod m = cc.getDeclaredMethod(\"doSomeThing\");
//在方法执行前插入代码
m.insertBefore(\"{ System.out.println(\"recordLog\"); }\");
} catch (NotFoundException e) {
} catch (CannotCompileException e) {
}
}
public static void main(String[] args) {
Business b = new Business();
b.doSomeThing2();
b.doSomeThing();
}
}
复制代码
CtClass 是一个class文件的抽象描述。也可以使用 insertAfter() 在方法的末尾插入代码,或者使用 insertAt() 在指定行插入代码。
使用自定义的类加载器实现AOP在性能上要优于动态代理和Cglib,因为它不会产生新类,但是它仍然存在一个问题,就是如果其他的类加载器来加载类的话,这些类将不会被拦截。
自定义的类加载器实现AOP只能拦截自己加载的字节码,那么有没有一种方式能够监控所有类加载器加载字节码呢?有,使用Instrumentation,它是 Java 5 提供的新特性,使用 Instrumentation,开发者可以构建一个字节码转换器,在字节码加载前进行转换。
发生在 运行期 ,于 字节码加载前,Java 1.5 开始提供的 Instrumentation API 。Instrumentation API 就像是 JVM 预先放置的后门,它可以拦截在JVM上运行的程序,修改字节码。
这种方式是 Java API 天然提供的,在 java.lang.instrumentation ,就算 javasist 也是基于此实现。
一个代理实现 ClassFileTransformer 接口用于改变运行时的字节码(class File),这个改变发生在 jvm 加载这个类之前,对所有的类加载器有效。class File 这个术语定义于虚拟机规范3.1,指的是字节码的 byte 数组,而不是文件系统中的 class 文件。接口中只有一个方法:
/**
* 字节码加载到虚拟机前会进入这个方法
*/
@Override
public byte[] transform(
ClassLoader loader,
String className,
Class> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException;
// 把 classBeingRedefined 重定义之后再交还回去
复制代码
ClassFileTransformer 需要添加到 Instrumentation 实例中才能生效。
当对 JVM 中的字节码进行修改的时候,虚拟机也会通知所有线程通过安全点的方式停下来,因为修改会影响到类结构。
Bean生命周期管理,基本从无到有(IoC),从有到增强(AOP)。
任何Bean在Spring容器中只有三种形态,定义、实例、增强。
从Bean定义信息观察,通过 xml 定义 bean关系,properties、yaml、json定义 属性,bean关系和属性就构成Bean的定义,其中BeanDefinitionReader负责扫描定义信息生成Bean定义对象 BeanDefinition。在此基础上,允许对 BeanDefinition 定义进行增强(Mybatis与Spring存在很多使用场景)。
Bean定义完成之后,开始通过反射实例化对象、填充属性等,同时又再次预留了很多增强的口,最终生成一个完整的对象。
从定义到扩展,然后反射实例化,到增强,每种状态都会存在引用。
所以Spring设计 三级缓存,说白了是对应存储Bean生命周期的三种形态:
Spring 就是 反射 + 字节码增强。
深刻理解 Spring 这两部分核心特性,关于 spring、springboot、springcloud 的所有语法糖设计与使用,就自然清楚。
原文链接:https://juejin.cn/post/7091500028476276767