• Spring AOP


    代理

    二十三种设计模式中的一种,属于结构型模式。它的作用就是通过提供一个代理类,让我们在调用目标方法的时候,不再是直接对目标方法进行调用,而是通过代理类间接调用。让不属于目标方法核心逻辑的代码从目标方法中剥离出来——解耦。调用目标方法时先调用代理对象的方法,减少对目标方法的调用和打扰,同时让附加功能能够集中在一起也有利于统一维护。 

    先来看一个需求:
    设计一个计算器程序,要求在计算的前后输出日志

    先定义一个接口

    1. package com.atguigu.spring6.aop.annoaop;
    2. public interface Calculator {
    3. int add(int i, int j);
    4. int sub(int i, int j);
    5. int mul(int i, int j);
    6. int div(int i, int j);
    7. }

    创建接口的实现类

     

    1. package com.atguigu.spring6.aop.annoaop;
    2. import org.springframework.stereotype.Component;
    3. //基本实现类
    4. @Component
    5. public class CalculatorImpl implements Calculator {
    6. @Override
    7. public int add(int i, int j) {
    8. int result = i + j;
    9. System.out.println("方法内部 result = " + result);
    10. return result;
    11. }
    12. @Override
    13. public int sub(int i, int j) {
    14. int result = i - j;
    15. System.out.println("方法内部 result = " + result);
    16. return result;
    17. }
    18. @Override
    19. public int mul(int i, int j) {
    20. int result = i * j;
    21. System.out.println("方法内部 result = " + result);
    22. return result;
    23. }
    24. @Override
    25. public int div(int i, int j) {
    26. int result = i / j;
    27. System.out.println("方法内部 result = " + result);
    28. return result;
    29. }
    30. }

    在程序里面设计一个在计算前后输出日志的功能
     

    1. package com.atguigu.spring6.aop.example;
    2. //带日志
    3. public class CalculatorLogImpl implements Calculator{
    4. @Override
    5. public int add(int i, int j) {
    6. System.out.println("[日志] add 方法开始了,参数是:" + i + "," + j);
    7. int result = i + j;
    8. System.out.println("方法内部 result = " + result);
    9. System.out.println("[日志] add 方法结束了,结果是:" + result);
    10. return result;
    11. }
    12. @Override
    13. public int sub(int i, int j) {
    14. System.out.println("[日志] sub 方法开始了,参数是:" + i + "," + j);
    15. int result = i - j;
    16. System.out.println("方法内部 result = " + result);
    17. System.out.println("[日志] sub 方法结束了,结果是:" + result);
    18. return result;
    19. }
    20. @Override
    21. public int mul(int i, int j) {
    22. System.out.println("[日志] mul 方法开始了,参数是:" + i + "," + j);
    23. int result = i * j;
    24. System.out.println("方法内部 result = " + result);
    25. System.out.println("[日志] mul 方法结束了,结果是:" + result);
    26. return result;
    27. }
    28. @Override
    29. public int div(int i, int j) {
    30. System.out.println("[日志] div 方法开始了,参数是:" + i + "," + j);
    31. int result = i / j;
    32. System.out.println("方法内部 result = " + result);
    33. System.out.println("[日志] div 方法结束了,结果是:" + result);
    34. return result;
    35. }
    36. }

    ①现有代码缺陷

    针对带日志功能的实现类,我们发现有如下缺陷:

    • 对核心业务功能有干扰,导致程序员在开发核心业务功能时分散了精力
    • 附加功能分散在各个业务功能方法中,不利于统一维护

    ②解决思路

    解决这两个问题,核心就是:解耦。我们需要把附加功能从业务功能代码中抽取出来。

    ③困难

    解决问题的困难:要抽取的代码在方法内部,靠以前把子类中的重复代码抽取到父类的方式没法解决。所以需要引入新的技术。

     

    在没有使用代理之前,我们调用的逻辑是这样的:
     

    下面我们要使用代理,代理的调用逻辑是这样的: 

     

    使用代理以后得代码

    1. package com.atguigu.spring6.aop.example;
    2. public class CalculatorStaticProxy implements Calculator{
    3. //被代理目标对象传递过来
    4. private Calculator calculator;
    5. public CalculatorStaticProxy(Calculator calculator) {
    6. this.calculator = calculator;
    7. }
    8. @Override
    9. public int add(int i, int j) {
    10. //输出日志
    11. System.out.println("[日志] add 方法开始了,参数是:" + i + "," + j);
    12. //调用目标对象的方法实现核心业务
    13. int addResult = calculator.add(i, j);
    14. System.out.println("[日志] add 方法结束了,结果是:" + addResult);
    15. return addResult;
    16. }
    17. @Override
    18. public int sub(int i, int j) {
    19. return 0;
    20. }
    21. @Override
    22. public int mul(int i, int j) {
    23. return 0;
    24. }
    25. @Override
    26. public int div(int i, int j) {
    27. return 0;
    28. }
    29. }

    上面就是使用了代理技术,不过使用的是静态代理。

    静态代理确实实现了解耦,但是由于代码都写死了,完全不具备任何的灵活性。就拿日志功能来说,将来其他地方也需要附加日志,那还得再声明更多个静态代理类,那就产生了大量重复的代码,日志功能还是分散的,没有统一管理。

    提出进一步的需求:将日志功能集中到一个代理类中,将来有任何日志需求,都通过这一个代理类来实现。这就需要使用动态代理技术了。

     

    动态代理的过程:
     

    实现代码

    1. package com.atguigu.spring6.aop.example;
    2. import java.lang.reflect.InvocationHandler;
    3. import java.lang.reflect.Method;
    4. import java.lang.reflect.Proxy;
    5. import java.util.Arrays;
    6. public class ProxyFactory {
    7. //目标对象
    8. private Object target;
    9. public ProxyFactory(Object target) {
    10. this.target = target;
    11. }
    12. //返回代理对象
    13. public Object getProxy() {
    14. /**
    15. * Proxy.newProxyInstance()方法
    16. * 有三个参数
    17. * 第一个参数:ClassLoader: 加载动态生成代理类的来加载器
    18. * 第二个参数: Class[] interfaces:目录对象实现的所有接口的class类型数组
    19. * 第三个参数:InvocationHandler:设置代理对象实现目标对象方法的过程
    20. */
    21. //第一个参数:ClassLoader: 加载动态生成代理类的来加载器
    22. ClassLoader classLoader = target.getClass().getClassLoader();
    23. //第二个参数: Class[] interfaces:目录对象实现的所有接口的class类型数组
    24. Class[] interfaces = target.getClass().getInterfaces();
    25. //第三个参数:InvocationHandler:设置代理对象实现目标对象方法的过程
    26. InvocationHandler invocationHandler =new InvocationHandler() {
    27. //第一个参数:代理对象
    28. //第二个参数:需要重写目标对象的方法
    29. //第三个参数:method方法里面参数
    30. @Override
    31. public Object invoke(Object proxy,
    32. Method method,
    33. Object[] args) throws Throwable {
    34. //方法调用之前输出
    35. System.out.println("[动态代理][日志] "+method.getName()+",参数:"+ Arrays.toString(args));
    36. //调用目标的方法
    37. Object result = method.invoke(target, args);
    38. //方法调用之后输出
    39. System.out.println("[动态代理][日志] "+method.getName()+",结果:"+ result);
    40. return result;
    41. }
    42. };
    43. return Proxy.newProxyInstance(classLoader,interfaces,invocationHandler);
    44. }
    45. }
    1. package com.atguigu.spring6.aop.example;
    2. public class TestCal {
    3. public static void main(String[] args) {
    4. //创建代理对象(动态)
    5. ProxyFactory proxyFactory = new ProxyFactory(new CalculatorImpl());
    6. Calculator proxy = (Calculator)proxyFactory.getProxy();
    7. //proxy.add(1,2);
    8. proxy.mul(2,4);
    9. }
    10. }

     

     

     

     

    AOP

    AOP(Aspect Oriented Programming)是一种面向切面编程的思想,切面指的是系统的某一个方面。AOP是针对程序中的某一类(某个方面)的功能做统一处理,比如用户登录权限的效验。Spring AOP是一个框架,提供了AOP思想的实现,AOP和Spring AOP的关系就像IoC和DI。

    AOP可以实现一下功能:

    统一日志记录、统一方法执行时间统计、统一格式返回格式设置、统一的异常处理、事务的开启和提交等,AOP编程是对OOP编程的补充和完善。

    在登录授权中AOP的作用:

    AOP的组成 

    AOP主要由四点组成,分别是:切面、切点、连接点、通知

    切面 

    切面由切点和通知组成,它是针对某一个功能的具体定义,这个功能可能是登录验证功能,也有可能是日志记录功能,这里可以将AOP形象的比喻为一个数据库,一个AOP就是一个数据库。

    切点 

    切点是一个保存了众多连接点的一个集合,切点是切面中的某个方法,如果将切面比作数据库,那么切点就是数据库里面的表。

    通知 

    我们将切面必须完成工作称为通知,切面的工作就是通知。通知有五种:前置通知(使用@Before)、后置通知(使用@After)、返回之后通知(使用@AfterReturning)、抛出异常通知(使用AfterThrowing)、环绕通知(使用@Around)

    连接点

    所有可能触发AOP(拦截方法的点)就称之为连接点

    上面AOP的组成可以用一张图来表示:

    Spring AOP的实现 

    基于注解的方式实现

    Spring AOP的实现步骤如下:

    1.添加Spring AOP框架支持;

    2.定义切面和切点;

    3.定义通知;

    添加Spring AOP框架支持 

    在pom.xml文件中插入以下框架 

    1. <dependency>
    2. <groupId>org.springframework.bootgroupId>
    3. <artifactId>spring-boot-starter-aopartifactId>
    4. dependency>
    定义切面和切点 

    1. @Aspect //定义切面
    2. @Component
    3. public class UserAspect {
    4. //切点(配置拦截规则)
    5. @Pointcut("execution(* com.example.myblog.controller.UserController.*(..))")
    6. public void pointcut(){
    7. }
    8. }

    对上面代码的解释:

    @Aspect这个注解表示这个类是一个AOP类,也就是说这个类是一个切面;

    @Component这个注解表示组件,当我们不知道具体该给某个类使用那个注解来注入到Spring框架时,我们就可以使用@Component这个注解;

    @Pointcut这个注解表示该方法是一个切点,它后面跟的是AspectJ表达式(切点表达式)。该表达式的语法:execution(<修饰符><返回类型><包.类.方法(参数)><异常>)

    上面的包和类一般情况下是要有的,但可以省略;异常可省略一般不写。 

    AspectJ支持三种通配符:

    *:匹配任意字符,只匹配一个元素(这个元素可以是包、类、或者方法、

    方法参数)。

    ..:匹配任意字符,可以匹配多个元素,在表示类时,必须和*联合使用。 

    +:表示按照类型匹配指定类的所有类,必须跟在类名后面,如com.cad.Car+,表示继承该类的所有子类包括本身。

    表达式示例:

    execution(* addUser(String,int)):匹配addUser方法,且第一个参数类型是String,第二个参数是int。

    excution(* com.cad.demo.User.*(..)):匹配User类里面的所有方法。

    excution(* com.cad.demo.User+.*(..)):匹配该类的子类包括该类的所有方法。

    excution(* com.cad.*.*(..):匹配com.cad包下面的所有类的所有方法。

    excution(* com.cad..*.*(..)):匹配com.cad包下、子孙包下所有类的所有方法。

    定义通知(在什么时机执行什么方法)

    通知定义的是被拦截的方法具体要执行的业务,比如用户登录权限验证就是具体要执行的业务。Spring AOP中,可以在方法上使用以下注解,会设置方法为通知方法,在满足条件后会通知本方法进行调用:

    前置通知使用@Before:通知方法会在目标方法执行调用之前执行。

    后置通知使用@After:通知方法会在目标方法返回或者抛出异常后调用。

    返回之后通知使用@AfterRuturning:通知方法会在目标方法返回后调用。

    抛出异常后通知使用@AfterThrowing:通知方法会在目标方法抛出异常后调用。

    环绕通知使用@Around:通知包裹了被通知的方法,在被通知的方法通知之前和调用之后执行自定义的行为。 

    执行前置通知:  

    1. @Aspect //定义切面
    2. @Component
    3. public class UserAspect {
    4. //切点(配置拦截规则)
    5. @Pointcut("execution(* com.example.myblog.controller.UserController.*(..))")
    6. public void pointcut(){
    7. }
    8. //前置通知
    9. @Before("pointcut()")
    10. public void doBefore(){
    11. //业务代码...
    12. System.out.println();
    13. System.out.println("执行了前置通知");
    14. System.out.println();
    15. }
    16. }

      

    演示结果:

    执行后置通知:

    1. @Aspect //定义切面
    2. @Component
    3. public class UserAspect {
    4. //切点(配置拦截规则)
    5. @Pointcut("execution(* com.example.myblog.controller.UserController.*(..))")
    6. public void pointcut(){
    7. }
    8. //后置通知
    9. @After("pointcut()")
    10. public void doAfter(){
    11. //业务代码...
    12. System.out.println();
    13. System.out.println("执行了后置通知");
    14. System.out.println();
    15. }
    16. }

     

    执行返回之后通知:

    1. @Aspect //定义切面
    2. @Component
    3. public class UserAspect {
    4. //切点(配置拦截规则)
    5. @Pointcut("execution(* com.example.myblog.controller.UserController.*(..))")
    6. public void pointcut(){
    7. }
    8. //前置通知
    9. @Before("pointcut()")
    10. public void doBefore(){
    11. //业务代码...
    12. System.out.println();
    13. System.out.println("执行了前置通知");
    14. System.out.println();
    15. }
    16. //后置通知
    17. @After("pointcut()")
    18. public void doAfter(){
    19. //业务代码...
    20. System.out.println();
    21. System.out.println("执行了后置通知");
    22. System.out.println();
    23. }
    24. //return之前通知
    25. @AfterReturning("pointcut()")
    26. public void doAfterReturning(){
    27. //业务代码...
    28. System.out.println();
    29. System.out.println("执行了doAfterReturning通知");
    30. System.out.println();
    31. }
    32. }

    可以看到执行返回之后通知它是在返回的时候之后调用的,在返回通知之前调用。 

    执行抛出异常后通知:

    1. @Aspect //定义切面
    2. @Component
    3. public class UserAspect {
    4. //切点(配置拦截规则)
    5. @Pointcut("execution(* com.example.myblog.controller.UserController.*(..))")
    6. public void pointcut(){
    7. }
    8. //前置通知
    9. @Before("pointcut()")
    10. public void doBefore(){
    11. //业务代码...
    12. System.out.println();
    13. System.out.println("执行了前置通知");
    14. System.out.println();
    15. }
    16. //后置通知
    17. @After("pointcut()")
    18. public void doAfter(){
    19. //业务代码...
    20. System.out.println();
    21. System.out.println("执行了后置通知");
    22. System.out.println();
    23. }
    24. //return之前通知
    25. @AfterReturning("pointcut()")
    26. public void doAfterReturning(){
    27. //业务代码...
    28. System.out.println();
    29. System.out.println("执行了doAfterReturning通知");
    30. System.out.println();
    31. }
    32. //抛出异常之前通知
    33. @AfterThrowing("pointcut()")
    34. public void doAfterThrowing(){
    35. //业务代码...
    36. System.out.println();
    37. System.out.println("执行了doAfterThrowing通知");
    38. System.out.println();
    39. }
    40. }

      

    执行环绕通知:

    1. //添加环绕通知
    2. @Around("pointcut()")
    3. public Object doAround(ProceedingJoinPoint joinPoint){
    4. Object o = null;
    5. System.out.println("Around 方法开始执行");
    6. try {
    7. o = joinPoint.proceed();
    8. } catch (Throwable e) {
    9. e.printStackTrace();
    10. }
    11. System.out.println("Around方法结束执行");
    12. return o;
    13. }

    可以看到环绕通知是在所有通知方法之前和之后调用,因此可以用来统计方法的执行时间。 

    1. @Aspect //定义切面
    2. @Component
    3. public class UserAspect {
    4. //切点(配置拦截规则)
    5. @Pointcut("execution(* com.example.myblog.controller.UserController.*(..))")
    6. public void pointcut(){
    7. }
    8. //添加环绕通知
    9. @Around("pointcut()")
    10. public Object doAround(ProceedingJoinPoint joinPoint){
    11. Object o = null;
    12. StopWatch stopWatch = new StopWatch();
    13. try {
    14. stopWatch.start();
    15. o = joinPoint.proceed();
    16. stopWatch.stop();
    17. } catch (Throwable e) {
    18. e.printStackTrace();
    19. }
    20. System.out.println(joinPoint.getSignature().getDeclaringTypeName()+"."+
    21. joinPoint.getSignature().getName()+" 方法执行花费的时间: "+
    22. stopWatch.getTotalTimeMillis()+"ms");
    23. return o;
    24. }
    25. }

    基于XML的方式实现 

    1. package com.atguigu.spring6.aop.xmlaop;
    2. public interface Calculator {
    3. int add(int i, int j);
    4. int sub(int i, int j);
    5. int mul(int i, int j);
    6. int div(int i, int j);
    7. }
    1. package com.atguigu.spring6.aop.xmlaop;
    2. import org.springframework.stereotype.Component;
    3. //基本实现类
    4. @Component
    5. public class CalculatorImpl implements Calculator {
    6. @Override
    7. public int add(int i, int j) {
    8. int result = i + j;
    9. System.out.println("方法内部 result = " + result);
    10. //为了测试,模拟异常出现
    11. // int a = 1/0;
    12. return result;
    13. }
    14. @Override
    15. public int sub(int i, int j) {
    16. int result = i - j;
    17. System.out.println("方法内部 result = " + result);
    18. return result;
    19. }
    20. @Override
    21. public int mul(int i, int j) {
    22. int result = i * j;
    23. System.out.println("方法内部 result = " + result);
    24. return result;
    25. }
    26. @Override
    27. public int div(int i, int j) {
    28. int result = i / j;
    29. System.out.println("方法内部 result = " + result);
    30. return result;
    31. }
    32. }
    1. package com.atguigu.spring6.aop.xmlaop;
    2. import org.aspectj.lang.JoinPoint;
    3. import org.aspectj.lang.ProceedingJoinPoint;
    4. import org.aspectj.lang.annotation.*;
    5. import org.springframework.stereotype.Component;
    6. import java.util.Arrays;
    7. //切面类
    8. @Component //ioc容器
    9. public class LogAspect {
    10. //前置通知
    11. public void beforeMethod(JoinPoint joinPoint) {
    12. String methodName = joinPoint.getSignature().getName();
    13. Object[] args = joinPoint.getArgs();
    14. System.out.println("Logger-->前置通知,方法名称:"+methodName+",参数:"+Arrays.toString(args));
    15. }
    16. // 后置通知
    17. public void afterMethod(JoinPoint joinPoint) {
    18. String methodName = joinPoint.getSignature().getName();
    19. System.out.println("Logger-->后置通知,方法名称:"+methodName);
    20. }
    21. // 返回通知,获取目标方法的返回值
    22. public void afterReturningMethod(JoinPoint joinPoint,Object result) {
    23. String methodName = joinPoint.getSignature().getName();
    24. System.out.println("Logger-->返回通知,方法名称:"+methodName+",返回结果:"+result);
    25. }
    26. // 异常通知 获取到目标方法异常信息
    27. //目标方法出现异常,这个通知执行
    28. public void afterThrowingMethod(JoinPoint joinPoint,Throwable ex) {
    29. String methodName = joinPoint.getSignature().getName();
    30. System.out.println("Logger-->异常通知,方法名称:"+methodName+",异常信息:"+ex);
    31. }
    32. // 环绕通知
    33. public Object aroundMethod(ProceedingJoinPoint joinPoint) {
    34. String methodName = joinPoint.getSignature().getName();
    35. Object[] args = joinPoint.getArgs();
    36. String argString = Arrays.toString(args);
    37. Object result = null;
    38. try {
    39. System.out.println("环绕通知==目标方法之前执行");
    40. //调用目标方法
    41. result = joinPoint.proceed();
    42. System.out.println("环绕通知==目标方法返回值之后");
    43. }catch (Throwable throwable) {
    44. throwable.printStackTrace();
    45. System.out.println("环绕通知==目标方法出现异常执行");
    46. } finally {
    47. System.out.println("环绕通知==目标方法执行完毕执行");
    48. }
    49. return result;
    50. }
    51. //重用切入点表达式
    52. @Pointcut(value = "execution(* com.atguigu.spring6.aop.xmlaop.CalculatorImpl.*(..))")
    53. public void pointCut() {}
    54. }
    1. "1.0" encoding="UTF-8"?>
    2. "http://www.springframework.org/schema/beans"
    3. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    4. xmlns:context="http://www.springframework.org/schema/context"
    5. xmlns:aop="http://www.springframework.org/schema/aop"
    6. xsi:schemaLocation="http://www.springframework.org/schema/beans
    7. http://www.springframework.org/schema/beans/spring-beans.xsd
    8. http://www.springframework.org/schema/context
    9. http://www.springframework.org/schema/context/spring-context.xsd
    10. http://www.springframework.org/schema/aop
    11. http://www.springframework.org/schema/aop/spring-aop.xsd">
    12. package="com.atguigu.spring6.aop.xmlaop">
    13. "logAspect">
    14. "pointcut" expression="execution(* com.atguigu.spring6.aop.xmlaop.CalculatorImpl.*(..))"/>
    15. "beforeMethod" pointcut-ref="pointcut">
    16. "afterMethod" pointcut-ref="pointcut">
    17. "afterReturningMethod" returning="result" pointcut-ref="pointcut">
    18. "afterThrowingMethod" throwing="ex" pointcut-ref="pointcut">
    19. "aroundMethod" pointcut-ref="pointcut">
    1. package com.atguigu.spring6.aop.xmlaop;
    2. import org.junit.jupiter.api.Test;
    3. import org.springframework.context.ApplicationContext;
    4. import org.springframework.context.support.ClassPathXmlApplicationContext;
    5. public class TestAop {
    6. @Test
    7. public void testAdd() {
    8. ApplicationContext context =
    9. new ClassPathXmlApplicationContext("beanaop.xml");
    10. Calculator calculator = context.getBean(Calculator.class);
    11. calculator.add(4,3);
    12. }
    13. }

     

     

    切面的优先级 

    相同目标方法上同时存在多个切面时,切面的优先级控制切面的内外嵌套顺序。

    • 优先级高的切面:外面
    • 优先级低的切面:里面

    使用@Order注解可以控制切面的优先级:

    • @Order(较小的数):优先级高
    • @Order(较大的数):优先级低

     

     

     

    场景:日志记录 

    记录用户的登录和退出:

    1. //定义切面
    2. @Aspect
    3. @Component
    4. public class UserAspect {
    5. /**
    6. * 定义切点,切点是一个包含了众多拦截点的一个集合
    7. * @param joinPoint 所有需要aop处理的方法都称为连接点
    8. */
    9. //配置代理规则
    10. @Before("execution(* com.example.demo.controller.UserController.*(..))")
    11. public void logBefore(JoinPoint joinPoint) {
    12. String methodName = joinPoint.getSignature().getName();
    13. String className = joinPoint.getTarget().getClass().getSimpleName();
    14. System.out.println("Executing " + className + "." + methodName + "()");
    15. // 记录日志到日志文件或数据库
    16. }
    17. }

    用户的登录和注销操作在UserController类里面,当用户请求登录和注销操作时:

    Spring AOP实现原理 

    Spring AOP是构建在动态代理基础上的,因此Spring对AOP的支持局限于方法级别的拦截。Spring AOP支持JDK Proxy和CGLIB方式实现动态代理,而这两类方式底层都是通过反射来实现的。 

    动态代理分两种,基于接口的动态代理和基于类的动态代理。

    基于接口的动态代理 

    基于接口的动态代理使用Java的反射机制,在运行时动态地创建代理对象。代理对象实现了与原始对象相同的接口,并将方法调用转发给原始对象,同时还可以在方法调用前后执行其他逻辑。使用`java.lang.reflect.Proxy`类和`java.lang.reflect.InvocationHandler`接口可以实现基于接口的动态代理。 

     

    基于类的动态代理 

    基于类的动态代理是通过继承或扩展来实现的。在运行时,它创建一个类来继承原始类,并覆盖其中的方法以添加额外的业务逻辑。使用第三方库,如CGLIB(Code Generation Library)可以实现基于类的动态代理。 

     

    AspectJ 

    AspectJ是AOP的框架, Spring依赖AspectJ的注解实现AOP的功能,AspectJ本质是一个静态代理。SpringAOP属于运行时增强,而AspectJ是编译时增强,SpringAOP基于代理,而AspectJ基于字节码操作。

     

  • 相关阅读:
    【论文翻译】使用区块链的非阻塞两阶段提交
    ArrayList、Vector和LinkedList比较
    流动舞台车改装设计(说明书+任务书+开题报告+评分表+cad图纸)
    【檀越剑指大厂--redis】redis高阶篇
    vue.config 同时配置 chainWebpack和关闭eslint检查多个配置项目共存
    以太网 TCP协议(三次握手、四次挥手)
    《实用软件工程》课程教学大纲(Practicality Software Engineering)
    OpenCV python下载和安装
    什么是浏览器指纹识别
    【前端素材】推荐优质后台管理系统网页Star admin平台模板(附源码)
  • 原文地址:https://blog.csdn.net/yahid/article/details/127186514