SpringMVC内容协商与MessageConverter原理

紧接上篇的SpringMVC执行流程,到最后一个writeWithMessageConverters细节,该方法里面封装了SpringMVC如何处理响应请求,内容协商,HttpMessageConverter的内部细节.逐步分析可以掌握SpringMVC是如何知道浏览器想要什么格式的请求(json/xml或自定义的协议),底层如何将响应写出

分析之前需要先了解HandlerMethodReturnValueHandler接口

public interface HandlerMethodReturnValueHandler {
    // 是否支持
    boolean supportsReturnType(MethodParameter returnType);
    // 支持之后如何处理
    void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
			ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception;
}

我们标注了@RequestBody注解的接口会调用RequestResponseBodyMethodProcessor来处理这种响应

该类的实现也比较简单

// 标注了@ResponseBody
	@Override
	public boolean supportsReturnType(MethodParameter returnType) {
		return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) ||
				returnType.hasMethodAnnotation(ResponseBody.class));
	}
// 如何写出逻辑
	@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);

		// Try even with null return value. ResponseBodyAdvice could get involved.
		writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
	}

返回值处理器的原理

  1. 返回值处理器判断是否支持该类型的返回值supportsReturnType 方法如RequestResponseBodyMethodProcessor判断是否标注了@ResponseBody注解
  2. 调用ReturnValueHandler的 handleReturnValue方法进行返回值的处理
  3. 以RequestResponseBodyMethodProcessor为例
    1. 先进行内容协商,根据浏览器请求头中的Accept字段判断浏览器能接受什么类型
    2. 服务器遍历所有的HttpMessageConverter获取到自身能处理的所有MediaType
    3. 最终会找到MappingJackson2HttpMessageConverter可以处理application/json的请求,底层利用Jackson2Json的核心类ObjectMapper将响应写出去

image-20210203174103662

SpringMVC底层提供了默认的10个MessageConverter

  • ByteArrayHttpMessageConverter 用于处理byte数组
  • StringHttpMessageConverter 用于处理String类型
  • Resource用于处理org.springframework.core.io.Resource类型的返回值
  • ResourceRegionHttpMessageConverter 处理ResourceRegion
  • SourceHttpMessageConverter 处理excel相关的DOMSource/SAXSource/StAXSource/StreamSource/Source 类型
  • AllEncompassingFormHttpMessageConverter 处理MultiValueMap
  • MappingJackson2HttpMessageConverter 处理 this.objectMapper.canSerialize(clazz, causeRef)支持的类型
  • Jaxb2RootElementHttpMessageConverter 支持被@XmlRootElement标注的类型

内容协商

用于支持返回xml类型的数据需要加入

<dependency>
            <groupId>com.fasterxml.jackson.dataformat</groupId>
            <artifactId>jackson-dataformat-xml</artifactId>
</dependency>
  1. MediaType contentType = outputMessage.getHeaders().getContentType();// 查看之前是否在Response中已经设置了ContentType如果已经设置了直接用
  2. List acceptableTypes = getAcceptableMediaTypes(request); // 获取浏览器中可以接受的MediaType,底层使用contentNegotiationManager内容协商管理器中resolveMediaTypes方法根据内容协商管理器中的策略来匹配可以接受的类型,SpringMVC默认配置了HeaderContentNegotiationStrategy策略 默认获取请求头的Accept字段来确定 没有就是*/*
  3. 接着马上获取服务器能处理哪些类型getProducibleMediaTypes(request, valueType, targetType);
    1. 进入方法内部本质就是循环遍历所有的MessageConverter
    2. 调用MessageConverter#canWrite方法判断该MessageConverter能否处理该类型的数据
    3. 最终getProducibleMediaTypes方法返回获取到所有服务器能处理的媒体类型
  4. 进入最佳适配的媒体类型
    1. 进入一个双重for循环 外层循环浏览器可以接受的媒体类型 内存循环服务器提供的媒体类型,选择匹配的媒体类型
    2. 最媒体类型进行最佳排序sortBySpecificityAndQuality
    3. 最终得到selectedMediaType 选择最终的媒体类型
  5. 根据选择媒体类型 再次循环遍历判断所有的HttpMessageConverter找到能够处理该类型的MessageConverter
    1. 再次调用MessageConverter的canWrite方法获取到可以处理的Converter
    2. canwrite返回为true,进去该Converter的write方法
  6. write 方法进入MessageConverter的写出逻辑 以MappingJackson2HttpMessageConverter为例
    1. 底层调用了MappingJackson2的writeInternal 方法
    2. 底层给response设置了一些默认的响应头
    3. 调用MappingJackson2的核心类ObjectMapper#writeValue方法写出
    4. 再调用generator#flush方法将流刷新出去
    5. 最终调用原生response的getBody().flush()写入到响应流中
  7. 获取到最终封装好了ModelAndViewContainer对象,最终调用getModelAndView方法传入container和原生的request response得到ModelAndView
  8. 此时HandlerExecutionChain的hanlder方法真正返回了ModelAndView对象
  9. 后面进入视图渲染逻辑 下篇详细的分析

源码分析

writeWithMessageConverters方法

	protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType,
			ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)
			throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {

		Object body;
		Class<?> valueType;
		Type targetType;
		// 判断返回值是不是String
		if (value instanceof CharSequence) {
			body = value.toString();
			valueType = String.class;
			targetType = String.class;
		}
		else {
            // 获取返回值类型
			body = value;
			valueType = getReturnValueType(body, returnType);
			targetType = GenericTypeResolver.resolveType(getGenericType(returnType), returnType.getContainingClass());
		}
		// 判断是不是Resource类型 
		if (isResourceType(value, returnType)) {
			outputMessage.getHeaders().set(HttpHeaders.ACCEPT_RANGES, "bytes");
			if (value != null && inputMessage.getHeaders().getFirst(HttpHeaders.RANGE) != null &&
					outputMessage.getServletResponse().getStatus() == 200) {
				Resource resource = (Resource) value;
				try {
					List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
					outputMessage.getServletResponse().setStatus(HttpStatus.PARTIAL_CONTENT.value());
					body = HttpRange.toResourceRegions(httpRanges, resource);
					valueType = body.getClass();
					targetType = RESOURCE_REGION_LIST_TYPE;
				}
				catch (IllegalArgumentException ex) {
					outputMessage.getHeaders().set(HttpHeaders.CONTENT_RANGE, "bytes */" + resource.contentLength());
					outputMessage.getServletResponse().setStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value());
				}
			}
		}
		// 定义 最终选择的媒体类型
		MediaType selectedMediaType = null;
        // 如果之前已经操作过response设置了ContentType直接拿到用不进行内容协商
		MediaType contentType = outputMessage.getHeaders().getContentType();
		boolean isContentTypePreset = contentType != null && contentType.isConcrete();
		if (isContentTypePreset) {
			if (logger.isDebugEnabled()) {
				logger.debug("Found 'Content-Type:" + contentType + "' in response");
			}
			selectedMediaType = contentType;
		}
		else {
            // 进去内容协商核心逻辑
			HttpServletRequest request = inputMessage.getServletRequest();
            // 拿到浏览器请求的可以接受的所有媒体类型 本质是获取内容协商管理器contentNegotiationManager的所有策略挨个遍历调用strategy策略的resolveMediaTypes方法获取媒体类型,默认SpringMVC只有一个内容协商策略HeaderContentNegotiation本质就是从Accept字段获取
			List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
            // 获取所有服务器能处理的所有媒体类型,本质是遍历SpringMVC的所有MessageConverter调用canWrite方法是否能处理该类型数据,最终调用getSupportedMediaTypes拿到所有服务器能处理的媒体类型加入集合返回
			List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);

			if (body != null && producibleTypes.isEmpty()) {
				throw new HttpMessageNotWritableException(
						"No converter found for return value of type: " + valueType);
			}
			List<MediaType> mediaTypesToUse = new ArrayList<>();
            // 双重for循环匹配 浏览器接受和服务器能提供的媒体类型交集
			for (MediaType requestedType : acceptableTypes) {
				for (MediaType producibleType : producibleTypes) {
					if (requestedType.isCompatibleWith(producibleType)) {
						mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
					}
				}
			}
			if (mediaTypesToUse.isEmpty()) {
				if (body != null) {
					throw new HttpMediaTypeNotAcceptableException(producibleTypes);
				}
				if (logger.isDebugEnabled()) {
					logger.debug("No match for " + acceptableTypes + ", supported: " + producibleTypes);
				}
				return;
			}
			// 媒体选择排序
			MediaType.sortBySpecificityAndQuality(mediaTypesToUse);
			// 选择最佳的一个媒体类型 赋值给selectedMediaType
			for (MediaType mediaType : mediaTypesToUse) {
				if (mediaType.isConcrete()) {
					selectedMediaType = mediaType;
					break;
				}
				else if (mediaType.isPresentIn(ALL_APPLICATION_MEDIA_TYPES)) {
					selectedMediaType = MediaType.APPLICATION_OCTET_STREAM;
					break;
				}
			}

			if (logger.isDebugEnabled()) {
				logger.debug("Using '" + selectedMediaType + "', given " +
						acceptableTypes + " and supported " + producibleTypes);
			}
		}
		// 如果已经选择好了媒体类型
		if (selectedMediaType != null) {
			selectedMediaType = selectedMediaType.removeQualityValue();
            // 再次遍历所有MessageConverter 调用canWrite方法获取到能够处理该类型的Converter 调用write方法,将数据写入 response中
			for (HttpMessageConverter<?> converter : this.messageConverters) {
				GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ?
						(GenericHttpMessageConverter<?>) converter : null);
                // 判断能否处理
				if (genericConverter != null ?
						((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) :
						converter.canWrite(valueType, selectedMediaType)) {
                    // 重新包装一下body
					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) {
                            // 调用Converter的write 方法
							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;
				}
			}
		}

		if (body != null) {
			Set<MediaType> producibleMediaTypes =
					(Set<MediaType>) inputMessage.getServletRequest()
							.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);

			if (isContentTypePreset || !CollectionUtils.isEmpty(producibleMediaTypes)) {
				throw new HttpMessageNotWritableException(
						"No converter for [" + valueType + "] with preset Content-Type '" + contentType + "'");
			}
			throw new HttpMediaTypeNotAcceptableException(this.allSupportedMediaTypes);
		}
	}

获取浏览器能接受媒体类型逻辑

private List<MediaType> getAcceptableMediaTypes(HttpServletRequest request)
      throws HttpMediaTypeNotAcceptableException {
	// 使用内容协商管理器来处理
   return this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request));
}
	public List<MediaType> resolveMediaTypes(NativeWebRequest request) throws HttpMediaTypeNotAcceptableException {
		for (ContentNegotiationStrategy strategy : this.strategies) {
            // 遍历内容协商管理器的所有strage策略来获取 默认只有从请求头Accept字段获取的策略
			List<MediaType> mediaTypes = strategy.resolveMediaTypes(request);
			if (mediaTypes.equals(MEDIA_TYPE_ALL_LIST)) {
				continue;
			}
			return mediaTypes;
		}
		return MEDIA_TYPE_ALL_LIST;
	}

image-20210204103313988

HeaderContentNegotiationStrategy选择逻辑分析

	public List<MediaType> resolveMediaTypes(NativeWebRequest request)
			throws HttpMediaTypeNotAcceptableException {
		// 逻辑很简单从原生request拿到Accpet字段
		String[] headerValueArray = request.getHeaderValues(HttpHeaders.ACCEPT);
        // 如果没有返回 */*
		if (headerValueArray == null) {
			return MEDIA_TYPE_ALL_LIST;
		}
		// 有值就一一解析返回所有的MediaType
		List<String> headerValues = Arrays.asList(headerValueArray);
		try {
			List<MediaType> mediaTypes = MediaType.parseMediaTypes(headerValues);
			MediaType.sortBySpecificityAndQuality(mediaTypes);
			return !CollectionUtils.isEmpty(mediaTypes) ? mediaTypes : MEDIA_TYPE_ALL_LIST;
		}
		catch (InvalidMediaTypeException ex) {
			throw new HttpMediaTypeNotAcceptableException(
					"Could not parse 'Accept' header " + headerValues + ": " + ex.getMessage());
		}
	}

获取所有服务器支持的媒体类型getProducibleMediaTypes方法分析

	protected List<MediaType> getProducibleMediaTypes(
			HttpServletRequest request, Class<?> valueClass, @Nullable Type targetType) {

		Set<MediaType> mediaTypes =
				(Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
		if (!CollectionUtils.isEmpty(mediaTypes)) {
			return new ArrayList<>(mediaTypes);
		}
		else if (!this.allSupportedMediaTypes.isEmpty()) {
			List<MediaType> result = new ArrayList<>();
            // 核心逻辑遍历所有MessageConverter调用canWrite方法判断该Converter是否支持如果支持调用getSupportedMediaTypes 加入媒体集合返回
			for (HttpMessageConverter<?> converter : this.messageConverters) {
				if (converter instanceof GenericHttpMessageConverter && targetType != null) {
					if (((GenericHttpMessageConverter<?>) converter).canWrite(targetType, valueClass, null)) {
                        // 获取该Converter支持的媒体类型
						result.addAll(converter.getSupportedMediaTypes());
					}
				}
				else if (converter.canWrite(valueClass, null)) {
					result.addAll(converter.getSupportedMediaTypes());
				}
			}
			return result;
		}
		else {
			return Collections.singletonList(MediaType.ALL);
		}
	}

HttpMessageConverter的write方法核心逻辑

先设置了一些默认响应头,调用具体子类的writeInternal方法具体处理

	@Override
	public final void write(final T t, @Nullable final Type type, @Nullable MediaType contentType,
			HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {

		final HttpHeaders headers = outputMessage.getHeaders();
		addDefaultHeaders(headers, t, contentType);

		if (outputMessage instanceof StreamingHttpOutputMessage) {
			StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage;
			streamingOutputMessage.setBody(outputStream -> writeInternal(t, type, new HttpOutputMessage() {
				@Override
				public OutputStream getBody() {
					return outputStream;
				}
				@Override
				public HttpHeaders getHeaders() {
					return headers;
				}
			}));
		}
		else {
			writeInternal(t, type, outputMessage);
			outputMessage.getBody().flush();
		}
	}

writeInternal方法

底层使用了ObjectMappe获取到ObjectWriterr#writeValue执行具体写出操作

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

		MediaType contentType = outputMessage.getHeaders().getContentType();
		JsonEncoding encoding = getJsonEncoding(contentType);

		try (JsonGenerator generator = this.objectMapper.getFactory().createGenerator(outputMessage.getBody(), encoding)) {
			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);
			}
			// 拿到所有的ObjectMapper
			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 写出操作
			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);
		}
	}

自定义HttpMessageConverter处理自定义协议

示例: 如果我们需要自己定义个协议application/myContentType,需要自定义一个HttpMessageConverter重写接口中的方法,并添加到MessageConverter中

示例中只重写write相关方法,如果需要读还需要需要重写canRead 和 Read方法

/**
自定义 一个 MessageConverter 来处理Map类型和协议为application/myContentType的数据
*/
public class MyMapHttpMessageConverter implements HttpMessageConverter<Map> {
    @Override
    public boolean canRead(Class<?> clazz, MediaType mediaType) {
        return false;
    }

    @Override
    public boolean canWrite(Class<?> clazz, MediaType mediaType) {
        // 根据媒体类型判断是不是我们自己的媒体类型
        if (mediaType != null && clazz.isAssignableFrom(Map.class)) {
            return mediaType.toString().equalsIgnoreCase("application/myContentType");
        }
        return ClassUtils.isAssignable(Map.class, clazz);
    }

    @Override
    public List<MediaType> getSupportedMediaTypes() {
        return Collections.singletonList(MediaType.parseMediaType("application/myContentType"));
    }

    @Override
    public Map read(Class<? extends Map> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
        return null;
    }


    @Override
    public void write(Map map, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
        // 写出 简单的[k1=v1,k2=v2]的形式写出
        Object result = map.keySet().stream().map(k -> k + "=" + map.get(k)).collect(Collectors.joining(","));
        outputMessage.getBody().write(result.toString().getBytes());
    }
}

注册自定义的MessageConverter

extendMessageConverters添加我们自定义的MessageConverter

@Configuration
public class MyWebConfiguration implements WebMvcConfigurer {

    /**
     * 添加自己的messageConverter
     *
     * @param converters
     */
    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.add(new MyMapHttpMessageConverter());
    }

}

Controller

package com.corn.turorial.spring.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @author : Jim Wu
 * @version 1.0
 * @function :
 * @since : 2021/2/1 15:21
 */
@Controller
public class MVCDebugSourceCodeController {

    @GetMapping("/test/{id}")
    @ResponseBody
    public Map test(@RequestParam("code") String code,
                    @PathVariable("id") String id,
                    @RequestHeader("User-Agent") String userAgent,
                    @RequestBody String body,
                    @CookieValue("jsessionid") String cookie,
                    ModelAndView modelAndView,
                    Model model
    ) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", code);
        result.put("id", id);
        result.put("userAgent", userAgent);
        result.put("body", body);
        result.put("cookie", cookie);
        return result;
    }

}

发送请求

GET http://localhost:9999/test/111?code=3333
User-Agent: MS/111
Cookie: jsessionid=123123
Accept: application/myContentType

{
  "name": "zs",
  "age": 15
}

返回结果

image-20210204195228880

添加参数形式修改内容协商方式

有一些情况下,我们没有办法修改请求头中的Accept字段 SpringBoot默认还提供了一种通过携带参数的方式来修改内容协商模式

开启方式

  spring:
      mvc:
        contentnegotiation:
          favor-parameter: true # 开启基于参数的协商协议

本质是在ContentNegotiationManager添加了一个stratege策略 ParameterContentNegotiation策略,该策略是获取format参数中值来获取MediaType,开启了基于参数的协商模式之后这个Stratege优先级高于基于Header Accept的Stratege

pom 加入xml支持开始测试

<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
</dependency>
GET http://localhost:9999/test/111?code=3333&format=xml
User-Agent: MS/111
Cookie: jsessionid=123123

{
  "name": "zs",
  "age": 15
}

###
GET http://localhost:9999/test/111?code=3333&format=json
User-Agent: MS/111
Cookie: jsessionid=123123

{
  "name": "zs",
  "age": 15
}

添加了format参数 为xml返回的就是xml

image-20210204200304745

但现在format参数不能满足我们自定义的协议,默认这个策略只支持xml和json如果需要满足我们自定义的协议,注意的是一旦覆盖了WebMvcConfigurer#configureContentNegotiation的方法.需要自己把HeaderContentNegotiationStrategy添加进去

    /**
     * 注意覆盖这个方法之后 默认HeaderContentNegotiationStrategy为被覆盖
     *
     * @param configurer
     */
    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        List<ContentNegotiationStrategy> strategies = new ArrayList<>();
        Map<String, MediaType> mediaTypes = new HashMap<>();
        mediaTypes.put("json", MediaType.APPLICATION_JSON);
        mediaTypes.put("xml", MediaType.APPLICATION_XML);
        mediaTypes.put("myContentType", MediaType.parseMediaType("application/myContentType"));
        // ParameterContentNegotiationStrategy 构造器可以传入map 添加自定义的MediaType和format参数值的映射关系
        ParameterContentNegotiationStrategy parameterStrategy = new ParameterContentNegotiationStrategy(mediaTypes);
        // 为了避免与前端传入的参数名重复还可以自定义参数名
        parameterStrategy.setParameterName("myFormat");
        strategies.add(parameterStrategy);
        strategies.add(new HeaderContentNegotiationStrategy());
        configurer.strategies(strategies);
    }
GET http://localhost:9999/test/111?code=3333&myFormat=myContentType
User-Agent: MS/111
Cookie: jsessionid=123123

{
  "name": "zs",
  "age": 15
}

image-20210204201826118

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值