Controller与Filter如何交替使用
阅读本文,需要具备以下基础知识:
- 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]微信小程序登录接口
推荐阅读