Springboot-starter

发布时间 2023-08-21 14:29:38作者: primaryC

1. Spring 手动注入和自动注入

通常情况下,系统中类和类之间是有依赖关系的,如果一个类对外提供的功能需要通过调用其他类的方法来实现的时候,说明这两个类之间存在依赖关系。

example:

public class UserService{
    public void insert(UserModel model){
        //插入用户信息
    }
}
public class UserController{
    private UserService userService;
    public void insert(UserModel model){
        this.userService.insert(model);
    }
}

UserController 中的 insert 方法中需要调用 userService 的 insert 方法,说明 UserController 依赖于 UserService,如果 userService 不存在,此时 UserControler 无法对外提供 insert 操作。

之前我们的做法通常有两种:
  1、通过构造器设置依赖对象
  2、通过set方法设置依赖对象

上面这些操作,将被依赖的对象设置到依赖的对象中,spring 容器内部都提供了支持,这个在 spirng 中叫做依赖注入。

1. 手动注入

手动注入是指采用硬编码的方式来配置注入的对象,比如通过构造器注入或者set方法注入。

example:

<bean id="diByConstructorParamIndex" class="com.javacode2018.lesson001.demo5.UserModel">
    <constructor-arg index="0" value="路人甲Java"/>
    <constructor-arg index="1" value="上海市"/>
</bean>

2. 自动注入

自动注入是采用约定大约配置的方式来实现的,程序和spring容器之间约定好,遵守某一种都认同的规则,来实现自动注入。

xml中可以在bean元素中通过autowire属性来设置自动注入的方式:
  1、byteName:按照名称进行注入
  2、byType:按类型进行注入
  3、constructor:按照构造方法进行注入
  4、default:默认注入方式

example:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans-4.3.xsd">
    <bean id="service1" class="com.javacode2018.lesson001.demo6.DiAutowireByName$Service1">
        <property name="desc" value="service1"/>
    </bean>
    <bean id="service2" class="com.javacode2018.lesson001.demo6.DiAutowireByName$Service2">
        <property name="desc" value="service2"/>
    </bean>
    <bean id="service2-1" class="com.javacode2018.lesson001.demo6.DiAutowireByName$Service2">
        <property name="desc" value="service2-1"/>
    </bean>
    
<!-- autowire:byName 配置按照name进行自动注入,寻找和 DiAutowireByName 类属性名相同的 bean 注入 -->
    <bean id="diAutowireByName1" class="com.javacode2018.lesson001.demo6.DiAutowireByName" autowire="byName"/>
    
</beans>

按照我的理解,自动注入就是一个创建一个 bean 的时候,某个属性需要从 spring 容器已经有的组件中按照名称、类型等获取对应的 bean,赋值给对应的属性。与手动注入的区别就是,这个属性的值只要从 spring 容器获取就好,手动注入需要硬编码。对应注解的话,就是 @Autowide 神器。

2. 自动装配

自动装配是Spring IoC容器的一个核心特性,它利用依赖注入(Dependency Injection)的方式,在声明Spring bean时通过一定的规则自动解析并自动注入其他bean的依赖关系。也就是说,Spring IoC容器会根据类型(Type)或名称(Name)等规则,自动在容器中查找匹配的bean,并将其自动注入到需要该依赖的bean中。

spring的自动装配需要从两个角度来实现,或者说是两个操作:
  1、组件扫描(component scanning):spring会自动发现应用上下文中所创建的bean;
  2、自动装配(autowiring):spring自动满足bean之间的依赖,也就是我们说的IoC/DI;

通常使用注解来完成,一般不推荐使用 xml 配置文件。Spring 自动装配可以通过一系列的注解实现。

1. SpringBoot 是如何实现自动装配的:

SpringBoot 的自动装配,是在 Spring 自动装配的基础上,通过 SPI 的方式,做了进一步优化。

SpringBoot 在启动时会扫描外部引用 jar 包中的META-INF/spring.factories文件,将文件中配置的类型信息加载到 Spring 容器(此处涉及到 JVM 类加载机制与 Spring 的容器知识),并执行类中定义的各种操作。对于外部 jar 来说,只需要按照 SpringBoot 定义的标准,就能将自己的功能装置进 SpringBoot。

2. 源码分析

1. 核心注解 SpringBootApplication

@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) })
public @interface SpringBootApplication {}

SpringBootApplication 注解是一个复合注解,主要有 @SpringBootConfiguration,@EnableAutoConfiguration,@ComponencScan

2. @SpringBootConfiguration

@SpringBootConfiguration 注解是 Spring Boot 特有的注解,它是对 Spring Framework 中的 @Configuration 注解的特化,专门用于 Spring Boot 应用程序。在大多数情况下,我们可以直接使用 @Configuration 注解来代替 @SpringBootConfiguration 注解,它们具有相同的效果。

3. @ComponentScan

扫描包下的类中添加了@Component (@Service,@Controller,@Repostory,@RestController)注解的类 ,并添加的到spring的容器中。

  • TypeExcludeFilter.class: 是Spring框架中用于排除特定类型组件的过滤器类,当组件扫描过程中遇到被TypeExcludeFilter排除的类型时,这些类型的组件将不会被注册为bean,从而被忽略。。
  • AutoConfigurationExcludeFilter.class: 使用一些规则和条件来判断哪些自动配置类应该被排除。这些规则和条件通常是基于应用程序的配置、已经显式声明的bean以及其他相关因素。通过排除某些自动配置类,可以控制应用程序上下文中的bean创建和配置,以满足特定需求或避免冲突。

4. @EnableAutoConfiguration

实现自动装配的核心注解, 通常被放置在应用程序的主配置类上(通常是带有 @SpringBootApplication 注解的类),以启用自动配置。当应用程序启动时,Spring Boot 会自动扫描类路径上的各种配置类和依赖,根据约定和条件进行自动配置。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {
}

5. @AutoConfigurationPackage

用于指示 Spring Boot 自动配置的基础包。在 Spring Boot 应用程序中,自动配置是通过扫描特定包及其子包下的组件来实现的。@AutoConfigurationPackage 注解用于指定这些组件扫描的基础包。它通常被放置在应用程序的主配置类上,以指示 Spring Boot 自动配置的基础包路径。当使用 @AutoConfigurationPackage 注解时,它会将注解所在类所在的包路径作为自动配置的基础包。这样,Spring Boot 将会扫描这个基础包及其子包下的组件,并根据约定和条件进行自动配置。

6. @Import

通过使用 @Import 注解,可以将其他的配置类或组件引入到当前的配置类中,以扩展配置或添加额外的组件。被引入的配置类或组件将会被 Spring 容器管理,并参与应用程序的装配和协作。被引入的配置类或组件类通常需要使用合适的注解进行标记,例如 @Configuration、@Component 等。这样,它们才能被正确地识别和管理。

7.AutoConfigurationImportSelector.class

自动装配核心功能的实现实际是通过 AutoConfigurationImportSelector类, AutoConfigurationImportSelector 类实现了 ImportSelector接口,也就实现了这个接口中的 selectImports 方法,该方法主要用于获取所有符合条件的类的全限定类名,这些类需要被加载到 IoC 容器中。

1. selectImports()

@Override
public String[] selectImports(AnnotationMetadata annotationMetadata) {
    // 判断自动装配开关是否打开, 可在 application.properties 或 application.yml 中设置
    if (!this.isEnabled(annotationMetadata)) {
        return NO_IMPORTS;
    } else {
        // 加载自动配置元数据,返回一个 AutoConfigurationMetadata 对象。该对象包含了自动配置类的条件和属性信息。这些信息来自spring-boot-autoconfigure.jar包下
        AutoConfigurationMetadata autoConfigurationMetadata = AutoConfigurationMetadataLoader.loadMetadata(this.beanClassLoader);
        // 传入自动配置元数据和当前被注解标记的类的元数据信息,获取一个 AutoConfigurationEntry 对象。AutoConfigurationEntry 对象包含了根据条件筛选后的自动配置类的信息。
        AutoConfigurationEntry autoConfigurationEntry = this.getAutoConfigurationEntry(autoConfigurationMetadata, annotationMetadata);
        // 获取筛选后的自动配置类的全限定类名
        return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations());
    }
}

接收一个 AnnotationMetadata 参数,用于获取当前被注解标记的类的元数据信息。根据元数据信息,该方法会返回一个字符串数组,表示要导入的自动配置类的全限定类名。

2. getAutoConfigurationEntry()

protected AutoConfigurationEntry getAutoConfigurationEntry(AutoConfigurationMetadata autoConfigurationMetadata, AnnotationMetadata annotationMetadata) {
    // 判断自动装配开关是否打开, 可在 application.properties 或 application.yml 中设置
    if (!this.isEnabled(annotationMetadata)) {
        return EMPTY_ENTRY;
    } else {
        // 获取注解元数据的属性信息,并保存到 attributes 变量中。
        AnnotationAttributes attributes = this.getAttributes(annotationMetadata);
        // 根据注解元数据和属性信息获取候选的自动配置类的全限定类名列表,并保存到 configurations 变量中。从 META-INF/spring.factories 下找
        List<String> configurations = this.getCandidateConfigurations(annotationMetadata, attributes);
        // 去除重复的自动配置类
        configurations = this.removeDuplicates(configurations);
        // 获取需要排除的自动配置类的全限定类名列表
        Set<String> exclusions = this.getExclusions(annotationMetadata, attributes);
        // 检查排除的自动配置类是否存在于候选的自动配置类列表中, 如果排除类存在且不在候选的自动配置类列表中, 则为无效的排除类, 抛出异常
        this.checkExcludedClasses(configurations, exclusions);
        // 从 configurations 列表中移除 exclusions 列表中的自动配置类。
        configurations.removeAll(exclusions);
        // 根据自动配置元数据对 configurations 列表进行过滤,保留满足条件的自动配置类。
        configurations = this.filter(configurations, autoConfigurationMetadata);
        // 触发自动配置导入的事件
        this.fireAutoConfigurationImportEvents(configurations, exclusions);
        // 返回最终的自动配置类列表 configurations 和排除的自动配置类列表 exclusions。
        return new AutoConfigurationEntry(configurations, exclusions);
    }
}

3. getCandidateConfigurations()

protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
  // 使用 SpringFactoriesLoader 加载指定类加载器下 META-INF/spring.factories 文件中的自动配置类配置。
  // getSpringFactoriesLoaderFactoryClass() 方法用于获取 SpringFactoriesLoader 的工厂类。该工厂类是一个用于加载 META-INF/spring.factories 文件中配置的实现类。
  // this.getBeanClassLoader() 方法用于获取当前使用的类加载器,以便在加载自动配置类时使用。
  List<String> configurations = SpringFactoriesLoader.loadFactoryNames(this.getSpringFactoriesLoaderFactoryClass(), this.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;
}

用于获取候选的自动配置类列表,这些类是通过读取 META-INF/spring.factories 文件中的配置获取的。

4. loadFactoryNames()

public static List<String> loadFactoryNames(Class<?> factoryType, @Nullable ClassLoader classLoader) {
	String factoryTypeName = factoryType.getName();
	return loadSpringFactories(classLoader).getOrDefault(factoryTypeName, Collections.emptyList());
}

用于加载指定工厂类的工厂名称列表,这些工厂名称是通过读取 META-INF/spring.factories 文件中的配置获取的。

5. loadSpringFactories()

 
    private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
      // 从缓存中获取与给定类加载器相关联的配置映射 result。
      MultiValueMap<String, String> result = (MultiValueMap)cache.get(classLoader);
      // 如果缓存中存在配置映射,则直接返回该映射
      if (result != null) {
          return result;
      } else {
          try {
              // 根据给定的类加载器 classLoader 获取 META-INF/spring.factories 文件的 URL 枚举,如果类加载器为 null,则使用系统类加载器获取 URL 枚举。
              Enumeration<URL> urls = classLoader != null ? classLoader.getResources("META-INF/spring.factories") : ClassLoader.getSystemResources("META-INF/spring.factories");
              // 保存加载到的配置映射,其中键是工厂类的完全限定类名,值是工厂类的名称列表。
              MultiValueMap<String, String> result = new LinkedMultiValueMap();
              // 遍历 URL 枚举中的每个 URL, 每个URL为一个 spring.factories 文件
              while(urls.hasMoreElements()) {
                  URL url = (URL)urls.nextElement();
                  // 读取该 URL 对应的资源文件。
                  UrlResource resource = new UrlResource(url);
                  // 加载资源文件中的属性
                  Properties properties = PropertiesLoaderUtils.loadProperties(resource);
                  // 遍历 properties 文件中的每一对键值对
                  Iterator var6 = properties.entrySet().iterator();

                  while(var6.hasNext()) {
                      Map.Entry<?, ?> entry = (Map.Entry)var6.next();
                      // 对于每个键值对,将键转换为工厂类的完全限定类名,并将值转换为工厂类的名称列表。
                      // (因为多个 properties文件中可能含有相同的key, 所以要采用 MultiValueMap 的数据结构)
                      String factoryClassName = ((String)entry.getKey()).trim();
                      // 将每个文件中的 key 值转换为字符串数组
                      String[] var9 = StringUtils.commaDelimitedListToStringArray((String)entry.getValue());
                      int var10 = var9.length;

                      for(int var11 = 0; var11 < var10; ++var11) {
                          String factoryName = var9[var11];
                          // 以列表的形式向 key 的 value 中添加值
                          result.add(factoryClassName, factoryName.trim());
                      }
                  }
              }
              // 将 result 映射添加到缓存中,与给定的类加载器关联。
              cache.put(classLoader, result);
              return result;
          } catch (IOException var13) {
              throw new IllegalArgumentException("Unable to load factories from location [META-INF/spring.factories]", var13);
          }
      }
}

该方法用于加载 META-INF/spring.factories 文件中的配置,并返回一个映射,其中键是工厂类的完全限定类名,值是工厂类的名称列表。

总结: Spring Boot 通过@EnableAutoConfiguration开启自动装配,通过 SpringFactoriesLoader 最终加载META-INF/spring.factories中的自动配置类实现自动装配,自动配置类其实就是通过@Conditional按需加载的配置类,想要其生效必须引入spring-boot-starter-xxx包实现起步依赖。

3. 普通 jar 包依赖

1. 创建一个普通的 maven 模块

2. pom 依赖

<dependencies>
  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-autoconfigure</artifactId>
  </dependency>
</dependencies>

spring-boot-start-web 中也是引用了这个依赖,因为我们这里是普通依赖包,不需要启动,所以用这个就够了。

3. 普通类

TestUtilTestDao 都是普通的类,没有使用 @Component 等注解,所以不会注入。

4. 使用 spring 注解修饰的类

com.yanq.core.service 包下的 CoreService:

package com.yanq.core.service;

import org.springframework.stereotype.Service;

/**
 * @author admin
 */
@Service
public class CoreService {

    public void testMethod(){
        System.out.println("core 包下的业务类测试");
    }
}

com.yanq.normal.service 包下的 TestService:

package com.yanq.normal.service;

import org.springframework.stereotype.Service;

/**
 * @author admin
 */
@Service
public class TestService {

    public void testMethod(){
        System.out.println("业务类测试");
    }
}

这两个类不同的地方,就在于所处的包不一样。

5. 其他模块引用我们刚刚的依赖

Pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!--引入我们自己的依赖包-->
    <dependency>
        <groupId>org.example</groupId>
        <artifactId>normal</artifactId>
        <version>${project.version}</version>
    </dependency>
</dependencies>

6. 引用依赖包中的资源

@RestController
public class TestController {

    @Autowired
    private TestService testService;

    @Autowired
    private CoreService coreService;

    @PostConstruct
    public void test(){
        //依赖包中普通类
        new TestDao().toString();
        //依赖包中静态类
        TestUtil.msg();
        //com.yanq.core.service 包下的组件
        coreService.testMethod();
        //com.yanq.normal.service 包下的组件
        testService.testMethod();
    }
}

7. 整个启动类

@SpringBootApplication
public class CoreMain {
    public static ConfigurableApplicationContext ac;

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

此时启动会发现启动异常:

因为找不到 com.yanq.normal.service 包下的 TestService 组件。这个很好理解,因为 @SpringBootApplication 注解中的 @ComponentScan 只会扫描当前类所在包及子包的组件。所以我们可以通过 @Import 来导入这个:

@SpringBootApplication
@Import({TestService.class})
public class CoreMain {
    public static ConfigurableApplicationContext ac;

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

或者使用 @Bean 强行 new 一个,再或者 @ComponentScan({"com.yanq"}) 让 springboot 扫描这个包,只要想办法把这个组件注入到 spring 容器中就行。如果需要注入的组件特别多的时候,就需要大量的配置,此时我们的 starter 闪亮登场。

4. 手撸一个 starter

在我看来,starter 就是一个特殊依赖包,这个依赖包遵循 springboot 约定俗称的规则,在依赖包中定义好哪些可用组件,能够无配置或者少量配置的提供一种功能。

example:创建一个连接 ftp 的 starter

1. 整体结构

2. pom 依赖

<!--自动装配的依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-autoconfigure</artifactId>
</dependency>

<!--springboot 日志依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-logging</artifactId>
</dependency>

<!--网络连接的包-->
<dependency>
    <groupId>commons-net</groupId>
    <artifactId>commons-net</artifactId>
    <version>3.3</version>
</dependency>

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.12</version>
</dependency>

starter 本质上是一个依赖包,不需要 spring-boot 打包插件

3. 在资源目录下创建一个 ftp.properties

spring.ftp.ip=192.168.0.119
spring.ftp.port=21
spring.ftp.username=ftpAdmin
spring.ftp.password=wjn18

这里就是 starter 少量配置或者不配置的原因,因为 starter 依赖包中给了默认的配置,如果引入这个依赖的 springboot 不添加此项配置,那么就用默认的。如果 springboot 在 application.yml 等配置文件中添加相同配置,由于优先级问题,要高过这个使用 @PropertyResource 引用的配置文件,就会使用 springboot 项目的配置文件。

4. 配置类加载配置文件

/**
 * @author gx
 */
@Component
@Data
@Slf4j
@PropertySource({"classpath:ftp.properties"})
public class FtpConfig {
    @Value("${spring.ftp.ip}")
    private String ip;

    @Value("${spring.ftp.port}")
    private Integer port;

    @Value("${spring.ftp.username}")
    private String username;

    @Value("${spring.ftp.password}")
    private String password;
}

5. ftp 操作的工具类(简易版)

import com.yanq.ftp.config.FtpConfig;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.net.ftp.FTPReply;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @author ftp 工具类
 */
@Slf4j
public class FtpUtils {
    /**
     * 获取 FTPClient
     * @return FTPClient
     */
    private static FTPClient getFTPClient(FtpConfig ftpConfig) {
        FTPClient ftpClient = connect(ftpConfig);
        if (ftpClient == null || !FTPReply.isPositiveCompletion(ftpClient.getReplyCode())) {
            throw new RuntimeException("ftp客户端异常");
        }
        return ftpClient;
    }

    /**
     * 获取FTP某一特定目录下的所有文件名称
     *
     * @param ftpDirPath FTP上的目标文件路径
     */
    public static List<String> getFileNameList(FtpConfig ftpConfig, String ftpDirPath) {
        FTPClient ftpClient = getFTPClient(ftpConfig);
        try {
            // 通过提供的文件路径获取 FTPFile 文件列表
            // FTPFile[] ftpFiles = ftpClient.listFiles(ftpDirPath, FTPFile::isFile); // 只获取文件
            // FTPFile[] ftpFiles = ftpClient.listFiles(ftpDirPath, FTPFile::isDirectory); // 只获取目录
            FTPFile[] ftpFiles = ftpClient.listFiles(ftpDirPath);
            if (ftpFiles != null && ftpFiles.length > 0) {
                return Arrays.stream(ftpFiles).map(FTPFile::getName).collect(Collectors.toList());
            }
            log.error(String.format("路径有误,或目录【%s】为空", ftpDirPath));
        } catch (IOException e) {
            log.error("文件获取异常:", e);
        } finally {
            closeConnect(ftpClient);
        }
        return null;
    }

    /**
     * 断开 FTP 服务
     */
    public static void closeConnect(FTPClient ftpClient) {
        log.warn("关闭ftp服务器");
        try {
            if (ftpClient != null) {
                ftpClient.logout();
                ftpClient.disconnect();
            }
        } catch (Exception e) {
            log.error("关闭 ftp 异常:{}!", e.getMessage());
        }
    }

    /**
     * 连接 ftp
     */
    public static FTPClient connect(FtpConfig ftpConfig){
        FTPClient ftpClient = new FTPClient();
        //编码
        ftpClient.setControlEncoding("utf-8");
        //连接超时时间 10s
        ftpClient.setConnectTimeout(10_1000);

        try {
            //连接
            ftpClient.connect(ftpConfig.getIp(), ftpConfig.getPort());

            //登录
            ftpClient.login(ftpConfig.getUsername(), ftpConfig.getPassword());

            //服务器响应编码
            int replyCode = ftpClient.getReplyCode();
        } catch (IOException e) {
            log.error("ftp 连接异常:{}!地址:{},端口:{}", e.getMessage(), ftpConfig.getIp(), ftpConfig.getPort());
        }
        return ftpClient;
    }

}

也可以调整包的结构和权限修饰符来控制不让别人调用。

6. 暴露给调用者的组件

@Service
public class FtpService {

    @Autowired
    private FtpConfig ftpConfig;

    public List<String> getFileNameList(){
        return getFileNameList(null);
    }

    public List<String> getFileNameList(String path){
        if(path == null){
            path = "/";
        }
        return FtpUtils.getFileNameList(ftpConfig, path);
    }
    
}

给调用者的内容,调用者只要注入这个组件,就可以直接使用功能

7. 给一个包的扫描入口

@ComponentScan
public class FtpAutoConfigure {
}

这个类的作用就是,@ComponentScan 注解默认扫描当前包以及子包的组件,我们需要提供一个表示扫描路径的标识,在这个类中控制我们依赖包中需要扫描的组件。

8. spring.factories

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.yanq.ftp.FtpAutoConfigure

这个就是 starter 的核心了,Springboot 容器扫描所有依赖包的 META-INF/spring.factories 文件,然后可以通过这个文件的内容反射具体的类,加载到 springboot 中。我们在通过引入这个依赖的 springboot 中看到

正是我们这个文件中的内容,然后我们的依赖包被扫描就从这里开始。这样一个简单的 starter 依赖包就完成了。

5. 参考文献

SpringbootStarter:https://blog.csdn.net/weixin_56017850/article/details/124232399
自己开发一个 starter:https://blog.csdn.net/weixin_38538285/article/details/119979419
Windows ftp 简介与搭建:https://blog.csdn.net/wangmx1993328/article/details/82150961
Apache Commons Net:https://blog.csdn.net/wangmx1993328/article/details/82150290