前言:
Spring的AOP理念, 以及j2ee中責任鏈(過濾器鏈)的設計模式, 確實深入人心, 處處可以看到它的身影. 這次借項目空閑, 來總結一下SpringMVC的Interceptor機制, 並以用戶登陸和日志記錄作為案例, 以做實踐.
原理及類圖:
攔截器的使用, 其實非常的廣泛, 尤其對通用普適的功能調用, 提取到攔截器層中實現.
常見的攔截器有如下幾種: 用戶登陸/日志記錄/性能評估/權限控制等等.
攔截器Interceptor鏈, 橫亘在控制器Controller(Action)前, 具體的接口定義如下所示:
package org.springframework.web.servlet; public interface HandlerInterceptor { boolean preHandle( HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception; void postHandle( HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception; void afterCompletion( HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception; }
摘錄了開濤老師的原話和圖文解說:
preHandle: 預處理回調方法, 在controller層之前調用. 返回值: true 表示繼續流程(如調用下一個攔截器或處理器). false 表示流程中斷, 不會繼續調用其他的攔截器或處理器. postHandle: 后處理回調方法, 在controller層之后調用(但在渲染視圖之前), 我們可以通過modelAndView(模型和視圖對象)對模型數據進行處理或對視圖進行處理, modelAndView也可能為null. afterCompletion: 整個請求處理完畢回調方法, 即在視圖渲染完畢時回調, 類似於try-catch-finally中的finally. 當然前提是該攔截器的preHandle返回true.
正常流程和異常流程的圖說明:
注: 圖摘自開濤老師的博客, <<第五章 處理器攔截器詳解——跟着開濤學SpringMVC>>.
但有多個攔截器的時候, 其配置順序也特別重要, preHandle是順序執行, postHandle則是逆序執行, afterCompletion也是逆序執行.
集成於springmvc時, 配置也非常的簡潔, 如下樣例即可:
<mvc:interceptors> <!-- 使用bean定義一個Interceptor,直接定義在mvc:interceptors根下面的Interceptor將攔截所有的請求 --> <bean class="com.host.app.web.interceptor.AllInterceptor"/> <mvc:interceptor> <!-- 定義在mvc:interceptor下面的表示是對特定的請求才進行攔截的 --> <mvc:mapping path="/**"/> <bean class="xxx.xxx.XXXInterceptor"/> </mvc:interceptor> <mvc:interceptor> <mvc:mapping path="/**"/> <bean class="yyy.yyy.YYYInterceptor"/> </mvc:interceptor> </mvc:interceptors>
注: 在最外層定義的Interceptor類, 對所有的url映射都進行攔截, 而mvc:interceptor標簽申明的interceptor則通過mvc:mapping來自定義過濾規則.
用戶登陸:
用戶登陸驗證, 是最常見的一種需求, 也是很多開發者第一次使用攔截器使用的對象. 因此我們就以此作為案例.
比如我們編寫如下代碼:
@Controller @RequestMapping("/") public class HelloController { @RequestMapping(value="/login", method={RequestMethod.POST, RequestMethod.GET}) @ResponseBody public String login(@RequestParam("username") String username, @RequestParam("password") String password, HttpSession session) { session.setAttribute("user", "..."); return "ok"; } @RequestMapping(value="/echo", method={RequestMethod.POST, RequestMethod.GET}) public ModelAndView echo(@RequestParam("message") String message, HttpSession session, HttpServletResponse response) { ModelAndView mav = new ModelAndView(); // *) 判斷是否已經登陸 Object obj = session.getAttribute("user"); if ( obj == null ) { try { response.sendRedirect("/html/login.html"); } catch (IOException e) { e.printStackTrace(); } } mav.addObject("message", message); mav.setViewName("/echo"); return mav; } }
比如echo函數, 需要添加一段判斷用戶是否登陸的代碼, 若沒登陸, 需要重定向到登陸頁面上去.
當類似這樣的接口很多, 這段登陸判斷的代碼, 就會被粘貼復制很多, 若登陸判斷邏輯有變動, 難免形成蝴蝶效應.
我們可以抽象到攔截器中去實現, 添加UserVerifyInterceptor類.
@Component public class UserVerifyIntercptor extends HandlerInterceptorAdapter { private String[] allowUrls = new String[] { // *) 用戶登陸相關的接口 "/login", }; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); for ( String allowUri : allowUrls ) { if ( allowUri.equalsIgnoreCase(uri) ) { return true; } } // *) 判斷是否已經登陸 HttpSession session = request.getSession(); Object obj = session.getAttribute("user"); if ( obj == null ) { response.sendRedirect("/html/login.html"); return false; } // *) return true; } }
注: 有些url不需要登陸判斷, 可以添加排除數組以實現白名單機制, 類似上邊代碼的allowUrls數組.
然后在springmvc的dispatcher-servlet.xml中添加如下配置:
<!-- 攔截器列表 --> <mvc:interceptors> <!-- 用戶登陸的驗證攔截器 --> <mvc:interceptor> <mvc:mapping path="/**" /> <mvc:exclude-mapping path="/html/**" /> <bean class="com.springapp.mvc.interceptor.UserVerifyIntercptor" /> </mvc:interceptor> </mvc:interceptors>
注: 對於mvc:mapping和mvc:exclude-mapping, 很好地調控了攔截器作用對象的范圍.
同時, 這樣之前的echo函數, 就可以簡化為:
@RequestMapping(value="/echo", method={RequestMethod.POST, RequestMethod.GET}) public ModelAndView echo(@RequestParam("message") String message) { ModelAndView mav = new ModelAndView(); mav.addObject("message", message); mav.setViewName("/echo"); return mav; }
這樣就比之前的代碼要簡潔很多了.
日志記錄:
其實, 這邊我希望到達的一個目的是, 一個完整的rest api請求, 單獨輸出一條日志, 里面包含各類信息, 包括各個子過程的調用過程(耗時, 返回結果), 請求參數, 最終結果等. 這樣的好處顯而易見, 能夠避免多個點的日志, 分散在多行, 當請求量多得時候, 難以尋找和聚合.
這個實現機制, 大致和我之前寫過的一篇文章類似: Thrift 個人實戰--Thrift RPC服務框架日志的優化.
大致的代碼示例效果如下所示:
@RequestMapping(value="/sample", method={RequestMethod.GET, RequestMethod.POST}) @ResponseBody public String sample(@RequestParam("message") String message) { // *) 記錄請求參數 RestLoggerUtility.noticeLog("[params: {message:%s}]", message); // serviceA.call(), // 記錄調用的子過程/子服務, 結果是什么, 總共耗時多少等等 RestLoggerUtility.noticeLog("[serviceA.call, params: xxx, result: xxx, consume xs]"); // serviceB.call(), // 記錄調用的子過程/子服務, 結果是什么, 總共耗時多少等等 RestLoggerUtility.noticeLog("[serviceB.call, params: xxx, result: xxx, consume xs]"); // *) 記錄最終的響應結果 RestLoggerUtility.noticeLog("[response: ok]"); return "ok"; }
其最終的日志輸出如下所示:
[params: {message:10}][serviceA.call, params: xxx, result: xxx, consume xs][serviceB.call, params: xxx, result: xxx, consume xs][response: ok]
我們可以借助, 線程私有變量ThreadLocal來組裝日志, 然后在Action的外層做攔截, 並做日志的准備和輸出.
1). 添加借助ThreadLocal實現的日志聚合工具類
對RestLoggerUtility類的設計如下:
public class RestLoggerUtility { private static final Logger restLogger = LoggerFactory.getLogger("rest"); public static final ThreadLocal<StringBuilder> threadLocals = new ThreadLocal<StringBuilder>(); public static void beforeInvoke() { StringBuilder sb = threadLocals.get(); if (sb == null) { sb = new StringBuilder(); threadLocals.set(sb); } sb.delete(0, sb.length()); } public static void returnInvoke() { StringBuilder sb = threadLocals.get(); if (sb != null && sb.length() > 0) { restLogger.info(sb.toString()); } } public static void throwableInvoke(String fmt, Object... args) { StringBuilder sb = threadLocals.get(); if (sb != null) { restLogger.info(sb.toString() + " " + String.format(fmt, args)); } } public static void noticeLog(String fmt, Object... args) { StringBuilder sb = threadLocals.get(); if (sb != null) { // *) 對長度進行限定 if ( sb.length() < 1024 ) { sb.append(String.format(fmt, args)); } } } }
2). 實現日志攔截器
然后, 我們定義攔截器類RestLoggerInterceptor, 其具體的類代碼如下:
public class RestLoggerInterceptor extends HandlerInterceptorAdapter { private static final Logger restLogger = LoggerFactory.getLogger("rest"); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // *) 日志准備 RestLoggerUtility.beforeInvoke(); return super.preHandle(request, response, handler); } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { super.postHandle(request, response, handler, modelAndView); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { super.afterCompletion(request, response, handler, ex); // *) 進行日志的刷新 RestLoggerUtility.returnInvoke(); } }
根據springmvc攔截器的原理, 我們需要把日志初始化工作, 放在preHandle中實現. 把日志整體輸入, 放在afterCompletion函數中實現.
3). 添加攔截器配置
再次添加攔截器配置, 並把它置於首位.
<!-- 攔截器列表 --> <mvc:interceptors> <!-- 日志攔截器, 更好地記錄整個請求過程 --> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/html/**" /> <bean class="com.springapp.mvc.interceptor.RestLoggerInterceptor" /> </mvc:interceptor> <mvc:interceptor> <mvc:mapping path="/**" /> <mvc:exclude-mapping path="/html/**" /> <bean class="xxx.xxx.XXXIntercptor" /> </mvc:interceptor> </mvc:interceptors>
4). 完善異常的處理
對異常的攔截, 需要再補充, 定義一個ControlAdvice, 在處理異常的代碼中, 添加異常日志記錄的pointcut.
@ControllerAdvice public class RestApiControlAdvice { private static final Logger restLogger = LoggerFactory.getLogger("rest"); @ExceptionHandler(value=Exception.class) @ResponseBody public String handle(Exception e) { restLogger.warn("exception", e); RestLoggerUtility.throwableInvoke("[exception: msg:%s]", e.getMessage()); return "error"; } }
這樣, 我們想要實現的基本目標就能達到了.
示例代碼:
樣例代碼的下載:http://pan.baidu.com/s/1jH1ggZ0.
代碼類組織如下:
總結:
好久想寫這篇文章了,算是對springmvc攔截器機制的一份整理和自身理解. 希望能對讀者有益,對自己而言,權當學習筆記.
公眾號&游戲站點:
個人微信公眾號: 木目的H5游戲世界
個人游戲作品集站點(尚在建設中...): www.mmxfgame.com, 也可直接ip訪問: http://120.26.221.54/.