文章目录

  • SpringBoot 源码总结
    • 注册初始化器和监听器
    • 设置系统环境变量
    • 注册注解处理增强器并注册 beanFactory
    • 加载源类到容器中
    • 注册 JVM 关闭钩子
    • 执行 ConfigurationClassPostProcessor 增强器
    • doProcessConfigurationClass 解析 Configuration 类上的注解
    • 自动装配
      • 获取自动装配类
    • 过滤自动装配类
    • 手写自动装配组件
    • 总结
    • 总结

SpringBoot 源码总结

SpringApplication.run(Application.class, args) 这一行方法到底干了什么?

本文前置知识:[读 Spring 源码总结](./读 Spring 源码总结.md) 与 [SPI 机制](./SPI 机制以及 jdbc 打破双亲委派.md),基于 jdk11

注册初始化器和监听器

关键词:加载并设置初始化器和监听器、记录主配置类、META-INF/spring.factories、getSpringFactoriesInstances、loadFactoryNames、loadSpringFactories。

点进 run 方法,事实上调用了:

public static ConfigurableApplicationContext run(Class<?>[] primarySources, String[] args) {return new SpringApplication(primarySources).run(args);
}

此处 primarySources是我们的主类,跟进查看new SpringApplication()的逻辑:

public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources) {this.resourceLoader = resourceLoader;this.primarySources = new LinkedHashSet<>(Arrays.asList(primarySources));this.webApplicationType = WebApplicationType.deduceFromClasspath();setInitializers((Collection) getSpringFactoriesInstances(ApplicationContextInitializer.class));setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));this.mainApplicationClass = deduceMainApplicationClass();
}

这个初始化代码干了啥呢?首先添加了主要资源,然后设置了初始化器和监听器,最后跟踪栈堆信息推导出主配置类 mainApplicationClass,getSpringFactoriesInstances 方法很重要,看看具体的逻辑:

private <T> Collection<T> getSpringFactoriesInstances(Class<T> type, Class<?>[] parameterTypes, Object... args) {ClassLoader classLoader = getClassLoader();Set<String> names = new LinkedHashSet<>(SpringFactoriesLoader.loadFactoryNames(type, classLoader));List<T> instances = createSpringFactoriesInstances(type, parameterTypes, classLoader, args, names);AnnotationAwareOrderComparator.sort(instances);return instances;
}

getSpringFactoriesInstances 方法通过 loadFactoryNames 获取了一系列的 names,然后反射创造实例返回,继续跟进 SpringFactoriesLoader.loadFactoryNames 方法,发现主要核心是 loadSpringFactories 方法:

public static List<String> loadFactoryNames(Class<?> factoryType, @Nullable ClassLoader classLoader) {String factoryTypeName = factoryType.getName();return loadSpringFactories(classLoader).getOrDefault(factoryTypeName, Collections.emptyList());
}private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {MultiValueMap<String, String> result = cache.get(classLoader);if (result != null)  return result;Enumeration<URL> urls = classLoader.getResources(FACTORIES_RESOURCE_LOCATION); result = new LinkedMultiValueMap<>();while (urls.hasMoreElements()) {URL url = urls.nextElement();UrlResource resource = new UrlResource(url);Properties properties = PropertiesLoaderUtils.loadProperties(resource);for (Map.Entry<?, ?> entry : properties.entrySet()) {String factoryTypeName = ((String) entry.getKey()).trim();for (String factoryImplementationName : StringUtils.commaDelimitedListToStringArray((String) entry.getValue())) {result.add(factoryTypeName, factoryImplementationName.trim());}}}cache.put(classLoader, result);return result;
}

其中 String FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories",loadSpringFactories 函数查找了 classPath 下 META-INF/spring.factories 文件,并把所有的资源以键值对的形式放进缓存中,loadFactoryNames 返回对应键的所有元素(全限定名)。

现在来回顾下这两行代码是干啥的:

setInitializers(getSpringFactoriesInstances(ApplicationContextInitializer.class));
setListeners(getSpringFactoriesInstances(ApplicationListener.class));

getSpringFactoriesInstances(ApplicationContextInitializer.class) 加载了所有 META-INF/spring.factories 文件下的所有名称为 ApplicationContextInitializer 的元素,例如:

也就是说这些类会在这一步被加载进去,至于这些类都是干嘛的,读者可点进对应的类查看对应的注解。

再次说明 loadFactoryNames、loadSpringFactories 这两个方法很重要!

loadSpringFactories 加载 MEAT-INFO/factories 下的所有键值对,每个值都是一个类的全限定名,loadFactoryNames 根据 loadSpringFactories 加载的 Map 选取对应的值返回!

设置系统环境变量

关键词:设置系统环境变量、StandardEnvironment 类

进入 public ConfigurableApplicationContext run(String… args) 方法,其中有一行代码是:

ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);

这一行代码将帮我们设置环境变量,在 prepareEnvironment 方法内调用了 getOrCreateEnvironment() 方法,跟进方法发现是一个 switch 判断,具体返回环境属性类什么取决于具体的情况,例如 Web 应用将返回 StandardServletEnvironment()。

private ConfigurableEnvironment getOrCreateEnvironment() {if (this.environment != null) {return this.environment;}switch (this.webApplicationType) {case SERVLET:return new StandardServletEnvironment();case REACTIVE:return new StandardReactiveWebEnvironment();default:return new StandardEnvironment();}
}

但不管怎么样都是继承了 StandardEnvironment 类,而 StandardEnvironment 类又继承了 AbstractEnvironment 类,因此无论如何都会触发 AbstractEnvironment 类的初始化:

public AbstractEnvironment() {customizePropertySources(this.propertySources);
}

而初始化又调用了子类 StandardEnvironment 的方法,

protected void customizePropertySources(MutablePropertySources propertySources) {propertySources.addLast(new PropertiesPropertySource(SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME, getSystemProperties()));propertySources.addLast(new SystemEnvironmentPropertySource(SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, getSystemEnvironment()));
}

getSystemProperties() 和 getSystemEnvironment() 其实就是返回了 System.getProperties() 和 System.getenv(),这就是我们的系统环境变量。

注册注解处理增强器并注册 beanFactory

反射、createApplicationContext、DefaultListableBeanFactory、注册默认的增强器、ConfigurationClassPostProcessor。

还是在 public ConfigurableApplicationContext run(String… args) 方法中,有一行代码 context = createApplicationContext(); 创建了 ConfigurableApplicationContext 上下文,点进这个方法:

protected ConfigurableApplicationContext createApplicationContext() {Class<?> contextClass = this.applicationContextClass;if (contextClass == null) {switch (this.webApplicationType) {case SERVLET:contextClass = Class.forName(DEFAULT_SERVLET_WEB_CONTEXT_CLASS);break;case REACTIVE:contextClass = Class.forName(DEFAULT_REACTIVE_WEB_CONTEXT_CLASS);break;default:contextClass = Class.forName(DEFAULT_CONTEXT_CLASS);}}return (ConfigurableApplicationContext) BeanUtils.instantiateClass(contextClass);
}

不要看这个方法很简单,但细节是魔鬼,我这里是进入了第一个 case 语句,也就是调用了 Class.forName(DEFAULT_SERVLET_WEB_CONTEXT_CLASS) 语句,DEFAULT_SERVLET_WEB_CONTEXT_CLASS 是:

"org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext"

也就是说我应该在 BeanUtils.instantiateClass 方法中实例化这个上下文容器,点进这个上下文容器的构造器:

public AnnotationConfigServletWebServerApplicationContext() {this.reader = new AnnotatedBeanDefinitionReader(this);this.scanner = new ClassPathBeanDefinitionScanner(this);
}

首先不要看这个代码,要知道这肯定会向上调用父类构造器,跟踪发现持续调用了 ServletWebServerApplicationContext、GenericWebApplicationContext、GenericApplicationContext 类的构造器方法,在 GenericApplicationContext 类中:

public GenericApplicationContext() {this.beanFactory = new DefaultListableBeanFactory();
}

此时,DefaultListableBeanFactory 被注册。

然后在回到 AnnotationConfigServletWebServerApplicationContext() 方法中,这里注册了两个解析器,点入 AnnotatedBeanDefinitionReader 类的构造器,持续调用父类的构造器,最终调用了一行代码:

AnnotationConfigUtils.registerAnnotationConfigProcessors(this.registry);

点入这行代码,跳到了 AnnotationConfigUtils 类中执行,代码很长,但逻辑很简单,就是将几个硬编码的增强器加到容器上下文中,例如:

SpringBoot 中最最最重要的增强器 ConfigurationClassPostProcessor 在这里诞生了。

ClassPathBeanDefinitionScanner 这一行代码类似,添加了一些过滤器到容器内。

加载源类到容器中

关键词:prepareContext 方法、加载主配置类到容器中(未实例化)。

继续回到 run 方法中,可以发现 SpringBoot 又从 factorys 文件注册了异常播报者,但这不少我们关心的,注意方法:prepareContext,在这个方法内有一个核心逻辑:

Set<Object> sources = getAllSources(); // allSources.addAll(this.primarySources);
load(context, sources.toArray(new Object[0]));

getAllSources 其实加载了一些源,默认情况下就是我们传入的 primarySources,即主类,在 load 方法内被添加到容器中(未实例化)。

回到 run 方法:

成功下载了源类!

注册 JVM 关闭钩子

你一定很好奇为什么关闭虚拟机时 springboot 还会输出一些日志,这是因为它向 JVM 注册了关闭钩子!

private void refreshContext(ConfigurableApplicationContext context) {if (this.registerShutdownHook) {try {// 注册关闭狗子context.registerShutdownHook();}catch (AccessControlException ex) {// Not allowed in some environments.}}refresh((ApplicationContext) context);
}

执行 ConfigurationClassPostProcessor 增强器

关键词:refresh 方法、ConfigurationClassPostProcessor 增强器、主配置类 — 候选者、ConfigurationClassParser.parse 方法。

现在终于进入 refresh 方法,这是 spring 的知识点,就不再说了,现在到执行增强器的时候了,spirngboot 会调用 ConfigurationClassPostProcessor 类的 postProcessBeanDefinitionRegistry 方法(这个方法与 postProcessBeanFactory 方法具有同样的语义),然后调用了 processConfigBeanDefinitions 方法,在这个方法内,我们调用了:

ConfigurationClassParser parser = new ConfigurationClassParser(this.metadataReaderFactory, this.problemReporter, this.environment,this.resourceLoader, this.componentScanBeanNameGenerator, registry);
do {parser.parse(candidates);
} while (!candidates.isEmpty());

现在开始执行 ConfigurationClassParser.parse 了,而 candidates 就是我们的主配置类,这与之前 springboot 费尽心思加载主配置类也对应上了。

跟进 parse 方法内,核心代码是:

public void parse(Set<BeanDefinitionHolder> configCandidates) {for (BeanDefinitionHolder holder : configCandidates) {BeanDefinition bd = holder.getBeanDefinition();parse(((AnnotatedBeanDefinition) bd).getMetadata(), holder.getBeanName());}this.deferredImportSelectorHandler.process();
}

this.deferredImportSelectorHandler.process() 这个方法很重要,先记下来,等会再说。

再跟进重载的 parse 方法,主要调用了 processConfigurationClass 方法,这个方法内有一个很重要的逻辑:

SourceClass sourceClass = asSourceClass(configClass, filter);
do {sourceClass = doProcessConfigurationClass(configClass, sourceClass, filter);
}
while (sourceClass != null);

doProcessConfigurationClass 是真正做事的方法了,并且,如果 doProcessConfigurationClass 方法返回不是 null,那么会循环调用!

doProcessConfigurationClass 解析 Configuration 类上的注解

关键词:解析主配置类上的注解、@Import、getImports 方法、找到 @EnableAutoConfiguration 注解中的 @Import(AutoConfigurationImportSelector.class)。

这里面会执行对 @ComponentScan 的扫描,我们的启动类上的注解@SpringBootApplication 注解,这个注解内就包含了 @ComponentScan 注解:

@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM,classes = TypeExcludeFilter.class), @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })

也就是说这里 springboot 会扫描和主类在同一个包下的所有被@Controller,@Service,@Repository,@Component 标注的类,将它们注册到容器中,然后递归的对这些类执行 parse 方法重新解析。

现在假设容器已经递归的解析好了其他的类,重新开始解析我们的主类,有一行关键的代码是:

processImports(configClass, sourceClass, getImports(sourceClass), filter, true);

重点是这个函数的参数:getImports(sourceClass) 方法,跟进方法内:

private Set<SourceClass> getImports(SourceClass sourceClass) throws IOException {Set<SourceClass> imports = new LinkedHashSet<>();Set<SourceClass> visited = new LinkedHashSet<>();collectImports(sourceClass, imports, visited);return imports;
}
private void collectImports(SourceClass sourceClass, Set<SourceClass> imports, Set<SourceClass> visited) {if (visited.add(sourceClass)) {for (SourceClass annotation : sourceClass.getAnnotations()) {String annName = annotation.getMetadata().getClassName();if (!annName.equals(Import.class.getName())) {collectImports(annotation, imports, visited);}}imports.addAll(sourceClass.getAnnotationAttributes(Import.class.getName(), "value"));}
}

主要逻辑在 collectImports 方法内,这个方法啥意思?看if (!annName.equals(Import.class.getName())),其实这个方法就是搜集类上的注解,然后继续递归的跟踪这些注解,看看注解上有没有 @Import,有就加入集合,然后继续跟踪注解…递归调用,同时用 visited 防止死循环。

简言之,就是搜集类上或从其他类继承的所有 @Import 注解,将这些注解信息添加到集合内。我们的启动类上应该是有两个 @Import 的,他们在 @EnableAutoConfiguration 和 @AutoConfigurationPackage 中。

拿到资源后将执行 processImports 方法,这个方法会执行普通的 @Import 注解类,但不是我们的重点,这个我们马上会说。

自动装配

请先了解 @Import 基本知识

关键词:延迟导入、getAutoConfigurationEntry 方法、loadFactoryNames 、 loadSpringFactories、获取自动装配类、EnableAutoConfiguration.class、过滤自动装配类、META-INF/spring-autoconfigure-metadata.properties。

@Import(AutoConfigurationImportSelector.class) 中的注解并不会在 processImports 中执行,这是因为 AutoConfigurationImportSelector 实现了 DeferredImportSelector 接口,标志自己被延迟导入

回到 parse 方法:

public void parse(Set<BeanDefinitionHolder> configCandidates) {for (BeanDefinitionHolder holder : configCandidates) {BeanDefinition bd = holder.getBeanDefinition();parse(((AnnotatedBeanDefinition) bd).getMetadata(), holder.getBeanName());}this.deferredImportSelectorHandler.process();
}

this.deferredImportSelectorHandler.process() 是处理延迟导入的关键方法,跟进 process() 方法,这个方法调用了:

DeferredImportSelectorGroupingHandler handler = new DeferredImportSelectorGroupingHandler();
handler.processGroupImports();

点进 handler.processGroupImports() 方法内有一行:

grouping.getImports().forEach(......)

跟进 grouping.getImports() 内:

public Iterable<Group.Entry> getImports() {for (DeferredImportSelectorHolder deferredImport : this.deferredImports) {this.group.process(deferredImport.getConfigurationClass().getMetadata(),deferredImport.getImportSelector());}return this.group.selectImports();
}

核心方法是 this.group.process,继续跟进:

@Override
public void process(AnnotationMetadata annotationMetadata, DeferredImportSelector deferredImportSelector) {AutoConfigurationEntry autoConfigurationEntry = ((AutoConfigurationImportSelector) deferredImportSelector).getAutoConfigurationEntry(annotationMetadata);this.autoConfigurationEntries.add(autoConfigurationEntry);for (String importClassName : autoConfigurationEntry.getConfigurations()) {this.entries.putIfAbsent(importClassName, annotationMetadata);}
}

至关重要的一行代码:getAutoConfigurationEntry(annotationMetadata),跟进这个方法:

protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {AnnotationAttributes attributes = getAttributes(annotationMetadata);// 获取候选类List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);// 过滤候选类configurations = getConfigurationClassFilter().filter(configurations);return new AutoConfigurationEntry(configurations, exclusions);
}

这个方法主要就两个逻辑:获取自动装配类和过滤自动装配类,一个一个看!

获取自动装配类

点进 getCandidateConfigurations 方法:

protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {List<String> configurations = SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(),getBeanClassLoader());return configurations;
}

看来核心是 SpringFactoriesLoader.loadFactoryNames 方法了,跟进这个方法,发现竟然调用了 loadFactoryNames 和 loadSpringFactories 方法!这两个方法我们在第一步的时候就已经详细讲了!我们来看看具体的参数(key):getSpringFactoriesLoaderFactoryClass():

protected Class<?> getSpringFactoriesLoaderFactoryClass() {return EnableAutoConfiguration.class;
}

是 EnableAutoConfiguration!也就是说这一步会拿到所有 key = EnableAutoConfiguration 的值:

类多的数不胜数,所有全限定名都被加载返回,这就是自动装配核心!

\ 是换行的意思,按照上面的分析,这里肯定走缓存了。

过滤自动装配类

须熟悉 OnCondition 相关知识

跟进 getConfigurationClassFilter().filter(configurations) 的 filter 方法,configurations 是我们刚刚获得的自动装配类的全限定名:

List<String> filter(List<String> configurations) {String[] candidates = StringUtils.toStringArray(configurations);for (AutoConfigurationImportFilter filter : this.filters) {boolean[] match = filter.match(candidates, this.autoConfigurationMetadata);for (int i = 0; i < match.length; i++) {if (!match[i]) {candidates[i] = null;}}}List<String> result = new ArrayList<>(candidates.length);for (String candidate : candidates) {if (candidate != null) {result.add(candidate);}}
}

这个方法遍历 this.filters 来执行 match 方法,只要有一个不符合就过滤掉,this.filters 是什么呢?

看到 OnCondition 应该很熟悉了,这里面的方法比较绕,就不放源码了,大致的是在 AutoConfigurationMetadataLoader 类内,对 PATH = “META-INF/spring-autoconfigure-metadata.properties” 下的文件进行了资源加载,然后根据对应的规则(onClass 还是 onBean),去判断对应的 key 是否符合条件:

手写自动装配组件

定义一个类 Hello:

public class Hello {public void hello() {System.out.println("Hello World!");}
}

按照要求建立资源文件:

按照要求填写类的全限定名:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.happysnaker.Hello

打 jar 包,然后重启另一个项目导入 jar 包,运行 test(爆红别怕):

@SpringBootTest
class ApplicationTests {@AutowiredHello hello;@Testvoid contextLoads() {hello.hello();}}

成功输出:Hello World!

再建个文件:

填入:

com.happysnaker.Hello=
com.happysnaker.Hello.ConditionalOnClass=com.happysnaker.H

重新打包,运行程序报错!说明被过滤了!

新建一个类 H:

public class H {}

重新打包运行,成功输出:Hello World!

总结

com.happysnaker.Hello


打 jar 包,然后重启另一个项目导入 jar 包,运行 test(爆红别怕):```java
@SpringBootTest
class ApplicationTests {@AutowiredHello hello;@Testvoid contextLoads() {hello.hello();}}

成功输出:Hello World!

再建个文件:

[外链图片转存中…(img-3RePEjSw-1641825115371)]

填入:

com.happysnaker.Hello=
com.happysnaker.Hello.ConditionalOnClass=com.happysnaker.H

重新打包,运行程序报错!说明被过滤了!

新建一个类 H:

public class H {}

重新打包运行,成功输出:Hello World!

总结

按照目录的顺序下来就是启动的所有流程了,如果能围绕着其中的关键字思考应该没多大问题了。

SpringBoot 运行启动的所有步骤,自动装配以及细节总结相关推荐

  1. 启动rrt什么意思_面试官:你来说一下springboot启动时的一个自动装配过程吧!...

    前言 继续总结吧,没有面试就继续夯实自己的基础,前阵子的在面试过程中遇到的各种问题陆陆续续都会总结出来分享给大家,这次要说的也是面试中被问到的一个高频的问题,我当时其实没答好,因为很早之前是看过spr ...

  2. SpringBoot源码分析(二)之自动装配demo

    SpringBoot源码分析(二)之自动装配demo 文章目录 SpringBoot源码分析(二)之自动装配demo 前言 一.创建RedissonTemplate的Maven服务 二.创建测试服务 ...

  3. 一步一步手绘Spring DI运行时序图(Spring 自动装配之依赖注入)

    相关内容: 架构师系列内容:架构师学习笔记(持续更新) 一步一步手绘Spring IOC运行时序图一(Spring 核心容器 IOC初始化过程) 一步一步手绘Spring IOC运行时序图二(基于XM ...

  4. SpringBoot:认认真真梳理一遍自动装配原理

    目录 前言 SpringBoot介绍 SpringBoot所具备的特征有: 自己的理解: 开箱即用原理 体验开箱即用 开箱即用原理剖析 对比SSM配置 从pom.xml开始 所以我们可以得出第一个结论 ...

  5. Springboot源码分析第一弹 - 自动装配实现

    Springboot就不用多了吧,解放Java开发双手的神器. 最显著的特点就是,去配置化,自动装配,自动配置.让开发人员只需要注重业务的开发 今天就来了解一下自动装配的源码是怎么实现的 预先准备 直 ...

  6. 刨析 SpringBoot 自动装配原理,其实很简单

    J3 SpringBoot # 源码 # 自动装配 一日我在愉快得遨游时,看到有鱼友在问:SpringBoot 中引入了 Nacos 依赖为啥就可以直接使用 Nacos 中的相关功能呀! 认真思考了一 ...

  7. Alian解读SpringBoot 2.6.0 源码(十):启动流程之自动装配原理

    目录 一.背景 1.1.主类的加载 1.2.后置处理器的获取 二.配置类后处理器 2.1.获取配置类 2.2. 2.3.解析主类 2.3.1.整体解析过程 2.3.2.核心解析过程 2.3.3.延迟导 ...

  8. python脚本自动运行失败_Linux下Python脚本自启动和定时启动的详细步骤

    一.Python开机自动运行 假如Python自启动脚本为 auto.py .那么用root权限编辑以下文件: sudo vim /etc/rc.local 如果没有 rc.local 请看 这篇文章 ...

  9. SpringBoot自动装配源码解析

    Spring Boot 自动装配原理 使用Spring Boot最方便的一点体验在于我们可以几零配置的搭建一个Spring Web项目,那么他是怎么做到不通过配置来对Bean完成注入的呢.这就要归功于 ...

最新文章

  1. Linux中锚定符号的作用,Linux基础(9)文本处理三剑客之grep
  2. 什么是BP神经网络?
  3. springboot 事务_原创002 | 搭上SpringBoot事务源码分析专车
  4. ejb+jpa_使用Arquillian(包括JPA,EJB,Bean验证和CDI)测试Java EE 6
  5. else 策略模式去掉if_java – 用状态/策略模式替换if/else逻辑
  6. 自动备份SQL Server数据库中用户创建的Stored Procedures
  7. 混沌思维模型实战课课件分享
  8. 机器学习(1)——基础概念
  9. R语言利器之ddply
  10. Gambit学习2-曲面挖洞
  11. Unity 与EasyAR结合 新手入门教程
  12. 谁说EMC、IBM不能替换,还你一个存储虚拟化的真相!
  13. Sketch 插件篇(1)——Sketch Measure
  14. CJT长江连接器A2005系列线对板连接器排针排母PCB封装库
  15. excel设置自动排序123的详细教程
  16. 「SQL面试题库」 No_10 超过经理收入的员工
  17. xdm,程序员外包能干吗?
  18. Blender:如何翻转UV
  19. Vim文本编辑器及文本处理常用操作
  20. maven的下载安装,setting.xml配置教程,Idea 配置maven

热门文章

  1. Python解析罗永浩直播带货背后的数据秘密!
  2. 小白学习51单片机(第一天) 关于数码管
  3. mysql 集群 grra_Oracle RAC 导致实例驱逐的五大原因[ID 1526186.1]
  4. 来讲一讲php的单例模式及应用场景
  5. 中文翻译韩文软件有哪些?
  6. CAN控制器——MCP2518FD的FPGA调式
  7. hive 复合类型_hive原生和复合类型的数据加载和使用
  8. 制作一个简单HTML电影网页设计(HTML+CSS)---电影主题网站 6页带
  9. Python递归函数的应用
  10. Justnews主题源码V6.0.1开心版+社交问答插件/附教程