Spring AOP学习笔记01:AOP概述
1. AOP概述
软件开发一直在寻求更加高效、更易维护甚至更易扩展的方式。为了提高开发效率,我们对开发使用的语言进行抽象,走过了从汇编时代到现在各种高级语言繁盛之时期;为了便于维护和扩展,我们对某些相同的功能进行归类并使之模块化,冲出了最初的"原始部落",走过了从过程化编程到面向对象编程(OOP)的"短暂而漫长"的历程。但不管走过的路有多长,多么坎坷,我们一直没有停止寻找更加完美、更加高效的软件开发方法,过去如此,现在亦然。
当OOP被提出来,以取代过去基于过程化编程的开发方法时,或许那个时代的人都会以为,面向对象编程和面向对象的软件开发就是我们一直追求的那颗能够搞定一切的"银弹"。但不得不承认的是,即使面向对象的软件开发模式,依然不能很好地解决软件开发中的所有问题。
软件开发的目的,最终是为了解决各种需求,包括业务需求和系统需求。使用面向对象方法,我们可以对业务需求等普通关注点进行很好的抽象和封装,并且使之模块化。但对于系统需求(比如日志记录、权限验证、事务管理等)一类的关注点来说,情况却有所不同。
对于业务需求而言,需求与其具体实现之间的关系基本上是一对一的。我们可以在系统中某一个确定的点找到针对这种需求的实现,无论从开发还是维护的角度,都比较方便。比如电商系统中的账户管理模块、订单模块、支付模块等,可以很容易地按照功能划分模块并完成开发。
但是,事情并没有结束!开发中为了调试或在进入生产环境后为了对系统进行监控,我们需要为这些业务需求的实现对象添加日志记录功能;或者,业务方法的执行需要一定的权限限制,那么方法执行前肯定需要有相应的安全检查功能。而这些则属于系统需求的范畴。虽然需求都很明确(加入日志记录、加入安全检查),但是要将这些需求以面向对象的方式实现并集成到整个的系统中去,可就不是一个需求对应一个实现那么简单了,系统中的每个业务对象都需要加入日志记录,加入相应的安全检查,那么,这些需求的实现代码就会遍及所有业务对象。
对于系统中普通的业务关注点,OOP可以很好地对其进行分解并使之模块化,但却无法更好地避免类似于系统需求的实现在系统中各处散落这样的问题。所以,我们要寻求一种更好的方法,它可以在OOP的基础上更上一层楼,提出一套全新的方法论来避免以上问题,也可以提供某种方法对基于OOP的开发模式做一个补足,帮助OOP以更好的方式解决以上问题。迄今为止,我们还找不到比OOP更加有效的软件开发模式。不过,我们找到了后者,那就是AOP,对OOP的补足。
AOP全称为Aspect-Oriented Programming,中文通常翻译为面向方面编程。使用AOP,我们可以对类似于Logging和Security等系统需求进行模块化的组织,简化系统需求与实现之间的对比关系,进而使得整个系统的实现更具模块化。
对于一个软件系统而言,日志记录、安全检查、事务管理等系统需求就像一把把刀“恶狠狠”地横切到我们组织良好的各个业务功能模块之上。以AOP的行话来说,这些系统需求是系统中的横切关注点(cross-cutting concern)。使用传统方法,我们无法更好地以模块化的方式,对这些横切关注点进行组织和实现。所以AOP引入了Aspect的概念,用来以模块化的形式对系统中的横切关注点进行封装。Aspect 之对于AOP,就相当于Class之对于OOP。我们说过AOP仅是对OOP方法的一种补足,当我们把以Class形式模块化的业务需求和以Aspect形式模块化的系统需求拼装到一起的时候,整个系统就算完成了。
2. AOP相关概念
在进一步学习Spring AOP之前,我们还需要了解一下AOP涉及的相关概念:
2.1 切点(JoinPoint)
在系统运行之前,AOP的功能模块都需要织入到OOP的功能模块中。所以,要进行这种织入过程,我们需要知道在系统的哪些执行点上进行织入操作,这些将要在其之上进行织入操作的系统执行点就称之为切点(Joinpoint)。对应到spring中可以理解为具体拦截的某个业务点。
以下是一些较为常见的Joinpoint类型
- 方法调用(Method Call)。当某个方法被调用的时候所处的程序执行点。
- 方法调用执行(Method Call execution)。也可以称之为方法执行,该Joinpoint类型代表的是某个方法内部执行开始时点,这需要与上面的方法调用类型的Jointpoint进行区分。方法调用(method call)是在调用对象上的执行点,而方法执行(method execution)则是在被调用到的方法逻辑执行的时点,对于同一对象,方法调用要先于方法执行。
- 构造方法调用(Constructor Call)。程序执行过程中对某个对象调用其构造方法进行初始化的时点。
- 构造方法执行(Constructor Call Execution)。构造方法执行和构造方法调用之间的关系类似于方法执行和方法调用之间的关系,指的是某个对象构造方法内部执行的开始时点。
- 字段设置(Field Set)。对象的某个属性通过setter方法被设置或者直接被设置的时点。
- 字段获取(Field Get)。对象的某个属性通过getter方法获取或者直接访问的时点。
- 异常处理(Exception Handler Execution)。在某些类型异常抛出后,对应的异常处理逻辑执行的时点。
- 类初始化(Class initialization)。类中某些静态类型或者静态块的初始化时点。
基本上程序执行过程中你认为必要的执行时点都可以作为Joinpoint,但是对于一些位置,具体的AOP实现产品在捕捉的时候可能存在一定的困难,或者能够实现但付出太多却可能收效甚微。在Spring AOP中最常见的就是前面的方法执行类型的Joinpoint。
2.2 切面(Pointcut)
Pointcut概念代表的是JointPoint的表述方式。将横切逻辑织入当前系统的过程中,需要参照Pointcut规定的Jointpoint信息,才可以知道应该往系统的哪些Joinpoint上织入横切逻辑。
一个Pointcut可以指定系统中符合条件的一组Joinpoint,但是其是如何来指定的呢?通常有如下几种方式:
直接指定Joinpoint所在方法名称。这种形式的Pointcut表述方式比较简单,而且功能单一,通常只限于支持方法级别Joinpoint的AOP框架。并且这种方式只能一个一个指定,所以通常只限于Joinpoint较少且较为简单的情况。
正则表达式。这是比较普遍的Pointcut表达方式,可以充分利用正则表达式的强大功能来归纳表述符合某种条件的多组Joinpoint。几乎现在大部分的Java平台的AOP产品都支持这种形式的Pointcut表达形式,包括Jboss AOP、Spring AOP以及AspectWerkz等。
使用特定的Pointcut表述语言。这是一种最为强大的表达Pointcut的方式,很灵活,但具体实现起来可能会很复杂,需要设计该表述语言的语法,实现相应的解释器等许多工作。AspectJ使用这种方式来指定Pointcut,它提供了一种类似于正则表达式的针对Pointcut的表述语言,在表达Pointcut方面支持比较完善,而且Spring 2.0之后也是支持这种方式。
2.3 通知(Advice)
Advice是单一横切关注点逻辑的载体,它代表将会织入到Joinpoint的横切逻辑。如果将Aspect比作OOP中的Class,那么Advice就相当于Class中的Method。
按照Advice在Jointpoint位置执行时机的差异或者完成功能的不同,Advice可以分成多种具体形式。
- Before Advice
Before Advice是在Joinpoint指定位置之前执行的Advice类型。通常,它不会中断程序执行流程,但如果必要,可以通过在Before Advice中抛出异常的方式来中断当前程序流程。如果当前Before Advice将被织入到方法执行类型的Joinpoint,那么这个Before Advice就会先于方法执行而执行。 通常,可以使用Before Advice做一些系统的初始化工作,比如设置系统初始值,获取必要系统资源。
- After Advice
顾名思义,After Advice就是在相应连接点之后执行的Advice类型,但该类型的Advice还可以细分为三种:
After returning Advice。只有当前Joinpoint处执行流程正常完成后,After returning Advice才会执行。
After throwing Advice。又称Throws Advice,只有在当前Joinpoint执行过程中抛出异常的情况下,才会执行。比如某个方法执行类型的Joinpoint抛出某异常而没有正常返回。
After Advice。或许叫After (Finally) Advice更为确切,该类型Advice不管Joinpoint处执行流程是正常终了还是抛出异常都会执行,就好像Java中的finally块一样。
- Around Advice
Around Advice对附加其上的Joinpoint进行"包裹",可以在Joinpoint之前和之后都指定相应的逻辑,甚至于中断或者忽略Joinpoint处原来程序流程的执行。
2.4 Aspect
Aspect是对系统中的横切关注点逻辑进行模块化封装的AOP概念实体,可以理解为拦截器类,其中会定义切点以及拦截处理逻辑。通常情况下,Aspect可以包含多个Pointcut以及相关Advice定义。在Spring中,是通过使用@AspectJ注解并结合普通POJO来声明Aspect的。
@AspectJ
public class AspectClass{
// pointcut 定义 // advice 定义
}
2.5 目标对象
符合Pointcut所指定的条件,将在织入过程中被织入横切逻辑的对象,称为目标对象(Target Object)。
3. Spring AOP
AOP只是一种理念,要实现这种理念,通常需要一种现实的方式。Spring AOP就是一款AOP的实现产品,Spring AOP是Spring核心框架的重要组成部分,通常认为它与Spring的IoC容器以及Spring框架对其他JavaEE服务的集成共同组成了Spring框架的"质量三角",足见其地位之重要。
在Java语言的基础之上,Spring AOP对AOP的概念进行了适当的抽象和实现,使得每个AOP的概念都可以落到实处,在详细学习Spring AOP概念实体之前,我们有必要先看一下其是如何运作的。
Spring AOP从最初发布以来,一直延续了最初的设计,也就是采用动态代理机制和字节码生成技术来实现基于Java语言的简单而强大的AOP框架。与最初的AspectJ采用编译器将横切逻辑织入目标对象不同,动态代理机制和字节码生成都是在运行期间为目标对象生成一个代理对象,再将横切逻辑织入到这个代理对象中,系统最终使用的是织入了横切逻辑的代理对象,而不是真正的目标对象。
要理解这种差别以及最终可以达到的效果,有必要先从动态代理机制的根源--代理模式(Proxy Pattern)开始说起。。。
3.1 设计模式之代理模式
说到代理,举几个简单的例子,比如房地产中介就是一种代理,我们偶尔使用的网络代理也是一种代理,类似例子很多,就不一一列举了。代理处于访问者与被访问者之间,可以隔离这两者之间的直接交互,访问者与代理打交道就好像在跟被访问者在打交道一样,因为代理通常几乎会全权拥有被代理者的职能,代理能够处理的访问请求就不必要劳烦被访问者来处理了。从这个角度来讲,有两个好处:
- 代理可以减少被访问者的负担;
- 即使代理最终要将访问请求转发给真正的被访问者,它也可以在转发访问请求之前或者之后加入特定的逻辑,比如安全访问限制;
在软件系统中,代理机制的实现有现成的设计模式支持,即代理模式。在代理模式中通常涉及4种角色:
- ISubject。该接口是对被访问者或者被访问资源的抽象。在严格的设计模式中,这样的抽象接口是必须的。
- SubjectImpl。这是被访问者或者被访问资源的具体实现类。如果你要访问某位明星,那么SubjectImpl就是你想要访问的明星;如果你想要买房子,那么SubjectImpl就是房主。
- SubjectProxy。这是被访问者或者被访问资源的代理实现类,该类持有一个ISubject接口的具体实例。在这个场景中,我们要对SubjectImpl进行代理,那么SubjectProxy现在持有的就是SubjectImpl的实例。
- Client。这代表访问者的抽象角色,Client将会访问ISubject类型的对象或者资源。在这个场景中,Client将会请求具体的SubjectImpl实例,但Client无法直接请求其真正要访问的资源SubjectImpl,而是必须通过ISubject资源的访问代理类SubjectProxy进行。
SubjectImpl和SubjectProxy都实现了相同的接口ISubject,而SubjectProxy内部持有SubjectImpl的引用。当Client通过request()请求服务的时候,SubjectProxy将转发该请求给SubjectImpl。从这个角度来说,SubjectProxy反而有多此一举之嫌了,不过SubjectProxy的作用不只局限于请求的转发,更多时候是对请求添加更多访问限制。SubjectImpl和SubjectProxy之间的调用关系如下代码所示:
public class SubjectProxy implements ISubject{
private ISubject subject; // Inject SubjectImpl to SubjectProxy
public String request(){
// add pre-process logic if necessary String originalResult = subject.request(); // add post process logic if necessary return "Proxy:" + originalResult;
}
public ISubject getSubject(){
return subject;
}
public void setSubject(ISubject subject){
this.subject = subject;
}
} public class SubjectImpl implements ISubject{
public String request(){
// process logic
return "OK";
}
}
在将请求转发给被代理对象SubjectImpl之前或者之后,都可以根据情况插入其他处理逻辑,比如在转发之前记录方法执行开始时间,在转发之后记录结束时间,这样就能够对SubjectImpl的request()执行的时间进行检测。或者,可以只在转发之后对SubjectImpl的request()方法返回结果进行覆盖,返回不同的值。甚至,可以不做请求转发,这样,就不会有SubjectImpl的访问发生。
代理对象SubjectProxy就像是SubjectImpl的影子,只不过这个影子通常拥有更多的功能。如果SubjectImpl是系统中Jointpoint所在的对象(即目标对象),那么就可以为这个目标对象创建一个代理对象,然后将横切逻辑添加到这个代理对象中。当系统使用这个代理对象的时候,原有逻辑的实现和横切逻辑就完全融合到一个系统中。
Spring AOP本质上就是采用这种代理机制实现的,但是,具体实现细节上有所不同。我们来看一下上面的代理实现,我们是将代理类直接写好,然后在代码中手动初始化代理类并通过调用代理类来实现代理功能,发现没有,如果系统里面有很多类需要代理相同的能,那么我们就要写很多的代理类,尽管它们代理的内容是一样的,这样是有问题的。上面这种为对应的目标对象创建静态代理的方法,原理上是可行的,但具体应用上存在问题,所以要寻找其他方法,那有没有呢,答案是肯定有的,就是接下来我们要讲的动态代理。
3.2 动态代理
JDK1.3之后,引入了动态代理(Dynamic Proxy)机制,可以在运行期间,为相应的接口(Interface)动态生成对应的代理对象,从而帮助我们走出最初使用静态代理实现AOP的窘境。
动态代理机制的实现主要由一个类和一个接口组成,即java.lang.reflect.Proxy类和java.lang.reflect.InvocationHandler接口。InvacationHandler就是我们实现横切逻辑的地方,它是横切逻辑的载体,作用跟Advice是一样的。所以在使用动态代理机制实现AOP的过程中,我们可以在InvocationHandler的基础上细化程序结构,根据Advice的类型,分化出对应不同的Advice类型的程序结构。
所以,我们可以将横切关注点逻辑封装到动态代理的InvocationHandler中,然后在系统运行期间,根据横切关注点需要织入的模块位置,将横切逻辑织入到相应的代理类中。以动态代理类为载体的横切逻辑,现在当然就可以与系统其他实现模块一起工作了。
动态代理虽好,但不能满足所有的需求,这种方式实现的唯一缺点或者说优点就是,所有需要织入横切关注点逻辑的模块类都得实现相应的接口,因为动态代理机制只针对接口有效。如果某个类没有实现任何的接口,就无法使用动态代理机制为其生成相应的动态代理对象。对于没有实现任何接口的目标对象我们需要寻找其他方式为其动态的生成代理对象。
默认情况下,Spring AOP发现目标对象实现了相应接口,则采用动态代理机制为其生成代理对象实例。而如果目标对象没有实现任何接口,Spring AOP则会尝试使用一个称为CGLIB(Code Generation Library)的开源的动态字节码生成类库,为目标对象生成动态的代理对象实例。
3.3 动态字节码增强
使用动态字节码生成技术扩展对象行为的原理是,我们可以对目标对象进行继承扩展,为其生成相应的子类,而子类可以通过覆写来扩展父类的行为,只要将横切逻辑的实现放到子类中,然后让系统使用扩展后的目标对象的子类,就可以达到与代理模式相同的效果了。
但是使用继承的方式来扩展对象定义,也不能像静态代理模式那样,为每个不同类型的目标对象都单独创建相应的扩展子类。所以,我们要借助于CGLIB这样的动态字节码生成库,在系统运行期间动态地为目标对象生成相应的扩展子类。
我们知道,Java虚拟机加载的文件都是符合一定规范的,所以,只要交给Java虚拟机运行的文件符合Java class规范,程序的运行就没有问题。通常的class文件都是从Java源代码文件使用Javac编译器编译而成的,但只要符合Java class规范,我们也可以使用ASM或者CGLiB等Java工具库,在程序运行期间,动态构建字节码的class文件。
在这样的前提下,我们可以为需要织入横切逻辑的模块类在运行期间,通过动态字节码增强技术,为这些系统模块类生成相应的子类,而将横切逻辑加到这些子类中,让应用程序在执行期间使用从这些动态生成的子类,从而达到将横切逻辑织入系统的目的。 使用动态字节码增强技术,即使模块类没有实现相应的接口,我们依然可以对其进行扩展,而不用像动态代理那样受限于接口。不过,这种实现机制依然存在不足,如果需要扩展的类以及类中的实例方法等声明为final的话,则无法对其进行子类化的扩展。
3.4 一个spring aop示例
上面说了这么多,下面就来看一个简单的例子,体会一下aop的魔法吧。如果只是引用了spring-context,那么还需要引入spring-aspects:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
<version>3.2.18.RELEASE</version>
</dependency>
这里我们采用xml配置的方式来开启aop功能,在resources目录下添加一个xml配置文件,其中<aop:aspectj-autoproxy/>是用来开启aop的:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.springframework.org/schema/beans"
xmlns:aop = "http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop-3.0.xsd"> <aop:aspectj-autoproxy/> <bean id = "test" class = "spring.aop.TestAopBean"/>
<bean class = "spring.aop.AspectJTest"/>
</beans>
添加Aspect:
@Aspect
public class AspectJTest { @Pointcut("execution(* *.test(..))")
public void test(){ } @Before("test()")
public void beforeTest(){
System.out.println("beforeTest");
} @After("test()")
public void afterTest(){
System.out.println("afterTest");
} @Around("test()")
public Object aroundTest(ProceedingJoinPoint p){
System.out.println("before1");
Object o = null;
try{
o = p.proceed();
}catch (Throwable e){
e.printStackTrace();
}
System.out.println("after1");
return o;
}
}
添加测试类:
public class TestAopBean { private String testStr = "testStr"; public String getTestStr(){
return testStr;
} public void setTestStr(String testStr){
this.testStr = testStr;
} public void test(){
System.out.println("hello test");
} public static void main(String[] args) {
ApplicationContext ctx = new ClassPathXmlApplicationContext("aspectJTest.xml");
TestAopBean test = (TestAopBean)ctx.getBean("test");
test.test();
}
}
可以看到输出结果:
before1
beforeTest
hello test
after1
afterTest
这是一个aop简单示例,我们写了一个切面(Pointcut),用来指定Joinpoint的位置在执行test()方法时;同时分别定义了三个Advice(Before、After、Around)用来指定要织入的动作,最后将Pointcut和Advice封装到一个Aspect中,这样就完成了横切逻辑的织入。
4. 总结
在深入学习Spring AOP之前,我们先对AOP的概况进行了介绍,接着一起探索了Spring AOP的实现机制,包括最原始的代理模式,直至最终的动态代理与动态字节码生成技术。
- AOP是能够让我们在不影响系统原有功能前提下,为软件系统横向扩展功能;
- Spring AOP通过两种方式实现:JDK动态代理、动态字节码增强;
在了解了这些内容之后,我们将继续深入学习Spring AOP。
Spring AOP学习笔记01:AOP概述的更多相关文章
- Spring MVC学习笔记 01
applicationcontext.xml的配置 <?xml version="1.0" encoding="UTF-8" ?> <bean ...
- Spring Boot 学习笔记 - 01
看了[纯洁的微笑]的博客后,我决定开始学好 Spring 体系了,真的是解决了饥渴的我.
- 【转】Spring.NET学习笔记——目录
目录 前言 Spring.NET学习笔记——前言 第一阶段:控制反转与依赖注入IoC&DI Spring.NET学习笔记1——控制反转(基础篇) Level 200 Spring.NET学习笔 ...
- Spring.NET学习笔记——目录(原)
目录 前言 Spring.NET学习笔记——前言 第一阶段:控制反转与依赖注入IoC&DI Spring.NET学习笔记1——控制反转(基础篇) Level 200 Spring.NET学习笔 ...
- Spring入门IOC和AOP学习笔记
Spring入门IOC和AOP学习笔记 概述 Spring框架的核心有两个: Spring容器作为超级大工厂,负责管理.创建所有的Java对象,这些Java对象被称为Bean. Spring容器管理容 ...
- Spring学习笔记之aop动态代理(3)
Spring学习笔记之aop动态代理(3) 1.0 静态代理模式的缺点: 1.在该系统中有多少的dao就的写多少的proxy,麻烦 2.如果目标接口有方法的改动,则proxy也需要改动. Person ...
- Spring MVC 学习笔记12 —— SpringMVC+Hibernate开发(1)依赖包搭建
Spring MVC 学习笔记12 -- SpringMVC+Hibernate开发(1)依赖包搭建 用Hibernate帮助建立SpringMVC与数据库之间的联系,通过配置DAO层,Service ...
- Spring Boot 学习笔记(六) 整合 RESTful 参数传递
Spring Boot 学习笔记 源码地址 Spring Boot 学习笔记(一) hello world Spring Boot 学习笔记(二) 整合 log4j2 Spring Boot 学习笔记 ...
- Spring框架学习笔记(1)
Spring 框架学习笔记(1) 一.简介 Rod Johnson(spring之父) Spring是分层的Java SE/EE应用 full-stack(服务端的全栈)轻量级(跟EJB比)开源框架, ...
随机推荐
- 从垃圾回收机制解析为什么局部内部类只能访问final修饰的局部变量以及为什么加final能解决问题
我们先稍微看一下代码: 从这里的提示可以看到,必须要将a的修饰符变为final才行. 现在笔者就这一结果做出自己的分析: 首先来说,我们知道,方法被调用时会执行,当执行的时候,方法中的局部变量会加载到 ...
- 心路历程-安装Docker
心路历程-安装Docker 本机环境 Windows10 激活HyperV功能 新建CentOS虚拟机 centos docker安装 由于是新的虚拟机,所以没有docker旧版本的问题,不需要卸载旧 ...
- matlab 提示 Error using mex No supported compiler or SDK was found 错误的解决办法
在使用simulink的S-Function去调用C程序的时候,需要使用mex指令预先编译C程序,但是出现 Error using mex No supported compiler or SDK w ...
- Cassandra数据建模
1. 概述 Apache Cassandra将数据存储在表中,每个表都由行和列组成.CQL(Cassandra查询语言)用于查询存储在表中的数据.Apache Cassandra数据模型基于查询并针 ...
- vue实现音乐播放器实战笔记
原文链接:https://blog.csdn.net/Forever201295/article/details/80266600 一.项目说明该播放器的是基于学习vue的实战练习,不用于其他途径.应 ...
- 07JAVA基础面向对象-继承/多态
一.继承 1.概念 子类的共性 重用现有类并在此基础上进行扩展 public class 子类 extends 父类{} 2.继承中的成员访问 成员变量 成员方法 局部->本类中成员变量-> ...
- mysql批量导入删除
批量导入 <insert id="extractOrderBaseHis">INSERT INTO `odr_order_base_his`(`order_base_i ...
- javaweb学习之路(2)response
写一个简单的登录页面 1.创建一个login.jsp文件 主要内容: <form action="check.jsp" method="post"> ...
- 3、get请求(url详解)
前言 上一篇介绍了Composer的功能,可以模拟get和post请求,get请求有些是不带参数的,这种比较容易,直接放到url地址栏就行.有些get请求会带有参数,本篇详细介绍url地址格式. 一. ...
- 蒲公英 · JELLY技术周刊 Vol.07: EcmaScript 2020 -- 所有你想要知道的都在这
「蒲公英」期刊,每周更新,我们专注于挖掘「基础技术.工程化.跨端框架技术.图形编程.服务端开发.桌面开发.人工智能」等多个大方向的业界热点,并加以专业的解读:不仅如此,我们还精选凹凸技术文章,向大家呈 ...