JavaWeb後端
JavaWeb後端
我們學習JavaWeb的最終目的是為了搭建一個網站,並且讓用戶能訪問我們的網站並在我們的網站上做一些事情。
電腦網路基礎
在電腦網路(謝希仁 第七版 第264頁)中,是這樣描述萬維網的:
萬維網(World Wide Web)並非是某種特殊的電腦網路,萬維網是一個大規模的聯機式資訊儲藏所,英文簡稱
Web
,萬維網用鏈接的方法,能夠非常方便地從互聯網上的一個站點訪問另一個站點,從而主動地按需求獲取豐富的資訊。
這句話說的非常官方,但是也蘊藏著許多的資訊,首先它指明,我們的互聯網上存在許許多多的伺服器,而我們通過訪問這些伺服器就能快速獲取伺服器為我們提供的資訊(比如打開百度就能展示搜索、打開小破站能刷影片、打開微博能查看實時熱點)而這些伺服器就是由不同的公司在運營。
其次,我們通過瀏覽器,只需要輸入對應的網址或是點擊頁面中的一個鏈接,就能夠快速地跳轉到另一個頁面,從而按我們的意願來訪問伺服器。
而書中是這樣描述萬維網的工作方式:
萬維網以客戶伺服器的方式工作,瀏覽器就是安裝在用戶主機上的萬維網客戶程式,萬維網文檔所駐留的主機則運行伺服器程式,因此這台主機也稱為萬維網伺服器。客戶程式向伺服器程式發出請求,伺服器程式向客戶程式送回客戶所要的萬維網文檔,在一個客戶程式主窗口上顯示出的萬維網文檔稱為頁面。
上面提到的客戶程式其實就是我們電腦上安裝的瀏覽器,而服務端就是我們即將要去學習的Web伺服器,也就是說,我們要明白如何搭建一個Web伺服器並向用戶發送我們提供的Web頁面,在瀏覽器中顯示的,一般就是HTML文檔被解析後的樣子。
那麼,我們的伺服器可能不止一個頁面,可能會有很多個頁面,那麼客戶端如何知道該去訪問哪個伺服器的哪個頁面呢?這個時候就需要用到URL
統一資源定位符。互聯網上所有的資源,都有一個唯一確定的URL,比如//www.baidu.com
URL的格式為:
<協議>://<主機>:<埠>/<路徑>
協議是指採用什麼協議來訪問伺服器,不同的協議決定了伺服器返回資訊的格式,我們一般使用HTTP協議。
主機可以是一個域名,也可以是一個IP地址(實際上域名最後會被解析為IP地址進行訪問)
埠是當前伺服器上Web應用程式開啟的埠,我們前面學習TCP通訊的時候已經介紹過了,HTTP協議默認使用80埠,因此有時候可以省略。
路徑就是我們希望去訪問此伺服器上的某個文件,不同的路徑代表訪問不同的資源。
我們接著來了解一下什麼是HTTP協議:
HTTP是面向事務的應用層協議,它是萬維網上能夠可靠交換文件的重要基礎。HTTP不僅傳送完成超文本跳轉所需的必須資訊,而且也傳送任何可從互聯網上得到的資訊,如文本、超文本、聲音和影像。
實際上我們之前訪問百度、訪問自己的網站,所有的傳輸都是以HTTP作為協議進行的。
我們來看看HTTP的傳輸原理:
HTTP使用了面向連接的TCP作為運輸層協議,保證了數據的可靠傳輸。HTTP不必考慮數據在傳輸過程中被丟棄後又怎樣被重傳。但是HTTP協議本身是無連接的。也就是說,HTTP雖然使用了TCP連接,但是通訊的雙方在交換HTTP報文之前不需要先建立HTTP連接。1997年以前使用的是HTTP/1.0協議,之後就是HTTP/1.1協議了。
那麼既然HTTP是基於TCP進行通訊的,我們首先來回顧一下TCP的通訊原理:
TCP協議實際上是經歷了三次握手再進行通訊,也就是說保證整個通訊是穩定的,才可以進行數據交換,並且在連接已經建立的過程中,雙方隨時可以互相發送數據,直到有一方主動關閉連接,這時在進行四次揮手,完成整個TCP通訊。
而HTTP和TCP並不是一個層次的通訊協議,TCP是傳輸層協議,而HTTP是應用層協議,因此,實際上HTTP的內容會作為TCP協議的報文被封裝,並繼續向下一層進行傳遞,而傳輸到客戶端時,會依次進行解包,還原為最開始的HTTP數據。
HTTP使用TCP協議是為了使得數據傳輸更加可靠,既然它是依靠TCP協議進行數據傳輸,那麼為什麼說它本身是無連接的呢?我們來看一下HTTP的傳輸過程:
用戶在點擊滑鼠鏈接某個萬維網文檔時,HTTP協議首先要和伺服器建立TCP連接。這需要使用三報文握手。當建立TCP連接的三報文握手的前兩部分完成後(即經過了一個RTT時間後),萬維網客戶就把HTTP請求報文作為建立TCP連接的三報文握手中的第三個報文的數據,發送給萬維網伺服器。伺服器收到HTTP請求報文後,就把所請求的文檔作為響應報文返回給客戶。
因此,我們的瀏覽器請求一個頁面,需要兩倍的往返時間。
最後,我們再來了解一下HTTP的報文結構:
由客戶端向服務端發送是報文稱為請求報文,而服務端返回給客戶端的稱為響應報文,實際上,整個報文全部是以文本形式發送的,通過使用空格和換行來完成分段。
現在,我們已經了解了HTTP協議的全部基礎知識,那麼什麼是Web伺服器呢,實際上,它就是一個軟體,但是它已經封裝了所有的HTTP協議層面的操作,我們無需關心如何使用HTTP協議通訊,而是直接基於伺服器軟體進行開發,我們只需要關心我們的頁面數據如何展示、前後端如何交互即可。
認識Tomcat伺服器
Tomcat(湯姆貓)就是一個典型的Web應用伺服器軟體,通過運行Tomcat伺服器,我們就可以快速部署我們的Web項目,並交由Tomcat進行管理,我們只需要直接通過瀏覽器訪問我們的項目即可。
那麼首先,我們需要進行一個簡單的環境搭建,我們需要在Tomcat官網下載最新的Tomcat服務端程式://tomcat.apache.org/download-10.cgi(下載速度可能有點慢)
- 下載:64-bit Windows zip
下載完成後,解壓,並放入桌面,接下來需要配置一下環境變數,打開高級系統設置
,打開環境變數
,添加一個新的系統變數,變數名稱為JRE_HOME
,填寫JDK的安裝目錄+/jre,比如Zulujdk默認就是:C:\Program Files\Zulu\zulu-8\jre
設置完成後,我們進入tomcat文件夾bin目錄下,並在當前位置打開CMD窗口,將startup.sh拖入窗口按回車運行,如果環境變數配置有誤,會提示,若沒問題,伺服器則正常啟動。
如果出現亂碼,說明編碼格式配置有問題,我們修改一下伺服器的配置文件,打開conf
文件夾,找到logging.properties
文件,這就是日誌的配置文件(我們在前面已經給大家講解過了)將ConsoleHandler的默認編碼格式修改為GBK編碼格式:
java.util.logging.ConsoleHandler.encoding = GBK
現在重新啟動伺服器,就可以正常顯示中文了。
伺服器啟動成功之後,不要關閉,我們打開瀏覽器,在瀏覽器中訪問://localhost:8080/,Tomcat伺服器默認是使用8080埠(可以在配置文件中修改),訪問成功說明我們的Tomcat環境已經部署成功了。
整個Tomcat目錄下,我們已經認識了bin目錄(所有可執行文件,包括啟動和關閉伺服器的腳本)以及conf目錄(伺服器配置文件目錄),那麼我們接著來看其他的文件夾:
- lib目錄:Tomcat服務端運行的一些依賴,不用關心。
- logs目錄:所有的日誌資訊都在這裡。
- temp目錄:存放運行時產生的一些臨時文件,不用關心。
- work目錄:工作目錄,Tomcat會將jsp文件轉換為java文件(我們後面會講到,這裡暫時不提及)
- webapp目錄:所有的Web項目都在這裡,每個文件夾都是一個Web應用程式:
我們發現,官方已經給我們預設了一些項目了,訪問後默認使用的項目為ROOT項目,也就是我們默認打開的網站。
我們也可以訪問example項目,只需要在後面填寫路徑即可://localhost:8080/examples/,或是docs項目(這個是Tomcat的一些文檔)//localhost:8080/docs/
Tomcat還自帶管理頁面,我們打開://localhost:8080/manager,提示需要用戶名和密碼,由於不知道是什麼,我們先點擊取消,頁面中出現如下內容:
You are not authorized to view this page. If you have not changed any configuration files, please examine the file
conf/tomcat-users.xml
in your installation. That file must contain the credentials to let you use this webapp.For example, to add the
manager-gui
role to a user namedtomcat
with a password ofs3cret
, add the following to the config file listed above.<role rolename="manager-gui"/> <user username="tomcat" password="s3cret" roles="manager-gui"/>
Note that for Tomcat 7 onwards, the roles required to use the manager application were changed from the single
manager
role to the following four roles. You will need to assign the role(s) required for the functionality you wish to access.
manager-gui
– allows access to the HTML GUI and the status pagesmanager-script
– allows access to the text interface and the status pagesmanager-jmx
– allows access to the JMX proxy and the status pagesmanager-status
– allows access to the status pages onlyThe HTML interface is protected against CSRF but the text and JMX interfaces are not. To maintain the CSRF protection:
- Users with the
manager-gui
role should not be granted either themanager-script
ormanager-jmx
roles.- If the text or jmx interfaces are accessed through a browser (e.g. for testing since these interfaces are intended for tools not humans) then the browser must be closed afterwards to terminate the session.
For more information – please see the Manager App How-To.
現在我們按照上面的提示,去配置文件中進行修改:
<role rolename="manager-gui"/>
<user username="admin" password="admin" roles="manager-gui"/>
現在再次打開管理頁面,已經可以成功使用此用戶進行登陸了。登錄後,展示給我們的是一個圖形化介面,我們可以快速預覽當前伺服器的一些資訊,包括已經在運行的Web應用程式,甚至還可以查看當前的Web應用程式有沒有出現記憶體泄露。
同樣的,還有一個虛擬主機管理頁面,用於一台主機搭建多個Web站點,一般情況下使用不到,這裡就不做演示了。
我們可以將我們自己的項目也放到webapp文件夾中,這樣就可以直接訪問到了,我們在webapp目錄下新建test文件夾,將我們之前編寫的前端程式碼全部放入其中(包括html文件、js、css、icon等),重啟伺服器。
我們可以直接通過 //localhost:8080/test/ 來進行訪問。
使用Maven創建Web項目
雖然我們已經可以在Tomcat上部署我們的前端頁面了,但是依然只是一個靜態頁面(每次訪問都是同樣的樣子),那麼如何向伺服器請求一個動態的頁面呢(比如顯示我們訪問當前頁面的時間)這時就需要我們編寫一個Web應用程式來實現了,我們需要在用戶向伺服器發起頁面請求時,進行一些處理,再將結果發送給用戶的瀏覽器。
注意:這裡需要使用終極版IDEA,如果你的還是社區版,就很難受了。
我們打開IDEA,新建一個項目,選擇Java Enterprise(社區版沒有此選項!)項目名稱隨便,項目模板選擇Web應用程式,然後我們需要配置Web應用程式伺服器,將我們的Tomcat伺服器集成到IDEA中。配置很簡單,首先點擊新建,然後設置Tomcat主目錄即可,配置完成後,點擊下一步即可,依賴項使用默認即可,然後點擊完成,之後IDEA會自動幫助我們創建Maven項目。
創建完成後,直接點擊右上角即可運行此項目了,但是我們發現,有一個Servlet頁面不生效。
需要注意的是,Tomcat10以上的版本比較新,Servlet API包名發生了一些變化,因此我們需要修改一下依賴:
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>5.0.0</version>
<scope>provided</scope>
</dependency>
注意包名全部從javax改為jakarta,我們需要手動修改一下。
感興趣的可以了解一下為什麼名稱被修改了:
Eclipse基金會在2019年對 Java EE 標準的每個規範進行了重命名,闡明了每個規範在Jakarta EE平台未來的角色。
新的名稱Jakarta EE是Java EE的第二次重命名。2006年5月,「J2EE」一詞被棄用,並選擇了Java EE這個名稱。在YouTube還只是一家獨立的公司的時候,數字2就就從名字中消失了,而且當時冥王星仍然被認為是一顆行星。同樣,作為Java SE 5(2004)的一部分,數字2也從J2SE中刪除了,那時Google還沒有上市。
因為不能再使用javax名稱空間,Jakarta EE提供了非常明顯的分界線。
- Jakarta 9(2019及以後)使用jakarta命名空間。
- Java EE 5(2005)到Java EE 8(2017)使用javax命名空間。
- Java EE 4使用javax命名空間。
我們可以將項目直接打包為war包(默認),打包好之後,放入webapp文件夾,就可以直接運行我們通過Java編寫的Web應用程式了,訪問路徑為文件的名稱。
Servlet
前面我們已經完成了基本的環境搭建,那麼現在我們就可以開始來了解我們的第一個重要類——Servlet。
它是Java EE的一個標準,大部分的Web伺服器都支援此標準,包括Tomcat,就像之前的JDBC一樣,由官方定義了一系列介面,而具體實現由我們來編寫,最後交給Web伺服器(如Tomcat)來運行我們編寫的Servlet。
那麼,它能做什麼呢?我們可以通過實現Servlet來進行動態網頁響應,使用Servlet,不再是直接由Tomcat伺服器發送我們編寫好的靜態網頁內容(HTML文件),而是由我們通過Java程式碼進行動態拼接的結果,它能夠很好地實現動態網頁的返回。
當然,Servlet並不是專用於HTTP協議通訊,也可以用於其他的通訊,但是一般都是用於HTTP。
創建Servlet
那麼如何創建一個Servlet呢,非常簡單,我們只需要實現Servlet
類即可,並添加註解@WebServlet
來進行註冊。
@WebServlet("/test")
public class TestServlet implements Servlet {
...實現介面方法
}
我們現在就可以去訪問一下我們的頁面://localhost:8080/test/test
我們發現,直接訪問此頁面是沒有任何內容的,這是因為我們還沒有為該請求方法編寫實現,這裡先不做講解,後面我們會對瀏覽器的請求處理做詳細的介紹。
除了直接編寫一個類,我們也可以在web.xml
中進行註冊,現將類上@WebServlet
的註解去掉:
<servlet>
<servlet-name>test</servlet-name>
<servlet-class>com.example.webtest.TestServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>test</servlet-name>
<url-pattern>/test</url-pattern>
</servlet-mapping>
這樣的方式也能註冊Servlet,但是顯然直接使用註解更加方便,因此之後我們一律使用註解進行開發。只有比較新的版本才支援此註解,老的版本是不支援的哦。
實際上,Tomcat伺服器會為我們提供一些默認的Servlet,也就是說在伺服器啟動後,即使我們什麼都不編寫,Tomcat也自帶了幾個默認的Servlet,他們編寫在conf目錄下的web.xml中:
<!-- The mapping for the default servlet -->
<servlet-mapping>
<servlet-name>default</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<!-- The mappings for the JSP servlet -->
<servlet-mapping>
<servlet-name>jsp</servlet-name>
<url-pattern>*.jsp</url-pattern>
<url-pattern>*.jspx</url-pattern>
</servlet-mapping>
我們發現,默認的Servlet實際上可以幫助我們去訪問一些靜態資源,這也是為什麼我們啟動Tomcat伺服器之後,能夠直接訪問webapp目錄下的靜態頁面。
我們可以將之前編寫的頁面放入到webapp目錄下,來測試一下是否能直接訪問。
探究Servlet的生命周期
我們已經了解了如何註冊一個Servlet,那麼我們接著來看看,一個Servlet是如何運行的。
首先我們需要了解,Servlet中的方法各自是在什麼時候被調用的,我們先編寫一個列印語句來看看:
public class TestServlet implements Servlet {
public TestServlet(){
System.out.println("我是構造方法!");
}
@Override
public void init(ServletConfig servletConfig) throws ServletException {
System.out.println("我是init");
}
@Override
public ServletConfig getServletConfig() {
System.out.println("我是getServletConfig");
return null;
}
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
System.out.println("我是service");
}
@Override
public String getServletInfo() {
System.out.println("我是getServletInfo");
return null;
}
@Override
public void destroy() {
System.out.println("我是destroy");
}
}
我們首先啟動一次伺服器,然後訪問我們定義的頁面,然後再關閉伺服器,得到如下的順序:
我是構造方法!
我是init
我是service
我是service(出現兩次是因為瀏覽器請求了2次,是因為有一次是請求favicon.ico,瀏覽器通病)我是destroy
我們可以多次嘗試去訪問此頁面,但是init和構造方法只會執行一次,而每次訪問都會執行的是service
方法,因此,一個Servlet的生命周期為:
- 首先執行構造方法完成 Servlet 初始化
- Servlet 初始化後調用 init () 方法。
- Servlet 調用 service() 方法來處理客戶端的請求。
- Servlet 銷毀前調用 destroy() 方法。
- 最後,Servlet 是由 JVM 的垃圾回收器進行垃圾回收的。
現在我們發現,實際上在Web應用程式運行時,每當瀏覽器向伺服器發起一個請求時,都會創建一個執行緒執行一次service
方法,來讓我們處理用戶的請求,並將結果響應給用戶。
我們發現service
方法中,還有兩個參數,ServletRequest
和ServletResponse
,實際上,用戶發起的HTTP請求,就被Tomcat伺服器封裝為了一個ServletRequest
對象,我們得到是其實是Tomcat伺服器幫助我們創建的一個實現類,HTTP請求報文中的所有內容,都可以從ServletRequest
對象中獲取,同理,ServletResponse
就是我們需要返回給瀏覽器的HTTP響應報文實體類封裝。
那麼我們來看看ServletRequest
中有哪些內容,我們可以獲取請求的一些資訊:
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
//首先將其轉換為HttpServletRequest(繼承自ServletRequest,一般是此介面實現)
HttpServletRequest request = (HttpServletRequest) servletRequest;
System.out.println(request.getProtocol()); //獲取協議版本
System.out.println(request.getRemoteAddr()); //獲取訪問者的IP地址
System.out.println(request.getMethod()); //獲取請求方法
//獲取頭部資訊
Enumeration<String> enumeration = request.getHeaderNames();
while (enumeration.hasMoreElements()){
String name = enumeration.nextElement();
System.out.println(name + ": " + request.getHeader(name));
}
}
我們發現,整個HTTP請求報文中的所有內容,都可以通過HttpServletRequest
對象來獲取,當然,它的作用肯定不僅僅是獲取頭部資訊,我們還可以使用它來完成更多操作,後面會一一講解。
那麼我們再來看看ServletResponse
,這個是服務端的響應內容,我們可以在這裡填寫我們想要發送給瀏覽器顯示的內容:
//轉換為HttpServletResponse(同上)
HttpServletResponse response = (HttpServletResponse) servletResponse;
//設定內容類型以及編碼格式(普通HTML文本使用text/html,之後會講解文件傳輸)
response.setHeader("Content-type", "text/html;charset=UTF-8");
//獲取Writer直接寫入內容
response.getWriter().write("我是響應內容!");
//所有內容寫入完成之後,再發送給瀏覽器
現在我們在瀏覽器中打開此頁面,就能夠收到伺服器發來的響應內容了。其中,響應頭部分,是由Tomcat幫助我們生成的一個默認響應頭。
因此,實際上整個流程就已經很清晰明了了。
解讀和使用HttpServlet
前面我們已經學習了如何創建、註冊和使用Servlet,那麼我們繼續來深入學習Servlet介面的一些實現類。
首先Servlet
有一個直接實現抽象類GenericServlet
,那麼我們來看看此類做了什麼事情。
我們發現,這個類完善了配置文件讀取和Servlet資訊相關的的操作,但是依然沒有去實現service方法,因此此類僅僅是用於完善一個Servlet的基本操作,那麼我們接著來看HttpServlet
,它是遵循HTTP協議的一種Servlet,繼承自GenericServlet
,它根據HTTP協議的規則,完善了service方法。
在閱讀了HttpServlet源碼之後,我們發現,其實我們只需要繼承HttpServlet來編寫我們的Servlet就可以了,並且它已經幫助我們提前實現了一些操作,這樣就會給我們省去很多的時間。
@Log
@WebServlet("/test")
public class TestServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/html;charset=UTF-8");
resp.getWriter().write("<h1>恭喜你解鎖了全新玩法</h1>");
}
}
現在,我們只需要重寫對應的請求方式,就可以快速完成Servlet的編寫。
@WebServlet註解詳解
我們接著來看WebServlet註解,我們前面已經得知,可以直接使用此註解來快速註冊一個Servlet,那麼我們來想細看看此註解還有什麼其他的玩法。
首先name屬性就是Servlet名稱,而urlPatterns和value實際上是同樣功能,就是代表當前Servlet的訪問路徑,它不僅僅可以是一個固定值,還可以進行通配符匹配:
@WebServlet("/test/*")
上面的路徑表示,所有匹配/test/隨便什麼
的路徑名稱,都可以訪問此Servlet,我們可以在瀏覽器中嘗試一下。
也可以進行某個擴展名稱的匹配:
@WebServlet("*.js")
這樣的話,獲取任何以js結尾的文件,都會由我們自己定義的Servlet處理。
那麼如果我們的路徑為/
呢?
@WebServlet("/")
此路徑和Tomcat默認為我們提供的Servlet衝突,會直接替換掉默認的,而使用我們的,此路徑的意思為,如果沒有找到匹配當前訪問路徑的Servlet,那麼久會使用此Servlet進行處理。
我們還可以為一個Servlet配置多個訪問路徑:
@WebServlet({"/test1", "/test2"})
我們接著來看loadOnStartup屬性,此屬性決定了是否在Tomcat啟動時就載入此Servlet,默認情況下,Servlet只有在被訪問時才會載入,它的默認值為-1,表示不在啟動時載入,我們可以將其修改為大於等於0的數,來開啟啟動時載入。並且數字的大小決定了此Servlet的啟動優先順序。
@Log
@WebServlet(value = "/test", loadOnStartup = 1)
public class TestServlet extends HttpServlet {
@Override
public void init() throws ServletException {
super.init();
log.info("我被初始化了!");
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/html;charset=UTF-8");
resp.getWriter().write("<h1>恭喜你解鎖了全新玩法</h1>");
}
}
其他內容都是Servlet的一些基本配置,這裡就不詳細講解了。
使用POST請求完成登陸
我們前面已經了解了如何使用Servlet來處理HTTP請求,那麼現在,我們就結合前端,來實現一下登陸操作。
我們需要修改一下我們的Servlet,現在我們要讓其能夠接收一個POST請求:
@Log
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.getParameterMap().forEach((k, v) -> {
System.out.println(k + ": " + Arrays.toString(v));
});
}
}
ParameterMap
存儲了我們發送的POST請求所攜帶的表單數據,我們可以直接將其遍歷查看,瀏覽器發送了什麼數據。
現在我們再來修改一下前端:
<body>
<h1>登錄到系統</h1>
<form method="post" action="login">
<hr>
<div>
<label>
<input type="text" placeholder="用戶名" name="username">
</label>
</div>
<div>
<label>
<input type="password" placeholder="密碼" name="password">
</label>
</div>
<div>
<button>登錄</button>
</div>
</form>
</body>
通過修改form標籤的屬性,現在我們點擊登錄按鈕,會自動向後台發送一個POST請求,請求地址為當前地址+/login(注意不同路徑的寫法),也就是我們上面編寫的Servlet路徑。
運行伺服器,測試後發現,在點擊按鈕後,確實向伺服器發起了一個POST請求,並且攜帶了表單中文本框的數據。
現在,我們根據已有的基礎,將其與資料庫打通,我們進行一個真正的用戶登錄操作,首先修改一下Servlet的邏輯:
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//首先設置一下響應類型
resp.setContentType("text/html;charset=UTF-8");
//獲取POST請求攜帶的表單數據
Map<String, String[]> map = req.getParameterMap();
//判斷表單是否完整
if(map.containsKey("username") && map.containsKey("password")) {
String username = req.getParameter("username");
String password = req.getParameter("password");
//許可權校驗(待完善)
}else {
resp.getWriter().write("錯誤,您的表單數據不完整!");
}
}
接下來我們再去編寫Mybatis的依賴和配置文件,創建一個表,用於存放我們用戶的帳號和密碼。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"//mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="${驅動類(含包名)}"/>
<property name="url" value="${資料庫連接URL}"/>
<property name="username" value="${用戶名}"/>
<property name="password" value="${密碼}"/>
</dataSource>
</environment>
</environments>
</configuration>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.7</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.27</version>
</dependency>
配置完成後,在我們的Servlet的init方法中編寫Mybatis初始化程式碼,因為它只需要初始化一次。
SqlSessionFactory factory;
@SneakyThrows
@Override
public void init() throws ServletException {
factory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config.xml"));
}
現在我們創建一個實體類以及Mapper來進行用戶資訊查詢:
@Data
public class User {
String username;
String password;
}
public interface UserMapper {
@Select("select * from users where username = #{username} and password = #{password}")
User getUser(@Param("username") String username, @Param("password") String password);
}
<mappers>
<mapper class="com.example.dao.UserMapper"/>
</mappers>
好了,現在完事具備,只欠東風了,我們來完善一下登陸驗證邏輯:
//登陸校驗(待完善)
try (SqlSession sqlSession = factory.openSession(true)){
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.getUser(username, password);
//判斷用戶是否登陸成功,若查詢到資訊則表示存在此用戶
if(user != null){
resp.getWriter().write("登陸成功!");
}else {
resp.getWriter().write("登陸失敗,請驗證您的用戶名或密碼!");
}
}
現在再去瀏覽器上進行測試吧!
註冊介面其實是同理的,這裡就不多做講解了。
上傳和下載文件
首先我們來看看比較簡單的下載文件,首先將我們的icon.png放入到resource文件夾中,接著我們編寫一個Servlet用於處理文件下載:
@WebServlet("/file")
public class FileServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("image/png");
OutputStream outputStream = resp.getOutputStream();
InputStream inputStream = Resources.getResourceAsStream("icon.png");
}
}
為了更加快速地編寫IO程式碼,我們可以引入一個工具庫:
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.6</version>
</dependency>
使用此類庫可以快速完成IO操作:
resp.setContentType("image/png");
OutputStream outputStream = resp.getOutputStream();
InputStream inputStream = Resources.getResourceAsStream("icon.png");
//直接使用copy方法完成轉換
IOUtils.copy(inputStream, outputStream);
現在我們在前端頁面添加一個鏈接,用於下載此文件:
<hr>
<a href="file" download="icon.png">點我下載高清資源</a>
下載文件搞定,那麼如何上傳一個文件呢?
首先我們編寫前端部分:
<form method="post" action="file" enctype="multipart/form-data">
<div>
<input type="file" name="test-file">
</div>
<div>
<button>上傳文件</button>
</div>
</form>
注意必須添加enctype="multipart/form-data"
,來表示此表單用於文件傳輸。
現在我們來修改一下Servlet程式碼:
@MultipartConfig
@WebServlet("/file")
public class FileServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
try(FileOutputStream stream = new FileOutputStream("/Users/nagocoler/Documents/IdeaProjects/WebTest/test.png")){
Part part = req.getPart("test-file");
IOUtils.copy(part.getInputStream(), stream);
resp.setContentType("text/html;charset=UTF-8");
resp.getWriter().write("文件上傳成功!");
}
}
}
注意,必須添加@MultipartConfig
註解來表示此Servlet用於處理文件上傳請求。
現在我們再運行伺服器,並將我們剛才下載的文件又上傳給服務端。
使用XHR請求數據
現在我們希望,網頁中的部分內容,可以動態顯示,比如網頁上有一個時間,旁邊有一個按鈕,點擊按鈕就可以刷新當前時間。
這個時候就需要我們在網頁展示時向後端發起請求了,並根據後端響應的結果,動態地更新頁面中的內容,要實現此功能,就需要用到JavaScript來幫助我們,首先在js中編寫我們的XHR請求,並在請求中完成動態更新:
function updateTime() {
let xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
document.getElementById("time").innerText = xhr.responseText
}
};
xhr.open('GET', 'time', true);
xhr.send();
}
接著修改一下前端頁面,添加一個時間顯示區域:
<hr>
<div id="time"></div>
<br>
<button onclick="updateTime()">更新數據</button>
<script>
updateTime()
</script>
最後創建一個Servlet用於處理時間更新請求:
@WebServlet("/time")
public class TimeServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss");
String date = dateFormat.format(new Date());
resp.setContentType("text/html;charset=UTF-8");
resp.getWriter().write(date);
}
}
現在點擊按鈕就可以更新了。
GET請求也能傳遞參數,這裡做一下演示。
重定向與請求轉發
當我們希望用戶登錄完成之後,直接跳轉到網站的首頁,那麼這個時候,我們就可以使用重定向來完成。當瀏覽器收到一個重定向的響應時,會按照重定向響應給出的地址,再次向此地址發出請求。
實現重定向很簡單,只需要調用一個方法即可,我們修改一下登陸成功後執行的程式碼:
resp.sendRedirect("time");
調用後,響應的狀態碼會被設置為302,並且響應頭中添加了一個Location屬性,此屬性表示,需要重定向到哪一個網址。
現在,如果我們成功登陸,那麼伺服器會發送給我們一個重定向響應,這時,我們的瀏覽器會去重新請求另一個網址。這樣,我們在登陸成功之後,就可以直接幫助用戶跳轉到用戶首頁了。
那麼我們接著來看請求轉發,請求轉發其實是一種伺服器內部的跳轉機制,我們知道,重定向會使得瀏覽器去重新請求一個頁面,而請求轉發則是伺服器內部進行跳轉,它的目的是,直接將本次請求轉發給其他Servlet進行處理,並由其他Servlet來返回結果,因此它是在進行內部的轉發。
req.getRequestDispatcher("/time").forward(req, resp);
現在,在登陸成功的時候,我們將請求轉發給處理時間的Servlet,注意這裡的路徑規則和之前的不同,我們需要填寫Servlet上指明的路徑,並且請求轉發只能轉發到此應用程式內部的Servlet,不能轉發給其他站點或是其他Web應用程式。
現在再次進行登陸操作,我們發現,返回結果為一個405頁面,證明了,我們的請求現在是被另一個Servlet進行處理,並且請求的資訊全部被轉交給另一個Servlet,由於此Servlet不支援POST請求,因此返回405狀態碼。
那麼也就是說,該請求包括請求參數也一起被傳遞了,那麼我們可以嘗試獲取以下POST請求的參數。
現在我們給此Servlet添加POST請求處理,直接轉交給Get請求處理:
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
this.doGet(req, resp);
}
再次訪問,成功得到結果,但是我們發現,瀏覽器只發起了一次請求,並沒有再次請求新的URL,也就是說,這一次請求直接返回了請求轉發後的處理結果。
那麼,請求轉發有什麼好處呢?它可以攜帶數據!
req.setAttribute("test", "我是請求轉發前的數據");
req.getRequestDispatcher("/time").forward(req, resp);
System.out.println(req.getAttribute("test"));
通過setAttribute
方法來給當前請求添加一個附加數據,在請求轉發後,我們可以直接獲取到該數據。
重定向屬於2次請求,因此無法使用這種方式來傳遞數據,那麼,如何在重定向之間傳遞數據呢?我們可以使用即將要介紹的ServletContext對象。
最後總結,兩者的區別為:
- 請求轉發是一次請求,重定向是兩次請求
- 請求轉發地址欄不會發生改變, 重定向地址欄會發生改變
- 請求轉發可以共享請求參數 ,重定向之後,就獲取不了共享參數了
- 請求轉發只能轉發給內部的Servlet
了解ServletContext對象
ServletContext全局唯一,它是屬於整個Web應用程式的,我們可以通過getServletContext()
來獲取到此對象。
此對象也能設置附加值:
ServletContext context = getServletContext();
context.setAttribute("test", "我是重定向之前的數據");
resp.sendRedirect("time");
System.out.println(getServletContext().getAttribute("test"));
因為無論在哪裡,無論什麼時間,獲取到的ServletContext始終是同一個對象,因此我們可以隨時隨地獲取我們添加的屬性。
它不僅僅可以用來進行數據傳遞,還可以做一些其他的事情,比如請求轉發:
context.getRequestDispatcher("/time").forward(req, resp);
它還可以獲取根目錄下的資源文件(注意是webapp根目錄下的,不是resource中的資源)
初始化參數
初始化參數類似於初始化配置需要的一些值,比如我們的資料庫連接相關資訊,就可以通過初始化參數來給予Servlet,或是一些其他的配置項,也可以使用初始化參數來實現。
我們可以給一個Servlet添加一些初始化參數:
@WebServlet(value = "/login", initParams = {
@WebInitParam(name = "test", value = "我是一個默認的初始化參數")
})
它也是以鍵值對形式保存的,我們可以直接通過Servlet的getInitParameter
方法獲取:
System.out.println(getInitParameter("test"));
但是,這裡的初始化參數僅僅是針對於此Servlet,我們也可以定義全局初始化參數,只需要在web.xml編寫即可:
<context-param>
<param-name>lbwnb</param-name>
<param-value>我是全局初始化參數</param-value>
</context-param>
我們需要使用ServletContext來讀取全局初始化參數:
ServletContext context = getServletContext();
System.out.println(context.getInitParameter("lbwnb"));
有關ServletContext其他的內容,我們需要完成後面內容的學習,才能理解。
Cookie
什麼是Cookie?不是曲奇,它可以在瀏覽器中保存一些資訊,並且在下次請求時,請求頭中會攜帶這些資訊。
我們可以編寫一個測試用例來看看:
Cookie cookie = new Cookie("test", "yyds");
resp.addCookie(cookie);
resp.sendRedirect("time");
for (Cookie cookie : req.getCookies()) {
System.out.println(cookie.getName() + ": " + cookie.getValue());
}
我們可以觀察一下,在HttpServletResponse
中添加Cookie之後,瀏覽器的響應頭中會包含一個Set-Cookie
屬性,同時,在重定向之後,我們的請求頭中,會攜帶此Cookie作為一個屬性,同時,我們可以直接通過HttpServletRequest
來快速獲取有哪些Cookie資訊。
還有這麼神奇的事情嗎?那麼我們來看看,一個Cookie包含哪些資訊:
- name – Cookie的名稱,Cookie一旦創建,名稱便不可更改
- value – Cookie的值,如果值為Unicode字元,需要為字元編碼。如果為二進位數據,則需要使用BASE64編碼
- maxAge – Cookie失效的時間,單位秒。如果為正數,則該Cookie在maxAge秒後失效。如果為負數,該Cookie為臨時Cookie,關閉瀏覽器即失效,瀏覽器也不會以任何形式保存該Cookie。如果為0,表示刪除該Cookie。默認為-1。
- secure – 該Cookie是否僅被使用安全協議傳輸。安全協議。安全協議有HTTPS,SSL等,在網路上傳輸數據之前先將數據加密。默認為false。
- path – Cookie的使用路徑。如果設置為「/sessionWeb/」,則只有contextPath為「/sessionWeb」的程式可以訪問該Cookie。如果設置為「/」,則本域名下contextPath都可以訪問該Cookie。注意最後一個字元必須為「/」。
- domain – 可以訪問該Cookie的域名。如果設置為「.google.com」,則所有以「google.com」結尾的域名都可以訪問該Cookie。注意第一個字元必須為「.」。
- comment – 該Cookie的用處說明,瀏覽器顯示Cookie資訊的時候顯示該說明。
- version – Cookie使用的版本號。0表示遵循Netscape的Cookie規範,1表示遵循W3C的RFC 2109規範
我們發現,最關鍵的其實是name
、value
、maxAge
、domain
屬性。
那麼我們來嘗試修改一下maxAge來看看失效時間:
cookie.setMaxAge(20);
設定為20秒,我們可以直接看到,響應頭為我們設定了20秒的過期時間。20秒內訪問都會攜帶此Cookie,而超過20秒,Cookie消失。
既然了解了Cookie的作用,我們就可以通過使用Cookie來實現記住我功能,我們可以將用戶名和密碼全部保存在Cookie中,如果訪問我們的首頁時攜帶了這些Cookie,那麼我們就可以直接為用戶進行登陸,如果登陸成功則直接跳轉到首頁,如果登陸失敗,則清理瀏覽器中的Cookie。
那麼首先,我們先在前端頁面的表單中添加一個勾選框:
<div>
<label>
<input type="checkbox" placeholder="記住我" name="remember-me">
記住我
</label>
</div>
接著,我們在登陸成功時進行判斷,如果用戶勾選了記住我,那麼就講Cookie存儲到本地:
if(map.containsKey("remember-me")){ //若勾選了勾選框,那麼會此表單資訊
Cookie cookie_username = new Cookie("username", username);
cookie_username.setMaxAge(30);
Cookie cookie_password = new Cookie("password", password);
cookie_password.setMaxAge(30);
resp.addCookie(cookie_username);
resp.addCookie(cookie_password);
}
然後,我們修改一下默認的請求地址,現在一律通過//localhost:8080/yyds/login
進行登陸,那麼我們需要添加GET請求的相關處理:
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Cookie[] cookies = req.getCookies();
if(cookies != null){
String username = null;
String password = null;
for (Cookie cookie : cookies) {
if(cookie.getName().equals("username")) username = cookie.getValue();
if(cookie.getName().equals("password")) password = cookie.getValue();
}
if(username != null && password != null){
//登陸校驗
try (SqlSession sqlSession = factory.openSession(true)){
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.getUser(username, password);
if(user != null){
resp.sendRedirect("time");
return; //直接返回
}
}
}
}
req.getRequestDispatcher("/").forward(req, resp); //正常情況還是轉發給默認的Servlet幫我們返回靜態頁面
}
現在,30秒內都不需要登陸,訪問登陸頁面後,會直接跳轉到time頁面。
現在已經離我們理想的頁面越來越接近了,但是仍然有一個問題,就是我們的首頁,無論是否登陸,所有人都可以訪問,那麼,如何才可以實現只有登陸之後才能訪問呢?這就需要用到Session了。
Session
由於HTTP是無連接的,那麼如何能夠辨別當前的請求是來自哪個用戶發起的呢?Session就是用來處理這種問題的,每個用戶的會話都會有一個自己的Session對象,來自同一個瀏覽器的所有請求,就屬於同一個會話。
但是HTTP協議是無連接的呀,那Session是如何做到辨別是否來自同一個瀏覽器呢?Session實際上是基於Cookie實現的,前面我們了解了Cookie,我們知道,服務端可以將Cookie保存到瀏覽器,當瀏覽器下次訪問時,就會附帶這些Cookie資訊。
Session也利用了這一點,它會給瀏覽器設定一個叫做JSESSIONID
的Cookie,值是一個隨機的排列組合,而此Cookie就對應了你屬於哪一個對話,只要我們的瀏覽器攜帶此Cookie訪問伺服器,伺服器就會通過Cookie的值進行辨別,得到對應的Session對象,因此,這樣就可以追蹤到底是哪一個瀏覽器在訪問伺服器。
那麼現在,我們在用戶登錄成功之後,將用戶對象添加到Session中,只要是此用戶發起的請求,我們都可以從HttpSession
中讀取到存儲在會話中的數據:
HttpSession session = req.getSession();
session.setAttribute("user", user);
同時,如果用戶沒有登錄就去訪問首頁,那麼我們將發送一個重定向請求,告訴用戶,需要先進行登錄才可以訪問:
HttpSession session = req.getSession();
User user = (User) session.getAttribute("user");
if(user == null) {
resp.sendRedirect("login");
return;
}
在訪問的過程中,注意觀察Cookie變化。
Session並不是永遠都存在的,它有著自己的過期時間,默認時間為30分鐘,若超過此時間,Session將丟失,我們可以在配置文件中修改過期時間:
<session-config>
<session-timeout>1</session-timeout>
</session-config>
我們也可以在程式碼中使用invalidate
方法來使Session立即失效:
session.invalidate();
現在,通過Session,我們就可以更好地控制用戶對於資源的訪問,只有完成登陸的用戶才有資格訪問首頁。
Filter
有了Session之後,我們就可以很好地控制用戶的登陸驗證了,只有授權的用戶,才可以訪問一些頁面,但是我們需要一個一個去進行配置,還是太過複雜,能否一次性地過濾掉沒有登錄驗證的用戶呢?
過濾器相當於在所有訪問前加了一堵牆,來自瀏覽器的所有訪問請求都會首先經過過濾器,只有過濾器允許通過的請求,才可以順利地到達對應的Servlet,而過濾器不允許的通過的請求,我們可以自由地進行控制是否進行重定向或是請求轉發。並且過濾器可以添加很多個,就相當於添加了很多堵牆,我們的請求只有穿過層層阻礙,才能與Servlet相擁,像極了愛情。
添加一個過濾器非常簡單,只需要實現Filter介面,並添加@WebFilter
註解即可:
@WebFilter("/*") //路徑的匹配規則和Servlet一致,這裡表示匹配所有請求
public class TestFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
}
}
這樣我們就成功地添加了一個過濾器,那麼添加一句列印語句看看,是否所有的請求都會經過此過濾器:
HttpServletRequest request = (HttpServletRequest) servletRequest;
System.out.println(request.getRequestURL());
我們發現,現在我們發起的所有請求,一律需要經過此過濾器,並且所有的請求都沒有任何的響應內容。
那麼如何讓請求可以順利地到達對應的Servlet,也就是說怎麼讓這個請求順利通過呢?我們只需要在最後添加一句:
filterChain.doFilter(servletRequest, servletResponse);
那麼這行程式碼是什麼意思呢?
由於我們整個應用程式可能存在多個過濾器,那麼這行程式碼的意思實際上是將此請求繼續傳遞給下一個過濾器,當沒有下一個過濾器時,才會到達對應的Servlet進行處理,我們可以再來創建一個過濾器看看效果:
@WebFilter("/*")
public class TestFilter2 implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("我是2號過濾器");
filterChain.doFilter(servletRequest, servletResponse);
}
}
由於過濾器的過濾順序是按照類名的自然排序進行的,因此我們將第一個過濾器命名進行調整。
我們發現,在經過第一個過濾器之後,會繼續前往第二個過濾器,只有兩個過濾器全部經過之後,才會到達我們的Servlet中。
實際上,當doFilter
方法調用時,就會一直向下直到Servlet,在Servlet處理完成之後,又依次返回到最前面的Filter,類似於遞歸的結構,我們添加幾個輸出語句來判斷一下:
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("我是2號過濾器");
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("我是2號過濾器,處理後");
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("我是1號過濾器");
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("我是1號過濾器,處理後");
}
最後驗證我們的結論。
同Servlet一樣,Filter也有對應的HttpFilter專用類,它針對HTTP請求進行了專門處理,因此我們可以直接使用HttpFilter來編寫:
public abstract class HttpFilter extends GenericFilter {
private static final long serialVersionUID = 7478463438252262094L;
public HttpFilter() {
}
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
if (req instanceof HttpServletRequest && res instanceof HttpServletResponse) {
this.doFilter((HttpServletRequest)req, (HttpServletResponse)res, chain);
} else {
throw new ServletException("non-HTTP request or response");
}
}
protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
chain.doFilter(req, res);
}
}
那麼現在,我們就可以給我們的應用程式添加一個過濾器,用戶在未登錄情況下,只允許靜態資源和登陸頁面請求通過,登陸之後暢行無阻:
@WebFilter("/*")
public class MainFilter extends HttpFilter {
@Override
protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
String url = req.getRequestURL().toString();
//判斷是否為靜態資源
if(!url.endsWith(".js") && !url.endsWith(".css") && !url.endsWith(".png")){
HttpSession session = req.getSession();
User user = (User) session.getAttribute("user");
//判斷是否未登陸
if(user == null && !url.endsWith("login")){
res.sendRedirect("login");
return;
}
}
//交給過濾鏈處理
chain.doFilter(req, res);
}
}
現在,我們的頁面已經基本完善為我們想要的樣子了。
當然,可能跟著教程編寫的項目比較亂,大家可以自己花費一點時間來重新編寫一個Web應用程式,加深對之前講解知識的理解。我們也會在之後安排一個編程實戰進行深化練習。
Listener
監聽器並不是我們學習的重點內容,那麼什麼是監聽器呢?
如果我們希望,在應用程式載入的時候,或是Session創建的時候,亦或是在Request對象創建的時候進行一些操作,那麼這個時候,我們就可以使用監聽器來實現。
默認為我們提供了很多類型的監聽器,我們這裡就演示一下監聽Session的創建即可:
@WebListener
public class TestListener implements HttpSessionListener {
@Override
public void sessionCreated(HttpSessionEvent se) {
System.out.println("有一個Session被創建了");
}
}
有關監聽器相關內容,了解即可。
了解JSP頁面與載入規則
前面我們已經完成了整個Web應用程式生命周期中所有內容的學習,我們已經完全了解,如何編寫一個Web應用程式,並放在Tomcat上部署運行,以及如何控制瀏覽器發來的請求,通過Session+Filter實現用戶登陸驗證,通過Cookie實現自動登陸等操作。到目前為止,我們已經具備編寫一個完整Web網站的能力。
在之前的教程中,我們的前端靜態頁面並沒有與後端相結合,我們前端頁面所需的數據全部需要單獨向後端發起請求獲取,並動態進行內容填充,這是一種典型的前後端分離寫法,前端只負責要數據和顯示數據,後端只負責處理數據和提供數據,這也是現在更流行的一種寫法,讓前端開發者和後端開發者各盡其責,更加專一,這才是我們所希望的開發模式。
JSP並不是我們需要重點學習的內容,因為它已經過時了,使用JSP會導致前後端嚴重耦合,因此這裡只做了解即可。
JSP其實就是一種模板引擎,那麼何謂模板引擎呢?顧名思義,它就是一個模板,而模板需要我們填入數據,才可以變成一個頁面,也就是說,我們可以直接在前端頁面中直接填寫數據,填寫後生成一個最終的HTML頁面返回給前端。
首先我們來創建一個新的項目,項目創建成功後,刪除Java目錄下的內容,只留下默認創建的jsp文件,我們發現,在webapp目錄中,存在一個index.jsp
文件,現在我們直接運行項目,會直接訪問這個JSP頁面。
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<title>JSP - Hello World</title>
</head>
<body>
<h1><%= "Hello World!" %>
</h1>
<br/>
<a href="hello-servlet">Hello Servlet</a>
</body>
</html>
但是我們並沒有編寫對應的Servlet來解析啊,那麼為什麼這個JSP頁面會被載入呢?
實際上,我們一開始提到的兩個Tomcat默認的Servlet中,一個是用於請求靜態資源,還有一個就是用於處理jsp的:
<!-- The mappings for the JSP servlet -->
<servlet-mapping>
<servlet-name>jsp</servlet-name>
<url-pattern>*.jsp</url-pattern>
<url-pattern>*.jspx</url-pattern>
</servlet-mapping>
那麼,JSP和普通HTML頁面有什麼區別呢,我們發現它的語法和普通HTML頁面幾乎一致,我們可以直接在JSP中編寫Java程式碼,並在頁面載入的時候執行,我們隨便找個地方插入:
<%
System.out.println("JSP頁面被載入");
%>
我們發現,請求一次頁面,頁面就會載入一次,並執行我們填寫的Java程式碼。也就是說,我們可以直接在此頁面中執行Java程式碼來填充我們的數據,這樣我們的頁面就變成了一個動態頁面,使用<%= %>
來填寫一個值:
<h1><%= new Date() %></h1>
現在訪問我們的網站,每次都會創建一個新的Date對象,因此每次訪問獲取的時間都不一樣,我們的網站已經算是一個動態的網站的了。
雖然這樣在一定程度上上為我們提供了便利,但是這樣的寫法相當於整個頁面既要編寫前端程式碼,也要編寫後端程式碼,隨著項目的擴大,整個頁面會顯得難以閱讀,並且現在都是前後端開發人員職責非常明確的,如果要編寫JSP頁面,那就必須要招一個既會前端也會後端的程式設計師,這樣顯然會導致不必要的開銷。
那麼我們來研究一下,為什麼JSP頁面能夠在載入的時候執行Java程式碼呢?
首先我們將此項目打包,並在Tomcat服務端中運行,生成了一個文件夾並且可以正常訪問。
我們現在看到work
目錄,我們發現這個裡面多了一個index_jsp.java
和index_jsp.class
,那麼這些東西是幹嘛的呢,我們來反編譯一下就啥都知道了:
public final class index_jsp extends org.apache.jasper.runtime.HttpJspBase //繼承自HttpServlet
implements org.apache.jasper.runtime.JspSourceDependent,
org.apache.jasper.runtime.JspSourceImports {
...
public void _jspService(final jakarta.servlet.http.HttpServletRequest request, final jakarta.servlet.http.HttpServletResponse response)
throws java.io.IOException, jakarta.servlet.ServletException {
if (!jakarta.servlet.DispatcherType.ERROR.equals(request.getDispatcherType())) {
final java.lang.String _jspx_method = request.getMethod();
if ("OPTIONS".equals(_jspx_method)) {
response.setHeader("Allow","GET, HEAD, POST, OPTIONS");
return;
}
if (!"GET".equals(_jspx_method) && !"POST".equals(_jspx_method) && !"HEAD".equals(_jspx_method)) {
response.setHeader("Allow","GET, HEAD, POST, OPTIONS");
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, "JSP 只允許 GET、POST 或 HEAD。Jasper 還允許 OPTIONS");
return;
}
}
final jakarta.servlet.jsp.PageContext pageContext;
jakarta.servlet.http.HttpSession session = null;
final jakarta.servlet.ServletContext application;
final jakarta.servlet.ServletConfig config;
jakarta.servlet.jsp.JspWriter out = null;
final java.lang.Object page = this;
jakarta.servlet.jsp.JspWriter _jspx_out = null;
jakarta.servlet.jsp.PageContext _jspx_page_context = null;
try {
response.setContentType("text/html; charset=UTF-8");
pageContext = _jspxFactory.getPageContext(this, request, response,
null, true, 8192, true);
_jspx_page_context = pageContext;
application = pageContext.getServletContext();
config = pageContext.getServletConfig();
session = pageContext.getSession();
out = pageContext.getOut();
_jspx_out = out;
out.write("\n");
out.write("\n");
out.write("<!DOCTYPE html>\n");
out.write("<html>\n");
out.write("<head>\n");
out.write(" <title>JSP - Hello World</title>\n");
out.write("</head>\n");
out.write("<body>\n");
out.write("<h1>");
out.print( new Date() );
out.write("</h1>\n");
System.out.println("JSP頁面被載入");
out.write("\n");
out.write("<br/>\n");
out.write("<a href=\"hello-servlet\">Hello Servlet</a>\n");
out.write("</body>\n");
out.write("</html>");
} catch (java.lang.Throwable t) {
if (!(t instanceof jakarta.servlet.jsp.SkipPageException)){
out = _jspx_out;
if (out != null && out.getBufferSize() != 0)
try {
if (response.isCommitted()) {
out.flush();
} else {
out.clearBuffer();
}
} catch (java.io.IOException e) {}
if (_jspx_page_context != null) _jspx_page_context.handlePageException(t);
else throw new ServletException(t);
}
} finally {
_jspxFactory.releasePageContext(_jspx_page_context);
}
}
}
我們發現,它是繼承自HttpJspBase
類,我們可以反編譯一下jasper.jar(它在tomcat的lib目錄中)來看看:
package org.apache.jasper.runtime;
import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.jsp.HttpJspPage;
import java.io.IOException;
import org.apache.jasper.compiler.Localizer;
public abstract class HttpJspBase extends HttpServlet implements HttpJspPage {
private static final long serialVersionUID = 1L;
protected HttpJspBase() {
}
public final void init(ServletConfig config) throws ServletException {
super.init(config);
this.jspInit();
this._jspInit();
}
public String getServletInfo() {
return Localizer.getMessage("jsp.engine.info", new Object[]{"3.0"});
}
public final void destroy() {
this.jspDestroy();
this._jspDestroy();
}
public final void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
this._jspService(request, response);
}
public void jspInit() {
}
public void _jspInit() {
}
public void jspDestroy() {
}
protected void _jspDestroy() {
}
public abstract void _jspService(HttpServletRequest var1, HttpServletResponse var2) throws ServletException, IOException;
}
實際上,Tomcat在載入JSP頁面時,會將其動態轉換為一個java類並編譯為class進行載入,而生成的Java類,正是一個Servlet的子類,而頁面的內容全部被編譯為輸出字元串,這便是JSP的載入原理,因此,JSP本質上依然是一個Servlet!
如果同學們感興趣的話,可以查閱一下其他相關的教程,本教程不再講解此技術。
使用Thymeleaf模板引擎
雖然JSP為我們帶來了便捷,但是其缺點也是顯而易見的,那麼有沒有一種既能實現模板,又能兼顧前後端分離的模板引擎呢?
Thymeleaf(百里香葉)是一個適用於Web和獨立環境的現代化伺服器端Java模板引擎,官方文檔://www.thymeleaf.org/documentation.html。
那麼它和JSP相比,好在哪裡呢,我們來看官網給出的例子:
<table>
<thead>
<tr>
<th th:text="#{msgs.headers.name}">Name</th>
<th th:text="#{msgs.headers.price}">Price</th>
</tr>
</thead>
<tbody>
<tr th:each="prod: ${allProducts}">
<td th:text="${prod.name}">Oranges</td>
<td th:text="${#numbers.formatDecimal(prod.price, 1, 2)}">0.99</td>
</tr>
</tbody>
</table>
我們可以在前端頁面中填寫佔位符,而這些佔位符的實際值則由後端進行提供,這樣,我們就不用再像JSP那樣前後端都寫在一起了。
那麼我們來創建一個例子感受一下,首先還是新建一個項目,注意,在創建時,勾選Thymeleaf依賴。
首先編寫一個前端頁面,名稱為test.html
,注意,是放在resource目錄下,在html標籤內部添加xmlns:th="//www.thymeleaf.org"
引入Thymeleaf定義的標籤屬性:
<!DOCTYPE html>
<html lang="en" xmlns:th="//www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div th:text="${title}"></div>
</body>
</html>
接著我們編寫一個Servlet作為默認頁面:
@WebServlet("/index")
public class HelloServlet extends HttpServlet {
TemplateEngine engine;
@Override
public void init() throws ServletException {
engine = new TemplateEngine();
ClassLoaderTemplateResolver r = new ClassLoaderTemplateResolver();
engine.setTemplateResolver(r);
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Context context = new Context();
context.setVariable("title", "我是標題");
engine.process("test.html", context, resp.getWriter());
}
}
我們發現,瀏覽器得到的頁面,就是已經經過模板引擎解析好的頁面,而我們的程式碼依然是後端處理數據,前端展示數據,因此使用Thymeleaf就能夠使得當前Web應用程式的前後端劃分更加清晰。
雖然Thymeleaf在一定程度上分離了前後端,但是其依然是在後台渲染HTML頁面並發送給前端,並不是真正意義上的前後端分離。
Thymeleaf語法基礎
那麼,如何使用Thymeleaf呢?
首先我們看看後端部分,我們需要通過TemplateEngine
對象來將模板文件渲染為最終的HTML頁面:
TemplateEngine engine;
@Override
public void init() throws ServletException {
engine = new TemplateEngine();
//設定模板解析器決定了從哪裡獲取模板文件,這裡直接使用ClassLoaderTemplateResolver表示載入內部資源文件
ClassLoaderTemplateResolver r = new ClassLoaderTemplateResolver();
engine.setTemplateResolver(r);
}
由於此對象只需要創建一次,之後就可以一直使用了。接著我們來看如何使用模板引擎進行解析:
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//創建上下文,上下文中包含了所有需要替換到模板中的內容
Context context = new Context();
context.setVariable("title", "<h1>我是標題</h1>");
//通過此方法就可以直接解析模板並返迴響應
engine.process("test.html", context, resp.getWriter());
}
操作非常簡單,只需要簡單幾步配置就可以實現模板的解析。接下來我們就可以在前端頁面中通過上下文提供的內容,來將Java程式碼中的數據解析到前端頁面。
接著我們來了解Thymeleaf如何為普通的標籤添加內容,比如我們示例中編寫的:
<div th:text="${title}"></div>
我們使用了th:text
來為當前標籤指定內部文本,注意任何內容都會變成普通文本,即使傳入了一個HTML程式碼,如果我希望向內部添加一個HTML文本呢?我們可以使用th:utext
屬性:
<div th:utext="${title}"></div>
並且,傳入的title屬性,不僅僅只是一個字元串的值,而是一個字元串的引用,我們可以直接通過此引用調用相關的方法:
<div th:text="${title.toLowerCase()}"></div>
這樣看來,Thymeleaf既能保持JSP為我們帶來的便捷,也能兼顧前後端程式碼的界限劃分。
除了替換文本,它還支援替換一個元素的任意屬性,我們發現,th:
能夠拼接幾乎所有的屬性,一旦使用th:屬性名稱
,那麼屬性的值就可以通過後端提供了,比如我們現在想替換一個圖片的鏈接:
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Context context = new Context();
context.setVariable("url", "//n.sinaimg.cn/sinakd20121/600/w1920h1080/20210727/a700-adf8480ff24057e04527bdfea789e788.jpg");
context.setVariable("alt", "圖片就是載入不出來啊");
engine.process("test.html", context, resp.getWriter());
}
<!DOCTYPE html>
<html lang="en" xmlns:th="//www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<img width="700" th:src="${url}" th:alt="${alt}">
</body>
</html>
現在訪問我們的頁面,就可以看到替換後的結果了。
Thymeleaf還可以進行一些算術運算,幾乎Java中的運算它都可以支援:
<div th:text="${value % 2}"></div>
同樣的,它還支援三元運算:
<div th:text="${value % 2 == 0 ? 'yyds' : 'lbwnb'}"></div>
多個屬性也可以通過+
進行拼接,就像Java中的字元串拼接一樣,這裡要注意一下,字元串不能直接寫,要添加單引號:
<div th:text="${name}+' 我是文本 '+${value}"></div>
Thymeleaf流程式控制制語法
除了一些基本的操作,我們還可以使用Thymeleaf來處理流程式控制制語句,當然,不是直接編寫Java程式碼的形式,而是添加一個屬性即可。
首先我們來看if判斷語句,如果if條件滿足,則此標籤留下,若if條件不滿足,則此標籤自動被移除:
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Context context = new Context();
context.setVariable("eval", true);
engine.process("test.html", context, resp.getWriter());
}
<div th:if="${eval}">我是判斷條件標籤</div>
th:if
會根據其中傳入的值或是條件表達式的結果進行判斷,只有滿足的情況下,才會顯示此標籤,具體的判斷規則如下:
- 如果值不是空的:
- 如果值是布爾值並且為
true
。 - 如果值是一個數字,並且是非零
- 如果值是一個字元,並且是非零
- 如果值是一個字元串,而不是「錯誤」、「關閉」或「否」
- 如果值不是布爾值、數字、字元或字元串。
- 如果值是布爾值並且為
- 如果值為空,th:if將計算為false
th:if
還有一個相反的屬性th:unless
,效果完全相反,這裡就不演示了。
我們接著來看多分支條件判斷,我們可以使用th:switch
屬性來實現:
<div th:switch="${eval}">
<div th:case="1">我是1</div>
<div th:case="2">我是2</div>
<div th:case="3">我是3</div>
</div>
只不過沒有default屬性,但是我們可以使用th:case="*"
來代替:
<div th:case="*">我是Default</div>
最後我們再來看看,它如何實現遍歷,假如我們有一個存放書籍資訊的List需要顯示,那麼如何快速生成一個列表呢?我們可以使用th:each
來進行遍歷操作:
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Context context = new Context();
context.setVariable("list", Arrays.asList("傘兵一號的故事", "倒一杯卡布奇諾", "玩遊戲要嘯著玩", "十七張牌前的電腦螢幕"));
engine.process("test.html", context, resp.getWriter());
}
<ul>
<li th:each="title : ${list}" th:text="'《'+${title}+'》'"></li>
</ul>
th:each
中需要填寫 “單個元素名稱 : ${列表}”,這樣,所有的列表項都可以使用遍歷的單個元素,只要使用了th:each
,都會被循環添加。因此最後生成的結果為:
<ul>
<li>《傘兵一號的故事》</li>
<li>《倒一杯卡布奇諾》</li>
<li>《玩遊戲要嘯著玩》</li>
<li>《十七張牌前的電腦螢幕》</li>
</ul>
我們還可以獲取當前循環的迭代狀態,只需要在最後添加iterStat
即可,從中可以獲取很多資訊,比如當前的順序:
<ul>
<li th:each="title, iterStat : ${list}" th:text="${iterStat.index}+'.《'+${title}+'》'"></li>
</ul>
狀態變數在th:each
屬性中定義,並包含以下數據:
- 當前迭代索引,以0開頭。這是
index
屬性。 - 當前迭代索引,以1開頭。這是
count
屬性。 - 迭代變數中的元素總量。這是
size
屬性。 - 每個迭代的迭代變數。這是
current
屬性。 - 當前迭代是偶數還是奇數。這些是
even/odd
布爾屬性。 - 當前迭代是否是第一個迭代。這是
first
布爾屬性。 - 當前迭代是否是最後一個迭代。這是
last
布爾屬性。
通過了解了流程式控制制語法,現在我們就可以很輕鬆地使用Thymeleaf來快速替換頁面中的內容了。
Thymeleaf模板布局
在某些網頁中,我們會發現,整個網站的頁面,除了中間部分的內容會隨著我們的頁面跳轉而變化外,有些部分是一直保持一個狀態的,比如打開小破站,我們翻動評論或是切換影片分P的時候,變化的僅僅是對應區域的內容,實際上,其他地方的內容會無論內部頁面如何跳轉,都不會改變。
Thymeleaf就可以輕鬆實現這樣的操作,我們只需要將不會改變的地方設定為模板布局,並在不同的頁面中插入這些模板布局,就無需每個頁面都去編寫同樣的內容了。現在我們來創建兩個頁面:
<!DOCTYPE html>
<html lang="en" xmlns:th="//www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div class="head">
<div>
<h1>我是標題內容,每個頁面都有</h1>
</div>
<hr>
</div>
<div class="body">
<ul>
<li th:each="title, iterStat : ${list}" th:text="${iterStat.index}+'.《'+${title}+'》'"></li>
</ul>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en" xmlns:th="//www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div class="head">
<div>
<h1>我是標題內容,每個頁面都有</h1>
</div>
<hr>
</div>
<div class="body">
<div>這個頁面的樣子是這樣的</div>
</div>
</body>
</html>
接著將模板引擎寫成工具類的形式:
public class ThymeleafUtil {
private static final TemplateEngine engine;
static {
engine = new TemplateEngine();
ClassLoaderTemplateResolver r = new ClassLoaderTemplateResolver();
engine.setTemplateResolver(r);
}
public static TemplateEngine getEngine() {
return engine;
}
}
@WebServlet("/index2")
public class HelloServlet2 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Context context = new Context();
ThymeleafUtil.getEngine().process("test2.html", context, resp.getWriter());
}
}
現在就有兩個Servlet分別對應兩個頁面了,但是這兩個頁面實際上是存在重複內容的,我們要做的就是將這些重複內容提取出來。
我們單獨編寫一個head.html
來存放重複部分:
<!DOCTYPE html>
<html xmlns:th="//www.thymeleaf.org" lang="en">
<body>
<div class="head" th:fragment="head-title">
<div>
<h1>我是標題內容,每個頁面都有</h1>
</div>
<hr>
</div>
</body>
</html>
現在,我們就可以直接將頁面中的內容快速替換:
<div th:include="head.html::head-title"></div>
<div class="body">
<ul>
<li th:each="title, iterStat : ${list}" th:text="${iterStat.index}+'.《'+${title}+'》'"></li>
</ul>
</div>
我們可以使用th:insert
和th:replace
和th:include
這三種方法來進行頁面內容替換,那麼th:insert
和th:replace
(和th:include
,自3.0年以來不推薦)有什麼區別?
th:insert
最簡單:它只會插入指定的片段作為標籤的主體。th:replace
實際上將標籤直接替換為指定的片段。th:include
和th:insert
相似,但它沒有插入片段,而是只插入此片段的內容。
你以為這樣就完了嗎?它還支援參數傳遞,比如我們現在希望插入二級標題,並且由我們的子頁面決定:
<div class="head" th:fragment="head-title">
<div>
<h1>我是標題內容,每個頁面都有</h1>
<h2>我是二級標題</h2>
</div>
<hr>
</div>
稍加修改,就像JS那樣添加一個參數名稱:
<div class="head" th:fragment="head-title(sub)">
<div>
<h1>我是標題內容,每個頁面都有</h1>
<h2 th:text="${sub}"></h2>
</div>
<hr>
</div>
現在直接在替換位置添加一個參數即可:
<div th:include="head.html::head-title('這個是第1個頁面的二級標題')"></div>
<div class="body">
<ul>
<li th:each="title, iterStat : ${list}" th:text="${iterStat.index}+'.《'+${title}+'》'"></li>
</ul>
</div>
這樣,不同的頁面還有著各自的二級標題。
探討Tomcat類載入機制
有關JavaWeb的內容,我們就聊到這裡,在最後,我們還是來看一下Tomcat到底是如何載入和運行我們的Web應用程式的。
Tomcat伺服器既然要同時運行多個Web應用程式,那麼就必須要實現不同應用程式之間的隔離,也就是說,Tomcat需要分別去載入不同應用程式的類以及依賴,還必須保證應用程式之間的類無法相互訪問,而傳統的類載入機制無法做到這一點,同時每個應用程式都有自己的依賴,如果兩個應用程式使用了同一個版本的同一個依賴,那麼還有必要去重新載入嗎,帶著諸多問題,Tomcat伺服器編寫了一套自己的類載入機制。
首先我們要知道,Tomcat本身也是一個Java程式,它要做的是去動態載入我們編寫的Web應用程式中的類,而要解決以上提到的一些問題,就出現了幾個新的類載入器,我們來看看各個載入器的不同之處:
- Common ClassLoader:Tomcat最基本的類載入器,載入路徑中的class可以被Tomcat容器本身以及各個Web應用程式訪問。
- Catalina ClassLoader:Tomcat容器私有的類載入器,載入路徑中的class對於Web應用程式不可見。
- Shared ClassLoader:各個Web應用程式共享的類載入器,載入路徑中的class對於所有Web應用程式可見,但是對於Tomcat容器不可見。
- Webapp ClassLoader:各個Web應用程式私有的類載入器,載入路徑中的class只對當前Web應用程式可見,每個Web應用程式都有一個自己的類載入器,此載入器可能存在多個實例。
- JasperLoader:JSP類載入器,每個JSP文件都有一個自己的類載入器,也就是說,此載入器可能會存在多個實例。
通過這樣進行劃分,就很好地解決了我們上面所提到的問題,但是我們發現,這樣的類載入機制,破壞了JDK的雙親委派機制
(在JavaSE階段講解過),比如Webapp ClassLoader,它只載入自己的class文件,它沒有將類交給父類載入器進行載入,也就是說,我們可以隨意創建和JDK同包同名的類,豈不是就出問題了?
難道Tomcat的開發團隊沒有考慮到這個問題嗎?
實際上,WebAppClassLoader的載入機制是這樣的:WebAppClassLoader 載入類的時候,繞開了 AppClassLoader,直接先使用 ExtClassLoader 來載入類。這樣的話,如果定義了同包同名的類,就不會被載入,而如果是自己定義 的類,由於該類並不是JDK內部或是擴展類,所有不會被載入,而是再次回到WebAppClassLoader進行載入,如果還失敗,再使用AppClassloader進行載入。