MVC 到前后端分离的 REST 风格框架

本文作者:书呆子 Rico
文章出处:CSDN
 

  本文是在 CSDN 博友 shao.bing 的专栏的《从 MVC 到前后端分离(REST- 个人也认为是目前比较流行和比较好的方式)》一文基础上整理而成,实现了文中所述的 REST 框架并进行分享,相关源码移步至我的 Github 进行下载,项目地址:https://github.com/githubofrico/RestSpringMVCDemo

  本文旨在分享与交流,若涉及侵权,请留言或邮件 (rico@tju.edu.cn) 联系。


一. MVC 概述

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 语言中,最后在其它许多开发语言中都得到了很好的应用。随着包括 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。


二. 认识 REST

  REST 本质上是使用 URL 来访问资源的一种方式。众所周知,URL(Uniform Resoure Locator: 统一资源定位器) 就是我们平常使用的请求地址了,其中包括两部分:请求方式与请求路径,比较常见的请求方式是 GET 与 POST,但在 REST 中又提出了几种其它类型的请求方式,汇总起来有六种:GET、POST、PUT、DELETE、HEAD、OPTIONS。尤其是前四种,正好与 CRUD(Create-Retrieve-Update-Delete,增删改查)四种操作相对应,例如,GET(查)、POST(增)、PUT(改)、DELETE(删),这正是 REST 与 CRUD 的异曲同工之妙!需要强调的是,REST 是面向资源 (ROA) 的,这里提到的资源,实际上就是我们常说的领域对象,在系统设计过程中,我们经常通过领域对象来进行数据建模。

  REST 是一个无状态的架构模式,因为在任何时候都可以由客户端发出请求到服务端,最终返回自己想要的数据,当前请求不会受到上次请求的影响。也就是说,** 服务端将内部资源发布 REST 服务,客户端通过 URL 来定位这些资源并通过 HTTP 协议来访问它们。** 下面我们举几个例子对 REST 请求进行简单描述:

                

  可见,请求路径相同但请求方式不同,所代表的业务操作也不同,例如,/advertiser/1 这个请求,带有 GET、PUT、DELETE 三种不同的请求方式,对应三种不同的业务操作。

  虽然 REST 看起来还是很简单的,实际上我们往往需要提供一个 REST 框架,让其实现前后端分离架构,让开发人员将精力集中在业务上,而并非那些具体的技术细节。下面我们将使用 Java 技术来实现这个 REST 框架,整体框架会基于 Spring 进行开发。


三. 实现 REST 框架

1、统一响应结构

  使用 REST 框架实现前后端分离架构,我们需要首先确定返回的 JSON 响应结构是统一的,也就是说,每个 REST 请求将返回相同结构的 JSON 响应结构。不妨定义一个相对通用的 JSON 响应结构,其中包含两部分:元数据与返回值,其中,元数据表示操作是否成功与返回值消息等,返回值对应服务端方法所返回的数据。该 JSON 响应结构如下:

    {
        "meta": {
            "success": true,
            "message": "ok"
        },
        "data": ...
    }

  为了在框架中映射以上 JSON 响应结构,我们需要编写一个 Response 类与其对应:

/**
 * Title: 统一响应结构 
 * Description:使用REST框架实现前后端分离架构,我们需要首先确定返回的JSON响应结构是统一的,
 * 也就是说,每个REST请求将返回相同结构的JSON响应结构。不妨定义一个相对通用的JSON响应结构,其
 * 中包含两部分:元数据与返回值,其中,元数据表示操作是否成功与返回值消息等,返回值对应服务端方法所返回的数据。
 * { "meta": { "success": true, "message": "ok" }, "data": ... }
 * 
 * @author rico
 * @created 2017年7月4日 下午5:06:00
 */
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;
    }

    /**
     * Title: 请求元数据
     * @author rico
     * @created 2017年7月4日 下午5:08:12
     */
    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 框架需要考虑许多问题,首当其冲的就是前后数据流转问题,即 HTTP 消息与 Java 对象之间的转化问题。


2、前后台数据流转
  
  前后台数据流转问题具体指的是什么?不妨通过一些例子进行说明。比如,通过浏览器发送了一个普通的 HTTP 请求,该请求携带了一个 JSON 格式的参数,在服务端需要将该 JSON 参数转换为普通的 Java 对象;再比如,在服务端获取了数据,此时该数据是一个普通的 Java 对象,然后需要将这个 Java 对象转换为 JSON 字符串,并将其返回到浏览器中进行渲染,这个过程就涉及 HTTP 消息与 Java 对象之间的转化问题。

  实际上,Spring MVC 已经为我们提供了这类转化特性,只需在 Controller 的方法参数中使用 @RequestBody 注解定义需要转化的参数即可;类似地,若需要对 Controller 的方法返回值进行转化,则需要在该返回值上使用 @ResponseBody 注解来定义,如以下代码片段:

@RequestMapping("/users")
public class UserController {

    private UserService userService;

    /** Log4j日志处理(@author: rico) */
    private static final Logger log = Logger.getLogger(UserController.class);

    public UserService getUserService() {
        return userService;
    }

    @Resource(name = "userService")
    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @RequestMapping(value = "/user", method = RequestMethod.PUT, produces = "application/json", 
            consumes = "application/json")
    @ResponseBody     // 将 Java 对象转化为特定的HTTP消息
    public User addUser(@RequestBody User user) {  // 将接收到的HTTP消息转化为Java对象
        userService.addUser(user);
        log.debug("添加用户 :" + user);
        return user;
    }
}

   当然,@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
@RequestMapping("/users")
public class UserController {

    private UserService userService;

    /** Log4j日志处理(@author: rico) */
    private static final Logger log = Logger.getLogger(UserController.class);

    public UserService getUserService() {
        return userService;
    }

    @Resource(name = "userService")
    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @RequestMapping(value = "/user", method = RequestMethod.PUT, produces = "application/json", 
            consumes = "application/json")
    public User addUser(@RequestBody User user) {  // 将接收到的HTTP消息转化为Java对象
        userService.addUser(user);
        log.debug("添加用户 :" + user);
        return user;
    }
}

   除了使用注解来定义消息转化行为以外,我们还需要添加 Jackson 包进行支持,Maven 依赖如下:

<!-- JSON: jackson -->
<dependency>
    <groupId>org.codehaus.jackson</groupId>
    <artifactId>jackson-core-asl</artifactId>
    <version>1.9.12</version>
</dependency>
<dependency>
    <groupId>org.codehaus.jackson</groupId>
    <artifactId>jackson-mapper-lgpl</artifactId>
    <version>1.9.12</version>
</dependency>

   在 Spring 配置文件中添加以下配置即可:

    <!-- 该配置会自动注册RequestMappingHandlerMapping与RequestMappingHandlerAdapter两个Bean,
    这是SpringMVC为@Controllers分发请求所必需的,并提供了数据绑定支持、@NumberFormatannotation支持、
    @DateTimeFormat支持、@Valid支持、读写XML的支持和读写JSON的支持等功能。 -->
    <mvc:annotation-driven />

   通过以上过程,我们已经完成了一个基于 SpringMVC 的 REST 框架,只不过该框架还非常单薄,还缺乏很多关键性特性,尤其是异常处理。


3、处理异常行为

  在 Spring MVC 中,我们可以使用 AOP 技术,编写一个全局的异常处理切面类,用它来统一处理所有的异常行为,在 Spring 3.2 中才开始提供。使用很简单,只需定义一个类,并通过 @ControllerAdvice 注解将其标注即可,同时需要使用 @ResponseBody 注解表示返回值可序列化为 JSON 字符串。代码如下:

/**        
 * Title: 全局异常处理切面    
 * Description: 利用 @ControllerAdvice + @ExceptionHandler 组合处理Controller层RuntimeException异常
 * @author rico       
 * @created 2017年7月4日 下午4:29:07    
 */      
@ControllerAdvice   // 控制器增强
@ResponseBody
public class ExceptionAspect {

    /** Log4j日志处理(@author: rico) */
    private static final Logger log = Logger.getLogger(ExceptionAspect.class);

    /**
     * 400 - Bad Request
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public Response handleHttpMessageNotReadableException(
            HttpMessageNotReadableException e) {
        log.error("could_not_read_json...", e);
        return new Response().failure("could_not_read_json");
    }

    /**
     * 400 - Bad Request
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler({MethodArgumentNotValidException.class})
    public Response handleValidationException(MethodArgumentNotValidException e) {
        log.error("parameter_validation_exception...", e);
        return new Response().failure("parameter_validation_exception");
    }

    /**
     * 405 - Method Not Allowed。HttpRequestMethodNotSupportedException
     * 是ServletException的子类,需要Servlet API支持
     */
    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public Response handleHttpRequestMethodNotSupportedException(
            HttpRequestMethodNotSupportedException e) {
        log.error("request_method_not_supported...", e);
        return new Response().failure("request_method_not_supported");
    }

    /**
     * 415 - Unsupported Media Type。HttpMediaTypeNotSupportedException
     * 是ServletException的子类,需要Servlet API支持
     */
    @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
    @ExceptionHandler({ HttpMediaTypeNotSupportedException.class })
    public Response handleHttpMediaTypeNotSupportedException(Exception e) {
        log.error("content_type_not_supported...", e);
        return new Response().failure("content_type_not_supported");
    }

    /**
     * 500 - Internal Server Error
     */
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(TokenException.class)
    public Response handleTokenException(Exception e) {
        log.error("Token is invaild...", e);
        return new Response().failure("Token is invaild");
    }

    /**
     * 500 - Internal Server Error
     */
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(Exception.class)
    public Response handleException(Exception e) {
        log.error("Internal Server Error...", e);
        return new Response().failure("Internal Server Error");
    }
}

  可见,在 ExceptionAdvice 类中包含一系列的异常处理方法,每个方法都通过 @ResponseStatus 注解定义了响应状态码,此外还通过 @ExceptionHandler 注解指定了具体需要拦截的异常类。以上过程只是包含了一部分的异常情况,若需处理其它异常,可添加方法具体的方法。需要注意的是,在运行时从上往下依次调用每个异常处理方法,匹配当前异常类型是否与 @ExceptionHandler 注解所定义的异常相匹配,若匹配,则执行该方法,同时忽略后续所有的异常处理方法,最终会返回经 JSON 序列化后的 Response 对象。


4、支持参数验证

  我们回到上文所提到的示例,这里处理一个普通的 PUT 请求,代码如下:

@RestController
@RequestMapping("/users")
public class UserController {

    private UserService userService;

    /** Log4j日志处理(@author: rico) */
    private static final Logger log = Logger.getLogger(UserController.class);

    public UserService getUserService() {
        return userService;
    }

    @Resource(name = "userService")
    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @RequestMapping(value = "/user", method = RequestMethod.PUT, produces = "application/json", 
            consumes = "application/json")
    public User addUser(@RequestBody User user) {  // 将接收到的HTTP消息转化为Java对象
        userService.addUser(user);
        log.debug("添加用户 :" + user);
        return user;
    }

    ...
}

  其中,User 参数包含若干属性,通过以下类结构可见,它是一个传统的 POJO:

public class User implements Serializable{

    private static final long serialVersionUID = 1L;

    private int id;
    private String uname;
    private String passwd;
    private String gentle;
    private String email;
    private String city;

    public User() {
        super();
    }

    // getter/setter

    // toString
}

  如果业务上需要确保 User 对象的 uname 属性必填,如何实现呢?若将这类参数验证的代码写死在 Controller 中,势必会与正常的业务逻辑搅在一起,导致责任不够单一,违背于“单一责任原则”。建议将其参数验证行为从 Controller 中剥离出来,放到另外的类中,这里仅通过 @Valid 注解来定义 uname 参数,并通过 Bean Validation 的参考实现 Hibernate Validator 的 @NotEmpty 注解来定义 User 类中的 uname 属性,就像下面这样:

@RestController
@RequestMapping("/users")
public class UserController {

    private UserService userService;

    /** Log4j日志处理(@author: rico) */
    private static final Logger log = Logger.getLogger(UserController.class);

    public UserService getUserService() {
        return userService;
    }

    @Resource(name = "userService")
    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @RequestMapping(value = "/user", method = RequestMethod.PUT, produces = "application/json", 
            consumes = "application/json")
    public User addUser(@RequestBody  @Valid User user) {  // 将接收到的HTTP消息转化为Java对象
        userService.addUser(user);
        log.debug("添加用户 :" + user);
        return user;
    }

    ...
}

public class User implements Serializable{

    private static final long serialVersionUID = 1L;

    private int id;
    @NotEmpty
    private String uname;
    private String passwd;
    private String gentle;
    private String email;
    private String city;

    public User() {
        super();
    }

    // getter/setter

    // toString
}

  这里的 @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 公司的开源项目而已。然后,我们需要在 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 是不支持跨域访问的,也就是说,前后端两个应用必须在同一个域名下才能访问。这是非常严重的技术障碍,一定需要找到解决方案。


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 对象中,代码如下:

/**        
 * Title: 跨域访问处理(跨域资源共享)    
 * Description: 解决前后端分离架构中的跨域问题
 * @author rico       
 * @created 2017年7月4日 下午5:00:09    
 */      
public class CorsFilter implements Filter {

    /** Log4j日志处理(@author: rico) */
    private static final Logger log = Logger.getLogger(UserController.class);

    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");
    }

    /** 
     * @description 通过CORS技术实现AJAX跨域访问,只要将CORS响应头写入response对象中即可
     * @author rico       
     * @created 2017年7月4日 下午5:02:38      
     * @param req
     * @param res
     * @param chain
     * @throws IOException
     * @throws ServletException     
     * @see javax.servlet.Filter#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain)     
     */  
    @Override
    public void doFilter(ServletRequest req, ServletResponse res,
            FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;
        String currentOrigin = request.getHeader("Origin");
        log.debug("currentOrigin : " + currentOrigin);
        if (StringUtil.isNotEmpty(allowOrigin)) {
            List<String> allowOriginList = Arrays
                    .asList(allowOrigin.split(","));
            log.debug("allowOriginList : " + allowOrigin);
            if (CollectionUtil.isNotEmpty(allowOriginList)) {
                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 的方法:

<!-- 通过CORS技术实现AJAX跨域访问 -->
    <filter>
        <filter-name>corsFilter</filter-name>
        <filter-class>cn.edu.tju.rico.filter.CorsFilter</filter-class>
        <init-param>
            <param-name>allowOrigin</param-name>
            <param-value>http://localhost:8020</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,X-Token</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>corsFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

  完成以上过程即可实现 AJAX 跨域功能了,但似乎还存在另外一个问题,由于 REST 是无状态的,后端应用发布的 REST API 可在用户未登录的情况下被任意调用,这显然是不安全的,如何解决这个问题呢?我们需要为 REST 请求提供安全机制。


6、提供安全机制

  解决 REST 安全调用问题,可以做得很复杂,也可以做得特简单,可按照以下过程提供 REST 安全机制:

  (1). 当用户登录成功后,在服务端生成一个 token,并将其放入内存中(可放入 JVM 或 Redis 中),同时将该 token 返回到客户端;

  (2). 在客户端中将返回的 token 写入 cookie 中,并且每次请求时都将 token 随请求头一起发送到服务端;

  (3). 提供一个 AOP 切面,用于拦截所有的 Controller 方法,在切面中判断 token 的有效性;

  (4). 当登出时,只需清理掉 cookie 中的 token 即可,服务端 token 可设置过期时间,使其自行移除。

  首先,我们需要定义一个用于管理 token 的接口,包括创建 token 与检查 token 有效性的功能。代码如下:

/**        
 * Title: REST 鉴权   
 * Description: 登录用户的身份鉴权
 * @author rico       
 * @created 2017年7月4日 下午4:41:43    
 */      
public interface TokenManager {

    String createToken(String username);  

    boolean checkToken(String token); 

    void deleteToken(String token);
}

  然后,我们可提供一个简单的 TokenManager 实现类,将 token 存储到 JVM 内存中。代码如下:

/**        
 * Title: TokenManager的默认实现    
 * Description: 管理 Token
 * @author rico       
 * @created 2017年7月4日 下午4:41:32    
 */      
public class DefaultTokenManager implements TokenManager {

    /** 将token存储到JVM内存(ConcurrentHashMap)中   (@author: rico) */      
    private static Map<String, String> tokenMap = new ConcurrentHashMap<String, String>();

    /** 
     * @description 利用UUID创建Token(用户登录时,创建Token)
     * @author rico       
     * @created 2017年7月4日 下午4:46:46      
     * @param username
     * @return     
     * @see cn.edu.tju.rico.authorization.TokenManager#createToken(java.lang.String)     
     */  
    public String createToken(String username) {
        String token = CodecUtil.createUUID();
        tokenMap.put(token, username);
        return token;
    }

    /** 
     * @description Token验证(用户登录验证)
     * @author rico       
     * @created 2017年7月4日 下午4:46:50      
     * @param token
     * @return     
     * @see cn.edu.tju.rico.authorization.TokenManager#checkToken(java.lang.String)     
     */  
    public boolean checkToken(String token) {
        return !StringUtil.isEmpty(token) && tokenMap.containsKey(token);
    }

    /** 
     * @description Token删除(用户登出时,删除Token)
     * @author rico       
     * @created 2017年7月4日 下午4:46:54      
     * @param token     
     * @see cn.edu.tju.rico.authorization.TokenManager#deleteToken(java.lang.String)     
     */  
    @Override
    public void deleteToken(String token) {
        // TODO Auto-generated method stub
        tokenMap.remove(token);
    }
}

  需要注意的是,如果需要做到分布式集群,建议基于 Redis 提供一个实现类,将 token 存储到 Redis 中,并利用 Redis 与生俱来的特性,做到 token 的分布式一致性。
然后,我们可以基于 Spring AOP 写一个切面类,用于拦截 Controller 类的方法,并从请求头中获取 token,最后对 token 有效性进行判断。代码如下:

/**
 * Title:安全检查切面(是否登录检查) 
 * Description: 通过验证Token维持登录状态
 * 
 * @author rico
 * @created 2017年7月4日 下午4:32:34
 */
@Component
@Aspect
public class SecurityAspect {

    /** Log4j日志处理(@author: rico) */
    private static final Logger log = Logger.getLogger(SecurityAspect.class);

    private TokenManager tokenManager;

    @Resource(name = "tokenManager")
    public void setTokenManager(TokenManager tokenManager) {
        this.tokenManager = tokenManager;
    }

    @Around("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
    public Object execute(ProceedingJoinPoint pjp) throws Throwable {
        // 从切点上获取目标方法
        MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
        log.debug("methodSignature : " + methodSignature);
        Method method = methodSignature.getMethod();
        log.debug("Method : " + method.getName() + " : "
                + method.isAnnotationPresent(IgnoreSecurity.class));
        // 若目标方法忽略了安全性检查,则直接调用目标方法
        if (method.isAnnotationPresent(IgnoreSecurity.class)) {
            return pjp.proceed();
        }

        // 从 request header 中获取当前 token
        String token = WebContextUtil.getRequest().getHeader(
                Constants.DEFAULT_TOKEN_NAME);
        // 检查 token 有效性
        if (!tokenManager.checkToken(token)) {
            String message = String.format("token [%s] is invalid", token);
            log.debug("message : " + message);
            throw new TokenException(message);
        }
        // 调用目标方法
        return pjp.proceed();
    }
}

  若要使 SecurityAspect 生效,则需要在 SpringMVC 配置文件中添加如下 Spring 配置:

    <!-- 启用注解扫描,并定义组件查找规则 ,mvc层只负责扫描@Controller、@ControllerAdvice -->
    <!-- base-package 如果多个,用“,”分隔 -->
    <context:component-scan base-package="cn.edu.tju.rico"
        use-default-filters="false">
        <!-- 扫描 @Controller -->
        <context:include-filter type="annotation"
            expression="org.springframework.stereotype.Controller" />
        <!-- 控制器增强,使一个Contoller成为全局的异常处理类,类中用@ExceptionHandler方法注解的方法可以处理所有Controller发生的异常 -->
        <context:include-filter type="annotation"
            expression="org.springframework.web.bind.annotation.ControllerAdvice" />
    </context:component-scan>

    <!-- 支持Controller的AOP代理 -->
    <aop:aspectj-autoproxy />

最后,别忘了在 web.xml 中添加允许的 X-Token 响应头,配置如下:

<init-param>  
    <param-name>allowHeaders</param-name>  
    <param-value>Content-Type,X-Token</param-value>  
</init-param>  

四. 关于 Demo 部署的若干建议

  本项目是一个使用 Maven 进行构建的项目,关于 Maven 的了解、使用推荐大家看孤傲苍狼的《Maven 学习总结》一系列博客;

  关于 REST 服务的调试推荐大家使用 Postman 这款工具,请大家自行下载与安装,具体见《postman 的安装与使用(模拟请求)》


五. 总结

  本文从经典的 MVC 模式开始,对 MVC 模式是什么以及该模式存在的不足进行了简述。然后引出了如何对 MVC 模式的改良,让其转变为前后端分离架构,以及解释了为何要进行前后端分离。最后通过 REST 服务将前后端进行解耦,并提供了一款基于 Java 的 REST 框架的主要实现过程,尤其是需要注意的核心技术问题及其解决方案。希望本文对正在探索前后端分离的读者们有所帮助,期待与大家共同探讨。


六. 更多

  本项目的全部完整源码可以在我的 GitHub 上找到,项目名为 RestSpringMVCDemo,项目地址为:https://github.com/githubofrico/RestSpringMVCDemo

  更多关于 REST 的介绍,请移步我的博文《理解 RESTful 架构》

  更多关于 Java Web 方面的内容,请关注我的专栏 《Java Web 成神之路》。本专栏全面记录了 Java Web 开发相关知识,不但包括对 http, servlet,session 等基础知识的讲解,还包括对流行框架 (SSM,SpringMVC 等)、中间件(Redis 等) 等进阶知识的深入分析。笔者将持续跟进最新 Web 技术,期望对大家能够起到抛砖引玉的效果。