分类
Java

自己实现一个 Mini MVC 框架

上回说到自己实现一个 Mini IoC 容器,有了一个依赖注入的容器,如果不尝试用于 Web 应用那岂不是没什么用。因此我们可以试着在 Mini IoC 的基础上,实现一个 Spring MVC Style 的简单 MVC 框架。

同样,先给出我们希望怎么使用这个的框架的代码,然后再一步步实现我们想要的写法:

用法

@Controller
public class UserController {
    private static final Logger LOGGER = LoggerFactory.getLogger(UserController.class);
    @Resource
    private UserService userService;

    @URL(value = {"/", "/index"}, method = {HttpMethod.GET, HttpMethod.POST})
    public String list(Map<String, Object> map) {
        map.put("userList", userService.listUsers());
        return "list";
    }

    @URL("home")
    public String home() {
        return "forward:/";
    }

    @URL(value = "/add", method = HttpMethod.GET)
    public String addPage() {
        return "add";
    }

    @URL(value = "/add", method = HttpMethod.POST)
    public String addUser(Map<String, String> map, @Param("name") String name) {
        //String name = map.get("name");  // map 里有所有参数 或者从方法中标有 @Param 的参数获取
        String email = map.get("email");
        String note = map.get("note");
        if (name == null || email == null) {
            map.put("error", "用户名及电子邮件是必填项");  // map 还充当返回结果的 Model 的角色
            return "add";
        }
        User user = new User().setName(name).setEmail(email).setNote(note);
        userService.saveUser(user);
        return "redirect:/";
    }
     // ... 
}

容器启动

首先,我们需要在 Web 应用启动时初始化 IoC 容器,然后扫描所有的 Bean 并注册;然后保存 Controller 中 方法与其相应的 URL 映射关系;把 IoC 容器放在 Web 的 Application 域中,方便后续获取。这个部分需要在启动时自动进行,因此我们可以选择实现 ServletContextListener 接口。

public class ContextLoaderListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        init(sce);
        mapping(sce);
    }
    private void init(ServletContextEvent sce) {
      // 初始化 IoC 容器,使用上篇文章的 Mini IoC 扫描所有 Bean 即可
    }
    private void mapping(ServletContextEvent sce) {
      // 处理所有 Controller, 扫描其中的 Method,  如果有 @URL 注解说明是能够处理请求的方法,保存映射关系,在分发请求时才能知道该分发给谁
    }
}

Dispatch

另一件需要做的事就是处理请求的分发,把 URL 请求分发到我们写的 Controller 的方法上。这个参照 Spring MVC, 我们也叫做 DispatcherServlet.
由于我们需要处理请求的分发,因此不是简单继承 HttpServlet 然后重写 doGet(), doPost() 等方法,而应该重写 service 方法,根据我们的规则进行处理:

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String reqMethod = req.getMethod();
        String uri = req.getRequestURI();
        LOGGER.debug("{} {}", reqMethod, uri);
        ControllerAndMethod controllerAndMethod = findControllerAndMethod(uri, reqMethod);
        try{
            if (controllerAndMethod != null) {
                dispatch(req, resp, controllerAndMethod);
            } else {
                processNoMatch(req, resp);
            }
        }catch(Throwable t){/*...*/}
    }

其中 dispatch 方法是核心功能的实现,其实也很简单:

    private void dispatch(HttpServletRequest req, HttpServletResponse resp, ControllerAndMethod controllerAndMethod)
            throws Throwable {
        HttpRequestWithModelMap request = new HttpRequestWithModelMap(req);
        Object controller = controllerAndMethod.getController();
        Method method = controllerAndMethod.getMethod();
        Throwable exception = null;
        try {
            Object[] parameter = injectParameter(request, resp, method);
            if (!preHandle(request, resp, controller)) {
                return;
            }
            Object ret = method.invoke(controller, parameter);
            postHandle(request, resp, controller, ret);
            Map<String, Object> model = request.getMap();
            processInvokeResult(request, resp, model, ret, controllerAndMethod);
        } catch (Throwable e) {
            exception = e;// throw
        } finally {
            exception = afterCompletion(request, resp, controller, exception);
            if (exception != null) {
                throw exception;
            }
        }
    }

拦截器

可以看到,虽然简单,但我们还是支持了拦截器功能,拦截器接口和 Spring MVC 差不多:

public interface Interceptor extends Ordered {
    /**
     * 是否拦截当前URL
     */
    boolean accept(String uri);

    /**
     * 是否继续
     */
    boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object controller) throws Exception;

    void postHandle(HttpServletRequest request, HttpServletResponse response, Object controller, Object result)
            throws Exception;

    /**
     * @return null means the exception has been processed,
     * or you should return a exception to propagate it.
     * @throws Throwable will be logged but not propagate
     */
    Throwable afterCompletion(HttpServletRequest request, HttpServletResponse response, Object controller, Throwable e)
            throws Throwable;

}

为了减少配置,是的,暂时不打算支持 xml 配置,拦截器多了个 accept 方法,表示是否处理当前 URL,preHandle 返回 false 表示不继续处理请求,请求将不会打到 Controller 上。postHandle 在 Controller 方法执行后、处理执行结果前(即渲染页面前)调用。最后是 afterCompletion 在处理完结果后(已经渲染了页面)调用,同时担任异常处理的角色。 Spring MVC 配置文件中拦截器中是有顺序的,我们的接口也继承了一个 Ordered 接口表示是有序的:

public interface Ordered {
    Comparator<Ordered> DEFAULT_ORDERED_COMPARATOR = new Comparator<Ordered>() {
        @Override
        public int compare(Ordered o1, Ordered o2) {
            return o1.getOrder() - o2.getOrder();
        }
    };
    int getOrder();
}

默认排序器的实现是 getOrder 返回的数字越小越靠前。

拦截器只是一个相对不那么重要的部分,重要的部分是注入 Controller 方法的参数,以及如何处理 Controller 方法的返回值并渲染出页面来。

参数注入

injectParameter 方法将会自动注入参数给 Controller 的方法,因此我们可以在 Controller 中写:

    @URL("delete")
    public String delete(@Param("id") Long id) {
        if (userService.deleteById(id)) {
            LOGGER.debug("删除成功 用户ID: {}", id);
        } else {
            LOGGER.debug("删除失败 用户ID:{}", id);
        }
        return "redirect:/";
    }

当然,当参数太多时,可以使用 Map 全装起来~
所以 injectParameter 应当如何实现呢?

  • 如果参数类型是 HttpServletRequest 或 HttpServletResponse 直接将 request, response 传进去就行
  • 如果参数类型是 Map 那么新建一个 Map 并把 request 里的参数全放进去
  • 如果参数是 String 或基本类型及其封装类型,那么从 request 中获取相应的参数传进去
  • 是 Java 对象,通过反射获取对象的字段(或 set 方法),一个一个把 request 里相对应的参数设置到对象中 —— 这个嫌麻烦暂未实现

——参考: AsMVC: 一个简单的 MVC 框架的 Java 实现

视图处理

然后得到 Controller 方法的返回值后该怎么办呢?这里我又分为了两种情况,一种是直接返回 ResponseBody, 比如 json 或 xml;另一种是返回页面。不过,写着写着最后还是统一作为 View 看待,毕竟我们是 MVC 框架,Model 由用户定义,Controller 注解我们提供,也是用户在里面的方法写具体逻辑,View 就是视图,页面是视图, ResponseBody 也是视图。

public interface View extends Ordered {
    /**
     * @return true if has complete and success render the view. return false to try next view
     */
    boolean render(HttpServletRequest request, HttpServletResponse response,
            Map<String, Object> model, Object result, ControllerAndMethod controllerAndMethod) throws Exception;
}

通过抽象出接口,框架只需要面向接口编程而不需要关心实现,方便用户扩展,例如需要支持 Thymeleaf, Tiles, FreeMaker 等视图技术。

    protected void processInvokeResult(HttpServletRequest req, HttpServletResponse resp, Map<String, Object> model,
            Object result, ControllerAndMethod controllerAndMethod) throws Throwable {
        if (result instanceof String &&
                (((String) result).startsWith(Constants.FORWARD) || ((String) result).startsWith(Constants.REDIRECT))) {
            processRedirectOrForward(req, resp, model, (String) result, controllerAndMethod);
            return;
        }
        List<View> sortedViewList = new ArrayList<>(getContext().getBeans(View.class));
        Collections.sort(sortedViewList, Ordered.DEFAULT_ORDERED_COMPARATOR);
        boolean rendered = false;
        for (View view : sortedViewList) {
            rendered = view.render(req, resp, model, result, controllerAndMethod);
            if (rendered) {
                break;
            }
        }
        if (!rendered) {
            DEFAULT_VIEW.render(req, resp, model, result, controllerAndMethod);
        }
    }

因为可能有多种视图,所以 render 有返回值,返回 true 就代表这个页面已经渲染完毕,那就直接返回即可,否则尝试另外的视图,如果没有的话,使用默认的视图(JSP)
这样,整个分发流程就完成了,从请求到响应全部搞定。

无匹配处理

另一个场景就是当没有匹配的 Controller 时:

    protected void processNoMatch(HttpServletRequest req, HttpServletResponse resp) throws Throwable {
        @SuppressWarnings("unchecked")
        Set<String> mappedUrls = (Set<String>) getServletContext().getAttribute(Constants.MAPPED_URL_SET);
        String requestURI = req.getRequestURI();
        boolean containsURI = mappedUrls.contains(requestURI);
        if (!containsURI) {
            sendError404(req, resp);
            return;
        }
        String method = req.getMethod();
        switch (method) {
            case "HEAD":
                processHead(req, resp);
                break;
            case "OPTIONS":
                processOptions(req, resp);
                break;
            case "TRACE":
                super.doTrace(req, resp);
                break;
            case "GET":
            case "POST":
            case "PUT":
            case "PATCH":
            case "DELETE":
            default:
                sendError405(req, resp);
        }
    }

如果是压根就没有哪个方法映射了这个 URL 那么显然应该是 404,如果有一个方法是这个 URL 但请求方法不对,比如要求是 POST 的,但请求是 GET 的,那么返回 404 貌似不太符合语义,应该返回 405 才对。还有 HEAD, OPTIONS, TRACE 这三个特殊请求方法——要不是这里遇到了,我都不知道还有 OPTIONS 和 TRACE——默认是实现了 GET 就应当自动支持 HEAD, OPTIONS 会返回请求的 URL 支持哪些方法,而 TRACE 则会回显客户端的请求(通常这个请求方法会被 tomcat 禁止,如何开启可自行搜索)。

支持第三方框架

Jackson

这个其实也挺简单的,只需要实现一个 JsonView 把 invoke Controller method 的返回值转化为 json 输出到 Response 就可以了。

@Resource//没有引入jackson的话jsonBodyHandler就会new失败,这个类就不会加入容器
public class JsonBodyView implements View {
    private static final ResponseBodyHandler jsonBodyHandler = new JsonBodyHandler();
    @Override
    public boolean render(HttpServletRequest request, HttpServletResponse response,
            Map<String, Object> model, Object result, ControllerAndMethod controllerAndMethod) throws Exception {
        if (AnnotationUtil.getAnnotation(controllerAndMethod.getMethod(), JsonBody.class) == null) {
            return false;
        }
        jsonBodyHandler.handler(request, response, result);//if has exception, just throws out
        return true;
    }

    @Override
    public int getOrder() {return 0;}
}

Thymeleaf

视图默认是 JSP 的,我们也可以支持 Thymeleaf,

@Resource
public class ThymeleafView implements View {
    private TemplateEngine templateEngine;

    private void init(ServletContext servletContext) {
        ServletContextTemplateResolver resolver = new ServletContextTemplateResolver(servletContext);
        resolver.setTemplateMode(TemplateMode.HTML);
        String prefix = servletContext.getInitParameter(Constants.TH_VIEW_PREFIX);
        if (prefix == null) {
            prefix = servletContext.getInitParameter(Constants.VIEW_PREFIX_PARAM_NAME);
        }
        if (prefix == null) {
            prefix = "";// /WEB-INF/templates/
        }
        String suffix = servletContext.getInitParameter(Constants.TH_VIEW_SUFFIX);
        if (suffix == null) {
            suffix = servletContext.getInitParameter(Constants.VIEW_SUFFIX_PARAM_NAME);
        }
        if (suffix == null) {
            suffix = "";// .html
        }
        resolver.setPrefix(prefix);
        resolver.setSuffix(suffix);
        resolver.setCacheTTLMs(3600000L);
        resolver.setCacheable(true);
        resolver.setCharacterEncoding("UTF-8");
        templateEngine = new TemplateEngine();
        templateEngine.setTemplateResolver(resolver);
    }

    @Override
    public boolean render(HttpServletRequest request, HttpServletResponse response,
            Map<String, Object> model, Object result, ControllerAndMethod controllerAndMethod) throws Exception {
        if (templateEngine == null) {
            init(request.getServletContext());
        }
        if (AnnotationUtil.getAnnotation(controllerAndMethod.getMethod(), ResponseBody.class) != null
                || !(result instanceof String)) {
            return false;//不处理 ResponseBody
        }
        WebContext ctx = new WebContext(request, response, request.getServletContext(), request.getLocale(), model);
        templateEngine.process((String) result, ctx, response.getWriter());
        return true;
    }

    public TemplateEngine getTemplateEngine() {return templateEngine;}

    // allow user to custom settings
    public ThymeleafView setTemplateEngine(TemplateEngine templateEngine) {
        this.templateEngine = templateEngine;
        return this;
    }
}

这部分是参考 Thymeleaf 的文档写的,首先初始化一下引擎,然后 new 一个 WebContext, 把 Request, Response, Model 等放进去,最后调用引擎,把上下文传进入就可以让框架自行渲染出页面了。

MyBatis

MyBatis 的支持就略微麻烦一些了。首先的问题是 SqlSession 是线程不安全的,我们不能把一个 SqlSession 注入到容器中,再随时拿出用。其次,通常 Dao 层都是只写接口,不写实现,具体的实现是 SqlSession getMapper 时动态获取的代理对象,因此我们无法在初始容器之前扫描时拿到 Dao 接口的实例。
对于第一个问题,我们发现,MyBatis 自带了一个 SqlSessionManager 是线程安全的,这个管理器实现了 SqlSessionFactory, SqlSession 两个接口,内部使用动态代理和 ThreadLocal 实现的。动态代理代理的是 SqlSession, 获取 SqlSession 时(因为他继承了 SqlSessionFactory 所以可以获取 SqlSession)是从线程本地变量里拿的,这样每个线程的 SqlSession 其实是互不干扰的,所以是线程安全的。
第二个问题一度无解,受制于之前的实现,容器启动时分两步的:
第一步扫描类同时把所有有注解的类 newInstance 一下放到容器中,尽管此时这些类需要被注入的属性(如果有的话)都是空的;第二步是遍历容器里的对象,看哪些属性需要注入,就从容器中拿出相应类型的实例 set 进去。
但是现在第一步就进行不下去了,因为 Dao 接口我们无法 newInstance 而后面 Service 肯定会依赖 Dao. 因此不得不对容器进行一些改造。

这么来看,只能在扫描之前就把 SqlSessionManager 和所有的 Dao 接口的代理对象拿到注入容器。于是我们在 Mini-Ioc 中新增了一个 PreScan 的接口。

/**
 * 在容器扫描之前执行一些动作,如可以先将一部分 Bean 注册到容器中
 * 在你的项目中的 META-INF/services/com.youthlin.ioc.spi.IPreScanner
 * 写上实现类的全限定名可能会被加载(如 mini-mvc)
 * <p>
 * 创建: youthlin.chen
 * 时间: 2017-08-18 19:38.
 */
public interface IPreScanner {
    void preScan(Context context);
}

然后在容器构造时先执行 preScan 的动作。这样,我们就可以把 MyBatis 的这些构造 SqlSession, 获取 Mapper 代理对象的工作放在 PreScanner 里去做。为了改动小一些,做成了 ServiceLoader spi 接口的形式。这样可以在 Mini-MVC 里写 MyBatisPreScanner, 然后在 META-INF 里注册这个类,使用 ServiceLoader 就可以获取到这个 PreScanner 了。在 ContextLoaderListener 里构造容器时:

        ServiceLoader<IPreScanner> preScanners = ServiceLoader.load(IPreScanner.class);
        List<IPreScanner> preScannerList = new ArrayList<>();
        preScannerList.add((context) -> {context.registerBean(servletContext);});
        for (IPreScanner preScanner : preScanners) {
            preScannerList.add(preScanner);
        }
        CONTAINER = new ClasspathContext(preScannerList, scanPackages);

这样就会把下面这个 PreScannerForMyBatis 拿到放到 preScannerList 里:

public class PreScannerForMyBatis implements IPreScanner {
    private static final Logger LOGGER = LoggerFactory.getLogger(PreScannerForMyBatis.class);

    @Override
    public void preScan(Context context) {
        try {
            MapperScanner mapperScanner = new MapperScanner();
            ServletContext servletContext = context.getBean(ServletContext.class);
            if (servletContext != null) {
                setProperties(servletContext, mapperScanner);
            }
            mapperScanner.scan(context);
        } catch (Throwable e) {
            LOGGER.error("", e);
        }
    }

    private void setProperties(ServletContext servletContext, MapperScanner mapperScanner) {
        String configFile = servletContext.getInitParameter(Constants.MYBATIS_CONFIG_FILE);
        String scanAnnotation = servletContext.getInitParameter(Constants.MYBATIS_SCAN_ANNOTATION);
        String scanPackagesStr = servletContext.getInitParameter(Constants.MYBATIS_SCAN_PACKAGES);
        String initSql = servletContext.getInitParameter(Constants.MYBATIS_INIT_SQL);
        String initSqlFileName = servletContext.getInitParameter(Constants.MYBATIS_INIT_FILE);
         //...
    }
}

这里的 config-file initSql 等设置项都从 web.xml 里拿,然后再调用 MapperScanner 真正做 MyBatis 的初始化工作:

@Resource
public class MapperScanner {
    private static final Logger LOGGER = LoggerFactory.getLogger(MapperScanner.class);
    private Context context;
    private String scanAnnotation = Dao.class.getName();
    private String[] scanPackages = {""};
    private String configFile = "mybatis/config.xml";
    private String initSql;
    private String initSqlFile;
    private SqlSessionFactory factory;
    private SqlSessionManager manager;
    private Map<Class, Object> mappers = new ConcurrentHashMap<>();

    @SuppressWarnings("unchecked")
    public void scan(Context context) {
        this.context = context;
        Class<? extends Annotation> scanAnnotation;
        try {
            scanAnnotation = (Class<? extends Annotation>) Class.forName(this.scanAnnotation);
        } catch (ReflectiveOperationException e) {
            throw new IllegalArgumentException(e);
        }
        if (!scanAnnotation.isAnnotation()) {
            throw new IllegalArgumentException("scan annotation class name error.");
        }
        InputStream in;
        try {
            in = Resources.getResourceAsStream(configFile);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        factory = new SqlSessionFactoryBuilder().build(in);
        manager = SqlSessionManager.newInstance(factory);//MyBatis 自带的 线程安全的 SqlSession
        context.registerBean(factory);
        context.registerBean(manager);
        Set<String> classNames = AnnotationUtil.getClassNames(scanPackages);
        for (String className : classNames) {
            try {
                Class<?> aClass = Class.forName(className);
                Annotation annotation = AnnotationUtil.getAnnotation(aClass, scanAnnotation);
                if (annotation != null) {
                    Object mapper = manager.getMapper(aClass);
                    registerMapper(mapper);
                }
            } catch (Throwable e) {
                LOGGER.debug("", e);
            }
        }
        initSql();
        initSqlFile();
    }

    private void registerMapper(Object mapper) {
        context.registerBean(mapper);
        mappers.put(mapper.getClass(), mapper);
    }

    private void initSql() {
        if (initSql != null && !initSql.isEmpty()) {
            try (SqlSession sqlSession = factory.openSession();
                 Connection connection = sqlSession.getConnection();
                 Statement statement = connection.createStatement()) {
                connection.setAutoCommit(false);
                statement.execute(initSql);
                connection.commit();
            } catch (SQLException e) {
                throw new PersistenceException(e);
            }
        }
    }

    private void initSqlFile() {
        if (initSqlFile != null && !initSqlFile.isEmpty()) {
            try {
                Reader sqlFileReader = Resources.getResourceAsReader(initSqlFile);
                try (SqlSession sqlSession = factory.openSession();
                     Connection connection = sqlSession.getConnection()) {
                    connection.setAutoCommit(false);
                    // java 执行 sql 脚本的 3 种方式 (ant,ibatis,ScriptRunner)
                    // http://mxm910821.iteye.com/blog/1701822
                    ScriptRunner scriptRunner = new ScriptRunner(connection);
                    scriptRunner.runScript(sqlFileReader);
                    connection.commit();
                } catch (SQLException e) {
                    throw new PersistenceException(e);
                }
            } catch (IOException e) {
                throw new IllegalArgumentException(e);
            }
        }
    }
}

先根据 MyBatis 配置文件生成 SqlSessionFactory SqlSessionManager 等,再获取 mapper 实例,把他们都注册到容器中,最后还支持了 init-sql / init-file 小扩展,可以在项目启动时方便得进行建表、插入测试数据等操作。

完整代码在 GitHub,有帮助的话,不妨给个 star 哦:https://github.com/YouthLin/mini-framework/tree/v1.0.1
你也可以通过 Maven 坐标直接引用:

<properties>
    <mini-framework.version>1.0.1</mini-framework.version>
</properties>
<dependency>
    <groupId>com.youthlin</groupId>
    <artifactId>mini-ioc</artifactId>
    <version>${mini-framework.version}</version>
</dependency>

这里是使用 Mini-MVC 的一个示例项目:https://github.com/YouthLin/examples/tree/master/example-mini-mvc 使用了 H2 + MyBatis + Thymeleaf 实现了基本的增删改查。预览地址:https://mvc.youthlin.com/


“自己实现一个 Mini MVC 框架”上的5条回复

这里的沙发根本没人要……

你主页的背景图有什么更新规律啊

[/坏笑] 啥时出门拍到新照片以后,就往github传……
本来想自己画些,水平太渣

哇哦 所以照片都是你自己拍的咯
现在这幅是鸟巢,我就在8号线呢哈哈哈哈

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

[/鼓掌] [/难过] [/调皮] [/白眼] [/疑问] [/流泪] [/流汗] [/撇嘴] [/抠鼻] [/惊讶] [/微笑] [/得意] [/大兵] [/坏笑] [/呲牙] [/吓到] [/可爱] [/发怒] [/发呆] [/偷笑] [/亲亲]