从Spring到SpringBoot,我们可以感受到,框架越来越智能,集成度越来越高,让我们使用越来越简单,解放程序员,更加关注业务本身。用的越简单,后面做的工作就越多。说到底,SpringBoot最终还是对Spring框架的封装,主要任务
还是在做(IOC)控制反转,(DI)依赖出入和(DL)依赖查找,实现自动化装配
,一切交给spring托管,更好的完成各功能的集成。设计思想类似于Mybaties,Mybaties其实是封装了ibaties,而ibaties封装了JDBC。
1、SpringBoot的出现
Spring配置
发展阶段一:在没有
SpringBoot之前,我们接触最多的就是spring和springMVC,Spring的出现最重要的就是帮我们如何自动管理Bean,例如IOC(控制反转)和 DI (依赖注入),一切都是基于Bean实现的,而这些框架在使用的过程中会需要配置大量繁琐的xml配置,
通过ApplicationContest.xml来配置我们的Bean类即可托管给Spring框架。
<bean id="person" class="com.beijing.ConfigurationTest.bean.Person" init-method="init" destroy-method="destory">
<constructor-arg index="0" value="name"/>
<constructor-arg index="1" value="19"/>
<property name="name" value="king"></property>
<property name="age" value="18"></property>
</bean>
Spring配置发展阶段二:但是随着Bean实例的加,之后为了减少程序员繁杂的配置工作,spring3开始,java除了支持xml形式外, 还支持了 Javaconfig,注解方式;
Spring配置
发展阶段三:之后为了更加简化我们的开发流程,既然一切基础功能我们都实现了,那是不是可以有一种更好的规范和规则来快速的搭建我们的项目呢?类似于很多项目脚手架一样,避免每次都做一些繁琐且单一的xml文件配置。
这就有了SpringBoot框架的出现,目标是帮助使用Spring框架的使用者快速高效的搭建一个基于spring生态体系的应用解决方案,它是对
“约定优于配置”这个理念下的一个最佳实践。
基于“约定大于配置”的原则,按需自动加载常用的组件,根据历史经验来预先设置好参数的值,如果有特殊需求也可以自定义设置修改,最大程度的简化了繁杂的xml配置文件,一键式启动服务,得到了开箱即用的便捷。常见的“约定优于配置”功能如下:
-
1、maven的默认目录结构:
-
默认会在resource文件夹下存放配置文件;
-
默认会以jar的方式打包;
-
-
2、默认使用classpath下的application.properties/ yml为 项目配置文件;
-
3、EnableAutoConfiguration 默认对于依赖的starter进行自动装载;
-
4、默认使用日志slf4j + logback的组合框架等等;
例如spring-boot-starter-web默认包含SpringMVC相关的依赖以及内置tomcat容器,使得构建一个web应用更加简单。所以了解了这些,
发现SpringBoot并没有什么特殊的存在,底层还是Spring,只是对旧的东西做了一层封装。下面就简单学习一下SpringBoot的特点和原理。
为了更好的理解SpringBoot的工作原理,这里一切从@
SpringBootApplication注解开始,这个是进入SpringBoot世界的大门,看看它里面到底做了什么?
2、SpringBoot的启动
SpringBoot
的
启动类
@SpringBootApplication //标注这个类是一个springboot的一个应用
public class HelloWorldApplication {
public static void main(String[] args) {//一键启动的便捷
SpringApplication.run(HelloWorldApplication.class, args);
}
}
SpringBoot的启动也十分简单,主要有三种:
-
1 、直接运行 main 方法,因为springboot已经帮我们内置了一个tomcat,并且回去加载classpath下的配置文件;
-
2 、 通过spring-boot-plugin方式: mvn spring-boot:run -Drun.arguments="--server.port=8081"
-
3 、 通过java -jar的方式:先mvn install,之后通过 java -jar emample.jar --server.port=8082
3、启动原理-@SpringBootApplication
进入
注解:@SpringBootApplication,打可以看到它其实是一个
复合注解,主要由以下几个注解组成:
-
@ ComponentScan (ImportSelector ) ;
-
@Enable Aut oConfiguration (ImportSelector + ImportBeanDefinationRegistrar);
-
@ Configuration (Component);
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration //配置类
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
@ConfigurationPropertiesScan
public @interface SpringBootApplication {
@AliasFor(annotation = EnableAutoConfiguration.class)
Class<?>[] exclude() default {}; //动态排除一些类
@AliasFor(annotation = EnableAutoConfiguration.class)
String[] excludeName() default {};
@AliasFor(annotation = ComponentScan.class, attribute = "basePackages")
String[] scanBasePackages() default {};
@AliasFor(annotation = Configuration.class)
boolean proxyBeanMethods() default true;
}
发现@SpringBootApplication这个复合注解主要是由4个注解组成,继续往下:
@SpringBootConfiguration注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration(proxyBeanMethods = false) //配置类
public @interface SpringBootConfiguration {
@AliasFor(annotation = Configuration.class)
boolean proxyBeanMethods() default true;
}
@EnableAutoConfiguration注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class) //给容器注册一些Bean组件
public @interface EnableAutoConfiguration {
String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
Class<?>[] exclude() default {}; //动态的体现:排除那些组件,不要导入
String[] excludeName() default {};
}
其注解引用关系图:
不难发现@SpringBootApplication上层主要由三个注解组成:
-
@ComponentScan
-
@EnableAutoConfiguration
-
@Configuration
其实我们只使用这三个注解,不需要@SpringBootApplication注解也可以启动项目,只是应用三个注解相对比较繁琐,所以直接使用一个复合注解解决了所有的烦恼。或者只要喜欢干脆给@SpringBootApplication改个名字@NewSpring也是OK的。
底层主要也是三个基础注解组成:
-
@ImportSelector
-
@ImportBeanDefinitonRegistrar
-
@Component
从底层的这几个注解的作用就可以看出注解的功能是给容器添加Bean组件,这还是Spring底层的几个注解,验证了我们前面所说,SpringBoot是对Spring的封装,本质上也是在帮我们做IOC和DI。下面看一下每个注解都干了什么工作。
3.1、@Configuration - 配置类
在spring3之前的Bean配置都是
基于applicationContext.
xml
文件方式,它是spirng3.0
spring
就支持了两种
bean
的配置方式,一种是基于applicationContext.
xml
文件方式,另一种就是
JavaConfig
形式,
任何一个标注了
@Configuration注解
的
java
类都是一个
JavaConfig
配置类,这种
无配置化的设计减少了xml文件的配置。因为SpringBoot本质上就是一个Spring应用,通过这个注解来加载IOC容器的配置是很正常的,所以在启动类上标注了@
Configuration,意味着它其实也是一个
IOC
容器的配置类。
而在这个配置类中,任何一个标注了@Bean的方法,它的返回值都会作为Bean定义注册到spring的ioc容器map中,方法名默认为这个bean的id;
实例演示:
@Configuration //表示这是一个配置类,将会把Person注入到容器中,取代之前的xml配置
public class MyConfig {
@Bean //托管到spring容器中,beanid=person,每次用的时候通过DL返回一个单例实例给用户;
public Person person() {
System.out.println("给容器中添加了person组件");
return new Person();
}
}
3.2、@ComponentScan
作用:
-
ComponentScan默认会扫描当前package下的所有加了相关注解标识的类到IOC容器中( @Component @Repository @Service @Controller), 把bean交给 Spring 容器托管;
-
如果需要扫描其他的子包,配置路径即可;
@ComponentScan(value = "com.king.Config", includeFilters = {
@ComponentScan.Filter(type = FilterType.CUSTOM, classes = {KingServiceFilter.class})},
useDefaultFilters = false) //默认为true
public class Cap2MainConfig {
public Person person02() {
return new Person("king", 19);
}
}
ComponentScan这个注解是大家接触的最多的了,相当于xml配置文件中的<context:component-scan>。它的主要作用就是扫描指定路径下的标示了需要装配的类,自动装配到spring的ioc容器中。
<context:component-scan base-package="com.*"></context:component-scan>
3.3、@EnableAutoConfiguration
作用:帮助
springboot
应用把所有符合条件的
@Configuration
配置都自动加载注册到
容器map当中
.
其实Spring3.1开始,java也支持了@Enablexx,例如@EnableWebMvc @EnableCache等以注解的形式来配置我们的Bean,省去了繁杂的xml文件配置,它是
JavaConfig框架上更进一步的完善,使得用户在使用spring相关框架时,避免配置大量的代码从而降低使用难度,其实EnableAutoConfiguration并不是一个全新的东西,
只是它的出现对
springboot
来说意义重大;
比如以前常见的Enable注解:
-
@ EnableWebMvc(这个注解引入了MVC框架在spring应用中所需要的所有的bean)
-
@ EnableScheduling, 开启计划任务的支持:
-
@ EnableAspectJAutoProcy 开启aop功能
-
@ EnableTransactionalManagement 事务
-
@ EnableAsync 开启异步注解
设计思想:通过查看源码就会发现这里的
@
EnableAutoConfiguration设计思想有一个套路,大抵相似。就是
每个涉及到@
Enablexxx
开头的注解,都会有一个
@Import
的注解,另外
注册几个相关的Bean前置或者后置处理器BeanPostProcessor,
在tomcat启动的时候,JVM会扫描jar包class文件,将相关加了注解的Bean
根据条件或者
BeanPostProcessor后置处理器动态代理增强后,
自动添加到map容器中,完成IOC功能。
下面看一下它的具体实现:
@EnableAutoConfiguration注解的实现
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import({ EnableAutoConfigurationImportSelector.class, //向容器中导入组件EnableAutoConfigurationImportSelector
AutoConfigurationPackages.Registrar.class })//向容器中导入组件AutoConfigurationPackages.Registrar
public @interface EnableAutoConfiguration {
/**
* Exclude specific auto-configuration classes such that they will never be applied.
*/
Class<?>[] exclude() default {};
}
3.3.1、@Import注解
为了更好的了解@EnableAutoConfiguration注解,这里就需要了解
Import + (ImportSelector/
ImportBeanDefinitionRegistor)的组合功能。
作用:通过条件ConditionBean,自动注入相关的组件bean,帮助springboot应用把所有符合条件的@Configuration配置类都加载到spring容器中。
联想到xml下有一个
<import resource/>形式的注解,就明白它的作用了。
<import resource="classpath:application-beans.xml"/>
<import resource="classpath:application-common.xml"/>
<import resource="classpath:application-db.xml" />
<import resource="classpath:application-aop.xml" />
Import就是把多个分开的容器配置合并在一个配置中。在 javaConf中所表达的意义是一样的,可以导入不同子包的Bean内容;
Import使用示例:
@Configuration //Import导入其他包相关的Bean
@Import({KingImportSelector.class, KingImportBeanDefinitionRegistrar.class, KingFactoryBean.class})
public class KingMainConfig {
@Bean("zhouzhou")
public Person person() {
return new Person("zhouzhou", 28);
}
}
下面来看@EnableAutoConfiguration的两个重要的类
-
EnableAutoConfigurationImportSelector
-
AutoConfigurationPackage.Registrar
3.3.2、Import+ ImportSelector
点开
EnableAutoConfigurationImportSelector源码发现其中主要使用了
Import+ ImportSelector来给容器添加一些组件。
思考:普通bean的注入很简单,但是缺少灵活性控制,能不能
根据条件或者上下文来控制bean的加载呢?或者是批量的加载呢?
这里就用到了
ImportSelector和ImportBeanDefinitionRegistrar,让@Enablexxx注解不仅仅可以像前面演示的案例一样很简单的实现多个Configuration的整合,还可以实现一些复杂的场景,比如可以
根据上下文条件来激活不同的
Bean动态注入。实现示例如下:
-
1、实现 ImportSelector接口根据条件选择进行批量bean的 动态加载,SelectImports返回的数组(类的全类名)都会被加载到spring容器中;;
-
2、实现 ImportBeanDefinitionRegistrar接口进行 动态选择注入bean;
实例一:使用Import+ ImportSelector给容器注册Person和King实例:
@Service //通过实现ImportSelector自定义逻辑需要注册的组件名称Person和King
public class KingImportSelector implements ImportSelector {
//返回值就是导入到容器的全类名
//annotationMetadata 获取标注了Import及其他@Service注解的所有类的元信息
public String[] selectImports(AnnotationMetadata annotationMetadata) {
//这里可进行业务逻辑,选择性的动态加载Bean实例;
System.out.println(" 使用ImportSelector selectImoports 来注册组件。。");
//注意:如果这里的类写成可配置文件,这不就是AutoConfiguration的原理吗?
return new String[]{"com.beijing.entity..Person", "com.beijing.entity.King"};
}
}
3.3.3、Import+ ImportBeanDefinitionRegitrar
实现
ImportBeanDefinitionRegistrar的registerBeanDefinitions给容器注册一个bean。
实例二:使用Import+ImportBeanDefinitionRegitrar给容器注册Person实例:
@Service //通过实现ImportSelector自定义逻辑需要注册的组件名称Person和King
public class KingImportBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar {
/*
* annotationMetadata 当前类的注解元信息
* beanDefinitionRegistry beanDefinitionRegistry注册类
* 把所需要注册的类通过 BeanDefinitionsRegister.registerBeanDefinitions来手工定义注册
*/
public void registerBeanDefinitions(AnnotationMetadata annoMetadata, BeanDefinition definitionRegistry) {
boolean definitionBeanKing = beanDefinitionRegistry.containsBeanDefinition("com.beijing.entity.King");
//这里可进行业务逻辑,选择性的动态加载Bean实例;
//todo 通过条件判断是否存在Bean King后开始注册bean Person
if (definitionBeanKing) {
if(true){
RootBeanDefinition beanDefinition = new RootBeanDefinition(Person.class);
System.out.println("ImportBeanDefinitionRegistrar: registerBeanDefinition被调用 ");
//开始加载注册Bean实例;
beanDefinitionRegistry.registerBeanDefinition("Person",beanDefinition);
}
}
}
3.3.4、给容器注册一个Bean
这里可以小结一下,容器中添加注册组件Bean的方式有那些?
-
方式一:@Bean【导入第三方的类核组件】或者xm配置 <bean id=person calss= "com.king.Person">;
-
方式二:@ComponentScan包扫描+组件的标注注解(@Controller,@Service @Reponsitory @Component)一般都是我们自己写的业务类;
-
方式三:@Import:快速给容器导入一个组件;
-
(1). @import要导入到容器中的组件,容器会自动注册这个组件,bean的id为全类名;
-
(2). ImportSelector:是一个接口,返回需要导入到容器的组件的全类名数组;
-
(3). ImportBeanDefinitionRegistrar:可以手动添加组件到IOC容器,所有的Bean注册都是使用BeanDefinitionRegistry;
-
-
方式四:使用spring提供的FactoryBean的getObject方法来向容器中注入一个Bean;
从这里也可以看出来,Spring对bean的注册一直在升级优化,从xml文件到javaConfig,再到现在的AutoConfiguration。
了解了ImportSelector和ImportBeanDefinitionRegistrar后,对于EnableAutoConfiguration的理解就容易一些了,它们可以配合@Import导入第三方提供的bean的配置类,
根据条件注册相应的bean。
以前我们是通过xml文件配置去装载Bean,但是现在我们通过@Import + ImportSelector/ImportBeanDefinitionRegistor 可以实现批量的bean的装载,
这就是EnableAutoConfiguration的作用。
4、SpringFactoriesLoader实现原理-SPI思想
EnableAutoConfigurationImportSelector的实现原理
从名字可以猜到它是基于ImportSelector来实现基于动态bean的加载功能。前面说过springboot@Enablexxx注解的工作原理,通过@Import及ImportSelector接口
实现bean的批量加载,那么可以猜想这里的实现原理也是一样的。那么它到底是如何自动加载的呢?
为什么在resource/META-INF/spring.factories中加入如下内容就OK了呢?
这里spring提供了一种扩展点机制,设置固定的文件路径
classpath/META-INF/spring.factories ,通过工具类
SpringFactoreiesLoader去得到文件中注解key对应的所有的value值,然后在tomcat启动的时候,将需要加载的value实例类class文件
加载注册到spring容器中。
SpringFactoriesLoader这个类的使用其实和java中的SPI机制的原理是一样的,不过它比SPI更好的点在于不会一次性加载所有的类,而是根据key进行加载。满足规则如下:
-
文件目录结构一致:classpath/META-INF
-
文件名一致:spring.factories
-
基于key全路径名的类存在且符合当前的加载;
加载流程如下:
-
(1). SpringFactoriesLoader扫描spring.factories 文件中,拿到需要加载的class类全路径;
-
(2). 查询容器中类是否加载,如果没有,通过类加载器ClassLoader将Bean的字节码加载到内存;
-
(3). 通过 反射创建对象-赋值-初始化,加载对应的类到Spring IOC容器map中;
-
(4). 返回给用户使用;
这也是非常经典的面向接口编程思想,让应用和实现解耦,大大提高了程序的可扩展性和灵活性。
同样的SPI思想还应用在:
1. Java的SPI机制,tomcat在启动的时候
ServiceLoader会扫描
META-INF/service路径下依赖的jar包,将相关的bean注入到spring 中托管;
2、servlet中运行时插件机制:
:tomcat启动的时候,利用运行时插件机制来加载感兴趣的 filter/listener/servlet Bean组件信息,首先创建目录:META-INF/services,然后创建一个javax.servlet.ServletContainerInitializer文件,配置需要加载Bean信息。
3、dubbo的SPI机制,在META-INF/dubbo或者service下配置需要加载的key=感兴趣的bean,这样系统的在启动的时候就会优先加载相应的Bean.
5、条件过滤@Conditional
自动装载如何进行Bean的筛选呢?
这里还是固定的模式,只需要在classpath下建立一个META-INF/spring-autoconfigure-metadata.properties文件,将条件Bean的加载条件配置在这个文件里面即可。
在分析AutoConfigurationImportSelector的源码时会发现,tomcat会先扫描
spring-autoconfiguration-metadata.properties文件,最后在扫描spring.factories对应的类时,
会结合前面的元数据进行过滤。
还有一些条件过滤注解Conditions:
-
@ConditionalOnBean //在存在某个 bean 的时候
-
@ConditionalOnMissingBean //不存在某个 bean 的时候
-
@ConditionalOnClass //当前 classpath 可以找到某个类型的类时
-
@ConditionalOnMissingClass //当前 classpath 不可以找到某个类型的类 时
-
@ConditionalOnResource //当前 classpath 是否存在某个资源文件
-
@ConditionalOnProperty //当前 jvm 是否包含某个系统属性为某个值
-
@ConditionalOnWebApplication //当前 spring context 是否是 web 应用程序
思考:
为什么需要对Bean的加载进行过滤呢?
原因是很多的@Configuration其实是依托于其他的框架来加载的,如果当前的classpath环境下没有相关联的依赖,则意味着这些类没必要进行加载,所以通过这种条件过滤可以有效的减少@Configuration类的数量从而降低SpringBoot的启动时间,也节约了内存。
6、SPI机制自动加载源码解析
具体实现源码如下:
//批量动态加载bean - 主要是扫描META-INF/spring.factories下的文件:
public class AutoConfigurationImportSelector implements ImportSelector, BeanClassLoaderAware,
ResourceLoaderAware, BeanFactoryAware, EnvironmentAware, Ordered {
@Override
public String[] selectImports(AnnotationMetadata annotationMetadata) {
//第一步:加载类元信息配置文件,主要进行加载类的过滤和筛选
//条件过滤加载:META-INF/spring-autoconfigure-metadata.properties
AutoConfigurationMetadata autoConfigurationMetadata = AutoConfigurationMetadataLoader
.loadMetadata(this.beanClassLoader);
//第二步:通过SPI机制加载获取配置类的实体文件,META-INF/spring.factories文件
//根据key扫描: key = org.springframework.boot.autoconfigure.EnableAutoConfiguration
//自动加载配置类全路径名 value = com.beijing.starter.config.KingInfoAutoConfiguration
AutoConfigurationEntry autoConfigurationEntry = getAutoConfigurationEntry(autoConfigurationMetadata,
annotationMetadata);
//第三步:这里面包含了key对应的所有的class类名,它们将会被加载到内存中;
return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations());
}
(1)加载类元信息配置文件 : 该path下的配置文件
protected static final String PATH = "META-INF/spring-autoconfigure-metadata.properties";
static AutoConfigurationMetadata loadMetadata(ClassLoader classLoader) {
return loadMetadata(classLoader, PATH);
}
(2): 获取需要自动加载的实例类class文件;
protected AutoConfigurationEntry getAutoConfigurationEntry(AutoConfigurationMetadata autoConfigurationMetadata,
AnnotationMetadata annotationMetadata) {
if (!isEnabled(annotationMetadata)) {
return EMPTY_ENTRY;
}
AnnotationAttributes attributes = getAttributes(annotationMetadata);
//获取配置的value的值:需要加载的class类路径;
List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
//过滤重复的bean,value是会重复的;
configurations = removeDuplicates(configurations);
//获取注解里排除掉的value值;
Set<String> exclusions = getExclusions(annotationMetadata, attributes);
checkExcludedClasses(configurations, exclusions);
//删除注解里排除掉的value属性值,不加载的类;
configurations.removeAll(exclusions);
configurations = filter(configurations, autoConfigurationMetadata);
fireAutoConfigurationImportEvents(configurations, exclusions);
return new AutoConfigurationEntry(configurations, exclusions);
}
(3)通过SPI机制SpringFactoreiesLoader 动态批量加载满足条件的bean,实现bean的加载,它会加载classpath下:META-INF/spring.factories 中key对应的所有的class文件;
protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
List<String> configurations = SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(),
getBeanClassLoader());
Assert.notEmpty(configurations, "No auto configuration classes found in META-INF/spring.factories. If you "
+ "are using a custom packaging, make sure that file is correct.");
return configurations;
}
AutoConfigurationPackage.Registrar
AutoConfigurationPackage.Registrar其实也是同样的原理,通过实现ImportBeanDefinitionRegistrar接口,按条件自动向容器中注册一个bean实例;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(AutoConfigurationPackages.Registrar.class)
public @interface AutoConfigurationPackage {
}
//方式二:扫描jar包获取加有相关注解的类注册到ioc容器
static class Registrar implements ImportBeanDefinitionRegistrar, DeterminableImports {
@Override
public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
register(registry, new PackageImport(metadata).getPackageName());
}
@Override
public Set<Object> determineImports(AnnotationMetadata metadata) {
return Collections.singleton(new PackageImport(metadata));
}
}
至此,我们就初步简单的分析了下@SpringBootApplication注解的自动配置装载的工作内容及流程。虽然花了很多的时间,但是明白后的感觉很舒服。
7、小结
这里主要学习了下SpringBoot的AutoConfiguration自动装配的功能,其实所有的操作目的都是通过配置类的注解方式把Bean自动加载注册交给spring IOC容器去托管。除此之外
还有启动器Starter的实现也是其一大特色,另外还有基于JMX实现的Actuator监控器,SpringBoot CCL 命令终端等还有很多,需要慢慢使用慢慢体会和学习,这里就不展开了。
可以体会到,SpringBoot其实并不是完全是一个全新的东西,它只是对Spring的一个更好的封装,将看似散乱繁琐的工作进一步抽象规范化,进行了一个整合,让我们可以从配置的丛林里解放出来,站在高处欣赏和使用它。比如它的类加载和容器的启动流程及工作原理还是和spring一样,从refreash开始,并没有什么不同。
水滴石穿,积少成多。学习笔记,内容简单,用于复习,梳理巩固。
参考文件: