整体代码结构

├─java
│  └─com
│      └─github
│          └─houbb
│              └─mvc
│                  │  package-info.java
│                  │
│                  ├─annotation
│                  │      Controller.java
│                  │      RequestMapping.java
│                  │      RequestParam.java
│                  │
│                  ├─controller
│                  │      IndexController.java
│                  │
│                  ├─exception
│                  │      MvcRuntimeException.java
│                  │
│                  └─servlet
│                          DispatchServlet.java
│
├─resources
│      application.properties
│
└─webapp
    └─WEB-INF
            web.xml

pom.xml 依赖

引入 servlet-api 相关的包,用户开发。

<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>3.0.1</version>
    <scope>provided</scope>
</dependency>

注解

因为本次主要实现 dispatch 分发这个功能,所有的 ioc 并不是我们实现的重点。

关于 ioc,可以参见 spring ioc 实现

所以只实现了以下几个注解:

功能和 spring 保持一致,此处不再赘述。

@Controller

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Controller {

    /**
     * 对象的别名
     * @return 路径
     * @since 0.0.1
     */
    String value() default "";

}

@RequestMapping

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RequestMapping {

    /**
     * 映射 url 路徑
     * @return 路径
     * @since 0.0.1
     */
    String value();

}

@RequestParam

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface RequestParam {

    /**
     * 参数别称
     * @return 路径
     * @since 0.0.1
     */
    String value();

}

属性配置文件

web.xml

首先看一下 web 程序最核心的文件 web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://java.sun.com/xml/ns/javaee"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
         version="3.0">
    <servlet>
        <servlet-name>SpringMvc</servlet-name>
        <servlet-class>com.github.houbb.mvc.servlet.DispatchServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>application.properties</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>SpringMvc</servlet-name>
        <url-pattern>/*</url-pattern>
    </servlet-mapping>

</web-app>

配置文件

这里主要有一个配置文件 application.properties

basePackage=com.github.houbb.mvc

spring-mvc 中一般是一个 app.xml,其他指定的基本也是扫描包等基础信息。

分发 Servlet

还有一个 DispatchServlet,用于处理各种请求。

也是本次实现的核心内容。

DispatchServlet

继承自 HttpServlet

public class DispatchServlet extends HttpServlet {

基本属性

后面实现会用到。

/**
 * 实例 Map
 *
 * @since 0.0.1
 */
private Map<String, Object> controllerInstanceMap = new HashMap<>();
/**
 * 请求方法 map
 *
 * @since 0.0.1
 */
private Map<String, Method> requestMethodMap = new HashMap<>();
/**
 * 配置文件
 *
 * @since 0.0.1
 */
private Properties properties = new Properties();

重载父类方法

@Override
public void init(ServletConfig config) throws ServletException {}

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {}

实现 init 方法

init 方法主要负责解析 web.xml 中的配置,然后进行相关的初始化。

@Override
public void init(ServletConfig config) throws ServletException {
    super.init();
    //1. 加载配置文件信息
    initConfig(config);

    //2. 根据配置信息进行相关处理
    String basePackage = properties.getProperty("basePackage");
    initInstance(basePackage);

    //3. 初始化映射关系
    initRequestMappingMap();
}

我们分开一个个看。

1. 加载配置文件信息

这个就是根据 web.xml 中的配置,初始化一下配置文件信息。

/**
 * 初始化配置信息
 * (1)spring-mvc 一般是指定一个 xml 文件。
 * 至于各种 classpath,我们也可以对其进行特殊处理,暂时简单化。
 *
 * @param config 配置信息
 * @since 0.0.1
 */
private void initConfig(final ServletConfig config) {
    final String configPath = config.getInitParameter("contextConfigLocation");
    //把web.xml中的contextConfigLocation对应value值的文件加载到流里面
    try (InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream(configPath)) {
        properties.load(resourceAsStream);
    } catch (IOException e) {
        throw new MvcRuntimeException(e);
    }
}
  • MvcRuntimeException 异常类

一个简单的自定义运行时异常类:

public class MvcRuntimeException extends RuntimeException {
    //....
}

2. 根据配置信息进行初始化

根据指定的扫描包,我们初始化所有指定 @Controller 注解的类。

当然这里最简单的场景,还有很多复杂的情况,比如 jar 包中引用等等,可以参考 spring ioc 实现

/**
 * 初始化对象实例
 *
 * @param basePackage 基本包
 * @since 0.0.1
 */
private void initInstance(final String basePackage) {
    String path = basePackage.replaceAll("\\.", "/");
    URL url = this.getClass().getClassLoader().getResource(path);
    if (null == url) {
        throw new MvcRuntimeException("base package can't loaded!");
    }
    File dir = new File(url.getFile());
    File[] files = dir.listFiles();
    if (files != null) {
        for (File file : files) {
            if (file.isDirectory()) {
                //递归读取包
                initInstance(basePackage + "." + file.getName());
            } else {
                String className = basePackage + "." + file.getName().replace(".class", "");
                //实例化处理
                try {
                    Class clazz = Class.forName(className);
                    if (clazz.isAnnotationPresent(Controller.class)) {
                        Object instance = clazz.newInstance();
                        controllerInstanceMap.put(className, instance);
                    }
                } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {
                    throw new MvcRuntimeException(e);
                }
            }
        }
    }
}

3. 初始化映射关系

这个主要是解析 @RequestMapping 对应的 url 信息。

/**
 * 初始化 {@link com.github.houbb.mvc.annotation.RequestMapping} 的方法映射
 *
 * @since 0.0.1
 */
private void initRequestMappingMap() {
    for (Map.Entry<String, Object> entry : controllerInstanceMap.entrySet()) {
        Object instance = entry.getValue();
        String prefix = "/";
        final Class controllerClass = instance.getClass();
        if (controllerClass.isAnnotationPresent(RequestMapping.class)) {
            RequestMapping requestMapping = (RequestMapping) controllerClass.getAnnotation(RequestMapping.class);
            prefix = requestMapping.value();
        }
        // 暂时只处理当前类的方法
        Method[] methods = controllerClass.getDeclaredMethods();
        // 为了简单,只有注解处理的方法才被作为映射。
        // 当然这里可以加一些限制,比如只处理 public 方法等。
        // 可以加一些严格的判重,暂不处理。
        for (Method method : methods) {
            if (method.isAnnotationPresent(RequestMapping.class)) {
                RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
                String methodUrl = requestMapping.value();
                String fullUrl = prefix + methodUrl;
                requestMethodMap.put(fullUrl, method);
            }
        }
    }
}

请求分发

请求主要分为 get/post 两种:

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    doDispatch(req, resp);
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    doDispatch(req, resp);
}

这里我们统一调用了消息分发接口。

doDispatch() 核心实现

/**
 * 执行消息的分发
 *
 * @param req  请求
 * @param resp 响应
 * @since 0.0.1
 */
private void doDispatch(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    try {
        if (requestMethodMap.isEmpty()) {
            return;
        }
        // 请求信息 url 的处理
        String requestUrl = req.getRequestURI();
        System.out.println("requestUrl====" + requestUrl);
        String contextPath = req.getContextPath();
        // 直接替换掉 contextPath,感觉这样不太好。可以选择去掉这种开头。
        requestUrl = requestUrl.replace(contextPath, "");
        //404
        // 这里应该有各种响应码的处理,暂时简单点。
        if (!requestMethodMap.keySet().contains(requestUrl)) {
            resp.getWriter().write("404 for request " + requestUrl);
            return;
        }
        Method method = requestMethodMap.get(requestUrl);
        // 参数,这里其实要处理各种基本类型等
        // 还有各种信息的类型转换
        Class<?>[] paramTypes = method.getParameterTypes();
        // 参数为空的情况
        final Object instance = controllerInstanceMap.get(method.getDeclaringClass().getName());
        if (paramTypes.length <= 0) {
            method.invoke(instance);
        }
        // 这里实际上需要对各种类型的参数加以转换,目前只支持 String
        Object[] paramValues = new Object[paramTypes.length];
        List<String> paramNames = getParamNames(method);
        Map<String, String[]> requestParamMap = req.getParameterMap();
        for (int i = 0; i < paramTypes.length; i++) {
            // 类的简称
            String typeName = paramTypes[i].getSimpleName();
            if ("HttpServletRequest".equals(typeName)) {
                //参数类型已明确,这边强转类型
                paramValues[i] = req;
            } else if ("HttpServletResponse".equals(typeName)) {
                paramValues[i] = resp;
            } else if("String".endsWith(typeName)) {
                // 为什么是数组 https://www.cnblogs.com/wscit/p/5800147.html
                String paramName = paramNames.get(i);
                String[] strings = requestParamMap.get(paramName);
                // 简单处理,只取第一个。
                paramValues[i] = strings[0];
            } else {
                throw new MvcRuntimeException("Not support type for " + typeName);
            }
        }
        // 反射调用
        method.invoke(instance, paramValues);
    } catch (IOException | IllegalAccessException | InvocationTargetException e) {
        resp.getWriter().write("500");
    }
}

获取参数名称的方法

/**
 * 获取参数的名称
 * (1)后期可以结合 asm,对于没有注解的也可以处理。
 *
 * @param method 方法
 * @return 结果
 * @since 0.0.1
 */
private List<String> getParamNames(final Method method) {
    Annotation[][] paramAnnos = method.getParameterAnnotations();
    List<String> paramNames = new ArrayList<>(paramAnnos.length);
    final int paramSize = paramAnnos.length;
    for(int i = 0; i < paramSize; i++) {
        Annotation[] annotations = paramAnnos[i];
        String paramName = getParamName(i, annotations);
        paramNames.add(paramName);
    }
    return paramNames;
}

/**
 * 获取参数名称
 * @param index 参数的下标
 * @param annotations 注解信息
 * @return 参数名称
 * @since 0.0.1
 */
private static String getParamName(final int index, final Annotation[] annotations) {
    final String defaultName = "arg"+index;
    if(annotations == null) {
        return defaultName;
    }
    for(Annotation annotation : annotations) {
        if(annotation.annotationType().equals(RequestParam.class)) {
            RequestParam param = (RequestParam)annotation;
            return param.value();
        }
    }
    return defaultName;
}

代码自测

IndexController

我们定义个简单的控制类

package com.github.houbb.mvc.controller;

import com.github.houbb.mvc.annotation.Controller;
import com.github.houbb.mvc.annotation.RequestMapping;
import com.github.houbb.mvc.annotation.RequestParam;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * index 控制器
 *
 * @author binbin.hou
 * @since 1.0.0
 */
@Controller
@RequestMapping("/index")
public class IndexController {

    @RequestMapping("/print")
    public void print(@RequestParam("param") String param) {
        System.out.println(param);
    }

    @RequestMapping("/echo")
    public void echo(HttpServletRequest request,
                     HttpServletResponse response,
                     @RequestParam("param") String param) {
        try {
            response.getWriter().write("Echo :" + param);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

项目启动

为了方便,此处直接使用 tomcat maven 插件。

<plugin>
    <groupId>org.apache.tomcat.maven</groupId>
    <artifactId>tomcat7-maven-plugin</artifactId>
    <version>2.2</version>
    <configuration>
        <port>8080</port>
        <path>/</path>
        <uriEncoding>${project.build.sourceEncoding}</uriEncoding>
    </configuration>
</plugin>

页面访问

  • 404

tomcat7 启动以后,直接页面访问 http://localhost:8080/

默认页面显示

404 for request /

因为我们没有处理默认的 index 页面。

  • print

页面访问 http://localhost:8080/index/print?param=hello

会在控台打印

hello
  • echo

页面访问 http://localhost:8080/index/echo?param=hello

会在页面打印

Echo :hello

开源地址

完整代码地址 mvc