使用自定义参数解析器同一个参数支持多种Content-Type

 更新时间:2021年08月18日 09:53:43   作者:DDF_YiChen  
这篇文章主要介绍了使用自定义参数解析器同一个参数支持多种Content-Type的操作,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教

一堆废话

事出有因, 原先上线的接口现在被要求用Java重写,按照原暴露出去的文档然后毫无疑问的,按照Java的惯例,

一定是@RequestBody然后去接收application/json;charset=utf-8,然后一通参数接收处理逻辑。

结果测试都通过了,上线的时候,刚把原接口切到新接口上,日志就狂飙

application/x-www-form-urlencoded;charset=utf-8 NOT SUPPORT

What?然后就一通问号脸。赶紧把接口切回到老接口,然后跑去问PHP的同事,什么情况,原对接参数不是json吗?

然后才明白,草,原来要同时支持application/json;charset=utf-8和application/x-www-form-urlencoded;charset=utf-8

PHP咱是不懂的,但是Java对这个需求的原生支持却不是很好,印象中没有现成的。

因为一般我们定义对象接收参数,如果使用了@RequestBody接收,那么传参一定要使用post+一个对应的参数解析器一个可读的流,按照现在的情况即application/json;charset=utf-8。

要么是直接一个对象接收,不要加任何注解,这个时候对应的Content-Type是application/x-www-form-urlencoded;charset=utf-8则参数可正常解析。

但是这两种情况是矛盾的,如果一个加了@RequestBody的参数对应的Content-Type是application/x-www-form-urlencoded;charset=utf-8, 则最终无法解析。反过来如果一个未加@RequestBody的参数对应的Content-Type是application/json;charset=utf-8则也无法解析。

那么现在就只能来看一下如何定义一个自定义的参数解析器来完成这个需求了。但是这个自定义的参数解析器还有点不太一样,因为数据格式本身不是我们自定义的,本身就存在对应标准的解析器。只是SpringMVC在根据参数去找对应解析器的时候没有对应起来。我们现在只要让自己的解析器能够让这个参数转发到对应可以解析的参数解析器上就可以了。

探究Springmvc参数解析器工作流程

现在就要不怕麻烦的还看一下原来的参数解析器是如何工作的,毕竟不知道它怎么写的我也不知道怎么抄。

SpringMVC项目,二话不说,直接找到org.springframework.web.servlet.DispatcherServlet#doDispatch这个方法,看整个处理器的流程,这里直接简化找到最终映射到方法后的执行

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

方法跳转流程如下

public abstract class AbstractHandlerMethodAdapter extends WebContentGenerator implements HandlerAdapter, Ordered {
 @Override
 @Nullable
 public final ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
   throws Exception {
  return handleInternal(request, response, (HandlerMethod) handler);
 }
}

我再跳

org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter#handleInternal
public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
  implements BeanFactoryAware, InitializingBean {
 /**
  * 参数解析器列表
  */
 @Nullable
 private HandlerMethodArgumentResolverComposite argumentResolvers;
 
 @Override
 protected ModelAndView handleInternal(HttpServletRequest request,
   HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
  ModelAndView mav;
  checkRequest(request);
  // 这里删除了大量相关判断方法,只关注实际跳转执行方法
  // No synchronization on session demanded at all...
  mav = invokeHandlerMethod(request, response, handlerMethod); 
 }
 
 /**
  * 上面跳到了这里
  */
 @Nullable
 protected ModelAndView invokeHandlerMethod(HttpServletRequest request,
   HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
  // 这里又删除了大量的代码
  ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
  // 将本类自己的参数解析器列表赋值给ServletInvocableHandlerMethod 
  if (this.argumentResolvers != null) {
   invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
  }
  if (this.returnValueHandlers != null) {
   invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
  }
  invocableMethod.setDataBinderFactory(binderFactory);
  invocableMethod.setParameterNameDiscoverer(this.parameterNameDiscoverer);
  // 带着使命继续往下执行
  invocableMethod.invokeAndHandle(webRequest, mavContainer);
 }
}

接着跳到了

org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod#invokeAndHandle
public class ServletInvocableHandlerMethod extends InvocableHandlerMethod {
 public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer,
   Object... providedArgs) throws Exception {
  Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);
  // 再删除无关代码
 }
}

这里总算看到了一些关键信息了

org.springframework.web.method.support.InvocableHandlerMethod#invokeForRequest
public class InvocableHandlerMethod extends HandlerMethod {
 @Nullable
 public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
   Object... providedArgs) throws Exception {
  // 这里就是解析参数
  Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
  if (logger.isTraceEnabled()) {
   logger.trace("Arguments: " + Arrays.toString(args));
  }
  return doInvoke(args);
 }
 
 /**
  * 上面方法跳到了这里
  */
 protected Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
   Object... providedArgs) throws Exception {
  // 这里就是获取入参的参数类型,如单个字符串的每个参数,或者是某个对象参数,甚至是HttpServletRequest
  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;
   }
   // org.springframework.web.method.support.HandlerMethodArgumentResolverComposite#supportsParameter
   // 后面就贴出了这个方法的内部,就是遍历所有的参数解析器判断是否能够解析当前参数
   if (!this.resolvers.supportsParameter(parameter)) {
    throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver"));
   }
   try {
       // 获取参数解析器,解析当前参数
    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;
 }
}

上面关于找到参数解析器的关键代码

org.springframework.web.method.support.HandlerMethodArgumentResolverComposite
public class HandlerMethodArgumentResolverComposite implements HandlerMethodArgumentResolver {
  private final List<HandlerMethodArgumentResolver> argumentResolvers = new LinkedList<>();
 private final Map<MethodParameter, HandlerMethodArgumentResolver> argumentResolverCache =
   new ConcurrentHashMap<>(256);
 
 /**
  * 判断参数是否能够找到对应的参数解析器
  */
 @Override
 public boolean supportsParameter(MethodParameter parameter) {
  return getArgumentResolver(parameter) != null;
 }
 
 @Nullable
 private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
  // 这里指向一个本地缓存,如果一个参数类型可以被某个参数解析器解析,则缓存下次无须遍历
  HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
  if (result == null) {
      // 遍历所有的参数解析器,判断是否支持当前参数类型
   for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
    if (resolver.supportsParameter(parameter)) {
     result = resolver;
     // 如果支持则放入本地缓存,下次直接从缓存中取
     this.argumentResolverCache.put(parameter, result);
     break;
    }
   }
  }
  return result;
 }
 
 @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.");
  }
  // 解析参数
  return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
 } 
}

通过对

org.springframework.web.method.support.HandlerMethodArgumentResolverComposite#getArgumentResolver

的调试, 我们贴出几张图来说明系统中目前所有的参数解析器,以及我们目前需要的用来解析@RequestBody和application/x-www-form-urlencoded对应的参数解析器

application/x-www-form-urlencoded对应的解析器为ServletModelAttributeMethodProcessor
@RequestBody application/json对应的解析器为RequestResponseBodyMethodProcessor
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
 @Override
 public boolean supportsParameter(MethodParameter parameter) {
     // 可以看到必须要有RequestBody这个注解,该参数解析器才会工作
  return parameter.hasParameterAnnotation(RequestBody.class);
 }
}

顺便说下为什么我们在controller方法入参的时候写HttpServletRequest和HttpServletResponse也能够入参进来,就是因为有对应的参数解析器,这里也给找出来了

org.springframework.web.servlet.mvc.method.annotation.ServletRequestMethodArgumentResolver
org.springframework.web.servlet.mvc.method.annotation.ServletResponseMethodArgumentResolver

不想看废话的可以直接进结果

定义一个注解用于标注在参数上,用以标识这个参数希望用我们的参数解析器进行解析

import java.lang.annotation.*;
/**
 * <p>标识参数可以被多个参数解析器尝试进行参数解析</p >
 *
 * 同一个参数支持application/json和application/x-www-form-urlencoded
 *
 * @see com.company.content.risk.order.common.handle.MultiArgumentResolverMethodProcessor
 * @author Snowball
 * @version 1.0
 * @date 2020/08/31 18:57
 */
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MultiArgumentResolver {
}

实现一个自定义参数解析器,要实现接口org.springframework.web.method.support.HandlerMethodArgumentResolver。在解析方法里我们去判断当前客户端传入的Content-Type, 如果是application/json则将参数解析交给RequestResponseBodyMethodProcessor, 如果是application/x-www-form-urlencoded, 则将参数解析交给ServletModelAttributeMethodProcessor。

HandlerMethodArgumentResolver接口要实现两个方法

  • supportsParameter 判断当前解析器是否支持入参对象
  • resolveArgument 解析逻辑
import com.company.content.risk.order.common.annotation.MultiArgumentResolver;
import com.google.common.collect.ImmutableList;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodArgumentResolverComposite;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor;
import org.springframework.web.servlet.mvc.method.annotation.ServletModelAttributeMethodProcessor;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
 * 自定义参数解析器用以支持同一个参数支持application/json和application/x-www-form-urlencoded解析
 *
 * @see MultiArgumentResolver
 * @author Snowball
 * @version 1.0
 * @date 2020/08/31 19:00
 */
public class MultiArgumentResolverMethodProcessor implements HandlerMethodArgumentResolver  {
    @Autowired
    private RequestMappingHandlerAdapter requestMappingHandlerAdapter;
    private static final String CONTENT_TYPE_JSON = "application/json";
    private static final String CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded";
    /**
     * 支持的content_type
     */
    private static final ImmutableList<String> SUPPORT_CONTENT_TYPE_LIST = ImmutableList.of(CONTENT_TYPE_JSON, CONTENT_TYPE_FORM_URLENCODED);
    /**
     * 参考这个写法, 同一个类型的参数解析后缓存对应的参数解析器,不过这里的key改为了Content-Type
     * @see HandlerMethodArgumentResolverComposite#argumentResolverCache
     */
    private final Map<String, HandlerMethodArgumentResolver> argumentResolverCache =
            new ConcurrentHashMap<>(8);
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameter().isAnnotationPresent(MultiArgumentResolver.class);
    }
    /**
     * 解析参数
     */
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        String contentType = webRequest.getHeader("Content-Type");
        isSupport(contentType);
        List<HandlerMethodArgumentResolver> argumentResolvers = requestMappingHandlerAdapter.getArgumentResolvers();
        HandlerMethodArgumentResolver handlerMethodArgumentResolver = argumentResolverCache.get(contentType);
        if (handlerMethodArgumentResolver != null) {
            return handlerMethodArgumentResolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
        }
        for (HandlerMethodArgumentResolver argumentResolver : argumentResolvers) {
            if (isJson(contentType) && argumentResolver instanceof RequestResponseBodyMethodProcessor) {
                argumentResolverCache.put(contentType, argumentResolver);
                return argumentResolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
            } else if (isFormUrlEncoded(contentType) && argumentResolver instanceof ServletModelAttributeMethodProcessor) {
                argumentResolverCache.put(contentType, argumentResolver);
                return argumentResolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
            }
        }
        return null;
    }
    private boolean isJson(String contentType) {
        return contentType.contains(CONTENT_TYPE_JSON);
    }
    private boolean isFormUrlEncoded(String contentType) {
        return contentType.contains(CONTENT_TYPE_FORM_URLENCODED);
    }
    /**
     * 判断当前参数解析器是否支持解析当前的Content-Type
     * @param contentType
     * @return
     * @throws HttpMediaTypeNotSupportedException
     */
    private boolean isSupport(String contentType) throws HttpMediaTypeNotSupportedException {
        if (contentType == null) {
            throw new HttpMediaTypeNotSupportedException("contentType不能为空");
        }
        boolean isMatch = false;
        for (String item : SUPPORT_CONTENT_TYPE_LIST) {
            if (contentType.contains(item)) {
                isMatch = true;
                break;
            }
        }
        if (!isMatch) {
            throw new HttpMediaTypeNotSupportedException("支持Content-Type" + SUPPORT_CONTENT_TYPE_LIST.toString());
        }
        return true;
    }

将参数解析器注册成bean,添加到系统参数解析器列表即可

import com.company.content.risk.order.common.handle.MultiArgumentResolverMethodProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/**
 * <p>web核心配置</p >
 *
 * @author Snowball
 * @version 1.0
 * @date 2020/08/31 18:57
 */
@Configuration
@Order
public class CoreWebConfig implements WebMvcConfigurer {
    /**
     * 注册自定义参数解析器
     * @return
     */
    @Bean
    public MultiArgumentResolverMethodProcessor multiArgumentResolverMethodProcessor() {
        return new MultiArgumentResolverMethodProcessor();
    }
    /**
     * 添加自定义参数解析器
     * @param resolvers
     */
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(0, multiArgumentResolverMethodProcessor());
    }
}

使用,将@MultiArgumentResolver标识在controller方法的某个入参对象即可

@PostMapping(value = "text/submit")
public OutApiResponse<OutTextResponseBody> submitText(@MultiArgumentResolver OutTextRequest outTextRequest) {
}

补充

在上面自定义参数解析器的类中,注入了一个bean,类为RequestMappingHandlerAdapter, 目的是为了从这个类中获取到目前系统中已有的参数解析器列表。那么如何知道这个类里面包含了哪些参数解析器呢?摘录相关代码如下。这个类实现了接口InitializingBean,在bean初始化完成后调用afterPropertiesSet,然后在里面判断加入了默认的参数解析器列表

public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
		implements BeanFactoryAware, InitializingBean {
	@Nullable
	private HandlerMethodArgumentResolverComposite argumentResolvers;
	
	@Override
	public void afterPropertiesSet() {
		// Do this first, it may add ResponseBody advice beans
		initControllerAdviceCache();
		if (this.argumentResolvers == null) {
			List<HandlerMethodArgumentResolver> resolvers = getDefaultArgumentResolvers();
			this.argumentResolvers = new HandlerMethodArgumentResolverComposite().addResolvers(resolvers);
		}
	}
	private List<HandlerMethodArgumentResolver> getDefaultArgumentResolvers() {
		List<HandlerMethodArgumentResolver> resolvers = new ArrayList<>();
		// Annotation-based argument resolution
		resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), false));
		resolvers.add(new RequestParamMapMethodArgumentResolver());
		resolvers.add(new PathVariableMethodArgumentResolver());
		resolvers.add(new PathVariableMapMethodArgumentResolver());
		resolvers.add(new MatrixVariableMethodArgumentResolver());
		resolvers.add(new MatrixVariableMapMethodArgumentResolver());
		resolvers.add(new ServletModelAttributeMethodProcessor(false));
		resolvers.add(new RequestResponseBodyMethodProcessor(getMessageConverters(), this.requestResponseBodyAdvice));
		resolvers.add(new RequestPartMethodArgumentResolver(getMessageConverters(), this.requestResponseBodyAdvice));
		resolvers.add(new RequestHeaderMethodArgumentResolver(getBeanFactory()));
		resolvers.add(new RequestHeaderMapMethodArgumentResolver());
		resolvers.add(new ServletCookieValueMethodArgumentResolver(getBeanFactory()));
		resolvers.add(new ExpressionValueMethodArgumentResolver(getBeanFactory()));
		resolvers.add(new SessionAttributeMethodArgumentResolver());
		resolvers.add(new RequestAttributeMethodArgumentResolver());
		// Type-based argument resolution
		resolvers.add(new ServletRequestMethodArgumentResolver());
		resolvers.add(new ServletResponseMethodArgumentResolver());
		resolvers.add(new HttpEntityMethodProcessor(getMessageConverters(), this.requestResponseBodyAdvice));
		resolvers.add(new RedirectAttributesMethodArgumentResolver());
		resolvers.add(new ModelMethodProcessor());
		resolvers.add(new MapMethodProcessor());
		resolvers.add(new ErrorsMethodArgumentResolver());
		resolvers.add(new SessionStatusMethodArgumentResolver());
		resolvers.add(new UriComponentsBuilderMethodArgumentResolver());
		// Custom arguments
		if (getCustomArgumentResolvers() != null) {
			resolvers.addAll(getCustomArgumentResolvers());
		}
		// Catch-all
		resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), true));
		resolvers.add(new ServletModelAttributeMethodProcessor(true));
		return resolvers;
	}
}

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

相关文章

  • Java在枚举类型中增加自定义方法详解

    Java在枚举类型中增加自定义方法详解

    这篇文章主要介绍了Java在枚举类型中增加自定义方法详解,对于枚举类型来说,除了无法继承它以外,基本可以将它看作一个普通的类,这意味着你可以在里面增加自定义的方法,甚至可以增加一个 main() 方法,需要的朋友可以参考下
    2023-11-11
  • Java中删除文件或文件夹的几种方法总结

    Java中删除文件或文件夹的几种方法总结

    这篇文章主要介绍了Java中删除文件或文件夹的几种方法总结,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2023-04-04
  • java之assert关键字用法案例详解

    java之assert关键字用法案例详解

    这篇文章主要介绍了java之assert关键字用法案例详解,本篇文章通过简要的案例,讲解了该项技术的了解与使用,以下就是详细内容,需要的朋友可以参考下
    2021-08-08
  • 一文搞懂Spring中的Bean作用域

    一文搞懂Spring中的Bean作用域

    scope用来声明容器中的对象所应该处的限定场景或者说该对象的存活时间,即容器在对象进入其 相应的scope之前,生成并装配这些对象,在该对象不再处于这些scope的限定之后,容器通常会销毁这些对象,这篇文章主要介绍了Spring中的Bean作用域,需要的朋友可以参考下
    2022-06-06
  • JAVA HashSet和TreeSet 保证存入元素不会重复的操作

    JAVA HashSet和TreeSet 保证存入元素不会重复的操作

    这篇文章主要介绍了JAVA HashSet和TreeSet 保证存入元素不会重复的操作,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-09-09
  • Java通过Callable实现多线程

    Java通过Callable实现多线程

    这篇文章主要介绍了Java通过Callable实现多线程,Callable的任务执行后可返回值,运行Callable任务可以拿到一个Future对象,Future表示异步计算的结果,它提供了检查计算是否完成的方法,以等待计算的完成,并检查计算的结果,需要的朋友可以参考下
    2023-10-10
  • SSM框架前后端信息交互实现流程详解

    SSM框架前后端信息交互实现流程详解

    这篇文章主要介绍了SSM框架前后端信息交互实现流程详解,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2020-07-07
  • 实例详解Java库中的LocalDate类

    实例详解Java库中的LocalDate类

    在做报表统计时,需要对指定时间内的数据做统计,则需要使用到时间日期API,下面这篇文章主要给大家介绍了关于Java库中LocalDate类的相关资料,文中通过实例代码介绍的非常详细,需要的朋友可以参考下
    2022-01-01
  • Spring BeanUtils忽略空值拷贝的方法示例代码

    Spring BeanUtils忽略空值拷贝的方法示例代码

    本文用示例介绍Spring(SpringBoot)如何使用BeanUtils拷贝对象属性忽略空置,忽略null值拷贝属性的用法,代码简单易懂,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧
    2022-03-03
  • Java设计模式之模板方法模式

    Java设计模式之模板方法模式

    这篇文章介绍了Java设计模式之模板方法模式,文中通过示例代码介绍的非常详细。对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2022-10-10

最新评论