從 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 框架的主要實現過程,尤其是需要注意的核心技術問題及其解決方案。希望本文對正在探索前后端分離的讀者們有所幫助,期待與大家共同探討。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容