尽管用springBoot做开发已经有很长一段时间了,在开发时一般都是直接将application.properties或application.yml,放在开发环境的resources下的,运行起来感觉也没什么问题。

但是由于项目最终都是要通过打包,最终打包为一个jar包运行的。但如果一个项目由于环境不同需要对配置文件修改时,直接将在IDE中修改配置文件再重新打成一个JAR包很耗费时间。

最终通过搜索,得到一个理想的配置文件设置方式。可以在打好的将要运行的springBoot的jar包同级目录下放置上配置文件,或在其同级目录下新建一个config目录,将配置文件放在config目录中就可以了。试了一下,确实可以,感觉挺高级的,非常棒!

对于一向充满好奇心的我来说,对于springBoot它能这样做的原理充满了兴趣,决定通过ide的debug跟踪下源码。

软件环境:
springBoot版本:1.5.4.RELEASE

springBoot初始化listenner

从SpringApplication.run()方法开始打debug开始跟踪
run方法会调用到这里:

    /*** Static helper that can be used to run a {@link SpringApplication} from the* specified sources using default settings and user supplied arguments.* @param sources the sources to load* @param args the application arguments (usually passed from a Java main method)* @return the running {@link ApplicationContext}*/public static ConfigurableApplicationContext run(Object[] sources, String[] args) {return new SpringApplication(sources).run(args);}

new SpringApplication(sources)方法中有调用initialize(sources)这个初始化方法
其initialize方法代码为:

    @SuppressWarnings({ "unchecked", "rawtypes" })private void initialize(Object[] sources) {if (sources != null && sources.length > 0) {this.sources.addAll(Arrays.asList(sources));}this.webEnvironment = deduceWebEnvironment();setInitializers((Collection) getSpringFactoriesInstances(ApplicationContextInitializer.class));setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));this.mainApplicationClass = deduceMainApplicationClass();}

其中重点关注此方法:setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class))
在这个方法中,会初始化一些监听器,主要看下这个方法
它会调用此方法:

    private <T> Collection<? extends T> getSpringFactoriesInstances(Class<T> type,Class<?>[] parameterTypes, Object... args) {ClassLoader classLoader = Thread.currentThread().getContextClassLoader();// Use names and ensure unique to protect against duplicatesSet<String> names = new LinkedHashSet<String>(SpringFactoriesLoader.loadFactoryNames(type, classLoader));List<T> instances = createSpringFactoriesInstances(type, parameterTypes,classLoader, args, names);AnnotationAwareOrderComparator.sort(instances);return instances;}

在此方法中会通过SpringFactoriesLoader.loadFactoryNames方法获得出一串names的集合,然后再通过createSpringFactoriesInstances方法将names实例化出来

其SpringFactoriesLoader.loadFactoryNames方法为:

    /*** Load the fully qualified class names of factory implementations of the* given type from {@value #FACTORIES_RESOURCE_LOCATION}, using the given* class loader.* @param factoryClass the interface or abstract class representing the factory* @param classLoader the ClassLoader to use for loading resources; can be* {@code null} to use the default* @see #loadFactories* @throws IllegalArgumentException if an error occurs while loading factory names*/public static List<String> loadFactoryNames(Class<?> factoryClass, ClassLoader classLoader) {String factoryClassName = factoryClass.getName();try {Enumeration<URL> urls = (classLoader != null ? classLoader.getResources(FACTORIES_RESOURCE_LOCATION) :ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));List<String> result = new ArrayList<String>();while (urls.hasMoreElements()) {URL url = urls.nextElement();Properties properties = PropertiesLoaderUtils.loadProperties(new UrlResource(url));String factoryClassNames = properties.getProperty(factoryClassName);result.addAll(Arrays.asList(StringUtils.commaDelimitedListToStringArray(factoryClassNames)));}return result;}catch (IOException ex) {throw new IllegalArgumentException("Unable to load [" + factoryClass.getName() +"] factories from location [" + FACTORIES_RESOURCE_LOCATION + "]", ex);}}

其中此条语句:

Enumeration<URL> urls=(classLoader!=null?classLoader.getResources(FACTORIES_RESOURCE_LOCATION):ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));

会通过classLoader在jar内获取出FACTORIES_RESOURCE_LOCATION的资源。
其FACTORIES_RESOURCE_LOCATION的值可以通过源码找到这句

    /*** The location to look for factories.* <p>Can be present in multiple JAR files.*/public static final String FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories";

那么也就是springBoot在初始化的时候会加载所有依赖包的META-INF/spring.factories文件
为此,为了验证都能获取出哪些具体的spring.factories配置文件,我在这个springBoot项目的测试类中写了方法

    public static void main(String[] args) {IndexServiceApplicationTests indexServiceApplicationTests = new IndexServiceApplicationTests();try {Enumeration<URL> urls = indexServiceApplicationTests.getClass().getClassLoader().getResources("META-INF/spring.factories");System.out.println("urls:" + urls);while(urls.hasMoreElements()){URL url = urls.nextElement();System.out.println("urlItem:"+url);}} catch (Exception e) {e.printStackTrace();}}

其输出结果为:

urls:sun.misc.CompoundEnumeration@f2a0b8e
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/cloud/spring-cloud-context/1.2.2.RELEASE/spring-cloud-context-1.2.2.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/cloud/spring-cloud-commons/1.2.2.RELEASE/spring-cloud-commons-1.2.2.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/cloud/spring-cloud-netflix-eureka-server/1.3.1.RELEASE/spring-cloud-netflix-eureka-server-1.3.1.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/cloud/spring-cloud-netflix-core/1.3.1.RELEASE/spring-cloud-netflix-core-1.3.1.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/cloud/spring-cloud-netflix-eureka-client/1.3.1.RELEASE/spring-cloud-netflix-eureka-client-1.3.1.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/boot/spring-boot-test/1.5.4.RELEASE/spring-boot-test-1.5.4.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/boot/spring-boot/1.5.4.RELEASE/spring-boot-1.5.4.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/boot/spring-boot-test-autoconfigure/1.5.4.RELEASE/spring-boot-test-autoconfigure-1.5.4.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/boot/spring-boot-autoconfigure/1.5.4.RELEASE/spring-boot-autoconfigure-1.5.4.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/spring-test/4.3.9.RELEASE/spring-test-4.3.9.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/spring-beans/4.3.9.RELEASE/spring-beans-4.3.9.RELEASE.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/commons-logging/commons-logging/1.1.1/commons-logging-1.1.1.jar!/META-INF/spring.factories
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/boot/spring-boot-actuator/1.5.4.RELEASE/spring-boot-actuator-1.5.4.RELEASE.jar!/META-INF/spring.factories

其中前面的/D:/program%20files/maven_repo字段为我电脑本地maven仓库的路径
关注下这条输出记录:
urlItem:jar:file:/D:/program%20files/maven_repo/org/springframework/boot/spring-boot/1.5.4.RELEASE/spring-boot-1.5.4.RELEASE.jar!/META-INF/spring.factories
这个路径为springboot这个jar包下的spring.facotires文件
随后,代码又执行了如下语句:

Properties properties = PropertiesLoaderUtils.loadProperties(new UrlResource(url));
String factoryClassNames = properties.getProperty(factoryClassName);
result.addAll(Arrays.asList(StringUtils.commaDelimitedListToStringArray(factoryClassNames)));

先把此配置文件加载为properties,再获取出变量factoryClassName属性的值。通过debug或通过入参可以获取到factoryClassName为ApplicationListener.class这个类的名称,即org.springframework.context.ApplicationListener
获得了以上信息后,我们便可以打开对应的JAR包,找到对应的配置文件下的org.springframework.context.ApplicationListener键,查看都有哪些值(spring-boot-1.5.4.RELEASE.jar!/META-INF/spring.factories中的org.springframework.context.ApplicationListener的值)
通过打开JAR包,在此配置文件中发现了如下内容

# Application Listeners
org.springframework.context.ApplicationListener=\
org.springframework.boot.ClearCachesApplicationListener,\
org.springframework.boot.builder.ParentContextCloserApplicationListener,\
org.springframework.boot.context.FileEncodingApplicationListener,\
org.springframework.boot.context.config.AnsiOutputApplicationListener,\
org.springframework.boot.context.config.ConfigFileApplicationListener,\
org.springframework.boot.context.config.DelegatingApplicationListener,\
org.springframework.boot.liquibase.LiquibaseServiceLocatorApplicationListener,\
org.springframework.boot.logging.ClasspathLoggingApplicationListener,\
org.springframework.boot.logging.LoggingApplicationListener

上面的代码result.addAll(Arrays.asList(StringUtils.commaDelimitedListToStringArray(factoryClassNames)))就将这些listener添加进了result集合中,最终返回给了它上一级的调用方法names

    private <T> Collection<? extends T> getSpringFactoriesInstances(Class<T> type,Class<?>[] parameterTypes, Object... args) {ClassLoader classLoader = Thread.currentThread().getContextClassLoader();// Use names and ensure unique to protect against duplicatesSet<String> names = new LinkedHashSet<String>(SpringFactoriesLoader.loadFactoryNames(type, classLoader));List<T> instances = createSpringFactoriesInstances(type, parameterTypes,classLoader, args, names);AnnotationAwareOrderComparator.sort(instances);return instances;}@SuppressWarnings("unchecked")private <T> List<T> createSpringFactoriesInstances(Class<T> type,Class<?>[] parameterTypes, ClassLoader classLoader, Object[] args,Set<String> names) {List<T> instances = new ArrayList<T>(names.size());for (String name : names) {try {Class<?> instanceClass = ClassUtils.forName(name, classLoader);Assert.isAssignable(type, instanceClass);Constructor<?> constructor = instanceClass.getDeclaredConstructor(parameterTypes);T instance = (T) BeanUtils.instantiateClass(constructor, args);instances.add(instance);}catch (Throwable ex) {throw new IllegalArgumentException("Cannot instantiate " + type + " : " + name, ex);}}return instances;}

从代码可以看出,上面的代码将获取出来的listener全通过反射进行了实例化,最终回到了初始化方法,通过setListeners设置到了SpringApplication的类中

    /*** Sets the {@link ApplicationListener}s that will be applied to the SpringApplication* and registered with the {@link ApplicationContext}.* @param listeners the listeners to set*/public void setListeners(Collection<? extends ApplicationListener<?>> listeners) {this.listeners = new ArrayList<ApplicationListener<?>>();this.listeners.addAll(listeners);}

ConfigFileApplicationListener执行过程

通过上面,可以得知在springApplication初始化的时候会加载ConfigFileApplicationListener这个类,那么它是在什么时候调用了这个类呢?仍然是通过源码,探究Application的run方法

    /*** Run the Spring application, creating and refreshing a new* {@link ApplicationContext}.* @param args the application arguments (usually passed from a Java main method)* @return a running {@link ApplicationContext}*/public ConfigurableApplicationContext run(String... args) {StopWatch stopWatch = new StopWatch();stopWatch.start();ConfigurableApplicationContext context = null;FailureAnalyzers analyzers = null;configureHeadlessProperty();SpringApplicationRunListeners listeners = getRunListeners(args);listeners.starting();try {ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);ConfigurableEnvironment environment = prepareEnvironment(listeners,applicationArguments);Banner printedBanner = printBanner(environment);context = createApplicationContext();analyzers = new FailureAnalyzers(context);prepareContext(context, environment, listeners, applicationArguments,printedBanner);refreshContext(context);afterRefresh(context, applicationArguments);listeners.finished(context, null);stopWatch.stop();if (this.logStartupInfo) {new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);}return context;}catch (Throwable ex) {handleRunFailure(context, listeners, analyzers, ex);throw new IllegalStateException(ex);}}

其中有如下两行代码:

        SpringApplicationRunListeners listeners = getRunListeners(args);listeners.starting();

starting方法的代码为:

    public void starting() {for (SpringApplicationRunListener listener : this.listeners) {listener.starting();}}

通过代码可知stating方法就是通过遍历listeners,来依次触发listener的starting方法。最终会执行到这里:

    public void multicastEvent(final ApplicationEvent event, ResolvableType eventType) {ResolvableType type = eventType != null ? eventType : this.resolveDefaultEventType(event);Iterator var4 = this.getApplicationListeners(event, type).iterator();while(var4.hasNext()) {final ApplicationListener<?> listener = (ApplicationListener)var4.next();Executor executor = this.getTaskExecutor();if (executor != null) {executor.execute(new Runnable() {public void run() {SimpleApplicationEventMulticaster.this.invokeListener(listener, event);}});} else {this.invokeListener(listener, event);}}}

上面的this.getApplicationListeners(event, type)会获得由上文初始化的那10多个集合,然后再通过迭代器进行遍历listener。每遍历到一个listener,就从线程器中开启一个线程,去执行这个listener

    protected void invokeListener(ApplicationListener listener, ApplicationEvent event) {ErrorHandler errorHandler = this.getErrorHandler();if (errorHandler != null) {try {listener.onApplicationEvent(event);} catch (Throwable var7) {errorHandler.handleError(var7);}} else {try {listener.onApplicationEvent(event);} catch (ClassCastException var8) {String msg = var8.getMessage();if (msg != null && !msg.startsWith(event.getClass().getName())) {throw var8;}Log logger = LogFactory.getLog(this.getClass());if (logger.isDebugEnabled()) {logger.debug("Non-matching event type for listener: " + listener, var8);}}}}
}

最终都会触发listener的onApplicationEvent方法。
这里只跟踪下ConfigFileApplicationListener这个监听器。
当触发到ConfigFileApplicationListener的onApplicationEvent时,会执行如下的代码

    public void onApplicationEvent(ApplicationEvent event) {if (event instanceof ApplicationEnvironmentPreparedEvent) {onApplicationEnvironmentPreparedEvent((ApplicationEnvironmentPreparedEvent) event);}if (event instanceof ApplicationPreparedEvent) {onApplicationPreparedEvent(event);}}

由debu跟踪,会发现初始化时运行的是onApplicationEnvironmentPreparedEvent((ApplicationEnvironmentPreparedEvent) event)这个方法
随后进入:

    private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {List<EnvironmentPostProcessor> postProcessors = loadPostProcessors();postProcessors.add(this);AnnotationAwareOrderComparator.sort(postProcessors);for (EnvironmentPostProcessor postProcessor : postProcessors) {postProcessor.postProcessEnvironment(event.getEnvironment(),event.getSpringApplication());}}

postProcessor.postProcessEnvironment(event.getEnvironment(),
event.getSpringApplication()方法为:

    @Overridepublic void postProcessEnvironment(ConfigurableEnvironment environment,SpringApplication application) {addPropertySources(environment, application.getResourceLoader());configureIgnoreBeanInfo(environment);bindToSpringApplication(environment, application);}

在addPropertySources方法中,发现了如下关键代码:

    /*** Add config file property sources to the specified environment.* @param environment the environment to add source to* @param resourceLoader the resource loader* @see #addPostProcessors(ConfigurableApplicationContext)*/protected void addPropertySources(ConfigurableEnvironment environment,ResourceLoader resourceLoader) {RandomValuePropertySource.addToEnvironment(environment);new Loader(environment, resourceLoader).load();}

后面的new Loader(environment,resourceLoader).load()方法代码为:

        public void load() {this.propertiesLoader = new PropertySourcesLoader();this.activatedProfiles = false;this.profiles = Collections.asLifoQueue(new LinkedList<Profile>());this.processedProfiles = new LinkedList<Profile>();// Pre-existing active profiles set via Environment.setActiveProfiles()// are additional profiles and config files are allowed to add more if// they want to, so don't call addActiveProfiles() here.Set<Profile> initialActiveProfiles = initializeActiveProfiles();this.profiles.addAll(getUnprocessedActiveProfiles(initialActiveProfiles));if (this.profiles.isEmpty()) {for (String defaultProfileName : this.environment.getDefaultProfiles()) {Profile defaultProfile = new Profile(defaultProfileName, true);if (!this.profiles.contains(defaultProfile)) {this.profiles.add(defaultProfile);}}}// The default profile for these purposes is represented as null. We add it// last so that it is first out of the queue (active profiles will then// override any settings in the defaults when the list is reversed later).this.profiles.add(null);while (!this.profiles.isEmpty()) {Profile profile = this.profiles.poll();for (String location : getSearchLocations()) {if (!location.endsWith("/")) {// location is a filename already, so don't search for more// filenamesload(location, null, profile);}else {for (String name : getSearchNames()) {load(location, name, profile);}}}this.processedProfiles.add(profile);}addConfigurationProperties(this.propertiesLoader.getPropertySources());}

上面首先初始化一个profiles队列,其队列为一个lifo队列(lastInFirstOut后进先出),代码为:

this.profiles = Collections.asLifoQueue(new LinkedList<Profile>());

随后判断下当前环境中是否有设profile,没有的话,就使用默认的profile,在profiles中加入一个名为default的profile.随后又在profiles中加入了一个null,对于为什么要加入一个null,代码里也有相应的注释说明。

// The default profile for these purposes is represented as null. We add it
// last so that it is first out of the queue (active profiles will then
// override any settings in the defaults when the list is reversed later).
this.profiles.add(null);

大致意思是说放一个null值在profiles队列的末尾,由于队列是lifo类型的,所以null值就会最先出队,先将默认配置给初始化。当其他激活的profile出队的时候,就会重载默认的配置。
而后关注这个方法中的这段代码:

            while (!this.profiles.isEmpty()) {Profile profile = this.profiles.poll();for (String location : getSearchLocations()) {if (!location.endsWith("/")) {// location is a filename already, so don't search for more// filenamesload(location, null, profile);}else {for (String name : getSearchNames()) {load(location, name, profile);}}}this.processedProfiles.add(profile);}

在这里就是配置文件体现加载顺序的主要代码
String location : getSearchLocations()
在getSearchLocations代码中,在没有设置其他配置文件的情况下,就会在配置文件的路径中加入如下地址

            locations.addAll(
asResolvedSet(ConfigFileApplicationListener.this.searchLocations,DEFAULT_SEARCH_LOCATIONS));

及DEFAULT_SEARCH_LOCATIONS的值,而其值在此类的头部也可以找到它的定义:

    // Note the order is from least to most specific (last one wins)private static final String DEFAULT_SEARCH_LOCATIONS = "classpath:/,classpath:/config/,file:./,file:./config/";

这里写的先后顺序是classpath:/,classpath:/config/,file:./,file:./config/,但上面有注释说明,这个顺序是通过由后到前的顺序来进行选择的。
通过asResolvedSet这个方法,也可以得证:

        private Set<String> asResolvedSet(String value, String fallback) {List<String> list = Arrays.asList(StringUtils.trimArrayElements(StringUtils.commaDelimitedListToStringArray(value != null? this.environment.resolvePlaceholders(value) : fallback)));Collections.reverse(list);return new LinkedHashSet<String>(list);}

Collections.reverse(list);

springBoot配置文件加载原理探究相关推荐

  1. SpringBoot - 配置文件加载位置与优先级

    SpringBoot - 配置文件加载位置与优先级 [1]项目内部配置文件 spring boot 启动会扫描以下位置的application.properties或者application.yml文 ...

  2. Spring 配置文件加载原理

    参考:准备Spring Boot的环境 1 核心原理 ⭐️1 在SpringBoot的环境准备阶段的后期, 发布一个ApplicationEnvironmentPreparedEvent事件 ⭐️2 ...

  3. springboot配置文件加载顺序_「SpringBoot系列」配置文件加载优先级解析

    SpringBoot提供了外部分配置功能,可以使用属性文件(properties).YAML(yml)文件.环境变量和命令行参数来进行处部参数配置,并t以特定的顺序来处理配置,以便于允许合理的覆盖值. ...

  4. springboot配置文件加载顺序

    1.同一目录下,properties配置优先级 > YAML配置优先级. 2.SpringBoot配置文件可以放置在多种路径下,不同路径下的配置优先级有所不同.可放置目录(优先级从高到低) fi ...

  5. SpringBoot配置文件加载时机

    今天遇到一个问题,项目中,数据库密码使用CyberArk管理,在yml配置文件中的配置是密码对应的一个key,所以需要在运行时合适的时机去通过接口获取密码并设置到SpringBoot中.问题是使用lo ...

  6. springboot 配置文件加载顺序 与boboootStrap属性文件对比

    spring boot 启动时 会扫描 以下位置的 application.properties 或者 yml 作为默认配置文件 file:./config/ file:./ classpath:/c ...

  7. springboot配置文件加载位置

    高优先级覆盖低优先级 配置互补

  8. 配置文件加载位置||外部配置加载顺序||自动配置原理

    配置文件加载位置 SpringBoot会从这四个位置全部加载主配置文件:互补配置: 外部配置加载顺序 自动配置原理 1.自动配置原理: 1).SpringBoot启动的时候加载主配置类,开启了自动配置 ...

  9. Springboot默认加载application.yml原理

    Springboot默认加载application.yml原理以及扩展 SpringApplication.run(-)默认会加载classpath下的application.yml或applicat ...

最新文章

  1. WCF第一个Demo
  2. 2.14 向量化 Logistic 回归的梯度输出-深度学习-Stanford吴恩达教授
  3. 笔记 - Ali Cloud网络(VPC, SLB) 简介
  4. 出现“adb不是内部或外部命令,也不是可运行的程序或批量文件。”
  5. 《Invisible Inc.》游戏分析:如何在回合制中塑造紧张刺激的体验?
  6. 使用 rapidxml 做配置文件
  7. shell:判断一个进程是否存在
  8. 程序员 挣钱比健康重要
  9. 安装虚拟机Centos系统并安装Docker过程记录
  10. XMLHttpRequest对象AJAX技术的基本使用
  11. 多目标优化算法_阿里提出多目标优化全新算法框架,同时提升电商GMV和CTR
  12. 解决win10学习汇编工具的烦恼——汇编学习工具DOSBox0.74的下载和使用(包含可用下载链接)
  13. 四川师范大学大学计算机基础,大学计算机基础课程教学改革探索——以四川师范大学为例...
  14. 图片裁切批处理_PS照片裁剪批量处理方法
  15. 数字三角形、数塔问题(DP)
  16. 基于asp.net学生信息管理系统的设计与实现(毕设)
  17. HTML+CSS大作业:旅游网页设计与实现——旅游风景网站6页HTML+CSS+JavaScript实训大作业 HTML+CSS大作业 HTML期末大作业
  18. 努比亚修复工具_努比亚Play刷机包(官方刷机完整固件升级包V2)
  19. 《Python 数据科学实践指南》读书笔记
  20. Tomcat详细使用步骤

热门文章

  1. 云计算虚拟化技术与开发-------虚拟化技术应用第二章内容(CPU虚拟机X86要解决的问题、VT-x、VMX、vCPU、EPT、VT-d)
  2. 人工智能时代,为什么大量物理学家开始纷纷转型涌入科技界?
  3. 如何设置HTML select下拉框的默认值?
  4. 【IAP支付之一】In-App Purchase Walk Through 整个支付流程
  5. 微信小程序开发(十三)富文本插件wxParse的wxParseImgTap的bug修复
  6. 回味时尚,KZ ZEX Pro静电6单元耳机,百元价格千元级享受
  7. java程序员工资和c语言工资_2017程序员薪资大爆料!你在哪个阶段?
  8. 绿茶、红茶、茉莉花茶的简述分享
  9. springboot 整合 mongodb 增删改查 第二篇
  10. [英语阅读]美国首家大麻餐馆开业