天天看點

深入了解SpringMVC工作原理,手寫SpringMVC架構沒那麼難

作者:Java程式媛睡不着

引言

對于SpringMVC相信諸位并不陌生,這是Java開發過程中使用最頻繁的架構,在你的項目中可能不一定用MyBatis,但絕對會使用SpringMVC,因為操作資料庫還有Hibernate、JPA等其他ORM架構選擇,但SpringMVC這個架構在其領域中,可謂是獨領風騷,是以在面試中也會常常問到一些與之相關的面試題,其中最為經典的則是那道:

SpringMVC在啟動後是如何工作的?(工作原理)

對于這題的答案,相信大家在“Java面試八股文”中絕對背過,但之前大多數小夥伴應該也隻是死記,并未真正的了解其核心原理,那本篇的目的就在于讓諸位真正的掌握SpringMVC原理。當然,為了更好地了解,咱們也不會以之前分析底層時的那種源碼方式,對其進行長篇概述,本次則使用一種新的方式來對其進行原理講解。

那新的方式是什麼呢?那就是自己手寫架構,真正的了解就是自己能夠把輪子重新造一次,這原本源碼的方式更加形象,也能夠更加讓我們對其原理印象深刻。

在之後有可能會寫的《源碼分析》專題中,會再次詳細剖析一些常用開源架構的源碼實作,同時為了加深對每個技術棧的了解,在剖析清楚源碼實作後,也會以本文這種形式,對架構進行迷你版的手寫實戰,是以本文也算是一個新的嘗試。

一、SpringMVC架構的概述與回憶

SpringMVC是Spring家族中的元老之一,它是一個基于MVC三層架構模式的Web應用架構,它的出現也一統了JavaWEB應用開發的項目結構,進而避免将所有業務代碼都糅合在同一個包下的複雜情況。在該架構中通過把Model、View、Controller分離,如下:

  • M/Model模型:由service、dao、entity等JavaBean構成,主要負責業務邏輯處理。
  • V/View視圖:負責向使用者進行界面的展示,由jsp、html、ftl....等組成。
  • C/Controller控制器:主要負責接收請求、調用業務服務、根據結果派發頁面。

SpringMVC貫徹落實了MVC思想,以分層工作的模式,把整個較為複雜的web應用拆分成邏輯清晰的幾部分,從很大程度上也簡化了開發工作,減少了團隊協作開發時的出錯幾率。

回想最初的servlet開發,或者說最初我們學習Java時,如稚子般的操作,當時也不會劃分子產品、劃分包,所有代碼一股腦的全都放在少數的幾個包下。但不知從何時起,慢慢的,每當有一個新的項目需求出現時,我們都會先對其劃分子產品,再劃分層次,SpringMVC這個架構已經讓每位Java開發徹底将MVC思想刻入到了DNA中,無論是最初的單體開發,亦或是如今主流的分布式、微服務開發,相信大家都已經遵守着這個思想。

SpringMVC架構的設計,是以請求為驅動,圍繞Servlet設計的,将請求發給控制器,然後通過模型對象,分派器來展示請求結果的視圖。SpringMVC的核心類是DispatcherServlet,它是一個Servlet子類,頂層是實作的Servlet接口。

當然,此刻暫且避開其原理不談,先回想最初的SpringMVC是如何使用的呢?一起來看看。

1.1、SpringMVC的使用方式

對于SpringMVC架構的原生使用方式,估計大部分小夥伴都已經忘了,尤其是近些年SpringBoot架構的流行,由于其簡化配置的特性,讓我們幾乎無需再關注最初那些繁雜的XML配置。

說到這塊就引起了我早些年那些痛苦的回憶,在SpringBoot還未那麼流行之前,幾乎所有的配置都是基于XML來弄的,而且每當引入一個新的技術棧,都需要配置一大堆檔案,比如Spring、SpringMVC、MyBatis、Shiro、Quartz、EhCache....,這個整合過程無疑是痛苦的。

但随着後續的SpringBoot流行,這些問題則無需開發者再關注,不過成也SpringBoot,敗也SpringBoot,尤其是近幾年新入行的Java程式員,正是由于未曾有過之前那種繁重的XML配置經曆,是以對于application.yml中很多技術棧的配置項也并不是特别了解,項目開發中需要引入一個新的技術棧時,幾乎靠在網上copy他人的配置資訊,也就成了“知其然而不知其是以然”,這對後續想要深入研究底層也成了一道新的屏障。

就此打住,感慨也不多說了,咱們先來回憶回憶最初SpringMVC的使用方式:基于最普通的maven-web工程建構。

在使用SpringMVC架構時,一般會首先配置它的核心檔案:springmvc-servlet.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:context="http://www.springframework.org/schema/context"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
		http://www.springframework.org/schema/context
		http://www.springframework.org/schema/context/spring-context-4.3.xsd 
		http://www.springframework.org/schema/mvc
		http://www.springframework.org/schema/mvc/spring-mvc.xsd">
    
    <!-- 通過context:component-scan元素掃描指定包下的控制器-->
    <!-- 掃描com.xxx.xxx及子孫包下的控制器(掃描範圍過大,耗時)-->
    <context:component-scan base-package="com.xxx.controller"/>
    
    <!-- ViewResolver -->
    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <!-- viewClass需要在pom中引入兩個包:standard.jar and jstl.jar -->
        <property name="viewClass"
                  value="org.springframework.web.servlet.view.JstlView"></property>
        <property name="prefix" value="/WEB-INF/jsp/"/>
        <property name="suffix" value=".jsp"/>
    </bean>
    
    <!-- 省略其他配置...... -->
</beans>           

在springmvc-servlet.xml這個核心配置檔案中,最重要的其實是配置Controller類所在的路徑,即包掃描的路徑,以及配置一個視圖解析器,主要用于解析請求成功之後的視圖資料。

OK~,配置好了springmvc-servlet.xml檔案後,緊接着我們會再修改maven-web項目核心檔案web.xml中的配置項:

<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
  <display-name>Archetype Created Web Application</display-name>

  <!-- 再這裡會添加一個SpringMVC的servlet配置項 -->
  <servlet>
  <!-- 首先指定SpringMVC核心控制器所在的位置 -->
    <servlet-name>SpringMVC</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <!-- DispatcherServlet啟動時,從哪個檔案中加載元件的初始化資訊 -->
    <!--此參數可以不配置,預設值為:/WEB-INF/springmvc-servlet.xml-->
    <init-param>
      <param-name>contextConfigLocation</param-name>
      <param-value>/WEB-INF/springmvc-servlet.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
    <!--web.xml 3.0的新特性,是否支援異步-->
    <!--<async-supported>true</async-supported>-->
  </servlet>
  <!-- 配置路由比對規則,/ 代表比對所有,類似于nginx的location規則 -->
  <servlet-mapping>
    <servlet-name>SpringMVC</servlet-name>
    <url-pattern>/</url-pattern>
  </servlet-mapping>
</web-app>           

修改web.xml中的配置時,主要就幹了一件事情,也就是為SpringMVC添加了一對servlet的配置項,主要指定了幾個值:

  • ①指定了SpringMVC中DispatcherServlet類的全路徑。
  • ②指定DispatcherServlet初始化元件時,從哪個檔案中加載元件的配置資訊。
  • ③配置了一條值為/的路由比對規則,/代表所有請求路徑都比對。

經過上述配置後,伺服器啟動後,所有的請求都會根據配置好的路由規則,先去到DispatcherServlet中處理。

至此,大概的配置就弄好了,緊接着是在前面配置的com.xxx.controller包中編寫對應的Controller類,如下:

package com.xxx.controller;

@Controller("/user")
public class UserController{
    // 省略......
}           

一切就緒後,一般都會将WEB應用打成war包,然後放入到Tomcat中運作,而當Tomcat啟動時,首先會找到對應的WEB程式,緊接着會去加載web.xml,加載web.xml時,由于前面在其中配置了DispatcherServlet,是以此時會先去加載DispatcherServlet,而加載這個類時,又會觸發它的初始化方法,會調用initStrategies()方法對元件進行初始化,如下:

// DispatcherServlet類 → initStrategies()方法
protected void initStrategies(ApplicationContext context) {
        // 在這裡面初始化SpringMVC工作時,需要用到的各大元件
        initMultipartResolver(context);
        initLocaleResolver(context);
        initThemeResolver(context);
        initHandlerMappings(context);
        initHandlerAdapters(context);
        initHandlerExceptionResolvers(context);
        initRequestToViewNameTranslator(context);
        initViewResolvers(context);
        initFlashMapManager(context);
    }           

那初始化元件時,肯定需要一些加載一些對應的元件配置,這些配置資訊從哪兒來呢?也就是根據我們指定的<init-param></init-param>配置項,讀取之前的核心檔案:springmvc-servlet.xml中所配置的資訊,對各大元件進行初始化。

是以,當Tomcat啟動成功後,SpringMVC的各大元件也會初始化完成。

當然,DispatcherServlet除開是SpringMVC的初始化建構器外,還是SpringMVC的元件調用器,因為前面在web.xml還配置了一條路由規則,所有的請求都會先進入DispatcherServlet中處理,那既然所有的請求都進入了這個類,此時究竟該如何分發請求,就可以任由SpringMVC排程了。

但SpringMVC内部究竟是如何調用各大元件對請求進行處理的,這就涉及到了本文開頭抛出的面試題了,也就是SpringMVC的工作原理,接下來我們簡單聊一聊。

二、SpringMVC工作原理詳解

在了解SpringMVC的工作原理之前,首先認識一些常用元件:

DispatcherServlet前端控制器:接收請求,響應結果,相當于轉發器,是整個流程控制的中心,由它調用其它元件處理使用者的請求,是以也可稱為中央處理器。有了它之後,可以很大程度上減少其它元件之間的耦合度。
HandlerMapping處理映射器:主要負責根據請求路徑查找Handler處理器,也就是根據使用者的請求路徑找到具體的Java方法,具體是如何找到的呢?是根據映射關系查找的,SpringMVC提供了不同的映射器實作不同的映射方式,例如:配置檔案方式,實作接口方式,注解方式等。
HandlerAdapter處理擴充卡:就是一個用于執行Handler處理器的元件,會根據用戶端不同的請求方式(get/post/...),執行對應的Handler。說人話就是前面的元件定位到具體Java方法後,用來執行Java方法的元件。
Handler處理器:其實這也就是包含具體業務操作的Java方法,在SpringMVC中會被包裝成一個Handler對象。

ViewResolver視圖解析器::對業務代碼執行完成之後的結果進行視圖解析,根據邏輯視圖名解析成真正的視圖,比如controller方法執行完成之後,return的值是index,那麼會對這個結果進行解析,将結果生成例如index.jsp這類的View視圖。

ViewResolver工作時,會首先根據邏輯視圖名解析成實體視圖名,即具體的頁面位址,然後再生成View視圖對象,最後對視圖進行渲染,将處理結果通過頁面展示給使用者。

SpringMVC提供了很多的View視圖類型,如:jstlView、freemarkerView、pdfView等,前面我們配置的JSP視圖解析器則是JstlView,這裡也可以根據模闆引擎的不同,選擇不同的解析器。

View視圖:View在SpringMVC中是一個接口,實作類支援不同的類型,例如jsp、freemarker、ftl...,不過現在一般都是前後端分離的項目,是以也很少再用到這塊内容,視圖一般都成了html頁面,資料結果的渲染工作也交給了前端完成。

SpringMVC的核心就是DispatcherServlet,由它去調用各類元件完成工作。而DispatcherServlet其實本質上就是一個Servlet子類,一般WEB層架構本質上都離不開Servlet,就好比ORM架構離不開JDBC,比如Zuul、GateWay等架構,本質上也是依賴于Servlet技術作為底層的。

三、手寫Mini版SpringMVC架構

到目前為止,相對來說已經将SpringMVC的工作原理做了簡單概述,接下來就來到本文的核心:自己手寫一個Mini版的SpringMVC架構。步驟主要分為五步:

  • ①自定義相關注解。
  • ②實作核心元件。
  • ③實作DispatcherServlet。
  • ④編寫相關的視圖(jsp網頁)。
  • ⑤編寫測試用例。

3.1、自定義相關注解

SpringMVC中的注解實際上并不少,是以在這裡不會全部實作,重點就自定義@Controller、@RequestMapping、@ResponseBody這幾個常用的核心注解。

3.1.1、@Controller注解的定義

// 聲明注解的生命周期:RUNTIME表示運作時期有效
@Retention(RetentionPolicy.RUNTIME)
// 注解的生效範圍:隻能生效于類上面
@Target(ElementType.TYPE)
public @interface Controller {
    //@interface是元注解:JDK封裝的專門用來實作自定義注解的注解
}           

這個注解稍後會加載咱們要掃描的Controller類上,主要是為了标注出掃描時的目标類。

3.1.2、@RequestMapping注解的定義

// 聲明注解的生命周期:RUNTIME表示運作時期有效
@Retention(RetentionPolicy.RUNTIME)
// 注解的生效範圍:可應用在類上面、方法上面
@Target({ElementType.METHOD,ElementType.TYPE})
public @interface RequestMapping {
    // 允許該注解可以填String類型的參數,預設為空
    String value() default "";
}           

這個注解可以加在類或方法上,主要是用來給類或方法映射請求路徑。

3.1.3、@ResponseBody注解的定義

// 聲明注解的生命周期:RUNTIME表示運作時期有效
@Retention(RetentionPolicy.RUNTIME)
// 注解的生效範圍:隻能應用在方法上面
@Target(ElementType.METHOD)
public @interface ResponseBody {
}           

這個注解的作用是在于控制傳回時的響應方式,不加該注解的方法,預設會跳轉頁面,也加了該注解的方法,則會直接響應資料。

OK~,在上面定義了三個注解,其中使用到了兩個JDK提供的元注解:@Retention、@Target,前者用于控制注解的生命周期,表示自定義的注解在何時生效。後者則控制了注解的生效範圍,可以控制自定義注解在類、方法、屬性上生效。

不過在這裡并未對這些注解進行處理,隻是簡單的定義,如果想要注解生效,一般有兩種方式:①使用AOP切面對注解進行處理。②使用反射機制對注解進行處理。

稍後我們會采用上述的第二種方式對自定義的注解進行處理。

3.2、實作核心元件

自定義注解的工作完成後,緊接着再來實作一些運作時需要用到的核心元件。當然,這裡也不會将之前SpringMVC擁有的所有元件全部實作,僅實作幾個核心的元件,能夠達到效果即可。

3.3、實作DispatcherServlet中央控制器

public class DispacherServlet extends HttpServlet {

    // 定義一個 Map 容器,存儲映射關系
    private static Map<String, InvocationHandler> HandlerMap;

    @Override
    public void init() throws ServletException {
        System.out.println("項目啟動了.....");
        // 指定要掃描的包路徑(原本是從xml檔案中讀取的)
        String packagePath = "com.xxx.controller";
        // 在指定的包路徑下掃描帶有@Controller注解的類
        Set<Class<?>> classSet = ClassUtil.
                scanPackageByAnnotation(packagePath, Controller.class);
        System.out.println("掃描到類的數量為:" + classSet.size());
        // 建立一個HandlerMapping并調用urlMapping()方法
        HandlerMapping handlerMapping = new HandlerMapping();
        HandlerMap = handlerMapping.urlMapping(classSet);
        // 最終擷取到一個帶有所有映射關系的 Map 集合
        System.out.println("HandlerMap的長度:" + HandlerMap.size());
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        doPost(req,resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        // 擷取用戶端的請求路徑
        StringBuffer requestURL = req.getRequestURL();
        System.out.println("用戶端請求路徑:" + requestURL);
        // 判斷請求路徑中是否包含項目名,包含的話使用空字元替換掉
        String path = new String(requestURL).replace("http://" +
                req.getServerName() + ":" + req.getServerPort(), "");
        System.out.println("處理後的用戶端請求路徑:" + path);
        // 根據處理好的 path 作為條件去map中查找對應的方法
        InvocationHandler handler = HandlerMap.get(path);
        // 擷取到對應的類執行個體對象和Java方法
        Object object = handler.getObject();
        Method method = handler.getMethod();

        // 判斷該方法上是否添加了@ResponseBody注解:
        //      true:直接傳回資料  false:跳轉頁面
        boolean f = method.isAnnotationPresent(ResponseBody.class);
        System.out.println("是否添加了@ResponseBody注解:" + f);
        // 如果方法上存在@ResponseBody注解
        if (f){
            try {
                // 通過反射的方式調用方法并執行
                Object invoke = method.invoke(object);
                // 将結果通過Response直接寫回給用戶端
                resp.getWriter().print(invoke.toString());
            } catch (Exception e) {
                e.printStackTrace();
            }
        } else{
            // 擷取用戶端的請求路徑作為傳回時的前路徑
            String URL = "http://" + req.getServerName() + ":" +
                    req.getServerPort() + "/" + req.getContextPath();
            System.out.println("URL:" + URL);
            // 自定義的前字尾(原本也是在xml中讀取)
            String prefix = "";
            String suffix = ".jsp";
            try {
                // 通過反射機制,執行對應的Java方法
                Object invoke = method.invoke(object);
                if(invoke instanceof ModelAndView){
                    // 如果是傳回的ModelAndView對象,這裡做額外處理....
                } else{
                    // 擷取Java方法執行之後的傳回結果
                    String str = (String)invoke;
                    // 如果指定了跳轉方法為 forward: 轉發
                    if(str.contains("forward:")){
                        System.out.println("以轉發的方式跳轉頁面...");
                        req.getRequestDispatcher("index.jsp").forward(req,resp);
                    }
                    // 如果指定了跳轉方法為 redirect: 重定向
                    if(str.contains("redirect:")){
                        System.out.println("以重定向的方式跳轉頁面...");
                        resp.sendRedirect(URL + prefix +
                            str.replace("redirect:","") + suffix);
                    }
                    // 如果沒有指定,則預設使用轉發的方式跳轉頁面
                    if(!str.contains("forward:") && !str.contains("redirect:")){
                        resp.sendRedirect(URL + prefix + str + suffix);
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }

        }
    }
}           

由于DispacherServlet實作了HttpServlet抽象類,是以也重寫了它的三個方法:init()、doGet()、doPost(),其中init()方法會在項目啟動時執行,而doGet()、doPost()則會在用戶端請求時被觸發。

總結一下上述DispacherServlet所做的工作:

  • ①初始化所有請求路徑與Java方法之間的映射關系。
  • ②根據用戶端的請求路徑,查找對應的Java方法并執行。
  • ③判斷方法上是否添加了@ResponseBody注解: 添加了:直接向用戶端傳回資料。 未添加:跳轉對應的頁面。
  • ④以重定向或轉發的方式跳轉對應的頁面。

OK~,最後也不要忘了在web.xml配置一下我們自己的DispacherServlet:

<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
  <display-name>Archetype Created Web Application</display-name>

  <servlet>
    <servlet-name>dispacherServlet</servlet-name>
    <!-- 這裡配置的DispacherServlet是我們自己的 -->
    <servlet-class>com.xxx.DispacherServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>

  <servlet-mapping>
    <servlet-name>dispacherServlet</servlet-name>
    <!-- 比對規則依舊是所有請求路徑都會比對 -->
    <url-pattern>/</url-pattern>
  </servlet-mapping>
</web-app>           

3.4、編寫View視圖

3.5、編寫測試用例

四、手寫SpringMVC架構總結

最後結合手寫SpringMVC的過程,再談談SpringMVC工作流程的了解,其實在咱們把一個JavaWeb程式打成war包丢入Tomcat後,當啟動Tomcat時,它就會先去加載web.xml檔案,而加載web.xml檔案時,會碰到DispacherServlet需要被加載,是以又會去加載它,當加載DispacherServlet時,其實本質上會把SpringMVC的元件初始化,然後将所有Controller的URL資源都映射到一個容器中存儲。

當後續用戶端發生請求時,首先會根據配置好的路由規則,所有請求會先進入DispacherServlet,DispacherServlet會先解析用戶端的請求路徑,然後根據路徑去容器中找到該Url對應的Java方法,找到之後再調用元件去執行具體的Controller方法,當執行完之後,又會将結果傳回給DispacherServlet,此時又會去調用相關元件處理執行後的結果,最後才将渲染後的結果響應。

如果面試碰到這樣的問題,也可以結合本文去回答。

你面試時,如果回答能比他人更有深度以及你自己的思考,自然你就比其他候選者的機會更大,畢竟當下内卷越來越嚴重,一個能讓面試官眼前一亮的候選者,自然也會給面試官帶來不同的體驗,是以你收到Offer的幾率也會更高。