從零開始實現一個簡易的Java MVC框架(七)--實現MVC

zzzzbw發表於2018-07-26

前言

標題是‘從零開始實現一個簡易的Java MVC框架’,結果寫了這麼多才到實現MVC的時候...只能說前戲確實有點多了。不過這些前戲都是必須的,如果只是簡簡單單實現一個MVC的功能那就沒有意思了,要有Bean容器、IOC、AOP和MVC才像是一個'框架'嘛。

實現準備

為了實現mvc的功能,先要為pom.xml新增一些依賴。

<properties>
    ...
    <tomcat.version>8.5.31</tomcat.version>
    <jstl.version>1.2</jstl.version>
    <fastjson.version>1.2.47</fastjson.version>
</properties>
<dependencies>
	...
    <!-- tomcat embed -->
    <dependency>
        <groupId>org.apache.tomcat.embed</groupId>
        <artifactId>tomcat-embed-jasper</artifactId>
        <version>${tomcat.version}</version>
    </dependency>

    <!-- JSTL -->
    <dependency>
        <groupId>javax.servlet</groupId>
        <artifactId>jstl</artifactId>
        <version>${jstl.version}</version>
        <scope>runtime</scope>
    </dependency>

    <!-- FastJson -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>${fastjson.version}</version>
    </dependency>
</dependencies>
複製程式碼
  • tomcat-embed-jasper這個依賴是引入了一個內建的tomcat,spring-boot預設就是引用這個嵌入式的tomcat包實現直接啟動服務的。這個包除了加入了一個嵌入式的tomcat,還引入了java.servlet-apijsp-api這兩個包,如果不想用這種嵌入式的tomcat的話,可以去除tomcat-embed-jasper然後引入這兩個包。

  • jstl用於解析jsp表示式的,比如在jsp頁面編寫下面這樣c:forEach語句就需要這個包。

    <c:forEach items="${list}" var="user">
    	<tr>
            <td>${user.id}</td>
            <td>${user.name}</td>
    	</tr>
    </c:forEach>
    複製程式碼
  • fastjson是阿里開發的一個json解析包,用於將實體類轉換成json。類似的包還有GsonJackson等,這裡就不具體比較了,可以挑選一個自己喜歡的。

實現MVC

MVC實現原理

首先我們要了解到MVC的實現原理,在使用spring-boot編寫專案的時候,我們通常都是通過編寫一系列的Controller來實現一個個連結,這是'現代'的寫法。但是在以前springmvc甚至是struts2這類mvc框架都還沒流行的時候,都是通過編寫Servlet來實現。

每一個請求都會對應一個Servlet,然後還要在web.xml中配置這個Servlet,然後對請求的接收和處理啥的都分佈在一大堆的Servlet中,程式碼十分混雜。

為了讓人們編寫的時候更專注於業務程式碼而減少對請求的處理,springmvc就通過一箇中央的Servlet,處理這些請求,然後再轉發到對應的Controller中,這樣就只有一個Servlet統一處理請求了。下面的一段話來自spring的官方文件docs.spring.io/spring/docs…

Spring MVC, like many other web frameworks, is designed around the front controller pattern where a central Servlet, the DispatcherServlet, provides a shared algorithm for request processing while actual work is performed by configurable, delegate components. This model is flexible and supports diverse workflows.

The DispatcherServlet, as any Servlet, needs to be declared and mapped according to the Servlet specification using Java configuration or in web.xml. In turn the DispatcherServlet uses Spring configuration to discover the delegate components it needs for request mapping, view resolution, exception handling, and more.

這段大致意思就是:springmvc通過中心Servlet(DispatcherServlet)來實現對控制controller的操作。這個Servlet要通過java配置或者配置在web.xml中,它用於尋找請求的對映(即找到對應的controller),檢視解析(即執行controller的結果),異常處理(即對執行過程的異常統一處理)等等

所以實現MVC的效果就是以下幾點:

  1. 通過一箇中央sevlet如DispatcherServlet來接收所有請求
  2. 根據請求找到對應的controller
  3. 執行controller獲取結果
  4. 對controller的結果解析並轉到對應檢視
  5. 若有異常則統一處理異常

根據上面的步驟,我們先從步驟2、3、4、5開始,最後再實現1完成mvc。

建立註解

為了方便實現,先在com.zbw.mvc.annotation包下建立三個註解和一個列舉:RequestMappingRequestParamResponseBodyRequestMethod

package com.zbw.mvc.annotation;
import ...

/**
 * http請求路徑
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
    /**
     * 請求路徑
     */
    String value() default "";

    /**
     * 請求方法
     */
    RequestMethod method() default RequestMethod.GET;
}
複製程式碼
package com.zbw.mvc.annotation;

/**
 * http請求型別
 */
public enum RequestMethod {
    GET, POST
}
複製程式碼
package com.zbw.mvc.annotation;
import ...

/**
 * 請求的方法引數名
 */
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestParam {
    /**
     * 方法引數別名
     */
    String value() default "";

    /**
     * 是否必傳
     */
    boolean required() default true;
}
複製程式碼
package com.zbw.mvc.annotation;
import ...

/**
 * 用於標記返回json
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ResponseBody {
}

複製程式碼

這幾個類的作用就不解釋了,都是springmvc最常見的註解。

建立ModelAndView

為了能夠方便的傳遞引數到前端,建立一個工具bean,相當於spring中簡化版的ModelAndView。這個類建立於com.zbw.mvc.bean包下

package com.zbw.mvc.bean;
import ...

/**
 * ModelAndView
 */
public class ModelAndView {

    /**
     * 頁面路徑
     */
    private String view;

    /**
     * 頁面data資料
     */
    private Map<String, Object> model = new HashMap<>();

    public ModelAndView setView(String view) {
        this.view = view;
        return this;
    }
    public String getView() {
        return view;
    }
    public ModelAndView addObject(String attributeName, Object attributeValue) {
        model.put(attributeName, attributeValue);
        return this;
    }
    public ModelAndView addAllObjects(Map<String, ?> modelMap) {
        model.putAll(modelMap);
        return this;
    }
    public Map<String, Object> getModel() {
        return model;
    }
}
複製程式碼

實現Controller分發器

Controller分發器類似於Bean容器,只不過後者是存放Bean的而前者是存放Controller的,然後根據一些條件可以簡單的獲取對應的Controller。

先在com.zbw.mvc包下建立一個ControllerInfo類,用於存放Controller的一些資訊。

package com.zbw.mvc;
import ...

/**
 * ControllerInfo 儲存Controller相關資訊
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ControllerInfo {
    /**
     * controller類
     */
    private Class<?> controllerClass;

    /**
     * 執行的方法
     */
    private Method invokeMethod;

    /**
     * 方法引數別名對應引數型別
     */
    private Map<String, Class<?>> methodParameter;
}
複製程式碼

然後再建立一個PathInfo類,用於存放請求路徑和請求方法型別

package com.zbw.mvc;
import ...

/**
 * PathInfo 儲存http相關資訊
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PathInfo {
    /**
     * http請求方法
     */
    private String httpMethod;

    /**
     * http請求路徑
     */
    private String httpPath;
}
複製程式碼

接著建立Controller分發器類ControllerHandler

package com.zbw.mvc;
import ...

/**
 * Controller 分發器
 */
@Slf4j
public class ControllerHandler {

    private Map<PathInfo, ControllerInfo> pathControllerMap = new ConcurrentHashMap<>();

    private BeanContainer beanContainer;

    public ControllerHandler() {
        beanContainer = BeanContainer.getInstance();
        Set<Class<?>> classSet = beanContainer.getClassesByAnnotation(RequestMapping.class);
        for (Class<?> clz : classSet) {
            putPathController(clz);
        }
    }

    /**
     * 獲取ControllerInfo
     */
    public ControllerInfo getController(String requestMethod, String requestPath) {
        PathInfo pathInfo = new PathInfo(requestMethod, requestPath);
        return pathControllerMap.get(pathInfo);
    }

    /**
     * 新增資訊到requestControllerMap中
     */
    private void putPathController(Class<?> clz) {
        RequestMapping controllerRequest = clz.getAnnotation(RequestMapping.class);
        String basePath = controllerRequest.value();
        Method[] controllerMethods = clz.getDeclaredMethods();
        // 1. 遍歷Controller中的方法
        for (Method method : controllerMethods) {
            if (method.isAnnotationPresent(RequestMapping.class)) {
                // 2. 獲取這個方法的引數名字和引數型別
                Map<String, Class<?>> params = new HashMap<>();
                for (Parameter methodParam : method.getParameters()) {
                    RequestParam requestParam = methodParam.getAnnotation(RequestParam.class);
                    if (null == requestParam) {
                        throw new RuntimeException("必須有RequestParam指定的引數名");
                    }
                    params.put(requestParam.value(), methodParam.getType());
                }
                // 3. 獲取這個方法上的RequestMapping註解
                RequestMapping methodRequest = method.getAnnotation(RequestMapping.class);
                String methodPath = methodRequest.value();
                RequestMethod requestMethod = methodRequest.method();
                PathInfo pathInfo = new PathInfo(requestMethod.toString(), basePath + methodPath);
                if (pathControllerMap.containsKey(pathInfo)) {
                    log.error("url:{} 重複註冊", pathInfo.getHttpPath());
                    throw new RuntimeException("url重複註冊");
                }
                // 4. 生成ControllerInfo並存入Map中
                ControllerInfo controllerInfo = new ControllerInfo(clz, method, params);
                this.pathControllerMap.put(pathInfo, controllerInfo);
                log.info("Add Controller RequestMethod:{}, RequestPath:{}, Controller:{}, Method:{}",
                        pathInfo.getHttpMethod(), pathInfo.getHttpPath(),
                        controllerInfo.getControllerClass().getName(), controllerInfo.getInvokeMethod().getName());
            }
        }
    }
}
複製程式碼

這個類最複雜的就是建構函式中呼叫的putPathController()方法,這個方法也是這個類的核心方法,實現了controller類中的資訊存放到pathControllerMap變數中的功能。大概講解一些這個類的功能流程:

  1. 在構造方法中獲取Bean容器BeanContainer的單例例項
  2. 獲取並遍歷BeanContainer中存放的被RequestMapping註解標記的類
  3. 遍歷這個類中的方法,找出被RequestMapping註解標記的方法
  4. 獲取這個方法的引數名字和引數型別,生成ControllerInfo
  5. 根據RequestMapping裡的value()method()生成PathInfo
  6. 將生成的PathInfoControllerInfo存到變數pathControllerMap
  7. 其他類通過呼叫getController()方法獲取到對應的controller

以上就是這個類的流程,其中有個注意的點:

步驟4的時候,必須規定這個方法的所有引數名字都被RequestParam註解標註,這是因為在java中,雖然我們編寫程式碼的時候是有引數名的,比如String name這樣的形式,但是被編譯成class檔案後‘name’這個欄位就會被擦除,所以必須要通過一個RequestParam來儲存名字。

但是大家在springmvc中並不用必須每個方法都用註解標記的,這是因為spring中藉助了*asm* ,這種工具可以在編譯之前拿到引數名然後儲存起來。還有一種方法是在java8之後支援了儲存引數名,但是必須修改編譯器的引數來支援。這兩種方法實現起來都比較複雜或者有限制條件,這裡就不實現了,大家可以查詢資料自己實現

實現結果執行器

接下來實現結果執行器,這個類中實現剛才mvc流程中的步驟3、4、5。

在com.zbw.mvc包下建立類ResultRender

package com.zbw.mvc;
import ...

/**
 * 結果執行器
 */
@Slf4j
public class ResultRender {

    private BeanContainer beanContainer;

    public ResultRender() {
        beanContainer = BeanContainer.getInstance();
    }

    /**
     * 執行Controller的方法
     */
    public void invokeController(HttpServletRequest req, HttpServletResponse resp, ControllerInfo controllerInfo) {
        // 1. 獲取HttpServletRequest所有引數
        Map<String, String> requestParam = getRequestParams(req);
        // 2. 例項化呼叫方法要傳入的引數值
        List<Object> methodParams = instantiateMethodArgs(controllerInfo.getMethodParameter(), requestParam);

        Object controller = beanContainer.getBean(controllerInfo.getControllerClass());
        Method invokeMethod = controllerInfo.getInvokeMethod();
        invokeMethod.setAccessible(true);
        Object result;
        // 3. 通過反射呼叫方法
        try {
            if (methodParams.size() == 0) {
                result = invokeMethod.invoke(controller);
            } else {
                result = invokeMethod.invoke(controller, methodParams.toArray());
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        // 4.解析方法的返回值,選擇返回頁面或者json
        resultResolver(controllerInfo, result, req, resp);
    }

    /**
     * 獲取http中的引數
     */
    private Map<String, String> getRequestParams(HttpServletRequest request) {
        Map<String, String> paramMap = new HashMap<>();
        //GET和POST方法是這樣獲取請求引數的
        request.getParameterMap().forEach((paramName, paramsValues) -> {
            if (ValidateUtil.isNotEmpty(paramsValues)) {
                paramMap.put(paramName, paramsValues[0]);
            }
        });
        // TODO: Body、Path、Header等方式的請求引數獲取
        return paramMap;
    }

    /**
     * 例項化方法引數
     */
    private List<Object> instantiateMethodArgs(Map<String, Class<?>> methodParams, Map<String, String> requestParams) {
        return methodParams.keySet().stream().map(paramName -> {
            Class<?> type = methodParams.get(paramName);
            String requestValue = requestParams.get(paramName);
            Object value;
            if (null == requestValue) {
                value = CastUtil.primitiveNull(type);
            } else {
                value = CastUtil.convert(type, requestValue);
                // TODO: 實現非原生類的引數例項化
            }
            return value;
        }).collect(Collectors.toList());
    }


    /**
     * Controller方法執行後返回值解析
     */
    private void resultResolver(ControllerInfo controllerInfo, Object result, HttpServletRequest req, HttpServletResponse resp) {
        if (null == result) {
            return;
        }
        boolean isJson = controllerInfo.getInvokeMethod().isAnnotationPresent(ResponseBody.class);
        if (isJson) {
            // 設定響應頭
            resp.setContentType("application/json");
            resp.setCharacterEncoding("UTF-8");
            // 向響應中寫入資料
            try (PrintWriter writer = resp.getWriter()) {
                writer.write(JSON.toJSONString(result));
                writer.flush();
            } catch (IOException e) {
                log.error("轉發請求失敗", e);
                // TODO: 異常統一處理,400等...
            }
        } else {
            String path;
            if (result instanceof ModelAndView) {
                ModelAndView mv = (ModelAndView) result;
                path = mv.getView();
                Map<String, Object> model = mv.getModel();
                if (ValidateUtil.isNotEmpty(model)) {
                    for (Map.Entry<String, Object> entry : model.entrySet()) {
                        req.setAttribute(entry.getKey(), entry.getValue());
                    }
                }
            } else if (result instanceof String) {
                path = (String) result;
            } else {
                throw new RuntimeException("返回型別不合法");
            }
            try {
                req.getRequestDispatcher("/templates/" + path).forward(req, resp);
            } catch (Exception e) {
                log.error("轉發請求失敗", e);
                // TODO: 異常統一處理,400等...
            }
        }
    }
}
複製程式碼

通過呼叫類中的invokeController()方法反射呼叫了Controller中的方法並根據結果解析對應的頁面。主要流程為:

  1. 呼叫getRequestParams() 獲取HttpServletRequest中引數
  2. 呼叫instantiateMethodArgs() 例項化呼叫方法要傳入的引數值
  3. 通過反射呼叫目標controller的目標方法
  4. 呼叫resultResolver()解析方法的返回值,選擇返回頁面或者json

通過這幾個步驟算是凝聚了MVC核心步驟了,不過由於篇幅問題,幾乎每一步驟得功能都有所精簡,如

  • 步驟1獲取HttpServletRequest中引數只獲取get或者post傳的引數,實際上還有 Body、Path、Header等方式的請求引數獲取沒有實現
  • 步驟2例項化呼叫方法的值只實現了java的原生引數,自定義的類的例項化沒有實現
  • 步驟4異常統一處理也沒具體實現

雖然有缺陷,但是一個MVC流程是完成了。接下來就要把這些功能組裝一下了。

實現DispatcherServlet

終於到實現開頭說的DispatcherServlet了,這個類繼承於HttpServlet,所有請求都從這裡經過。

在com.zbw.mvc下建立DispatcherServlet

package com.zbw.mvc;
import ...

/**
 * DispatcherServlet 所有http請求都由此Servlet轉發
 */
@Slf4j
public class DispatcherServlet extends HttpServlet {

    private ControllerHandler controllerHandler = new ControllerHandler();

    private ResultRender resultRender = new ResultRender();

    /**
     * 執行請求
     */
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 設定請求編碼方式
        req.setCharacterEncoding("UTF-8");
        //獲取請求方法和請求路徑
        String requestMethod = req.getMethod();
        String requestPath = req.getPathInfo();
        log.info("[DoodleConfig] {} {}", requestMethod, requestPath);
        if (requestPath.endsWith("/")) {
            requestPath = requestPath.substring(0, requestPath.length() - 1);
        }

        ControllerInfo controllerInfo = controllerHandler.getController(requestMethod, requestPath);
        log.info("{}", controllerInfo);
        if (null == controllerInfo) {
            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        resultRender.invokeController(req, resp, controllerInfo);
    }
}

複製程式碼

在這個類裡呼叫了ControllerHandlerResultRender兩個類,先根據請求的方法和路徑獲取對應的ControllerInfo,然後再用ControllerInfo解析出對應的檢視,然後就能訪問到對應的頁面或者返回對應的json資訊了。

然而一直在說的所有請求都從DispatcherServlet經過好像沒有體現啊,這是因為要配置web.xml才行,現在很多都在使用spring-boot的朋友可能不大清楚了,在以前使用springmvc+spring+mybatis時代的時候要寫很多配置檔案,其中一個就是web.xml,要在裡面新增上。通過萬用字元*讓所有請求都走的是DispatcherServlet。

<servlet>
	<servlet-name>springMVC</servlet-name>
	<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
	<load-on-startup>1</load-on-startup>
	<async-supported>true</async-supported>
</servlet>
<servlet-mapping>
	<servlet-name>springMVC</servlet-name>
	<url-pattern>*</url-pattern>
</servlet-mapping>
複製程式碼

不過我們無需這樣做,為了致敬spring-boot,我們會在下一節實現內嵌Tomcat,並通過啟動器啟動。

缺陷

可能這一節的程式碼讓大家看起來不是很舒服,這是因為目前這個程式碼雖然說功能已經是實現了,但是程式碼結構還需要優化。

首先DispatcherServlet是一個請求分發器,這裡面不應該有處理Http的邏輯程式碼的

其次我們把MVC步驟的3、4、5的時候都放在了一個類裡,這樣也不好,本來這裡每一步驟的功能就很繁雜,還將這幾步驟都放在一個類中,這樣不利於後期更改對應步驟的功能。

還有目前也沒實現異常的處理,不能返回異常頁面給使用者。

這些優化工作會在後期的章節完成的。


原始碼地址:doodle

原文地址:從零開始實現一個簡易的Java MVC框架(七)--實現MVC

相關文章