從 MVC 到前後端分離
本文首先描述 MVC 模式是什麼,然後針對 MVC 的不足發表了作者的個人觀點,隨後引出了基於 REST 架構實現前後端分離的方案,最後使用了 Java 的 Spring 框架搭建了一個簡單的 REST 框架。全文從原理到實戰,希望對於想了解如何實現前後端分離架構的朋友有所幫助。由於篇幅有限,且個人水平不足,難免會出現一些遺漏或不足之處,懇請大家提出寶貴意見或建議,謝謝!
1 理解 MVC
MVC 是一種經典的 設計模式 ,全名為Model-View-Controller,即模型-檢視-控制器。
其中,模型是用於封裝資料的載體,例如,在 Java 中一般通過一個簡單的POJO(Plain Ordinary Java Object)來表示,其本質是一個普通的 Java Bean,包含一系列的成員變數及其 getter/setter 方法。對於檢視而言,它更加偏重於展現,也就是說,檢視決定了介面到底長什麼樣子,在 Java 中可通過 JSP 來充當檢視,或者通過純 HTML 的方式進行展現,而後者才是目前的主流。模型和檢視需要通過控制器來進行粘合,例如,使用者傳送一個 HTTP 請求,此時該請求首先會進入控制器,然後控制器去獲取資料並將其封裝為模型,最後將模型傳遞到檢視中進行展現。
綜上所述,MVC 的互動過程如下圖所示:

2 MVC 模式的優點與不足
MVC 模式早在上個世紀 70 年代就誕生了,直到今天它依然存在,可見生命力相當之強。MVC 模式最早用於 Smalltalk 語言中,最後在其它許多開發語言中都得到了很好的應用,例如,Java 中的 Struts、Spring MVC 等框架。正是因為這些 MVC 框架的出現,才讓 MVC 模式真正落地,讓開發更加高效,讓程式碼耦合度儘量減小,讓應用程式各部分的職責更加清晰。
既然 MVC 模式這麼好,難道它就沒有不足的地方嗎?我認為 MVC 至少有以下三點不足:
每次請求必須經過“控制器->模型->檢視”這個流程,使用者才能看到最終的展現的介面,這個過程似乎有些複雜。
實際上檢視是依賴於模型的,換句話說,如果沒有模型,檢視也無法呈現出最終的效果。
渲染檢視的過程是在服務端來完成的,最終呈現給瀏覽器的是帶有模型的檢視頁面,效能無法得到很好的優化。
為了使資料展現過程更加直接,並且提供更好的使用者體驗,我們有必要對 MVC 模式進行改進。不妨這樣來嘗試,首先從瀏覽器傳送 AJAX 請求,然後服務端接受該請求並返回 JSON 資料返回給瀏覽器,最後在瀏覽器中進行介面渲染。
改進後的 MVC 模式如下圖所示:

也就是說,我們輸入的是 AJAX 請求,輸出的是 JSON 資料,市面上有這樣的技術來實現這個功能嗎?答案是 REST。
REST 全稱是 Representational State Transfer(表述性狀態轉移),它是 Roy Fielding 博士在 2000 年寫的一篇關於軟體架構風格的論文,此文一出,威震四方!國內外許多知名網際網路公司紛紛開始採用這種輕量級的 Web 服務,大家習慣將其稱為 RESTful Web Services,或簡稱 REST 服務。
如果將瀏覽器這一端視為前端,而伺服器那一端視為後端的話,可以將以上改進後的 MVC 模式簡化為以下前後端分離模式:

可見,有了 REST 服務,前端關注介面展現,後端關注業務邏輯,分工明確,職責清晰。那麼,如何使用 REST 服務將應用程式進行前後端分離呢?我們接下來繼續探討,首先我們需要認識 REST。
3 認識 REST
REST 本質上是使用 URL 來訪問資源種方式。眾所周知,URL 就是我們平常使用的請求地址了,其中包括兩部分:請求方式與請求路徑,比較常見的請求方式是 GET 與 POST,但在 REST 中又提出了幾種其它型別的請求方式,彙總起來有六種:GET、POST、PUT、DELETE、HEAD、OPTIONS。尤其是前四種,正好與CRUD(Create-Retrieve-Update-Delete,增刪改查)四種操作相對應,例如,GET(查)、POST(增)、PUT(改)、DELETE(刪),這正是 REST 與 CRUD 的異曲同工之妙!需要強調的是,REST 是“面向資源”的,這裡提到的資源,實際上就是我們常說的領域物件,在系統設計過程中,我們經常通過領域物件來進行資料建模。
REST 是一個“無狀態”的架構模式,因為在任何時候都可以由客戶端發出請求到服務端,最終返回自己想要的資料,當前請求不會受到上次請求的影響。也就是說,服務端將內部資源釋出 REST 服務,客戶端通過 URL 來訪問這些資源,這不就是 SOA 所提倡的“面向服務”的思想嗎?所以,REST 也被人們看做是一種“輕量級”的 SOA 實現技術,因此在企業級應用與網際網路應用中都得到了廣泛應用。
下面我們舉幾個例子對 REST 請求進行簡單描述:

可見,請求路徑相同,但請求方式不同,所代表的業務操作也不同,例如,/advertiser/1 這個請求,帶有 GET、PUT、DELETE 三種不同的請求方式,對應三種不同的業務操作。
雖然 REST 看起來還是很簡單的,實際上我們往往需要提供一個 REST 框架,讓其實現前後端分離架構,讓開發人員將精力集中在業務上,而並非那些具體的技術細節。下面我們將使用 Java 技術來實現這個 REST 框架,整體框架會基於 Spring 進行開發。
4 實現 REST 框架
4.1 統一響應結構
使用 REST 框架實現前後端分離架構,我們需要首先確定返回的 JSON 響應結構是統一的,也就是說,每個 REST 請求將返回相同結構的 JSON 響應結構。不妨定義一個相對通用的 JSON 響應結構,其中包含兩部分:元資料與返回值,其中,元資料表示操作是否成功與返回值訊息等,返回值對應服務端方法所返回的資料。該 JSON 響應結構如下:
{ "meta": { "success": true, "message": "ok" }, "data": ... }
為了在框架中對映以上 JSON 響應結構,我們需要編寫一個Response類與其對應:
publicclassResponse { privatestaticfinalString OK = "ok"; privatestaticfinalString ERROR = "error"; privateMeta meta; privateObject data; publicResponse success() { this.meta = newMeta(true, OK); returnthis; } publicResponse success(Object data) { this.meta = newMeta(true, OK); this.data = data; returnthis; } publicResponse failure() { this.meta = newMeta(false, ERROR); returnthis; } publicResponse failure(String message) { this.meta = newMeta(false, message); returnthis; } publicMeta getMeta() { returnmeta; } publicObject getData() { returndata; } publicclassMeta { privatebooleansuccess; privateString message; publicMeta(booleansuccess) { this.success = success; } publicMeta(booleansuccess, String message) { this.success = success; this.message = message; } publicbooleanisSuccess() { returnsuccess; } publicString getMessage() { returnmessage; } } }
以上 Response 類包括兩類通用返回值訊息:ok 與 error,還包括兩個常用的操作方法:success( ) 與 failure( ),通過一個內部類來展現元資料結構,我們在下文中多次會使用該 Response 類。
實現該 REST 框架需要考慮許多問題,首當其衝的就是物件序列化問題。
4.2 實現物件序列化
想要解釋什麼是物件序列化?不妨通過一些例子進行說明。比如,在服務端從資料庫中獲取了資料,此時該資料是一個普通的 Java 物件,然後需要將這個 Java 物件轉換為 JSON 字串,並將其返回到瀏覽器中進行渲染,這個轉換過程稱為序列化;再比如,通過瀏覽器傳送了一個普通的 HTTP 請求,該請求攜帶了一個 JSON 格式的引數,在服務端需要將該 JSON 引數轉換為普通的 Java 物件,這個轉換過程稱為反序列化。不管是序列化還是反序列化,我們一般都稱為序列化。
實際上,Spring MVC 已經為我們提供了這類序列化特性,只需在 Controller 的方法引數中使用@RequestBody註解定義需要反序列化的引數即可,如以下程式碼片段:
@Controller publicclassAdvertiserController { @RequestMapping(value = "/advertiser", method = RequestMethod.POST) publicResponse createAdvertiser(@RequestBodyAdvertiserParam advertiserParam) { ... } }
若需要對 Controller 的方法返回值進行序列化,則需要在該返回值上使用@ResponseBody註解來定義,如以下程式碼片段:
@Controller publicclassAdvertiserController { @RequestMapping(value = "/advertiser/{id}", method = RequestMethod.GET) public@ResponseBodyResponse getAdvertiser(@PathVariable("id") String advertiserId) { ... } }
當然,@ResponseBody 註解也可以定義在類上,這樣所有的方法都繼承了該特性。由於經常會使用到 @ResponseBody 註解,所以 Spring 提供了一個名為@RestController的註解來取代以上的 @Controller 註解,這樣我們就可以省略返回值前面的 @ResponseBody 註解了,但引數前面的 @RequestBody 註解是無法省略的。實際上,看看 Spring 中對應 @RestController 註解的原始碼便可知曉:
@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Controller @ResponseBody public@interfaceRestController { String value() default""; }
可見,@RestController 註解已經被 @Controller 與 @ResponseBody 註解定義過了,Spring 框架會識別這類註解。需要注意的是,該特性在 Spring 4.0 中才引入。
因此,我們可將以上程式碼進行如下改寫:
@RestController publicclassAdvertiserController { @RequestMapping(value = "/advertiser", method = RequestMethod.POST) publicResponse createAdvertiser(@RequestBodyAdvertiserParam advertiserParam) { ... } @RequestMapping(value = "/advertiser/{id}", method = RequestMethod.GET) publicResponse getAdvertiser(@PathVariable("id") String advertiserId) { ... } }
除了使用註解來定義序列化行為以外,我們還需要使用 Jackson 來提供 JSON 的序列化操作,在 Spring 配置檔案中只需新增以下配置即可:
<mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"/> </mvc:message-converters> </mvc:annotation-driven>
若需要對 Jackson 的序列化行為進行定製,比如,排除值為空屬性、進行縮排輸出、將駝峰轉為下劃線、進行日期格式化等,這又如何實現呢?
首先,我們需要擴充套件 Jackson 提供的ObjectMapper類,程式碼如下:
publicclassCustomObjectMapper extendsObjectMapper { privatebooleancamelCaseToLowerCaseWithUnderscores = false; privateString dateFormatPattern; publicvoidsetCamelCaseToLowerCaseWithUnderscores(booleancamelCaseToLowerCaseWithUnderscores) { this.camelCaseToLowerCaseWithUnderscores = camelCaseToLowerCaseWithUnderscores; } publicvoidsetDateFormatPattern(String dateFormatPattern) { this.dateFormatPattern = dateFormatPattern; } publicvoidinit() { // 排除值為空屬性 setSerializationInclusion(JsonInclude.Include.NON_NULL); // 進行縮排輸出 configure(SerializationFeature.INDENT_OUTPUT, true); // 將駝峰轉為下劃線 if(camelCaseToLowerCaseWithUnderscores) { setPropertyNamingStrategy(PropertyNamingStrategy.CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES); } // 進行日期格式化 if(StringUtil.isNotEmpty(dateFormatPattern)) { DateFormat dateFormat = newSimpleDateFormat(dateFormatPattern); setDateFormat(dateFormat); } } }
然後,將 CustomObjectMapper 注入到 MappingJackson2HttpMessageConverter 中,Spring 配置如下:
<bean id="objectMapper" class="com.xxx.api.json.CustomObjectMapper" init-method="init"> <property name="camelCaseToLowerCaseWithUnderscores" value="true"/> <property name="dateFormatPattern" value="yyyy-MM-dd HH:mm:ss"/> </bean> <mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <property name="objectMapper" ref="objectMapper"/> </bean> </mvc:message-converters> </mvc:annotation-driven>
通過以上過程,我們已經完成了一個基於 Spring MVC 的 REST 框架,只不過該框架還非常單薄,還缺乏很多關鍵性特性,尤其是異常處理。
4.3 處理異常行為
在 Spring MVC 中,我們可以使用 AOP 技術,編寫一個全域性的異常處理切面類,用它來統一處理所有的異常行為,在 Spring 3.2 中才開始提供。使用法很簡單,只需定義一個類,並通過@ControllerAdvice註解將其標註即可,同時需要使用@ResponseBody註解表示返回值可序列化為 JSON 字串。程式碼如下:
@ControllerAdvice @ResponseBody publicclassExceptionAdvice { /** * 400 - Bad Request */ @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(HttpMessageNotReadableException.class) publicResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { logger.error("引數解析失敗", e); returnnewResponse().failure("could_not_read_json"); } /** * 405 - Method Not Allowed */ @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) @ExceptionHandler(HttpRequestMethodNotSupportedException.class) publicResponse handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { logger.error("不支援當前請求方法", e); returnnewResponse().failure("request_method_not_supported"); } /** * 415 - Unsupported Media Type */ @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE) @ExceptionHandler(HttpMediaTypeNotSupportedException.class) publicResponse handleHttpMediaTypeNotSupportedException(Exception e) { logger.error("不支援當前媒體型別", e); returnnewResponse().failure("content_type_not_supported"); } /** * 500 - Internal Server Error */ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(Exception.class) publicResponse handleException(Exception e) { logger.error("服務執行異常", e); returnnewResponse().failure(e.getMessage()); } }
可見,在 ExceptionAdvice 類中包含一系列的異常處理方法,每個方法都通過@ResponseStatus註解定義了響應狀態碼,此外還通過@ExceptionHandler註解指定了具體需要攔截的異常類。以上過程只是包含了一部分的異常情況,若需處理其它異常,可新增方法具體的方法。需要注意的是,在執行時從上往下依次呼叫每個異常處理方法,匹配當前異常型別是否與 @ExceptionHandler 註解所定義的異常相匹配,若匹配,則執行該方法,同時忽略後續所有的異常處理方法,最終會返回經 JSON 序列化後的 Response 物件。
4.4 支援引數驗證
我們回到上文所提到的示例,這裡處理一個普通的 POST 請求,程式碼如下:
@RestController publicclassAdvertiserController { @RequestMapping(value = "/advertiser", method = RequestMethod.POST) publicResponse createAdvertiser(@RequestBodyAdvertiserParam advertiserParam) { ... } }
其中,AdvertiserParam 引數包含若干屬性,通過以下類結構可見,它是一個傳統的 POJO:
publicclassAdvertiserParam { privateString advertiserName; privateString description; // 省略 getter/setter 方法 }
如果業務上需要確保 AdvertiserParam 物件的 advertiserName 屬性必填,如何實現呢?
若將這類引數驗證的程式碼寫死在 Controller 中,勢必會與正常的業務邏輯攪在一起,導致責任不夠單一,違背於“單一責任原則”。建議將其引數驗證行為從 Controller 中剝離出來,放到另外的類中,這裡僅提供一個@Valid註解來定義 AdvertiserParam 引數,並在 AdvertiserParam 類中通過@NotEmpty註解來定義 advertiserName 屬性,就像下面這樣:
@RestController
publicclassAdvertiserController {
@RequestMapping(value = "/advertiser", method = RequestMethod.POST)
publicResponse createAdvertiser(@RequestBody@ValidAdvertiserParam advertiserParam) {
...
}
}
publicclassAdvertiserParam {
@NotEmpty
privateString advertiserName;
privateString description;
// 省略 getter/setter 方法
}
這裡的 @Valid 註解實際上是Validation Bean規範提供的註解,該規範已由Hibernate Validator框架實現,因此需要新增以下 Maven 依賴到 pom.xml 檔案中:
org.hibernate hibernate-validator ${hibernate-validator.version}
需要注意的是,Hibernate Validator 與 Hibernate 沒有任何依賴關係,唯一有聯絡的只是都屬於 JBoss 公司的開源專案而已。
要實現 @NotEmpty 註解的功能,我們需要做以下幾件事情。
首先,定義一個 @NotEmpty 註解類,程式碼如下:
@Documented @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = NotEmptyValidator.class) public@interfaceNotEmpty { String message() default"not_empty"; Class[] groups() default{}; Class[] payload() default{}; }
以上註解類必須包含 message、groups、payload 三個屬性,因為這是規範所要求的,此外,需要通過 @Constraint 註解指定一個驗證器類,這裡對應的是 NotEmptyValidator,其程式碼如下:
publicclassNotEmptyValidator implementsConstraintValidator { @Override publicvoidinitialize(NotEmpty constraintAnnotation) { } @Override publicbooleanisValid(String value, ConstraintValidatorContext context) { returnStringUtil.isNotEmpty(value); } }
以上驗證器類實現了 ConstraintValidator 介面,並在該介面的 isValid( ) 方法中完成了具體的引數驗證邏輯。需要注意的是,實現介面時需要指定泛型,第一個引數表示驗證註解型別(NotEmpty),第二個引數表示需要驗證的引數型別(String)。
然後,我們需要在 Spring 配置檔案中開啟該特性,需新增如下配置:
<bean class="org.springframework.validation.beanvalidation.MethodValidationPostProcessor"/>
最後,需要在全域性異常處理類中新增引數驗證處理方法,程式碼如下:
@ControllerAdvice @ResponseBody publicclassExceptionAdvice { /** * 400 - Bad Request */ @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(ValidationException.class) publicResponse handleValidationException(ValidationException e) { logger.error("引數驗證失敗", e); returnnewResponse().failure("validation_exception"); } }
至此,REST 框架已集成了 Bean Validation 特性,我們可以使用各種註解來完成所需的引數驗證行為了。
看似該框架可以在本地成功跑起來,整個架構包含兩個應用,前端應用提供純靜態的 HTML 頁面,後端應用釋出 REST API,前端需要通過 AJAX 呼叫後端釋出的 REST API,然而 AJAX 是不支援跨域訪問的,也就是說,前後端兩個應用必須在同一個域名下才能訪問。這是非常嚴重的技術障礙,一定需要找到解決方案。
4.5 解決跨域問題
比如,前端應用為靜態站點且部署在 http://web.xxx.com 域下,後端應用釋出 REST API 並部署在 http://api.xxx.com 域下,如何使前端應用通過 AJAX 跨域訪問後端應用呢?這需要使用到CORS技術來實現,這也是目前最好的解決方案了。
CORS 全稱為 Cross Origin Resource Sharing(跨域資源共享),服務端只需新增相關響應頭資訊,即可實現客戶端發出 AJAX 跨域請求。
CORS 技術非常簡單,易於實現,目前絕大多數瀏覽器均已支援該技術(IE8 瀏覽器也支援了),服務端可通過任何程式語言來實現,只要能將 CORS 響應頭寫入 response 物件中即可。
下面我們繼續擴充套件 REST 框架,通過 CORS 技術實現 AJAX 跨域訪問。
首先,我們需要編寫一個 Filter,用於過濾所有的 HTTP 請求,並將 CORS 響應頭寫入 response 物件中,程式碼如下:
publicclassCorsFilter implementsFilter { privateString allowOrigin; privateString allowMethods; privateString allowCredentials; privateString allowHeaders; privateString exposeHeaders; @Override publicvoidinit(FilterConfig filterConfig) throwsServletException { allowOrigin = filterConfig.getInitParameter("allowOrigin"); allowMethods = filterConfig.getInitParameter("allowMethods"); allowCredentials = filterConfig.getInitParameter("allowCredentials"); allowHeaders = filterConfig.getInitParameter("allowHeaders"); exposeHeaders = filterConfig.getInitParameter("exposeHeaders"); } @Override publicvoiddoFilter(ServletRequest req, ServletResponse res, FilterChain chain) throwsIOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; if(StringUtil.isNotEmpty(allowOrigin)) { List allowOriginList = Arrays.asList(allowOrigin.split(",")); if(CollectionUtil.isNotEmpty(allowOriginList)) { String currentOrigin = request.getHeader("Origin"); if(allowOriginList.contains(currentOrigin)) { response.setHeader("Access-Control-Allow-Origin", currentOrigin); } } } if(StringUtil.isNotEmpty(allowMethods)) { response.setHeader("Access-Control-Allow-Methods", allowMethods); } if(StringUtil.isNotEmpty(allowCredentials)) { response.setHeader("Access-Control-Allow-Credentials", allowCredentials); } if(StringUtil.isNotEmpty(allowHeaders)) { response.setHeader("Access-Control-Allow-Headers", allowHeaders); } if(StringUtil.isNotEmpty(exposeHeaders)) { response.setHeader("Access-Control-Expose-Headers", exposeHeaders); } chain.doFilter(req, res); } @Override publicvoiddestroy() { } }
以上 CorsFilter 將從 web.xml 中讀取相關 Filter 初始化引數,並將在處理 HTTP 請求時將這些引數寫入對應的 CORS 響應頭中,下面大致描述一下這些 CORS 響應頭的意義:
Access-Control-Allow-Origin:允許訪問的客戶端域名,例如: http://web.xxx.com,若為 *,則表示從任意域都能訪問,即不做任何限制。
Access-Control-Allow-Methods:允許訪問的方法名,多個方法名用逗號分割,例如:GET,POST,PUT,DELETE,OPTIONS。
Access-Control-Allow-Credentials:是否允許請求帶有驗證資訊,若要獲取客戶端域下的 cookie 時,需要將其設定為 true。
Access-Control-Allow-Headers:允許服務端訪問的客戶端請求頭,多個請求頭用逗號分割,例如:Content-Type。
Access-Control-Expose-Headers:允許客戶端訪問的服務端響應頭,多個響應頭用逗號分割。
需要注意的是,CORS 規範中定義 Access-Control-Allow-Origin 只允許兩種取值,要麼為 *,要麼為具體的域名,也就是說,不支援同時配置多個域名。為了解決跨多個域的問題,需要在程式碼中做一些處理,這裡將 Filter 初始化引數作為一個域名的集合(用逗號分隔),只需從當前請求中獲取 Origin 請求頭,就知道是從哪個域中發出的請求,若該請求在以上允許的域名集合中,則將其放入 Access-Control-Allow-Origin 響應頭,這樣跨多個域的問題就輕鬆解決了。
以下是 web.xml 中配置 CorsFilter 的方法:
corsFilter com.xxx.api.cors.CorsFilter allowOrigin http://web.xxx.com allowMethods GET,POST,PUT,DELETE,OPTIONS allowCredentials true allowHeaders Content-Type corsFilter /*
完成以上過程即可實現 AJAX 跨域功能了,但似乎還存在另外一個問題,由於 REST 是無狀態的,後端應用釋出的 REST API 可在使用者未登入的情況下被任意呼叫,這顯然是不安全的,如何解決這個問題呢?我們需要為 REST 請求提供安全機制。
4.6 提供安全機制
解決 REST 安全呼叫問題,可以做得很複雜,也可以做得特簡單,可按照以下過程提供 REST 安全機制:
當用戶登入成功後,在服務端生成一個 token,並將其放入記憶體中(可放入 JVM 或 Redis 中),同時將該 token 返回到客戶端。
在客戶端中將返回的 token 寫入 cookie 中,並且每次請求時都將 token 隨請求頭一起傳送到服務端。
提供一個 AOP 切面,用於攔截所有的 Controller 方法,在切面中判斷 token 的有效性。
當登出時,只需清理掉 cookie 中的 token 即可,服務端 token 可設定過期時間,使其自行移除。
首先,我們需要定義一個用於管理 token 的介面,包括建立 token 與檢查 token 有效性的功能。程式碼如下:
publicinterfaceTokenManager { String createToken(String username); booleancheckToken(String token); }
然後,我們可提供一個簡單的 TokenManager 實現類,將 token 儲存到 JVM 記憶體中。程式碼如下:
publicclassDefaultTokenManager implementsTokenManager { privatestaticMap tokenMap = newConcurrentHashMap<>(); @Override publicString createToken(String username) { String token = CodecUtil.createUUID(); tokenMap.put(token, username); returntoken; } @Override publicbooleancheckToken(String token) { return!StringUtil.isEmpty(token) && tokenMap.containsKey(token); } }
需要注意的是,如果需要做到分散式叢集,建議基於 Redis 提供一個實現類,將 token 儲存到 Redis 中,並利用 Redis 與生俱來的特性,做到 token 的分散式一致性。
然後,我們可以基於 Spring AOP 寫一個切面類,用於攔截 Controller 類的方法,並從請求頭中獲取 token,最後對 token 有效性進行判斷。程式碼如下:
publicclassSecurityAspect { privatestaticfinalString DEFAULT_TOKEN_NAME = "X-Token"; privateTokenManager tokenManager; privateString tokenName; publicvoidsetTokenManager(TokenManager tokenManager) { this.tokenManager = tokenManager; } publicvoidsetTokenName(String tokenName) { if(StringUtil.isEmpty(tokenName)) { tokenName = DEFAULT_TOKEN_NAME; } this.tokenName = tokenName; } publicObject execute(ProceedingJoinPoint pjp) throwsThrowable { // 從切點上獲取目標方法 MethodSignature methodSignature = (MethodSignature) pjp.getSignature(); Method method = methodSignature.getMethod(); // 若目標方法忽略了安全性檢查,則直接呼叫目標方法 if(method.isAnnotationPresent(IgnoreSecurity.class)) { returnpjp.proceed(); } // 從 request header 中獲取當前 token String token = WebContext.getRequest().getHeader(tokenName); // 檢查 token 有效性 if(!tokenManager.checkToken(token)) { String message = String.format("token [%s] is invalid", token); thrownewTokenException(message); } // 呼叫目標方法 returnpjp.proceed(); } }
若要使 SecurityAspect 生效,則需要新增如下 Spring 配置:
<bean id="securityAspect" class="com.xxx.api.security.SecurityAspect"> <property name="tokenManager" ref="tokenManager"/> <property name="tokenName" value="X-Token"/> </bean> <aop:config> <aop:aspect ref="securityAspect"> <aop:around method="execute" pointcut="@annotation(org.springframework.web.bind.annotation.RequestMapping)"/> </aop:aspect> </aop:config>
最後,別忘了在 web.xml 中新增允許的 X-Token 響應頭,配置如下:
<init-param> <param-name>allowHeaders <param-value>Content-Type,X-Token> </init-param>
5 總結
本文從經典的 MVC 模式開始,對 MVC 模式是什麼以及該模式存在的不足進行了簡述。然後引出瞭如何對 MVC 模式的改良,讓其轉變為前後端分離架構,以及解釋了為何要進行前後端分離。最後通過 REST 服務將前後端進行解耦,並提供了一款基於 Java 的 REST 框架的主要實現過程,尤其是需要注意的核心技術問題及其解決方案。希望本文對正在探索前後端分離的讀者們有所幫助,期待與大家共同探討。