SpringMVC: 前端控制器

3,857 阅读16分钟

在SpringMVC中, 开发者不在需要关心Servlet等组件的机制. 只需要按照SpringMVC的约定(框架使用方法): 在控制器中添加方法并声明可以处理的请求, 将数据保存至Model中返回视图即可.

SpringMVC在J2EE上进行了封装, 让开发者的工作更专注于业务. 在J2EE中, 只有Filter和Servlet才可以处理请求, 由于Filter更偏向于进行验证处理(如请求的合法性等), 因此处理业务请求需要由Servlet完成. 在SpringMVC中开发者不需要实现Servlet就可以处理请求. 下面来分析一下SpringMVC的实现机制.

1. 什么是前端控制器

使用J2EE开发时, 每次请求都需要一个Servlet处理, 整个过程可以模拟为患者在医院就医的过程. 每个患者去医院相当于一次请求, 那么处理这次请求的Servlet就是一名医生.

去小型诊所就医时可以直接去医生办公室办理手续后就可以接受诊治. 每个医生都了解如何给患者办理手续. 但对于大型医院来说, 由于患者和科室较多, 让每个医生学习这些非专业的流程是比较浪费资源的. 大型医院会设置前台, 由前台负责给患者办理手续, 办理完成后根据患者的病情交由不同学科的医生进行诊治. 当有医生休息, 调岗, 离职或新加入时都需要按照统一的格式填写相应的变动信息. 前台定期收集整理, 保证能够准确的了解当日的医生出诊信息. 同时, 将相同学科的医生安排在同一办公室, 将出诊医生安排在相对集中的办公区, 这样既方便前台搜集医生出诊信息, 也便于对医生的管理.

所有患者来医院就医时都由前台统一接待并办理手续, 根据收集的医生出诊信息引领至对应的医生就行诊治. 通过前台实现了对患者就医流程的统一管理.

SpringMVC就是按照上述思路进行处理的, 他也有这样一个前台, 叫做前端控制器.

2. 前端控制器的处理流程

上述文案转换为SpringMVC描述:

每个控制器方法(医生)中声明可以处理的请求(填写可以诊治的病). 前端控制器(前台)在每日上班(每次项目启动)时, 去医生办公区(控制器所在目录)的每个科室(控制器)收集(加载)各个医生(控制器方法)可以诊治的病(处理的请求), 汇总并整理成文档(方法与URL映射Mapping). 当患者(客户端)来就医(发送请求)时, 由前台接待(前端控制器处理所有请求), 前台根据患者的病情(访问的请求URL)从整理的文档(Mapping)中找到可以诊治该病的医生(控制器方法), 并交由(分发)相应的医生进行诊治(执行业务逻辑).

由此可见, 前端控制器是一个负责处理所有请求的Servlet.

3. 前端控制器的实现

上述已经说明了SpringMVC前端控制器的实现原理, 下面通过代码实现一个前端控制器.

3.1. 配置控制器目录(指定医生办公区):

大规模的系统中可能有成百上千个类, 如果前端控制器在加载控制器时扫描所有的类势必会严重影响加载速度, 因此我们应当告知前端控制器需要被扫描的控制器所在的具体目录, 也就是说需要前端控制器配置参数. 这样前端控制器在扫描时只需要遍历指定目录下的类即可.

常见的方式是在定义前端控制器(web.xml配置Servlet)时配置初始化参数. 但随着框架功能不断的增加, 前端控制器的配置项会越来越多, 这种方式并不灵活. 因此需要采用一种更加灵活的方式: 通过XML配置, 在前端控制器加载时通过读取XML配置文件并解析获取控制器所在目录.

配置控制器目录的XML格式如下: controller节点为控制器相关配置, package属性为控制器所在目录.

<mvc>
    <controller package="com.atd681.xc.ssm.controller" />
</mvc>

3.2. URL映射注解(填写可以诊治的病的表格)

定义控制器方法设置可以处理请求的注解.

@Documented
@Target(ElementType.METHOD)
@Inherited
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {

    // 请求URL
    String url();

    // HTTP方法
    String method() default "GET";

}

控制器方法统一使用注解声明可以处理的请求.

public class UserController {

    // 处理"/list"请求
    @RequestMapping(url = "/list")
    public String userList() {
        return "";
    }

    // 处理"/detail"请求
    @RequestMapping(url = "/detail")
    public String userDetail() {
        return "";
    }

}

3.3. 实现前端控制器(医院前台)

3.3.1. 项目启动时(前台每日上班)加载配置文件

J2EE中规定, Servlet在被加载时会执行init方法, 因此我们可以把加载控制器的过程写在init方法中.

根据约定优于配置的原则: 我们约定好配置文件的路径在classpath下, 名称为mvc.xml, 有些场景下用户需要自定义配置文件的路径和名称, 因此我们也需要支持用户自定义, 自定义配置文件路径和名称时在web.xml中通过名为configLocation的参数传入前端控制器.

/**
 * 前端控制器(负责处理所有请求)
 */
public class DispatcherServlet extends HttpServlet {

    // 默认MVC配置文件路径
    private static final String DEFAULT_CONFIG_LOCATION = "mvc.xml";

    /**
     * 初始化Servlet. 容器初始化Servlet时调用, 加载配置文件初始化MVC相关组件(控制器,视图解析器等)
     */
    @Override
    public void init() throws ServletException {

        // 获取用户自定义的配置文件路径
        String configLocation = getInitParameter("configLocation");

        // 未定义配置文件路径, 使用默认配置文件路径
        if (configLocation == null || "".equals(configLocation)) {
            configLocation = DEFAULT_CONFIG_LOCATION;
        }

        try {

            // 开始加载配置文件(JDom解析XML)
            String classpath = Thread.currentThread().getContextClassLoader().getResource("").getPath();
            Document doc = new SAXBuilder().build(new File(classpath, configLocation));

            // 解析配置文件中控制器的配置
            initController(doc);

        } catch (Exception e) {
            throw new ServletException("加载配置文件错误", e);
        }

    }
	
}

3.3.2. 加载控制器(去办公区各科室搜集医生可以诊治的病的表格)

在前端控制器中定义全局变量, 用来保存控制器方法与URL的映射关系, 以下简称urlMapping.

// URL映射MAP(K:URL, V:对应的控制器方法)
private static Map<String, Method> urlMappings = new HashMap<String, Method>();;

在配置文件中获取控制器目录并遍历该目录, 获取每个控制器的文件名称. 通过JAVA反射分别加载控制器, 将每个方法及对应的URL保存至映射urlMapping中.

/**
 * 解析配置文件中的控制器配置
 * 
 * @param doc XML配置文件
 * @throws Exception
 */
@SuppressWarnings("unchecked")
private void initController(Document doc) throws Exception {

    // 配置格式:<controller package="com.atd681.xc.ssm.controller"/>
    // package为控制器所在目录, 模拟SpringMVC配置文件中的控制器包扫描
    List<Element> controllerEle = doc.getRootElement().getChildren("controller");
    if (controllerEle == null) {
        throw new Exception("请配置Controller节点.");
    }

    // 获取配置文件中的控制器所在目录
    String controllerPackage = controllerEle.get(0).getAttributeValue("package");
    if (controllerPackage == null) {
        throw new Exception("Controller的package属性必须设置.");
    }

    // 获取控制器目录的在磁盘中的绝对路径(D:\atd681-xc-ssm\com\atd681\controller)
    // Java目录分隔符需转换为文件系统格式(com.atd681 -> com/atd681)
    String controllerDir = controllerPackage.replaceAll("\\.", "/");
    String controllerPath = getClass().getClassLoader().getResource(controllerDir).getPath();

    // 遍历控制器目录下的所有CLASS
    for (File controller : new File(controllerPath).listFiles()) {

        String className = controller.getName().replaceAll("\\.class", ""); // 控制器类名称
        Class<?> clazz = Class.forName(controllerPackage + "." + className); // 加载控制器类

        // 遍历控制器类的所有方法,将每个方法和处理的URL做映射
        for (Method method : clazz.getMethods()) {

            // 只处理有@RequestMapping注解的方法
            if (!method.isAnnotationPresent(RequestMapping.class)) {
                continue;
            }

            RequestMapping rm = method.getAnnotation(RequestMapping.class);

            // 同一URL可能以GET或POST提交, URL和HTTP方法(GET/POST)才能确定是相同的请求
            // 将URL和HTTP方法作为KEY, 使用统一方法生成KEY便于在分发时准确的获取对应的方法
            String urlKey = wrapperKey(rm.url(), rm.method());

            // 当多个方法同时声明了相同的请求时, 在前端控制器分发时无法准确的找到对应方法
            if (urlMappings.containsKey(urlKey)) {
                throw new Exception("URL不能重复, URL: " + rm.url());
            }

            // 保存URL及对应的方法
            urlMappings.put(urlKey, method);

        }

    }

}
  • 多个方法配置了处理相同的URL后, 前端控制器在收到请求进行分发时将无法分辨本次请求应该由哪个方法处理, 因此不能有两个以上的方法声明处理同一URL, 加载控制器时需要进行URL重复性验证.

  • HTTP支持使用GET/POST等多种方式请求同一URL. 例: GET方式请求/add代表访问添加页, POST方式请求/add代表提交数据. 由于两次请求业务不同, 需要有两个方法分别处理. 因此需要有两个方法配置处理/add请求, 用HTTP Method区分(GET&POST). 在设置控制的方法urlMapping时需要使用URL+HTTP Method作为KEY. 在请求分发时也应该根据URL+HTTP Method做为KEY找到对应处理方法.

封装统一的规则生成urlMapping的KEY, 在设置urlMapping和分发时使用相同的KEY.

/**
 * 封装URL映射的KEY,在加添加映射和分发时使用相同的KEY
 * 
 * @param url
 * @param method
 * @return url|GET
 */
private String wrapperKey(String url, String method) {
    return url.toLowerCase() + "|" + method.toUpperCase();
}

3.3.3. 配置前端控制器

web.xml中配置前端控制器处理所有请求, 同时指定配置文件路径.

<servlet>
    <servlet-name>mvc</servlet-name>
    <servlet-class>com.atd681.xc.ssm.framework.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>configLocation</param-name>
        <param-value>/com/atd681/xc/ssm/framework/mvc.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
</servlet>

<servlet-mapping>
    <servlet-name>mvc</servlet-name>
    <url-pattern>/</url-pattern>
</servlet-mapping>

至此, 项目启动时会加载前端控制器, 加载时会将所有处理的方法和处理URL一一映射后保存.

3.4. 请求分发

J2EE中规定, Servlet处理请求时会执行service方法. 因此分发的逻辑需要写到service方法中. 在收到请求时根据请求的URL从urlMapping中找到对应的方法, 通过JAVA反射动态调用方法即可.

通常, 项目中的控制器会调用业务逻辑层及DAO或其他接口, 调用过程中难免会出现未知的错误异常, 如果程序中没有处理异常信息, 那么这些异常将会返回至前端控制器中, 因此需要捕获这些异常, 便于用户统一处理. 也叫做全局异常处理机制.

定义一个doService方法, 在该方法中执行分发的逻辑. 在service方法中调用doService执行分发并捕获其抛出的所有异常即可实现对异常的统一处理.

/**
 * 处理所有请求
 */
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

    try {
        doService(req, resp);
    }
    catch (Exception e) {
        // 可以在这里捕获异常进行全局处理
        // 模拟Spring全局异常处理
        e.printStackTrace();
    }

}

请求分发在doService方法中.

/**
 * 根据请求URL分发至控制器
 */
private void doService(HttpServletRequest req, HttpServletResponse resp) throws Exception {

    // 获取当前请求对应的方法名称
    // urlMappings保存每个URL对应的处理方法, 详见init方法
    Method method = urlMappings.get(getMappingKey(req));

    // 未找到方法处理时跳转至全局错误页
    // 此处忽略该过程直接跑出异常
    if (method == null) {
        throw new RuntimeException("没有找到处理该请求的方法");
    }

    // 实例化控制器
    Object classInstance = method.getDeclaringClass().newInstance();
    // 通过Java反射调用控制器方法
    method.invoke(classInstance, new Object[] {});

}
  • 分发时需要根据请求的URL及HTTP Method(GET&POST)作为KEY从urlMapping中找到对应的处理方法. 生成KEY的规则需要和设置urlMapping时生成的KEY的规则保持一致(统一调用wrapperKey方法).
  • 由于WEB应用服务器配置不同, 有些项目访问时需要在URL中加入项目名称. 当访问的URL中含有项目名称时, 无法找到对应的处理方法(urlMapping的URL不含有项目名称). 此时需要使用去除项目名称后的URL.
/**
 * 根据Request取得访问地址对应的处理方法KEY
 * 
 * <pre>
 * 例: 请求路径/list, get请求. 对应的key为"/list|get"
 * 如果请求路径中含有项目名称,去掉项目名称, 例请求为:/demo/list,转换为/list
 * </pre>
 * 
 * @param req
 * @return
 */
private String getMappingKey(HttpServletRequest req) {
    
    String httpMethod = req.getMethod().toUpperCase(); // HTTP Method(GET&POST)
    String httpUrl = req.getRequestURI().toLowerCase(); // 请求的URL

    // 由于WEB服务器配置不同, 有些项目访问时需要在URL中加入项目名称
    // 如果访问的URL中含有项目名称,将项目名称从URL中去除
    if (httpUrl.startsWith(req.getContextPath())) {
        httpUrl = httpUrl.replaceFirst(req.getContextPath(), "");
    }

    // 生成KEY的规则应和加载控制器时生成KEY的规则相同
    return wrapperKey(httpUrl, httpMethod);
    
}

至此, 我们已经实现了前端控制器的分发机制, 当有请求到达时前端控制器会将请求分发至对应的控制器方法处理.

3.5. 请求参数绑定:

上例的控制器方法中没有任何参数, 很多场景下控制器方法中需要参数(Request, Response, Model及请求参数等), SpringMVC提供了灵活的参数绑定机制.

SpringMVC可以将请求中传递的参数绑定至控制器对应方法的参数中, 参数可以是基本数据类型, 也可以是自定义的Javabean. 以下面的URL为例, 请求时携带了3个参数.

http://localhost/list?userName=zhangsan&age=30&gender=M

控制器中使用3个参数来接收, 并且参数的位置可以任意

@RequestMapping("/list")
public String list(String userName, Integer age, String gender) {
}

@RequestMapping("/list")
public String list(Integer age, String gender, String userName) {
}

当参数较多时可以定义一个Javabean来接收

public class User {

    private String userName;
    private Integer age;
    private String gender;

    // Getter&Setter
    
}
@RequestMapping("/list")
public String list(User user) {
    
}

如果你需要Request对象, 只需要在控制器方法的参数中添加即可. 并且参数的顺序仍然没有限制.

@RequestMapping("/list")
public String list(HttpServletRequest req, User user) {
}
@RequestMapping("/list")
public String list(User user, HttpServletRequest req) {
}

下面分析一下SpringMVC如何做到如此灵活的参数绑定. 控制器方法中的参数可以分为几类:

  • 请求中自带的对象: 例如Request, Response, Session等.
  • 框架自定义的对象: 例如保存数据的ModelMap.
  • 接收请求参数的模型: 形式各样, 可以使用多个基本类型的参数或者Javabean接收.

前端控制器在请求分发时已经可以获取到对应的控制器方法, 同样可以获取到方法的各个参数的类型. 每个参数都根据其类型, 找到对应的对象赋值即可. 我们需要根据参数类型判断参数属于哪一类:

  • 请求中自带的对象: 将J2EE中对象的对象赋值即可.
  • 框架自定义的对象: 实例化相应对象后进行赋值.
  • 非上述两类的参数全部认为是用来接收请求参数的.

基于上面的分析来实现对控制器方法的参数进行动态绑定.

// 自定义的ModelMap, 保存在此的数据便于在视图中使用
Map<String, Object> model = new HashMap<String, Object>();

// 处理请求的控制器方法参数类型数组
Class<?>[] classes = method.getParameterTypes();
// JAVA反射调用方法时需要传入参数的实例化对象数组
Object[] methodParams = new Object[classes.length];

// 遍历控制器方法的某个参数, 根据参数类型设置相应参数或其实例
// 控制器方法的参数位置变化时, 此处设置的参数的实例化对象数组位置也随之变化
for (int i = 0; i < classes.length; i++) {
    Class<?> paramClass = classes[i];

    if (paramClass.isAssignableFrom(HttpServletRequest.class)) {
        methodParams[i] = req; // 将J2EE的Request对象设置到参数
    }
    else if (paramClass.isAssignableFrom(HttpSession.class)) {
        methodParams[i] = req.getSession(); // 将J2EE的会话对象设置到参数
    }
    else if (paramClass.isAssignableFrom(Map.class)) {
        methodParams[i] = model; // 将自定义保存数据的Map设置到参数
    }
    else {
        // 其余的类型的参数为接收请求参数, 实例化该参数并将设置请求数据
        methodParams[i] = wrapperBean(req, paramClass);
    }

}

如果请求中的参数名称和Javabean的属性名称一致时, 通过JAVA反射机制将该参数的数据设置到Javabean的属性中.

/**
 * 将请求中的参数分别设置到Javabean对应的属性中
 */
@SuppressWarnings({ "unchecked" })
private <T> T wrapperBean(HttpServletRequest req, Class<?> bean) {

    T beanInstance = null;

    // 实例化处理方法中从参数bean
    try {
        beanInstance = (T) bean.newInstance();
    }
    catch (Exception e) {
        throw new RuntimeException("请求参数映射出现错误", e);
    }

    // 请求中所有参数
    Set<String> keySet = req.getParameterMap().keySet();

    // 遍历请求中的参数将值设置到Javabean对应的属性中
    for (String reqParam : keySet) {

        try {
            Class<?> fieldType = bean.getDeclaredField(reqParam).getType(); // Bean中参数类型
            Object fieldValue = getRequestValue(req, reqParam, fieldType); // Bean中参数在请求中的值
            String fieldSetter = "set" +  reqParam.substring(0, 1).toUpperCase() + reqParam.substring(1); // Bean中参数的set方法

            // 使用属性的Setter方法将请求中的值设置到属性中
            bean.getMethod(fieldSetter, fieldType).invoke(beanInstance, fieldValue);

        }
        catch (Exception e) {
            // BEAN中没有请求中对应的参数属性时继续下一个参数处理
            // JAVA反射未找到类的属性时会抛出异常终止循环
        }

    }

    return beanInstance;

}

请求中取出的参数数据都是字符串类型的, 需要转换成Javabean相应属性的类型.

/**
 * 将request属性值转换为对应JavaBean属性类型
 */
private Object getRequestValue(HttpServletRequest req, String name, Class<?> type) {
    String value = req.getParameterValues(name)[0];

    if (Integer.class.isAssignableFrom(type)) {
        return Integer.valueOf(value);
    }
    else if (Long.class.isAssignableFrom(type)) {
        return Long.valueOf(value);
    }
    else if (BigDecimal.class.isAssignableFrom(type)) {
        return BigDecimal.valueOf(Long.valueOf(value));
    }
    else if (Date.class.isAssignableFrom(type)) {
        try {
            return new SimpleDateFormat().parse(value);
        }
        catch (ParseException e) {
            throw new RuntimeException("参数[name]格式不正确");
        }
    }
    return value;
}

4. 总结

前端控制器编写完成, 运行项目:

  • 请求/list: 执行UserController.userList
  • 请求/detail: 执行UserController.userDetail
  • 请求list?userName=zhangsan&age=30&gender=M, 会将请求中的三个参数设置到UserController.userList方法的参数中.

前端控制器的核心思想在于分发机制与参数绑定. 面向开发者屏蔽了J2EE的冗余代码, 提升了开发效率. 使得开发者可以更专注于业务开发. 在实现前端控制器的过程中大量运用了JAVA反射机制来实现动态处理. 对JAVA反射不太熟悉的开发者需要巩固一下相关知识点.

下一篇将分析SpringMVC基于策略模式的视图解析机制.