TANKENQI.cn

May 13, 2025

从零开始手搓一个MVC框架

Backend12.7 min to read

项目地址:https://github.com/binwenwu/ZenMVC

1 前言

通过结合Servlet和JSP的MVC模式,我们可以发挥二者各自的优点:

但是,直接把MVC搭在Servlet和JSP之上还是不太好,原因如下:

能不能通过普通的Java类实现MVC的Controller?类似下面的代码:

public class UserController {    @GetMapping("/signin")    public ModelAndView signin() {        ...    }    @PostMapping("/signin")    public ModelAndView doSignin(SignInBean bean) {        ...    }    @GetMapping("/signout")    public ModelAndView signout(HttpSession session) {        ...    }}

上面的这个Java类每个方法都对应一个GET或POST请求,方法返回值是ModelAndView,它包含一个View的路径以及一个Model,这样,再由MVC框架处理后返回给浏览器。

如果是GET请求,我们希望MVC框架能直接把URL参数按方法参数对应起来然后传入:

@GetMapping("/hello")public ModelAndView hello(String name) {    ...}

如果是POST请求,我们希望MVC框架能直接把Post参数变成一个JavaBean后通过方法参数传入:

@PostMapping("/signin")public ModelAndView doSignin(SignInBean bean) {    ...}

为了增加灵活性,如果Controller的方法在处理请求时需要访问HttpServletRequestHttpServletResponseHttpSession这些实例时,只要方法参数有定义,就可以自动传入:

@GetMapping("/signout")public ModelAndView signout(HttpSession session) {    ...}

以上就是我们在设计MVC框架时,上层代码所需要的一切信息。

2 框架设计思路

如何设计一个MVC框架?在上文中,我们已经定义了上层代码编写Controller的一切接口信息,并且并不要求实现特定接口,只需返回ModelAndView对象,该对象包含一个View和一个Model。实际上View就是模板的路径,而Model可以用一个Map<String, Object>表示,因此,ModelAndView定义非常简单:

public class ModelAndView {    Map<String, Object> model;    String view;}

比较复杂的是我们需要在MVC框架中创建一个接收所有请求的Servlet,通常我们把它命名为DispatcherServlet,它总是映射到/,然后,根据不同的Controller的方法定义的@Get@Post的Path决定调用哪个方法,最后,获得方法返回的ModelAndView后,渲染模板,写入HttpServletResponse,即完成了整个MVC的处理。

这个MVC的架构如下:

其中,DispatcherServlet以及如何渲染均由MVC框架实现,在MVC框架之上只需要编写每一个Controller。

我们来看看如何编写最复杂的DispatcherServlet。首先,我们需要存储请求路径到某个具体方法的映射:

@WebServlet(urlPatterns = "/")public class DispatcherServlet extends HttpServlet {    private Map<String, GetDispatcher> getMappings = new HashMap<>();    private Map<String, PostDispatcher> postMappings = new HashMap<>();}

处理一个GET请求是通过GetDispatcher对象完成的,它需要如下信息:

class GetDispatcher {    ...    public ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) {        Object[] arguments = new Object[parameterClasses.length];        for (int i = 0; i < parameterClasses.length; i++) {            String parameterName = parameterNames[i];            Class<?> parameterClass = parameterClasses[i];            if (parameterClass == HttpServletRequest.class) {                arguments[i] = request;            } else if (parameterClass == HttpServletResponse.class) {                arguments[i] = response;            } else if (parameterClass == HttpSession.class) {                arguments[i] = request.getSession();            } else if (parameterClass == int.class) {                arguments[i] = Integer.valueOf(getOrDefault(request, parameterName, "0"));            } else if (parameterClass == long.class) {                arguments[i] = Long.valueOf(getOrDefault(request, parameterName, "0"));            } else if (parameterClass == boolean.class) {                arguments[i] = Boolean.valueOf(getOrDefault(request, parameterName, "false"));            } else if (parameterClass == String.class) {                arguments[i] = getOrDefault(request, parameterName, "");            } else {                throw new RuntimeException("Missing handler for type: " + parameterClass);            }        }        return (ModelAndView) this.method.invoke(this.instance, arguments);    }    private String getOrDefault(HttpServletRequest request, String name, String defaultValue) {        String s = request.getParameter(name);        return s == null ? defaultValue : s;    }}

上述代码比较繁琐,但逻辑非常简单,即通过构造某个方法需要的所有参数列表,使用反射调用该方法后返回结果。

类似的,PostDispatcher需要如下信息:

class PostDispatcher {    Object instance; // Controller实例    Method method; // Controller方法    Class<?>[] parameterClasses; // 方法参数类型    ObjectMapper objectMapper; // JSON映射}

和GET请求不同,POST请求严格地来说不能有URL参数,所有数据都应当从Post Body中读取。这里我们为了简化处理,只支持JSON格式的POST请求,这样,把Post数据转化为JavaBean就非常容易。

class PostDispatcher {    ...    public ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) {        Object[] arguments = new Object[parameterClasses.length];        for (int i = 0; i < parameterClasses.length; i++) {            Class<?> parameterClass = parameterClasses[i];            if (parameterClass == HttpServletRequest.class) {                arguments[i] = request;            } else if (parameterClass == HttpServletResponse.class) {                arguments[i] = response;            } else if (parameterClass == HttpSession.class) {                arguments[i] = request.getSession();            } else {                // 读取JSON并解析为JavaBean:                BufferedReader reader = request.getReader();                arguments[i] = this.objectMapper.readValue(reader, parameterClass);            }        }        return (ModelAndView) this.method.invoke(instance, arguments);    }}

最后,我们来实现整个DispatcherServlet的处理流程,以doGet()为例:

public class DispatcherServlet extends HttpServlet {    ...    @Override    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {        resp.setContentType("text/html");        resp.setCharacterEncoding("UTF-8");        String path = req.getRequestURI().substring(req.getContextPath().length());        // 根据路径查找GetDispatcher:        GetDispatcher dispatcher = this.getMappings.get(path);        if (dispatcher == null) {            // 未找到返回404:            resp.sendError(404);            return;        }        // 调用Controller方法获得返回值:        ModelAndView mv = dispatcher.invoke(req, resp);        // 允许返回null:        if (mv == null) {            return;        }        // 允许返回`redirect:`开头的view表示重定向:        if (mv.view.startsWith("redirect:")) {            resp.sendRedirect(mv.view.substring(9));            return;        }        // 将模板引擎渲染的内容写入响应:        PrintWriter pw = resp.getWriter();        this.viewEngine.render(mv, pw);        pw.flush();    }}

这里有几个小改进:

这样使得上层代码编写更灵活。例如,一个显示用户资料的请求可以这样写:

@GetMapping("/user/profile")public ModelAndView profile(HttpServletResponse response, HttpSession session) {    User user = (User) session.getAttribute("user");    if (user == null) {        // 未登录,跳转到登录页:        return new ModelAndView("redirect:/signin");    }    if (!user.isManager()) {        // 权限不够,返回403:        response.sendError(403);        return null;    }    return new ModelAndView("/profile.html", Map.of("user", user));}

最后一步是在DispatcherServletinit()方法中初始化所有Get和Post的映射,以及用于渲染的模板引擎:

public class DispatcherServlet extends HttpServlet {    private Map<String, GetDispatcher> getMappings = new HashMap<>();    private Map<String, PostDispatcher> postMappings = new HashMap<>();    private ViewEngine viewEngine;    @Override    public void init() throws ServletException {        this.getMappings = scanGetInControllers();        this.postMappings = scanPostInControllers();        this.viewEngine = new ViewEngine(getServletContext());    }    ...}

如何扫描所有Controller以获取所有标记有@GetMapping@PostMapping的方法?当然是使用反射了。

这样,整个MVC框架就搭建完毕。

3 实现渲染

那如何使用模板引擎进行渲染呢,即如何实现上述的ViewEngine?其实ViewEngine非常简单,只需要实现一个简单的render()方法:

public class ViewEngine {    public void render(ModelAndView mv, Writer writer) throws IOException {        String view = mv.view;        Map<String, Object> model = mv.model;        // 根据view找到模板文件:        Template template = getTemplateByPath(view);        // 渲染并写入Writer:        template.write(writer, model);    }}

Java有很多开源的模板引擎,常用的有:

他们的用法都大同小异。这里我们推荐一个使用Jinja语法的模板引擎Pebble,它的特点是语法简单,支持模板继承,编写出来的模板类似:

<html><body>  <ul>  {% for user in users %}    <li><a href="{{ user.url }}">{{ user.username }}</a></li>  {% endfor %}  </ul></body></html>

即变量用{{ xxx }}表示,控制语句用{% xxx %}表示。

使用Pebble渲染只需要如下几行代码:

public class ViewEngine {    private final PebbleEngine engine;    public ViewEngine(ServletContext servletContext) {        // 定义一个ServletLoader用于加载模板:        ServletLoader loader = new ServletLoader(servletContext);        // 模板编码:        loader.setCharset("UTF-8");        // 模板前缀,这里默认模板必须放在`/WEB-INF/templates`目录:        loader.setPrefix("/WEB-INF/templates");        // 模板后缀:        loader.setSuffix("");        // 创建Pebble实例:        this.engine = new PebbleEngine.Builder()            .autoEscaping(true) // 默认打开HTML字符转义,防止XSS攻击            .cacheActive(false) // 禁用缓存使得每次修改模板可以立刻看到效果            .loader(loader).build();    }    public void render(ModelAndView mv, Writer writer) throws IOException {        // 查找模板:        PebbleTemplate template = this.engine.getTemplate(mv.view);        // 渲染:        template.evaluate(writer, mv.model);    }}

最后我们来看看整个工程的结构:

web-mvc├── pom.xml└── src    └── main        ├── java        │   └── com        │       └── itranswarp        │           └── learnjava        │               ├── Main.java        │               ├── bean        │               │   ├── SignInBean.java        │               │   └── User.java        │               ├── controller        │               │   ├── IndexController.java        │               │   └── UserController.java        │               └── framework        │                   ├── DispatcherServlet.java        │                   ├── FileServlet.java        │                   ├── GetMapping.java        │                   ├── ModelAndView.java        │                   ├── PostMapping.java        │                   └── ViewEngine.java        └── webapp            ├── WEB-INF            │   ├── templates            │   │   ├── _base.html            │   │   ├── hello.html            │   │   ├── index.html            │   │   ├── profile.html            │   │   └── signin.html            │   └── web.xml            └── static                ├── css                │   └── bootstrap.css                └── js                    ├── bootstrap.js                    └── jquery.js

其中,framework包是MVC的框架,完全可以单独编译后作为一个Maven依赖引入,controller包才是我们需要编写的业务逻辑。

我们还硬性规定模板必须放在webapp/WEB-INF/templates目录下,静态文件必须放在webapp/static目录下,因此,为了便于开发,我们还顺带实现一个FileServlet来处理静态文件:

@WebServlet(urlPatterns = { "/favicon.ico", "/static/*" })public class FileServlet extends HttpServlet {    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {        // 读取当前请求路径:        ServletContext ctx = req.getServletContext();        // RequestURI包含ContextPath,需要去掉:        String urlPath = req.getRequestURI().substring(ctx.getContextPath().length());        // 获取真实文件路径:        String filepath = ctx.getRealPath(urlPath);        if (filepath == null) {            // 无法获取到路径:            resp.sendError(HttpServletResponse.SC_NOT_FOUND);            return;        }        Path path = Paths.get(filepath);        if (!path.toFile().isFile()) {            // 文件不存在:            resp.sendError(HttpServletResponse.SC_NOT_FOUND);            return;        }        // 根据文件名猜测Content-Type:        String mime = Files.probeContentType(path);        if (mime == null) {            mime = "application/octet-stream";        }        resp.setContentType(mime);        // 读取文件并写入Response:        OutputStream output = resp.getOutputStream();        try (InputStream input = new BufferedInputStream(new FileInputStream(filepath))) {            input.transferTo(output);        }        output.flush();    }}

运行代码,在浏览器中输入URLhttp://localhost:8080/hello?name=Bob可以看到如下页面:

image-20250513132351221

为了把方法参数的名称编译到class文件中,以便处理@GetMapping时使用,我们需要打开编译器的一个参数,在Eclipse中勾选Preferences-Java-Compiler-Store information about method parameters (usable via reflection);在Idea中选择Preferences-Build, Execution, Deployment-Compiler-Java Compiler-Additional command line parameters,填入-parameters;在Maven的pom.xml添加一段配置如下:

<project ...>    <modelVersion>4.0.0</modelVersion>    ...    <build>        <plugins>            <plugin>                <groupId>org.apache.maven.plugins</groupId>                <artifactId>maven-compiler-plugin</artifactId>                <configuration>                    <compilerArgs>                        <arg>-parameters</arg>                    </compilerArgs>                </configuration>            </plugin>        </plugins>    </build></project>

有些用过Spring MVC的家人们会发现,本节实现的这个MVC框架,上层代码使用的公共类如GetMappingPostMappingModelAndView都和Spring MVC非常类似。实际上,我们这个MVC框架主要参考就是Spring MVC,通过实现一个“简化版”MVC,可以掌握Java Web MVC开发的核心思想与原理,对将来直接使用Spring MVC是非常有帮助的。