本篇来学习SpringBoot如何整合Web开发,由于其中涉及的内容非常多,因此分为三篇来进行记录。所谓的SpringBoot如何整合Web开发,其实就是对前面的一些具体细节的补充。本篇学习的内容具体包括:返回JSON数据、静态资源访问、文件上传和@ControllerAdvice等相关知识。

返回JSON数据

默认实现

JSON是目前主流的前后端数据传输方式,SpringMVC中使用消息转换器HttpMessageConverter对JSON的转换提供了很好的支持,在SpringBoot中这种支持更进一步得到了提升,也对相关的配置做了进一步的简化。在默认情况下,当开发人员新创建一个SpringBoot项目后,添加Web依赖,也就是web启动器:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

这个依赖中默认添加了jackson-databind作为JSON处理器,此时不需要添加额外的JSON处理器就能返回一段JSON。

使用spring Initializr构建工具构建一个SpringBoot的Web应用,名称为webspringboot,然后添加spring-boot-starter-web依赖,也就是上面的代码。在com.envy.webspringboot包内新建一个pojo包,接着在里面新建Book实体类,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.envy.webspringboot.pojo;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.Date;

public class Book {
private Integer id;
private String name;

@JsonIgnore
private Integer price;
@JsonFormat(pattern="yyyy-MM-dd")
private Date publishDate;

//getter和setter方法,包含三个参数的构造方法
}

在上面使用了@JsonIgnore@JsonFormat注解。当你不希望某个字段被序列化为JSON字符串时就可以在该字段上使用@JsonIgnore注解;当你需要对某个字段的输出格式进行自定义的时候就可以使用@JsonFormat注解,同时里面传入一个pattern对象作为序列化时的标准样式。

接着在com.envy.webspringboot包内新建一个controller包,然后在里面新建BookController实体类用于序列化字符串,其实就是将前面的对象转换为JSON对象,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.envy.webspringboot.controller;

import com.envy.webspringboot.pojo.Book;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.Date;

@Controller
public class BookController {

@GetMapping("/book")
@ResponseBody
public Book book(){
Book book = new Book(1,"西游记",128,new Date());
return book;
}
}

当然如果你需要频繁的使用@ResponseBody注解,那么可以使用@RestController这个组合注解来替换@Controller@ResponseBody注解。此时代码就变成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.envy.webspringboot.controller;

import com.envy.webspringboot.pojo.Book;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;


import java.util.Date;

@RestController
public class BookController {

@GetMapping("/book")
public Book book(){
Book book = new Book(1,"西游记",128,new Date());
return book;
}
}

运行项目,在浏览器地址栏中输入http://localhost:8080/book,即可看到返回了JSON数据:(使用了JSON Viewer插件后的显示结果,价格不会显示)

这是SpringBoot自带的处理方式。如果采用这种方式,那么对于字段忽略、日期格式化等常见需求都是可以通过注解来实现的。其实这都是Spring中默认提供的MappingJackson2HttpMessageConverter来实现的,因此你可以根据自己的实际需求来自定义JSON转换器。

自定义转换器

常见的JSON处理器除了jackson-dababind之外,还有GSON和fastjson,这里针对常见的用法分别来进行举例说明。

(1)使用Gson。Gson是Google开源的一个JSON解析框架,如果你想使用Gson,需要先除去默认的jackson-databind,然后加入Gson依赖,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!--使用GSON需要先移除默认的jackson-databind-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>

由于SpringBoot中默认提供了Gson的自动转换类GsonHttpMessageConverter,因此Gson的依赖添加成功后,可以像使用jackson-databind那样直接使用Gson。但是在Gson进行转换时,如果想对日期数据进行格式化,那么还需要开发者自定义HttpMessageConverter

你可以通过如下方式来自定义HttpMessageConverter。首先需要阅读GsonHttpMessageConverter中的一段源码:

1
2
3
4
public GsonHttpMessageConverter(Gson gson) {
Assert.notNull(gson, "A Gson instance is required");
this.gson = gson;
}

注意此处Gson的版本为2.8.6,使用了断言来保证必须传入Gson对象。它允许用户可以不提供一个GsonHttpMessageConverter对象,如果用户没有提供时,SpringBoot会默认提供一个GsonHttpMessageConverter对象。

com.envy.webspringboot包内新建一个config包,接着在里面新建GsonConfig类用于返回一个GsonHttpMessageConverter对象,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.envy.webspringboot.config;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.GsonHttpMessageConverter;

import java.lang.reflect.Modifier;

@Configuration
public class GsonConfig {

@Bean
GsonHttpMessageConverter gsonHttpMessageConverter(){
GsonHttpMessageConverter converter = new GsonHttpMessageConverter();
GsonBuilder builder = new GsonBuilder();
builder.setDateFormat("yyyy-MM-dd");
builder.excludeFieldsWithModifiers(Modifier.PROTECTED);

Gson gson =builder.create();
converter.setGson(gson);
return converter;
}
}

接下来介绍一下上述代码的含义,开发人员首先需要定义一个方法用于提供一个GsonHttpMessageConverter的实例。然后设置Gson的解析日期时的解析规则,接着设置Gson解析时,修饰符为protected的字段被过滤掉。(这里的过滤方式和jackson-databind不一样,需要特别注意。),最后就是创建Gson对象,并将其放入到GsonHttpMessageConverter的实例中并返回converter对象即可。

前面说了Gson解析时被过滤的字段前面是通过使用protected修饰符来修饰的,因此可以新建一个GsonBook类,这里仅仅是将原来Book类中的price字段前面的修饰符修改为protected,相应的代码为:

1
2
3
4
5
6
7
8
9
public class GsonBook {
private Integer id;
private String name;

/**通过设置protected标识符来进行字段的过滤**/
protected Integer price;
private Date publishDate;
//getter和setter方法,包含三个参数的构造方法
}

既然这样,那可以在BookController中新建一个gsonBook方法,里面的代码为:

1
2
3
4
5
@GetMapping("/gsonbook")
public GsonBook gsonBook(){
GsonBook gsonBook = new GsonBook(2,"红楼梦",168,new Date());
return gsonBook;
}

运行项目,在浏览器地址栏中输入http://localhost:8080/gsonbook,即可看到返回了JSON数据:(价格被过滤了,依旧不会显示)


(2)使用fastjson。fastjson是阿里巴巴开源的一个JSON解析框架,是目前JSON解析速度最快的开源框架,该框架也可以集成到SpringBoot中。不同于Gson的地方在于,fastjson集成完后不能立即使用,需要提供一个HttpMessageConverter后才能使用。

同样如果你想使用fastjson,需要先除去默认的jackson-databind,然后加入fastjson依赖,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--使用fastjson需要先移除默认的jackson-databind-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>

接着在config包内新建MyFastJsonConfig类(不能定义为FastJsonConfig类,因为这个就是它内置的配置类)用于返回一个FastJsonHttpMessageConverter对象,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.envy.webspringboot.config;

import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson.support.config.FastJsonConfig;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.nio.charset.Charset;

@Configuration
public class MyFastJsonConfig {

@Bean
FastJsonHttpMessageConverter fastJsonHttpMessageConverter(){
FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
FastJsonConfig config = new FastJsonConfig();
config.setDateFormat("yyyy-MM-dd");
config.setCharset(Charset.forName("UTF-8"));
config.setSerializerFeatures(
SerializerFeature.WriteClassName,
SerializerFeature.WriteMapNullValue,
SerializerFeature.PrettyFormat,
SerializerFeature.WriteNullListAsEmpty,
SerializerFeature.WriteNullStringAsEmpty
);
converter.setFastJsonConfig(config);
return converter;
}
}

首先你需要自定义一个MyFastJsonConfig类,用于返回一个FastJsonHttpMessageConverter实例。接着实例化一个FastJsonConfig对象,并在里面设置fastjson解析JOSN的一些细节,如日期格式、数据编码、是否在生成的JSON中输出类名、是否输出value为null的数据、生成的JSON格式化、空集合输出[]而非null、空字符串输出””,而不是null等基本配置。

请注意MyFastJsonConfig类配置完后,还需要配置一下响应的编码,否则返回的JSON中文会乱码,你只需要在application.properties配置文件中添加如下配置:

1
spring.http.encoding.force-response=true

pojo对象可以继续使用前面的GsonBook,自然controller包内的gsonBook方法也是可以使用的。运行一下项目,在浏览器地址栏中输入http://localhost:8080/gsonbook,即可看到返回了JSON数据:(价格没有被过滤,因此会被显示)

对于FastJsonHttpMessageConverter的配置,除了上述这种方式外,还有一种方式。在SpringBoot项目中,当开发者引入spring-boot-starter-web依赖后,该依赖又依赖了spring-boot-autoconfigure,在这个自动化配置中,有一个WebMvcAutoConfiguration类提供了对SpringMVC最基本的配置,如果某一项自动化配置不满足开发需求,开发者可以针对该项来自定义配置,此时只需要实现WebMvcConfigurer接口即可(在Spring5.0之前是通过继承WebMvcConfigurerAdapter类来实现的),相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.envy.webspringboot.config;

import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson.support.config.FastJsonConfig;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.nio.charset.Charset;
import java.util.List;

@Configuration
public class MyWebMvcConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
FastJsonConfig config = new FastJsonConfig();
config.setDateFormat("yyyy-MM-dd");
config.setCharset(Charset.forName("UTF-8"));
config.setSerializerFeatures(
SerializerFeature.WriteClassName,
SerializerFeature.WriteMapNullValue,
SerializerFeature.PrettyFormat,
SerializerFeature.WriteNullListAsEmpty,
SerializerFeature.WriteNullStringAsEmpty
);
converter.setFastJsonConfig(config);
converters.add(converter);
}
}

这里自定义的MyWebMvcConfig类实现了WebMvcConfigurer接口中的configureMessageConverters方法,然后将自定义的FastJsonHttpMessageConverter对象加入到converters中。

当然如果你使用了Gson,也是可以采用上面这种方式,但是并不推荐这么做。因为当项目中没有GsonHttpMessageConverter时,SpringBoot会默认提供一个GsonHttpMessageConverter,此时你如果重写configureMessageConverters方法,而参数converters中已经有GsonHttpMessageConverter的实例了,需要替换已有的GsonHttpMessageConverter实例,操作比较麻烦,所以对于Gson,推荐直接提供GsonHttpMessageConverter这种方式。

(3)使用hutoolHutool是一个小而全的Java工具类库,通过静态方法封装,用于降低相关API的学习成本,提高工作效率,使Java拥有函数式语言般的优雅。该框架也可以集成到SpringBoot中。同样,hutool集成完后不能立即使用,需要提供一个HttpMessageConverter后才能使用。

同样如果你想使用hutool,需要先除去默认的jackson-databind,然后加入hutool依赖,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--使用hutool需要先移除默认的jackson-databind-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.3</version>
</dependency>

请注意使用hutool是不需要配置HttpMessageConverter,因为它内部已经实现了,因此最后可以直接返回String对象。在pojo包内新建一个HutoolBook类,里面的代码为:

1
2
3
4
5
6
7
public class HutoolBook {
private Integer id;
private String name;
private Integer price;
private Date publishDate;
//getter和setter方法,包含所有参数的构造方法
}

接着在BookController中新建一个hutoolBook方法,里面的代码为:

1
2
3
4
5
6
7
@GetMapping("/hutoolbook")
public String hutoolBook(){
HutoolBook hutoolBook = new HutoolBook(3,"三国演义",null,new Date());
//第二2个true表示跳过空值, 第三个参数表示保持有序
JSONObject jsonObject = JSONUtil.parseObj(hutoolBook,true,true);
return jsonObject.toStringPretty();
}

运行项目,在浏览器地址栏中输入http://localhost:8080/hutoolbook,即可看到返回了JSON数据:(价格被过滤了,因为你这里设置了跳过空值,所以值为null的属性都不会显示)

Hutool默认将日期输出为时间戳,如果需要自定义日期格式,可以修改上述代码为:

1
2
3
4
5
6
7
8
@GetMapping("/hutoolbook")
public String hutoolBook(){
HutoolBook hutoolBook = new HutoolBook(3,"三国演义",null,new Date());
//第二2个true表示跳过空值, 第三个参数表示保持有序
JSONObject jsonObject = JSONUtil.parseObj(hutoolBook,true,true);
jsonObject.setDateFormat("yyyy-MM-dd");
return jsonObject.toStringPretty();
}

同样,JSONUtil还可以支持以下对象转为JSONObject对象:String对象、Java Bean对象、Map对象、XML字符串(使用JSONUtil.parseFromXml方法)、ResourceBundle(使用JSONUtil.parseFromResourceBundle)等,所以优先建议大家使用hutool,这个工具配置和使用非常简单。

静态文件上传

在SpringMVC中,对于静态资源都需要开发者手动配置静态资源过滤。SpringBoot中对此提供了自动化配置,可以简化静态资源过滤配置。

默认策略

前面介绍过SpringBoot对于SpringMVC的自动化配置都是在WebMvcAutoConfiguration类中,因此默认的静态资源过滤策略可以从这个类中一探究竟。

WebMvcAutoConfiguration类中有一个静态内部类WebMvcAutoConfigurationAdapter,同样它也实现了前面提到的WebMvcConfigurer接口。而WebMvcConfigurer接口中有一个addResourceHandlers方法,该方法用于配置静态资源过滤。这个addResourceHandlers方法在WebMvcAutoConfigurationAdapter类中得到了实现,里面有一段比较核心的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void addResourceHandlers(ResourceHandlerRegistry registry) {
//其他代码

String staticPathPattern = this.mvcProperties.getStaticPathPattern();
if (!registry.hasMappingForPattern(staticPathPattern)) {
this.customizeResourceHandlerRegistration(registry.addResourceHandler(new String[]{staticPathPattern})
.addResourceLocations(WebMvcAutoConfiguration.getResourceLocations(this.resourceProperties
.getStaticLocations())).setCachePeriod(this.getSeconds(cachePeriod))
.setCacheControl(cacheControl));
}

}
}

SpringBoot在这里进行了默认的静态资源过滤配置,其中staticPathPattern默认定义在WebMvcProperties中(通过无参的构造方法来进行设置),定义的内容如下:

1
2
3
4
5
public WebMvcProperties() {
......
this.staticPathPattern = "/**";
......
}

回到addResourceHandlers方法中,看下面这行代码:this.resourceProperties.getStaticLocations();,它获取到的默认静态资源位置定义在ResourceProperties类中,相应的代码为:

1
private static final String[] CLASSPATH_RESOURCE_LOCATIONS = new String[]{"classpath:/META-INF/resources/", "classpath:/resources/", "classpath:/static/", "classpath:/public/"};

看到没有,这里一共有4个方法,但是这个WebMvcAutoConfiguration.getResourceLocations()方法又对这4个静态资源位置进行了扩充,代码如下:

1
2
3
4
5
6
static String[] getResourceLocations(String[] staticLocations) {
String[] locations = new String[staticLocations.length + SERVLET_LOCATIONS.length];
System.arraycopy(staticLocations, 0, locations, 0, staticLocations.length);
System.arraycopy(SERVLET_LOCATIONS, 0, locations, staticLocations.length, SERVLET_LOCATIONS.length);
return locations;
}

再来看一下这行代码private static final String[] SERVLET_LOCATIONS = new String[]{"/"};,它告诉我们这个SERVLET_LOCATIONS其实是一个字符串数组,值为{"/"}

通过前面的分析可以知道,SpringBoot默认会过滤所有的静态资源,而静态资源一共有5个位置,分别为:classpath:/META-INF/resources/classpath:/resources/classpath:/static/classpath:/public/、和/,也就是说开发者可以将静态资源放到这5个位置中的任意一个。注意需要按照定义的顺序,5个静态资源位置的优先级依次降低。但是通常SpringBoot项目不需要webapp目录,所以第5个/一般不会考虑。

你可以尝试按照上面的方式来创建对应的4个目录,4个目录中放入同名的静态资源(序号表示优先级):

启动项目,在浏览器地址栏中输入http://localhost:8080/avatar.jpg就可以访问到classpath:/META-INF/resources/目录下的avatar.jpg。如果将classpath:/META-INF/resources/目录下的avatar.jpg删除掉,那么访问的就是classpath:/resources/目录下的avatar.jpg文件,以此类推。如果你使用IDEA来创建SpringBoot项目,那么它会默认创建出classpath:/static/,也就是序号3对应的路径,此时静态资源一般就放在这个目录下。

自定义策略

如果默认的静态资源过滤策略不能满足开发需求,也可以自定义静态资源过滤策略,同样自定义静态资源过滤策略有两种方式:

(1)在配置文件中定义。这种方式是最简单的,只需要在application.properties配置文件中直接定义过滤规则和静态资源位置:

1
2
3
4
# 设置访问路径前缀
spring.mvc.static-path-pattern=/static/**
# 设置静态资源路径
spring.resources.static-locations=classpath:/static/

这里设置了过滤规则为/static/**,静态资源位置为classpath:/static/

启动项目,在浏览器地址栏中输入http://localhost:8080/static/avatar.jpg就可以访问到classpath:/static/目录下的avatar.jpg资源文件。

(2)通过java代码定义。其实也可以通过书写一个类,只要它实现WebMvcConfigurer接口中的addResourceHandlers方法即可,相应的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.envy.webspringboot.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**静态文件访问配置**/
@Configuration
public class MyResourceWebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
//第一个方法设置访问路径前缀,第二个方法设置资源路径
registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
}
}

同样启动项目,在浏览器地址栏中输入http://localhost:8080/static/avatar.jpg依然可以访问到classpath:/static/目录下的avatar.jpg资源文件。

文件上传

SpringMVC对文件上传做了简化,在SpringBoot中这种简化就更彻底了,几乎是零配置。

Java中文件的上传涉及到两个组件:CommonsMultipartResolverStandardServletMultipartResolver。其中CommonsMultipartResolver使用commons-fileupload来处理mutilpart请求,而StandardServletMultipartResolver则是基于Servlet3.0来处理mutilpart请求,所以如果你使用CommonsMultipartResolver,则不需要添加额外的Jar包。众所周知,Tomcat自7.0版本开始就支持Servlet3.0,而此处使用的SpringBoot版本为2.1.14,内嵌的Tomcat版本为9.0.34,所以其实不用添加额外的Jar包,也是可以直接使用StandardServletMultipartResolver的。SpringBoot提供的文件上传自动配置类MultipartAutoConfiguration中,默认也是使用了StandardServletMultipartResolver,部分源码为:

1
2
3
4
5
6
7
8
9
@Bean(
name = {"multipartResolver"}
)
@ConditionalOnMissingBean({MultipartResolver.class})
public StandardServletMultipartResolver multipartResolver() {
StandardServletMultipartResolver multipartResolver = new StandardServletMultipartResolver();
multipartResolver.setResolveLazily(this.multipartProperties.isResolveLazily());
return multipartResolver;
}

注意到没有,它使用了@ConditionalOnMissingBean注解,也就是说如果开发者没有提供MultipartResolver,那么就会采用默认的MultipartResolver也就是StandardServletMultipartResolver。所以前面就说了,SpringBoot基本上做到了上文文件时的零配置。

单文件上传

首先创建一个SpringBoot的Web项目(名称为filespringboot),然后在resources目录的static目录下新建一个fileupload.html文件,该文件用于测试单文件上传:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>单文件上传</title>
</head>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="uploadFile" value="请选择文件">
<input type="submit" value="开始上传">
</form>
</body>
</html>

这个页面非常的简单,就是一个演示单文件上传的demo,上传使用的接口是/upload,请求方式是Post,这个name控件中的uploadFile就是后续action方法中传入的MultipartFile对象。特别注意就是需要设置enctype="multipart/form-data",这个配置非常重要,博主曾经就因为这个而导致无法修复Bug。。。

接着就是新建一个FileUploadController,用于处理文件上传接口,这样我们让文件上传后返回一个String格式的URL地址。里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.envy.filespringboot.controller;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;

@RestController
public class FileUploadController {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy/MM/dd/");

@PostMapping("/upload")
public String upload(MultipartFile uploadFile, HttpServletRequest request){
String realPath = request.getSession().getServletContext().getRealPath("/uploadFile/");
String format = simpleDateFormat.format(new Date());
File folder = new File(realPath+format);
if(!folder.isDirectory()){
folder.mkdirs();
}
String oldName = uploadFile.getOriginalFilename();
String newName = UUID.randomUUID().toString()+oldName.substring(oldName.lastIndexOf("."), oldName.length());
try{
uploadFile.transferTo(new File(folder,newName));
String filePath = request.getScheme()+"://"+request.getServerName()+":"+request.getServerPort()+"/uploadFile/"+format+newName;
return filePath;
}catch (IOException e){
e.printStackTrace();
}
return "文件上传失败";
}
}

解释一下上面代码的含义:首先我们设置上传文件的保存路径为项目运行目录下的uploadFile文件夹(目前就先这样,后期会结合自定义静态资源访问路径来让上传的文件保存在static目录下),并通过日期对所上传的文件进行归类保存。同时为避免文件名称重复,使用UUID给上传的文件进行了重命名。然后就是一些文件的保存操作。最后就是生成上传文件的访问路径,并将访问路径返回。

启动项目,在浏览器地址栏中输入http://localhost:8080/fileupload.html即可访问到该页面,选择一张图片后,点击上传按钮,即可返回对应的URL地址:

你可能会好奇这里的图片怎么直接能被访问呢?其实静态资源位置除了classpath下面的4个路径之外,还有一个”/“,而此处的图片就是放在了”/“下,因此这里的图片虽说是静态资源,却可以直接访问的到。

这样一个简单的图片上传逻辑就完成了,开发者并没有花太多的时间在配置上,而是专注于图片逻辑的实现。

当然如果你想对图片上传的细节进行配置,如设置上传图片的大小限制,本地存放地址等,这些都是可以的。你只需要在application.properties配置文件中添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
# 是否开启文件上传支持,默认为true
spring.servlet.multipart.enabled=true
# 文件写入磁盘的阈值,默认为0
spring.servlet.multipart.file-size-threshold=0
# 上传文件的临时保存位置
spring.servlet.multipart.location=F:\\temp
# 上传的单个文件的最大大小,默认为1MB
spring.servlet.multipart.max-file-size=1MB
# 多文件上传时文件的总大小,默认为10MB
spring.servlet.multipart.max-request-size=10MB
# 文件是否延迟解析,默认为false
spring.servlet.multipart.resolve-lazily=false

多文件上传

说完了单文件的上传,接下来介绍多文件的上传,它几乎和单文件上传一致。首先新建一个uploads.html文件,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>多文件上传</title>
</head>
<body>
<form action="/uploads" method="post" enctype="multipart/form-data">
<input type="file" name="uploadFiles" value="请选择文件" multiple>
<input type="submit" value="开始上传">
</form>
</body>
</html>

这个页面也简单,就是一个演示多文件上传的demo,上传使用的接口是/uploads,请求方式是Post,这个name控件中的uploadFiles就是后续action方法中传入的MultipartFile[]对象。特别注意就是需要设置enctype="multipart/form-data"和在input输入框后使用multiple来标明这个是多文件上传按钮。

接着在FileUploadController类中新建一个uploads方法,用于处理多文件上传的逻辑。同样文件上传成功后返回一个JSON格式的数组,里面包含各个图片的访问地址。里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 多文件上传
* **/
@PostMapping("/uploads")
public String uploads (MultipartFile[] uploadFiles, HttpServletRequest request){
String realPath = request.getSession().getServletContext().getRealPath("/uploadFile/");
String format = simpleDateFormat.format(new Date());
File folder = new File(realPath+format);
if(!folder.isDirectory()){
folder.mkdirs();
}
List<String>stringList = new ArrayList<>();
for(MultipartFile uploadFile:uploadFiles){
String oldName = uploadFile.getOriginalFilename();
String newName = UUID.randomUUID().toString()+oldName.substring(oldName.lastIndexOf("."), oldName.length());
try{
uploadFile.transferTo(new File(folder,newName));
String filePath = request.getScheme()+"://"+request.getServerName()+":"+request.getServerPort()+"/uploadFile/"+format+newName;
stringList.add(filePath);
}catch (IOException e){
e.printStackTrace();
return "文件上传失败";
}
}
JSONArray jsonArray = JSONUtil.parseArray(stringList);
return jsonArray.toString();
}

这里其实和单文件的逻辑几乎一致,唯一不同之处,在于多了一个遍历uploadFiles的步骤,以及最后将java list对象序列化为jsonarray对象。

@ControllerAdvice

接下来介绍@ControllerAdvice注解相关的内容。顾名思义,@ControllerAdvice就是@Controller的增强版,因为在SpringMVC中Advice就是通知,增强的意思,特别是在AOP中,用的更为广泛。@ControllerAdvice主要用来处理全局数据,一般搭配@ExceptionHandler@ModelAttribute以及@InitBinder使用。这个在日常工作过程中使用的还是较为频繁,特别是搭配@ExceptionHandler用于处理全局异常,在前后端分离的情况下,对于返回统一的数据格式显得尤为重要。

全局异常处理

@ControllerAdvice最常见的使用场景就是全局异常处理。前面通过在aplication.properties配置文件中设置了上传文件大小的限制的配置,如果用户上传的文件超过了限制大小,此时就会抛异常,此时你就可以通过@ControllerAdvice结合@ExceptionHandler注解来定义全局异常捕获机制。在com.envy.filespringboot包内新建一个handler包,接着在里面新建一个CustomExceptionHandler类,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
@ControllerAdvice
public class CustomExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
public void uploadException(MaxUploadSizeExceededException e, HttpServletResponse response) throws IOException {
response.setContentType("text/html;charset=utf-8");
PrintWriter out = response.getWriter();
out.write("上传文件大小超出限制");
out.flush();
out.close();
}
}

只需要在系统中定义CustomExceptionHandler类,然后添加@ControllerAdvice注解即可。当系统启动时,该类就会被扫描到Spring容器中,然后定义uploadException方法,需要在该方法上添加@ExceptionHandler注解,其中定义的MaxUploadSizeExceededException.class表明该方法用来处理MaxUploadSizeExceededException类型的异常。如果想让该方法处理所有的异常,只需将MaxUploadSizeExceededException换成Exception即可。方法的参数可以有异常实例、HttpServletResponseHttpServletRequestModel等。同样它的返回值可以是一个JSON对象、一个ModelAndView、一个逻辑视图名等。下现在尝试启动项目,上传一个超大的文件,看是否有错误提示给用户:

如果返回参数是一个ModelAndView,假设使用的页面模板为Thymeleaf(注意你需要在pom.xml文件中添加Thymeleaf相关依赖),此时异常处理方法定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 使用了Thymeleaf模板
* **/
@ControllerAdvice
public class CustomExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ModelAndView uploadException(MaxUploadSizeExceededException e) throws IOException {
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("msg","上传文件大小超出限制");
modelAndView.setViewName("error");
return modelAndView;
}
}

同时为了让错误信息得到显示,你还需要在resources/templates目录下新建一个error.html文件:

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en" xmlns="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>错误信息</title>
</head>
<body>
<div th:text="${ msg }">

</div>
</body>
</html>

然后运行项目,可以发现这个也能实现之前相同的效果。

添加全局数据

@ControllerAdvice是一个全局数据处理组件,因此也可以在@ControllerAdvice中配置全局数据,使用@ModelAttribute注解进行配置,你可以新建一个GlobalConfig类,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
@ControllerAdvice
public class GlobalConfig {

@ModelAttribute(value = "info")
public Map<String,String> userInfo(){
Map<String,String> map =new HashMap<>();
map.put("username","吴承恩");
map.put("gender","男");
return map;
}
}

这里定义了一个全局配置类GlobalConfig,并添加了userInfo方法,返回一个map对象。该方法有一个 @ModelAttribute注解,其中的value属性表示这条返回数据的key,而方法的返回值是返回数据的value。这样在任意请求的Controller中,通过方法参数中的Model都可以获取info的数据。在之前的FileUploadController类中新增一个hello方法,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 演示ModelAttribute信息获取
* **/
@GetMapping("/hello")
public void hello(Model model){
Map<String,Object> map = model.asMap();
Set<String> keySet= map.keySet();
Iterator<String> iterator = keySet.iterator();
while (iterator.hasNext()){
String key = iterator.next();
Object value = map.get(key);
System.out.println(key+">>>>>>"+value);
}
}

这个Model是一个接口,其中定义了一些方法,此处就是使用了它的asMap方法:

1
2
3
4
5
6
7
8
9
public interface Model {
Model addAttribute(String var1, @Nullable Object var2);
Model addAttribute(Object var1);
Model addAllAttributes(Collection<?> var1);
Model addAllAttributes(Map<String, ?> var1);
Model mergeAttributes(Map<String, ?> var1);
boolean containsAttribute(String var1);
Map<String, Object> asMap();
}

然后启动项目,访问hello方法,可以发现控制台输出以下信息:

1
info>>>>>>{gender=男, username=吴承恩}

可以发现这个key确实是info,也就是前面@ModelAttribute中设置的value的值。

请求参数预处理

@ControllerAdvice结合@InitBinder还能实现请求参数预处理,即将表单中的数据绑定到实体类上时进行一些额外的处理。

在pojo包中定义两个实体类Book和Author,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.envy.filespringboot.pojo;

public class Book {
private String name;
private String author;
//getter、setter和toString方法
}

public class Author {
private String name;
private int age;
//getter、setter和toString方法
}

可能你也注意到此时故意在Book和Author类中定义了同名属性name,这个在实际开发过程中也是经常会遇到的问题。现在需要在Controller上接收两个实体类的数据,那么可以在Controller中定义一个book方法,里面的代码为:

1
2
3
4
5
6
7
/**
* 请求参数预处理
* **/
@GetMapping("book")
public String book(Book book, Author author){
return book.toString()+" >>>>>> "+author.toString();
}

那么在参数传递时,两个实体类中的name属性就会被混淆,此时你可以使用@ControllerAdvice结合@InitBinder注解就能解决这个问题。配置步骤如下。首先给Controller中book方法的参数添加@ModelAttribute注解,代码如下:

1
2
3
4
5
6
7
/**
* 请求参数预处理
* **/
@GetMapping("book")
public String book(@ModelAttribute("b")Book book,@ModelAttribute("a") Author author){
return book.toString()+" >>>>>> "+author.toString();
}

既然使用了@ModelAttribute注解,那么需要定义一个GlobalConfig配置类,里面的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 配合InitBinder注解
* **/
@ControllerAdvice
public class GlobalConfig {
@InitBinder("b")
public void init(WebDataBinder binder){
binder.setFieldDefaultPrefix("b.");
}

@InitBinder("a")
public void init2(WebDataBinder binder){
binder.setFieldDefaultPrefix("a.");
}
}

解释一下上述代码的含义,在GlobalConfig类中定义了两个方法:第一个@InitBinder("b")表示该方法是处理@ModelAttribute("b")对应的参数,第二个@InitBinder("a")表示该方法是处理@ModelAttribute("a")对应的参数。然后在每个init方法中都给相应的Filed设置了一个前缀,然后运行项目,在浏览器地址栏中输入http://localhost:8080/book?b.name=西游记&b.author=吴承恩&a.name=罗贯中&a.age=188,这样就能成功地区分出name的属性。浏览器页面输出:

1
Book{name='西游记', author='吴承恩'}  >>>>>>  Author{name='罗贯中', age=188}

当然在WebDataBinder对象中,还可以设置允许的字段、禁止的字段、必填字段以及验证器等等。