spring-tutorial/docs/01.Java/13.框架/01.Spring/03.SpringWeb/02.SpringWeb应用.md

39 KiB
Raw Permalink Blame History

title date categories tags permalink
Spring Web 应用 2023-02-14 19:21:22
Java
框架
Spring
SpringWeb
Java
框架
Spring
Web
Controller
/pages/5d002f/

Spring Web 应用

Spring MVC 提供了一种基于注解的编程模型,@Controller@RestController 组件使用注解来表达请求映射、请求输入、异常处理等。注解控制器具有灵活的方法签名,并且不必扩展基类或实现特定接口。以下示例显示了一个由注解定义的控制器:

@Controller
public class HelloController {

    @GetMapping("/hello")
    public String handle(Model model) {
        model.addAttribute("message", "Hello World!");
        return "index";
    }
}

在前面的示例中,该方法接受一个 Model 并以 String 形式返回一个视图名称,但还存在许多其他选项。

快速入门

下面,通过一个简单的示例来展示如何通过 Spring 创建一个 Hello World Web 服务。

1pom.xml 中引入依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

2定义 Controller

Spring 构建 RESTful 服务的方法HTTP 请求由 Controller 处理。 这些组件由 @RestController 注解标识。

【示例】下面的示例定义了一个处理 /greeting 的 GET 请求

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class GreetingController {

    @GetMapping("/greeting")
    public String greeting(@RequestParam(name = "name", required = false, defaultValue = "World") String name,
        Model model) {
        model.addAttribute("name", name);
        return "greeting";
    }

}

3创建启动类

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class HelloWorldApplication {

    public static void main(String[] args) {
        SpringApplication.run(HelloWorldApplication.class);
    }

}

4启动服务执行 HelloWorldApplication.main 方法启动 web 服务

5测试

打开浏览器,访问 http://localhost:8080/greeting页面会显示如下内容

Hello, World!

打开浏览器,访问 http://localhost:8080/greeting?name=dunwu页面会显示如下内容

Hello, dunwu!

Spring Web 组件

组件扫描

可以使用 Servlet 的 WebApplicationContext 中的标准 Spring bean 定义来定义控制器。@Controller 构造型允许自动检测,与 Spring 对检测类路径中的 @Component 类并为它们自动注册 bean 定义的一般支持保持一致。它还充当带注解类的构造型,表明其作为 Web 组件的角色。

要启用此类 @Controller 的自动检测,可以将组件扫描添加到您的 Java 配置中,如以下示例所示:

@Configuration
@ComponentScan("org.example.web")
public class WebConfig {

    // ...
}

以下示例显示了与上述示例等效的 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"
    xmlns:p="http://www.springframework.org/schema/p"
    xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        https://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context
        https://www.springframework.org/schema/context/spring-context.xsd">

    <context:component-scan base-package="org.example.web"/>

    <!-- ... -->

</beans>

AOP 代理

在某些情况下,可能需要在运行时使用 AOP 代理装饰控制器。一个例子是,如果选择直接在控制器上使用 @Transactional 注解。在这种情况下,特别是对于控制器,建议使用基于类的代理。直接在控制器上使用此类注解会自动出现这种情况。

如果控制器实现了一个接口,并且需要 AOP 代理,您可能需要显式配置基于类的代理。例如,对于 @EnableTransactionManagement ,可以更改为 @EnableTransactionManagement(proxyTargetClass = true),对于 <tx:annotation-driven/> ,您可以更改为 <tx:annotation-driven proxy-target-class="true"/>

@Controller

@RestController 是一个组合注解,它本身使用 @Controller@ResponseBody 元注解进行标记,以指示控制器的每个方法继承了类型级别的 @ResponseBody 注解,因此直接写入响应主体,而不是使用 HTML 模板进行视图解析和渲染。

@RequestMapping

可以使用 @RequestMapping 注解将请求映射到控制器方法。它具有各种属性,可以通过 URL、HTTP 方法、请求参数、标头和媒体类型进行匹配。可以在类级别使用它来表达共享映射,或者在方法级别使用它来缩小到特定端点的映射。

@RequestMapping 的主要参数:

  • path / method 指定映射路径与方法
  • params / headers 限定映射范围
  • consumes / produces 限定请求与响应格式

Spring 还提供了以下 @RequestMapping 的变体:

  • @GetMapping
  • @PostMapping
  • @PutMapping
  • @DeleteMapping
  • @PatchMapping

快捷方式是提供的自定义注解,因为可以说,大多数控制器方法应该映射到特定的 HTTP 方法,而不是使用 @RequestMapping,默认情况下,它与所有 HTTP 方法匹配。在类级别仍然需要 @RequestMapping 来表达共享映射。

以下示例具有类型和方法级别的映射:

@RestController
@RequestMapping("/persons")
class PersonController {

    @GetMapping("/{id}")
    public Person getPerson(@PathVariable Long id) {
        // ...
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public void add(@RequestBody Person person) {
        // ...
    }
}

URI 模式

@RequestMapping 方法可以使用 URL 模式进行映射。有两种选择:

  • PathPattern - 与 URL 路径匹配的预解析模式也预解析为 PathContainer。该解决方案专为网络使用而设计,可有效处理编码和路径参数,并高效匹配。
  • AntPathMatcher - 根据字符串路径匹配字符串模式。这是在 Spring 配置中也使用的原始解决方案,用于在类路径、文件系统和其他位置选择资源。它的效率较低,并且字符串路径输入对于有效处理 URL 的编码和其他问题是一个挑战。

PathPattern 是 Web 应用程序的推荐解决方案,它是 Spring WebFlux 中的唯一选择。它从 5.3 版开始在 Spring MVC 中使用,从 6.0 版开始默认启用。请参阅 MVC 配置 以自定义路径匹配选项。

PathPattern 支持与 AntPathMatcher 相同的模式语法。此外,它还支持捕获模式,例如 {spring},用于匹配路径末尾的 0 个或多个路径段。PathPattern 还限制使用 ** 来匹配多个路径段,这样它只允许出现在模式的末尾。这消除了在为给定请求选择最佳匹配模式时出现的许多歧义。有关完整模式语法,请参阅 PathPatternAntPathMatcher

一些示例模式:

  • "/resources/ima?e.png" -匹配一个字符
  • "/resources/*.png" - 匹配零个或多个字符
  • "/resources/**" - 匹配多个字符
  • "/projects/{project}/versions" - 匹配路径段并将其捕获为变量
  • "/projects/{project:[a-z]+}/versions" - 使用正则表达式匹配并捕获变量

可以使用 @PathVariable 访问捕获的 URI 变量。例如:

@GetMapping("/owners/{ownerId}/pets/{petId}")
public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
    // ...
}

可以在类和方法级别声明 URI 变量,如以下示例所示:

@Controller
@RequestMapping("/owners/{ownerId}")
public class OwnerController {

    @GetMapping("/pets/{petId}")
    public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
        // ...
    }
}

URI 变量会自动转换为适当的类型,否则会引发 TypeMismatchException。默认支持简单类型(intlongDate 等),可以注册对任何其他数据类型的支持。请参见类型转换DataBinder

可以显式命名 URI 变量(例如,@PathVariable("customId")),但如果名称相同并且代码是使用 -parameters 编译器标志编译的,则可以省略该细节。

语法 {varName:regex} 使用正则表达式声明一个 URI 变量。例如,给定 URL "/spring-web-3.0.5.jar",以下方法提取名称、版本和文件扩展名:

@GetMapping("/{name:[a-z-]+}-{version:\\d\\.\\d\\.\\d}{ext:\\.[a-z]+}")
public void handle(@PathVariable String name, @PathVariable String version, @PathVariable String ext) {
    // ...
}

URI 路径模式还可以嵌入 ${…} 占位符,这些占位符在启动时通过使用 PropertySourcesPlaceholderConfigurer 针对本地、系统、环境和其他属性源进行解析。例如,可以使用它来根据某些外部配置参数化基本 URL。

模式比较

当多个模式匹配一个 URL 时,必须选择最佳匹配。这是通过以下方式之一完成的,具体取决于是否启用了已解析的 PathPattern 以供使用:

两者都有助于对模式进行排序,更具体的模式位于顶部。如果模式具有较少的 URI 变量(计为 1、单通配符计为 1和双通配符计为 2则模式不太具体。如果得分相同则选择较长的模式。给定相同的分数和长度选择 URI 变量多于通配符的模式。

默认映射模式 (/**) 被排除在评分之外并始终排在最后。此外,前缀模式(例如 /public/**)被认为不如其他没有双通配符的模式具体。

后缀匹配

从 5.3 开始,默认情况下 Spring MVC 不再执行 .* 后缀模式匹配,其中映射到 person 的控制器也隐式映射到 /person.*。因此,路径扩展不再用于解释请求的响应内容类型⟩——例如,/person.pdf/person.xml 等。

当浏览器过去发送难以一致解释的 Accept 请求头时,以这种方式使用文件扩展名是必要的。现在,这不再是必需的,使用 Accept 请求头应该是首选。

随着时间的推移,文件扩展名的使用在很多方面都被证明是有问题的。当使用 URI 变量、路径参数和 URI 编码覆盖时,它可能会导致歧义。关于基于 URL 的授权和安全性的推理也变得更加困难。

要在 5.3 之前的版本中完全禁用路径扩展,请设置以下内容:

除了通过 Accept 请求头之外,还有一种请求内容类型的方法仍然有用,例如在浏览器中键入 URL 时。路径扩展的一种安全替代方法是使用查询参数策略。如果您必须使用文件扩展名,请考虑通过 ContentNegotiationConfigurermediaTypes 属性将它们限制为明确注册的扩展名列表。

后缀匹配和 RFD

反射文件下载 (RFD) 攻击与 XSS 类似,因为它依赖于响应中反映的请求输入(例如,查询参数和 URI 变量。然而RFD 攻击不是将 JavaScript 插入 HTML而是依赖于浏览器切换来执行下载并在稍后双击时将响应视为可执行脚本。

在 Spring MVC 中,@ResponseBodyResponseEntity 方法存在风险,因为它们可以渲染不同的内容类型,客户端可以通过 URL 路径扩展请求这些内容类型。禁用后缀模式匹配并使用路径扩展进行内容协商可以降低风险,但不足以防止 RFD 攻击。

为了防止 RFD 攻击在渲染响应主体之前Spring MVC 添加了一个 Content-Disposition:inline;filename=f.txt 头以建议一个固定且安全的下载文件。仅当 URL 路径包含的文件扩展名既不安全也不明确注册用于内容协商时,才会执行此操作。但是,当 URL 直接输入浏览器时,它可能会产生副作用。

默认情况下,允许许多常见的路径扩展是安全的。具有自定义 HttpMessageConverter 实现的应用程序可以显式注册文件扩展名以进行内容协商,以避免为这些扩展名添加 Content-Disposition 头。请参阅 内容类型

关于 RFD 更多细节推荐参考 CVE-2015-5211

限定数据类型

您可以根据请求的 Content-Type 缩小请求映射,如以下示例所示:

@PostMapping(path = "/pets", consumes = "application/json")
public void addPet(@RequestBody Pet pet) {
    // ...
}

consumes 属性还支持否定表达式 - 例如,!textplain 表示除 textplain 之外的任何内容类型。

您可以在类级别声明一个共享的 consumes 属性。然而,与大多数其他请求映射属性不同的是,当在类级别使用时,方法级别的 consumes 属性会覆盖而不是扩展类级别的声明。

Producible Media Types

可以根据 Accept 请求头和控制器方法生成的内容类型列表来缩小请求映射,如以下示例所示:

@GetMapping(path = "/pets/{petId}", produces = "application/json")
@ResponseBody
public Pet getPet(@PathVariable String petId) {
    // ...
}

媒体类型可以指定一个字符集。支持否定表达式——例如,!textplain 表示除 "text/plain" 之外的任何内容类型。

可以在类级别声明一个共享的 produces 属性。然而,与大多数其他请求映射属性不同,当在类级别使用时,方法级别的 produces 属性会覆盖而不是扩展类级别的声明。

参数、请求头

可以根据请求参数条件缩小请求映射范围。可以测试是否存在请求参数 (myParam)、是否缺少请求参数 (!myParam) 或特定值 (myParam=myValue)。以下示例显示如何测试特定值:

@GetMapping(path = "/pets/{petId}", params = "myParam=myValue")
public void findPet(@PathVariable String petId) {
    // ...
}

还可以使用相同的请求头条件,如以下示例所示:

@GetMapping(path = "/pets", headers = "myHeader=myValue")
public void findPet(@PathVariable String petId) {
    // ...
}

HTTP HEAD, OPTIONS

@GetMapping(和 @RequestMapping(method=HttpMethod.GET))透明地支持 HTTP HEAD 以进行请求映射。控制器方法不需要改变。在 jakarta.servlet.http.HttpServlet 中应用的响应包装器确保将 Content-Length 头设置为写入的字节数(实际上没有写入响应)。

@GetMapping(和@RequestMapping(method=HttpMethod.GET))被隐式映射并支持 HTTP HEAD。HTTP HEAD 请求的处理方式就好像它是 HTTP GET 一样,除了不写入正文,而是计算字节数并设置 Content-Length 头。

默认情况下,通过将 Allow 响应头设置为所有具有匹配 URL 模式的 @RequestMapping 方法中列出的 HTTP 方法列表来处理 HTTP OPTIONS。

对于没有 HTTP 方法声明的 @RequestMapping Allow 头设置为 GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS。控制器方法应始终声明支持的 HTTP 方法(例如,通过使用 HTTP 方法特定变体:@GetMapping@PostMapping 等)。

You can explicitly map the @RequestMapping method to HTTP HEAD and HTTP OPTIONS, but that is not necessary in the common case.

可以显式地将 @RequestMapping 方法映射到 HTTP HEAD 和 HTTP OPTIONS但在常见情况下这不是必需的。

自定义注解

Spring MVC 支持使用组合注解 进行请求映射。这些注解本身是用 @RequestMapping 进行元注解的,并且组合起来重新声明 @RequestMapping 属性的一个子集(或全部),具有更明确的目的。

@GetMapping@PostMapping@PutMapping@DeleteMapping@PatchMapping 是组合注解的示例。提供它们是因为,可以说,大多数控制器方法应该映射到特定的 HTTP 方法,而不是使用 @RequestMapping,默认情况下,它与所有 HTTP 方法匹配。如果您需要组合注解的示例,请查看这些注解的声明方式。

Spring MVC 还支持具有自定义请求匹配逻辑的自定义请求映射属性。这是一个更高级的选项,需要继承 RequestMappingHandlerMapping 并覆盖 getCustomMethodCondition 方法,您可以在其中检查自定义属性并返回您自己的 RequestCondition

显示注册

您可以以编程方式注册处理程序方法,您可以将其用于动态注册或高级情况,例如不同 URL 下的同一处理程序的不同实例。以下示例注册了一个处理程序方法

@Configuration
public class MyConfig {

    @Autowired
    public void setHandlerMapping(RequestMappingHandlerMapping mapping, UserHandler handler)
            throws NoSuchMethodException {

        RequestMappingInfo info = RequestMappingInfo
                .paths("/user/{id}").methods(RequestMethod.GET).build();

        Method method = UserHandler.class.getMethod("getUser", Long.class);

        mapping.registerMapping(info, handler, method);
    }
}
  1. 为控制器注入目标处理程序和处理程序映射。

  2. 准备请求映射元数据。

  3. 获取处理程序方法。

  4. 添加注册。

处理方法

请求数据

  • @RequestParam

  • @RequestBody

  • @PathVariable

  • @RequestHeader

更多 Spring Web 方法参数可以参考: Method Arguments

响应数据

  • @ResponseBody

  • @ResponseStatus

  • ResponseEntity

  • HttpEntity

更多 Spring Web 方法返回值可以参考:Return Values

@ModelAttribute

可以使用 @ModelAttribute 注解:

  • @RequestMapping 方法中的方法参数上,用于模型创建或访问对象,并通过 WebDataBinder 将其绑定到请求。
  • 作为 @Controller@ControllerAdvice 类中的方法级注解,有助于在任何 @RequestMapping 方法调用之前初始化模型。
  • @RequestMapping 方法上标记它的返回值是一个模型属性。

本节讨论 @ModelAttribute 方法——前面列表中的第二项。一个控制器可以有任意数量的 @ModelAttribute 方法。所有这些方法都在同一控制器中的 @RequestMapping 方法之前被调用。@ModelAttribute 方法也可以通过 @ControllerAdvice 在控制器之间共享。

@ModelAttribute 方法具有灵活的方法签名。它们支持许多与 @RequestMapping 方法相同的参数,除了 @ModelAttribute 本身或与请求主体相关的任何内容。

以下示例显示了 @ModelAttribute 方法:

@ModelAttribute
public void populateModel(@RequestParam String number, Model model) {
    model.addAttribute(accountRepository.findAccount(number));
    // add more ...
}

以下示例仅添加一个属性:

@ModelAttribute
public Account addAccount(@RequestParam String number) {
    return accountRepository.findAccount(number);
}

还可以将 @ModelAttribute 用作 @RequestMapping 方法上的方法级注解,在这种情况下,@RequestMapping 方法的返回值被解释为模型属性。这通常不是必需的,因为它是 HTML 控制器中的默认行为,除非返回值是一个 String 否则将被解释为视图名称。 @ModelAttribute 还可以自定义模型属性名称,如下例所示:

@GetMapping("/accounts/{id}")
@ModelAttribute("myAccount")
public Account handle() {
    // ...
    return account;
}

@InitBinder

@Controller@ControllerAdvice 类可以用 @InitBinder 方法来初始化 WebDataBinder 的实例,而这些方法又可以:

  • 将请求参数(即表单或查询数据)绑定到模型对象。
  • 将基于字符串的请求值例如请求参数、路径变量、标头、cookie 等)转换为控制器方法参数的目标类型。
  • 在渲染 HTML 表单时将模型对象值格式化为 String 值。

@InitBinder 方法可以注册指定控制器 java.beans.PropertyEditor 或 Spring ConverterFormatter 组件。此外,您可以使用 MVC 配置 在全局共享的 FormattingConversionService 中注册 ConverterFormatter 类型。

@InitBinder 方法支持许多与 @RequestMapping 方法相同的参数,除了 @ModelAttribute(命令对象)参数。通常,它们使用 WebDataBinder 参数(用于注册)和 void 返回值声明。下面展示了一个示例:

@Controller
public class FormController {

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        dateFormat.setLenient(false);
        binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, false));
    }

    // ...
}

或者,当您通过共享的 FormattingConversionService 使用基于 Formatter 的设置时,您可以重复使用相同的方法并注册指定控制器的 Formatter 实现,如以下示例所示:

@Controller
public class FormController {

    @InitBinder
    protected void initBinder(WebDataBinder binder) {
        binder.addCustomFormatter(new DateFormatter("yyyy-MM-dd"));
    }

    // ...
}

在 Web 应用程序的上下文中,数据绑定涉及将 HTTP 请求参数(即表单数据或查询参数)绑定到模型对象及其嵌套对象中的属性。

仅公开遵循 JavaBeans 命名约定public 属性用于数据绑定——例如,firstName 属性的 get/set 方法:public String getFirstName()public void setFirstName(String)

默认情况下Spring 允许绑定到模型对象图中的所有公共属性。这意味着您需要仔细考虑模型具有哪些公共属性,因为客户端可以将任何公共属性路径作为目标,甚至是一些预计不会针对给定用例的公共属性路径。

例如,给定一个 HTTP 表单数据端点,恶意客户端可以为存在于模型对象图中但不属于浏览器中显示的 HTML 表单的属性提供值。这可能导致在模型对象及其任何嵌套对象上设置数据,这些数据预计不会更新。

荐的方法是使用一个专用模型对象,它只公开与表单提交相关的属性。例如,在用于更改用户电子邮件地址的表单上,模型对象应声明最少的一组属性,例如以下 ChangeEmailForm

public class ChangeEmailForm {

    private String oldEmailAddress;
    private String newEmailAddress;

    public void setOldEmailAddress(String oldEmailAddress) {
        this.oldEmailAddress = oldEmailAddress;
    }

    public String getOldEmailAddress() {
        return this.oldEmailAddress;
    }

    public void setNewEmailAddress(String newEmailAddress) {
        this.newEmailAddress = newEmailAddress;
    }

    public String getNewEmailAddress() {
        return this.newEmailAddress;
    }

}

如果您不能或不想为每个数据绑定用例使用专用模型对象,则必须限制允许用于数据绑定的属性。理想情况下,可以通过 WebDataBinder 上的 setAllowedFields() 方法注册允许的字段模式 来实现这一点。

例如,要在您的应用程序中注册允许的字段模式,您可以在 @Controller@ControllerAdvice 组件中实现 @InitBinder 方法,如下所示:

@Controller
public class ChangeEmailController {

    @InitBinder
    void initBinder(WebDataBinder binder) {
        binder.setAllowedFields("oldEmailAddress", "newEmailAddress");
    }

    // @RequestMapping methods, etc.

}

除了注册允许的模式外,还可以通过 DataBinder及其子类中的 setDisallowedFields() 方法注册 允许的字段模式。但是请注意,“允许列表”比“拒绝列表”更安全。因此,setAllowedFields() 应该优于 setDisallowedFields()

请注意,匹配允许的字段模式是区分大小写的;然而,与不允许的字段模式匹配是不区分大小写的。此外,匹配不允许的模式的字段将不会被接受,即使它也恰好匹配允许列表中的模式。

表单处理

创建处理表单的 Controller

GreetingController 通过返回视图的名称处理 /greeting 的 GET 请求,这意味着返回的内容是名为 greeting.html 的视图内容。

greetingForm() 方法是通过使用 @GetMapping 专门映射到 GET 请求的,而 greetingSubmit() 是通过 @PostMapping 映射到 POST 请求的。

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class GreetingController {

  @GetMapping("/greeting")
  public String greetingForm(Model model) {
    model.addAttribute("greeting", new Greeting());
    return "greeting";
  }

  @PostMapping("/greeting")
  public String greetingSubmit(@ModelAttribute Greeting greeting, Model model) {
    model.addAttribute("greeting", greeting);
    return "result";
  }

}

定义需要提交的表单实体

import lombok.Data;

@Data
public class Greeting {

    private long id;

    private String content;

}

提交表单前端代码

提交实体的页面必须依赖某种视图技术通过将视图名称转换为模板进行渲染从而对HTML进行服务端渲染。在下面的例子中使用了 Thymeleaf 模板引擎作为视图,它解析 greeting.html 的各种模板表达式以渲染表单。

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
  <head>
    <title>Getting Started: Handling Form Submission</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
  </head>
  <body>
    <h1>Form</h1>
    <form action="#" th:action="@{/greeting}" th:object="${greeting}" method="post">
      <p>Id: <input type="text" th:field="*{id}" /></p>
      <p>Message: <input type="text" th:field="*{content}" /></p>
      <p><input type="submit" value="Submit" /> <input type="reset" value="Reset" /></p>
    </form>
  </body>
</html>

文件上传

创建文件上传处理 Controller

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import com.example.uploadingfiles.storage.StorageFileNotFoundException;
import com.example.uploadingfiles.storage.StorageService;

@Controller
public class FileUploadController {

	private final StorageService storageService;

	@Autowired
	public FileUploadController(StorageService storageService) {
		this.storageService = storageService;
	}

	@GetMapping("/")
	public String listUploadedFiles(Model model) throws IOException {

		model.addAttribute("files", storageService.loadAll().map(
				path -> MvcUriComponentsBuilder.fromMethodName(FileUploadController.class,
						"serveFile", path.getFileName().toString()).build().toUri().toString())
				.collect(Collectors.toList()));

		return "uploadForm";
	}

	@GetMapping("/files/{filename:.+}")
	@ResponseBody
	public ResponseEntity<Resource> serveFile(@PathVariable String filename) {

		Resource file = storageService.loadAsResource(filename);
		return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,
				"attachment; filename=\"" + file.getFilename() + "\"").body(file);
	}

	@PostMapping("/")
	public String handleFileUpload(@RequestParam("file") MultipartFile file,
			RedirectAttributes redirectAttributes) {

		storageService.store(file);
		redirectAttributes.addFlashAttribute("message",
				"You successfully uploaded " + file.getOriginalFilename() + "!");

		return "redirect:/";
	}

	@ExceptionHandler(StorageFileNotFoundException.class)
	public ResponseEntity<?> handleStorageFileNotFound(StorageFileNotFoundException exc) {
		return ResponseEntity.notFound().build();
	}

}

FileUploadController 类使用 @Controller 注解,以便 Spring 可以扫描并注册它。 每个方法都标有 @GetMapping@PostMapping ,将路径和 HTTP 操作映射到指定的控制器。

在这种情况下:

  • GET /:从 StorageService 中查找当前上传文件的列表,并将其加载到 Thymeleaf 模板中。 它使用 MvcUriComponentsBuilder 计算指向实际资源的链接。

  • GET /files/{filename}:加载资源(如果存在)并使用 Content-Disposition 响应标头将其发送到浏览器进行下载。

  • POST /:处理一个多部分的消息文件,并将其交给 StorageService 进行保存。

定义存储文件的 Service

import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;

import java.nio.file.Path;
import java.util.stream.Stream;

public interface StorageService {

	void init();

	void store(MultipartFile file);

	Stream<Path> loadAll();

	Path load(String filename);

	Resource loadAsResource(String filename);

	void deleteAll();

}

一个加单的 StorageService 实现:


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.stream.Stream;

@Service
public class FileSystemStorageServiceImpl implements StorageService {

    private final Path rootLocation;

    @Autowired
    public FileSystemStorageServiceImpl(StorageProperties properties) {
        this.rootLocation = Paths.get(properties.getLocation());
    }

    @Override
    public void deleteAll() {
        FileSystemUtils.deleteRecursively(rootLocation.toFile());
    }

    @Override
    public void init() {
        try {
            Files.createDirectories(rootLocation);
        } catch (IOException e) {
            throw new StorageException("Could not initialize storage", e);
        }
    }

    @Override
    public Path load(String filename) {
        return rootLocation.resolve(filename);
    }

    @Override
    public Stream<Path> loadAll() {
        try {
            return Files.walk(this.rootLocation, 1).filter(path -> !path.equals(this.rootLocation))
                .map(this.rootLocation::relativize);
        } catch (IOException e) {
            throw new StorageException("Failed to read stored files", e);
        }
    }

    @Override
    public Resource loadAsResource(String filename) {
        try {
            Path file = load(filename);
            Resource resource = new UrlResource(file.toUri());
            if (resource.exists() || resource.isReadable()) {
                return resource;
            } else {
                throw new StorageFileNotFoundException("Could not read file: " + filename);
            }
        } catch (MalformedURLException e) {
            throw new StorageFileNotFoundException("Could not read file: " + filename, e);
        }
    }

    @Override
    public void store(MultipartFile file) {
        String filename = StringUtils.cleanPath(file.getOriginalFilename());
        try {
            if (file.isEmpty()) {
                throw new StorageException("Failed to store empty file " + filename);
            }
            if (filename.contains("..")) {
                // This is a security check
                throw new StorageException(
                    "Cannot store file with relative path outside current directory " + filename);
            }
            try (InputStream inputStream = file.getInputStream()) {
                Files.copy(inputStream, this.rootLocation.resolve(filename), StandardCopyOption.REPLACE_EXISTING);
            }
        } catch (IOException e) {
            throw new StorageException("Failed to store file " + filename, e);
        }
    }

}

创建文件上传表单

<html xmlns:th="https://www.thymeleaf.org">
<body>

	<div th:if="${message}">
		<h2 th:text="${message}"/>
	</div>

	<div>
		<form method="POST" enctype="multipart/form-data" action="/">
			<table>
				<tr><td>File to upload:</td><td><input type="file" name="file" /></td></tr>
				<tr><td></td><td><input type="submit" value="Upload" /></td></tr>
			</table>
		</form>
	</div>

	<div>
		<ul>
			<li th:each="file : ${files}">
				<a th:href="${file}" th:text="${file}" />
			</li>
		</ul>
	</div>

</body>
</html>

文件上传限制

如果使用 Spring Boot可以使用一些属性设置来调整其自动配置的 MultipartConfigElement

将以下属性添加到现有属性设置中(在 src/main/resources/application.properties 中):

spring.servlet.multipart.max-file-size=128KB
spring.servlet.multipart.max-request-size=128KB
  • spring.servlet.multipart.max-file-size 设置为 128KB表示总文件大小不能超过 128KB。
  • spring.servlet.multipart.max-request-size 设置为 128KB这意味着 multipart/form-data 的总请求大小不能超过 128KB。

异常处理

@ExceptionHandler

@Controller@ControllerAdvice 类可以用 @ExceptionHandler 方法来处理来自控制器方法的异常,如以下示例所示:

@Controller
public class SimpleController {

    // ...

    @ExceptionHandler
    public ResponseEntity<String> handle(IOException ex) {
        // ...
    }
}

异常可能与正在传播的顶级异常(例如,抛出直接的 IOException)或包装器异常中的嵌套原因(例如,包装在 IllegalStateException 中的 IOException)相匹配。从 5.3 开始,这可以匹配任意原因级别,而以前只考虑直接原因。

对于匹配的异常类型,最好将目标异常声明为方法参数,如前面的示例所示。当多个异常方法匹配时,根异常匹配通常优先于原因异常匹配。更具体地说,ExceptionDepthComparator 用于根据抛出的异常类型的深度对异常进行排序。

或者,注解声明可以缩小要匹配的异常类型,如以下示例所示:

@ExceptionHandler({FileSystemException.class, RemoteException.class})
public ResponseEntity<String> handle(IOException ex) {
    // ...
}

您甚至可以使用具有非常通用的参数签名的特定异常类型列表,如以下示例所示:

@ExceptionHandler({FileSystemException.class, RemoteException.class})
public ResponseEntity<String> handle(Exception ex) {
    // ...
}

通常建议您在参数签名中尽可能具体,以减少根本和原因异常类型之间不匹配的可能性。考虑将一个多重匹配方法分解为单独的 @ExceptionHandler 方法,每个方法通过其签名匹配一个特定的异常类型。

在多 @ControllerAdvice 安排中,建议在具有相应顺序优先级的 @ControllerAdvice 上声明您的主要根异常映射。虽然根异常匹配优于原因,但这是在给定控制器或 @ControllerAdvice 类的方法中定义的。这意味着优先级较高的 @ControllerAdvice 上的原因匹配优于优先级较低的 @ControllerAdvice 上的任何匹配例如root

最后但同样重要的是, @ExceptionHandler 方法实现可以选择通过以原始形式重新抛出给定异常实例来退出处理。这在您只对根级匹配或无法静态确定的特定上下文中的匹配感兴趣的情况下很有用。重新抛出的异常通过剩余的解析链传播,就好像给定的 @ExceptionHandler 方法一开始就不会匹配一样。

Spring MVC 中对 @ExceptionHandler 方法的支持建立在 DispatcherServlet 级别 HandlerExceptionResolver 机制上。

附录:

@ExceptionHandler 支持的参数

@ExceptionHandler 支持返回值

参考资料