1. 程式人生 > >java架構之路-(SpringMVC篇)SpringMVC主要流程原始碼解析(上)原始碼執行流程

java架構之路-(SpringMVC篇)SpringMVC主要流程原始碼解析(上)原始碼執行流程

  做過web專案的小夥伴,對於SpringMVC,Struts2都是在熟悉不過了,再就是我們比較古老的servlet,我們先來複習一下我們的servlet生命週期。

servlet生命週期

1)初始化階段

  當客戶端向 Servlet 容器發出 HTTP 請求要求訪問 Servlet 時,Servlet 容器首先會解析請求,檢查記憶體中是否已經有了該 Servlet 物件,如果有,則直接使用該 Servlet 物件,如果沒有,則建立 Servlet 例項物件,然後通過呼叫 init() 方法實現 Servlet 的初始化工作。需要注意的是,在 Servlet 的整個生命週期內,它的 init() 方法只能被呼叫一次。

2)執行階段

  這是 Servlet 生命週期中最重要的階段,在這個階段中,Servlet 容器會為這個請求建立代表 HTTP 請求的 ServletRequest 物件和代表 HTTP 響應的 ServletResponse 物件,然後將它們作為引數傳遞給 Servlet 的 service() 方法。service() 方法從 ServletRequest 物件中獲得客戶請求資訊並處理該請求,通過 ServletResponse 物件生成響應結果。在 Servlet 的整個生命週期內,對於 Servlet 的每一次訪問請求,Servlet 容器都會呼叫一次 Servlet 的 service() 方法,並且建立新的 ServletRequest 和 ServletResponse 物件,也就是說,service() 方法在 Servlet 的整個生命週期中會被呼叫多次。

3)銷燬階段

當伺服器關閉或 Web 應用被移除出容器時,Servlet 隨著 Web 應用的關閉而銷燬。在銷燬 Servlet 之前,Servlet 容器會呼叫 Servlet 的 destroy() 方法,以便讓 Servlet 物件釋放它所佔用的資源。在 Servlet 的整個生命週期中,destroy() 方法也只能被呼叫一次。需要注意的是,Servlet 物件一旦建立就會駐留在記憶體中等待客戶端的訪問,直到伺服器關閉或 Web 應用被移除出容器時,Servlet 物件才會銷燬。

上述文字摘自http://c.biancheng.net/view/3989.html

   整個過程是比較複雜的,而且我們的引數是通過問號的形式來傳遞的,比如http://boke?id=1234,id為1234來傳遞的,如果我們要http://boke/1234這樣來傳遞引數,servlet是做不到的,我們來看一下我們SpringMVC還有哪些優勢。

1.基於註解方式的URL對映。比如http://boke/type/{articleType}/id/{articleId}

2.表單引數自動對映,我們不在需要request.getParament得到引數,引數可以通過name屬性來自動對映到我們的控制層下。

3.快取的處理,SprinMVC提供了快取來提高我們的效率。

4.全域性異常處理,通過過濾器也可以實現,只不過SprinMVC的方法會更簡單一些。

5.攔截器的實現,通過過濾器也可以實現,只不過SprinMVC的方法會更簡單一些。

6.下載處理

我們來對比一下SprinMVC的流程圖。

SprinMVC的流程圖

下面我們先熟悉一下原始碼,來個例項,來一個最精簡啟動SpringMVC。

最精簡啟動SpringMVC

建立Maven專案就不說了啊,先設定我們的pom檔案

<dependencies>
    <dependency>
        <groupId>javax.servlet</groupId>
        <artifactId>javax.servlet-api</artifactId>
        <version>3.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>4.3.8.RELEASE</version>
    </dependency>
</dependencies>

再來編寫我們的Web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://java.sun.com/xml/ns/javaee"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
         id="WebApp_ID" version="3.0">
    <display-name>spring mvc</display-name>
    <servlet>
        <servlet-name>dispatcherServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>
                classpath:/spring-mvc.xml
            </param-value>
        </init-param>
    </servlet>
    <servlet-mapping>
        <servlet-name>dispatcherServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>
</web-app>

我們來簡單些一個Controller

package com.springmvcbk.controller;

import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.Controller;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class SpringmvcbkController implements Controller {
    
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setViewName("/WEB-INF/page/index.jsp");
        modelAndView.addObject("name","張三");
        return modelAndView;
    }
}

寫一個index.jsp頁面吧。

<%@ page language="java" contentType="text/html; charset=UTF-8"
         pageEncoding="UTF-8" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Insert title here</title>
</head>
<body>
good man is love
${name}
</body>
</html>

最後還有我們的spring-mvc.xml

<?xml version="1.0" encoding="GBK"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/context
       http://www.springframework.org/schema/context/spring-context.xsd
       http://www.springframework.org/schema/mvc
       http://www.springframework.org/schema/mvc/spring-mvc.xsd">
    
    <bean name="/hello" class="com.springmvcbk.controller.SpringmvcbkController"/>
</beans>

注意自己的路徑啊,走起,測試一下。

這樣我們最精簡的SpringMVC就配置完成了。講一下這段程式碼是如何執行的,上面圖我們也看到了,請求過來優先去找我們的dispatchServlet,也就是我們Spring-MVC.xml配置檔案,通過name屬性來找的。找到我們對應的類,我們的繼承我們的Controller介面來處理我們的請求,也就是圖中的3,4,5步驟。然後再把結果塞回給dispatchServlet。返回頁面,走起。

這個是我們表層的理解,後續我們逐漸會深入的,我們再來看另外一種實現方式。

package com.springmvcbk.controller;

import org.springframework.web.HttpRequestHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class SpringmvcbkController2 implements HttpRequestHandler {
    
    public void handleRequest(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws ServletException, IOException {
        httpServletRequest.setAttribute("name","李斯");
        httpServletRequest.getRequestDispatcher("/WEB-INF/page/index.jsp").forward(httpServletRequest,httpServletResponse);
    }
}

這種方式也是可以的。

整個過程是如何實現的?
1. dispatchServlet 如何找到對應的Control?
2. 如何執行呼叫Control 當中的業務方法?
在面試中要回答好上述問題,就必須得弄清楚spring mvc 的體系組成。

spring mvc 的體系組成

  只是舉了幾個例子的實現,SpringMVC還有很多的實現方法。我們來看一下內部都有什麼核心的元件吧。

HandlerMapping->url與控制器的映謝

HandlerAdapter->控制器執行介面卡

ViewResolver->檢視倉庫

view->具體解析檢視

HandlerExceptionResolver->異常捕捕捉器

HandlerInterceptor->攔截器

稍後我們會逐個去說一下這些元件,我們看一下我們的UML類圖吧,講解一下他們之間是如果先後工作呼叫的。

 圖沒上色,也沒寫漢字註釋,看著有點蒙圈....我來說一下咋回事。HTTPServlet發出請求,我們的DispatcherServlet拿到請求去匹配我們的HandlerMapping,經過HandlerMapping下的HandlerExecutionChain,HandlerInterceptor生成我們的Handl,返回給DispatcherServlet,拿到了Handl,給我們的Handl傳遞給HandlerAdapter進行處理,得到我們的View再有DispatcherServlet傳遞給ViewResolver,經由View處理,返回response請求。

  我們先來看看我們的Handler是如何生產的。

Handler

 這個是SpringMVC自己的繼承UML圖,最下層的兩個是我們常用的,一個是通過name來注入的,一個是通過註解的方式來注入的,他是通過一系列的HandlerInterceptor才生成我們的Handler。

目前主流的三種mapping 如下

1. SimpleUrlHandlerMapping:基於手動配置url與control映謝
2. BeanNameUrlHandlerMapping:  基於ioc name 中已 "/" 開頭的Bean時行 註冊至映謝.
3. RequestMappingHandlerMapping:基於@RequestMapping註解配置對應映謝

另外兩個不說了,太常見不過了。我們來嘗試自己配置一個SimpleUrlHandlerMapping

<?xml version="1.0" encoding="GBK"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/context
       http://www.springframework.org/schema/context/spring-context.xsd
       http://www.springframework.org/schema/mvc
       http://www.springframework.org/schema/mvc/spring-mvc.xsd">
    <bean name="hello2" class="com.springmvcbk.controller.SpringmvcbkController2"/>

    <bean class="org.springframework.web.servlet.handler.SimpleUrlHandlerMapping">
        <property name="urlMap">
            <props>
                <prop key="hello.do">hello2</prop>
            </props>
        </property>
    </bean>
</beans>

注意SimpleUrlHandlerMapping是沒有/的,而我們的BeanNameUrlHandlerMapping必須加/的。

 

我們來走一下動態程式碼,只看取得Handler這段,(初始化的階段可以自己研究一下)

 1 /**
 2  * Return the HandlerExecutionChain for this request.
 3  * <p>Tries all handler mappings in order.
 4  * @param request current HTTP request
 5  * @return the HandlerExecutionChain, or {@code null} if no handler could be found
 6  */
 7 protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
 8     for (HandlerMapping hm : this.handlerMappings) {
 9         if (logger.isTraceEnabled()) {
10             logger.trace(
11                     "Testing handler map [" + hm + "] in DispatcherServlet with name '" + getServletName() + "'");
12         }
13         HandlerExecutionChain handler = hm.getHandler(request);
14         if (handler != null) {
15             return handler;
16         }
17     }
18     return null;
19 }

我們找到我們的DispatcherServlet類的getHandler方法上。在原始碼的1150行,也就是我上圖的第7行。打個斷點。優先遍歷我們handlerMappings集合,找到以後去取我們的handler。

HandlerExecutionChain handler = hm.getHandler(request);方法就是獲得我們的Handler方法,這裡只是獲得了一個HandlerExecutionChain執行鏈,也就是說我們在找到handler的前後都可能做其它的處理。再來深入一下看getHandler方法。

這時會呼叫AbstractHandlerMapping類的getHandler方法,然後優先去AbstractUrlHandlerMapping的getHandlerInternal取得handler

 1 /**
 2  * Look up a handler for the URL path of the given request.
 3  * @param request current HTTP request
 4  * @return the handler instance, or {@code null} if none found
 5  */
 6 @Override
 7 protected Object getHandlerInternal(HttpServletRequest request) throws Exception {
 8     String lookupPath = getUrlPathHelper().getLookupPathForRequest(request);//取得路徑
 9     Object handler = lookupHandler(lookupPath, request);//拿著路徑去LinkedHashMap查詢是否存在
10     if (handler == null) {
11         // We need to care for the default handler directly, since we need to
12         // expose the PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE for it as well.
13         Object rawHandler = null;
14         if ("/".equals(lookupPath)) {
15             rawHandler = getRootHandler();
16         }
17         if (rawHandler == null) {
18             rawHandler = getDefaultHandler();
19         }
20         if (rawHandler != null) {
21             // Bean name or resolved handler?
22             if (rawHandler instanceof String) {
23                 String handlerName = (String) rawHandler;
24                 rawHandler = getApplicationContext().getBean(handlerName);
25             }
26             validateHandler(rawHandler, request);
27             handler = buildPathExposingHandler(rawHandler, lookupPath, lookupPath, null);
28         }
29     }
30     if (handler != null && logger.isDebugEnabled()) {
31         logger.debug("Mapping [" + lookupPath + "] to " + handler);
32     }
33     else if (handler == null && logger.isTraceEnabled()) {
34         logger.trace("No handler mapping found for [" + lookupPath + "]");
35     }
36     return handler;
37 }

得到request的路徑,帶著路徑去我們已經初始化好的LinkedHashMap檢視是否存在。

/**
 * Look up a handler instance for the given URL path.
 * <p>Supports direct matches, e.g. a registered "/test" matches "/test",
 * and various Ant-style pattern matches, e.g. a registered "/t*" matches
 * both "/test" and "/team". For details, see the AntPathMatcher class.
 * <p>Looks for the most exact pattern, where most exact is defined as
 * the longest path pattern.
 * @param urlPath URL the bean is mapped to
 * @param request current HTTP request (to expose the path within the mapping to)
 * @return the associated handler instance, or {@code null} if not found
 * @see #exposePathWithinMapping
 * @see org.springframework.util.AntPathMatcher
 */
protected Object lookupHandler(String urlPath, HttpServletRequest request) throws Exception {
    // Direct match?
    Object handler = this.handlerMap.get(urlPath);//拿著路徑去LinkedHashMap查詢是否存在
    if (handler != null) {
        // Bean name or resolved handler?
        if (handler instanceof String) {
            String handlerName = (String) handler;
            handler = getApplicationContext().getBean(handlerName);
        }
        validateHandler(handler, request);
        return buildPathExposingHandler(handler, urlPath, urlPath, null);
    }

    // Pattern match?
    List<String> matchingPatterns = new ArrayList<String>();
    for (String registeredPattern : this.handlerMap.keySet()) {
        if (getPathMatcher().match(registeredPattern, urlPath)) {
            matchingPatterns.add(registeredPattern);
        }
        else if (useTrailingSlashMatch()) {
            if (!registeredPattern.endsWith("/") && getPathMatcher().match(registeredPattern + "/", urlPath)) {
                matchingPatterns.add(registeredPattern +"/");
            }
        }
    }

    String bestMatch = null;
    Comparator<String> patternComparator = getPathMatcher().getPatternComparator(urlPath);
    if (!matchingPatterns.isEmpty()) {
        Collections.sort(matchingPatterns, patternComparator);
        if (logger.isDebugEnabled()) {
            logger.debug("Matching patterns for request [" + urlPath + "] are " + matchingPatterns);
        }
        bestMatch = matchingPatterns.get(0);
    }
    if (bestMatch != null) {
        handler = this.handlerMap.get(bestMatch);
        if (handler == null) {
            if (bestMatch.endsWith("/")) {
                handler = this.handlerMap.get(bestMatch.substring(0, bestMatch.length() - 1));
            }
            if (handler == null) {
                throw new IllegalStateException(
                        "Could not find handler for best pattern match [" + bestMatch + "]");
            }
        }
        // Bean name or resolved handler?
        if (handler instanceof String) {
            String handlerName = (String) handler;
            handler = getApplicationContext().getBean(handlerName);
        }
        validateHandler(handler, request);
        String pathWithinMapping = getPathMatcher().extractPathWithinPattern(bestMatch, urlPath);

        // There might be multiple 'best patterns', let's make sure we have the correct URI template variables
        // for all of them
        Map<String, String> uriTemplateVariables = new LinkedHashMap<String, String>();
        for (String matchingPattern : matchingPatterns) {
            if (patternComparator.compare(bestMatch, matchingPattern) == 0) {
                Map<String, String> vars = getPathMatcher().extractUriTemplateVariables(matchingPattern, urlPath);
                Map<String, String> decodedVars = getUrlPathHelper().decodePathVariables(request, vars);
                uriTemplateVariables.putAll(decodedVars);
            }
        }
        if (logger.isDebugEnabled()) {
            logger.debug("URI Template variables for request [" + urlPath + "] are " + uriTemplateVariables);
        }
        return buildPathExposingHandler(handler, bestMatch, pathWithinMapping, uriTemplateVariables);
    }

    // No handler found...
    return null;
}

到這裡其實我們就可以得到我們的Handler了,但是SpringMVC又經過了buildPathExposingHandler處理,經過HandlerExecutionChain,看一下是否需要做請求前處理,然後得到我們的Handler。得到Handler以後也並沒有急著返回,又經過了一次HandlerExecutionChain處理才返回的。

 圖中我們可以看到去和回來的時候都經過了HandlerExecutionChain處理的。就這樣我們的handler就得到了。注意的三種mapping的方式可能有略微差異,但不影響大體流程。

HandlerAdapter

拿到我們的Handler,我們該查我們的HandlerAdapter了,也就是我們的介面卡。我們回到我們的DispatchServlet類中

/**
 * Return the HandlerAdapter for this handler object.
 * @param handler the handler object to find an adapter for
 * @throws ServletException if no HandlerAdapter can be found for the handler. This is a fatal error.
 */
protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
    for (HandlerAdapter ha : this.handlerAdapters) {
        if (logger.isTraceEnabled()) {
            logger.trace("Testing handler adapter [" + ha + "]");
        }
        if (ha.supports(handler)) {
            return ha;
        }
    }
    throw new ServletException("No adapter for handler [" + handler +
            "]: The DispatcherServlet configuration needs to include a HandlerAdapter that supports this handler");
}

還是我們的迴圈呼叫,我們的介面卡有四種,分別是AbstractHandlerMethodAdapter,HTTPRequestHandlerAdapter,SimpleControllerHandlerAdapter,SimpleServletHandlerAdapter

 mv = ha.handle(processedRequest, response, mappedHandler.getHandler());方法開始處理我們的請求,返回ModelAndView。

返回以後,我們交給我們的ViewResolver來處理。

ViewResolver

 

ContentNegotiatingViewResolver下面還有很多子類,我就不展示了。 選擇對應的ViewResolver解析我們的ModelAndView得我到我們的view進行返回。

說到這一個請求的流程就算是大致結束了。我們來看兩段核心的程式碼。

/**
 * Process the actual dispatching to the handler.
 * <p>The handler will be obtained by applying the servlet's HandlerMappings in order.
 * The HandlerAdapter will be obtained by querying the servlet's installed HandlerAdapters
 * to find the first that supports the handler class.
 * <p>All HTTP methods are handled by this method. It's up to HandlerAdapters or handlers
 * themselves to decide which methods are acceptable.
 * @param request current HTTP request
 * @param response current HTTP response
 * @throws Exception in case of any kind of processing failure
 */
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    HttpServletRequest processedRequest = request;
    HandlerExecutionChain mappedHandler = null;
    boolean multipartRequestParsed = false;

    WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);

    try {
        ModelAndView mv = null;
        Exception dispatchException = null;

        try {
            processedRequest = checkMultipart(request);
            multipartRequestParsed = (processedRequest != request);

            // Determine handler for the current request.
            mappedHandler = getHandler(processedRequest);
            if (mappedHandler == null || mappedHandler.getHandler() == null) {
                noHandlerFound(processedRequest, response);
                return;
            }

            // Determine handler adapter for the current request.
            HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

            // Process last-modified header, if supported by the handler.
            String method = request.getMethod();
            boolean isGet = "GET".equals(method);
            if (isGet || "HEAD".equals(method)) {
                long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
                if (logger.isDebugEnabled()) {
                    logger.debug("Last-Modified value for [" + getRequestUri(request) + "] is: " + lastModified);
                }
                if (new ServletWebRequest(request, response).checkNotModified(lastModified) && isGet) {
                    return;
                }
            }

            if (!mappedHandler.applyPreHandle(processedRequest, response)) {
                return;
            }

            // Actually invoke the handler.
            mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

            if (asyncManager.isConcurrentHandlingStarted()) {
                return;
            }

            applyDefaultViewName(processedRequest, mv);
            mappedHandler.applyPostHandle(processedRequest, response, mv);
        }
        catch (Exception ex) {
            dispatchException = ex;
        }
        catch (Throwable err) {
            // As of 4.3, we're processing Errors thrown from handler methods as well,
            // making them available for @ExceptionHandler methods and other scenarios.
            dispatchException = new NestedServletException("Handler dispatch failed", err);
        }
        processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
    }
    catch (Exception ex) {
        triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
    }
    catch (Throwable err) {
        triggerAfterCompletion(processedRequest, response, mappedHandler,
                new NestedServletException("Handler processing failed", err));
    }
    finally {
        if (asyncManager.isConcurrentHandlingStarted()) {
            // Instead of postHandle and afterCompletion
            if (mappedHandler != null) {
                mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
            }
        }
        else {
            // Clean up any resources used by a multipart request.
            if (multipartRequestParsed) {
                cleanupMultipart(processedRequest);
            }
        }
    }
}

這個是DispatchServlet類裡面doDispatch方法,也就是我們請求來的時候進行解析的方法。

/**
 * Render the given ModelAndView.
 * <p>This is the last stage in handling a request. It may involve resolving the view by name.
 * @param mv the ModelAndView to render
 * @param request current HTTP servlet request
 * @param response current HTTP servlet response
 * @throws ServletException if view is missing or cannot be resolved
 * @throws Exception if there's a problem rendering the view
 */
protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
    // Determine locale for request and apply it to the response.
    Locale locale = this.localeResolver.resolveLocale(request);
    response.setLocale(locale);

    View view;
    if (mv.isReference()) {
        // We need to resolve the view name.
        view = resolveViewName(mv.getViewName(), mv.getModelInternal(), locale, request);
        if (view == null) {
            throw new ServletException("Could not resolve view with name '" + mv.getViewName() +
                    "' in servlet with name '" + getServletName() + "'");
        }
    }
    else {
        // No need to lookup: the ModelAndView object contains the actual View object.
        view = mv.getView();
        if (view == null) {
            throw new ServletException("ModelAndView [" + mv + "] neither contains a view name nor a " +
                    "View object in servlet with name '" + getServletName() + "'");
        }
    }

    // Delegate to the View object for rendering.
    if (logger.isDebugEnabled()) {
        logger.debug("Rendering view [" + view + "] in DispatcherServlet with name '" + getServletName() + "'");
    }
    try {
        if (mv.getStatus() != null) {
            response.setStatus(mv.getStatus().value());
        }
        view.render(mv.getModelInternal(), request, response);
    }
    catch (Exception ex) {
        if (logger.isDebugEnabled()) {
            logger.debug("Error rendering view [" + view + "] in DispatcherServlet with name '" +
                    getServletName() + "'", ex);
        }
        throw ex;
    }
}

這個是DispatchServlet類裡面render方法,也就是我們處理完成要返回時的方法。大家有興趣的可以逐行逐步的去走下流程。裡面東西也不少的,這裡就不一一講解了。

 

最進弄了一個公眾號,小菜技術,歡迎大家的加入

&n