spring boot 2源码系列(五)- 外部化配置

《Spring Boot源码博客》

spring 外部化配置官方文档 https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-external-config

Spring Boot允许外部化你的配置,这样你就可以在不同的环境中使用相同的应用程序代码,你可以使用properties文件、YAML文件、环境变量和命令行参数来外部化配置,属性值可以通过使用@Value注解直接注入到你的bean中,通过Spring的Environment抽象访问,或者通过@ConfigurationProperties绑定到结构化对象。
Spring Boot使用一种非常特殊的PropertySource命令,该命令旨在允许对值进行合理的覆盖,属性按以下顺序考虑:

1、Devtools全局设置属性在你的主目录(~/.spring-boot-devtools.properties当devtools处于激活状态时)。
2、测试中的@TestPropertySource注解
3、测试中的@SpringBootTestproperties注解属性
4、命令行参数
5、来自SPRING_APPLICATION_JSON(嵌入在环境变量或系统属性中的内联JSON)的属性
6、ServletConfig初始化参数
7、ServletContext初始化参数
8、java:comp/env中的JNDI属性
9、Java系统属性(System.getProperties())
10、操作系统环境变量
11、一个只有random.*属性的RandomValuePropertySource
12、在你的jar包之外的 特殊配置文件的 应用程序属性(application-{profile}.properties和YAML 变体)
13、打包在jar中的 特殊配置文件的 应用程序属性(application-{profile}.properties和YAML 变体)
14、在你的jar包之外的应用程序属性(application.properties和YAML 变体)
15、打包在jar中的应用程序属性(application.properties和YAML 变体)
16、@PropertySource注解在你的@Configuration类上,对yaml文件无效
17、默认属性(通过设置SpringApplication.setDefaultProperties指定)

下面实践这些参数配置

新建一个MyApplicationRunner类输出test.property属性

@Component
public class MyApplicationRunner implements ApplicationRunner {

    @Value("${test.property}")
    String testProperty;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        System.out.println("###输出test.property:"+testProperty);
    }
}

17、默认属性(通过设置SpringApplication.setDefaultProperties指定)。

在启动类中添加设置默认属性。

    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(SpringBootDemoApplication.class);
        Properties properties = new Properties();
        properties.setProperty("test.property", "17、默认属性(通过设置SpringApplication.setDefaultProperties指定)");
        app.setDefaultProperties(properties);
        app.run(args);
    }

启动工程,控制台输出     ###输出test.property:17、默认属性(通过设置SpringApplication.setDefaultProperties指定)

16、@PropertySource注解在你的@Configuration类上,对yaml文件无效。

启动类包含了@Configuration注解,可以在启动类上使用@PropertySource。

1、新建application-property-source.properties文件,配置test.property属性

test.property=16、@PropertySource注解在你的@Configuration类上,对yaml文件无效

2、启动类上配置  @PropertySource("classpath:application-property-source.properties")

@PropertySource("classpath:application-property-source.properties")
@SpringBootApplication
public class SpringBootDemoApplication {
    public static void main(String[] args) {
         SpringApplication app = new SpringApplication(SpringBootDemoApplication.class);
        Properties properties = new Properties();
        properties.setProperty("test.property", "17、默认属性(通过设置SpringApplication.setDefaultProperties指定)");
        app.setDefaultProperties(properties);
        app.run(args);

    }
}

启动工程,控制台输出 ###输出test.property:16、@PropertySource注解在你的@Configuration类上,对yaml文件无效

15、打包在jar中的应用程序属性(application.properties和YAML 变体)

在application.properties文件配置:test.property=15、打包在jar中的应用程序属性(application.properties和YAML 变体)

启动工程,控制台输出   ###输出test.property:15、打包在jar中的应用程序属性(application.properties和YAML 变体)

14、在你的jar包之外的应用程序属性(application.properties和YAML 变体)

1、新建目录 C:\Users\Administrator\Desktop\my-location,即在桌面新建目录my-location,my-location目录添加一个application.properties。填写配置

test.property=14、在你的jar包之外的应用程序属性(application.properties和YAML 变体)

2、添加程序参数 --spring.config.additional-location=C:\Users\Administrator\Desktop\my-location\

使用spring.config.additional-location配置,除了默认位置外,还搜索额外的位置。

启动工程,控制台输出   ###输出test.property:14、在你的jar包之外的应用程序属性(application.properties和YAML 变体)

13、打包在jar中的 特殊配置文件的 应用程序属性(application-{profile}.properties和YAML 变体)

1、新建 application-dev.properties 文件,配置 test.property=13、打包在jar中的 特殊配置文件的 应用程序属性(application-{profile}.properties和YAML 变体)

2、添加程序参数 --spring.profiles.active=dev 

启动工程,控制台输出  ###输出test.property:13、打包在jar中的 特殊配置文件的 应用程序属性(application-{profile}.properties和YAML 变体)

12、在你的jar包之外的 特殊配置文件的 应用程序属性(application-{profile}.properties和YAML 变体)

在C:\Users\Administrator\Desktop\my-location 中新增一个文件 application-dev.properties,添加配置 test.property=12、在你的jar包之外的 特殊配置文件的 应用程序属性(application-{profile}.properties和YAML 变体)

启动工程,控制台输出  ###输出test.property:12、在你的jar包之外的 特殊配置文件的 应用程序属性(application-{profile}.properties和YAML 变体)

11、一个只有random.*属性的RandomValuePropertySource

用于生成随机数,在application.properties文件中配置:test.property=${random.int[20,30]}  。结果不会覆盖12的配置。

10、操作系统环境变量

使用idea配置操作系统环境变量,Environment variables 中添加 test.property=10、操作系统环境变量

启动工程,控制台输出  ###输出test.property:10、操作系统环境变量

9、Java系统属性(System.getProperties())

启动类中设置java系统属性 System.setProperty("test.property", "9、Java系统属性(System.getProperties())");

@PropertySource("classpath:application-property-source.properties")
@SpringBootApplication
public class SpringBootDemoApplication {
    public static void main(String[] args) {

        System.setProperty("test.property", "9、Java系统属性(System.getProperties())");

        SpringApplication app = new SpringApplication(SpringBootDemoApplication.class);
        Properties properties = new Properties();
        properties.setProperty("test.property", "17、默认属性(通过设置SpringApplication.setDefaultProperties指定)");
        app.setDefaultProperties(properties);
        app.run(args);
    }
}

启动工程,控制台输出  ###输出test.property:9、Java系统属性(System.getProperties())

第8、7、6不讲了,我没去实践。

5、来自SPRING_APPLICATION_JSON(嵌入在环境变量或系统属性中的内联JSON)的属性

Environment variables 中添加 SPRING_APPLICATION_JSON={"test.property":"5、来自SPRING_APPLICATION_JSON(嵌入在环境变量或系统属性中的内联JSON)的属性"}

启动工程,控制台输出  ###输出test.property:###输出test.property:5、来自SPRING_APPLICATION_JSON(嵌入在环境变量或系统属性中的内联JSON)的属性

4、命令行参数

idea的Program Arguments 添加 --test.property=4、命令行参数

启动工程,控制台输出  ###输出test.property:4、命令行参数

3、测试中的@SpringBootTestproperties注解属性

在测试类上添加 @SpringBootTest(properties = {"test.property=3、测试中的@SpringBootTestproperties注解属性"})

@SpringBootTest(properties = {"test.property=3、测试中的@SpringBootTestproperties注解属性"})
class SpringBootDemoApplicationTests {
    @Test
    void contextLoads() {
    }
}

运行测试类

启动工程,控制台输出 ###输出test.property:3、测试中的@SpringBootTestproperties注解属性

2、测试中的@TestPropertySource注解

1、新建application-test-property-source.properties,添加配置

test.property=2、测试中的@TestPropertySource注解

2、测试类加上@SpringBootTest、@TestPropertySource({"classpath:application-test-property-source.properties"}) 注解

@SpringBootTest
@TestPropertySource({"classpath:application-test-property-source.properties"})
class SpringBootDemoApplicationTests {
    @Test
    void contextLoads() {
    }
}

启动工程,控制台输出  ###输出test.property:2、测试中的@TestPropertySource注解

1、Devtools全局设置属性在你的主目录(~/.spring-boot-devtools.properties当devtools处于激活状态时)。

这个我整不出来。

外部化配置源码

1、debug到SpringApplication#run(java.lang.String...)方法中,有下面这句代码

ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);   这是配置环境变量的方法。

1.1、prepareEnvironment(listeners, applicationArguments) 解析。

// 源码位置 org.springframework.boot.SpringApplication.prepareEnvironment
private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
        ApplicationArguments applicationArguments) {
    /**
     * 创建Environment对象,本教程使用的是servlet环境,创建的是StandardServletEnvironment对象
     */
    ConfigurableEnvironment environment = getOrCreateEnvironment();
    /**
     * 将命令行参数属性添加到StandardServletEnvironment对象中
     */
    configureEnvironment(environment, applicationArguments.getSourceArgs());
    ConfigurationPropertySources.attach(environment);
    /**
     * 运行Environment准备完毕事件
     */
    listeners.environmentPrepared(environment);
    bindToSpringApplication(environment);
    if (!this.isCustomEnvironment) {
        environment = new EnvironmentConverter(getClassLoader()).convertEnvironmentIfNecessary(environment,
                deduceEnvironmentClass());
    }
    ConfigurationPropertySources.attach(environment);
    return environment;
}

1.1.1、ConfigurableEnvironment environment = getOrCreateEnvironment(); 解析

environment 对象如下图所示:

propertySources和propertyResolver属性在AbstractEnvironment类中定义如下:

private final MutablePropertySources propertySources = new MutablePropertySources(this.logger);

// propertyResolver也存储了propertySources的引用
private final ConfigurablePropertyResolver propertyResolver = new PropertySourcesPropertyResolver(this.propertySources);

propertySources的propertySourceList是一个CopyOnwriteArrayList写时复制列表。

在MutablePropertySources中,propertySourceList定义如下:

List<PropertySource<?>> propertySourceList = new CopyOnWriteArrayList<>();

PropertySource是对键值对的抽象, 他的source属性用于存储键值对属性。

propertyResolver用于解析propertySources。

1.1.2、执行完   configureEnvironment(environment, applicationArguments.getSourceArgs());   这行代码后,再看下 environment 会发生哪些变化。

最明显的变化是propertySourceList多了两个元素,其中SimpleCommandLinePropertySource的source承载了命令行参数属性。而MapPropertySource的source属性承载了properties.setProperty(key, value)设置的默认属性。

使用Environment的getProperty(String key)方法时,通过propertyResolver去遍历propertySourceList,找到key对应的值就把值返回。由于SimpleCommandLinePropertySource排在MapPropertySource的前面,getProperty("test.property")就会返回命令行中配置的值“4、命令行参数”。获取属性值的源码分析后面会讲。

1.1.3、listeners.environmentPrepared(environment); 解析

listeners是SpringApplicationRunListeners对象,是SpringApplicationRunListener的合集。环境变量的处理会使用到SpringApplication运行时监听器,关于监听器的内容可以查看 spring boot 2源码系列(二)- 监听器ApplicationListener 这篇博客。

EventPublishingRunListener是SpringApplicationRunListener的默认实现,会广播ApplicationEnvironmentPreparedEvent事件。源码位置 EventPublishingRunListener#environmentPrepared(ConfigurableEnvironment environment)

1.1.3.1、ApplicationEnvironmentPreparedEvent被 ConfigFileApplicationListener监听到

// 源码位置 org.springframework.context.event.SimpleApplicationEventMulticaster.multicastEvent
@Override
public void multicastEvent(final ApplicationEvent event, @Nullable ResolvableType eventType) {
    ResolvableType type = (eventType != null ? eventType : resolveDefaultEventType(event));
    Executor executor = getTaskExecutor();
    /**
     * getApplicationListeners(event, type)
     * 获取监听了 ApplicationEnvironmentPreparedEvent 事件的监听器,调用监听器的 onApplicationEvent(E event) 方法。
     * getApplicationListeners(event, type)获取到的监听器中包含ConfigFileApplicationListener实例
     * ConfigFileApplicationListener将加载配置文件
     */
    for (ApplicationListener<?> listener : getApplicationListeners(event, type)) {
        if (executor != null) {
            executor.execute(() -> invokeListener(listener, event));
        }
        else {
            invokeListener(listener, event);
        }
    }
}

1.1.3.1.1、ConfigFileApplicationListener监听到ApplicationEnvironmentPreparedEvent事件后,使用环境变量后置处理器将配置的属性绑定到Environment中。

    // 源码位置 org.springframework.boot.context.config.ConfigFileApplicationListener.onApplicationEnvironmentPreparedEvent
    private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {
        List<EnvironmentPostProcessor> postProcessors = loadPostProcessors();
        postProcessors.add(this);
        AnnotationAwareOrderComparator.sort(postProcessors);
        /**
         * 使用环境变量后置处理器将属性添加到Environment的propertySources中
         * postProcessors包含很多个环境变量后置处理器,例如:
         * SystemEnvironmentPropertySourceEnvironmentPostProcessor 处理系统属性
         * SpringApplicationJsonEnvironmentPostProcessor 处理SPRING_APPLICATION_JSON属性
         * ConfigFileApplicationListener 处理properties、yml配置文件
         */
        for (EnvironmentPostProcessor postProcessor : postProcessors) {
            postProcessor.postProcessEnvironment(event.getEnvironment(), event.getSpringApplication());
        }
    }

当for (EnvironmentPostProcessor postProcessor : postProcessors) 循环完毕,Environment对象就变成了下面这样

配置文件application-{profile}.properties被存储到了propertySourceList的OriginTrackedMapPropertySource对象中,一个配置文件对应一个OriginTrackedMapPropertySource对象。

下面来分析application-{profile}.properties的加载过程

当 for (EnvironmentPostProcessor postProcessor : postProcessors) 遍历到ConfigFileApplicationListener时,debug进入方法内,最终进入addPropertySources方法

// 源码位置 org.springframework.boot.context.config.ConfigFileApplicationListener.addPropertySources
protected void addPropertySources(ConfigurableEnvironment environment, ResourceLoader resourceLoader) {
    RandomValuePropertySource.addToEnvironment(environment);
    // 使用Loader加载属性源并配置active文件
    new Loader(environment, resourceLoader).load();
}

Loader构造函数主要做一些属性赋值操作。

1、下面看下load()方法,这个方法就很重要了。

// 源码位置 org.springframework.boot.context.config.ConfigFileApplicationListener.Loader.load()
void load() {
    FilteredPropertySource.apply(this.environment, DEFAULT_PROPERTIES, LOAD_FILTERED_PROPERTY,
            (defaultProperties) -> {
                this.profiles = new LinkedList<>();
                this.processedProfiles = new LinkedList<>();
                this.activatedProfiles = false;
                this.loaded = new LinkedHashMap<>();

                // 初始化profile
                initializeProfiles();
                while (!this.profiles.isEmpty()) {
                    Profile profile = this.profiles.poll();
                    if (isDefaultProfile(profile)) {
                        addProfileToEnvironment(profile.getName());
                    }

                    // 加载配置文件
                    load(profile, this::getPositiveProfileFilter,
                            addToLoaded(MutablePropertySources::addLast, false));
                    this.processedProfiles.add(profile);
                }
                load(null, this::getNegativeProfileFilter, addToLoaded(MutablePropertySources::addFirst, true));

                 // 配置文件键值对绑定到environment对象
                addLoadedPropertySources();
                applyActiveProfiles(defaultProperties);
            });
}

1.1、initializeProfiles方法解析。

// 源码位置 org.springframework.boot.context.config.ConfigFileApplicationListener.Loader#initializeProfiles()
private void initializeProfiles() {
    /**
     * 给profiles添加一个null元素比较有意思。
     * 可以把application.properties、application.yml的profile认为是null。
     * 先添加null元素,即先处理默认配置文件application.properties、application.yml
     */
    this.profiles.add(null);

    /**
     * 此时还没加载配置文件,只能读取命令行属性、SPRING_APPLICATION_JSON、系统环境变量等方式配置的spring.profiles.active、spring.profiles.include
     */
    Set<ConfigFileApplicationListener.Profile> activatedViaProperty = getProfilesFromProperty(ACTIVE_PROFILES_PROPERTY);
    Set<ConfigFileApplicationListener.Profile> includedViaProperty = getProfilesFromProperty(INCLUDE_PROFILES_PROPERTY);

    List<ConfigFileApplicationListener.Profile> otherActiveProfiles = getOtherActiveProfiles(activatedViaProperty, includedViaProperty);
    this.profiles.addAll(otherActiveProfiles);
    // Any pre-existing active profiles set via property sources (e.g.
    // System properties) take precedence over those added in config files.
    this.profiles.addAll(includedViaProperty);
    addActiveProfiles(activatedViaProperty);

    /**
     * 如果没有配置spring.profiles.active、spring.profiles.include 就加一个default的profile
     */
    if (this.profiles.size() == 1) { // only has null profile
        for (String defaultProfileName : this.environment.getDefaultProfiles()) {
            ConfigFileApplicationListener.Profile defaultProfile = new ConfigFileApplicationListener.Profile(defaultProfileName, true);
            this.profiles.add(defaultProfile);
        }
    }
}

initializeProfiles()执行完后,this.profiles=[null, 命令行等方式配置的spring.profiles.active和spring.profiles.include/或者default]。
在本教程中this.profiles=[null, "dev"]。然后便是循环this.profiles。

1.1.1、while循环中的 load(profile, this::getPositiveProfileFilter, addToLoaded(MutablePropertySources::addLast, false));解析

// 源码位置 org.springframework.boot.context.config.ConfigFileApplicationListener.Loader#load()
private void load(String location, String name, ConfigFileApplicationListener.Profile
profile, ConfigFileApplicationListener.DocumentFilterFactory filterFactory,
        ConfigFileApplicationListener.DocumentConsumer consumer) {

    //省略部分代码

    /**
     * this.propertySourceLoaders有2个loader:
     *     PropertiesPropertySourceLoader("properties", "xml")、YamlPropertySourceLoader("yml", "yaml")
     */
    for (PropertySourceLoader loader : this.propertySourceLoaders) {
        for (String fileExtension : loader.getFileExtensions()) {
            if (processed.add(fileExtension)) {

                // 使用loader加载配置文件
                loadForFileExtension(loader, location + name, "." + fileExtension, profile, filterFactory,
                        consumer);
            }
        }
    }
}

由这两个同名load方法可以看出

(1)、先通过getSearchLocations()获取配置文件的存放目录(是个集合),然后遍历目录集合。

(2)、在遍历目录的过程中,遍历加载器集合this.propertySourceLoaders,使用加载器加载相应的文件类型,可加载"properties"、"xml"、"yml"、"yaml"这4种文件类型。

1.1.1.1、加载器加载配置文件的过程

// 源码位置 org.springframework.boot.context.config.ConfigFileApplicationListener.Loader#load()
private void load(PropertySourceLoader loader, String location, Profile profile, DocumentFilter filter,
        DocumentConsumer consumer) {

    // 仅列出主要代码

    // 定位资源
    Resource resource = this.resourceLoader.getResource(location);

    // 将配置文件键值对加载到Document对象中
    List<Document> documents = loadDocuments(loader, name, resource);

    List<Document> loaded = new ArrayList<>();
    for (Document document : documents) {
        if (filter.match(document)) {
            /**
             * 如果配置文件中配置了spring.profiles.active、spring.profiles.include,
             * 也添加到ConfigFileApplicationListener的profiles属性中
             */
            addActiveProfiles(document.getActiveProfiles());
            addIncludedProfiles(document.getIncludeProfiles());
            // document添加到loaded
            loaded.add(document);
        }
    }

    /**
     * 遍历loaded
     * consumer.accept(profile, document)是将document添加到MutablePropertySources的propertySourceList中
     */
    loaded.forEach((document) -> consumer.accept(profile, document));
}

List<Document> documents = loadDocuments(loader, name, resource); 这方法的主要代码如下:

// 源码位置 org.springframework.boot.env.PropertiesPropertySourceLoader.load()
// PropertiesPropertySourceLoader可以加载xml和properties文件
public List<PropertySource<?>> load(String name, Resource resource) throws IOException {
    // PropertiesPropertySourceLoader使用一个map存储配置文件中的键值对
    Map<String, ?> properties = loadProperties(resource);
    if (properties.isEmpty()) {
        return Collections.emptyList();
    }
    return Collections
            .singletonList(new OriginTrackedMapPropertySource(name, Collections.unmodifiableMap(properties), true));
}

Map<String, ?> properties = loadProperties(resource);详解

// 源码位置 org.springframework.boot.env.OriginTrackedPropertiesLoader.load(boolean)
Map<String, OriginTrackedValue> load(boolean expandLists) throws IOException {
    //仅列表部分代码    
    // 使用CharacterReader读取配置文件
    try (CharacterReader reader = new CharacterReader(this.resource)) {
        Map<String, OriginTrackedValue> result = new LinkedHashMap<>();
        StringBuilder buffer = new StringBuilder();
        while (reader.read()) {
            // 添加键值对到result中
            put(result, key, value);
        }
        // 返回result
        return result;
    }
}

配置文件键值对存储到在document的propertySource属性中

loaded.forEach((document) -> consumer.accept(profile, document));  继续debug这句代码,来到了

// 源码位置 org.springframework.core.env.MutablePropertySources.addLast()
public void addLast(PropertySource<?> propertySource) {
    removeIfPresent(propertySource);
    /**
     * this是loaded,先将配置文件键值对添加到loaded的propertySourceList中
     */
    this.propertySourceList.add(propertySource);
}

1.2、再回到 ConfigFileApplicationListener.Loader#load() 方法,load()方法中的 addLoadedPropertySources(); 便是将loaded的PropertySource添加给environment。

// 源码位置 org.springframework.boot.context.config.ConfigFileApplicationListener.Loader#addLoadedPropertySources
private void addLoadedPropertySources() {
    // 获取environment的propertySources
    MutablePropertySources destination = this.environment.getPropertySources();
    // 获取暂存在loaded中的键值对配置
    List<MutablePropertySources> loaded = new ArrayList<>(this.loaded.values());

    // 遍历loaded
    for (MutablePropertySources sources : loaded) {
        // 将loaded添加到destination中
        addLoadedPropertySource(destination, lastAdded, source);
    }
}

至此,配置文件的键值对如何添加到environment对象中就讲完了。

获取Environment属性源码分析

新建一个MyEnvironmentAware

@Component
public class MyEnvironmentAware implements EnvironmentAware {

    private static Environment env;

    public static String getProperty(String key){
        String pk = env.getProperty(key);
        System.out.println(pk);
        return pk;
    }

    @Override
    public void setEnvironment(Environment environment) {
        this.env = environment;
    }

}

然后在 MyApplicationRunner#run方法中加入一行代码

MyEnvironmentAware.getProperty("test.property")

启动工程,debug到Environment的getProperty(String key)方法内部。

1、最开始是调用propertyResolver的getProperty方法

// 源码位置 org.springframework.core.env.AbstractEnvironment.getProperty(java.lang.String)
public String getProperty(String key) {
    // 调用propertyResolver的getProperty方法
    return this.propertyResolver.getProperty(key);
}

2、getProperty解析

 //源码位置 org.springframework.core.env.PropertySourcesPropertyResolver.getProperty(java.lang.String, java.lang.Class<T>, boolean)
protected <T> T getProperty(String key, Class<T> targetValueType, boolean resolveNestedPlaceholders) {
    if (this.propertySources != null) {
        // 仅展示部分代码
        // 遍历this.propertySources
        for (PropertySource<?> propertySource : this.propertySources) {
            // propertySource.getProperty(key);其实就是获取获取source中的键值对
            Object value = propertySource.getProperty(key);
            /**
             * 如果value带有占位符
             * 1、截取占位符文本
             * 2、再次遍历this.propertySources,获取到value。
             * 从第2点可以看出,使用占位符与外部化配置的优先级无关。
             * 比方说:命令行参数的值是占位符,占位符的作为key可以配置到application.properties中.
             */
            if (value != null) {
                if (resolveNestedPlaceholders && value instanceof String) {
                    value = resolveNestedPlaceholders((String) value);
                }
            }
        }
    }
}

 Object value = propertySource.getProperty(key); 详解

// 源码位置 org.springframework.core.env.MapPropertySource#getProperty
public Object getProperty(String name) {
	// Object value = propertySource.getProperty(key); 就是取source中的值
	return this.source.get(name);
}

this.propertySources如下图所示

命令行属性的source如下图所示

 

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值