背景說明:
SpringBoot1.5+jsp+tomcat的管理後臺專案
坑1: tomcat-embed-jasper包依賴
SpringMVC中jsp請求流程:
- servlet容器收到請求,分發到SpringMVC的DispatcherServlet.
- SpringMVC經過處理,返回jsp檢視名稱,隨後通過InternalResourceViewResolver解析得到InternalResourceView
- InternalResourceView通過forward方式伺服器內部跳轉
- servlet容器再次收到請求,由於本次請求中url中帶有.jsp字尾,所以分發給JspServlet處理
- JspServlet在第一次被呼叫時使用jsp引擎解析jsp檔案,並生成servlet,並註冊,隨後呼叫
坑就坑在第4步中
現象:
當InternalResourceView進行forward之後,請求又進入到了SpringMVC的DispatcherServlet中
原因:
JspServlet沒有被註冊到Servlet容器中,所以請求分發到DispatcherServlet來處理
原因是很簡單,但是之前對Jsp處理流程不熟的我還是想了半天.甚至萌生手動解析jsp檔案的想法#-_-
解決方案:
新增下面這個包的依賴
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
複製程式碼
有人會奇怪之前使用SpringMVC(非SpringBoot)的時候不用管這些的啊?(我也是*-*)
下面來細說
外接容器(Tomcat)
其實使用外接Tomcat的時候我們是不需要新增上面這個包的依賴的
因為這個包已經在TOMCAT_HOME/lib中引入,同時JspServet也在TOMCAT_HOME/Conf/web.xml(全域性配置)被註冊
<servlet>
<servlet-name>jsp</servlet-name>
<servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
<init-param>
<param-name>fork</param-name>
<param-value>false</param-value>
</init-param>
<init-param>
<param-name>xpoweredBy</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>3</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>jsp</servlet-name>
<url-pattern>*.jsp</url-pattern>
<url-pattern>*.jspx</url-pattern>
</servlet-mapping>
複製程式碼
所以當我們使用外接Tomcat的時候壓根不用管這些.
然而到了內嵌Tomcat時就不太一樣了
內嵌容器(Tomcat)
- 首先tomcat-embed-jasper包是獨立出來的,需要我們單獨引入
- 內嵌Tomcat預設不註冊JspServet
這回都清楚了.
還有一點,在SpringBoot中我們除了新增依賴也沒註冊JspServlet啊?
因為SpringBoot幫我們註冊了
//tomcat啟動準備
protected void prepareContext(Host host, ServletContextInitializer[] initializers) {
File docBase = getValidDocumentRoot();
docBase = (docBase != null ? docBase : createTempDir("tomcat-docbase"));
final TomcatEmbeddedContext context = new TomcatEmbeddedContext();
...
//是否Classpath中有org.apache.jasper.servlet.JspServlet這個類
//有就註冊
if (shouldRegisterJspServlet()) {
addJspServlet(context);
addJasperInitializer(context);
context.addLifecycleListener(new StoreMergedWebXmlListener());
}
}
複製程式碼
這裡說一句,SpringBoot真是好東西.原先使用Spring,只會照著樣子用.現在可好,用了SpringBoot逼著我去搞清楚這些原理,要不然壓根駕馭不了這貨#-_-
坑2: Jsp檔案放哪?
當解決了坑1之後,滿心歡喜以為都ok,結果發現SpringBoot壓根沒WEB-INF目錄
那我的Jsp檔案放哪?隨便放可以嗎?
抱著試一試的態度,在resources下面建了個WEB-INF,希望SpringBoot能和我心有靈犀
結果我失敗了...
簡單推斷一下: 肯定是JspServlet找不到我的Jsp的檔案,那麼它是怎麼尋找Jsp檔案的呢?
打個斷點跟蹤一下
#org.apache.jasper.servlet.JspServlet
//被JspServlet.service()呼叫
private void serviceJspFile(HttpServletRequest request,
HttpServletResponse response, String jspUri,
boolean precompile)
throws ServletException, IOException {
//從快取中取出jsp->servlet物件
JspServletWrapper wrapper = rctxt.getWrapper(jspUri);
if (wrapper == null) {
synchronized(this) {
//雙重校驗
wrapper = rctxt.getWrapper(jspUri);
if (wrapper == null) {
//判斷jsp檔案是否存在
if (null == context.getResource(jspUri)) {
handleMissingResource(request, response, jspUri);
return;
}
wrapper = new JspServletWrapper(config, options, jspUri,
rctxt);
rctxt.addWrapper(jspUri,wrapper);
}
}
}
try {
//使用Jsp引擎解析得到的Servlet
wrapper.service(request, response, precompile);
} catch (FileNotFoundException fnfe) {
handleMissingResource(request, response, jspUri);
}
}
複製程式碼
一路跟著context.getResource(jspUri)最終進到StandardRoot#getResourceInternal方法中
#org.apache.catalina.webresources.StandardRoot
{//構造程式碼塊
allResources.add(preResources);
allResources.add(mainResources);
allResources.add(classResources);
allResources.add(jarResources);
allResources.add(postResources);
}
protected final WebResource getResourceInternal(String path,
boolean useClassLoaderResources) {
...
//遍歷
for (List<WebResourceSet> list : allResources) {
for (WebResourceSet webResourceSet : list) {
if (!useClassLoaderResources && !webResourceSet.getClassLoaderOnly() ||
useClassLoaderResources && !webResourceSet.getStaticOnly()) {
result = webResourceSet.getResource(path);
if (result.exists()) {
return result;
}
...
}
}
}
...
}
複製程式碼
我們呼叫一下看allResources都包含哪些物件
可以看到allResource中只有一個DirResourceSet,而且是一個臨時目錄(裡面啥檔案也沒有)
理所當然JspServlet找不到我們的jsp檔案
基於這個想法,我們只要手動新增一個ResourceSet到allResources,是不是就可以了
@Bean
public CustomTomcatEmbeddedServletContainerFactory customTomcatEmbeddedServletContainerFactory() {
return new CustomTomcatEmbeddedServletContainerFactory();
}
public static class CustomTomcatEmbeddedServletContainerFactory extends TomcatEmbeddedServletContainerFactory {
//在prepareContext中被呼叫
@Override
protected void postProcessContext(Context context) {
super.postProcessContext(context);
//新增監聽器
context.addLifecycleListener(new LifecycleListener() {
@Override
public void lifecycleEvent(LifecycleEvent event) {
if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) {
try {
//!!!資源所在url
URL url = ResourceUtils.getURL(ResourceUtils.CLASSPATH_URL_PREFIX);
//!!!資源搜尋路徑
String path = "/";
//手動建立一個ResourceSet
context.getResources().createWebResourceSet(
WebResourceRoot.ResourceSetType.RESOURCE_JAR, "/", url, path);
} catch (Exception e) {
e.printStackTrace();
}
}
}
});
}
}
複製程式碼
由於是在Idea中直接執行,所以base是在target/classes目錄下
再嘗試訪問以下,果真可以訪問到了
結論:
內嵌tomcat中,需要我們手動註冊資源搜尋路徑
坑點3:使用jar包方式執行 又訪問不到jsp
這回有點奇怪了,使用idea直接執行都沒問題 ,可是打成jar包後執行卻又不行了
檢視了一下日誌,發現報錯了
Caused by: org.apache.catalina.LifecycleException: Failed to initialize component [org.apache.catalina.webresources.JarWarResourceSet@59119757]
at org.apache.catalina.util.LifecycleBase.init(LifecycleBase.java:112)
at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:140)
at org.apache.catalina.webresources.JarWarResourceSet.<init>(JarWarResourceSet.java:76)
... 12 more
Caused by: java.lang.NullPointerException: entry
at java.util.zip.ZipFile.getInputStream(ZipFile.java:346)
at java.util.jar.JarFile.getInputStream(JarFile.java:447)
at org.apache.catalina.webresources.JarWarResourceSet.initInternal(JarWarResourceSet.java:173)
at org.apache.catalina.util.LifecycleBase.init(LifecycleBase.java:107)
... 14 more
複製程式碼
debug跟蹤了一下 發現取到的url是
jar:file:/Users/mic/IdeaProjects/mobileHall/mobileHall-start/target/mobileHall-start-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/
複製程式碼
看著很奇怪 不太像正常的Url 按正常的Url表示 應該是這樣的
file:/Users/mic/IdeaProjects/mobileHall/mobileHall-start/target/mobileHall-start-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes
複製程式碼
推測是springboot打包(簡稱springboot-jar)後路徑變化導致的(我是查了好久才知道的#_#)
假設目標檔案路徑為:專案根路徑/resource/a.jsp
1.idea中(以classpath關聯)
url = file:/Users/mic/IdeaProjects/mobileHall/mobileHall-start/target/classes/ (資源所在Url)
path= / (資源搜尋路徑)
2.普通jar
url= jar:file:/Users/mic/IdeaProjects/mobileHall/mobileHall-start/target/mobileHall-start-0.0.1-SNAPSHOT.jar
path= /BOOT-INF/classes
3.springboot-jar
url= jar:file:/Users/mic/IdeaProjects/mobileHall/mobileHall-start/target/mobileHall-start-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/
path= /
複製程式碼
可以看到springboot-jar中獲取的Url很特殊,不是一個標準Url
思考:
- SpringBoot-jar的url為何不是個標準Url
- 如何通過變種Url來進行資源定位(資源讀取)
結論:
- 特殊jar包格式(jarInjar),SpringBoot打包jar不是標準jar包結構(把依賴lib也以jar的形式打進去了)
- 變種Url,為了滿足自身特殊的打包格式進行資源定位(資源讀取),定義了一套變種Url
- URLStreamHandler實現,通過實現URLStreamHandler來滿足url.openConnection()獲取資源方法.同時還繼承了JarFile (Url根據Protocol找到不同URLStreamHandler實現來進行資源定位)
再來看java專案常見的打包格式一般就為兩種
- jar,依賴不會以jar包方式打入jar,要麼以外部依賴的方式通過-classpath關聯,要麼將原始碼合併打入jar中
- war,其實就是個壓縮包,解壓後就是一個專案自身的jar和外部依賴的jar
可以看到SpringBoot-jar和war有點像.而Tomcat支援war不解壓執行,那麼想必應該支援jarInjar的讀取方式
再回到Tomcat的資源搜尋來
Tomcat支援一下兩種方式新增資源搜尋路徑
#org.apache.catalina.WebResourceRoot
//方法1.拆分Url為base,archivePath 呼叫方法2
void createWebResourceSet(ResourceSetType type, String webAppMount, URL url,
String internalPath);
//方法2
/**
* 新增一個ResourceSet(資源集合)到Tomcat的資源搜尋路徑中
* @param type 資源型別(jar,file等)
* @param webAppMount 掛載點
* @param base 資源路徑
* @param archivePath jar中jar相對路徑
* @param internalPath jar中jar中resource的相對路徑
*/
void createWebResourceSet(ResourceSetType type, String webAppMount, String base, String archivePath, String internalPath);
#org.apache.catalina.webresources.StandardRoot
//方法1具體實現
@Override
public void createWebResourceSet(ResourceSetType type, String webAppMount,
URL url, String internalPath) {
//解析Url拆分為base,archivePath
BaseLocation baseLocation = new BaseLocation(url);
createWebResourceSet(type, webAppMount, baseLocation.getBasePath(),
baseLocation.getArchivePath(), internalPath);
}
複製程式碼
Tomcat果然支援jar中jar內資源的讀取
並且Tomcat本身提供了方法1,可以通過傳入Url來進行拆分
問題:
那麼為何變種Url直接傳入卻不行呢
來看Tomcat的拆分過程
#org.apache.catalina.webresources.StandardRoot.BaseLocation
//假設標準url= jar:file:/a.jar!/lib/b.jar
//拆分得到base= /a.jar archivePath= /lib/b.jar
//而此時變種url= jar:file:/a.jar!/lib/b.jar!/
//拆分得到 base= /a.jar archivePath= /lib/b.jar!/
BaseLocation(URL url) {
File f = null;
if ("jar".equals(url.getProtocol()) || "war".equals(url.getProtocol())) {
String jarUrl = url.toString();
int endOfFileUrl = -1;
if ("jar".equals(url.getProtocol())) {
endOfFileUrl = jarUrl.indexOf("!/");
} else {
endOfFileUrl = jarUrl.indexOf(UriUtil.getWarSeparator());
}
String fileUrl = jarUrl.substring(4, endOfFileUrl);
try {
f = new File(new URL(fileUrl).toURI());
} catch (MalformedURLException | URISyntaxException e) {
throw new IllegalArgumentException(e);
}
int startOfArchivePath = endOfFileUrl + 2;
if (jarUrl.length() > startOfArchivePath) {
archivePath = jarUrl.substring(startOfArchivePath);
} else {
archivePath = null;
}
}
...
basePath = f.getAbsolutePath();
}
複製程式碼
問題很明顯了 就是變種Url中拆分出的archivePath還帶了!/尾巴
解決思路:
解析SpringBoot的變種Url,去掉archivePath中的尾巴
複製程式碼
注意:SpringBoot的變種Url中Boot-INF/classes也被當做一個jar,但在標準Url中只是個目錄而已,所以要特殊處理
@Override
public void lifecycleEvent(LifecycleEvent event) {
if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) {
try {
//jar:file:/a.jar!/BOOT-INF/classes!/
URL url = ResourceUtils.getURL(ResourceUtils.CLASSPATH_URL_PREFIX);
String path = "/";
BaseLocation baseLocation = new BaseLocation(url);
if (baseLocation.getArchivePath() != null) {//當有archivePath時肯定是jar包執行
//url= jar:file:/a.jar
//此時Tomcat再拆分出base = /a.jar archivePath= /
url = new URL(url.getPath().replace("!/" + baseLocation.getArchivePath(), ""));
//path=/BOOT-INF/classes
path = "/" + baseLocation.getArchivePath().replace("!/", "");
}
context.getResources().createWebResourceSet(
WebResourceRoot.ResourceSetType.RESOURCE_JAR, "/", url, path);
} catch (Exception e) {
e.printStackTrace();
}
}
}
複製程式碼
通過處理變種Url->標準Url,,使得Tomcat容器能以標準Url進行拆分
再利用Tomcat本身支援的jarInjar資源讀取,就能獲取到資源了
那如果jsp放在依賴的jar中怎麼辦
同樣的只要我們jarInjar的Url進行處理就好了
@Bean
public CustomTomcatEmbeddedServletContainerFactory customTomcatEmbeddedServletContainerFactory() {
return new CustomTomcatEmbeddedServletContainerFactory();
}
public static class CustomTomcatEmbeddedServletContainerFactory extends TomcatEmbeddedServletContainerFactory {
@Override
protected void postProcessContext(Context context) {
super.postProcessContext(context);
context.addLifecycleListener(new LifecycleListener() {
private boolean isResourcesJar(JarFile jar) throws IOException {
try {
return jar.getName().endsWith(".jar")
&& (jar.getJarEntry("WEB-INF") != null);
} finally {
jar.close();
}
}
@Override
public void lifecycleEvent(LifecycleEvent event) {
if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) {
try {
ClassLoader classLoader = getClass().getClassLoader();
List<URL> staticResourceUrls = new ArrayList<URL>();
if (classLoader instanceof URLClassLoader) {
//遍歷Classpath中裝載的所有資源url
for (URL url : ((URLClassLoader) classLoader).getURLs()) {
URLConnection connection = url.openConnection();
//如果是jar包資源且jar包中含有WEB-INF目錄 則新增到集合中
if (connection instanceof JarURLConnection) {
if (isResourcesJar(((JarURLConnection) connection).getJarFile())) {
staticResourceUrls.add(url);
}
}
}
}
//遍歷集合 新增到容器的資源搜尋路徑中
for (URL url : staticResourceUrls) {
String file = url.getFile();
if (file.endsWith(".jar") || file.endsWith(".jar!/")) {
String jar = url.toString();
if (!jar.startsWith("jar:")) {
jar = "jar:" + jar + "!/";
}
//如果是jarinjar去掉!/尾巴
if ((jar+"1").split("!/").length==3) {//jarInjar
jar = jar.substring(0, jar.length() - 2);
}
URL newUrl = new URL(jar);
String path = "/";
context.getResources().createWebResourceSet(
WebResourceRoot.ResourceSetType.RESOURCE_JAR, "/", newUrl, path);
}
...
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
});
}
}
複製程式碼
參考org.springframework.boot.context.embedded.tomcat.TomcatResources.Tomcat8Resources#addResourceSet
另外
其實SpringBoot已經幫我們處理lib中資源的讀取了(主要是用於webjar)
#org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory#prepareContext
protected void prepareContext(Host host, ServletContextInitializer[] initializers) {
...
context.addLifecycleListener(new LifecycleListener() {
@Override
public void lifecycleEvent(LifecycleEvent event) {
//新增lib中(不包括專案自身)META/resource目錄到資源搜尋路徑中
if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) {
TomcatResources.get(context)
.addResourceJars(getUrlsOfJarsWithMetaInfResources());
}
}
});
...
}
複製程式碼
靜態資源訪問為何不會出現問題
如果SpringBoot也是利用Tomcat資源訪問(DefaultServlet),那麼肯定也會出現變種Url的問題. 在SpringMVC中有大致有兩種方式進行靜態資源訪問: 1. 使用DefaultServlet進行資源訪問 2. 使用ResourceHttpRequestHandler. 而在SpringBoot中預設是使用ResourceHttpRequestHandler進行靜態資源訪問
#org.springframework.http.converter.ResourceHttpMessageConverter
protected void writeContent(Resource resource, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
try {
//寫入http輸出流
InputStream in = resource.getInputStream();
try {
StreamUtils.copy(in, outputMessage.getBody());
}
catch (NullPointerException ex) {
// ignore, see SPR-13620
}
...
}
#org.springframework.core.io.ClassPathResource
@Override
public InputStream getInputStream() throws IOException {
InputStream is;
if (this.clazz != null) {
// 利用ClassLoader獲取資源
is = this.clazz.getResourceAsStream(this.path);
}
else if (this.classLoader != null) {
is = this.classLoader.getResourceAsStream(this.path);
}
else {
is = ClassLoader.getSystemResourceAsStream(this.path);
}
if (is == null) {
throw new FileNotFoundException(getDescription() + " cannot be opened because it does not exist");
}
return is;
}
複製程式碼
可以看到ResourceHttpRequestHandler最後是利用ClassLoader獲取資源,最後通過Url.openConnect()獲取資源,而SpringBoot-jar中註冊了handler來根據變種Url進行資源定位.所以可以成功訪問到資源. 而Tomcat中不是通過Url.openConnect()直接獲取資源,而是自己解析Url在根據路徑獲取資源,所以會出現問題