读源码学架构系列:SPI之Spring实现

TL;DR

前一篇详细介绍了JDK中的SPI机制,今天来看看Spring框架中是如何使用SPI来实现可扩展性的,然后与JDK的实现进行一下对比,再看看Spring是如何应用SPI的,最后,我们基于SPI来动手实现一个Spring的扩展。

本文大纲如下:

  1. SpringSPI实现
  2. JDKSPI的差异
  3. SpringSPI的应用
  4. 基于SPI实现Spring扩展

其实,明白了JDK中的SPISpringSPI实现原理是一样的,所以,本篇的内容会比较简单。

0x01 Spring的SPI实现

SPI分为多个角色:ServiceService ProviderServiceLoader和资源文件(META-INF目录下)。

Spring框架的SPI实现也离不开这几个角色。

首先,我们在Spring框架的源代码(或Spring Boot源代码)的资源文件目录下,可以找到META-INF文件夹,里面可以找到spring.factories文件,这个文件就是Spring实现SPI机制所需要的资源文件,里面会定义好一些扩展接口及实现类的信息,文件格式稍后再看。

有了这个spring.factories文件,那服务加载器肯定会读取这个路径,在源码中搜索字符串spring.factories,即可定位到一个类:SpringFactoriesLoader,这是一个用final修饰的类,是不可以被继承的。它的注释中第一句就明确的说明了这个类是用于Spring框架内部的一般用途的工厂加载机制的。主要有loadFactories()loadFactoryNames()这两个公有方法,这两个方法的用途注释已经解释得很清楚了,第一个是加载和实例化资源文件(META-INF/spring.factories)中的对应的工厂的实现类列表,另一个是加载资源文件中对应的工厂的实现类的全路径名称列表。

按上面的描述,剩下的ServiceService Provider已经定义在资源文件中了,那我们现在看一下资源文件spring.factories的具体内容。

上图是spring-boot项目resources目录下的META-INF/spring.factories文件的内容。

SpringFactoriesLoader类的注释中有对这个文件的格式做出相关的说明:

即,spring.factories文件的格式必须是Properties的格式,也就是键值对的格式,其中键(也就是key)是接口或抽象类的全路径名称,值(也就是value)是逗号分隔的实现类名的列表,也必须是全路径名称。

spring-boot下的spring.factories文件的第一个定义为例:

# PropertySource Loaders
org.springframework.boot.env.PropertySourceLoader=\
org.springframework.boot.env.PropertiesPropertySourceLoader,\
org.springframework.boot.env.YamlPropertySourceLoader

这里的org.springframework.boot.env.PropertySourceLoader就是服务(即SPI中的Service角色)的定义,org.springframework.boot.env.PropertiesPropertySourceLoaderorg.springframework.boot.env.YamlPropertySourceLoader就是服务提供者(即SPI中的Service Provider角色)的定义了。

到这里,SPI中的四种角色就齐了。

SpringSPI如何使用的呢?

Spring Boot为例,我们启动程序的时候入口是SpringApplication#run()方法,我们打开这个方法:

上图就是SpringApplication#run()方法的具体实现,其中有一行是调用了getSpringFactoriesInstances()方法,我们再看一下该方法的实现:

该方法里有一行调用SpringFactoriesLoader.loadFactoryNames(type, classLoader),它的结果直接作为了LinkedHashSet的初始化参数。这一行实是在使用服务加载器SpringFactoriesLoader来加载所有对应类型的实现了。

现在我们清楚了SpringSPI是怎么工作的了。

0x02 与JDK的SPI的差异

其实明白了JDKSPI实现,Spring的实现几乎一样,唯一的差别就是资源文件的名称和内容了。

JDKSPI对应的资源文件的名称必须是服务的全路径名称,而内容就是具体实现的类的全路径名称,可以有多个实现,但一行只能放一个实现;另外就是一个资源文件对于一个服务的定义;

再看SpringSPI对应的资源文件,该文件的名称是固定的,为spring.factories,内容必须是Properties格式,也可以有多组,但一个键值对应于一个服务的定义,如果一个服务有多个实现,可以在value中用逗号将多个实现的全路径类名分隔开。如果需要换行的号,就像前面的截图那样,用\结尾,以此表示下一行是当前行的续行。

另外,就是根据使用场景的不同,两种实现的服务加载器中定义的方法不同,SpringSPI实现支持更多的使用场景。

0x03 Spring中SPI的应用

我们还是以上面的那个示例来看SPISpring中的应用。

服务的定义:

/**
 * Strategy interface located via {@link SpringFactoriesLoader} and used to load a
 * {@link PropertySource}.
 *
 * @author Dave Syer
 * @author Phillip Webb
 * @since 1.0.0
 */
public interface PropertySourceLoader {

	/**
	 * Returns the file extensions that the loader supports (excluding the '.').
	 * @return the file extensions
	 */
	String[] getFileExtensions();

	/**
	 * Load the resource into one or more property sources. Implementations may either
	 * return a list containing a single source, or in the case of a multi-document format
	 * such as yaml a source for each document in the resource.
	 * @param name the root name of the property source. If multiple documents are loaded
	 * an additional suffix should be added to the name for each source loaded.
	 * @param resource the resource to load
	 * @return a list property sources
	 * @throws IOException if the source cannot be loaded
	 */
	List<PropertySource<?>> load(String name, Resource resource) throws IOException;

}

PropertiesPropertySourceLoader实现:

/**
 * Strategy to load '.properties' files into a {@link PropertySource}.
 *
 * @author Dave Syer
 * @author Phillip Webb
 * @author Madhura Bhave
 * @since 1.0.0
 */
public class PropertiesPropertySourceLoader implements PropertySourceLoader {

	private static final String XML_FILE_EXTENSION = ".xml";

	@Override
	public String[] getFileExtensions() {
		return new String[] { "properties", "xml" };
	}

	@Override
	public List<PropertySource<?>> load(String name, Resource resource) throws IOException {
		Map<String, ?> properties = loadProperties(resource);
		if (properties.isEmpty()) {
			return Collections.emptyList();
		}
		return Collections
				.singletonList(new OriginTrackedMapPropertySource(name, Collections.unmodifiableMap(properties), true));
	}

	@SuppressWarnings({ "unchecked", "rawtypes" })
	private Map<String, ?> loadProperties(Resource resource) throws IOException {
		String filename = resource.getFilename();
		if (filename != null && filename.endsWith(XML_FILE_EXTENSION)) {
			return (Map) PropertiesLoaderUtils.loadProperties(resource);
		}
		return new OriginTrackedPropertiesLoader(resource).load();
	}

}

YamlPropertySourceLoader实现:

/**
 * Strategy to load '.yml' (or '.yaml') files into a {@link PropertySource}.
 *
 * @author Dave Syer
 * @author Phillip Webb
 * @author Andy Wilkinson
 * @since 1.0.0
 */
public class YamlPropertySourceLoader implements PropertySourceLoader {

	@Override
	public String[] getFileExtensions() {
		return new String[] { "yml", "yaml" };
	}

	@Override
	public List<PropertySource<?>> load(String name, Resource resource) throws IOException {
		if (!ClassUtils.isPresent("org.yaml.snakeyaml.Yaml", null)) {
			throw new IllegalStateException(
					"Attempted to load " + name + " but snakeyaml was not found on the classpath");
		}
		List<Map<String, Object>> loaded = new OriginTrackedYamlLoader(resource).load();
		if (loaded.isEmpty()) {
			return Collections.emptyList();
		}
		List<PropertySource<?>> propertySources = new ArrayList<>(loaded.size());
		for (int i = 0; i < loaded.size(); i++) {
			String documentNumber = (loaded.size() != 1) ? " (document #" + i + ")" : "";
			propertySources.add(new OriginTrackedMapPropertySource(name + documentNumber,
					Collections.unmodifiableMap(loaded.get(i)), true));
		}
		return propertySources;
	}

}

这个服务是对Spring的配置文件的格式定义的扩展,两个实现分别是基于Properties格式的实现和yaml格式的实现。

上面的代码是服务的定义,以及两种服务的实现。具体来说就是Spring Boot对默认配置文件的解析处理的定义。

到这里,我们还不知道Spring是什么时候,在哪里加载该服务的定义,然后去哪里读取properties文件或yml文件的。也就是说,整个流程还没有串联起来,流程不清晰。

于是,我们继续看SpringAppliction#run()方法,里面有一行调用ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);,再进去看方法prepareEnvironment的具体实现:

这里首先会获取或创建环境对象,然后配置环境对象,我们看一下配置环境方法configureEnvironment的具体实现:

这个方法是委托实现configurePropertySources()configureProfiles()方法的模版方法,我们看一下这一行调用configurePropertySources(environment, args);,这里面通过环境去获取了PropertySource列表,并判断了defaultPropertiesaddCommandLineProperties,这里并没有使用服务加载器的代码,所以继续看源码。

回到prepareEnvironment方法,接下来有一行调用listeners.environmentPrepared(environment);,再后面就绑定环境对象了,即这一行的调用bindToSpringApplication(),再后面就是配置与环境绑定的调用ConfigurationPropertySources.attach(environment);了。

按道理,对配置文件的定位工作应该是在绑定到环境之前,因为就Spring的使用经验来说,配置文件本身是可以分环境来进行命名和配置的,所以,对应到这里的代码,应该就是在绑定环境之前的那一行调用了,即:listeners.environmentPrepared(environment);

我们进一步看这个方法的实现:

这里是通知所有关注ConfigurableEnvironment的监听器,我们看一下listener.enviromentPrepared()方法的具体实现:

原来是调用了EventPublishingRunListener的事件发布方法,这里使用的是事件驱动的方式进行事件的派发。

而且,这个EventPublishingRunListener本身也是基于SPI机制实现的:

我们接着看前面的事件派发,这里派发出去的事件类是ApplicationEnvironmentPreparedEvent,如果你清楚事件驱动模式的调用的话,这里就简单了,因为所有的关注事件ApplicationEnvironmentPreparedEvent的监听器都需要实现一个处理该事件的方法,所以,我们只需要去到该事件类,然后查看哪里监听器监听了该事件就可以定位到最终的目标了。

我们继续打开类ApplicationEnvironmentPreparedEvent,在IDEA中,按住ctrl再单击类名即可出现所有使用了该类的位置,如下图所示:

我们可以看到,有一个ConfigFileApplicationListener监听器,定义了方法:

onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event)

我们看一下该方法的实现,第一行调用了loadPostProcessors()方法,我们进去看一下具体的实现:

这里加载了所有的EnvironmentPostProcessor.class服务的实现,然后方法的第二行把当前对象也加到了EnvironmentPostProcessor.class服务提供者的列表中,然后对找到的这些服务提供者循环调用进行后续环境处理操作。

对这些实现一个个查找发现,当前类的处理方法的实现为:

进一步跟进,发现它调用了内部类Loader#load()方法:

我们定位到这个Loader内部类,发现它的构造方法中就使用服务加载器对PropertySourceLoader.class服务进行了查找:

前面的调用了Loader#load()方法,我们再看这个方法:

进一步定位到:

再继续查看就是根据条件进行判断并使用不同的实现去解析不同的文件了。

现在,我们回到PropertySourceLoader.class服务的两个实现看一下(代码已经贴在前面了)。

两个实现分别定义了各自所支持的不同的文件后缀,和上面的load()方法里的判断是匹配的。

Spring默认查找的是application.properties(或application.yaml),这个文件名application是哪里定义的呢?

其实这个文件名就定义在ConfigFileApplicationListener类中,同时还定义了配置文件默认的路径,它还支持通过不同的参数来修改这个配置文件的路径和名称等,有兴趣可以自行查看该类的代码。

现在我们再来回答前面的问题:Spring是什么时候,在哪里加载该服务的定义,然后去哪里读取properties文件或yml文件的?

答案就是ConfigFileApplicationListener类了,SpringApplication#run()方法在启动时,创建了环境对象之后,会通过事件派发的机制广播一系列的事件给一系列的SpringApplicationRunListener实现者,它们会关注各自所关心的事件,并在接收到相应的事件后,做一些相应的处理。

0x04 基于SPI实现Spring扩展

现在我们以PropertySourceLoader.class服务为例,结合SpringApplication的启动过程分析了Spring如何使用它的SPI机制的。

下面又是动手的环境,我们还是以这个服务为例,为Spring实现一种新的配置文件格式的支持。

toml是在golang生态中广泛使用的一种配置文件格式,我们现在就动手为Spring实现对toml文件格式的支持吧。

最新的toml标准为v1.0.0-rc,官方推荐的该版本的Java实现为https://github.com/tomlj/tomlj,我们就使用这个库来实现上面的扩展。

首先定义服务提供者的类名TomlPropertySourceLoader,并实现PropertySourceLoader接口,然后实现对应的方法:

/**
 * Strategy to load '.tml' (or '.toml') files into a {@link PropertySource}.
 *
 * @author zhaoyang.
 */
public class TomlPropertySourceLoader implements PropertySourceLoader {

    @Override
    public String[] getFileExtensions() {
        return new String[] { "tml", "toml" };
    }

    @Override
    public List<PropertySource<?>> load(String name, Resource resource) throws IOException {
        if (!ClassUtils.isPresent("org.tomlj.Toml", null)) {
            throw new IllegalStateException(
                "Attempted to load " + name + " but tomlj was not found on the classpath");
        }
        TomlParseResult tomlParseResult = Toml.parse(resource.getInputStream());
        Map<String, Object> resultMap = tomlParseResult.toMap();
        if (resultMap.isEmpty()) {
            return Collections.emptyList();
        }
        return Collections.singletonList(new OriginTrackedMapPropertySource(name, resultMap));
    }
}

除此之外,还要在resources目录新建META-INF目录,并在该目录下新建文件spring.factories,文件内容如下:

org.springframework.boot.env.PropertySourceLoader=\
me.zy.std.spi.spring.extension.TomlPropertySourceLoader

然后,在resources目录新建application.toml文件,内容如下:

# 这是一个 TOML 文档。

title = "TOML 示例"

[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T07:32:00-08:00 # 第一类日期时刻

[database]
server = "192.168.1.1"
ports = [ 8001, 8001, 8002 ]
connection_max = 5000
enabled = true

[servers]

  # 允许缩进(制表符和/或空格),不过不是必要的
  [servers.alpha]
  ip = "10.0.0.1"
  dc = "eqdc10"

  [servers.beta]
  ip = "10.0.0.2"
  dc = "eqdc10"

[clients]
data = [ ["gamma", "delta"], [1, 2] ]

# 数组中是可以换行的
hosts = [
  "alpha",
  "omega"
]

最后,新建一个Spring Boot程序的启动类:

/**
 * @author zhaoyang.
 */
@SpringBootApplication
public class SpringTomlTextApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringTomlTextApplication.class, args);
    }

    @Value("${title}")
    private String title;

    @Bean
    public CommandLineRunner test() {
        return (args -> {
            System.out.println("Server started...");
            System.out.println("\n\n\n");
            System.out.println("title = " + title);
        });
    }
}

我们从配置文件中,注入一个title属性,并在应用启动后,将配置文件中配置的title属性的值输出到配置文件。

启动程序后,可以看到控制台的输出:

成功的从application.toml配置文件中读取出了我们所配置的属性值。

至此,基于SpringSPI实现就分析完了。

0x05 总结

这一篇我们分析了Spring框架的SPI扩展,我们仅以PropertySourceLoader.class服务为例进行了分析,并动手为此服务实现了toml的扩展。

本质上,SpringSPIJavaSPI实现大同小异,但Spring在资源文件方面做了优化,将多个服务所需要的多个资源配置文件优化为统一的文件spring.factories,同时,文件的格式也是标准的Properties格式,这样对开发者要友好很多。

Spring框架的源代码中,大量的都使用了SPI机制进行扩展,有的扩展甚至有几十个之多,例如:EnableAutoConfiguration.class

SPI机制带来的可扩展性是得到了业界的认可的。

接下来会分析Dubbo框架中的SPI的实现,看看Dubbo的实现又有什么区别。

好了,这一篇的内容到此就结束了,欢迎各种反馈和交流!

References
  1. Toml Lang
  2. Toml Lang v1.0.0-rc Java Implement
  3. 示例代码仓库