閱讀本文,需要具備以下基礎知識:
- Java Web開發基礎知識;
- Servlet及其Filter過濾機制;
- 瞭解SpringMVC/Springboot框架 ,瞭解Controller基本使用,Controller本質是Servlet;
一、背景
存在這樣的一些業務場景:
- Filter用於@RestController註解下的Url攔截(比如白名單校驗、鑑權等業務)的,校驗成功後需要返回JSON,校驗失敗時則跳轉至鑑權失敗頁面,而不是需要返回失敗的JSON。比如微信小程序登錄接口,需要先獲取code,根據code再去獲取openId,必須要把openId返回給小程序,小程序纔可以正常使用,所以需要響應相對應的JSON;如果openId獲取失敗,則沒有必要返回失敗信息了,直接攔截所有請求,重定向到自定義登錄url的入口,這樣後面所有的請求都不需要考慮認證失敗這種情況了;
- Filter用於@Controller的Url跳轉(比如登錄):成功時跳轉至成功頁面,失敗時跳轉至失敗頁面,但是現在要求失敗時必須要加上失敗提示,而不應該做二次跳轉。
二、目標
- Java Web繞不開Controller(Servlet)和Filter,雖然之前都用過,但是都沒有好好深入去了解,藉此機會好好分析下二者的聯繫與區別;
- 解決上述2個業務場景下的問題,順便總結下該如何靈活運用Controller(Servlet)和Filter;
三、步驟
- 以前也經常看,鏈接爲:Servlet和Filter區別,但是概念老是記不住。總體來說,Servlet,一般是指HttpServlet。HttpServlet主要有如下默認實現:
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException{
...
}
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException{
...
}
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
}
這些接口通常用來處理不同的類型請求。但是處理的流程大致相同。都是從HttpServletRequest中拿請求參數,使用HttpServletResponse響應結果至前臺;
2. Filter一般是指HttpFilter。HttpFilter的核心代碼如下:
protected void doFilter(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
chain.doFilter(request, response);
}
HttpFilter使用了責任鏈設計模式,在存在多個filter時,如果本過濾器處理完畢無異常需要終止,則調用chain.doFilter(request, response),由後續的filter繼續處理,如果此處處理異常,則直接return,這樣就跳過了FilterChain,非常靈活,而且不需要也不知道上下游的filter是什麼,要怎麼處理;
3. 縱觀Servlet和Filter二者的請求參數都有HttpServletRequest,和HttpServletResponse 2個參數,而且在一個請求鏈裏面,這2個參數都是同一個對象。那能不能在Filter中去響應內容呢?能不能在Filter中去做跳轉呢?
4. 針對上面2個業務場景,業務場景1就是微信認證,拿到tokenId並返回,我使用的框架是Springboot+shiro(關於這塊我另外寫了一個專欄),爲了優雅地使用shiro,在認證Filter中去認證獲取openId。shiro原Filter是用來跳轉的,成功後跳轉至成功頁面,失敗時跳轉至失敗頁面,但是我必須要返回openId,二者權衡,就覆寫了Filter認證成功的分支代碼,通過Filter中的HttpServletResponse把JSON響應到前端,失敗時,繼續跳轉至登錄頁面。核心代碼如下:
public class MyTokenAuthenticationFilter extends FormAuthenticationFilter
{
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception
{
boolean result = super.executeLogin(request, response);
Session session = SecurityUtils.getSubject().getSession(true);
WxToken wxToken = SessionUtils.getSessionObj(session);
LOGGER.info("current token:{}", wxToken);
String sessionId = session.getId().toString();
wxToken.setSession_key(sessionId);
String json = JsonUtils.toJson(wxToken);
LOGGER.info("new token:{}", json);
response.setContentType(ContentType.APPLICATION_JSON.toString());
Writer writer = response.getWriter();
writer.write(json);
writer.flush();
return result;
}
/**
* 覆寫該方法,不讓跳轉至主頁
*
* @param token
* @param subject
* @param request
* @param response
* @return
*/
@Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request,
ServletResponse response)
{
return false;
}
說明下,super.executeLogin(request, response)中包含了認證成功和失敗的方法,分別爲:onLoginSuccess、onLoginFailure,源碼如下:
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject,
ServletRequest request, ServletResponse response) throws Exception {
issueSuccessRedirect(request, response);
//we handled the success redirect directly, prevent the chain from continuing:
return false;
}
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e,
ServletRequest request, ServletResponse response) {
if (log.isDebugEnabled()) {
log.debug( "Authentication exception", e );
}
setFailureAttribute(request, e);
//login failed, let request continue back to the login page:
return true;
}
覆寫後,成功時就不會跳轉了,失敗時會繼續跳轉。
至此,場景1的問題解決。
- 業務場景2是PC Web端通過shiro 的FormAuthenticationFilter來做認證過濾。我使用的框架是Springboot+shiro(關於這塊我另外寫了一個專欄)。通過此Filter,登錄成功後,返回至業務主頁,登錄失敗後,繼續跳轉至登錄頁面。後面業務變更爲登錄失敗後,要提示失敗信息,而不是再跳轉至登錄頁面。所以就需要按照業務場景1的方式把失敗信息通過Filter Response返回至前臺。代碼如下:
public class MyAuthenticationFilter extends FormAuthenticationFilter
{
/**
* 認證成功後,也不再跳轉至主頁
* <p>
* (因爲請求改成了異步請求,無法跳轉)
*
* @param token
* @param subject
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request,
ServletResponse response) throws Exception
{
//設置響應格式爲UTF-8,否則會亂碼
response.setContentType(ContentType.APPLICATION_JSON.toString());
Writer writer = response.getWriter();
String json = JsonUtils.toJson(Result.ok());
writer.write(json);
writer.flush();
return false;
}
/**
* 覆寫認證失敗的接口
* <p>
* 返回認證失敗的提示信息,不讓再返回認證失敗頁面
*
* @param token
* @param e
* @param request
* @param response
* @return
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request,
ServletResponse response)
{
LOGGER.info("start login failed flow.");
try
{
//設置響應格式爲UTF-8,否則會亂碼
response.setContentType(ContentType.APPLICATION_JSON.toString());
Writer writer = response.getWriter();
String failedMsg = I18nUtils.get(LOGIN_FAILED_KEY);
String failedJson = JsonUtils.toJson(Result.error(failedMsg));
writer.write(failedJson);
writer.flush();
}
catch (IOException ex)
{
LOGGER.error("login failed.", ex);
}
return false;
}
}
- PC Web登錄使用的Controller是@Controller,而不是@RestController註解的,沒有辦法向前臺返回JSON,所以也只能在Filter中響應JSON。但是響應JSON到前臺後,需要有接收JSON的Ajax請求才行。
原來前端Vue提交登錄請求的表單代碼如下:
<form class="imgBox" method="post" action="/login">
<img src="assets/img.png" alt="">
<div class="frame">
<p class="logoStyle"><img src="assets/logo.png" alt=""></p>
<p class="terrace">xxx</p>
<div class="nameInput">
<input type="text" placeholder="請輸入用戶名" name="name" class="userName">
</div>
<img src="assets/icon_yonghuming1.svg" alt="" class="namePicture1">
<img src="assets/icon_mima1.svg" alt="" class="namePicture2">
<div class="nameInput">
<input type="password" placeholder="請輸密碼" name="password" class="passWord">
</div>
<button type="submit" class="loginBox">登錄</button>
<p style="width:300px;height:50px;color:#fff;font-size:14px;margin-left:130px;opacity:0.4;" v-show="showView" >{{msg}}</p>
</div>
</form>
變更至ajax請求代碼如下(前端框架Vue,對應的ajax組件一般是axios):
<form class="imgBox" @submit.prevent="login($event)">
...
</form>
<a href="/index" style="display:none" ref="xxx"></a>
</div>
login(event) {
console.log("start login.");
let count = event.target.length;
let formData = new FormData();
if (count && count > 2) {
for (let i = 0; i < count; i++) {
let element = event.target[i];
if (element.nodeName === 'INPUT') {
console.log("name=" + element.name + ",value=" + element.value);
formData.append(element.name, element.value);
}
};
let self = this;
axios.post("/login", formData)
.then((res) => {
console.log("result:" + JSON.stringify(res));
if (res && res.data && res.data.code < 0) {
console.log("login failed");
this.showView = true
this.msg = error.msg
} else {
console.log("login successfully.");
self.$refs["xxx"].click();
}
})
.catch((error) => {
console.log("error:" + error);
});
}
}
}
- 經過上述變更後,無論登錄成功還是失敗,都能從請求的Response中拿到結果JSON,但是改成了ajax請求後,也無法做登錄成功後的跳轉,而且後臺Filter也不會跳轉,所以在上述第6步中需要額外加1個隱藏的<a>標籤,並在登錄成功後,模擬點擊<a>標籤跳轉的操作。關鍵代碼就是如下:
<a href="/index" style="display:none" ref="xxx"></a>
self.$refs["xxx"].click();
至此場景2的問題也解決了。這裏沒有使用vue的vue-router的原因是暫時只有一個登錄頁面和主頁。
四、總結
- Controller本質是Servlet,Controller的2種註解形式@RestController和@Controller一個是做ajax請求,一個是做url跳轉請求的,但如果通過Filter來過濾處理的話,就沒有明確的界線了,上面2個業務場景裏面,第1個本來是@RestController類型的Controller,使用Filter後就變成了:成功後響應JSON,失敗後,Url跳轉;第2個是@Controller類型的Controller,使用Filter後就變成了:成功和失敗都響應JSON,然後由前端控制成功後跳轉;
- 熟練掌握Controller和Filter是根本之道,靈活運用解決業務問題才更有意義;
五、參考
[1]Servlet和Filter區別
[2]微信小程序登錄接口