Administrator
Published on 2025-05-11 / 39 Visits
0
0

Spring框架学习笔记

1.Spring框架

1.1.概述

Spring是一个支持快速开发Java EE应用程序的框架,它提供了一系列底层容器和基础设施,并可以和大量常用的开源框架无缝集成,Spring Framework主要包括几个模块:

  • 支持IoC和AOP的容器;
  • 支持JDBC和ORM的数据访问模块;
  • 支持声明式事务的模块;
  • 支持基于Servlet的MVC开发;
  • 支持基于Reactive的Web开发;

2.IOC容器

2.1.什么是IOC容器

什么是容器?容器是一种为某种特定组件的运行提供必要支持的一个软件环境。例如,Tomcat就是一个Servlet容器,它可以为Servlet的运行提供运行环境。类似Docker这样的软件也是一个容器,它提供了必要的Linux环境以便运行一个特定的Linux进程。

Spring的核心就是提供了一个IoC容器,它可以管理所有轻量级的JavaBean组件,提供的底层服务包括组件的生命周期管理、配置和组装服务、AOP支持,以及建立在AOP基础上的声明式事务服务等。

什么是IOC?IoC全称Inversion of Control,直译为控制反转

在传统的应用程序中控制权在程序本身,程序的控制流程完全由开发者控制,例如:我们的服务层调用数据层时,首先我们需要在服务层层通过new创建出数据层对象,然后再调用数据层对象得到数据,总结来说就是,所有的资源实例都是由我们自己的APP直接创建和控制资源实例,然后也是通过APP直接调用资源实例。

image-20250423173534990

在ioc容器控制的程序中,我们可以通过将所有对象统一创建放入IOC容器中,然后什么地方需要实例对象时在从ioc容器中获取对应的实例对象,还是刚才那个例子,当我们需要在服务层调用数据层时,我们不需要直接创建数据层实例对象,而是通过从IOC容器中获取数据层实例,这样我们就从主动控制对象实例创建变成了主动获取实例对象,在IOC容器帮助下不需要我们主动去控制创建对象实例的创建,我们将主动控制创建实例的任务交由让ioc容器帮我们完成,这个过程就叫做控制反转(IOC)。总的来说就是所有的资源实例交由IOC容器来创建和控制,然后我们再去IOC容器中获取资源实例。

image-20250423173736047

Spring的IoC容器是一个高度可扩展的无侵入容器。所谓无侵入,是指应用程序的组件无需实现Spring的特定接口,或者说,组件根本不知道自己在Spring的容器中运行。这种无侵入的设计有以下好处:

  1. 应用程序组件既可以在Spring的IoC容器中运行,也可以自己编写代码自行组装配置;
  2. 测试的时候并不依赖Spring容器,可单独进行测试,大大提高了开发效率。

2.2.装配Bean(实例化bean)

IOC容器装配Bean就是通过ioc容器加载所有的资源,将其保存在IOC容器中。ioc容器加载了哪些资源可以通过xml或者注解两种方式进行配置。

下面通过一个用户登录例子完成IOC容器装配Bean

结构如下:

image-20250423185731364

User实体类,数据库中表对应的实体类

public class User {
	private String id;
	private String name;
	private String password;

	public User(String id, String name, String password) {
		this.id = id;
		this.name = name;
		this.password = password;
	}

	public String getId() {
		return id;
	}

	public void setId(String id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	@Override
	public String toString() {
		return "User{" +
				"id='" + id + '\'' +
				", name='" + name + '\'' +
				", password='" + password + '\'' +
				'}';
	}
}

UserMapper数据层,该类用于模拟真实的数据库数据返回

public class UserMapper {
	private List<User> users = new ArrayList<>(Arrays.asList( // users:
			new User("1",  "Bob", "password"), // bob
			new User("2", "Alice", "password"), // alice
			new User("3", "Tom", "password"))); // tom

	public User getUserById(String id){
		List<User> collect = users.stream().filter(user -> user.getId().equals(id)).collect(Collectors.toList());
		return collect.get(0);
	}
	public List<User> getAll(){
		return this.users;
	}
	public Integer addUser(User user){
		this.users.add(user);
		return 1;
	}
}

UserService服务层,用于处理业务逻辑,调用数据层进行数据持久化。

public class UserService {
   UserMapper userMapper;

   public User login(String name, String password) {
      List<User> users=userMapper.getAll();
      for (User user : users) {
         if (user.getPassword().equalsIgnoreCase(password) && user.getName().equals(name)) {
            System.out.println("登录成功!");
            return user;
         }
      }
      throw new RuntimeException("登录失败!");
   }

   public User getUser(String id) {
      return userMapper.getUserById(id);
   }

   public Integer register(String name,String password) {
      UUID uuid = UUID.randomUUID();
      return userMapper.addUser(new User(uuid.toString(),name,password));
   }
}public class UserService {
	UserMapper userMapper;

	public void setUserMapper(UserMapper userMapper){
		this.userMapper=userMapper;
	}

	public User login(String name, String password) {
		List<User> users=userMapper.getAll();
		for (User user : users) {
			if (user.getPassword().equalsIgnoreCase(password) && user.getName().equals(name)) {
				System.out.println("登录成功!");
				return user;
			}
		}
		throw new RuntimeException("登录失败!");
	}

	public User getUser(String id) {
		return userMapper.getUserById(id);
	}

	public Integer register(String name,String password) {
		UUID uuid = UUID.randomUUID();
		return userMapper.addUser(new User(uuid.toString(),name,password));
	}
}

APP启动类,用于创建ioc容器和加载bean

public class App {
   public static void main(String[] args) {
	//创建ioc容器并加载和控制bean
   }
}

如何将上面的登录案例转换为由IOC容器控制呢?可以通过xml或者注解这两种方式进行配置。

首先加入Spring框架的maven依赖:spring-context,我这里用的5.2.8版本

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>Spring-Learn</groupId>
    <artifactId>Spring-Learn</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>5.2.8.RELEASE</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>
</project>

2.2.1.xml方式装配Bean

通过xml文件可以清晰描述Bean的依赖关系。

在resources目录下创建application.xml文件,内容如下

<?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
       https://www.springframework.org/schema/beans/spring-beans.xsd">
    <bean id="userMapper" class="gc.learn.mapper.UserMapper">
    </bean>
    <bean id="userService" class="gc.learn.service.UserService">
        <property name="userMapper" ref="userMapper"></property>
    </bean>
</beans>

下面的bean配置表示实例化一个bean,class是将gc.learn.mapper包下的UserMapper类交由IOC容器管理,默认情况下该方式是直接通过无参数的构造方法创建的实例(如果该类没有无参数构造方法将无法创建,需要通过在bean标签中添加constructor-arg标签进行构造函数中参数的依赖注入),id是该类的唯一身份标识,后面从IOC容器中获取UserMapper类实例的时候需要用到该id值。

<bean id="userMapper" class="gc.learn.mapper.UserMapper">
     <!--为有参的构造函数注入参数-->
     <!--<constructor-arg name="" value=""></constructor-arg>-->
 </bean>

下面的bean配置表示实例化一个bean并依赖注入了参数,将gc.learn.service包下的UserService类交由IOC容器管理(实例化),然后通过property标签依赖注入成员变量userMapper的值为IOC容器中对应的bean实例,该实例通过id为userMapper来获取,注意:property标签中的name属性值为成员变量的属性名,ref属性值为IOC容器管理中存在的id对象实例value属性用于设置基础的数据类型(String、int、Integer、boolean、Boolean、char)的值,property标签是通过调用类中的set方法实现的,所以要通过property标签设置值或者对象实例,该类必须写一个set方法。

<bean id="userService" class="gc.learn.service.UserService">
	<property name="userMapper" ref="userMapper"></property>
	<property name="userServiceDescription" value="该类用于处理用户相关逻辑!"/>
</bean>

创建ioc容器并装配Bean

在APP中添加以下内容,其中ClassPathXmlApplicationContext类用于加载解析application.xml,它可以将我们配置的bean转载进ioc容器中,我们可以通过ApplicationContext应用上下文的getBean方法将对象实例id传入,得到id对应的实例,然后就可以使用该实例的方法完成用户服务的相关逻辑处理。

public class App {
	public static void main(String[] args) {
		ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
		UserService userService = context.getBean("userService", UserService.class);
		User loginUser = userService.login("Bob", "password");
		System.out.println(loginUser);
		System.out.println(userService.getUserServiceDescription());
	}
}

除了构造函数实例化bean外,还有通过静态工厂方法和是实例工厂方法来进行实例化bean

静态工厂方法实例化bean

第三方的bean创建经常会使用静态工厂方法来实例bean

public class ClientService {
    private static ClientService clientService = new ClientService();
    private ClientService() {}

    public static ClientService createInstance() {
        return clientService;
    }
}
<bean id="clientService"
    class="examples.ClientService"
    factory-method="createInstance"/>

实例工厂方法实例化bean

第三方的bean创建经常会使用静态工厂方法来实例bean

public class DefaultServiceLocator {

    private static ClientService clientService = new ClientServiceImpl();

    public ClientService createClientServiceInstance() {
        return clientService;
    }
}
<!-- 必须先实例化工厂类 -->
<bean id="serviceLocator" class="examples.DefaultServiceLocator">
    <!-- 可以通过方法注入(构造参数、方法参数等) -->
</bean>

<!-- 实例工厂方法实例化bean,通过factory-bean属性指定实例工厂类 -->
<bean id="clientService"
    factory-bean="serviceLocator"
    factory-method="createClientServiceInstance"/>

总结:XML这种配置方式,就是把我们自己的Bean的依赖关系描述出来,然后交由ioc容器来创建并装配Bean,实例化bean有构造函数、静态工厂方法、实例工厂方法三种方式,参数依赖注入有基于构造参数注入和setter方法参数注入两种。

2.2.2.注解方式装配Bean

使用XML配置的优点是所有的Bean都能一目了然地列出来,并通过配置注入能直观地看到每个Bean的依赖。它的缺点是写起来非常繁琐,每增加一个组件,就必须把新的Bean配置到XML中,为了避免这种繁琐操作,我们可以使用注解方式来让ioc容器装配Bean。

还是通过刚才的那个例子,我们将xml方式装配改为注解方式装配。

添加注解,描述Bean的依赖关系

因为改用了注解方式装配bean,就可以不用xml配置文件了,使用注解@Component来告诉ioc容器该类是需要被装配的,使用@Autowired来获取ioc容器中的被装配的对象实例。

UserMapper数据层,通过@Component注解将UserMapper类标记为需要被ioc容器装配管理,它id默认为userMapper,当然也可以在@Component中传入一个参数来自定义id名称。

@Component  //@Component("myUserMapper")
public class UserMapper {
   private List<User> users = new ArrayList<>(Arrays.asList( // users:
         new User("1",  "Bob", "password"), // bob
         new User("2", "Alice", "password"), // alice
         new User("3", "Tom", "password"))); // tom

   public User getUserById(String id){
      List<User> collect = users.stream().filter(user -> user.getId().equals(id)).collect(Collectors.toList());
      return collect.get(0);
   }
   public List<User> getAll(){
      return this.users;
   }
   public Integer addUser(User user){
      this.users.add(user);
      return 1;
   }
}

UserService服务层,通过@Component注解将UserService类标记为需要被ioc容器装配管理,然后通过**@Autowired**获取ioc容器中的UserMapper实例并依赖注入到成员参数userMapper中,@Autowired注解可以作用于成员变量上,也可以作用于构造函数参数和成员方法参数上,@Autowired注解本质上就是xml配置中的依赖注入。

注意:@Autowired是通过类型进行注入的,ioc容器中没有该类型的对象实例时就会报错,当ioc容器中有多个对象实例相匹配时,就按照id来匹配。如果需要直接通过id进行获取可以一起配置@Autowired和@Qualifier注解使用。如果不想使用@Autowired类型方式获取对象实例,还可以单独使用@Resource注解通过id来获取对象实例。

@Component
public class UserService {
   @Autowired
   //@Qualifier("myUserMapper")
   //@Resource(name = "myUserMapper")
   private UserMapper userMapper;
   private String UserServiceDescription;
   public void setUserMapper(UserMapper userMapper){
      this.userMapper=userMapper;
   }
   public void setUserServiceDescription(String s){
      this.UserServiceDescription=s;
   }

   public User login(String name, String password) {
      List<User> users=userMapper.getAll();
      for (User user : users) {
         if (user.getPassword().equalsIgnoreCase(password) && user.getName().equals(name)) {
            System.out.println("登录成功!");
            return user;
         }
      }
      throw new RuntimeException("登录失败!");
   }

   public User getUser(String id) {
      return userMapper.getUserById(id);
   }

   public Integer register(String name,String password) {
      UUID uuid = UUID.randomUUID();
      return userMapper.addUser(new User(uuid.toString(),name,password));
   }
   public String getUserServiceDescription(){
      return this.UserServiceDescription;
   }
}

创建ioc容器并装配Bean

通过AnnotationConfigApplicationContext方法加载配置类并创建ioc容器,因为AnnotationConfigApplicationContext只会识别带@Configuration注解的类,所以该类的必须添加上@Configuration注解,而@ComponentScan类用于扫描需要被ioc容器管理的类,所有添加了@Component注解的类都会被装配进ioc容器中。

注意:@ComponentScan不传包参数时,默认扫描添加了@ComponentScan类所在的包及其所在包下的子包。

@Configuration
@ComponentScan("gc.learn.*")
public class App {
	public static void main(String[] args) {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(App.class);
		UserService userService = context.getBean(UserService.class);
		User user = userService.login("Bob", "password");
		System.out.println(user);
	}
}

@ComponentScan注解等同下面这种配置xml扫描包形式,通过配置包扫描标签可以实现xml和注解的混合使用,混合使用时创建ioc容器必须使用xml形式进行加载。

<beans>
    <context:component-scan base-package="com.acme"/>
</beans>

上面例子中的注解可以完成大部分bean的装配,但是并不能完成第三方库的装配,如果需要让IOC容器装配第三方bean应该怎么做呢(第三方bean不允许我们修改,也就不能直接添加@Component)?,答案是:通过@bean注解来实现

@bean注解,它只能作用于成员方法上,且该类必须被IOC容器管理(需要添加上@Component或者@Configuration),@bean注解作用就是将返回的参数装配进ioc容器中

@Configuration
public class AppConfig {

    @Bean
    public TransferServiceImpl transferService() {
        return new TransferServiceImpl();
    }
}

上面的配置完全等同于下面的Spring XML。

<beans>
    <bean id="transferService" class="com.acme.TransferServiceImpl"/>
</beans>

2.3.注入配置(读取配置文件)

2.3.1基本数据类型配置

在开发应用程序时,经常需要读取配置文件。最常用的配置方法是以key=value的形式写在.properties文件中。

例如,MailService根据配置的app.zone=Asia/Shanghai来决定使用哪个时区,这时就需要读取配置文件,Spring容器提供了一个更简单的@PropertySource来自动读取配置文件。我们只需要在@Configuration配置类上再添加一个注解:

@Configuration
@ComponentScan
@PropertySource(value = "classpath:app.properties",encoding = "utf-8") // 表示读取classpath的app.properties
public class AppConfig {
    @Value("${app.zone}")
    String zoneId;

    @Bean
    ZoneId createZoneId() {
        return ZoneId.of(zoneId);
    }
}

resource目录下的app.properties文件内容如下

app.zone=Asia/Shanghai

还可以把注入的注解写到方法参数中:

@Bean
ZoneId createZoneId(@Value("${app.zone}") String zoneId) {
    return ZoneId.of(zoneId);
}

另一种注入配置的方式是先通过一个简单的JavaBean持有所有的配置,例如,一个SmtpConfig

@Component
public class SmtpConfig {
    @Value("${smtp.host}")
    private String host;

    @Value("${smtp.port:25}")
    private int port;

    public String getHost() {
        return host;
    }

    public int getPort() {
        return port;
    }
}

然后,在需要读取的地方,使用#{smtpConfig.host}注入:

@Component
public class MailService {
    @Value("#{smtpConfig.host}")
    private String smtpHost;

    @Value("#{smtpConfig.port}")
    private int smtpPort;
}

注意观察#{}这种注入语法,它和${key}不同的是,#{}表示从JavaBean读取属性。"#{smtpConfig.host}"的意思是,从名称为smtpConfig的Bean读取host属性,即调用getHost()方法。

2.3.2.文件资源注入

在Java程序中,我们经常会读取配置文件、资源文件等,例如,AppService需要读取logo.txt这个文件,通常情况下,我们需要写很多繁琐的代码,主要是为了定位文件,打开InputStream。

Spring提供了一个org.springframework.core.io.Resource(注意不是jarkata.annotation.Resourcejavax.annotation.Resource),它可以像Stringint一样使用@Value注入:

@Component
public class AppService {
    @Value("classpath:/logo.txt")
    private Resource resource;

    private String logo;

    @PostConstruct
    public void init() throws IOException {
        try (var reader = new BufferedReader(
                new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
            this.logo = reader.lines().collect(Collectors.joining("\n"));
        }
    }
}

注入Resource最常用的方式是通过classpath,即类似classpath:/logo.txt表示在classpath中搜索logo.txt文件,然后,我们直接调用Resource.getInputStream()就可以获取到输入流,避免了自己搜索文件的代码。

也可以直接指定文件的路径,例如:

@Value("file:/path/to/logo.txt")
private Resource resource;

2.4.装配自定义Bean

对于Spring容器来说,当我们把一个Bean标记为@Component后,它就会默认为我们创建一个单例(Singleton)对象,即ioc容器初始化时创建单例(Singleton)对象,容器关闭前销毁Bean。在容器运行期间,我们调用getBean(Class)获取到的Bean总是同一个实例(内存地址相同)。

如果我们不想创建默认的单例(Singleton)对象,可以使用**@Scope注解来配置创建Prototype(原型/多例)对象**,Prototype(原型)对象ioc容器初始化时不会创建Prototype(原型)对象,而是我们调用getBean(Class)获取到的Bean时才创建,回去到的不是同一个实例(内存地址不同)

2.4.1.配置bean模式

@Scope注解,用于指定bean类型是单例模式还是多例模式,作用于类和方法。

@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // @Scope("prototype")
public class MailSession {
    ...
}

2.4.2.配置bean初始化和销毁时回调方法

有些时候,一个Bean在注入必要的依赖后,需要进行初始化(监听消息等)。在容器关闭时,有时候还需要清理资源(关闭连接池等)。我们通常会定义一个init()方法进行初始化,定义一个shutdown()方法进行清理。

在Bean的初始化和清理方法上标记@PostConstruct@PreDestroy

@Component
public class MailService {
    @Autowired(required = false)
    ZoneId zoneId = ZoneId.systemDefault();

    @PostConstruct
    public void init() {
        System.out.println("Init mail service with zoneId = " + this.zoneId);
    }

    @PreDestroy
    public void shutdown() {
        System.out.println("Shutdown mail service");
    }
}

使用FactoryBean

我们在设计模式的工厂方法中讲到,很多时候,可以通过工厂模式创建对象。Spring也提供了工厂模式,允许定义一个工厂,然后由工厂创建真正的Bean。

用工厂模式创建Bean需要实现FactoryBean接口。我们观察下面的代码:

@Component
public class ZoneIdFactoryBean implements FactoryBean<ZoneId> {

    String zone = "Z";

    @Override
    public ZoneId getObject() throws Exception {
        return ZoneId.of(zone);
    }

    @Override
    public Class<?> getObjectType() {
        return ZoneId.class;
    }
}

当一个Bean实现了FactoryBean接口后,Spring会先实例化这个工厂,然后调用getObject()创建真正的Bean。getObjectType()可以指定创建的Bean的类型,因为指定类型不一定与实际类型一致,可以是接口或抽象类。

因此,如果定义了一个FactoryBean,要注意Spring创建的Bean实际上是这个FactoryBeangetObject()方法返回的Bean。为了和普通Bean区分,我们通常都以XxxFactoryBean命名。

2.4.3.控制bean加载顺序

@DependsOn和@Order注解

上面两个注解都可以实现对bean装配顺序的控制,不同在于@DependsOn作用于类和方法上,而@Order注解主要作用于类之上上。

例如Service层依赖于Mapper层,就可以使用下面注解进行1控制

@Component
@DependsOn("userMapper")
@Order(1)
public class UserService {
    //.....
}

2.4.4.根据开发环境控制bean加载

开发应用程序时,我们会使用开发环境,例如,使用内存数据库以便快速启动。而运行在生产环境时,我们会使用生产环境,例如,使用MySQL数据库。如果应用程序可以根据自身的环境做一些适配,无疑会更加灵活。

Spring为应用程序准备了Profile这一概念,用来表示不同的环境。例如,我们分别定义开发、测试和生产这3个环境:

  • native
  • test
  • production

创建某个Bean时,Spring容器可以根据注解@Profile来决定是否创建。例如,以下配置:

@Configuration
@ComponentScan
public class AppConfig {
    @Bean
    @Profile("!test") //不为测试环境时加载
    ZoneId createZoneId() {
        return ZoneId.systemDefault();
    }

    @Bean
    @Profile("test") //测试环境下加载
    ZoneId createZoneIdForTest() {
        return ZoneId.of("America/New_York");
    }
}

在运行程序时,加上JVM参数-Dspring.profiles.active=test就可以指定以test环境启动。

2.4.5.根据条件来加载bean

@Conditional注解

@Conditional会根据自定义条件判断是否加载bean,具体判断逻辑还需要我们自己实现。

例如,我们对SmtpMailService添加如下注解:

@Component
@Conditional(OnSmtpEnvCondition.class)
public class SmtpMailService implements MailService {
    ...
}

它的意思是,如果满足OnSmtpEnvCondition的条件,才会创建SmtpMailService这个Bean。OnSmtpEnvCondition的条件是什么呢?我们看一下代码:

public class OnSmtpEnvCondition implements Condition {
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        return "true".equalsIgnoreCase(System.getenv("smtp"));
    }
}

因此,OnSmtpEnvCondition的条件是存在环境变量smtp,值为true。这样,我们就可以通过环境变量来控制是否创建SmtpMailService

@Conditional注解的衍生注解

@ConditionalOnProperty

它可以判断配置文件中是否包含默写选项来判断是否加载bean

例子:如果配置文件中存在app.smtp=true,则创建MailService

@Component
@ConditionalOnProperty(name="app.smtp", havingValue="true")
public class MailService {
    ...
}@ConditionalOnProperty(name="app.smtp", havingValue="true")

例子:假设我们需要保存用户上传的头像,并返回存储路径,在本地开发运行时,我们总是存储到文件:

@Component
@ConditionalOnProperty(name = "app.storage", havingValue = "file", matchIfMissing = true)
public class FileUploader implements Uploader {
    ...
}

在生产环境运行时,我们会把文件存储到类似AWS S3上:

@Component
@ConditionalOnProperty(name = "app.storage", havingValue = "s3")
public class S3Uploader implements Uploader {
    ...
}

其他需要存储的服务则注入Uploader

@Component
public class UserImageService {
    @Autowired
    Uploader uploader;
}

当应用程序检测到配置文件存在app.storage=s3时,自动使用S3Uploader,如果存在配置app.storage=file,或者配置app.storage不存在,则使用FileUploader

可见,使用条件注解,能更灵活地装配Bean。

@ConditionalOnClass注解

它可以根据classpath中存在类来判断是否加载bean

如果当前classpath中存在类javax.mail.Transport,则创建MailService

@Component
@ConditionalOnClass(name = "javax.mail.Transport")
public class MailService {
    ...
}

3.AOP切面编程

3.1.什么是AOP

AOP是Aspect Oriented Programming,即面向切面编程,回顾下OOP:Object Oriented Programming,OOP作为面向对象编程的模式,获得了巨大的成功,OOP的主要功能是数据封装、继承和多态,而AOP是一种新的编程方式,它和OOP不同,OOP把系统看作多个对象的交互,AOP把系统分解为不同的关注点,或者称之为切面(Aspect)。

AOP主要弥补了OOP的不足,它基于OOP基础之上进行横向开发,OOP规定程序开发以类为主体模型,一切围绕对象进行,完成某个任务先构建模型,AOP程序开发主要关注基于OOP开发中的共性功能,一切围绕共性功能进行,完成某个任务先构建可能遇到的所有共性功能(当所有功能都开发出来也就没有共性与非共性之分)。

举个例子来说,如一个业务组件BookService,它有几个业务方法:

  • createBook:添加新的Book;
  • updateBook:修改Book;
  • deleteBook:删除Book。

对每个业务方法,例如,createBook(),除了业务逻辑,还需要安全检查、日志记录和事务处理,它的代码像这样:

public class BookService {
    public void createBook(Book book) {
        securityCheck(); //安全检查,每个业务方法中都需要写。
        Transaction tx = startTransaction();
        try {
            // 核心业务逻辑
            tx.commit();
        } catch (RuntimeException e) {
            tx.rollback();
            throw e;
        }
        log("created book: " + book);
    }
}

对于安全检查、日志、事务等代码,它们会重复出现在每个业务方法中。使用OOP,我们很难将这些四处分散的代码模块化。

为了解决这个问题我们就可以使用AOP切面编程,将安全检查、日志、事务等每个业务中都会重复出现的功能单独抽离出来(AOP中的切面),然后运行时动态将这些共有功能添加到每个业务中,这样就完美解决了每个业务方法需要重复添加的问题。

但是AOP也是具有局限性的,例如AOP对于解决特定问题,例如事务管理非常有用,这是因为分散在各处的事务代码几乎是完全相同的,并且它们需要的参数(JDBC的Connection)也是固定的。另一些特定问题,如日志,就不那么容易实现,因为日志虽然简单,但打印日志的时候,经常需要捕获局部变量,如果使用AOP实现日志,我们只能输出固定格式的日志,因此,使用AOP时,必须适合特定的场景。

3.2.AOP核心概念

在AOP编程中,我们经常会遇到下面的概念:

  • Aspect:切面,即一个横跨多个核心逻辑的功能,或者称之为系统关注点;
  • Joinpoint:连接点,即定义在应用程序流程的何处插入切面的执行;
  • Pointcut:切入点,即一组连接点的集合;
  • Advice:增强,指特定连接点上执行的动作;
  • Introduction:引介,指为一个已有的Java对象动态地增加新的接口;
  • Weaving:织入,指将切面整合到程序的执行流程中;
  • Interceptor:拦截器,是一种实现增强的方式;
  • Target Object:目标对象,即真正执行业务的核心逻辑对象;
  • AOP Proxy:AOP代理,是客户端持有的增强后的对象引用。

3.3.使用AOP

在maven中引入spring对AOP的依赖

<!--提供了与 AspectJ 框架集成的功能-->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
    <version>5.2.8.RELEASE</version>
</dependency>
<!--AspectJ 的编织器(weaver)
用于在运行时对 Java 代码进行横切(cross-cutting)操作和增强-->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
    <version>1.9.5</version>
</dependency>
<!--AspectJ 的运行时库(runtime library),
用于在运行时执行被织入的切面(aspects)-->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjrt</artifactId>
    <version>1.9.5</version>
</dependency>

需要被AOP进行增强的类如下

@Component
public class UserService {
   @Autowired
   private UserMapper userMapper;
   //需要被增强的方法
   public User login(String name, String password) {
      List<User> users=userMapper.getAll();
      for (User user : users) {
         if (user.getPassword().equalsIgnoreCase(password) && user.getName().equals(name)) {
            System.out.println("登录成功!");
            return user;
         }
      }
      throw new RuntimeException("登录失败!");
   }
}

配置切面和配置切入点通知(下一章节详细讲解切入点通知),其中@Before和@Around是切入点通知,,execution是切入点表达式

@Aspect //配置切面
@Component
public class ServiceAspect {
	//配置前置切入点通知,UserService的login方法执行前执行该方法
    //execution(访问修饰符 返回值 包名类名.方法名(参数) 异常名) 
	@Before("execution(public * gc.learn.service.UserService.login(String,String))")
	public void doAccessCheck(JoinPoint jp) {
		Object[] args = jp.getArgs(); //登录方法中的所有参数
		System.out.println(Arrays.toString(args));
		System.err.println("登陆前进行参数合法性检查");
	}

	//配置环绕切入点通知 UserService的login方法执行前后都会执行该方法,
	@Around("execution(public * gc.learn.service.UserService.login(String,String))")
	public Object doAccessCheck1(ProceedingJoinPoint pjp) throws Throwable {
		Object[] args = pjp.getArgs();//登录方法中的所有参数
		System.out.println(Arrays.toString(args));
		//执行登录方法前先执行
		System.err.println("[环绕切入点通知] start " + pjp.getSignature());
 		//执行login方法,得到login方法执行后的返回值,这里可以用try..catch捕获异常并得到异常信息
		Object retVal = pjp.proceed();
		//执行登录方法后先执行
		System.err.println("[环绕切入点通知] done " + pjp.getSignature());
		return retVal;
	}
}

在启动配置类中开启AOP支持

@Configuration
@ComponentScan("gc.learn.*")
@EnableAspectJAutoProxy //开启AOP支持
public class App {
    public static void main(String[] args) {
		annotationConfig();
	}
    public static void annotationConfig(){
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(App.class);
		UserService userService = context.getBean(UserService.class);
		User user = userService.login("Bob", "password");
		System.out.println(user);
	}
}

3.4.切入点和表达式

在使用AOP章节中,我们配置了切入点通知切入点表达式,其中切入点通知分为很多种类型,切入点表达式也有很多类型,具体如下:

3.4.1.切入点通知类型

@Before("execution(public * gc.learn.service.UserService.login(String,String))")
  • @Before前置通知,原始方法执行前执行,如果通知中抛出异常,阻止原始方法运行。
    应用:数据校验,通过传入JoinPoint对象参数相关信息
  • @After后置通知,原始方法执行后执行,无论原始方法中是否出现异常,都将执行通知
    应用:现场清理,通过传入JoinPoint对象参数相关信息
  • @AfterReturning返回后通知,原始方法正常执行完毕并返回结果后执行,如果原始方法中抛出异常,无法执行
    应用:返回值相关数据处理
  • @AfterThrowing抛出异常后通知,原始方法抛出异常后执行,如果原始方法没有抛出异常,无法执行
    应用:对原始方法中出现的异常信息进行处理
  • @Around环绕通知,在原始方法执行前后均会执行,还可以阻止原始方法的执行。
    应用:十分强大,可以做任何事情

3.4.2.切入点表达式类型

@Before("execution(public * gc.learn.service.UserService.login(String,String))")
  • execution: 用于匹配方法执行的连接点。这是在使用Spring AOP时要使用的主要切点指定器。
  • within: 将匹配限制在某些类型内的连接点(使用Spring AOP时,执行在匹配类型内声明的方法)。
  • this: 将匹配限制在连接点(使用Spring AOP时方法的执行),其中bean引用(Spring AOP代理)是给定类型的实例。
  • target: 将匹配限制在连接点(使用Spring AOP时方法的执行),其中目标对象(被代理的应用程序对象)是给定类型的实例。
  • args: 将匹配限制在连接点(使用Spring AOP时方法的执行),其中参数是给定类型的实例。
  • @target: 限制匹配到连接点(使用Spring AOP时方法的执行),其中执行对象的类有一个给定类型的注解。
  • @args: 将匹配限制在连接点(使用Spring AOP时方法的执行),其中实际传递的参数的运行时类型有给定类型的注解。
  • @within: 将匹配限制在具有给定注解的类型中的连接点(使用Spring AOP时,执行在具有给定注解的类型中声明的方法)。
  • @annotation: 将匹配限制在连接点的主体(Spring AOP中正在运行的方法)具有给定注解的连接点上。

3.4.3.使用@annotation切入点表达式

在实际项目中并不经常使用execution类型的切入点表达式,因为使用它很难发现到底哪个方法被AOP增强了。例如

Around("execution(public * update*(..))")
public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
    // 对update开头的方法切换数据源:
    String old = setCurrentDataSource("master");
    Object retVal = pjp.proceed();
    restoreCurrentDataSource(old);
    return retVal;
}

这个方式虽说可以实现update开头的方法名的增强,但是它的覆盖的面太广,如果项目过于复杂庞大,开发者可能不知道设置了该规则的AOP切入点表达式,当在不知配置了该切入点表达式时新增以update开头方法就会莫名被增强(实际上我并不想让这个新增的方法被增强),这时候就会产生不必要的麻烦。

实际上项目开发时更希望像控制事务一样使用一个@Transactional注解明确表示我需要让这个方法受事务控制,例如

@Component
public class UserService {
    // 有事务:
    @Transactional
    public User createUser(String name) {
        ...
    }

    // 无事务:
    public boolean isValidName(String name) {
        ...
    }

    // 有事务:
    @Transactional
    public void updateUser(User user) {
        ...
    }
}

像上面这样,如果我们将需要增强的方法加上一个特别的注解,只有被添加了这个注解的方法才进行方法增强,这样我们就一目了然的知道哪些方法被AOP增强了,通过使用@annotation切入点表达式可以实现我们想要的效果。

编写自定义注解AopAnnotation,用于方法增强时添加

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AopAnnotation {
   String value();
}

需要被AOP进行增强的类如下

@Component
public class UserService {
   @Autowired
   private UserMapper userMapper;
   @AopAnnotation("login") //添加自定义注解,表明该方法需要被aop增强
   public User login(String name, String password) {
      List<User> users=userMapper.getAll();
      for (User user : users) {
         if (user.getPassword().equalsIgnoreCase(password) && user.getName().equals(name)) {
            System.out.println("登录成功!");
            return user;
         }
      }
      throw new RuntimeException("登录失败!");
   }
}

配置切面类,并配置@annotation表达式类型匹配注解,如果包含aopAnnotation注解就执行增强方法,注意,aopAnnotation其实是参数中的参数名,起实际作用的是参数类型为自定义的注解类型

@Aspect //配置切面
@Component
public class ServiceAspect {
   @After("@annotation(aopAnnotation)") //使用@annotation表达式类型匹配注解
   public void doAccessCheck3(JoinPoint jp, AopAnnotation aopAnnotation) {
      String value = aopAnnotation.value();
      System.out.println("注解中的参数:"+value);
      Object[] args = jp.getArgs();
      System.out.println(Arrays.toString(args));
      System.err.println("@annotation : login方法执行后执行");
   }
}

4.数据库访问

4.1.通过JdbcTemplate访问

我这边通过JdbcTemplate访问mysql数据库为例子

添加依赖

<!--jdbc依赖包-->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
    <version>5.2.8.RELEASE</version>
</dependency>
<!--mysql连接驱动-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.45</version>
</dependency>

创建jdbc连接池和JdbcTemplate实例

@Configuration
@PropertySource("value = "classpath:application.properties",encoding = "utf-8"")
public class DatabaseConfig {
    @Value("${database.url}")
    private String jdbcUrl;
    @Value("${database.username}")
    private String userName;
    @Value("${database.password}")
    private String password;
    @Bean //创建连接池
    public DataSource createDataSource(){
        MysqlDataSource dataSource = new MysqlDataSource();
        dataSource.setURL(this.jdbcUrl);
        dataSource.setUser(this.userName);
        dataSource.setPassword(this.password);
        return dataSource;
    }
    @Bean //创建JdbcTemplate实例
    JdbcTemplate createJdbcTemplate(@Autowired DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }
}

application.properties文件内容如下

database.url=jdbc:mysql://localhost:3306/spring-database?characterEncoding=UTF-8
database.username=root
database.password=111111

JdbcTemplate访问mysql数据库,增删查改

@Component
public class UserMapper {
   @Autowired
   JdbcTemplate jdbcTemplate;
   public void testMysql() {
        //执行任何sql,增加删除查询更新的等
        List<User> userList = jdbcTemplate.execute("select * from user LIMIT ?,?", (PreparedStatement var1) -> {
            var1.setObject(1, 0);
            var1.setObject(2, 1);
            ResultSet resultSet = var1.executeQuery();
            List<User> users = new ArrayList<>();
            while (resultSet.next()) {
                long id = resultSet.getLong("id");
                String name = resultSet.getString("name");
                String password = resultSet.getString("password");
                User user = new User(id + "", name, password);
                users.add(user);
            }
            return users;
        });
        System.out.println(userList);
        //查询 返回单条结果
        try {
            User user = jdbcTemplate.queryForObject("select * from user where name=? and password=?", (ResultSet var1, int var2) -> {
                if (!var1.wasNull()) {
                    String id = var1.getString("id");
                    String name = var1.getString("name");
                    String password = var1.getString("password");
                    return new User(id, name, password);
                } else {
                    return null;
                }

            }, "nob", "1");
            System.out.println(user);
            //查询 返回多条结果
        } catch (Exception e) {
            System.out.println("用户未找到");
        }
        //查询 返回多条语句
        List<User> userList1 = jdbcTemplate.query("select * from user LIMIT ?,?",
                new BeanPropertyRowMapper<>(User.class), 0, 10);
        System.out.println(userList1);

        //更新删除添加
        if (1 != jdbcTemplate.update("UPDATE user SET name = ? WHERE id = ?", "Bob1", "1")) {
            throw new RuntimeException("User not found by id");
        }
        //更新删除添加 返回主键,多用于数据库主键自增
        // 创建一个KeyHolder:
        KeyHolder holder = new GeneratedKeyHolder();
        if (1 != jdbcTemplate.update(
                // 参数1:PreparedStatementCreator
                (conn) -> {
                    // 创建PreparedStatement时,必须指定RETURN_GENERATED_KEYS:
                    PreparedStatement ps = conn.prepareStatement("INSERT INTO user(name, password) VALUES(?, ?)",
                            Statement.RETURN_GENERATED_KEYS);
                    ps.setObject(1, "aaaaa");
                    ps.setObject(2, "111111");
                    return ps;
                },
                // 参数2:KeyHolder
                holder)
        ) {
            throw new RuntimeException("Insert failed.");
        }
        System.out.println("自增主键:"+holder.getKey().toString());
    }
}

4.2.数据库事务

4.2.1.什么是数据库事务

事务(Transaction)指一个操作,由多个步骤组成,要么全部成功,要么全部失败。
比如我们常用的转账功能,假设A账户向B账号转账,那么涉及两个操作:
从 A 账户扣钱。
往 B 账户加入等量的钱。
因为是独立的两个操作,所以可能有一个成功,一个失败的情况。但是因为在这种场景下,不能存在从 A 账户扣钱成功,往 B 账户加入等量钱失败这种情况,要么同时成功,要么同时失败(一个失败需要回滚),即必须要保证事务。

4.2.2.使用事务

在Spring中操作事务,没必要手写JDBC事务,可以使用Spring提供的高级接口来操作事务。Spring提供了一个PlatformTransactionManager来表示事务管理器,所有的事务都由它负责管理。

通过@EnableTransactionManagement来开启数据库事务支持。

@Configuration
@PropertySource(value = "classpath:application.properties",encoding = "utf-8")
@EnableTransactionManagement //开启数据库事务支持
public class DatabaseConfig {
    @Value("${database.url}")
    private String jdbcUrl;
    @Value("${database.username}")
    private String userName;
    @Value("${database.password}")
    private String password;
    @Bean
    public DataSource createDataSource(){
        MysqlDataSource dataSource = new MysqlDataSource();
        dataSource.setURL(this.jdbcUrl);
        dataSource.setUser(this.userName);
        dataSource.setPassword(this.password);
        return dataSource;
    }
    @Bean
    JdbcTemplate createJdbcTemplate(@Autowired DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }

    @Bean //创建事务实例
    PlatformTransactionManager createTxManager(@Autowired DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

通过添加@Transactional进行事务控制,当方法出现运行异常时,事务将会回滚。@Transactional本质上就是通过AOP切面实现的

@Component

public class UserMapper {
    @Autowired
    JdbcTemplate jdbcTemplate;
    @Transactional //对该方法进行事务控制
    public void insert(){
        jdbcTemplate.execute("INSERT INTO user(id,name, password) VALUES(?,?, ?)",(PreparedStatement var1)->{
            var1.setObject(1,"11555");
            var1.setObject(2,"zzzzz");
            var1.setObject(3,"555555");
            int i = var1.executeUpdate();
            if (i>=1){
                System.out.println("添加成功!");
            }else {
                System.out.println("添加失败!");
            }
            return i;
        });
        int s=1/0; //如果发生运行异常,事务将进行回滚,两条插入语句都将将失败
        jdbcTemplate.execute("INSERT INTO user(id,name, password) VALUES(?,?, ?)",(PreparedStatement var1)->{
            var1.setObject(1,"18888");
            var1.setObject(2,"hhhh");
            var1.setObject(3,"555555");
            int i = var1.executeUpdate();
            if (i>=1){
                System.out.println("添加成功!");
            }else {
                System.out.println("添加失败!");
            }
            return i;
        });
    }
}

4.2.3.事务边界与事务传播

事务边界

在使用事务的时候,明确事务边界非常重要。对于声明式事务,例如,下面的register()方法:

@Component
public class UserService {
    @Transactional
    public User register(String email, String password, String name) { // 事务开始
       ...
    } // 事务结束
}

它的事务边界就是register()方法开始和结束。

在现实世界中,问题总是要复杂一点点。用户注册后,能自动获得100积分,因此,实际代码如下:

@Component
public class UserService {
    @Autowired
    BonusService bonusService;

    @Transactional
    public User register(String email, String password, String name) {
        // 插入用户记录:该方法是否被事务管控了?
        User user = jdbcTemplate.insert("...");
        // 增加100积分:该方法是否被事务管控了?
        bonusService.addBonus(user.id, 100);
    }
}

现在问题来了:调用方(比如RegisterController)调用UserService.register()这个事务方法,它在内部又调用了BonusService.addBonus()这个事务方法,一共有几个事务?如果addBonus()抛出了异常需要回滚事务,register()方法的事务是否也要回滚?

事务传播

我们需要关心的问题是,在UserService.register()这个事务方法内,调用BonusService.addBonus(),我们期待的事务行为是什么:

@Transactional
public User register(String email, String password, String name) {
    // 事务已开启:
    User user = jdbcTemplate.insert("...");
    // ???:
    bonusService.addBonus(user.id, 100);
} // 事务结束

对于大多数业务来说,我们期待BonusService.addBonus()的调用,和UserService.register()应当融合在一起,它的行为应该如下:

UserService.register()已经开启了一个事务,那么在内部调用BonusService.addBonus()时,BonusService.addBonus()方法就没必要再开启一个新事务,直接加入到BonusService.register()的事务里就好了。

其实就相当于:

  1. UserService.register()先执行了一条INSERT语句:INSERT INTO users ...
  2. BonusService.addBonus()再执行一条INSERT语句:INSERT INTO bonus ...

因此,Spring的声明式事务为事务传播定义了几个级别,默认传播级别就是REQUIRED,它的意思是,如果当前没有事务,就创建一个新事务,如果当前有事务,就加入到当前事务中执行。

默认的事务传播级别是REQUIRED,它满足绝大部分的需求。还有一些其他的传播级别:

SUPPORTS:表示如果有事务,就加入到当前事务,如果没有,那也不开启事务执行。这种传播级别可用于查询方法,因为SELECT语句既可以在事务内执行,也可以不需要事务;

MANDATORY:表示必须要存在当前事务并加入执行,否则将抛出异常。这种传播级别可用于核心更新逻辑,比如用户余额变更,它总是被其他事务方法调用,不能直接由非事务方法调用;

REQUIRES_NEW:表示不管当前有没有事务,都必须开启一个新的事务执行。如果当前已经有事务,那么当前事务会挂起,等新事务完成后,再恢复执行;

NOT_SUPPORTED:表示不支持事务,如果当前有事务,那么当前事务会挂起,等这个方法执行完成后,再恢复执行;

NEVER:和NOT_SUPPORTED相比,它不但不支持事务,而且在监测到当前有事务时,会抛出异常拒绝执行;

NESTED:表示如果当前有事务,则开启一个嵌套级别事务,如果当前没有事务,则开启一个新事务。

那一个事务方法,如何获知当前是否存在事务?

答案是使用ThreadLocal。Spring总是把JDBC相关的ConnectionTransactionStatus实例绑定到ThreadLocal。如果一个事务方法从ThreadLocal未取到事务,那么它会打开一个新的JDBC连接,同时开启一个新的事务,否则,它就直接使用从ThreadLocal获取的JDBC连接以及TransactionStatus

因此,事务能正确传播的前提是,方法调用是在一个线程内才行。如果像下面这样写:

@Transactional
public User register(String email, String password, String name) { // BEGIN TX-A
    User user = jdbcTemplate.insert("...");
    new Thread(() -> {
        // BEGIN TX-B:
        bonusService.addBonus(user.id, 100);
        // END TX-B
    }).start();
} // END TX-A

在另一个线程中调用BonusService.addBonus(),它根本获取不到当前事务,因此,UserService.register()BonusService.addBonus()两个方法,将分别开启两个完全独立的事务。

换句话说,事务只能在当前线程传播,无法跨线程传播。

4.3.DAO数据持久层(mapper数据持久层)

DAO即Data Access Object的缩写,其实就是对数据库访问方法的进一步抽象封装。

数据持久层usermapper

@Component
public class UserMapper extends AbstractDao<User>{

}

数据持久层进一步抽象

public class AbstractDao<T> extends JdbcDaoSupport {
    @Autowired
    JdbcTemplate jdbcTemplate;

    private String table;
    private Class<T> entityClass;
    private RowMapper<T> rowMapper;
    public AbstractDao(){
        // 获取当前泛型类型T的具体类:
        this.entityClass = getParameterizedType();
        //根据具体类,将该类名作为数据库表的表名
        this.table = this.entityClass.getSimpleName().toLowerCase();
        //把ResultSet的一行记录映射为User对象
        this.rowMapper = new BeanPropertyRowMapper<>(entityClass);
    }
    @PostConstruct //自定义的类初始化执行回调
    public void init() {
        super.setJdbcTemplate(jdbcTemplate);
    }
    public T getById(long id) {
        return getJdbcTemplate().queryForObject("SELECT * FROM " + table + " WHERE id = ?", this.rowMapper, id);
    }

    public List<T> getAll(int pageIndex) {
        int limit = 100;
        int offset = limit * (pageIndex - 1);
        return getJdbcTemplate().query("SELECT * FROM " + table + " LIMIT ? OFFSET ?",
                new Object[] { limit, offset },
                this.rowMapper);
    }

    public void deleteById(long id) {
        getJdbcTemplate().update("DELETE FROM " + table + " WHERE id = ?", id);
    }

    @SuppressWarnings("unchecked")
    private Class<T> getParameterizedType() {
        //得到的是UserMapper类型字节码
        Class<?> aClass = getClass();
        //得到AbstractDao类型
        Type type = getClass().getGenericSuperclass(); 
        if (!(type instanceof ParameterizedType)) {
            throw new IllegalArgumentException("Class " + getClass().getName() + " does not have parameterized type.");
        }
        //强转为ParameterizedType,还是AbstractDao类型
        ParameterizedType pt = (ParameterizedType) type;
        //获取所有的泛型T的实际类型
        Type[] types = pt.getActualTypeArguments();
        if (types.length != 1) {
            throw new IllegalArgumentException("Class " + getClass().getName() + " has more than 1 parameterized types.");
        }
        //得到实际的User类型
        Type r = types[0];
        if (!(r instanceof Class<?>)) {
            throw new IllegalArgumentException("Class " + getClass().getName() + " does not have parameterized type of class.");
        }
        //转为User类型字节码
        return (Class<T>) r;
    }
}

4.4.通过Hibernate访问数据库

4.4.1.什么是Hibernate

使用JdbcTemplate的时候,我们用得最多的方法就是List<T> query(String, RowMapper, Object...)。这个RowMapper的作用就是把ResultSet的一行记录映射为Java Bean。

这种把关系数据库的表记录映射为Java对象的过程就是ORM:Object-Relational Mapping。ORM既可以把记录转换成Java对象,也可以把Java对象转换为行记录。

使用JdbcTemplate配合RowMapper可以看作是最原始的ORM。如果要实现更自动化的ORM,可以选择成熟的ORM框架,例如:Hibernate

4.4.2.使用Hibemate框架

引入Hibemate和orm依赖

<!--mysql连接驱动-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.45</version>
</dependency>
<!-- ORM框架 -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-orm</artifactId>
    <version>5.2.12.RELEASE</version>
</dependency>
<!-- hibernate框架 -->
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>5.2.12.Final</version>
</dependency>

创建mysql连接池和Hibernate实例

@Configuration
@PropertySource(value = "classpath:application.properties",encoding = "utf-8")
@EnableTransactionManagement
public class DatabaseConfig {
    @Value("${database.url}")
    private String jdbcUrl;
    @Value("${database.username}")
    private String userName;
    @Value("${database.password}")
    private String password;
    @Bean
    public DataSource createDataSource(){
        MysqlDataSource dataSource = new MysqlDataSource();
        dataSource.setURL(this.jdbcUrl);
        dataSource.setUser(this.userName);
        dataSource.setPassword(this.password);
        return dataSource;
    }
    @Bean
    public LocalSessionFactoryBean createSessionFactory(@Autowired DataSource dataSource) {
        Properties props = new Properties();
        //表示自动创建数据库的表结构,注意不要在生产环境中启用
        props.setProperty("hibernate.hbm2ddl.auto", "update");
        //指示Hibernate使用的数据库是HSQLDB。
        //Hibernate使用一种HQL的查询语句,它和SQL类似,但真正在“翻译”成SQL时,会根据设定的数据库“方言”来生成针对数据库优化的SQL,不同数据库设置不同我这里用的msql5.x所以对应的hibernate.dialect的值为org.hibernate.dialect.MySQLInnoDBDialect
        props.setProperty("hibernate.dialect", "org.hibernate.dialect.MySQLInnoDBDialect");
        //让Hibernate打印执行的SQL,这对于调试非常有用,我们可以方便地看到Hibernate生成的SQL语句是否符合我们的预期。
        props.setProperty("hibernate.show_sql", "true");
        LocalSessionFactoryBean sessionFactoryBean = new LocalSessionFactoryBean();
        sessionFactoryBean.setDataSource(dataSource);
        // 扫描指定的package获取所有entity class:
        sessionFactoryBean.setPackagesToScan("gc.learn.entity");
        sessionFactoryBean.setHibernateProperties(props);
        return sessionFactoryBean;
    }
    @Bean //创建Hibernate事务管理
    public PlatformTransactionManager createHibernateTxManager(@Autowired SessionFactory sessionFactory) {
        return new HibernateTransactionManager(sessionFactory);
    }
}

LocalSessionFactoryBean是一个FactoryBean,它会再自动创建一个SessionFactory,在Hibernate中,Session是封装了一个JDBC Connection的实例,而SessionFactory是封装了JDBC DataSource的实例,即SessionFactory持有连接池,每次需要操作数据库的时候,SessionFactory创建一个新的Session,相当于从连接池获取到一个新的ConnectionSessionFactory就是Hibernate提供的最核心的一个对象,但LocalSessionFactoryBean是Spring提供的为了让我们方便创建SessionFactory的类。

常用的设置请参考Hibernate文档

编写实体类User

该类用于将实体类和数据库字段进行映射

@Entity
@Table(name = "user") //数据库中的表名
public class User {
   private String id;
   private String name;
   private String password;
   public User(){};
   public User(String name, String password) {
		this.id = id;
		this.name = name;
		this.password = password;
	}
   public User(String id, String name, String password) {
      this.id = id;
      this.name = name;
      this.password = password;
   }
   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   @Column(nullable = false, updatable = false)
   public String getId() {
      return id;
   }

   public void setId(String id) {
      this.id = id;
   }
   @Column(nullable = false, unique = true, length = 100)
   public String getName() {
      return name;
   }

   public void setName(String name) {
      this.name = name;
   }
   @Column(nullable = false, unique = true, length = 100)
   public String getPassword() {
      return password;
   }

   public void setPassword(String password) {
      this.password = password;
   }
   
   @Transient
   public ZonedDateTime getCreatedDateTime() {
       return Instant.ofEpochMilli(this.createdAt).atZone(ZoneId.systemDefault());
    } 

   @Override
   public String toString() {
      return "User{" +
            "id='" + id + '\'' +
            ", name='" + name + '\'' +
            ", password='" + password + '\'' +
            '}';
   }
}

每个属性到数据库列的映射用@Column()标识,nullable指示列是否允许为NULLupdatable指示该列是否允许被用在UPDATE语句,length指示String类型的列的长度(如果没有指定,默认是255)。

@Transient注解表示,它返回一个“虚拟”的属性。因为getCreatedDateTime()是计算得出的属性,而不是从数据库表读出的值,因此必须要标注@Transient,否则Hibernate会尝试从数据库读取名为createdDateTime这个不存在的字段从而出错。

@MappedSuperclass表示它用于被继承的类

对于主键,还需要用@Id标识,自增主键再追加一个@GeneratedValue,以便Hibernate能读取到自增主键的值。

注意:实体类中需要使用包装类型,不能是基本数据类型。

插入、删除、修改操作

@Component
public class UserMapperByHibernate {
    @Autowired
    SessionFactory sessionFactory;
    @Transactional //添加删除修改操作Hibernate必须开启事务支持
    public void testHibernate() {
        //插入操作
        User user = new User("454","45454");
        sessionFactory.getCurrentSession().persist(user);//调用persist()来添加
        //获得自增id:
        System.out.println(user.getId());
        System.out.println(user);
        //删除操作
        
        User user1 = sessionFactory.getCurrentSession().byId(User.class).load("5555");
        if (user1 != null) {
            sessionFactory.getCurrentSession().remove(user);//调用remove()来删除
            System.out.println("删除成功");
        }else{
            System.out.println("删除失败");
        }
        //修改操作
        User user2 = sessionFactory.getCurrentSession().byId(User.class).load("2");
        user.setName("111111");
        sessionFactory.getCurrentSession().merge(user);//merge()来修改
    }
}

使用原生sql进行查询

@Component
public class UserMapperByHibernate {
    @Autowired
    SessionFactory sessionFactory;

    @Transactional
    public void testHibernateQuery(){
        //使用原始sql查询多条数据
        List<User> list = sessionFactory.getCurrentSession()
            .createNativeQuery("select * from user limit ?1,?2", User.class)
            .setParameter(1, 0).setParameter(2, 5)
            .list();
        System.out.println(list);
    }
}

4.5.使.用mybatis框架访问数据库

4.5.1.什么是mybatis框架

MyBatis是一种半自动化ORM框架,介于全自动ORM如Hibernate和手写全部如JdbcTemplate之间,它只负责把ResultSet自动映射到Java Bean,或者自动填充Java Bean参数,但仍需自己写出SQL。

4.5.2.使用mybatis框架

添加mybatis依赖

<!--mysql连接驱动-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.45</version>
</dependency>
<!--spring整合mybatis-->
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis-spring</artifactId>
    <version>3.0.0</version>
</dependency>
<!--mybatis核心依赖-->
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.11</version>
</dependency>

创建连接池和myabtis实例

@Configuration
@PropertySource(value = "classpath:application.properties",encoding = "utf-8"")
@EnableTransactionManagement
@MapperScan("gc.learn.mapper") //指定扫描的mapper接口
public class DatabaseConfig {
    @Value("${database.url}")
    private String jdbcUrl;
    @Value("${database.username}")
    private String userName;
    @Value("${database.password}")
    private String password;
    @Bean
    public DataSource createDataSource(){
        MysqlDataSource dataSource = new MysqlDataSource();
        dataSource.setURL(this.jdbcUrl);
        dataSource.setUser(this.userName);
        dataSource.setPassword(this.password);
        return dataSource;
    }

    @Bean //创建jdbc事务实例,mybatis使用的是jdbc事务实例
    public PlatformTransactionManager createTxManager(@Autowired DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    @Bean //创建mybatis实例
    SqlSessionFactoryBean createSqlSessionFactoryBean(@Autowired DataSource dataSource) {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);
        //XML映射文件位置,不配置默认路径需要和mapper接口一致
        ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
        Resource[] resource = resourceResolver.getResources("classpath:mapper/*Mapper*.xml");
        sqlSessionFactoryBean.setMapperLocations(resource);
        //配置控制台打印sql
        org.apache.ibatis.session.Configuration configuration =new org.apache.ibatis.session.Configuration();
        configuration.setLogImpl(org.apache.ibatis.logging.stdout.StdOutImpl.class);
        sqlSessionFactoryBean.setConfiguration(configuration);
        return sqlSessionFactoryBean;
    }
}

编写mapper接口

@Mapper
public interface UserMapperByMybatis {
    //增加
    @Insert("INSERT INTO user (name, password) VALUES (#{user.name}, #{user.password})")
    Integer insert(@Param("user") User user);

    //删除
    @Delete("DELETE FROM user WHERE id = #{id}")
    void deleteById(@Param("id") long id);
    //修改
    @Update("UPDATE user SET name = #{user.name} WHERE id = #{user.id}")
    void update(@Param("user") User user);

    //查询,返回多条记录
    @Select("SELECT * FROM user LIMIT #{offset}, #{maxResults}")
    List<User> getAll(@Param("offset") int offset, @Param("maxResults") int maxResults);
}

以上是注解方式配置,mybatis运行使用xml方式进行配置,好处是可以可以动态组装SQL,例如in查询可以在xml中用循环解决。

resources/mapper/userMapperByMybatis.xml文件内容如下,因为我配置文件中配置了映射文件位置为classpath:mapper/*Mapper.xml,如果不配置路径则需要userMapperByMybatis.xml和userMapperByMybatis接口路径保持一致*

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="gc.learn.mapper.UserMapperByMybatis">
    <resultMap id="userResultMap" type="gc.learn.entity.User">
        <id property="id" column="id" />
        <result property="name" column="name"/>
        <result property="password" column="password"/>
    </resultMap>

    <select id="getById" resultMap="userResultMap">
        select * from user where id = #{id}
    </select>


    <update id="updateUser">
        UPDATE user SET
        <set>
            <if test="user.name != null"> name = #{user.name} </if>
            <if test="user.password != null"> hobby = #{user.hobby} </if>
        </set>
        WHERE id = #{user.id}
    </update>

    <select id="selectPostIn" resultMap="userResultMap">
        SELECT *
        FROM user
        WHERE ID in
        <foreach item="item" index="index" collection="list"
                 open="(" separator="," close=")">
            #{item}
        </foreach>
    </select>
</mapper>

使用MyBatis最大的问题是所有SQL都需要全部手写,优点是执行的SQL就是我们自己写的SQL,对SQL进行优化非常简单,也可以编写任意复杂的SQL,或者使用数据库的特定语法,但切换数据库可能就不太容易。好消息是大部分项目并没有切换数据库的需求,完全可以针对某个数据库编写尽可能优化的SQL。

4.6.手写ORM框架

这里我们准备实现的ORM并不想要全自动ORM那种自动读取一对多和多对一关系的功能,也不想给Entity加上复杂的状态,因此,对于Entity来说,它就是纯粹的JavaBean,没有任何Proxy。

此外,ORM要兼顾易用性和适用性。易用性是指能覆盖95%的应用场景,但总有一些复杂的SQL,很难用ORM去自动生成,因此,也要给出原生的JDBC接口,能支持5%的特殊需求。

最后,我们希望设计的接口要易于编写,并使用流式API便于阅读。为了配合编译器检查,还应该支持泛型,避免强制转型。

TODO

5.Web应用(spring mvc)

5.1.spring整合WebMVC

5.1.1.什么是WebMVC

之前我们在servlet那篇文章中讲解了MVC框架和servlet相关知识,总的来说servlet有以下几个重要组件

  • Servlet:能处理HTTP请求并将HTTP响应返回;
  • JSP:一种嵌套Java代码的HTML,将被编译为Servlet;
  • Filter:能过滤指定的URL以实现拦截功能;
  • Listener:监听指定的事件,如ServletContext、HttpSession的创建和销毁。

其实WebMVC本质就是Servlet+视图解析模板+Contrller控制层,在Servlet那篇文章中讲解了MVC的实现,现在我们可以不用手写了,通过引入WebMVC依赖就可以完成一个web应用实现下面这种效果。

@Controller
public class UserController {
    @GetMapping("/register")
    public ModelAndView register() {
        ...
    }

    @PostMapping("/signin")
    public ModelAndView signin(@RequestParam("email") String email, @RequestParam("password") String password) {
        ...
    }
    ...
}

5.1.2.使用WebMVC

引入WebMVC依赖

<!--servlet依赖-->
<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>4.0.1</version>
    <scope>provided</scope>
</dependency>
<!--引入webmvc依赖-->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
    <version>5.2.12.RELEASE</version>
</dependency>
<!--thymeleaf模板引擎-->
<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf-spring5</artifactId>
    <version>3.0.15.RELEASE</version>
</dependency>

webMcv配置类,用来配置包扫描,扫描注解。

@Configuration
@ComponentScan("gc.learn.*")
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {
    /*
     * 开启对静态资源的访问
     * 类似在Spring MVC的xml配置文件中设置<mvc:default-servlet-handler/>元素
     */
    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }
    //放行静态资源
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        //将网络请求路径/static/** 映射到本地文件路径/WEB-INF/static/,
        //本地路径我测试是不能添加classpath否则加载不到,而且必须有要有/WEB-INF/
        registry.addResourceHandler("/static/**")
                .addResourceLocations("/WEB-INF/static/");
    }
}

webmvc容器启动配置类,加载Spring的ioc容器,以前我们是通过主方法来启动Ioc容器的,现在因为我们需要在web服务器中运行,所以要启动ioc容器就需要通过Web服务器来帮我们启动,当继承AbstractDispatcherServletInitializer类并实现以下三个方法时,Web服务器启动时就会调用我们实现的三个方法,通过这三个方法就能完成spring IOC容器的加载。

在SpringMVC中,有其实有两个应用上下文:RootApplicationContext、WebApplicationContext。其中,WebApplicationContext是DispatcherServlet专属的上下文,用来加载Controller、ViewResolver、HandlerMapping等web相关的Bean。RootApplicationContext则是加载数据库、@Service等中间件中的Bean。其中,WebApplicationContext继承了RootApplicationContext中的所有Bean,以便在@Controller中注入@Service等依赖。

//继承AbstractDispatcherServletInitializer类,
//当Web服务器启动时会调用继承过来的createServletApplicationContext方法
public class ServletConfig extends AbstractDispatcherServletInitializer {
    //当Web服务器启动时该方法会最先调用,主要用来加载spring的ioc容器
    @Override
    protected WebApplicationContext createServletApplicationContext() {
        //AnnotationConfigWebApplicationContext相当于Spring的Ioc容器,
        AnnotationConfigWebApplicationContext webApplicationContext
                = new AnnotationConfigWebApplicationContext();
        //通过register方法加载配置类
        webApplicationContext.register(WebMvcConfig.class);
        return webApplicationContext;
    }
    //当前web服务其启动完成后会调用该方法,该方法可以用来配置filter过滤器和Listener监听器
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        super.onStartup(servletContext);
        //创建一个字符编码过滤器,处理所有请求的字符编码
        CharacterEncodingFilter characterEncodingFilter
                = new CharacterEncodingFilter();
        characterEncodingFilter.setEncoding("utf-8");
        templateResolver.setCacheable(false);
        templateResolver.setTemplateMode("HTML5");
        //将过滤器添加到servlet运行容器中
        FilterRegistration.Dynamic filterRegistration
                = servletContext.addFilter("characterEncodingFilter", characterEncodingFilter);
        //设置对哪些请求进行拦截过滤
        filterRegistration.addMappingForServletNames(
                EnumSet.of(DispatcherType.REQUEST,DispatcherType.INCLUDE,DispatcherType.FORWARD),
                false,"/*");

    }
    //配置servlet拦截所有请求
    @Override
    protected String[] getServletMappings() {
        return new String[]{"/"};
    }

    @Override
    protected WebApplicationContext createRootApplicationContext() {
        return null;
    }
}

视图引擎配置

我这里用的模板是thymeleaf模板

@Configuration
public class ViewEngineConfig {
    //模板解析器,我这里配置的是thymeleaf模板解析器
    @Bean
    public SpringResourceTemplateResolver createTemplateResolver(){
        SpringResourceTemplateResolver templateResolver
                = new SpringResourceTemplateResolver();
        templateResolver.setPrefix("/WEB-INF/templates/");
        templateResolver.setSuffix(".html");
        templateResolver.setCharacterEncoding("utf-8");
        templateResolver.setCacheable(false);
        templateResolver.setTemplateMode("HTML5");
        return templateResolver;

    }
    //模板引擎,我这里配置的是thymeleaf模板引起
    @Bean
    public SpringTemplateEngine springTemplateEngine(SpringResourceTemplateResolver templateResolver) {
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.setTemplateResolver(templateResolver);
        return templateEngine;
    }
    //视图解析器,我这里配置的是thymeleaf试图解析器
    @Bean
    public ThymeleafViewResolver viewResolver(SpringTemplateEngine springTemplateEngine) {
        ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
        viewResolver.setTemplateEngine(springTemplateEngine);
        viewResolver.setCharacterEncoding("utf-8");
        return viewResolver;
    }
}

让后在main路径下创建webapp目录,创建WEB-INF目录等,对应的目录结构如下,然后就是将maven项目改造为maven web应用,可以参考servlet学习笔记中的JavaServlet项目创建。

image-20250503000851207

base.html内容如下

<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" th:fragment="html (head,content)">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>MVC</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<!--    <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.1.1/css/bootstrap.css" rel="stylesheet">-->
    <link href="/static/css/bootstrap.css" rel="stylesheet">
</head>
<body style="font-size:16px" >
<div class="d-flex flex-column flex-md-row align-items-center p-3 px-md-4 mb-3 bg-white border-bottom shadow-sm">
    <h5 class="my-0 mr-md-auto font-weight-normal"><a href="/">Home</a></h5>
    <nav class="my-2 mr-md-3">
        <a class="p-2 text-dark" target="_blank" href="https://www.leduo.work/">Learn</a>
        <a class="p-2 text-dark" target="_blank" href="#">Source</a>
    </nav>
    <th:block th:if="${user==null}">
        <a href="/signin" class="btn btn-outline-primary">Sign In</a>
    </th:block>
    <th:block th:if="${user!=null}">
        <span>Welcome, <a href="/user/profile">[[${user.name}]]</a></span>
        &nbsp;&nbsp;&nbsp;
        <a href="/signout" class="btn btn-outline-primary">Sign Out</a>
    </th:block>
</div>

<div class="container" style="max-width: 960px">
    <div class="row">
        <div class="col-12 col-md">
            <section>
                <th:block th:replace="${content}"/>
            </section>
        </div>
    </div>

    <footer class="pt-4 my-md-5 pt-md-5 border-top">
        <div class="row">
            <div class="col-12 col-md">
                <h5>Copyright&copy;2020</h5>
                <p>
                    <a target="_blank" href="https://www.leduo.work/">Learn</a> -
                    <a target="_blank" href="https://www.leduo.work/">Download</a> -
                    <a target="_blank" href="https://www.leduo.work/">Github</a>
                </p>
            </div>
        </div>
    </footer>
</div>
</body>
</html>

index.html内容如下

<div th:replace="~{base :: html(head = null,content = ~{::content})}">
    <th:block th:fragment="content">
<!--        <h3>Welcome [[${user.name}]]!</h3>-->
        <th:block th:if="${user!=null}">
            <h3>Welcome [[${user.name}]]!</h3>
        </th:block>
        <th:block th:if="${user==null}">
            <h3>Welcome</h3>
        </th:block>
    </th:block>
</div>

编写controller控制层代码

@Controller
public class IndexController {
    @Autowired
    UserService userService;
    @GetMapping("/")
    public ModelAndView index(){
        userService.getUserById();
        User user = new User("AL", "666666");
        ModelAndView index = new ModelAndView();
        index.setViewName("index");
        index.addObject("user",user);
        return index;
    }
}

运行后结果如下:

image-20250503001926799

5.2.请求和响应

5.2.1.请求request

@RequestMapping请求注解

这请求注解是个万能注解,它可以接受任何类型的请求,例如get、post、put、delete、Patch等。get请求是没有请求体的,而post是有请求体的,直接的不同就是网络请求中的Content-Type属性,我们熟悉的form表单post请求的Content-Type属性值为“application/x-www-form-urlencoded; charset=UTF-8”,而json数据对应的Content-Type属性值为“application/json;charset=UTF-8”,所以当需要发起json数据的post请求时,需要设置Content-Type属性值为“application/json;charset=UTF-8”。

用作用于类或者方法上、主要参数属性有value和method,其中value为网络请求路径,method为网络请求类型

  • 例如要查询用户,就使用GET请求(没有请求体)
  • 例如要新增用户,就使用POST请求(有请求体)
  • 例如要更新用户,就使用PUT请求(可以有请求体,必须为json)
  • 例如要删除用户,就使用DELETE请求(可以有请求体,必须为json)

映射基本数据类型

网络请求参数名称和方法参数名称匹配,如果要获取原始请求对象可以在参数中添加HttpServletRequest对象参数,通过它可以拿到session对象、Cookies对象等,这个就是servlet类中的原始请求对象。不管是什么类型的网络请求,只要参数匹配就能完成映射。

//http://127.0.0.1:8080/rm?name=小明&password=123456,参数名和方法名一一对应
@RequestMapping(value = "/rm",method = RequestMethod.GET)
public ModelAndView requestMapping(String name,String password){
    User user = new User(name, password);
    HashMap<String, User> hashMap = new HashMap<>();
    hashMap.put("user",user);
    return new ModelAndView("index",hashMap);
}

请求参数名称和方法参数名称不匹配时,通过@RequestParam来进行矫正匹配,它需要写在方法参数中,通过将请求username矫正匹配为name

//http://127.0.0.1:8080/rm1?username=小明&password=123456
@RequestMapping(value = "/rm1",method = RequestMethod.GET)
public ModelAndView requestMapping1(@RequestParam(name = "username") String name,
                                    String password){
    User user = new User(name, password);
    HashMap<String, User> hashMap = new HashMap<>();
    hashMap.put("user",user);
    return new ModelAndView("index",hashMap);
}

restful风格,通过路径匹配参数,需要通过注解**@PathVariable**进行参数修饰,用来匹配请求路径中的参数,delete请求,只能通过路径参数匹配获取值,put同样也只能也只能通过路径匹配获取值,如果要映射成为实体类就只能通过json映射。

//http://127.0.0.1:8080/rm1/小明/123456,通过路径匹配,@PathVariable("name") 可以通过参数纠正匹配参数
//@RequestMapping(value = "/rm1/{name}/{pssword}",method = RequestMethod.DELETE)
//@RequestMapping(value = "/rm1/{name}/{pssword}",method = RequestMethod.PUT)
@RequestMapping(value = "/rm1/{name}/{pssword}",method = RequestMethod.GET)
public ModelAndView requestMapping1_1(@PathVariable String name, @PathVariable String password){
    User user = new User(name, password);
    HashMap<String, User> hashMap = new HashMap<>();
    hashMap.put("user",user);
    return new ModelAndView("index",hashMap);
}

因为前端的form表单默认不支持restful风格的,所以需要需要配置一个filter(HiddenHttpMethodFilter对象)来让form表单支持restfull风格。

//继承AbstractDispatcherServletInitializer类,
//当Web服务器启动时会调用继承过来的createServletApplicationContext方法
public class ServletConfig extends AbstractDispatcherServletInitializer {
    //当Web服务器启动时该方法会最先调用,主要用来加载spring的ioc容器
    @Override
    protected WebApplicationContext createServletApplicationContext() {
        //AnnotationConfigWebApplicationContext相当于Spring的Ioc容器,
        AnnotationConfigWebApplicationContext webApplicationContext
                = new AnnotationConfigWebApplicationContext();
        //通过register方法加载配置类
        webApplicationContext.register(WebMvcConfig.class);
        return webApplicationContext;
    }
    //当前web服务其启动完成后会调用该方法,该方法可以用来配置filter过滤器和Listener监听器
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        super.onStartup(servletContext);
        //添加restful风格支持
        HiddenHttpMethodFilter hiddenHttpMethodFilter = new HiddenHttpMethodFilter();
        FilterRegistration.Dynamic hiddenHttpMethodFilter1 = servletContext.addFilter("hiddenHttpMethodFilter", hiddenHttpMethodFilter);
        hiddenHttpMethodFilter1.addMappingForServletNames( EnumSet.of(DispatcherType.REQUEST,DispatcherType.INCLUDE,DispatcherType.FORWARD),
                false,"/*");
    }
    //配置servlet拦截所有请求
    @Override
    protected String[] getServletMappings() {
        return new String[]{"/"};
    }

    @Override
    protected WebApplicationContext createRootApplicationContext() {
        return null;
    }
}

然后form表单就需要写成下面这种形式进行提交,注意:name的值必须为“_method”。因为现在基本都是使用Ajax等技术来发起请求了,所以直接使用form表单来发起restful风格请求很少,下面就是一个form表单发起restful风格请求。

image-20250503182055415

映射复杂参数类型

复杂类型就是对象类型,例如:实体类、集合、数组等

映射为实体类(键值数据对映射为实体类),需要网络请求参数和实体类属性一一对应,未对应的参数将无法完成映射也就是参数值为null。

//http://127.0.0.1:8080/rm1?name=小明&password=123456
@RequestMapping(value = "rm2",method = RequestMethod.GET)
public ModelAndView requestMapping2(User user){
    HashMap<String, User> hashMap = new HashMap<>();
    hashMap.put("user",user);
    return new ModelAndView("index",hashMap);
}

映射为实体类(json数据映射为实体类),json数据必须要通过post请求发起,所以网络请求类型必须为post,而且需要设置Content-Type属性值为“application/json;charset=UTF-8”,同时需要使用json转换工具将json转换为对应实体,可以用市面上的jackson工具或者fastjson来完成转换,这里使用jackson工具(它不需要手动配置,而fastjson需要手动配置)。

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.13.0-rc2</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.13.0-rc2</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-annotations</artifactId>
    <version>2.13.0-rc2</version>
</dependency>

@RequestBody表名该参数是json请求体内容,需要将其转换为实体类。

//接收json数据,并通过jackson转换为User实体类
@RequestMapping(value = "rm01",method = RequestMethod.POST)
public ModelAndView requestMapping01(@RequestBody User user) throws IOException {
    HashMap<String, User> hashMap = new HashMap<>();
    hashMap.put("user",user);
    return new ModelAndView("index",hashMap);
}

restful风格映射为实体类(json数据映射为实体类

@RequestMapping(value = "rm02",method = RequestMethod.PUT)
public ModelAndView requestMapping02(@RequestBody User user) throws IOException {
    HashMap<String, User> hashMap = new HashMap<>();
    hashMap.put("user",user);
    return new ModelAndView("index",hashMap);
}

restful风格映射为集合(json数据映射为集合

@PutMapping
@RequestMapping(value = "rm03",method = RequestMethod.DELETE)
public ModelAndView requestMapping03(@RequestBody List<String> list) throws IOException {
    HashMap<String, User> hashMap = new HashMap<>();
    hashMap.put("user",new User("1","1"));
    return new ModelAndView("index",hashMap);
}

其他类似@RequestMapping请求注解的注解有

@GetMapping

等价于@RequestMapping(method = RequestMethod.GET)

@PostMapping

等价于@RequestMapping(method = RequestMethod.POST)。

@PutMapping

等价于@RequestMapping(method = RequestMethod.PUT)。

@DeleteMapping

等价于@RequestMapping(method = RequestMethod.DELETE)

总结

@RequestMapping是一个万能请求注解,获取数据时用get请求,删除或者批量删除用delete,新增数据用post,修改数据用put,如果涉及到多参数请求请使用json进行传输。

5.2.2.响应response

响应json数据

通过@ResponseBody注解返回,因为将对象转换为json字符串格式需要用到转换工具,所以需要提前添加上转换工具依赖,推荐使用jackson工具(它不需要手动配置,引入即可。)

导入jackson工具

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.13.0-rc2</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.13.0-rc2</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-annotations</artifactId>
    <version>2.13.0-rc2</version>
</dependency>

通过@ResponseBody注解将对象转换为json字符串然后返回

@RequestMapping(value = "rm0101",method = RequestMethod.POST)
@ResponseBody //通过
public User requestMapping0101(@RequestBody User user) {
    return user;
}

响应数据视图模板

返回ModelAndView,通过模板引擎完成

引入thymeleaf依赖

<!--thymeleaf模板引擎-->
<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf-spring5</artifactId>
    <version>3.0.15.RELEASE</version>
</dependency>

配置thymeleaf模板引擎

@Configuration
public class ViewEngineConfig {
    //模板解析器,我这里配置的是thymeleaf模板解析器
    @Bean
    public SpringResourceTemplateResolver createTemplateResolver(){
        SpringResourceTemplateResolver templateResolver
                = new SpringResourceTemplateResolver();
        templateResolver.setPrefix("/WEB-INF/templates/");
        templateResolver.setSuffix(".html");
        templateResolver.setCharacterEncoding("utf-8");
        templateResolver.setCacheable(false);
        templateResolver.setTemplateMode("HTML5");
        return templateResolver;

    }
    //模板引擎,我这里配置的是thymeleaf模板引起
    @Bean
    public SpringTemplateEngine springTemplateEngine(SpringResourceTemplateResolver templateResolver) {
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.setTemplateResolver(templateResolver);
        return templateEngine;
    }
    //视图解析器,我这里配置的是thymeleaf试图解析器
    @Bean
    public ThymeleafViewResolver viewResolver(SpringTemplateEngine springTemplateEngine) {
        ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
        viewResolver.setTemplateEngine(springTemplateEngine);
        viewResolver.setCharacterEncoding("utf-8");
        return viewResolver;
    }
}

配置返回数据视图模板

@GetMapping("/register")
public ModelAndView register() {
    return new ModelAndView("register");
}

其他响应注解

@RestController

等价于@ResponseBody + @Controller合在一起的作用

总结:如果要返回json数据,需要借助转换工具(jackson、fastjson等),如果需要返回视图数据模板需要借助模板引擎(thymeleaf模板引擎、JSP等)。

5.3.文件上传与下载

文件上传和下载是web不可或缺的功能,要实现文件的上传和下载在java中是非常简单的,只需要引入一个通用的文件上传依赖即可实现文件上传,要实现文件下载可以通过设置http的header值来完成。

通过引入通用文件上传依赖commons-fileupload

<!--通用文件上传工具-->
<dependency>
    <groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
    <version>1.3.3</version>
</dependency>

5.3.1.文件上传

引入commons-fileupload需要配置工具,内容如下

注意:bean的名称必须为multipartResolver,默认情况下@bean的注入的bean名称为方法名称。

@Configuration
public class Config {
    @Bean("multipartResolver") //注意该bean的名称必须为multipartResolver
    public MultipartResolver createCommonsMultipartResolver(){
        CommonsMultipartResolver commonsMultipartResolver
                = new CommonsMultipartResolver();
        //控制单次最大上传大小为5M
        commonsMultipartResolver.setMaxUploadSize(5 * 1024 * 1024);
        return commonsMultipartResolver;
    }
}

5.3.2.小文件上传

编写contrller控制器方法

如果要接收文件需要使用post来处理,其中produces是指定返回的内容类型,可以不加,如果需要返回字符串的时候可以通过该参数指定返回的内容类型,@RequestPart("myFile")注解必须要使用,用来form表单传输过来的二进制封装到MultipartFile中,文件是否上传完整可以通过前端传入的md5进行校验。

@PostMapping(value = "fileupload",produces = {"application/json;charset=utf-8"})
@ResponseBody
public Map fileUpload(@RequestPart("myFile") MultipartFile myFIle,
                      String md5,
                      HttpServletRequest httpServletRequest) throws Exception {
    if (myFIle==null){
        throw new Exception("MultipartFile不能为空,上传失败");
    }
    System.out.println(myMD5);
    //将文件名从ISO_8859_1编码转换为UTF-8,获取真实文件名
    byte[] bytes = myFIle.getOriginalFilename().getBytes(StandardCharsets.ISO_8859_1);
    String name = new String(bytes,StandardCharsets.UTF_8);
    //获取文件后缀名 .type
    String type = name.substring(name.lastIndexOf("."));
    //文件类型
    String contentType = myFIle.getContentType();
    //文件大小
    long size = myFIle.getSize();
    LocalDateTime now = LocalDateTime.now();
    DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy/MM/dd");
    String dateNow = now.format(df);
    //获取 Web 应用程序在服务器上的实际路径,拼接upload路径,拼接以时间为单位的路径
    ServletContext servletContext = httpServletRequest.getServletContext();
    String realPath = servletContext.getRealPath("/upload/"+dateNow+"/");
    //根据拼接的路径创建目录
    File file = new File(realPath);
    if (!file.exists()){
        file.mkdirs();
    }
    //用spring框架提供的方法计算md5
    String newMD5 = DigestUtils.md5DigestAsHex(new FileInputStream(toFile));
    String message="文件上传成功!";
    String code="200";
    if (!md5.equals(newMD5)){
        System.out.println("文件不一致");
        message="上传失败,文件已损毁,请重新上传!";
        code="500";
        throw new Exception("文件md5不一致,文件已损坏!");
    }
    Map<String,String> map=new HashMap<>();
    map.put("code",code);
    map.put("message",message);
    return map;
    //以UUID为名称,防止文件重名
    String s = UUID.randomUUID().toString().replace("-", "").toUpperCase();
    //拼接上文件后缀
    String fileName=file.getPath()+File.separator+s+type;
    //将上传的转储到刚才指定的目录
    myFIle.transferTo(new File(fileName));
    Map<String,String> map=new HashMap<>();
    map.put("code","200");
    map.put("message","上传成功!");
    return map;
}

前端方法

首先需要引入js依赖来计算文件的md5

<script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>

对应的input元素

<div class="form-group">
    <label>选择要上传的文件</label>
    <input type="file" class="form-control-file" id="fileUpload">
</div>
<script>
    $(function () {
        $("#fileUpload").change(function () {
            let files = this.files
            for (let i = 0; i < files.length; i++) {
                computeMD5(files[i])
            }
            this.value = ''
        })
		//计算文件的md5
        function computeMD5(file) {
            let myMD5;
            let fr = new FileReader();
            fr.readAsBinaryString(file);
            fr.onload = function (e){
                let md5 = SparkMD5.hashBinary(e.target.result);
                //计算完成后开始上传文件
                fileUpload(file,md5)
            }
        }
        //上传文件
        function fileUpload(file,md5) {
            //构建一个form表单
            let formData = new FormData();
            formData.append("myFile", file);
            formData.append("md5", md5);
            $.ajax(
                {
                    url: "http://127.0.0.1:8080/fileupload",
                    data: formData,
                    type: "post",
                    async: "false",
                    contentType: false, //不要让Jquery处理,否则会导致上传失败
                    processData: false, //不要让Jquery处理,否则会导致上传失败
                    success: function (result) {
                        console.log(result)
                    },
                    error: function (result) {
                        console.log(result)
                    }
                }
            );
        }
    })
</script>

5.3.3.大文件分块上传

实现思路前端将文件进行分块,前端发送分块文件的的md5值、源文件的md5值、是第几个分块,用三个信息来确定服务器中是否包含该分块,,如果服务器中没有该分块,就让前端发起请求上传分块,当所有分块传输完毕后,前端发起一个合并分块请求,将分块合并成一个完整的文件,再对比一下合成的文件的md5值还有合成文件的大小就可以确定文件合并后的文件是否完整了。

编写contrller控制器方法检查分块是否存在

//检查分片情况
@PostMapping("/checkChunk")
@ResponseBody
public Map checkChunk(String sourceMD5,String chunkMD5,Integer num) throws Exception {
    //分块存储位置
    String chunkFilePath="D:\\fileTemp\\temp\\chunk\\"+sourceMD5+"\\"+num;
    String code="200";
    Boolean isExist=true;
    String message="已经上传过了";
    File chunkFile=new File(chunkFilePath);
    //判断分块文件是否存在
    if (!chunkFile.exists()){
        code="200";
        isExist=false; //不存在就反馈给前端,分块不存在,前端根据该值来决定是否上传分块
        message="文件未上传";
    }else {
        //对比分块是否损坏
        FileInputStream fis = new FileInputStream(chunkFile);
        String newMD5 = DigestUtils.md5DigestAsHex(fis);
        if (!chunkMD5.equals(newMD5)){
            code="200";
            isExist=false; //损坏的分块告知前端,需要重新上传
            message="分块已损坏,请重新上传";
        }
        fis.close();

    }
    HashMap<String,Object> hashMap = new HashMap<>();
    hashMap.put("code",code);
    hashMap.put("isExist",isExist);
    hashMap.put("message",message);
    return hashMap;
}

编写contrller控制器方法上传分块

@PostMapping(value = "chunkUpload")
@ResponseBody
public Map chunkUpload(@RequestPart("myFile") MultipartFile myFIle,
                       String sourceMD5,String chunkMD5,Integer num
                      ) throws Exception {
    if (myFIle==null){
        throw new Exception("MultipartFile不能为空,上传失败");
    }
    System.out.println(chunkMD5);
    //将文件名从ISO_8859_1编码转换为UTF-8,获取真实文件名
    byte[] bytes = myFIle.getOriginalFilename().getBytes(StandardCharsets.ISO_8859_1); 
    String name = new String(bytes,StandardCharsets.UTF_8);
    //根据拼接的路径创建目录
    String chunkFile="D:\\fileTemp\\temp\\chunk\\"+sourceMD5;
    File file = new File(chunkFile);
    if (!file.exists()){
        file.mkdirs();
    }
    File chunkPath = new File("\\"+file.getPath() + "\\" + num);
    //将分块复制到指定路径
    myFIle.transferTo(chunkPath);
    //用spring框架提供的方法计算md5
    FileInputStream fis = new FileInputStream(chunkPath);
    String newMD5 = DigestUtils.md5DigestAsHex(fis);
    fis.close();
    String message="文件上传成功!";
    String code="200";
    if (!chunkMD5.equals(newMD5)){
        System.out.println("文件不一致");
        message="上传失败,文件已损毁,请重新上传!";
        code="500";
        throw new Exception("文件md5不一致,文件已损坏!");
    }
    Map<String,String> map=new HashMap<>();
    map.put("code",code);
    map.put("message",message);
    return map;
}

编写contrller控制器方法合并分块

@PostMapping("/fileMerge")
@ResponseBody
public Map fileMerge(String fileName,String type,String size,String md5) throws Exception {
    byte[] bytes = fileName.getBytes(StandardCharsets.ISO_8859_1);
    fileName=new String(bytes,StandardCharsets.UTF_8);
    HashMap<String, String> map = new HashMap<>();
    String message="";
    String sourceFilePath="D:\\fileTemp\\temp\\chunk\\"+md5;
    String targetFilePath="D:\\fileTemp\\temp\\merge\\"+md5;
    String fileMerged=targetFilePath+"\\"+fileName;
    File sourceFile=new File(sourceFilePath);
    File targetFile=new File(targetFilePath);
    File mergeFile = new File(fileMerged);
    if (!sourceFile.exists()){
        throw new Exception("分块文件不存在,请重新上传!");
    }
    if (!targetFile.exists()){
        targetFile.mkdirs();
    }
	//检查文件是否已经合并过了
    if (mergeFile.exists()){
        FileInputStream fis = new FileInputStream(mergeFile);
        String newMD5 = DigestUtils.md5DigestAsHex(fis);
        if (md5.equals(newMD5)&&(Long.parseLong(size)==mergeFile.length())){
            message="文件已经合并完成了,不用多次合并";
        }
        fis.close();
    }else {
        File[] files = sourceFile.listFiles();
        //对分块排序,保证数据完整性
        Arrays.sort(files,(File f1,File f2)->{
            return (Integer.valueOf(f1.getName())-Integer.valueOf(f2.getName()));
        });
        //合并文件
        BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(mergeFile));
        for (File file : files) {
            System.out.println(file.getName());
            FileInputStream fis = new FileInputStream(file);
            BufferedInputStream bis = new BufferedInputStream(fis);
            int len=-1;
            byte[] bits=new byte[1*1024*1024];
            while ((len=bis.read(bits))!=-1){
                bos.write(bits,0,len);
            }
            fis.close();
            bis.close();
            bos.flush();
            System.out.println("完成  "+file.getName()+"  文件合并");
        }
        bos.close();
        message="全部文件合并完成!路径为:"+mergeFile.getPath();
        System.out.println("全部文件合并完成!路径为:"+mergeFile.getPath());
    }
    map.put("code","200");
    map.put("message",message);
    return map;
}

前端的js控制,这里没有对这些函数封装,其实可以写一个函数来封装ajax请求,让代码重复利用。

<div class="form-group">
    <label>选择要上传的文件</label>
    <input type="file" class="form-control-file" id="fileUpload">
</div>
<script>
$(function () {
    let chunkSize = 2 * 1024 * 1024;
    let sourceMD5 = "";
    let fileName = "";
    let type = "";
    let size = "";
    let allChunks = 0;
    let chunkIndex = 0;
    $("#fileUpload").change(function () {
        let files = this.files
        sourceMD5 = "";
        for (let i = 0; i < files.length; i++) {
            let file = files[i]
            console.log(file)
            fileName = file.name; //源文件名称
            size = file.size //源文件大小
            type = file.type  //源文件类型
            computeMD5(file); //计算源文件md5值
        }
        this.value = ''
    })
	//计算文件的md5值,
    function computeMD5(file, num) {
        let fr = new FileReader();
        fr.readAsBinaryString(file);
        fr.onload = function (e) {
            console.log("正在计算MD5值")
            let md5 = SparkMD5.hashBinary(e.target.result);
            console.log(sourceMD5)
            if (sourceMD5 === "") {
                sourceMD5 = md5;
                //对源文件进行分块
                chunkFile(file, chunkSize, false);
            } else {
                //检查服务器端分块是否存在
                checkChunk(sourceMD5, md5, num, file)
            }
        }
    }
    //对源文件进行分块,大小设置的是2M=2*1024*1024
    function chunkFile(file, chunkSize) {
        const chunks = Math.ceil(file.size / chunkSize);
        allChunks = chunks;
        console.log(allChunks)
        const chunksList = [];
        let currentChunk = 0;
        while (currentChunk < chunks) {
            const start = currentChunk * chunkSize;
            const end = Math.min(file.size, start + chunkSize);
            const chunk = file.slice(start, end);
            // chunksList.push(chunk);
            computeMD5(chunk, currentChunk);
            currentChunk++;
        }
    }
	//合并分块,将源文件名称、类型、大小、md5值上传到服务端
    function fileMerge(data) {
        let fromData = new FormData();
        fromData.append("fileName", data.fileName)
        fromData.append("type", data.type)
        fromData.append("size", data.size)
        fromData.append("md5", data.md5);
        $.ajax(
            {
                url: "http://127.0.0.1:8080/fileMerge",
                data: fromData,
                type: "post",
                contentType: false,
                processData: false,
                success: function (result) {
                    console.log(result)
                },
                error: function (result) {
                    console.log(result)
                }
            }
        );
    }
	//对分块文件进行检查,只对服务器不存在的分块进行上传
    function checkChunk(mySourceMD5, chunkMD5, num, file) {
        let formData = new FormData();
        formData.append("sourceMD5", sourceMD5);
        formData.append("chunkMD5", chunkMD5);
        formData.append("num", num);
        $.ajax(
            {
                url: "http://127.0.0.1:8080/checkChunk",
                data: formData,
                type: "post",
                async: "false",
                timeout: 3000,
                contentType: false,
                processData: false,
                success: function (result) {
                    console.log(result)
                    if (result.isExist === false) {
                        chunkUpload(file, mySourceMD5, chunkMD5, num, 0)
                    } else {
                        chunkIndex++
                        if (chunkIndex === allChunks) {
                            chunkIndex = 0;
                            console.log("合并请求")
                            let data = {
                                fileName: fileName,
                                type: type,
                                size: size,
                                md5: sourceMD5
                            }
                            fileMerge(data)
                            sourceMD5 = "";
                        }
                    }
                },
                error: function (result) {
                    console.log(result)
                }
            }
        );
    }
	//上传分块,5秒超时,失败重试三次
    function chunkUpload(myfile, mySourceMD5, chunkMD5, num, retryCount) {
        console.log("上传第:" + num + " 个分块");
        let formData = new FormData();
        formData.append("myFile", myfile);
        formData.append("sourceMD5", mySourceMD5);
        formData.append("chunkMD5", chunkMD5);
        formData.append("num", num);
        //当传输二进制的表单时,contentType: false和processData: false必须要设置
        $.ajax(
            {
                url: "http://127.0.0.1:8080/chunkUpload",
                data: formData,
                type: "post",
                async: "false",
                timeout: 5000, //设置请求超时5秒
                contentType: false,
                processData: false,
                success: function (result) {
                    console.log(result)
                    console.log(chunkIndex)
                    chunkIndex++;
                    if (chunkIndex === allChunks) {
                        chunkIndex = 0;
                        console.log("发起合并请求")
                        let data = {
                            fileName: fileName,
                            type: type,
                            size: size,
                            md5: sourceMD5
                        }
                        fileMerge(data)
                        sourceMD5 = "";
                    }
                },
                error: function (result) {
                    console.log(result)
                    let f = formData.get("myFile")
                    let s = formData.get("sourceMD5");
                    let c = formData.get("chunkMD5");
                    let n = formData.get("num");
                    //失败重试
                    if (result.statusText === "timeout") {
                        if (retryCount < 3) {
                            retryCount++
                            console.log("请求超时!重新发起请求。" + retryCount + "次")
                            chunkUpload(f, s, c, n, retryCount)
                        } else {
                            console.log("重试失败次数过多。。。停止重试")
                        }
                    }
                }
            }
        );
    }
})
</script>

5.3.4.文件下载

文件下载是一般来说小文件,例如图片、js、css这些小文件就直接将资源路径开放出去就可以了,通过配置放行这些资源,例如下面的配置类。通过实现WebMvcConfigurer的addResourceHandlers方法放行小文件即可。

@ComponentScan(value = "gc.learn.*")
@EnableWebMvc
@EnableAspectJAutoProxy//开启AOP代理
public class WebMvcConfig implements WebMvcConfigurer {
    /*
     * 开启对静态资源的访问
     * 类似在Spring MVC的xml配置文件中设置<mvc:default-servlet-handler/>元素
     */
    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }
    //放行静态资源,例如:图片、js、css等
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        //将网络请求路径/static/** 映射到本地文件路径/WEB-INF/static/
        registry.addResourceHandler("/static/**")
                .addResourceLocations("/WEB-INF/static/");
    }

    @Bean("multipartResolver")
    public MultipartResolver createCommonsMultipartResolver(){
        CommonsMultipartResolver commonsMultipartResolver
                = new CommonsMultipartResolver();
        commonsMultipartResolver.setMaxUploadSize(5 * 1024 * 1024);
        return commonsMultipartResolver;
    }
}

5.3.5.大文件分片下载

下载所谓的分片并不需要将一个大文件真实的分块存储存储在下载,而是通过更改文件指针指向,返回前端所需要的那一段文件内容就好。通过文件随机读写对象RandomAccessFile的seek方法设置文件指针,也可以通过BufferedInputStream对象的skip方法设置文件指针移动,当然最重要的还是要设置htt协议响应的下载请求头,只有这样才能触发浏览器的下载操作。

@GetMapping("/download")
public void fileDownload(HttpServletRequest request, HttpServletResponse response) throws IOException {
    String filePath="F:\\a\\b\\c.zip";
    //得到文件对象
    File file = new File(filePath);
    //得到文件大小
    long fileSize=file.length();
    //得到文件名称
    String fileName = URLEncoder.encode(file.getName(),"UTF-8");
    //设置http协议中相应标头,这些都可以在http协议官方文档中查到
    //Content-Type表示响应的内容为二进制下载数据
    response.setContentType("application/x-download");
    //Content-Disposition 表示响应的内容该以保存的形式呈现(弹出下载对话框)
    response.setHeader("Content-Disposition","attachment; filename=\""+fileName+"\"");
    //Accept-Ranges,向客户端表明支持分块下载
    response.setHeader("Accept-Ranges","bytes");
    response.setCharacterEncoding("UTF-8");
    //通过自定义标头将文件名称和文件大小返回给前端(这里只能这样返回给前端,因为响应体是文件二进制)
    response.setHeader("fileSize",fileSize+"");
    response.setHeader("fileName",fileName);
    //设置分片信息
    //如果前端下载支持分片下载就会在请求头中设置一个叫Range的请求头,表示请求从起始位置和结束位置这之间的文件内容
    //Range的请求头值为bytes=0-1000或者0-,它表示的是请求文件的0到1000字节的下标位置的部分文件或者0到整个文件末尾完的部分。
    //分别表示起始位置,结束位置,总共已经读取了多少字节(用于判断文件是否读取完了)
    long start=0,end=fileSize-1,sum=0;
    //获取请求头中的Range
    String range = request.getHeader("Range");
    RandomAccessFile raf=null;
    BufferedOutputStream os = null;
    try {
        //判断标头range是否存在,并解析出起始位置和结束位置
        if (range!=null&&(!"".equals(range))){
            String[] rangeValue = range.substring(range.indexOf("=")).split("-");
            if (rangeValue.length==2){
                start= Long.parseLong(rangeValue[0]);
                end= Long.parseLong(rangeValue[1]);
            }else {
                start=Long.parseLong(rangeValue[0]);
            }
        }
        //要读取的字节数数量
        byte[] bytes = new byte[2*1024*1024];
        //请求的总字节长度
        long rangeSize=end-start+1;
        //读取的长度
        int length = 0;
        //Content-Range HTTP 响应标头表示部分消息在完整消息中的位置。Content-Range: bytes 200-1000/67589
        String contentRange="bytes "+start+"-"+end+"/"+fileSize;
        response.setHeader("Content-Range",contentRange);
        //HTTP Content-Length 标头表示发送给接收方的消息体的大小(以字节为单位)。
        response.setHeader("Content-Length",rangeSize+"");
        //字节输出流,输出源为http响应字节流
        os = new BufferedOutputStream(response.getOutputStream());
        //随机读取流,可以通过seek方法设置文件指针,指针下标从0开始
        raf = new RandomAccessFile(file,"rw");
        raf.seek(start);
        //读取不超过请求的总字节数
        while (sum<rangeSize){
            length = raf.read(bytes);
            sum+=length;
            if (length!=-1){
                os.write(bytes,0,length);
            }
        }
        System.out.println("文件下载完成!");
    }catch (Exception e){
        System.out.println("文件下载出错!");
        e.printStackTrace();
    }finally {
        os.close();
        raf.close();
    }
}

5.3.6.流媒体

实现了服务器的大文件的下载,其实就已经解决了流媒体了,无非就是前端将获取的视频片段进行播放,当然使用fmpga工具

TODO

5.4.Filter过滤器

目前spring的web容器中存在两个容器,一个是Servlet容器,另一个是Spring的ioc容器,这两个容器是互不影响且不知道对方的存在,就是导致了一种情况,那就是当我们需要在,filter过滤器中注入ico容器中的bean时就会出现问题,因为filter是在servlet容器中运行的,所以直接注入是不行的,为了解决这个问题,springmvc专门设计了DelegatingFilterProxy类。过滤器可以用于鉴权认证的等信息。

DelegatingFilterProxy类

编写一个filter,这里使用的是spring中的@Component注解,并注入UserService

@Component("myFilter")
public class MyFilter implements Filter {
    @Autowired
    UserService userService;
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("MyFilter");
        System.out.println(userService);
        chain.doFilter(request,response);
    }
}

在servlet配置配置类中重写getServletFilters方法

//继承AbstractDispatcherServletInitializer类,
//当Web服务器启动时会调用继承过来的createServletApplicationContext方法
public class ServletConfig extends AbstractDispatcherServletInitializer {
    //当Web服务器启动时该方法会最先调用,主要用来加载spring的ioc容器
    @Override
    protected WebApplicationContext createServletApplicationContext() {
        //AnnotationConfigWebApplicationContext相当于Spring的Ioc容器,
        AnnotationConfigWebApplicationContext webApplicationContext
                = new AnnotationConfigWebApplicationContext();
        //通过register方法加载配置类
        webApplicationContext.register(WebMvcConfig.class);
        return webApplicationContext;
    }
    //当前web服务其启动完成后会调用该方法,该方法可以用来配置filter过滤器和Listener监听器
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        super.onStartup(servletContext);
        //创建一个字符编码过滤器,处理所有请求的字符编码
        CharacterEncodingFilter characterEncodingFilter
                = new CharacterEncodingFilter();
        characterEncodingFilter.setEncoding("utf-8");
        //将过滤器添加到servlet运行容器中
        FilterRegistration.Dynamic filterRegistration
                = servletContext.addFilter("characterEncodingFilter", characterEncodingFilter);
        //设置对哪些请求进行拦截过滤
        filterRegistration.addMappingForServletNames(
                EnumSet.of(DispatcherType.REQUEST,DispatcherType.INCLUDE,DispatcherType.FORWARD),
                false,"/*");
    }
	//使用DelegatingFilterProxy代理实例化filter
    @Override
    protected Filter[] getServletFilters() {
        DelegatingFilterProxy filterProxy = new DelegatingFilterProxy();
        filterProxy.setTargetBeanName("myFilter");
        DelegatingFilterProxy filterProxy2 = new DelegatingFilterProxy();
        filterProxy2.setTargetBeanName("myFilterServlet");
        return new Filter[]{filterProxy,filterProxy2};
    }

    //配置servlet拦截所有请求
    @Override
    protected String[] getServletMappings() {
        return new String[]{"/"};
    }

    @Override
    protected WebApplicationContext createRootApplicationContext() {
        return null;
    }
}

5.5.Interceptor拦截器

Filter由Servlet容器管理,而Interceptor是IOC容器管理,他们都可以对请求进行过滤,但是作用范围是不同的,filter是拦截范围包括了Interceptor,Interceptor的拦截范围是Controller方法,因为Interceptor受ioc管理,所以要在Interceptor中注入bean就可以直接使用。

使用Interceptor拦截器

一个Interceptor拦截器必须实现HandlerInterceptor。

@Order(1)
@Component
public class MyInterceptor implements HandlerInterceptor {
    @Autowired
    UserService userService;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println("在controller方法前执行");
        System.out.println(userService);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println("在controller方法正常返回后完后执行");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("不管controller是否异常都会执行");
    }
}

在springmvc配置类中注册拦截器

@Configuration
@ComponentScan(value = "gc.learn.*")
@EnableWebMvc
@EnableWebSocket
@EnableAspectJAutoProxy//开启AOP代理
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    HandlerInterceptor[] interceptors; //注入所有的拦截器
    @Override //注册拦截器
    public void addInterceptors(InterceptorRegistry registry) {
        for (HandlerInterceptor interceptor : interceptors) {
            registry.addInterceptor(interceptor);
        }
    }
}

5.6.WebSocket长连接

WebSocket是一种基于HTTP的长链接技术。传统的HTTP协议是一种请求-响应模型,如果浏览器不发送请求,那么服务器无法主动给浏览器推送数据。如果需要定期给浏览器推送数据,例如股票行情,或者不定期给浏览器推送数据,例如在线聊天,基于HTTP协议实现这类需求,只能依靠浏览器的JavaScript定时轮询,效率很低且实时性不高,而通过WebSocket能保持服务器和浏览器端的长时间连接,从而完成服务器主动向浏览器推送信息。

对于WebSocket的http连接请求对应的http请求头内容如下

GET /chat HTTP/1.1
Host: www.example.com
Upgrade: websocket
Connection: Upgrade

而服务端相应WebSocket连接的http响应头内容如下

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

收到成功响应后表示WebSocket“握手”成功,这样,代表WebSocket的这个TCP连接将不会被服务器关闭,而是一直保持,服务器可随时向浏览器推送消息,浏览器也可随时向服务器推送消息。双方推送的消息既可以是文本消息,也可以是二进制消息,一般来说,绝大部分应用程序会推送基于JSON的文本消息。

使用WebSocket

引入websocket依赖

<!--websocket依赖-->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
    <version>5.2.12.RELEASE</version>
</dependency>
<!--tomcat的websocket-->
<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-websocket</artifactId>
    <version>9.0.30</version>
</dependency>

配置websocket

开启EnableWebSocket支持、

@Configuration
@ComponentScan(value = "gc.learn.*")
@EnableWebSocket //开启EnableWebSocket支持、
public class WebMvcConfig implements WebMvcConfigurer {
    //创建WebSocketConfigurer配置,添加ChatHandlerWebSocket处理类和处理类对应的拦截器
    //ChatHandshakeInterceptor拦截器将session中自定义的属性和属性值复制到WebSocketSession中
    //TokenHandshakeInterceptor拦截器允许前端通过WebSocket子协议传递token
    @Bean
    WebSocketConfigurer createWebSocketConfigurer(
            @Autowired ChatHandlerWebSocket chatHandler,
            @Autowired ChatHandshakeInterceptor chatInterceptor,
            @Autowired TokenHandshakeInterceptor tokenHandshakeInterceptor)
    {
        return new WebSocketConfigurer() {
            public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
                // 把URL与指定的WebSocketHandler关联,可关联多个:
                registry.addHandler(chatHandler, "/chat").addInterceptors(chatInterceptor).addInterceptors(tokenHandshakeInterceptor);
            }
        };
    }
}

ChatHandlerWebSocket处理器,用来处理WebSocket请求

@Component
public class ChatHandlerWebSocket extends TextWebSocketHandler {
    @Autowired
    ChatHistory chatHistory;


    //存储所有已经连接的websocket的客户端信息
    private Map<String, WebSocketSession> clients=new ConcurrentHashMap<>();

    //websocket连接后调用该方法
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        Map<String, Object> attributes = session.getAttributes();
        //获取前端通过WebSocket子协议传递过来的token
        String token = session.getHandshakeHeaders().get("sec-websocket-protocol").get(0);
        //获取的是通过session复制的属性和值user
        User user = (User)attributes.get("user");
        System.out.println("用户:"+user.getName()+"  连接成功!" +"  token:"+token);
        //将当前WebSocketSession存储到连接池中
        clients.put(session.getId(),session);
        ObjectMapper objectMapper = new ObjectMapper();
        //发送历史消息
        TextMessage textMessage = new TextMessage(objectMapper.writeValueAsString(chatHistory.getHistory()));
        session.sendMessage(textMessage);
    }

    //断开WebSocket连接时调用该方法
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("sessionId:"+session.getId()+"  断开连接!");
        clients.remove(session.getId());
    }
	//接收前端发过来的WebSocket消息
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String s = message.getPayload();
        if (s.isEmpty()) {
            return;
        }
        User user = (User) session.getAttributes().get("user");
        ObjectMapper objectMapper = new ObjectMapper();
        ChatMessage chatMessage = objectMapper.readValue(s, ChatMessage.class);
        System.out.println(user.getName()+"发来消息:"+chatMessage.text);
        //加入历史消息
        chatHistory.addToHistory(chatMessage);
        ChatMessage chatMessage1 = new ChatMessage();
        chatMessage1.text="消息已经收到";
        chatMessage1.name="system";
        chatMessage1.timestamp= Instant.now().toEpochMilli();
        chatHistory.addToHistory(chatMessage1);
        TextMessage textMessage = new TextMessage(objectMapper.writeValueAsString(chatHistory.getHistory()));
        session.sendMessage(textMessage);
    }
}

ChatHandshakeInterceptor拦截器将session中自定义的属性和属性值复制到WebSocketSession中

@Component
public class ChatHandshakeInterceptor extends HttpSessionHandshakeInterceptor {
    public ChatHandshakeInterceptor(){
        super(Arrays.asList("user"));
    }
}

TokenHandshakeInterceptor拦截器允许前端通过WebSocket子协议传递token

@Component
public class TokenHandshakeInterceptor implements HandshakeInterceptor {
    @Override //握手前
    public boolean beforeHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Map<String, Object> map) throws Exception {
        return true;
    }

    @Override //握手后
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Exception e) {
        HttpServletRequest httpRequest = ((ServletServerHttpRequest) request).getServletRequest();
        HttpServletResponse httpResponse = ((ServletServerHttpResponse) serverHttpResponse).getServletResponse();
        if (httpRequest.getHeader("Sec-WebSocket-Protocol") != null) {
            //添加Sec-WebSocket-Protocol响应头,否则会导致子协议的握手失败
            httpResponse.addHeader("Sec-WebSocket-Protocol", httpRequest.getHeader("Sec-WebSocket-Protocol"));
        }
    }
}

前端控制器,返回页面,设置一个session属性user,ChatHandshakeInterceptor拦截器会将该属性和对应值复制到WebSocketSession中

@GetMapping("/chatRoom")
public ModelAndView chatRoom(HttpServletRequest request, HttpServletResponse responserespo){
    User user = new User("AL", "666666");
    HttpSession session = request.getSession();
    session.setAttribute("user",user);
    return new ModelAndView("/chatRoom");
}
<div th:replace="~{base :: html(head = '聊天室',content = ~{::content})}">
    <th:block th:fragment="content">
        <div id="chatTemplate" style="display:none">
            <div class="mb-1">
                <p>
                    <span class="chat-name font-weight-bold"></span>:
                    <br>
                    <span class="chat-message"></span>
                    <br>
                    <span class="chat-timestamp text-muted" style="font-size:0.8em"></span>
                </p>
            </div>
        </div>


        <h3>Chat Room</h3>
        <div id="chatRoom" class="overflow-auto border border-info rounded p-3" style="height:480px">
            <div id="connecting">
                <div class="spinner-border" role="status">
                    <span class="sr-only">Loading...</span>
                </div>
                <span>Connecting...</span>
            </div>
        </div>
        <form id="chatForm" onsubmit="return sendChatMessage()">
            <div class="form-group">
                <label>Message:</label>
                <input type="text" class="form-control" max-length="100">
            </div>
            <button type="submit" class="btn btn-primary" disabled>Submit</button>
        </form>
    </th:block>
</div>
<script>
	
    function sendChatMessage() {
        var input = $('#chatForm input[type=text]');
        var s = input.val();
        s = $.trim(s);
        if (s) {
            console.log('send: ' + s);
            let data={
                name:"test",
                text: s,
                timestamp:new Date().valueOf()
            }
            window.chatWs.send(JSON.stringify(data));
            input.val('');
        }
        return false;
    }

    function appendMessage(msg) {
        console.log(msg)
        var templ = $('#chatTemplate').html();
        var div = $('<div></div>');
        div.html(templ);
        div.find('.chat-name').text(msg.name);
        div.find('.chat-timestamp').text(new Date(msg.timestamp).toLocaleString());
        div.find('.chat-message').text(msg.text);
        var room = $('#chatRoom');
        room.append(div);
        room.scrollTop(room.prop('scrollHeight') - room.height());
    }
	
    $(function () {
        console.log(location.host)
        let token=['hdjkaskjdahsdkahsdajdsad']  //WebSocket子协议携带token值
        let ws = new WebSocket('ws://' + location.host + '/chat',[token]);
        ws.addEventListener('open', function (event) {
            $('#connecting').hide();
            $('#chatForm button[type=submit]').removeAttr('disabled');
        });
        ws.addEventListener('message', function (event) {
            console.log('message: ' + event.data);
            var msgs = JSON.parse(event.data);
            for (msg of msgs) {
                appendMessage(msg);
            }
        });
        ws.addEventListener('close', function () {
            console.log("WebSocket" + "  被关闭了")
            $('#chatForm button[type=submit]').attr('disabled', 'disabled');
        });
        window.chatWs = ws;
    });
</script>

运行效果如下:

image-20250508224331702

总结:通过websocket可以实现聊天室功能,实现实时通信,也可以用于服务端主动推送消息到客户端。


Comment