1. 程式人生 > 其它 >Spring實戰 七 SpringMVC的高階技術

Spring實戰 七 SpringMVC的高階技術

概述

先自己搭個專案回顧一下子上一章的內容,我這裡採用的是Java方式配置SpringMVC並且使用了thymeleaf模板技術展示一個簡單的首頁。

DispatcherServlet高階配置

AbstractAnnotationConfigDispatcherServletInitializer`來進行自動配置,這可以應付大部分情況下的應用了,但是總有特殊情況。

通過重寫三個抽象方法之外的其他的方法,我們可以完成更加細緻的配置。

在Spring初始化,註冊DispatcherServlet到Servlet容器後,就會呼叫customizeRegistration方法,傳入一個ServletRegistration.Dynamic

物件,可以通過這個物件對DispatcherServlet進行額外的配置。

@Override
protected void customizeRegistration(ServletRegistration.Dynamic registration) {
    registration.setMultipartConfig(
            new MultipartConfigElement("/tmp/useruploads/"));
    registration.setLoadOnStartup(1);
}

如上,我們通過customizeRegistration設定了多檔案上傳、DispatcherServlet的啟動時載入,通過這個方法,我們還可以其它的一些配置。

新增其他Servlet和Filter

SpringMVC接管了大部分我們該做的事,而且能做的很好,但有時候我們也需要建立自己的Servlet和Filter。

實現WebApplicationInitializer介面

EMMMM...第一個辦法就是實現WebApplicationInitializer介面。在第五章的筆記中介紹過這個介面。總之,這是Spring提供的一個介面,實現了這個介面的類會在Servlet容器啟動時被掃描到並自動呼叫其中的onStartup方法,我們之前繼承的AbstractAnnotationConfigDispatcherServletInitializer也是這個介面的實現類。

我們可以隨便建立一個初始化器然後在onStartup中呼叫servletContext中的addServlet或者addFilter來實現新增filter和servlet。

public class ServletAndFilterInitializer implements WebApplicationInitializer {
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        servletContext.addServlet("myServlet", MyServlet.class)
                .addMapping("/myservlet");
        servletContext.addFilter("myFilter", MyFilter.class)
                .addMappingForUrlPatterns(null,false,"/myservlet");
    }
}

下面貼上MyServlet和MyFilter程式碼:

public class MyServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.getWriter().println("MyServlet");
    }
}


public class MyFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("init....");
    }


    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        response.getWriter().println("MyFilter");
        filterChain.doFilter(servletRequest,servletResponse);
    }
}

使用這個辦法創建出來的Servlet和Filter就相當於獨立於Spring的DispatcherServlet工作了。大部分時候我們還是要工作在Spring中,只是想註冊一個對映到DispatcherServlet上的Filter的話,不用這麼大動作。只需要回到AbstractAnnotationConfigDispatcherServletInitializer中,重寫內部的getServletFilters方法即可。

重寫getServletFilters

public class AppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {

    @Override
    protected Filter[] getServletFilters() {
        return new Filter[]{new DispatcherServletFilter()};
    }
    // ...
}
public class DispatcherServletFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        ((HttpServletResponse) response).getWriter().println("Hello, DispatcherServletFilter!");
        chain.doFilter(request,response);
    }
}

這樣你就可以把任意多個Filter對映到DispatcherServlet上了。

其他方法的說明

其實我試了在web.xml下宣告Servlet或者開啟容器的自動掃描基於註解配置的Servlet,也都是可以正常配置的,但是我不知道和在Spring中配置有啥區別,在WebApplicationInitializer中的ServletContext和直接註冊時的是一個物件,所以也不存在Spring會包裝了那個Context物件在我們通過WebApplicationInitializer物件註冊時會做一些額外操作的事。

Web.xml中配置DispatcherServlet

如果我們必須在web.xml中配置的話,我們可以這樣配置。

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0" metadata-complete="false">
    <!-- 設定根上下文配置檔案位置 -->
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/spring/root-context.xml</param-value>
    </context-param>
    
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
    
    <servlet>
        <servlet-name>appServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>appServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>

</web-app>

像在Java中配置一樣ContextLoaderListener和DispatcherServlet各需要一個配置,經過如上設定,ContextLoaderListener會載入/WEB-INF/spring/root-context.xml,從這裡讀取Bean定義。

而DispatcherServlet需要從servlet定義的init-param中設定這個檔案的位置,要不然預設是從/WEB-INF/{Servlet名字}-context.xml中讀取,這個示例中也就是/WEB-INF/appServlet-context.xml

經過如下設定,DispatcherServlet就會從/WEB-INF/spring/appServlet/servlet-context.xml中讀取定義。

<servlet>
    <servlet-name>appServlet</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/spring/appServlet/servlet-context.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
    <servlet-name>appServlet</servlet-name>
    <url-pattern>/</url-pattern>
</servlet-mapping>

我們還是想通過Java來進行Bean的定義,而不是XML,我們只用XML來註冊DispatcherServlet和ContextLoaderListener。

如下我們通過指定contextClasscontextConfigLocation來進行這項操作,把之前的兩個配置檔案WebConfig.javaRootConfig.java給使用起來。

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0" metadata-complete="false">

    <context-param>
        <param-name>contextClass</param-name>
        <param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
    </context-param>

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>io.lilpig.springlearn.springlearn04.config.RootConfig</param-value>
    </context-param>
    
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
    
    <servlet>
        <servlet-name>appServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextClass</param-name>
            <param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
        </init-param>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>io.lilpig.springlearn.springlearn04.config.WebConfig</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>appServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>

</web-app>

行,刪了,換Java配置。。。。

處理檔案上傳

文字型別的表單通常有限制,遇到上傳檔案的需求就沒有辦法了,multipart型別是一個解決辦法。

如下是一個multipart型別的資料示例

每個------WebKitFormBoundary...是一個欄位,下面一行是欄位名啥的,然後一個空行,再往下一行是這個欄位的資料。

如果這個欄位是一個檔案型別或者其他型別的資料,就會有一個Content-Type頭,這個頭指定了它的型別,伺服器端會根據這個頭來取資料。

設定MultipartResolver

Spring提供了對應的Resolver來解析multipart型別的欄位。

  • CommonsMultipartResolver:使用Jakarta Commons FileUpload解析multipart請求
  • StandardServletMultipartResolver:依賴於Spring3.0對multipart請求的支援(始於Spring3.1)

我們使用第二種方案來處理檔案上傳

在WebConfig中建立這樣一個Bean。

@Bean
public MultipartResolver multipartResolver() {
    return new StandardServletMultipartResolver();
}

那麼使用者上傳的檔案該儲存到哪裡呢?大小、格式限制在哪裡做呢?

如果是XML方式,需要配置DispatcherServlet中的一部分來指定如何處理檔案。

如果是實現WebApplicationInitializer則可以在DispatcherServlet的Servlet Registration上呼叫setMutipartConfig進行設定。

對於大多數情況下的繼承AbstractAnnotationConfigDispatcherServletInitializer,我們只需要重寫其中的customizeRegistration方法來設定引數就好了。

@Override
protected void customizeRegistration(ServletRegistration.Dynamic registration) {
    registration.setMultipartConfig(
            new MultipartConfigElement("/tmp/uploads")
    );
}

我們只使用了一個引數的構造器來指定上傳的暫存路徑,還可以有其它的構造器用來指定更多的引數。

假如我們不希望使用者上傳大於2MB的檔案,不希望整個請求的資料高達4MB,所有檔案都寫入到磁碟中,那麼可以使用如下設定。

@Override
protected void customizeRegistration(ServletRegistration.Dynamic registration) {
    registration.setMultipartConfig(
            new MultipartConfigElement("/tmp/uploads", 2097152, 4194304, 0)
    );
}

XML配置如下

處理Multipart請求

下面就是編寫Controller來處理Multipart請求了

我先寫一個表單頁面

下面使用enctype指定請求體格式為multipart/form-data,然後使用了一個file型別的input,除此之外沒啥好說的。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form method="post" th:object="${user}" enctype="multipart/form-data">
        username: <input type="text" th:field="*{username}" name="username"> <br>
        age: <input type="number" th:field="*{age}" name="age"> <br>
        icon: <input type="file" name="profilePicture" accept="image/gif,image/jpeg,image/png"><br>
        <button type="submit">註冊</button>
    </form>
</body>
</html>

下面就是Controller,我們使用了@RequestPart註解,並通過一個byte陣列來接收檔案。使用者上傳的檔案先會被伺服器儲存到之前配置的暫存目錄下,然後再將檔案的位元組碼傳遞給我們。

@Controller
@RequestMapping("/user")
public class UserController {
    @GetMapping("/register")
    public String register(Model model) {
        model.addAttribute("user", new User());
        return "register";
    }


    @PostMapping("/register")
    public String processRegistration(
        @RequestPart("profilePicture") byte[] profilePicture,
        @Valid User user,
        Errors errors
    ) {
        if (errors.hasErrors()) {
            return "register";
        }else{
            return "redirect:/";
        }
    }
}

使用byte[]這種原始的資料型別,一切都得我們自己來處理,比如儲存檔案。

使用Spring提供的MultipartFile介面,我們可以更加方便地對使用者上傳的資料進行操作。

如下是MultipartFile提供的方法,我們可以獲取原始檔名,型別,大小,甚至可以用transferTo直接以檔案的形式儲存到本地。

@PostMapping("/register")
public String processRegistration(
        @RequestPart("profilePicture")MultipartFile multipartFile,
        @Valid User user,
        Errors errors
) {
    try {
        multipartFile.transferTo(
                new File("D:/tmp/springlearn/userpictures/"+multipartFile.getOriginalFilename()));
    } catch (IOException e) {
        e.printStackTrace();
    }
    return "redirect:/";
}

還可以使用Part型別來接收檔案,它更簡單,甚至無需配置MultipartResolver

異常處理

在這之前我們處理異常都是通過在Controller中使用try-catch語句然後返回不同的邏輯檢視名。這樣Controller中就會產生不同的實際檢視路徑。如下:

try{
    doSomething();
    return "successed";
} catch(SomeException e){
    return "redirect:/someErrorPage";
}

如果我們把這部分內容從Controller中剝離,讓Controller就關注成功的狀態,讓失敗的狀態由其他模組處理,會更好一些。

ResponseStatus

對於未捕獲的異常,預設情況下Spring會將其捕獲然後返回500狀態碼,並列印所有的異常堆疊。

這樣不僅對使用者不友好,而且將系統的異常堆疊暴露給使用者對系統來說也很不安全。

如果我們在異常上使用@ResponseStatus註解,那麼我們可以繫結一個錯誤程式碼和錯誤訊息。

@ResponseStatus(
        value = HttpStatus.NOT_FOUND,
        reason = "user not found"
)
public class UserNotFoundException extends Exception{

}

然後在controller的方法中丟擲對應異常

@GetMapping("/{username}")
public String getUser(@PathVariable String username) throws UserNotFoundException {
    // 就是假裝沒找到
    throw new UserNotFoundException();
}

編寫處理異常的方法

大部分發生異常的時候我們都要進行處理然後返回一個自定義頁面,而不只是返回一個預設頁面給使用者。

在Controller中定義一個方法,方法上宣告一個@ExceptionHandler的註解並指定要捕獲的異常型別。

@ExceptionHandler(UserNotFoundException.class)
public String userNotFoundHandler() {
    return "errors/user-not-found";
}

這個方法和普通的控制器方法沒什麼區別,也可以返回一個邏輯檢視名,主要是該控制器內所有未捕獲的UserNotFoundException異常都會被這個函式處理。

如下, 我們定義的異常頁面被渲染。

控制器通知

如果很多個控制器中都可能會出現同種型別的異常,那麼把處理異常的程式碼放到控制器中就不合適了。

控制器通知可以將這種異常的處理抽離出來,我們先寫一個看看。

@ControllerAdvice
public class UserExceptionHandler {
    @ExceptionHandler(UserNotFoundException.class)
    public String userNotFoundHandler() {
        return "errors/user-not-found";
    }
}

@ControllerAdvice上被聲明瞭一個@Component註解,也就是說它也可以被Spring掃描。接下來只要是控制器中未處理的@ExcepitonHandler中定義的異常都會執行對應的方法。

跨重定向請求資料

之前如果有重定向並傳遞資料的需求,我們都是通過"redirect:/url/"+引數來傳遞的。

這樣傳遞有一些限制,首先它不安全,直接拼接URL可能被使用者注入一些惡意程式碼,其次它只能儲存字串。

Spring提供了兩個辦法來解決這個問題。

URL模板

我們可以通過URL模板來解決掉直接拼接引數產生的不安全問題,URL模板會對拼接的值進行轉義。

@PostMapping("/register")
public String processRegistration(
        User user,
        Model model
) {
    model.addAttribute("username",user.getUsername());
    return "redirect:/user/showuser/{username}";
}

將model中的屬性放到返回的邏輯檢視名中。

然後跳轉到的處理器方法可以通過PathVariable接收它

@GetMapping("/showuser/{username}")
public String showUser(@PathVariable String username, Model model) {
    model.addAttribute("username",username);
    return "showuser";
}

flash屬性

flash屬性解決了傳遞的值只能是字串的問題。

flash使用session技術實現,但它被取出之後就不在session中存在了,也許是因為轉瞬即逝,所以叫flash????

使用flash屬性就不能使用普通的Model了,需要RedirectAttributes,下面是使用案例。

@PostMapping("/register")
public String processRegistration(
        User user,
        RedirectAttributes model
) {
    model.addAttribute("username",user.getUsername());
    model.addFlashAttribute("user", user);
    return "redirect:/user/showuser/{username}";
}

flash屬性會被傳到轉發請求的模型中,並且從session中移除。下面的控制器方法檢測了模型裡是否存在對應物件,如果存在就直接返回檢視名,然後檢視進行渲染。

@GetMapping("/showuser/{username}")
public String showUser(@PathVariable String username, Model model) throws Exception {
    if(!model.containsAttribute("user"))
        throw new Exception("bad paramter");
    return "showuser";
}