引言
對于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的幾率也會更高。