背景

在使用Langchain4j的过程中发现,如果在启动类所在的classPath以外的目录中定义了AiService,那么该类将不会被Langchain4j扫描并代理到,即使你使用@ComponentScan或者其他方式指定了文件扫描路径

查看源码后发现,Langchain4j中 AiService 只会扫描启动类所在的classPath,而对于其他方式配置的扫描路径都没有进行处理,也没有实现其他配置手段能够让用户指定扫描路径,最后就导致了 AiService 不存在的Bug _(:з」∠)_

相关issue

Tips:这也是我第一次参加开源,虽然只是做了一个微不足道的贡献,但是还是感觉收获很多吧…一直在担心自己的水平不够所以反复理解反复查资料,但是作者很热心帮我修改了代码并且很快就被处理接受了😃

问题分析

项目中相关代码如下:

1
2
3
4
5
6
7
8
9
private static Set<Class<?>> findAiServices(ConfigurableListableBeanFactory beanFactory) {
String[] applicationBean = beanFactory.getBeanNamesForAnnotation(SpringBootApplication.class);
BeanDefinition applicationBeanDefinition = beanFactory.getBeanDefinition(applicationBean[0]);
String basePackage = applicationBeanDefinition.getResolvableType().resolve().getPackage().getName();
Reflections reflections = new Reflections((new ConfigurationBuilder()).forPackage(basePackage));
Set<Class<?>> classes = reflections.getTypesAnnotatedWith(AiService.class);
classes.removeIf(clazz -> !clazz.getName().startsWith(basePackage));
return classes;
}

大致逻辑就是,获取启动类所在的路径,然后使用反射找到该路径下所有带有AiService的class。

目前有两个问题:

  1. 扫描路径的获取。在不增加额外的用户配置的情况下,如何知道应该扫描全部的路径呢?用户指定扫描路径的方式有很多种,其中最常用的就是通过@ComponentScan指定,于是想到我们可以解析项目中所有@ComponentScan注解,以此作为扫描路径
  2. 扫描的方式是通过Reflections,而不是通过Spring自带的方法。作者使用Reflections是因为Spring默认是不会扫描接口的,因为接口没有办法实例化,因此通过Spring自带的方法自然也无法获取到Bean。但是这么做属实有点不合理,有没有更加优雅的解决办法呢?

获取扫描路径

用户指定扫描路径的方式有很多种,其中最常用的就是通过@ComponentScan指定,于是想到我们可以解析项目中所有@ComponentScan注解,以此作为扫描路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private Set<String> getBasePackages(ConfigurableListableBeanFactory beanFactory) {
Set<String> collectedBasePackages = new LinkedHashSet<>();
beanFactory.getBeansWithAnnotation(ComponentScan.class).forEach((beanName, instance) -> {
Set<ComponentScan> componentScans = AnnotatedElementUtils.getMergedRepeatableAnnotations(instance.getClass(), ComponentScan.class);
for (ComponentScan componentScan : componentScans) {
Set<String> basePackages = new LinkedHashSet<>();
String[] basePackagesArray = componentScan.basePackages();
for (String pkg : basePackagesArray) {
String[] tokenized = StringUtils.tokenizeToStringArray(this.environment.resolvePlaceholders(pkg),
ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
Collections.addAll(basePackages, tokenized);
}
for (Class<?> clazz : componentScan.basePackageClasses()) {
basePackages.add(ClassUtils.getPackageName(clazz));
}
if (basePackages.isEmpty()) {
basePackages.add(ClassUtils.getPackageName(instance.getClass()));
}
collectedBasePackages.addAll(basePackages);
}
});
return collectedBasePackages;
}

Tips: 这里我只处理了@ComponentScan的basePackages等相关的路径扫描属性、别名问题,过滤器之类的属性目前还没有解决(Spring太复杂了🥲)

自定义类扫描器

如果是要与Spring集成的话肯定是使用Spring的方式扫描Bean更好,但是Spring自带的类扫描器会直接过滤接口,这怎么办?我想到一个比较熟悉的场景:MyBatis的Mapper也是接口,但是他是怎么被扫描并且注册到Spring中的呢?

于是我去翻了MyBatis相关的源代码,发现其原理是自定义了一个类扫描ClassPathMapperScanner,并重写了他的isCandidateComponent()方法:

1
2
3
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
return beanDefinition.getMetadata().isInterface() && beanDefinition.getMetadata().isIndependent();
}

在Spring自带的类扫描器中,他们的isCandidateComponent()方法是这样的:

1
2
3
4
5
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
AnnotationMetadata metadata = beanDefinition.getMetadata();
return (metadata.isIndependent() && (metadata.isConcrete() ||
(metadata.isAbstract() && metadata.hasAnnotatedMethods(Lookup.class.getName()))));
}

isConcrete()返回基础类是否表示具体类,即既不是接口也不是抽象类。这里我们也可以知道Spring不扫描接口的原因了,在这一步就被pass掉了

关于MyBatis代理的详细过程可以参考MyBatis创建代理全过程

接下来我们就可以参考MyBatis自定义类扫描器了:

1
2
3
4
5
6
7
8
9
10
11
12
13
class ClassPathAiServiceScanner extends ClassPathBeanDefinitionScanner {
ClassPathAiServiceScanner(BeanDefinitionRegistry registry, boolean useDefaultFilters) {
super(registry, useDefaultFilters);
addIncludeFilter(new AnnotationTypeFilter(AiService.class));
}

@Override
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();
return annotationMetadata.isInterface() && annotationMetadata.isIndependent();
}
}

按照Mybatis的方式,我们可以通过自定义类扫描器的方式实现让Spring扫描接口,那么问题来了,如何让Spring使用我们自定义的类扫描器呢?

我们可以在Spring的BeanDefinition扫描流程结束后,再扫描一遍不就好了吗?这时候后置处理器就可以派上用场了:

1
2
3
4
5
6
7
8
9
10
11
@Component
public class AiServiceScannerProcessor implements BeanDefinitionRegistryPostProcessor {

@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
ClassPathAiServiceScanner classPathAiServiceScanner = new ClassPathAiServiceScanner(registry, false);
classPathAiServiceScanner.addIncludeFilter(new AnnotationTypeFilter(AiService.class));
Set<String> basePackages = getBasePackages((ConfigurableListableBeanFactory) registry);
classPathAiServiceScanner.scan(StringUtils.toStringArray(basePackages));
}
}

至此这个问题就大致解决了,但是一些细节肯定没有处理好,这个项目目前也是刚起步的阶段,期待有大佬给出更好的解决方案🥳


附一个贴图,纪念一下: