自己实现一个 Mini MVC 框架

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

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

用法

容器启动

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

Dispatch

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

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

拦截器

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

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

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

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

参数注入

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

当然,当参数太多时,可以使用 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 也是视图。

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

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

无匹配处理

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

如果是压根就没有哪个方法映射了这个 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 就可以了。

Thymeleaf

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

这部分是参考 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 的接口。

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

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

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

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

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

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


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

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

发表评论

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