2022-06-07:AOP简介、装配AOP、AOP避坑指南

1、AOP简介

AOP是Aspect Oriented Programming,即面向切面编程

在介绍AOP之前,我们先回忆一下OOP:Object Oriented Programming,OOP作为面向对象编程的模式,获得了巨大的成功,OOP的主要功能是数据封装、继承、多态

而AOP是一种新的编程方式,它和OOP不同,OOP把系统看做多个对象的交互,AOP把系统分解不同的关注点,或者称之为切面(Aspect)

要理解AOP的概念,我们先用OOP举例,比如一个业务组件BookService,它有几个业务方法:

  • createBook:添加新的Book;
  • updateBook:修改Book;
  • deleteBook:删除Book.

对每个业务方法,例如,createBook(),除了业务逻辑,还需要安全检查、日志记录和事务处理,它的代码像这样:

复制代码
public class BookService {
    public void createBook(Book book) {
        securityCheck();
        Transaction tx = startTransaction();
        try {
            // 核心业务逻辑
            tx.commit();
        } catch (RuntimeException e) {
            tx.rollback();
            throw e;
        }
        log("created book: " + book);
    }
}
复制代码

继续编写updateBook(),代码如下:

复制代码
public class BookService {
    public void updateBook(Book book) {
        securityCheck();
        Transaction tx = startTransaction();
        try {
            // 核心业务逻辑
            tx.commit();
        } catch (RuntimeException e) {
            tx.rollback();
            throw e;
        }
        log("updated book: " + book);
    }
}
复制代码

对于安全检查、日志、事务等代码,它们会重复出现在每个业务方法中。使用OOP,我们很难将这些四处分散的代码模块化。

考察业务模型可以发现,BookService关心的是自身的核心逻辑,但整个系统还要求关注安全检查、日志、事务等功能,这些功能实际上“横跨”了多个业务方法,为了实现这些功能,不得不在每个业务方法上重复编写代码。

一种可行的方法是使用Proxy模式,将某个功能,例如权限检查,放在Proxy中:

复制代码
public class SecurityCheckBookService implements BookService {
    private final BookService target;

    public SecurityCheckBookService(BookService target) {
        this.target = target;
    }

    public void createBook(Book book) {
        securityCheck();
        target.createBook(book);
    }

    public void updateBook(Book book) {
        securityCheck();
        target.updateBook(book);
    }

    public void deleteBook(Book book) {
        securityCheck();
        target.deleteBook(book);
    }

    private void securityCheck() {
        ...
    }
}
复制代码

这种方法的缺点是比较麻烦,必须先抽取接口,然后针对每个方法实现Proxy。

另一种方法是,既然SecurityCheckBookService的代码都是标准的Proxy样版代码,不如把权限检查视为一种切面(Aspect),把日志、事务也视为切面,然后以某种自动化的方式,把切面织入到核心逻辑中,实现Proxy模式

 

如果我们以AOP的视角编写上述业务,可以依次实现:

  • 核心逻辑,即BookService;
  • 切面逻辑,即:权限检查的Aspect、日志的Aspect、事务的Aspect。

然后,以某种方式,让框架来把上述3个AspectProxy的方式“织入”到BookService中,这样一来就不用编写复杂而冗长的Proxy模式。

AOP原理

如何把切面织入到核心逻辑中?这正是AOP需要解决的问题。换句话说,如果客户端获得了BookService的引用,当调用bookService.createBook()时,如何对调用方法进行拦截,并在拦截前后进行安全检查、日志、事务等处理,就相当于完成了所有业务功能。

在Java平台上,对AOP的织入,有3种方式:

  1. 编译期:在编译时,由编译器切面调用编译字节码,这种方式需要重新定义新的关键字扩展编译器,AspectJ就扩展了Java编译器,使用关键字aspect来实现织入
  2. 类加载器:在目标类装载JVM时,通过一个特殊的类加载器,对目标类的字节码重新“增强”;
  3. 运行期目标对象切面都是普通Java类,通过JVM动态代理功能第三方库实现运行期动态织入。

最简单的方式是第三种,Spring的AOP就是基于JVM动态代理。由于JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者Javassist这些第三方库实现。

AOP技术看上去比较神秘,但实际上其本质就是一个动态代理,让我们把一些常用功能如权限检查、日志、事务等,从每个业务方法中剥离出来。

需要特别指出的是,AOP对于解决特定问题,例如事务管理很有用,这是因为分散在各处的事务代码几乎是完全相同的,并且它们需要的参数(JDBC的Connection)也是固定的。另一些特定问题,如日志就不那么容易实现,因为日志虽然简单,但是打印的时候经常要捕获局部变量,如果使用AOP实现日志,我们只能输出固定格式的日志,因此,使用AOP时必须适合特定的场景。

 

2、装配AOP

在AOP编程中,我们经常会遇到以下概念:

  • Aspect:切面,即一个横跨多个核心逻辑功能,或者称之为系统关注点
  • Joinpoint:连接点,即定义在应用程序流程的何处插入切面的执行;
  • Pointcut:切入点,即一组连接点集合
  • Advice:增强,指特定连接点上执行的动作
  • Introduction:引介,指为一个已有的Java对象动态地增加新的接口
  • Weaving:织入,指将切面整合程序的执行流程中
  • Interceptor:拦截器,是一种实现增强的方式;
  • Target Object:目标对象,即真正执行业务核心逻辑对象
  • AOP Proxy:AOP代理,是客户端持有增强后对象引用

不过在使用的时候,我们并不用关心AOP这些术语,只需要理解AOP本质上只是一种代理模式实现方式,在Spring的容器中实现AOP很方便。

我们以UserService和MailService为例,这两个属于核心业务逻辑,现在我们准备给UserService的每个业务方法执行前添加日志,给MailService的每个业务方法执行前后添加日志,在Spring中,需要以下步骤:

①通过Maven引入Spring对AOP的支持:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
    <version>${spring.version}</version>
</dependency>

上述依赖会自动引入AspectJ,使用AspectJ实现AOP比较方便,因为它的定义比较简单。

②我们定义一个LoggingAspect

复制代码
@Aspect
@Component
public class LoggingAspect {
    // 在执行UserService的每个方法前执行:
    @Before("execution(public * com.itranswarp.learnjava.service.UserService.*(..))")
    public void doAccessCheck() {
        System.err.println("[Before] do access check...");
    }

    // 在执行MailService的每个方法前后执行:
    @Around("execution(public * com.itranswarp.learnjava.service.MailService.*(..))")
    public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
        System.err.println("[Around] start " + pjp.getSignature());
        Object retVal = pjp.proceed();
        System.err.println("[Around] done " + pjp.getSignature());
        return retVal;
    }
}
复制代码

观察doAccessCheck()方法,我们定义了一个@Before注解,后面的字符串是告诉AspectJ应该在何处执行该方法,这里写的意思是:执行UserService每个public方法前执行doAccessCheck()代码。

再观察doLogging()方法,我们定义了一个@Aroung注解,它和@Before不同,@Around可以决定是否执行目标方法,因此,我们在doLogging()内部先打印日志,再调用方法,最后打印日志后返回结果。

LoggingAspect类声明处,除了用@Component表示它本身也是一个Bean外,我们再加上@Aspect注解,表示它的@Before标注的方法需要注入到UserService的每个public方法执行前@Around标注的方法需要注入到MailService的每个public方法执行前后

紧接着,我们需要给@Configuration类加上一个@EnableAspectJAutoProxy注解:

@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {
    ...
}

Spring的IoC容器看到该注解,就会自动查找带有@Aspect的Bean,然后根据每个方法的@Before、@Around等注解把AOP注入到特定的Bean中。执行代码,我们可以看到以下输出:

[Before] do access check...
[Around] start void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
Welcome, test!
[Around] done void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
[Before] do access check...
[Around] start void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)
Hi, Bob! You are logged in at 2020-02-14T23:13:52.167996+08:00[Asia/Shanghai]
[Around] done void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)

这说明执行业务逻辑前后,确实执行了我们定义的Aspect(即LoggingAspect的方法)。

不过LoggingAspect定义的方法是如何注入到其他Bean中的呢?

其实AOP的原理很简单,我们以LoggingAspect.doAccessCheck()为例,把它注入到UserService的每个public方法中,最简单的方法是编写一个子类,并持有原始实例的引用

复制代码
public UserServiceAopProxy extends UserService {
    private UserService target;
    private LoggingAspect aspect;//原始实例的引用

    public UserServiceAopProxy(UserService target, LoggingAspect aspect) {
        this.target = target;
        this.aspect = aspect;
    }

    public User login(String email, String password) {
        // 先执行Aspect的代码:
        aspect.doAccessCheck();
        // 再执行UserService的逻辑:
        return target.login(email, password);
    }

    public User register(String email, String password, String name) {
        aspect.doAccessCheck();
        return target.register(email, password, name);
    }

    ...
}
复制代码

这些都是Spring容器启动时为我们自动创建的注入了Aspect的子类,它取代了原始的UserService(原始的UserService实例作为内部变量隐藏在UserServiceAopProxy中)。如果我们打印从Spring容器获取的UserService实例类型,它类似UserService$$EnhancerBySpringCGLIB$$1f44e01c,实际上是Spring使用CGLIB动态创建的子类,但是对于调用方来说,感受不到任何区别。

Spring对接口类型使用JDK动态代理,对普通类使用CGLIB创建子类。如果一个Bean的class是final,Spring将无法为其创建子类。

 

可见,虽然Spring容器内部实现AOP的逻辑比较复杂(需要使用AspectJ解析注解,并通过CGLIB实现代理类),但我们使用AOP却非常简单,一共需要3步:

  1. 定义执行方法,并在方法上通过AspectJ的注解告诉Spring应该在何处调用该方法;
  2. 标记@Component@Aspect
  3. @Configuration类上标记@EnableAspectJAutoProxy

至于AspectJ的注入语法则比较复杂,参考Spring文档

Spring也提供了其他方法来装配AOP,但都没有使用AspectJ注解的方式来得简洁明了,所以我们不再做介绍了。

拦截器类型

拦截器有以下类型:

  • @Before:先执行拦截代码,再执行目标代码。如果拦截器抛出异常,那么目标代码就不再执行了;
  • @After:先执行目标代码,再执行拦截器代码。无论目标代码是否抛出异常,拦截器代码都会执行;
  • @AfterReturning:和@After不同的是,只有当目标代码正常返回时,才执行拦截器代码
  • @AfterThrowing:和@After不同的是,只有当目标代码抛出了异常时,才执行拦截器代码
  • @Around:能完全控制目标代码是否执行,并且可以在执行前后、抛出异常后执行任意拦截代码,可以说是包含了上边的所有功能。

小结

在Spring容器中使用AOP很简单,只需要定义执行方法,并用AspectJ的注解标注应该在何处执行并执行

Spring通过CGLIB动态创建子类等方式来实现AOP代理模式,大大简化了代码。

 

3、使用注解装配AOP

上一节我们讲解了如何使用AspectJ的注解,并配合一个复杂的execution(* xxx.Xyz.*(..))语法来定义如何装配AOP

在实际项目中,这种写法其实很少使用。假设我们写了一个SecurityAspect:

复制代码
@Aspect
@Component
public class SecurityAspect {
    @Before("execution(public * com.itranswarp.learnjava.service.*.*(..))")
    public void check() {
        if (SecurityContext.getCurrentUser() == null) {
            throw new RuntimeException("check failed");
        }
    }
}
复制代码

基本上可以实现无差别全覆盖,即某个包下的所有Bean的所有方法都会被这个check()方法拦截

还可以根据方法名前缀进行拦截

@Around("execution(public * update*(..))")
public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
    // 对update开头的方法切换数据源:
    String old = setCurrentDataSource("master");
    Object retVal = pjp.proceed();
    restoreCurrentDataSource(old);
    return retVal;
}

不过这种非精准匹配的误伤很大,因为从方法前缀区分是不是数据库操作是非常不可取的

我们在使用AOP时,要注意虽然Spring容器可以把指定的方法通过AOP规则装配到指定的Bean的指定方法前后,但是,如果自动装配时,因为不恰当的范围,容易导致意想不到的结果,即很多不需要AOP代理的Bean也被自动代理了,并且后续新增的Bean,如果不清楚现有的AOP装配规则,容易被强迫装配。

使用AOP时,被装配的Bean最好自己清楚地知道自己被安排了。例如,Spring提供的@Transactional就是一个很好的例子。如果我们自己写的Bean希望在一个数据库事务中被调用,就标注上@Transactional

复制代码
@Component
public class UserService {
    // 有事务:
    @Transactional
    public User createUser(String name) {
        ...
    }

    // 无事务:
    public boolean isValidName(String name) {
        ...
    }

    // 有事务:
    @Transactional
    public void updateUser(User user) {
        ...
    }
}
复制代码

或者直接在class级别注解,表示“所有public方法都被安排了”:

@Component
@Transactional
public class UserService {
    ...
}

通过@Transactional,某个方法是否启用了事务就清楚了。因此装配AOP时,使用注解是最好的方式。

我们用一个实际例子演示如何使用注解实现AOP装配。为了监控应用程序的性能,我们定义一个性能监控的注解:

@Target(METHOD)
@Retention(RUNTIME)
public @interface MetricTime {
    String value();
}

在需要被监控的关键方法上标注该注解:

复制代码
@Component
public class UserService {
    // 监控register()方法性能:
    @MetricTime("register")
    public User register(String email, String password, String name) {
        ...
    }
    ...
}
复制代码

然后我们定义MetricAspect

复制代码
@Aspect
@Component
public class MetricAspect {
    @Around("@annotation(metricTime)")
    public Object metric(ProceedingJoinPoint joinPoint, MetricTime metricTime) throws Throwable {
        String name = metricTime.value();
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long t = System.currentTimeMillis() - start;
            // 写入日志或发送至JMX:
            System.err.println("[Metrics] " + name + ": " + t + "ms");
        }
    }
}
复制代码

注意metric()方法标注了@Around("@annotation(metricTime)"),它的意思是,符合条件的目标方法是带有@MetricTime注解的方法,因为metric()方法参数类型MetricTime(而参数名是metricTime),我们通过它获取性能监控的名称

有了@MetricTime注解,再配合MetricAspect,任何Bean,只要方法标注了@MetricTime注解,就可以自动实现性能监控。运行代码,输出结果如下:

Welcome, Bob!
[Metrics] register: 16ms

小结

使用注解实现AOP需要先定义注解,然后使用@Around("@annotation(name)")实现装配;

使用注解既简单,又能明确标识AOP装配,是使用AOP推荐的方式。

 

4、AOP避坑指南

无论使用AspectJ语法,还是配合Annotation,使用AOP,实际上就是上Spring自动为我们创建一个Proxy,使得调用方能够无感知调用指定方法,但运行期动态“织入”了其他逻辑语句,因此,AOP本质上就是一个代理模式

因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果我们没能深入理解其运行原理和实现机制,有很有可能碰到一些诡异的问题。

一个实际的例子。

假设我们定义了一个UserService的Bean:

复制代码
@Component
public class UserService {
    // 成员变量:
    public final ZoneId zoneId = ZoneId.systemDefault();

    // 构造方法:
    public UserService() {
        System.out.println("UserService(): init...");
        System.out.println("UserService(): zoneId = " + this.zoneId);
    }

    // public方法:
    public ZoneId getZoneId() {
        return zoneId;
    }

    // public final方法:
    public final ZoneId getFinalZoneId() {
        return zoneId;
    }
}
复制代码

再写个MailService,并注入UserService:

复制代码
@Component
public class MailService {
    @Autowired
    UserService userService;

    public String sendMail() {
        ZoneId zoneId = userService.zoneId;
        String dt = ZonedDateTime.now(zoneId).toString();
        return "Hello, it is " + dt;
    }
}
复制代码

最后用main()方法测试一下:

复制代码
@Configuration
@ComponentScan
public class AppConfig {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
        MailService mailService = context.getBean(MailService.class);
        System.out.println(mailService.sendMail());
    }
}
复制代码

查看输出,一切正常:

UserService(): init...
UserService(): zoneId = Asia/Shanghai
Hello, it is 2020-04-12T10:23:22.917721+08:00[Asia/Shanghai]

下一步,我们给UserService加上AOP支持,就添加一个最简单的LoggingAspect:

@Aspect
@Component
public class LoggingAspect {
    @Before("execution(public * com..*.UserService.*(..))")
    public void doAccessCheck() {
        System.err.println("[Before] do access check...");
    }
}

再在AppConfig上加上@EnableAspectJAutoProxy。再次运行,不出意外的话,会得到一个NullPointerException

Exception in thread "main" java.lang.NullPointerException: zone
    at java.base/java.util.Objects.requireNonNull(Objects.java:246)
    at java.base/java.time.Clock.system(Clock.java:203)
    at java.base/java.time.ZonedDateTime.now(ZonedDateTime.java:216)
    at com.itranswarp.learnjava.service.MailService.sendMail(MailService.java:19)
    at com.itranswarp.learnjava.AppConfig.main(AppConfig.java:21)

跟踪代码后会发现null出现在MailService.sendMail()内部的这一行代码:

复制代码
@Component
public class MailService {
    @Autowired
    UserService userService;

    public String sendMail() {
        ZoneId zoneId = userService.zoneId;
        System.out.println(zoneId); // null
        ...
    }
}
复制代码

但是我们已经在UserService中特意用final修饰了一下成员变量zoneId,这里却变成了null,原因在什么地方?

为何加了AOP就报NPE,不加就一切正常?且final字段并不执行?为了解答该问题,我们需要深入理解Spring使用CGLIB生成Proxy的原理:

①正常创建UserService的原始实例,这是通过反射调用构造方法实现的,它的行为与我们的预期完全一致;

②通过CGLIB创建一个UserService的子类,并引用了原始实例LoggingAspect:

复制代码
public UserService$$EnhancerBySpringCGLIB extends UserService {
    UserService target;
    LoggingAspect aspect;

    public UserService$$EnhancerBySpringCGLIB() {
    }

    public ZoneId getZoneId() {
        aspect.doAccessCheck();
        return target.getZoneId();
    }
}
复制代码

如果我们观察Spring创建的AOP代理,它的类名总是类似UserService$$EnhancerBySpringCGLIB$$1c76af9d(Java的类名实际上允许$字符)。为了让调用方获得UserService的引用,它必须继承自UserService。然后,该代理类会覆写所有publicprotected方法,并在内部将调用委托给原始的UserService实例

这里就出现了两个UserService实例

一是我们代码中定义的原始实例,它的成员变量已经按照我们预期的方式被初始化完成

UserService original = new UserService();

第二个UserService实例实际上类型是UserService$$EnhancerBySpringCGLIB,它引用原始的UserService实例

UserService$$EnhancerBySpringCGLIB proxy = new UserService$$EnhancerBySpringCGLIB();
proxy.target = original;
proxy.aspect = ...

注意到这种情况仅仅出现在启用了AOP的情况,此刻,从ApplicationContext中获取的UserService实例是proxy,注意到MailService中的UserService实例也是proxy

问题就在这里,proxy实例成员变量,也就是从UserService继承zoneId,它的值是null

原因在于,UserService成员变量初始化

public class UserService {
    public final ZoneId zoneId = ZoneId.systemDefault();
    ...
}

UserService$$EnhancerBySpringCGLIB中并未执行该初始化,原因是没必要初始化proxy成员变量,因为proxy的目的是代理方法!

实际上,成员变量的初始化是在构造方法中完成的。下边这段是我们看到的代码:

public class UserService {
    public final ZoneId zoneId = ZoneId.systemDefault();
    public UserService() {
    }
}

而下边这段是编译器实际编译的代码:

public class UserService {
    public final ZoneId zoneId;
    public UserService() {
        super(); // 构造方法的第一行代码总是调用super()
        zoneId = ZoneId.systemDefault(); // 继续初始化成员变量
    }
}

然而,对于Spring通过CGLIB动态创建UserService$$EnhancerBySpringCGLIB代理类,它的构造方法中,并未调用super(),因此从父类继承成员变量,包括final类型成员变量,全都没有初始化

不过Java语言规定,任何类的构造方法,第一行必须调用super(),如果没有,编译器自动加上,为什么Spring的CGLIB就特殊?

这是因为自动加super()的功能是Java编译器实现的,它发现我们没加,就会自动加上,发现加错了就报编译错误。实际上,如果直接构造字节码,一个类的构造方法中,不一定非要调用super()。Spring使用CGLIB构造的Proxy类,是直接生成字节码,并没有源码-编译-字节码这个步骤,因此Spring通过CGLIB创建的代理类,并不会初始化代理类自身继承的任何成员变量,包括final类型的成员变量

再考察MailService的代码:

复制代码
@Component
public class MailService {
    @Autowired
    UserService userService;

    public String sendMail() {
        ZoneId zoneId = userService.zoneId;
        System.out.println(zoneId); // null
        ...
    }
}
复制代码

如果没启用AOP,注入的是原始的UserService实例,那么一切正常,因为UserService实例ZoneId字段已经被正确初始化了。

如果启动了AOP,注入的是代理后的UserService$$EnhancerBySpringCGLIB实例,那么获取到的UserService$$EnhancerBySpringCGLIB实例的ZoneId字段,永远为null。

启用了AOP,如何修复呢?

修复很简单,只需要将直接访问字段代码,改成通过方法访问

复制代码
@Component
public class MailService {
    @Autowired
    UserService userService;

    public String sendMail() {
        // 不要直接访问UserService的字段:
        ZoneId zoneId = userService.getZoneId();
        ...
    }
}
复制代码

这样,无论注入的UserService原始实例还是代理实例,getZoneId()都能正常工作,因为代理类覆写getZoneId()方法,并将其委托原始实例:

public UserService$$EnhancerBySpringCGLIB extends UserService {
    UserService target = ...
    ...

    public ZoneId getZoneId() {
        return target.getZoneId();
    }
}

注意到我们还给UserService添加了一个public + final的方法:

@Component
public class UserService {
    ...
    public final ZoneId getFinalZoneId() {
        return zoneId;
    }
}

如果在MailService中,调用的不是getZoneId(),而是getFinalZoneId(),又会出现NullPointerException,这是因为代理类无法覆写final方法(这一点绕不过JVM的ClassLoader检查),该方法返回的是代理类ZoneId字段,即null

实际上,如果我们加上日志,Spring在启动时会打印一个警告:

10:43:09.929 [main] DEBUG org.springframework.aop.framework.CglibAopProxy - Final method 
[public final java.time.ZoneId xxx.UserService.getFinalZoneId()] cannot get proxied via CGLIB:
Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.

上边这段日志的大意是,由于被代理的UserService有一个final方法getFinalZoneId(),这会导致其他Bean如果调用该方法,无法将其代理到真正的原始实例,从而有可能发生NPE异常

因此,正确使用AOP,我们需要一个避坑指南

  1. 访问被注入的Bean时,总是调用方法非直接访问字段
  2. 编写Bean时,如果可能会被代理,就不要编写public final方法

这样才能保证无论有无AOP,代码都可以正常工作。

小结

  • 由于Spring通过CGLIB实现代理类,我们要避免直接访问Bean的字段,以及由final方法带来的“未代理”问题。
  • 遇到CglibAopProxy的相关日志,务必要仔细检查,防止因为AOP出现NPE异常。

 

posted @   ShineLe  阅读(366)  评论(0编辑  收藏  举报
编辑推荐:
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
历史上的今天:
2021-06-07 QT:中文字符串与“常量中有字符串”报错
2020-06-07 Pycharm:设置自带控制台的python版本
点击右上角即可分享
微信分享提示