红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 专栏
  3. Java编程笔记
  4. 正文

Java编程笔记30:MVC

2023年3月31日 1100点热度 0人点赞 0条评论

image-20221101145143893

图源:Fotor懒设计

在上一篇文章Java编程笔记29:JSP - 红茶的个人站点 (icexmoon.cn)中,我们看到了如何结合Servlet和JSP实现简单的MVC思想。但这种做法存在一些问题:

  • 用Servlet类来实现Controller(控制器)会让Controller显得复杂,因为Controller必须继承自HttpServlet类,意味着它包含了一些Servlet的功能,这不符合单一任务原则的设计思想。

  • 在Servlet中加载JSP和附加信息的部分显得复杂,可以进一步封装。

为了解决上边的问题,我们可以设计一个负责发放所有请求的Servlet,将所有的请求按照Method和Uri进行分发到具体的Controller。

最终的Controller大概长这样:

package cn.icexmoon.java.note.ch30.controller;
// ...
public class HelloController {
    @GetMapping("/")
    public ModelAndViewer helloPage(@RequestParam("name") String name){
        Map<String, Object> data = new HashMap<>();
        data.put("name", name);
        return new ModelAndViewer("/WEB-INF/hello.jsp", data);
    }
}

这里引入一个注解GetMapping用于标记Controller的方法用于处理一个Get请求,其value值就是具体的Get请求的路径(uri)。

GetMapping注解的声明如下:

package cn.icexmoon.java.note.ch30.framework;
// ...
@Retention(RUNTIME)
@Target(METHOD)
public @interface GetMapping {
    String value();
}

类似的,还定义了一个PostMapping注解用于标记POST请求对应的处理方法。

此外,Controller方法返回一个ModelAndViewer类型的对象,该对象代表需要渲染的视图(viewer),具体包含一个指向JSP的路径和视图需要加载的数据(Model)。

package cn.icexmoon.java.note.ch30.framework;
// ...
@Data
@AllArgsConstructor
public class ModelAndViewer {
    private String viewer;
    private Map<String, ?> model;
​
    public static ModelAndViewer redirect(String targetUri) {
        return new ModelAndViewer(ServletDispatcher.REDIRECT_FLAG + targetUri, null);
    }
}

这里还有一个棘手的问题,如何将Http请求中可能包含的参数“绑定”到Controller的处理方法的形参上。一个简单的做法是利用反射获取到方法对用的参数名,但这样做有一个问题,Java作为一个编译语言,在源码转换到class文件的时候,会丢失一些信息,这其中就包含方法的参数名(参数名会变成arg1这样的无意义命名)。当然可以通过在编译和运行时添加一些参数来改变这一行为,但这样做会让事情变得复杂,且你要确保所有运行环境都有类似的参数。

这个问题可以通过添加一个用于标记参数名称的注解来解决(注解可以通过定义@Retention(RUNTIME)确保在运行时存在)。这里我定义了一个注解@RequestParam来做这件事。

下面需要做的就是编写一个类ServletDispatcher,用于处理所有的Controller调度。这个类需要做两件事:

  • 扫描所有的Controller类,为包含@PostMapping或@GetMapping注解的方法创建映射,这样做后我们就能知道发送到服务端的Http请求需要用哪个方法来处理。

  • 重写doGet和doPost方法,根据请求的uri和已经生成的方法映射,选取合适的方法,用动态调用的方式来完整方法调用。

为了方便实现上面两点,可以抽象出一个AbsHandler类,该类包含一个方法对象、方法形参名称、方法形参类型、uri等:

package cn.icexmoon.java.note.ch30.framework;
// ...
@AllArgsConstructor
@Getter
public abstract class AbsHandler {
    protected Method method;
    protected List<Class<?>> paramTypes;
    protected List<String> paramNames;
    protected String uri;
​
    public static AbsHandler scanMethod(Method method) {
        Parameter[] parameters = method.getParameters();
        List<Class<?>> paramTypes = new ArrayList<>();
        List<String> paramNames = new ArrayList<>();
        for (int i = 0; i < parameters.length; i++) {
            RequestParam rpa = parameters[i].getAnnotation(RequestParam.class);
            if (rpa != null){
                paramNames.add(i, rpa.value());
            }
            else{
                paramNames.add(i, parameters[i].getName());
            }
            paramTypes.add(i, parameters[i].getType());
        }
        GetMapping getMappingAnnotation = method.getAnnotation(GetMapping.class);
        if (getMappingAnnotation != null) {
            String uri = getMappingAnnotation.value();
            return new GetHandler(method, paramTypes, paramNames, uri);
        }
        PostMapping postMappingAnnotation = method.getAnnotation(PostMapping.class);
        if (postMappingAnnotation != null) {
            String uri = postMappingAnnotation.value();
            return new PostHandler(method, paramTypes, paramNames, uri);
        }
        return null;
    }
​
    protected abstract Object getParam(HttpServletRequest req, String paramName);
​
    public ModelAndViewer invoke(HttpServletRequest req, HttpServletResponse resp) {
        Object clsInstance = null;
        try {
            clsInstance = method.getDeclaringClass().newInstance();
        } catch (InstantiationException | IllegalAccessException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
        Object[] args = new Object[paramTypes.size()];
        for (int i = 0; i < paramNames.size(); i++) {
            String pName = paramNames.get(i);
            Class<?> pType = paramTypes.get(i);
            Object arg;
            if (pType == HttpServletRequest.class) {
                arg = req;
            } else if (pType == HttpSession.class) {
                arg = req.getSession();
            } else if (pType == HttpServletResponse.class) {
                arg = resp;
            } else {
                arg = this.getParam(req, pName);
            }
            args[i] = arg;
        }
        try {
            System.out.println(args);
            System.out.println(method.getName());
            return (ModelAndViewer) method.invoke(clsInstance, args);
        } catch (IllegalAccessException | InvocationTargetException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }
}

该类的scanMethod方法用于扫描一个Method对象,并且创建一个正确的AbsHandler对象。这里从AbsHandler派生出两个类GetHandler和PostHandler,分别代表Get请求和Post请求。

invoke方法用于有Http请求产生时,从请求获取Handler需要的参数,并调用具体的Handler方法。除了需要从Http请求参数获取的Handler参数外,Handler方法可能还需要某些特殊参数,比如Http请求对象,Http响应对象,session对象。这些我们可以通过参数类型来进行绑定。

处理好需要的传参后,就可以通过反射来进行动态调用:method.invoke(clsInstance, args)。

最后的工作就是编写一个用于调度的Servlet类ServletDispatcher:

package cn.icexmoon.java.note.ch30.framework;
// ...
@WebServlet(urlPatterns = "/")
public class ServletDispatcher extends HttpServlet {
    private static List<Class<?>> controllerClses = new ArrayList<>();
​
    static {
        controllerClses.add(HelloController.class);
        controllerClses.add(HomeController.class);
        controllerClses.add(SignController.class);
    }
​
    private static Map<String, AbsHandler> getMappings = new HashMap<>();
    private static Map<String, AbsHandler> postMappings = new HashMap<>();
​
    static {
        scan();
    }
​
    public static final String REDIRECT_FLAG = "redirect:";
​
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doHandler(req, resp, getMappings);
    }
​
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doHandler(req, resp, postMappings);
    }
​
    private void doHandler(HttpServletRequest req, HttpServletResponse resp, Map<String, AbsHandler> mappings) throws ServletException, IOException{
        String path = req.getRequestURI().substring(req.getContextPath().length());
        AbsHandler handler = mappings.get(path);
        if (handler == null) {
            resp.sendError(404);
            return;
        }
        ModelAndViewer mv = handler.invoke(req, resp);
        if (mv == null) {
            return;
        }
        if (mv.getViewer().startsWith(REDIRECT_FLAG)) {
            resp.sendRedirect(mv.getViewer().substring(REDIRECT_FLAG.length()));
            return;
        }
        resp.setContentType("text/html; charset=UTF-8");
        req.setAttribute("data", mv.getModel());
        req.getRequestDispatcher(mv.getViewer()).forward(req, resp);
    }
​
    private static void scan() {
        for (Class<?> cls : controllerClses) {
            for (Method method : cls.getMethods()) {
                AbsHandler handler = AbsHandler.scanMethod(method);
                if (handler == null) {
                    continue;
                } else if (handler instanceof GetHandler) {
                    getMappings.put(handler.getUri(), handler);
                } else if (handler instanceof PostHandler) {
                    postMappings.put(handler.getUri(), handler);
                } else {
                    continue;
                }
            }
        }
    }
}

这个类中,scan方法用于扫描Controller类,并生成用于处理Get请求的Handler集合getMappings以及处理POST请求的Handler集合postMappings。并且在有HTTP请求产生时,通过doHandler方法寻找一个正确的Handler进行调用。

doHandler方法的最后是利用Handler方法调用后产生的ModelAndViewer对象来渲染页面。这里有一个特殊处理,为了能进行页面跳转,我们添加了一个特殊的路径协议,Controller返回的Viewer路径如果是redirect:开头,就进行跳转。

其实到这里,我们就实现了一个简单的MVC框架。整个代码结构类似这样:

image-20230331184335218

在这个简单的MVC框架下,只需要用很简单的代码就可以实现Controller:

package cn.icexmoon.java.note.ch30.controller;
// ...
public class SignController {
    private static Map<String, String> users = Collections.synchronizedMap(new HashMap<>());
​
    static {
        users.put("icexmoon", "123");
    }
​
    @GetMapping("/sign")
    public ModelAndViewer signPage() {
        return new ModelAndViewer("/WEB-INF/sign.jsp", null);
    }
​
    @PostMapping("/sign")
    public ModelAndViewer sign(HttpSession session,
                               @RequestParam("name") String name,
                               @RequestParam("password") String password) {
        if (!users.containsKey(name)
                || users.get(name) == null
                || !users.get(name).equals(password)){
            //登录失败,跳转到错误信息显示页面
            Map<String, Object> data = new HashMap<>();
            data.put("msg", "用户名或密码错误");
            return new ModelAndViewer("/WEB-INF/error.jsp", data);
        }
        session.setAttribute("user", name);
        return ModelAndViewer.redirect("/hello/home");
    }
​
    @GetMapping("/exit")
    public ModelAndViewer exit(HttpSession session) {
        session.removeAttribute("user");
        return ModelAndViewer.redirect("/hello/sign");
    }
}

示例中的其他代码和JSP就不一一展示了,完整的示例代码可以通过java-notebook/ch30 (github.com)获取。

要说明的是,这个示例并不完善,存在一些问题:

  • Controller的方法形参除了String外,显然还有其他类型,比如Integer等,框架代码并没有处理这些类型的参数输入。

  • 简单起见,这里生成页面依然使用的是JSP,没有引入其他的模板语言,使用模板语言会让页面嵌入变量更容易,且避免可能的注入攻击。

  • Controller类的注册是通过代码硬编码实现的,这样显然不适合扩展。实际上应当通过反射扫描包实现自动注册,或者使用注解处理器编写代码来处理。

虽然存在一些问题,但这个简单示例依然可以说明如何用Java构建一个简单的MVC框架,实际上目前成熟的框架(如Spring MVC,Spring Boot等)实现的方式和结果都是与之类似的,可以帮助我们理解和学习这些成熟的商业框架。

就这样了,谢谢阅读。

参考资料

  • MVC高级开发 - 廖雪峰的官方网站 (liaoxuefeng.com)

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: java jsp mvc servlet
最后更新:2023年3月31日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇
下一篇 >

文章评论

取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号