简易MVC框架实现
一、MVC框架的初衷
通过结合Servlet和JSP的MVC模式,我们可以发挥二者各自的优点:
- Servlet实现业务逻辑;
- JSP实现页面展示逻辑。
但是,直接把MVC搭在Servlet和JSP之上还是不太好,原因如下:
- Servlet提供的接口仍然偏底层,需要实现Servlet调用相关接口;
- JSP对页面开发不友好,更好的替代品是
模板引擎
;
- 业务逻辑
最好由纯粹的Java类实现
,而不是强迫继承自Servlet。
能不能通过普通的Java类实现MVC的Controller?类似下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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参数按方法参数对应起来然后传入:
1 2 3 4
| @GetMapping("/hello") public ModelAndView hello(String name) { ... }
|
如果是POST请求,我们希望MVC框架能直接把Post参数变成一个JavaBean后通过方法参数传入:
1 2 3 4
| @PostMapping("/signin") public ModelAndView doSignin(SignInBean bean) { ... }
|
为了增加灵活性,如果Controller的方法在处理请求时需要访问HttpServletRequest
、HttpServletResponse
、HttpSession
这些实例时,只要方法参数有定义,就可以自动传入:
1 2 3 4
| @GetMapping("/signout") public ModelAndView signout(HttpSession session) { ... }
|
以上就是我们在设计MVC框架时,上层代码所需要的一切信息。
二、设计MVC框架
如何设计一个MVC框架?在上文中,我们已经定义了上层代码编写Controller的一切接口信息,并且并不要求实现特定接口,只需返回ModelAndView
对象,该对象包含一个View
和一个Model
。实际上View
就是模板的路径,而Model
可以用一个Map<String, Object>
表示,因此,ModelAndView
定义非常简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public class ModelAndView {
Map<String, Object> model; String view;
public ModelAndView(String view) { this.view = view; this.model = Map.of(); }
public ModelAndView(String view, String name, Object value) { this.view = view; this.model = new HashMap<>(); this.model.put(name, value); }
public ModelAndView(String view, Map<String, Object> model) { this.view = view; this.model = new HashMap<>(model); } }
|
比较复杂的是我们需要在MVC框架中创建一个接收所有请求的Servlet
,通常我们把它命名为DispatcherServlet
,它总是映射到/
,然后,根据不同的Controller的方法定义的@GetMapping
或@PostMapping
的Path决定调用哪个方法,最后,获得方法返回的ModelAndView
后,渲染模板,写入HttpServletResponse
,即完成了整个MVC的处理。
这个MVC的架构如下:
其中,DispatcherServlet
以及如何渲染均由MVC框架实现,在MVC框架之上只需要编写每一个Controller。
三、实现MVC框架
我们来看看如何编写最复杂的DispatcherServlet
。首先,我们需要存储请求路径到某个具体方法的映射:
1 2 3 4 5 6 7 8 9
| @WebServlet(urlPatterns = "/") public class DispatcherServlet extends HttpServlet { private Map<String, GetDispatcher> getMappings = new HashMap<>(); private Map<String, PostDispatcher> postMappings = new HashMap<>(); private List<Class<?>> controllers = List.of(IndexController.class, UserController.class); private ViewEngine viewEngine; }
|
处理一个GET请求是通过GetDispatcher
对象完成的,它需要如下信息:
1 2 3 4 5 6 7 8 9 10
| abstract class AbstractDispatcher { public abstract ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) throws IOException, ReflectiveOperationException; }
class GetDispatcher extends AbstractDispatcher{ Object instance; Method method; String[] parameterNames; Class<?>[] parameterClasses; }
|
有了以上信息,就可以定义invoke()
来处理真正的请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| @Override public ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) throws IOException, ReflectiveOperationException { 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; }
|
上述代码比较繁琐,但逻辑非常简单,即通过构造某个方法需要的所有参数列表,使用反射调用该方法后返回结果。(GET请求默认只支持int,long,boolean,String,HttpServlet,HttpServletResponse,HttpSession这几种参数类型)
类似的,PostDispatcher
需要如下信息:
1 2 3 4 5 6
| class PostDispatcher extends AbstractDispatcher{ Object instance; Method method; Class<?>[] parameterClasses; ObjectMapper objectMapper; }
|
和GET请求不同,POST请求严格地来说不能有URL参数,所有数据都应当从Post Body中读取。这里我们为了简化处理,==只支持==JSON格式的POST请求,这样,把Post数据转化为JavaBean就非常容易。(POST请求默认只支持HttpServlet,HttpServletResponse,HttpSession这三种参数类型或者单个JavaBean类型的参数)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Override public ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) throws IOException, ReflectiveOperationException { 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 { BufferedReader reader = request.getReader(); arguments[i] = this.objectMapper.readValue(reader, parameterClass); } } return (ModelAndView) this.method.invoke(instance, arguments); }
|
最后,我们来实现整个DispatcherServlet
的处理流程,以doGet()
和doPost()
为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| public class DispatcherServlet extends HttpServlet { ... @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { process(req, resp, this.getMappings); }
@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { process(req, resp, this.postMappings); }
private void process(HttpServletRequest req, HttpServletResponse resp, Map<String, ? extends AbstractDispatcher> dispatcherMap) throws ServletException, IOException { resp.setContentType("text/html"); resp.setCharacterEncoding("UTF-8"); String path = req.getRequestURI().substring(req.getContextPath().length()); AbstractDispatcher dispatcher = dispatcherMap.get(path); if (dispatcher == null) { resp.sendError(404); return; } ModelAndView mv = null; try { mv = dispatcher.invoke(req, resp); } catch (ReflectiveOperationException e) { throw new ServletException(e); } if (mv == null) { return; } if (mv.view.startsWith("redirect:")) { resp.sendRedirect(mv.view.substring(9)); return; } PrintWriter pw = resp.getWriter(); this.viewEngine.render(mv, pw); pw.flush(); }
|
这里有几个小改进:
- 允许Controller方法返回
null
,表示内部已自行处理完毕;
- 允许Controller方法返回以
redirect:
开头的view名称,表示一个重定向。
这样使得上层代码编写更灵活。例如,一个显示用户资料的请求可以这样写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @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()) { response.sendError(403); return null; } return new ModelAndView("/profile.html", Map.of("user", user)); }
|
最后一步是在DispatcherServlet
的init()
方法中初始化所有Get和Post的映射,以及用于渲染的模板引擎:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
|
@Override public void init() throws ServletException { logger.info("init {}...", getClass().getSimpleName()); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); for (Class<?> controllerClass : controllers) { try { Object controllerInstance = controllerClass.getConstructor().newInstance(); for (Method method : controllerClass.getMethods()) { if (method.getAnnotation(GetMapping.class) != null) { if (method.getReturnType() != ModelAndView.class && method.getReturnType() != void.class) { throw new UnsupportedOperationException( "Unsupported return type: " + method.getReturnType() + " for method: " + method); } for (Class<?> parameterClass : method.getParameterTypes()) { if (!supportedGetParameterTypes.contains(parameterClass)) { throw new UnsupportedOperationException( "Unsupported parameter type: " + parameterClass + " for method: " + method); } } String[] parameterNames = Arrays.stream(method.getParameters()) .map(p -> p.getName()).toArray(String[]::new); String path = method.getAnnotation(GetMapping.class).value(); logger.info("Found GET: {} => {}", path, method); this.getMappings.put(path, new GetDispatcher(controllerInstance, method, parameterNames,method.getParameterTypes())); } else if (method.getAnnotation(PostMapping.class) != null) { if (method.getReturnType() != ModelAndView.class && method.getReturnType() != void.class) { throw new UnsupportedOperationException( "Unsupported return type: " + method.getReturnType() + " for method: " + method); } Class<?> requestBodyClass = null; for (Class<?> parameterClass : method.getParameterTypes()) { if (!supportedPostParameterTypes.contains(parameterClass)) { if (requestBodyClass == null) { requestBodyClass = parameterClass; } else { throw new UnsupportedOperationException("Unsupported duplicate request body type: "+ parameterClass + " for method: " + method); } } } String path = method.getAnnotation(PostMapping.class).value(); logger.info("Found POST: {} => {}", path, method); this.postMappings.put(path, new PostDispatcher(controllerInstance, method, method.getParameterTypes(), objectMapper)); } } } catch (ReflectiveOperationException e) { throw new ServletException(e); } } this.viewEngine = new ViewEngine(getServletContext()); }
private static final Set<Class<?>> supportedGetParameterTypes = Set.of(int.class, long.class, boolean.class, String.class, HttpServletRequest.class, HttpServletResponse.class, HttpSession.class);
private static final Set<Class<?>> supportedPostParameterTypes = Set.of(HttpServletRequest.class,HttpServletResponse.class, HttpSession.class);
|
四、渲染的简单实现
其实ViewEngine
非常简单,只需要实现一个简单的render()
方法:
1 2 3 4 5 6 7 8 9 10
| public class ViewEngine { public void render(ModelAndView mv, Writer writer) throws IOException { String view = mv.view; Map<String, Object> model = mv.model; Template template = getTemplateByPath(view); template.write(writer, model); } }
|
Java有很多开源的模板引擎,他们的用法都大同小异。这里我们推荐一个使用Jinja语法的模板引擎Pebble,它的特点是语法简单,支持模板继承,编写出来的模板类似:
1 2 3 4 5 6 7 8 9
| <html> <body> <ul> {% for user in users %} <li><a href="{{ user.url }}">{{ user.username }}</a></li> {% endfor %} </ul> </body> </html>
|
即变量用{{ xxx }}
表示,控制语句用{% xxx %}
表示。使用Pebble渲染只需要如下几行代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public class ViewEngine { private final PebbleEngine engine;
public ViewEngine(ServletContext servletContext) { ServletLoader loader = new ServletLoader(servletContext); loader.setCharset("UTF-8"); loader.setPrefix("/WEB-INF/templates"); loader.setSuffix(""); this.engine = new PebbleEngine.Builder() .autoEscaping(true) .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); } }
|
最后我们来看看整个工程的结构:
其中,framework
包是MVC的框架,完全可以单独编译后作为一个Maven依赖引入,controller
包才是我们需要编写的业务逻辑。
我们还硬性规定模板必须放在webapp/WEB-INF/templates
目录下,静态文件必须放在webapp/static
目录下,因此,为了便于开发,我们还顺带实现一个FileServlet
来处理静态文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| @WebServlet(urlPatterns = { "/favicon.ico", "/static/*" }) public class FileServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { ServletContext ctx = req.getServletContext(); 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; } String mime = Files.probeContentType(path); if (mime == null) { mime = "application/octet-stream"; } resp.setContentType(mime); OutputStream output = resp.getOutputStream(); try (InputStream input = new BufferedInputStream(new FileInputStream(filepath))) { input.transferTo(output); } output.flush(); } }
|
运行代码,在浏览器中输入URLhttp://localhost:8080/hello?name=Bob
可以看到如下页面:
为了把方法参数的名称编译到class文件中,以便处理@GetMapping
时使用,我们需要打开编译器的一个参数在Idea中选择Preferences
-Build, Execution, Deployment
-Compiler
-Java Compiler
-Additional command line parameters
,填入-parameters
;在Maven的pom.xml
添加一段配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <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>
|
五、简单的案例测试
经过上面我们对MVC框架的实现,下面我们构想一个常见的个人信息查看场景,如果用户未登录的话需要先登录才能查看,下面我们利用前面实现的MVC框架编写相关的登录、登出、首页、查看个人信息的Controller。
模型层
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class User {
public String email; public String password; public String name; public String description;
public User() { }
public User(String email, String password, String name, String description) { this.email = email; this.password = password; this.name = name; this.description = description; } }
|
对于登录接口我们专门设计了相应的JavaBean,非常简单就是邮箱和密码两个字段:
1 2 3 4
| public class SignInBean { public String email; public String password; }
|
控制器层
下面是处理首页跳转的控制器IndexController
:
1 2 3 4 5 6 7
| public class IndexController { @GetMapping("/") public ModelAndView index(HttpSession session) { User user = (User) session.getAttribute("user"); return new ModelAndView("/index.html", "user", user); } }
|
下面是处理用户信息的核心控制器UserController
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| ublic class UserController { private Map<String, User> userDatabase = new HashMap<>() { { List<User> users = List.of( new User("bob@example.com", "bob123", "Bob", "This is bob."), new User("tom@example.com", "tomcat", "Tom", "This is tom.")); users.forEach(user -> { put(user.email, user); }); } };
@GetMapping("/signin") public ModelAndView signin() { return new ModelAndView("/signin.html"); }
@PostMapping("/signin") public ModelAndView doSignin(SignInBean bean, HttpServletResponse response, HttpSession session) throws IOException { User user = userDatabase.get(bean.email); if (user == null || !user.password.equals(bean.password)) { response.setContentType("application/json"); PrintWriter pw = response.getWriter(); pw.write("{\"error\":\"Bad email or password\"}"); pw.flush(); } else { session.setAttribute("user", user); response.setContentType("application/json"); PrintWriter pw = response.getWriter(); pw.write("{\"result\":true}"); pw.flush(); } return null; }
@GetMapping("/signout") public ModelAndView signout(HttpSession session) { session.removeAttribute("user"); return new ModelAndView("redirect:/"); }
@GetMapping("/user/profile") public ModelAndView profile(HttpSession session) { User user = (User) session.getAttribute("user"); return new ModelAndView("/profile.html", "user", user); } }
|
在上面的控制器方法中我们并没有对用户登录逻辑在每个控制器方法中做判断,因为我们考虑后续可能有很多的业务都需要首先保证用户登录成功,总不能所有相关的控制器方法都重复一遍登录逻辑判断吧,因此我们使用过滤器在控制器前统一处理,具体包括登录校验过滤器AuthFilter
、字符编码过滤器EncodingFilter
、日志过滤器LogFilter
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| @WebFilter(urlPatterns = "/user/*") public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("AuthFilter: check authentication"); HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; if (req.getSession().getAttribute("user") == null) { System.out.println("AuthFilter: not signin!"); resp.sendRedirect("/signin"); } else { chain.doFilter(request, response); } } }
@WebFilter(urlPatterns = "/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("EncodingFilter:doFilter"); request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); chain.doFilter(request, response); } }
@WebFilter(urlPatterns = "/*") public class LogFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("LogFilter: process " + ((HttpServletRequest) request).getRequestURI()); chain.doFilter(request, response); } }
|
视图层
至于模板文件如_base.html
、index.html
、profile.html
、signin.html
的内容我主要贴一些重要内容,不然显得有些啰嗦:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| {% extends "_base.html" %}
{% block main %}
<script> $(function () { $('#signinForm').submit(function (e) { e.preventDefault(); var data = { email: $('#email').val(), password: $('#password').val() }; $.ajax({ type: 'POST', url: '/signin', data: JSON.stringify(data), success: function (resp) { if (resp.error) { $('#error').text(resp.error); } else { location.assign('/'); } }, contentType: 'application/json', dataType: 'json' }); }); }); </script>
<form id="signinForm"> <div class="form-group"> <p id="error" class="text-danger"></p> </div> <div class="form-group"> <label>Email</label> <input id="email" type="email" class="form-control" placeholder="Email"> </div> <div class="form-group"> <label>Password</label> <input id="password" type="password" class="form-control" placeholder="Password"> </div> <button type="submit" class="btn btn-outline-primary">Submit</button> </form>
{% endblock %}
|
演示效果
启动项目后,访问首页,此时用户未登录:
点击登录,输入模拟数据库中的用户名和密码:
登录成功,session中保存用户信息,重定向到首页会显示用户名称:
点击查看用户信息,先显示用户的各项信息:
点击登出,用户信息会从session中清除,并再次重定向到首页: