使用Spring框架搭建簡單的REST框架

轉自:https://my.oschina.net/huangyong/blog/521891

摘要: 本文首先描述 MVC 模式是什麼,然後針對 MVC 的不足發表了作者的個人觀點,隨後引出了基於 REST 架構實現前後端分離的方案,最後使用了 Java 的 Spring 框架搭建了一個簡單的 REST 框架。全文從原理到實戰,希望對於想了解如何實現前後端分離架構的朋友有所幫助。由於篇幅有限,且個人水平不足,難免會出現一些遺漏或不足之處,懇請大家提出寶貴意見或建議,謝謝!

從 MVC 到前後端分離

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

源碼地址:http://git.oschina.net/huangyong/api


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章