Spring MVC源碼---- @RequestBody和@ResponseBody原理解析(版本:Spring Framework 5.1.7.RELEASE)

@RequestBody作用是將http請求解析爲對應的對象。例如:

http請求的參數(application/json格式):

{
  "accountId": 10,
  "adGroupId": "12345678",
  "campaignId": "12345678",
  "dataType": 0,
  "sign": "abcdefg",
  "site": "us",
  "timeStamp": 1453250,
  "userId": 10
}

通過@RequestBody可以解析爲ProductSyncNegativeDto對象(如下代碼所示)

public CustResponse syncNegative(@RequestBody ProductSyncNegativeDto productSyncNegativeDto) 

那@RequestBody註解是如何實現http請求報文轉對象的呢?

@ResponseBody作用是將返回的對象轉爲json字符串,例如我們返回一個CustResponse對象,那postman中的結果會是啥?

{
    "code": 100,
    "msg": "",
    "details": [
        10,
        10,
        "us",
        "12345678",
        "12345678",
        0
    ]
}

我們可以發現,結果是一個json字符串,那@ResponseBody註解到底是如何將對象轉爲json字符串返回的呢?

接下來老師會帶童鞋們一些來揭祕,@RequestBody、@ResponseBody的底層實現原理。

一、概述

@Controller註解

在開始之前,我們先來介紹一下@Controller,做過ssm/ssh項目的同學肯定都接觸過springMVC,那必然會用到@Controller註解。Controller方法被封裝成ServletInvocableHandlerMethod類,並且由invokeAndHandle方法完成請求處理。

HttpMessageConverter

SpringMVC處理請求和響應時,支持多種類型的請求參數和返回類型,而此種功能的實現就需要對HTTP消息體和參數及返回值進行轉換,爲此SpringMVC提供了大量的轉換類,所有轉換類都實現了HttpMessageConverter接口。

public interface HttpMessageConverter<T> {

    // 當前轉換器是否能將HTTP報文轉換爲對象類型
    boolean canRead(Class<?> clazz, MediaType mediaType);

    // 當前轉換器是否能將對象類型轉換爲HTTP報文
    boolean canWrite(Class<?> clazz, MediaType mediaType);

    // 轉換器能支持的HTTP媒體類型
    List<MediaType> getSupportedMediaTypes();

    // 轉換HTTP報文爲特定類型
    T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
            throws IOException, HttpMessageNotReadableException;

    // 將特定類型對象轉換爲HTTP報文
    void write(T t, MediaType contentType, HttpOutputMessage outputMessage)
            throws IOException, HttpMessageNotWritableException;

}

HttpMessageConverter接口定義了5個方法,用於將HTTP請求報文轉換爲java對象,以及將java對象轉換爲HTTP響應報文。

HandlerMethodArgumentResolver與HandlerMethodReturnValueHandler

對應到SpringMVC的Controller方法,read方法即是讀取HTTP請求轉換爲參數對象,write方法即是將返回值對象轉換爲HTTP響應報文。SpringMVC定義了兩個接口來操作這兩個過程:參數解析器HandlerMethodArgumentResolver和返回值處理器HandlerMethodReturnValueHandler。

// 參數解析器接口
public interface HandlerMethodArgumentResolver {

    // 解析器是否支持方法參數
    boolean supportsParameter(MethodParameter parameter);

    // 解析HTTP報文中對應的方法參數
    Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception;

}

// 返回值處理器接口
public interface HandlerMethodReturnValueHandler {

    // 處理器是否支持返回值類型
    boolean supportsReturnType(MethodParameter returnType);

    // 將返回值解析爲HTTP響應報文
    void handleReturnValue(Object returnValue, MethodParameter returnType,
            ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception;

}

參數解析器和返回值處理器在底層處理時,都是通過HttpMessageConverter進行轉換。流程如下:

image

二、@RequestBody解析過程

所有的http請求都會進入ServletInvocableHandlerMethod類(繼承InvocableHandlerMethod,所有的參數解析器都會在在這裏面進行初始化)的invokeAndHandle方法中,我們來具體看看invokeAndHandle方法是幹什麼的。

public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer,
			Object... providedArgs) throws Exception {
    // 執行http請求
	Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);
		setResponseStatus(webRequest);
	if (returnValue == null) {
		if (isRequestNotModified(webRequest) || getResponseStatus() != null || mavContainer.isRequestHandled()) {
			disableContentCachingIfNecessary(webRequest);
			mavContainer.setRequestHandled(true);
			return;
		}
	}
	else if (StringUtils.hasText(getResponseStatusReason())) {
		mavContainer.setRequestHandled(true);
		return;
	}

	mavContainer.setRequestHandled(false);
	Assert.state(this.returnValueHandlers != null, "No return value handlers");
	// 返回值處理
	try {
		this.returnValueHandlers.handleReturnValue(
					returnValue, getReturnValueType(returnValue), mavContainer, webRequest);
	}catch (Exception ex) {
		if (logger.isTraceEnabled()){
		logger.trace(formatErrorForReturnValue(returnValue), ex);
		}
		throw ex;
	}
}

我們可以看到invokeAndHandle方法都會進入invokeForRequest方法中,invokeForRequest方法就是實現@RequestBody註解的功能,將http請求報文解析爲我們設置的對象。我們進入該方法看看,裏面具體做了哪些事情。

public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
			Object... providedArgs) throws Exception {
    // http報文解析爲對象數組
	Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
	if (logger.isTraceEnabled()) {
		logger.trace("Arguments: " + Arrays.toString(args));
	}
	//執行@PostMapping、@GetMapping等接口
	return doInvoke(args);
}

我們可以看到invokeForRequest中主要做了兩件事情,一個是通過getMethodArgumentValues方法返回http解析後的對象數組,然後通過doInvoke方法執行接口的具體業務邏輯代碼。

我們接着進入getMethodArgumentValues方法,細看一下@RequestBody的具體解析過程。

protected Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
			Object... providedArgs) throws Exception {
    
    // 獲取http請求參數
	MethodParameter[] parameters = getMethodParameters();
	if (ObjectUtils.isEmpty(parameters)) {
		return EMPTY_ARGS;
	}
	Object[] args = new Object[parameters.length];
	// 遍歷所有參數,挨個解析
	for (int i = 0; i < parameters.length; i++) {
		MethodParameter parameter = parameters[i];
		parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
		args[i] = findProvidedArgument(parameter, providedArgs);
		if (args[i] != null) {
			continue;
		}
		if (!this.resolvers.supportsParameter(parameter)) {
			throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver"));
		}
		try {
		    // 參數解析器解析HTTP報文
			args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
		}catch (Exception ex) {
			// Leave stack trace for later, exception may actually be resolved and handled...
			if (logger.isDebugEnabled()) {
				String exMsg = ex.getMessage();
					if (exMsg != null && !exMsg.contains(parameter.getExecutable().toGenericString())) {
					logger.debug(formatArgumentError(parameter, exMsg));
				}
			}
			throw ex;
		}
	}
	return args;
}

image

其中this.resolvers.supportsParameter(parameter)用來判斷請求參數是否合法,this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory)方法最終實現@RequestBody解析操作。我們來看看this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory)中做了什麼。

@Override
@Nullable
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
    // 獲取對應的解析器
	HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
	if (resolver == null) {
		throw new IllegalArgumentException(
					"Unsupported parameter type [" + parameter.getParameterType().getName() + "]." +
							" supportsParameter should be called first.");
	}
	// 通過HandlerMethodArgumentResolver 解析器解析http報文
	return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
}

getArgumentResolver方法來獲取對應的HandlerMethodArgumentResolver參數解析器,參數解析器最終通過RequestResponseBodyMethodProcessor類來具體執行解析過程,我們接着來看看RequestResponseBodyMethodProcessor中resolveArgument方法又是怎樣的一個處理過程。

不同的resolvers(HandlerMethodArgumentResolver接口)會對應不同的參數解析器,例如public String testDemo(String name),解析器就會變成ServletRequestMethodArgumentResolver,如果是@RequestBody,參數解析器就是RequestResponseBodyMethodProcessor

@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

	parameter = parameter.nestedIfOptional();
	// 通過HttpMessageConverter來解析http報文爲Object對象
	Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
	String name = Conventions.getVariableNameForParameter(parameter);

	if (binderFactory != null) {
		WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
		if (arg != null) {
			validateIfApplicable(binder, parameter);
			if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
					throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
			}
		}
		if (mavContainer != null) {
			mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
		}
	}
	return adaptArgumentIfNecessary(arg, parameter);
}

readWithMessageConverters方法中,HttpMessageConverter(接口對應實現類)的read方法實現了http報文解析,我們來看看最終http參數解析部分的代碼。

protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
			Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {

	....
		
	try {
		message = new EmptyBodyCheckingHttpInputMessage(inputMessage);

		for (HttpMessageConverter<?> converter : this.messageConverters) {
			Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
			GenericHttpMessageConverter<?> genericConverter =
						(converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null);
			// 判斷轉換器是否支持參數類型
			if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
						(targetClass != null && converter.canRead(targetClass, contentType))) {
				if (message.hasBody()) {
					HttpInputMessage msgToUse =
								getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
					// read方法執行HTTP報文到參數的轉換
					body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
								((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
					body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
				}else {
					body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
				}
				break;
			}
		}
	}catch (IOException ex) {
		throw new HttpMessageNotReadableException("I/O error while reading input message", ex, inputMessage);
	}

	....
}

代碼部分省略了,關鍵部分即是遍歷所有的HttpMessageConverter,然後通過canRead方法判斷解析器是否支持,最後執行AbstractJackson2HttpMessageConverter對象(HttpMessageConverter實現類)的read方法完成最後的參數解析。

AbstractJackson2HttpMessageConverter對象的read方法,核心是利用了jackson工具,將http報文的json字符串轉換爲object對象並返回。

三、@ResponseBody返回值序列化過程

執行完doInvoke邏輯代碼之後,通過ServletInvocableHandlerMethod對象的invokeAndHandle方法,利用返回值處理器對返回值進行序列化輸出。

this.returnValueHandlers.handleReturnValue(returnValue, getReturnValueType(returnValue), mavContainer, webRequest);

returnValueHandlers爲HandlerMethodReturnValueHandlerComposite對象,該對象實現了HandlerMethodReturnValueHandler接口,我們接着來看看handleReturnValue方法的具體實現。

	@Override
	public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
			ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
        // 選擇合適的HandlerMethodReturnValueHandler返回值處理器
		HandlerMethodReturnValueHandler handler = selectHandler(returnValue, returnType);
		if (handler == null) {
			throw new IllegalArgumentException("Unknown return value type: " + returnType.getParameterType().getName());
		}
		// 執行返回值處理
		handler.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
	}

selectHandler方法會提供合適的HandlerMethodReturnValueHandler,用來處理返回值。

1002.png?ynotemdtimestamp=1585666184913uploading.4e448015.gif正在上傳…重新上傳取消image

我們看到的HandlerMethodReturnValueHandler處理器最終也是由RequestResponseBodyMethodProcessor實現的,我們具體來看看handleReturnValue方法。

handler(HandlerMethodReturnValueHandler)接口會根據不同類型選擇不同的返回值處理器,例如頁面跳轉類型的處理器就是ViewNameMethodReturnValueHandler。

@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
		ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
			throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {

		mavContainer.setRequestHandled(true);
		ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
		ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);

		// 調用HttpMessageConverter執行
		writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
	}
	
	
protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType,
			ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)
			throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
	....
	    for (HttpMessageConverter<?> converter : this.messageConverters) {
				GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ?
						(GenericHttpMessageConverter<?>) converter : null);
				// 判斷是否支持返回值類型,返回值類型很有可能不同,如String,Map,List等
				if (genericConverter != null ?
						((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) :
						converter.canWrite(valueType, selectedMediaType)) {
					body = getAdvice().beforeBodyWrite(body, returnType, selectedMediaType,
							(Class<? extends HttpMessageConverter<?>>) converter.getClass(),
							inputMessage, outputMessage);
					if (body != null) {
						Object theBody = body;
						LogFormatUtils.traceDebug(logger, traceOn ->
								"Writing [" + LogFormatUtils.formatValue(theBody, !traceOn) + "]");
						addContentDispositionHeader(inputMessage, outputMessage);
						if (genericConverter != null) {
						    // 執行返回值轉換
							genericConverter.write(body, targetType, selectedMediaType, outputMessage);
						}
						else {
						    // 執行返回值轉換
							((HttpMessageConverter) converter).write(body, selectedMediaType, outputMessage);
						}
					}
					else {
						if (logger.isDebugEnabled()) {
							logger.debug("Nothing to write: null body");
						}
					}
					return;
				}
			}
	
	....
}

我們看到最終還是由HttpMessageConverter(AbstractGenericHttpMessageConverter實現類)的write方法來進行對象的序列化輸出。

大家都知道@ResponseBody需要通過io流來讀取,也就HttpMessageConverter最終的write會寫入到io輸出流中,上面的createOutputMessage(webRequest)方法就是創建一個輸出流,我們來具體看看它的實現。

protected ServletServerHttpResponse createOutputMessage(NativeWebRequest webRequest) {
	HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
		Assert.state(response != null, "No HttpServletResponse");
		return new ServletServerHttpResponse(response);
}
	

public class ServletServerHttpResponse implements ServerHttpResponse {

	private final HttpServletResponse servletResponse;

	private final HttpHeaders headers;

	private boolean headersWritten = false;

	private boolean bodyUsed = false;


	/**
	 * Construct a new instance of the ServletServerHttpResponse based on the given {@link HttpServletResponse}.
	 * @param servletResponse the servlet response
	 */
	public ServletServerHttpResponse(HttpServletResponse servletResponse) {
		Assert.notNull(servletResponse, "HttpServletResponse must not be null");
		this.servletResponse = servletResponse;
		this.headers = new ServletResponseHttpHeaders();
	}
}

public interface ServletResponse {
    String getCharacterEncoding();

    String getContentType();

    ServletOutputStream getOutputStream() throws IOException;

    PrintWriter getWriter() throws IOException;

    void setCharacterEncoding(String var1);

    void setContentLength(int var1);

    void setContentLengthLong(long var1);

    void setContentType(String var1);

    void setBufferSize(int var1);

    int getBufferSize();

    void flushBuffer() throws IOException;

    void resetBuffer();

    boolean isCommitted();

    void reset();

    void setLocale(Locale var1);

    Locale getLocale();
}	

createOutputMessage方法中創建了ServletServerHttpResponse ,然後通過 ((HttpMessageConverter) messageConverter).write(outputValue, selectedMediaType, outputMessage)方法寫入到輸出流中。write方法的核心也是通過Jackson工具將對象解析爲json字符串。我們最後來看看write的核心處理方法writeInternal。

protected void writeInternal(Object object, @Nullable Type type, HttpOutputMessage outputMessage)
			throws IOException, HttpMessageNotWritableException {

		MediaType contentType = outputMessage.getHeaders().getContentType();
		JsonEncoding encoding = getJsonEncoding(contentType);
		JsonGenerator generator = this.objectMapper.getFactory().createGenerator(outputMessage.getBody(), encoding);
		try {
			writePrefix(generator, object);

			Object value = object;
			Class<?> serializationView = null;
			FilterProvider filters = null;
			JavaType javaType = null;

			if (object instanceof MappingJacksonValue) {
				MappingJacksonValue container = (MappingJacksonValue) object;
				value = container.getValue();
				serializationView = container.getSerializationView();
				filters = container.getFilters();
			}
			if (type != null && TypeUtils.isAssignable(type, value.getClass())) {
				javaType = getJavaType(type, null);
			}

			ObjectWriter objectWriter = (serializationView != null ?
					this.objectMapper.writerWithView(serializationView) : this.objectMapper.writer());
			if (filters != null) {
				objectWriter = objectWriter.with(filters);
			}
			if (javaType != null && javaType.isContainerType()) {
				objectWriter = objectWriter.forType(javaType);
			}
			SerializationConfig config = objectWriter.getConfig();
			if (contentType != null && contentType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM) &&
					config.isEnabled(SerializationFeature.INDENT_OUTPUT)) {
				objectWriter = objectWriter.with(this.ssePrettyPrinter);
			}
			objectWriter.writeValue(generator, value);

			writeSuffix(generator, object);
			generator.flush();
		}
		catch (InvalidDefinitionException ex) {
			throw new HttpMessageConversionException("Type definition error: " + ex.getType(), ex);
		}
		catch (JsonProcessingException ex) {
			throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getOriginalMessage(), ex);
		}
	}
	
	@Override
	public OutputStream getBody() throws IOException {
		this.bodyUsed = true;
		writeHeaders();
		return this.servletResponse.getOutputStream();
	}

image

objectWriter.writeValue(generator, value)方法中將value對象通過serialize序列化方法,將對象轉爲json字符串,然後設置到io流中。我們最後看看Jackson最終的序列化是怎麼樣的?

@Override
    public final void serialize(Object bean, JsonGenerator gen, SerializerProvider provider)
        throws IOException
    {
        if (_objectIdWriter != null) {
            gen.setCurrentValue(bean); // [databind#631]
            _serializeWithObjectId(bean, gen, provider, true);
            return;
        }
        // 設置json的開始符號("{")
        gen.writeStartObject(bean);
        if (_propertyFilterId != null) {
           // 循環將對象設置爲json字符串 serializeFieldsFiltered(bean, gen, provider);
        } else {
            serializeFields(bean, gen, provider);
        }
        // 設置json的結束符號("}")
        gen.writeEndObject();
    }

在serialize方法中通過JsonGenerator將要返回的對象轉爲json格式的字符串。

四、springMVC初始化

至此我們就基本走完了一個HTTP請求和響應的過程。現在你可能有個疑惑,SpringMVC我們都是開箱即用,這些參數解析器和返回值處理器在哪裏定義的呢?在覈心的HandlerAdapter實現類RequestMappingHandlerAdapter的初始化方法中定義的。

而在RequestMappingHandlerAdapter構造時,也同時初始化了衆多的HttpMessageConverter,以支持多樣的轉換需求。

WebMvcConfigurationSupport.java

protected final void addDefaultHttpMessageConverters(List<HttpMessageConverter<?>> messageConverters) {
    StringHttpMessageConverter stringConverter = new StringHttpMessageConverter();
    stringConverter.setWriteAcceptCharset(false);

    messageConverters.add(new ByteArrayHttpMessageConverter());
    messageConverters.add(stringConverter);
    messageConverters.add(new ResourceHttpMessageConverter());
    messageConverters.add(new SourceHttpMessageConverter<Source>());
    messageConverters.add(new AllEncompassingFormHttpMessageConverter());

    if (romePresent) {
        messageConverters.add(new AtomFeedHttpMessageConverter());
        messageConverters.add(new RssChannelHttpMessageConverter());
    }

    if (jackson2XmlPresent) {
        ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.xml().applicationContext(this.applicationContext).build();
        messageConverters.add(new MappingJackson2XmlHttpMessageConverter(objectMapper));
    }
    else if (jaxb2Present) {
        messageConverters.add(new Jaxb2RootElementHttpMessageConverter());
    }

    if (jackson2Present) {
        ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json().applicationContext(this.applicationContext).build();
        messageConverters.add(new MappingJackson2HttpMessageConverter(objectMapper));
    }
    else if (gsonPresent) {
        messageConverters.add(new GsonHttpMessageConverter());
    }
}

五、相關依賴

大家可能會發現springboot項目都沒有jackson相關的依賴,那爲什麼可以進行jackson的序列化呢,那是因爲在spring-boot-starter-web依賴中其實已經包含了jackson相關的依賴。

<dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.9.8</version>
      <scope>compile</scope>
      <optional>true</optional>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.dataformat</groupId>
      <artifactId>jackson-dataformat-cbor</artifactId>
      <version>2.9.8</version>
      <scope>compile</scope>
      <optional>true</optional>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.dataformat</groupId>
      <artifactId>jackson-dataformat-smile</artifactId>
      <version>2.9.8</version>
      <scope>compile</scope>
      <optional>true</optional>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.dataformat</groupId>
      <artifactId>jackson-dataformat-xml</artifactId>
      <version>2.9.8</version>
      <scope>compile</scope>
      <optional>true</optional>
    </dependency>

六、總結

看似簡簡單單的@RequestBody和@ResponseBody兩個註解,其實內部做了大量的準備工作。現在童鞋們明白這整個過程的實現原理吧。

想要更多幹貨、技術猛料的孩子,快點拿起手機掃碼關注我,我在這裏等你哦~

林老師帶你學編程https://wolzq.com

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章