1. 程式人生 > >從MVC到前後端分離(Rest框架搭建)

從MVC到前後端分離(Rest框架搭建)

1、理解 MVC

       MVC 是一種經典的設計模式,全名為 Model-View-Controller,即 模型-檢視-控制器。其中,模型 是用於封裝資料的載體,例如,在 Java 中一般通過一個簡單的POJO(Plain Ordinary Java Object)來表示,其本質是一個普通的 Java Bean,包含一系列的成員變數及其 getter/setter 方法。對於檢視 而言,它更加偏重於展現,也就是說,檢視決定了介面到底長什麼樣子,在 Java 中可通過 JSP 來充當檢視,或者通過純 HTML 的方式進行展現,而後者才是目前的主流。模型和檢視需要通過控制器 來進行粘合,例如,使用者傳送一個 HTTP 請求,此時該請求首先會進入控制器,然後控制器去獲取資料並將其封裝為模型,最後將模型傳遞到檢視中進行展現。

       綜上所述,MVC 的互動過程如下圖所示:

圖1 - 經典 MVC 模式

2、MVC 模式的優點與不足

        MVC 模式早在上個世紀 70 年代就誕生了,直到今天它依然存在,可見生命力相當之強。MVC 模式最早用於 Smalltalk 語言中,之後在其它許多開發語言中都得到了很好的應用。例如,Java 中的 Struts、Spring MVC 等框架。正是因為這些 MVC 框架的出現,才讓 MVC 模式真正落地,讓開發更加高效,讓程式碼耦合度儘量減小,讓應用程式各部分的職責更加清晰。

       既然 MVC 模式這麼好,難道它就沒有不足的地方嗎?我認為 MVC 至少有以下三點不足:

  1. 每次請求必須經過“控制器->模型->檢視”這個流程,使用者才能看到最終的展現的介面,這個過程似乎有些複雜。
  2. 實際上檢視是依賴於模型的,換句話說,如果沒有模型,檢視也無法呈現出最終的效果。
  3. 渲染檢視的過程是在服務端來完成的,最終呈現給瀏覽器的是帶有模型的檢視頁面,效能無法得到很好的優化。

       為了使資料展現過程更加直接,並且提供更好的使用者體驗,我們有必要對 MVC 模式進行改進。不妨這樣來嘗試,首先從瀏覽器傳送 AJAX 請求,然後服務端接受該請求並返回 JSON 資料返回給瀏覽器,最後在瀏覽器中進行介面渲染。

      改進後的 MVC 模式如下圖所示:

圖2 - 改進後的 MVC 模式

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

        如果將瀏覽器這一端視為前端,而伺服器那一端視為後端的話,可以將以上改進後的 MVC 模式簡化為以下前後端分離模式:

圖3 - 前後端分離

       可見,有了 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 請求進行簡單描述:

REST 請求 描述
GET:/advertisers 獲取所有的廣告主
GET:/advertiser/1 獲取 ID 為 1 的廣告主
PUT:/advertiser/1 更新 ID 為 1 的廣告主
DELETE:/advertiser/1 刪除 ID 為 1 的廣告主
POST:/advertiser 建立廣告主

        可見,請求路徑相同,但請求方式不同,所代表的業務操作也不同,例如,/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 類與其對應:

public class Response {

    private static final String OK = "ok";
    private static final String ERROR = "error";

    private Meta meta;
    private Object data;

    public Response success() {
        this.meta = new Meta(true, OK);
        return this;
    }

    public Response success(Object data) {
        this.meta = new Meta(true, OK);
        this.data = data;
        return this;
    }

    public Response failure() {
        this.meta = new Meta(false, ERROR);
        return this;
    }

    public Response failure(String message) {
        this.meta = new Meta(false, message);
        return this;
    }

    public Meta getMeta() {
        return meta;
    }

    public Object getData() {
        return data;
    }

    public class Meta {

        private boolean success;
        private String message;

        public Meta(boolean success) {
            this.success = success;
        }

        public Meta(boolean success, String message) {
            this.success = success;
            this.message = message;
        }

        public boolean isSuccess() {
            return success;
        }

        public String getMessage() {
            return message;
        }
    }
}

        以上 Response 類包括兩類通用返回值訊息:ok 與 error,還包括兩個常用的操作方法:success( ) 與 failure( ),通過一個內部類來展現元資料結構,我們在下文中多次會使用該Response類。實現該 REST 框架需要考慮許多問題,首當其衝的就是物件序列化問題。

4.2、實現物件序列化

        想要解釋什麼是 物件序列化?不妨通過一些例子進行說明。比如,在服務端從資料庫中獲取了資料,此時該資料是一個普通的 Java 物件,然後需要將這個 Java 物件轉換為 JSON 字串,並將其返回到瀏覽器中進行渲染,這個轉換過程稱為序列化;再比如,通過瀏覽器傳送了一個普通的 HTTP 請求,該請求攜帶了一個 JSON 格式的引數,在服務端需要將該 JSON 引數轉換為普通的 Java 物件,這個轉換過程稱為反序列化。不管是序列化還是反序列化,我們一般都稱為序列化。

       實際上,Spring MVC 已經為我們提供了這類序列化特性,只需在 Controller 的方法引數中使用 @RequestBody 註解定義需要反序列化的引數即可,如以下程式碼片段:

@Controller
public class AdvertiserController {

    @RequestMapping(value = "/advertiser", method = RequestMethod.POST)
    public Response createAdvertiser(@RequestBody AdvertiserParam advertiserParam) {
        ...
    }
}

       若需要對 Controller 的方法返回值進行序列化,則需要在該返回值上使用 @ResponseBody 註解來定義,如以下程式碼片段:

@Controller
public class AdvertiserController {

    @RequestMapping(value = "/advertiser/{id}", method = RequestMethod.GET)
    public @ResponseBody Response getAdvertiser(@PathVariable("id") String advertiserId) {
        ...
    }
}

        當然,@ResponseBody 註解也可以定義在類上,這樣所有的方法都繼承了該特性。由於經常會使用到 @ResponseBody 註解,所以 Spring 提供了一個名為@RestController 的註解來取代以上的 @Controller 註解,這樣我們就可以省略返回值前面的 @ResponseBody 註解了,但引數前面的 @RequestBody 註解是無法省略的。實際上,看看 Spring 中對應 @RestController 註解的原始碼便可知曉:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {

    String value() default "";
}

       可見,@RestController 註解已經被 @Controller 與 @ResponseBody 註解定義過了,Spring 框架會識別這類註解。需要注意的是,該特性在 Spring 4.0 中才引入。因此,我們可將以上程式碼進行如下改寫:

@RestController
public class AdvertiserController {

    @RequestMapping(value = "/advertiser", method = RequestMethod.POST)
    public Response createAdvertiser(@RequestBody AdvertiserParam advertiserParam) {
        ...
    }

    @RequestMapping(value = "/advertiser/{id}", method = RequestMethod.GET)
    public Response 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 類,程式碼如下:

public class CustomObjectMapper extends ObjectMapper {

    private boolean camelCaseToLowerCaseWithUnderscores = false;
    private String dateFormatPattern;

    public void setCamelCaseToLowerCaseWithUnderscores(boolean camelCaseToLowerCaseWithUnderscores) {
        this.camelCaseToLowerCaseWithUnderscores = camelCaseToLowerCaseWithUnderscores;
    }

    public void setDateFormatPattern(String dateFormatPattern) {
        this.dateFormatPattern = dateFormatPattern;
    }

    public void init() {
        // 排除值為空屬性
        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 = new SimpleDateFormat(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
public class ExceptionAdvice {

    /**
     * 400 - Bad Request
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public Response handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
        logger.error("引數解析失敗", e);
        return new Response().failure("could_not_read_json");
    }

    /**
     * 405 - Method Not Allowed
     */
    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public Response handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
        logger.error("不支援當前請求方法", e);
        return new Response().failure("request_method_not_supported");
    }

    /**
     * 415 - Unsupported Media Type
     */
    @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
    public Response handleHttpMediaTypeNotSupportedException(Exception e) {
        logger.error("不支援當前媒體型別", e);
        return new Response().failure("content_type_not_supported");
    }

    /**
     * 500 - Internal Server Error
     */
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(Exception.class)
    public Response handleException(Exception e) {
        logger.error("服務執行異常", e);
        return new Response().failure(e.getMessage());
    }
}

       可見,在 ExceptionAdvice 類中包含一系列的異常處理方法,每個方法都通過 @ResponseStatus 註解定義了響應狀態碼,此外還通過@ExceptionHandler 註解指定了具體需要攔截的異常類。以上過程只是包含了一部分的異常情況,若需處理其它異常,可新增方法具體的方法。需要注意的是,在執行時從上往下依次呼叫每個異常處理方法,匹配當前異常型別是否與 @ExceptionHandler 註解所定義的異常相匹配,若匹配,則執行該方法,同時忽略後續所有的異常處理方法,最終會返回經 JSON 序列化後的 Response 物件。

4.4、支援引數驗證

        我們回到上文所提到的示例,這裡處理一個普通的 POST 請求,程式碼如下:

@RestController
public class AdvertiserController {

    @RequestMapping(value = "/advertiser", method = RequestMethod.POST)
    public Response createAdvertiser(@RequestBody AdvertiserParam advertiserParam) {
        ...
    }
}

        其中,AdvertiserParam 引數包含若干屬性,通過以下類結構可見,它是一個傳統的 POJO:

public class AdvertiserParam {

    private String advertiserName;

    private String description;

    // 省略 getter/setter 方法
}

        如果業務上需要確保 AdvertiserParam 物件的 advertiserName 屬性必填,如何實現呢?

        若將這類引數驗證的程式碼寫死在 Controller 中,勢必會與正常的業務邏輯攪在一起,導致責任不夠單一,違背於“單一責任原則”。建議將其引數驗證行為從 Controller 中剝離出來,放到另外的類中,這裡僅提供一個@Valid 註解來定義 AdvertiserParam 引數,並在 AdvertiserParam 類中通過 @NotEmpty 註解來定義 advertiserName 屬性,就像下面這樣:

@RestController
public class AdvertiserController {

    @RequestMapping(value = "/advertiser", method = RequestMethod.POST)
    public Response createAdvertiser(@RequestBody @Valid AdvertiserParam advertiserParam) {
        ...
    }
}

public class AdvertiserParam {

    @NotEmpty
    private String advertiserName;

    private String description;

    // 省略 getter/setter 方法
}

        這裡的 @Valid 註解實際上是 Validation Bean 規範提供的註解,該規範已由 Hibernate Validator 框架實現,因此需要新增以下 Maven 依賴到 pom.xml 檔案中:

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>${hibernate-validator.version}</version>
</dependency>

        需要注意的是,Hibernate Validator 與 Hibernate 沒有任何依賴關係,唯一有聯絡的只是都屬於 JBoss 公司的開源專案而已。

        要實現 @NotEmpty 註解的功能,我們需要做以下幾件事情。

        首先,定義一個 @NotEmpty 註解類,程式碼如下:

@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NotEmptyValidator.class)
public @interface NotEmpty {

    String message() default "not_empty";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

        以上註解類必須包含 message、groups、payload 三個屬性,因為這是規範所要求的,此外,需要通過 @Constraint 註解指定一個驗證器類,這裡對應的是 NotEmptyValidator,其程式碼如下:

public class NotEmptyValidator implements ConstraintValidator<NotEmpty, String> {

    @Override
    public void initialize(NotEmpty constraintAnnotation) {
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return StringUtil.isNotEmpty(value);
    }
}

       以上驗證器類實現了 ConstraintValidator 介面,並在該介面的 isValid( ) 方法中完成了具體的引數驗證邏輯。需要注意的是,實現介面時需要指定泛型,第一個引數表示驗證註解型別(NotEmpty),第二個引數表示需要驗證的引數型別(String)。

然後,我們需要在 Spring 配置檔案中開啟該特性,需新增如下配置:

    <bean class="org.springframework.validation.beanvalidation.MethodValidationPostProcessor"/>

        最後,需要在全域性異常處理類中新增引數驗證處理方法,程式碼如下:

@ControllerAdvice
@ResponseBody
public class ExceptionAdvice {

    /**
     * 400 - Bad Request
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ValidationException.class)
    public Response handleValidationException(ValidationException e) {
        logger.error("引數驗證失敗", e);
        return new Response().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 物件中,程式碼如下:

public class CorsFilter implements Filter {

    private String allowOrigin;
    private String allowMethods;
    private String allowCredentials;
    private String allowHeaders;
    private String exposeHeaders;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        allowOrigin = filterConfig.getInitParameter("allowOrigin");
        allowMethods = filterConfig.getInitParameter("allowMethods");
        allowCredentials = filterConfig.getInitParameter("allowCredentials");
        allowHeaders = filterConfig.getInitParameter("allowHeaders");
        exposeHeaders = filterConfig.getInitParameter("exposeHeaders");
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;
        if (StringUtil.isNotEmpty(allowOrigin)) {
            List<String> 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
    public void destroy() {
    }
}

        以上 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 的方法:

<filter>
    <filter-name>corsFilter</filter-name>
    <filter-class>com.xxx.api.cors.CorsFilter</filter-class>
    <init-param>
        <param-name>allowOrigin</param-name>
        <param-value>http://web.xxx.com</param-value>
    </init-param>
    <init-param>
        <param-name>allowMethods</param-name>
        <param-value>GET,POST,PUT,DELETE,OPTIONS</param-value>
    </init-param>
    <init-param>
        <param-name>allowCredentials</param-name>
        <param-value>true</param-value>
    </init-param>
    <init-param>
        <param-name>allowHeaders</param-name>
        <param-value>Content-Type</param-value>
    </init-param>
</filter>
<filter-mapping>
    <filter-name>corsFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

        完成以上過程即可實現 AJAX 跨域功能了,但似乎還存在另外一個問題,由於 REST 是無狀態的,後端應用釋出的 REST API 可在使用者未登入的情況下被任意呼叫,這顯然是不安全的,如何解決這個問題呢?我們需要為 REST 請求提供安全機制。

4.6、提供安全機制

       解決 REST 安全呼叫問題,可以做得很複雜,也可以做得特簡單,可按照以下過程提供 REST 安全機制:

  1. 當用戶登入成功後,在服務端生成一個 token,並將其放入記憶體中(可放入 JVM 或 Redis 中),同時將該 token 返回到客戶端。
  2. 在客戶端中將返回的 token 寫入 cookie 中,並且每次請求時都將 token 隨請求頭一起傳送到服務端。
  3. 提供一個 AOP 切面,用於攔截所有的 Controller 方法,在切面中判斷 token 的有效性。
  4. 當登出時,只需清理掉 cookie 中的 token 即可,服務端 token 可設定過期時間,使其自行移除。

       首先,我們需要定義一個用於管理 token 的介面,包括建立 token 與檢查 token 有效性的功能。程式碼如下:

public interface TokenManager {

    String createToken(String username);

    boolean checkToken(String token);
}

       然後,我們可提供一個簡單的 TokenManager 實現類,將 token 儲存到 JVM 記憶體中。程式碼如下:

public class DefaultTokenManager implements TokenManager {

    private static Map<String, String> tokenMap = new ConcurrentHashMap<>();

    @Override
    public String createToken(String username) {
        String token = CodecUtil.createUUID();
        tokenMap.put(token, username);
        return token;
    }

    @Override
    public boolean checkToken(String token) {
        return !StringUtil.isEmpty(token) && tokenMap.containsKey(token);
    }
}

        需要注意的是,如果需要做到分散式叢集,建議基於 Redis 提供一個實現類,將 token 儲存到 Redis 中,並利用 Redis 與生俱來的特性,做到 token 的分散式一致性。

然後,我們可以基於 Spring AOP 寫一個切面類,用於攔截 Controller 類的方法,並從請求頭中獲取 token,最後對 token 有效性進行判斷。程式碼如下:

public class SecurityAspect {

    private static final String DEFAULT_TOKEN_NAME = "X-Token";

    private TokenManager tokenManager;
    private String tokenName;

    public void setTokenManager(TokenManager tokenManager) {
        this.tokenManager = tokenManager;
    }

    public void setTokenName(String tokenName) {
        if (StringUtil.isEmpty(tokenName)) {
            tokenName = DEFAULT_TOKEN_NAME;
        }
        this.tokenName = tokenName;
    }

    public Object execute(ProceedingJoinPoint pjp) throws Throwable {
        // 從切點上獲取目標方法
        MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
        Method method = methodSignature.getMethod();
        // 若目標方法忽略了安全性檢查,則直接呼叫目標方法
        if (method.isAnnotationPresent(IgnoreSecurity.class)) {
            return pjp.proceed();
        }
        // 從 request header 中獲取當前 token
        String token = WebContext.getRequest().getHeader(tokenName);
        // 檢查 token 有效性
        if (!tokenManager.checkToken(token)) {
            String message = String.format("token [%s] is invalid", token);
            throw new TokenException(message);
        }
        // 呼叫目標方法
        return pjp.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-name>
    <param-value>Content-Type,X-Token</param-value>
</init-param>

5、總結

       本文從經典的 MVC 模式開始,對 MVC 模式是什麼以及該模式存在的不足進行了簡述。然後引出瞭如何對 MVC 模式的改良,讓其轉變為前後端分離架構,以及解釋了為何要進行前後端分離。最後通過 REST 服務將前後端進行解耦,並提供了一款基於 Java 的 REST 框架的主要實現過程,尤其是需要注意的核心技術問題及其解決方案。希望本文對正在探索前後端分離的讀者們有所幫助,期待與大家共同探討。