SpringMVC之分析HandlerMethodArgumentResolver请求对应处理器方法参数的解析过程(一)

简介: 在我们做Web开发的时候,会提交各种数据格式的请求,而我们的后台也会有相应的参数处理方式。SpringMVC就为我们提供了一系列的参数解析器,不管你是要获取Cookie中的值,Header中的值,JSON格式的数据,URI中的值。

在我们做Web开发的时候,会提交各种数据格式的请求,而我们的后台也会有相应的参数处理方式。SpringMVC就为我们提供了一系列的参数解析器,不管你是要获取Cookie中的值,Header中的值,JSON格式的数据,URI中的值。下面我们分析几个SpringMVC为我们提供的参数解析器。

在SpringMVC中为我们定义了一个参数解析的顶级父类:HandlerMethodArgumentResolver。同时SpringMVC为我们提供了这么多的实现类:


这么多的类,看起来眼花缭乱的。下面选择几个常用的参数解析的类型来分析一下。在这之前先说一下HandlerMethodArgumentResolverComposite这个类,这个类是SpringMVC参数解析器的一个集合。

在前面的文章中我们大致说过SpringMVC请求处理的大致过程,首先我们先进入到RequestMappingHandlerAdapter#invokeHandlerMethod这个方法中,相关源码如下:

	protected ModelAndView invokeHandlerMethod(HttpServletRequest request,
			HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {

		ServletWebRequest webRequest = new ServletWebRequest(request, response);
		try {
			.............
			ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
			invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);//添加参数解析器
			invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);//添加请求返回的处理器
			invocableMethod.setDataBinderFactory(binderFactory);
			invocableMethod.setParameterNameDiscoverer(this.parameterNameDiscoverer);
			............
			invocableMethod.invokeAndHandle(webRequest, mavContainer);
			if (asyncManager.isConcurrentHandlingStarted()) {
				return null;
			}
			return getModelAndView(mavContainer, modelFactory, webRequest);
		}
		finally {
			webRequest.requestCompleted();
		}
	}
在上面的代码中我们创建了一个ServletInvocableHandlerMethod对象,在这个对象中设置了参数解析器、返回值处理器、数据校验工厂类等。接着我们进入到invocableMethod.invokeAndHandle这个方法中看一下(省略了其他代码):

	public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer,
			Object... providedArgs) throws Exception {

		Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);
		....................
		....................
	}
在invokeForRequest这个方法中,主要干了两件事,一是解析请求参数,二是调用Controller中的请求方法。这里我们主要关注的是参数解析的部分:

	public Object invokeForRequest(NativeWebRequest request, ModelAndViewContainer mavContainer,
			Object... providedArgs) throws Exception {
		//解析请求参数
		Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);

		Object returnValue = doInvoke(args);
		
		return returnValue;
	}
请求解析的方法是getMethodArgumentValues,这个是我们要分析的重点:

	private Object[] getMethodArgumentValues(NativeWebRequest request, ModelAndViewContainer mavContainer,
			Object... providedArgs) throws Exception {
		//获取所有执行的方法的参数信息 
		MethodParameter[] parameters = getMethodParameters();
		Object[] args = new Object[parameters.length];
		for (int i = 0; i < parameters.length; i++) {
			MethodParameter parameter = parameters[i];
			parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
			//如果之前有预先设置值的话,则取预先设置好的值
			args[i] = resolveProvidedArgument(parameter, providedArgs);
			if (args[i] != null) {
				continue;
			}
			//获取能解析出方法参数值的参数解析器类
			if (this.argumentResolvers.supportsParameter(parameter)) {
				try {
					//解析出参数值
					args[i] = this.argumentResolvers.resolveArgument(
							parameter, mavContainer, request, this.dataBinderFactory);
					continue;
				}
				catch (Exception ex) {
					throw ex;
				}
			}
			//如果没有能解析方法参数的类,抛出异常
			if (args[i] == null) {
				throw new IllegalStateException("Could not resolve method parameter at index " +
						parameter.getParameterIndex() + " in " + parameter.getMethod().toGenericString() +
						": " + getArgumentResolutionErrorMessage("No suitable resolver for", i));
			}
		}
		return args;
	}
这里需要说的是argumentResolvers这个对象是HandlerMethodArgumentResolverComposite这个类。所有参数的解析都是委托这个类来完成的,这个类会调用真正的请求参数的解析的类:
	public boolean supportsParameter(MethodParameter parameter) {
		return (getArgumentResolver(parameter) != null);
	}
	private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
		//先看之前有没有解析过这个方法参数,如果解析过,则从缓存中取
		HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
		if (result == null) {
			//循环所有的参数解析类,匹配真正参数解析的类
			for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) {
				if (methodArgumentResolver.supportsParameter(parameter)) {
					result = methodArgumentResolver;
					//放到缓存中
					this.argumentResolverCache.put(parameter, result);
					break;
				}
			}
		}
		return result;
	}

RequestParamMethodArgumentResolver

我们先来看这样的一个请求:
http://localhost:8086/allRequestFormat/simpleClassObjectRequest?userName=zhangsan&id=123
后端对于的代码如下:
    @RequestMapping("simpleClassObjectRequest")
    public String simpleClassObjectRequest(Long id, String userName) {
        System.out.println(String.format("id:%d,userName:%s",id,userName));
        return "这是一个接受简单类型参数的请求";
    }
这样的写法相信大家都很熟悉,那么SpringMVC是怎么解析出请求中的参数给InvocableHandlerMethod#doInvoke方法当入参的呢?经过我们debug发现,这里methodArgumentResolver.supportsParameter所匹配到的HandlerMethodArgumentResolver的实现类是RequestParamMethodArgumentResolver。我们进入到RequestParamMethodArgumentResolver中看一下supportsParameter方法:
	public boolean supportsParameter(MethodParameter parameter) {
		//方法的参数中是否有RequestParam注解。
		if (parameter.hasParameterAnnotation(RequestParam.class)) {
			//方法的参数是否是Map
			if (Map.class.isAssignableFrom(parameter.nestedIfOptional().getNestedParameterType())) {
				String paramName = parameter.getParameterAnnotation(RequestParam.class).name();
				return StringUtils.hasText(paramName);
			}
			else {
				return true;
			}
		}
		else {
			//如果有RequestPart注解,直接返回faslse
			if (parameter.hasParameterAnnotation(RequestPart.class)) {
				return false;
			}
			parameter = parameter.nestedIfOptional();
			//是否是文件上传中的值
			if (MultipartResolutionDelegate.isMultipartArgument(parameter)) {
				return true;
			}//如果useDefaultResolution为true
			else if (this.useDefaultResolution) {
				return BeanUtils.isSimpleProperty(parameter.getNestedParameterType());
			}
			else {
				return false;
			}
		}
	}
在上面的代码中我们可以看到,请求对应的处理方法的中的参数如果带有RequestParam注解,则判断是不是Map类型的参数,如果不是,则直接返回true。如果没有RequestParam注解,则判断如果有RequestPart注解,则直接返回false,接着判断是否是文件上传表单中的参数值,如果不是,则接着判断useDefaultResolution是否为true。这里需要说明一下的是: argumentResolvers中有两个RequestParamMethodArgumentResolver bean一个useDefaultResolution为false,一个useDefaultResolution为true。当useDefaultResolution为false的bean是用来处理RequestParam注解的,useDefaultResolution为true的bean是用来处理简单类型的bean的。在我们这个例子中,useDefaultResolution的值为true。那么接下来回判断是不是简单类型参数,我们进到BeanUtils.isSimpleProperty这个方法中看一下:
	public static boolean isSimpleProperty(Class<?> clazz) {
		Assert.notNull(clazz, "Class must not be null");
		return isSimpleValueType(clazz) || (clazz.isArray() && isSimpleValueType(clazz.getComponentType()));
	}
	public static boolean isSimpleValueType(Class<?> clazz) {
		return (ClassUtils.isPrimitiveOrWrapper(clazz) || clazz.isEnum() ||
				CharSequence.class.isAssignableFrom(clazz) ||
				Number.class.isAssignableFrom(clazz) ||
				Date.class.isAssignableFrom(clazz) ||
				URI.class == clazz || URL.class == clazz ||
				Locale.class == clazz || Class.class == clazz);
	}
真正进行类型判断的方法是isSimpleValueType这个方法,如果请求对应处理类的方法的参数为 枚举类型、String类型、Long、Integer、Float、Byte、Short、Double、Date、URI、URL、Locale、Class、文件上传对象或者参数是数组,数组类型为上面列出的类型则返回true。即 我们的请求对应处理类的方法的参数为:枚举类型、String类型、Long、Integer、Float、Byte、Short、Double、Date、URI、URL、Locale、Class、文件上传对象或者参数是数组,数组类型为上面列出的类型,则请求参数处理类为:RequestParamMethodArgumentResolver。我们先看一下RequestParamMethodArgumentResolver的UML类图关系:

接着我们看一下resolveArgument的这个方法,我们在RequestParamMethodArgumentResolver这个方法中没有找到对应的resolveArgument方法,但是我们在他的父类中找到了resolveArgument这个方法源码如下:
	@Override
	public final Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
		//获取请求对应处理方法的参数字段值
		NamedValueInfo namedValueInfo = getNamedValueInfo(parameter);
		MethodParameter nestedParameter = parameter.nestedIfOptional();
		//解析之后的请求对应处理方法的参数字段值
		Object resolvedName = resolveStringValue(namedValueInfo.name);
		if (resolvedName == null) {
			throw new IllegalArgumentException(
					"Specified name must not resolve to null: [" + namedValueInfo.name + "]");
		}
		//解析参数值
		Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
		//如果从请求中得到的参数值为null的话
		if (arg == null) {
			//判断是否有默认值
			if (namedValueInfo.defaultValue != null) {
				arg = resolveStringValue(namedValueInfo.defaultValue);
			}//判断这个字段是否是必填,RequestParam注解默认为必填。
			else if (namedValueInfo.required && !nestedParameter.isOptional()) {
				handleMissingValue(namedValueInfo.name, nestedParameter, webRequest);
			}//处理null值
			arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType());
		}//如果为得到的参数值为null,且有默认的值
		else if ("".equals(arg) && namedValueInfo.defaultValue != null) {
			arg = resolveStringValue(namedValueInfo.defaultValue);
		}
		//参数的校验
		if (binderFactory != null) {
			//参数校验
		}
		//空实现
		handleResolvedValue(arg, namedValueInfo.name, parameter, mavContainer, webRequest);

		return arg;
	}
在这个方法中,首先获取到参数名字是什么,这里封装为了NamedValueInfo的一个对象,我们可以去getNamedValueInfo这个方法中看一下:
	private NamedValueInfo getNamedValueInfo(MethodParameter parameter) {
		NamedValueInfo namedValueInfo = this.namedValueInfoCache.get(parameter);//先从缓存中获取
		if (namedValueInfo == null) {//缓存中不存在这个值
			namedValueInfo = createNamedValueInfo(parameter);//创建NamedValueInfo对象
			namedValueInfo = updateNamedValueInfo(parameter, namedValueInfo);//更新刚才得到的NamedValueInfo对象
			this.namedValueInfoCache.put(parameter, namedValueInfo);//放入缓存中
		}
		return namedValueInfo;
	}
我们看一下createNamedValueInfo这个方法,这个方法在RequestParamMethodArgumentResolver中:
	protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
		RequestParam ann = parameter.getParameterAnnotation(RequestParam.class);//判断是否有RequestParam注解
		return (ann != null ? new RequestParamNamedValueInfo(ann) : new RequestParamNamedValueInfo());
	}
RequestParamNamedValueInfo对象的源码如下:
		public RequestParamNamedValueInfo() {
			super("", false, ValueConstants.DEFAULT_NONE);
		}

		public RequestParamNamedValueInfo(RequestParam annotation) {
			super(annotation.name(), annotation.required(), annotation.defaultValue());
		}
这两个构造函数的区别是,如果有RequestParam注解的话,则取ReuqestParam注解中的值,否则取默认的值,我们看一下updateNamedValueInfo这个方法的源码:
	private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) {
		String name = info.name;
		//如果上一步创建的NamedValueInfo中的name为空的话,
		if (info.name.isEmpty()) {
			//从MethodParameter中解析出参数的名字
			name = parameter.getParameterName();
			if (name == null) {
				throw new IllegalArgumentException(
						"Name for argument type [" + parameter.getNestedParameterType().getName() +
						"] not available, and parameter name information not found in class file either.");
			}
		}
		//转换默认值
		String defaultValue = (ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue);
		return new NamedValueInfo(name, info.required, defaultValue);
	}
这个方法的主要作用是获取参数的名字。学过反射的我们都知道通过反射的API只能获取方法的形参的类型,不能获取形参的名称,但是这里很明显我们需要获取到形参的名称,所以 这里获取形参的名称不是通过反射的方式获取,而是通过了一个叫ASM的技术来实现的。当我们获取到NamedValueInfo 之后,会对获取到的形成做进一步的处理,这里我们可以先不用关注,直接到resolveName这个方法中看一下:
	@Override
	protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
		//获取HttpServletRequest
		HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class);
		//判断是不是文件上传的请求
		MultipartHttpServletRequest multipartRequest =
				WebUtils.getNativeRequest(servletRequest, MultipartHttpServletRequest.class);
		//先从文件上传的请求中获取上传的文件对象(Part这种东西现在应该很少用了吧,所以这里就直接忽略了)
		Object mpArg = MultipartResolutionDelegate.resolveMultipartArgument(name, parameter, servletRequest);
		if (mpArg != MultipartResolutionDelegate.UNRESOLVABLE) {
			return mpArg;
		}
		
		Object arg = null;
		//如果是文件上传请求,
		if (multipartRequest != null) {
			//则获取文件上传请求中的普通表单项的值
			List<MultipartFile> files = multipartRequest.getFiles(name);
			if (!files.isEmpty()) {
				//如果只有一个值的话,则返回一个值,否则返回数组
				arg = (files.size() == 1 ? files.get(0) : files);
			}
		}
		//说明是普通的请求
		if (arg == null) {
			//则从request.getParameterValues中获取值
			String[] paramValues = request.getParameterValues(name);
			if (paramValues != null) {
				//如果只有一个值的话,则返回一个值,否则返回数组
				arg = (paramValues.length == 1 ? paramValues[0] : paramValues);
			}
		}
		return arg;
	}
这里同时支持了 获取文件上传对象、文件上传请求中的表单项参数值的获取,普通请求参数值的获取。对于普通请求参数值的获取是通过request.getParameterValues来获取的。如果我们没有从请求中获取到参数值的话,则先判断是否是有默认值(用RequestParam注解可以设置默认值),接着判断这个参数是否是必要的参数(RequestParam默认为必要参数),如果是必要的参数且这个值为null的话,则处理过程如下:
	protected void handleMissingValue(String name, MethodParameter parameter, NativeWebRequest request)
			throws Exception {

		HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class);
		if (MultipartResolutionDelegate.isMultipartArgument(parameter)) {
			if (!MultipartResolutionDelegate.isMultipartRequest(servletRequest)) {
				throw new MultipartException("Current request is not a multipart request");
			}
			else {
				throw new MissingServletRequestPartException(name);
			}
		}
		else {
			throw new MissingServletRequestParameterException(name,
					parameter.getNestedParameterType().getSimpleName());
		}
	}
如果是文件上传请求,则异常信息为:Current request is not a multipart request。普通请求,则异常信息为:"Required " + this.parameterType + " parameter '" + this.parameterName + "' is not present"。如果值为null的话,则会对null值进行处理,
	private Object handleNullValue(String name, Object value, Class<?> paramType) {
		if (value == null) {
			if (Boolean.TYPE.equals(paramType)) {
				return Boolean.FALSE;
			}
			else if (paramType.isPrimitive()) { //int long 等
				throw new IllegalStateException("Optional " + paramType.getSimpleName() + " parameter '" + name +
						"' is present but cannot be translated into a null value due to being declared as a " +
						"primitive type. Consider declaring it as object wrapper for the corresponding primitive type.");
			}
		}
		return value;
	}
如果参数类型为int、long等基本类型,则如果请求参数值为null的话,则会抛出异常,异常信息如下:"Optional " + paramType.getSimpleName() + " parameter '" + name +' is present but cannot be translated into a null value due to being declared as a primitive type. Consider declaring it as object wrapper for the corresponding primitive type."。
我们在上一步获取到的参数,会在下一步进行数据校验。
如果我们的请求换成这个:
http://localhost:8086/allRequestFormat/requestParamRequest?id=122
后台处理代码如下:
    @RequestMapping("requestParamRequest")
    public String requestParamRequest(@RequestParam("id") Long id) {
        System.out.println("参数ID为:" + id);
        return "这是一个带RequestParam注解的请求";
    }
这个处理过程,和我们上面说的处理过程基本是一致的。
这里再多说一些RequestParam这个注解,使用RequestParam注解的好处是,可以指定所要的请求参数的名称,缩短处理过程,可以指定参数默认值。
另外再多说一点:java类文件编译为class文件时,有release和debug模式之分,在命令行中直接使用javac进行编译的时候,默认的是release模式,使用release模式会改变形参中的参数名, 如果形成的名称变化的话,我们可能不能正确的获取到请求参数中的值。而IDE都是使用debug模式进行编译的。ant编译的时候,需要在ant的配置文件中指定debug="true"。 如果要修改javac编译类文件的方式的话,需要指定-g参数。即:javac -g 类文件。

我们最后再总结一下:如果你的请求对应处理类中的形参类型为:枚举类型、String类型、Long、Integer、Float、Byte、Short、Double、Date、URI、URL、Locale、Class、文件上传对象或者参数是数组,数组类型为上面列出的类型的话,则会使用RequestParamMethodArgumentResolver进行参数解析。

相关文章
|
26天前
|
前端开发 Java 微服务
《深入理解Spring》:Spring、Spring MVC与Spring Boot的深度解析
Spring Framework是Java生态的基石,提供IoC、AOP等核心功能;Spring MVC基于其构建,实现Web层MVC架构;Spring Boot则通过自动配置和内嵌服务器,极大简化了开发与部署。三者层层演进,Spring Boot并非替代,而是对前者的高效封装与增强,适用于微服务与快速开发,而深入理解Spring Framework有助于更好驾驭整体技术栈。
|
8月前
|
前端开发 Java 测试技术
微服务——SpringBoot使用归纳——Spring Boot中的MVC支持——@RequestParam
本文介绍了 `@RequestParam` 注解的使用方法及其与 `@PathVariable` 的区别。`@RequestParam` 用于从请求中获取参数值(如 GET 请求的 URL 参数或 POST 请求的表单数据),而 `@PathVariable` 用于从 URL 模板中提取参数。文章通过示例代码详细说明了 `@RequestParam` 的常用属性,如 `required` 和 `defaultValue`,并展示了如何用实体类封装大量表单参数以简化处理流程。最后,结合 Postman 测试工具验证了接口的功能。
445 0
微服务——SpringBoot使用归纳——Spring Boot中的MVC支持——@RequestParam
|
8月前
|
JSON 前端开发 Java
微服务——SpringBoot使用归纳——Spring Boot中的MVC支持——@RequestBody
`@RequestBody` 是 Spring 框架中的注解,用于将 HTTP 请求体中的 JSON 数据自动映射为 Java 对象。例如,前端通过 POST 请求发送包含 `username` 和 `password` 的 JSON 数据,后端可通过带有 `@RequestBody` 注解的方法参数接收并处理。此注解适用于传递复杂对象的场景,简化了数据解析过程。与表单提交不同,它主要用于接收 JSON 格式的实体数据。
687 0
|
8月前
|
前端开发 Java 微服务
微服务——SpringBoot使用归纳——Spring Boot中的MVC支持——@PathVariable
`@PathVariable` 是 Spring Boot 中用于从 URL 中提取参数的注解,支持 RESTful 风格接口开发。例如,通过 `@GetMapping(&quot;/user/{id}&quot;)` 可以将 URL 中的 `{id}` 参数自动映射到方法参数中。若参数名不一致,可通过 `@PathVariable(&quot;自定义名&quot;)` 指定绑定关系。此外,还支持多参数占位符,如 `/user/{id}/{name}`,分别映射到方法中的多个参数。运行项目后,访问指定 URL 即可验证参数是否正确接收。
437 0
|
8月前
|
JSON 前端开发 Java
微服务——SpringBoot使用归纳——Spring Boot中的MVC支持——@RequestMapping
@RequestMapping 是 Spring MVC 中用于请求地址映射的注解,可作用于类或方法上。类级别定义控制器父路径,方法级别进一步指定处理逻辑。常用属性包括 value(请求地址)、method(请求类型,如 GET/POST 等,默认 GET)和 produces(返回内容类型)。例如:`@RequestMapping(value = &quot;/test&quot;, produces = &quot;application/json; charset=UTF-8&quot;)`。此外,针对不同请求方式还有简化注解,如 @GetMapping、@PostMapping 等。
389 0
|
8月前
|
JSON 前端开发 Java
微服务——SpringBoot使用归纳——Spring Boot中的MVC支持——@RestController
本文主要介绍 Spring Boot 中 MVC 开发常用的几个注解及其使用方式,包括 `@RestController`、`@RequestMapping`、`@PathVariable`、`@RequestParam` 和 `@RequestBody`。其中重点讲解了 `@RestController` 注解的构成与特点:它是 `@Controller` 和 `@ResponseBody` 的结合体,适用于返回 JSON 数据的场景。文章还指出,在需要模板渲染(如 Thymeleaf)而非前后端分离的情况下,应使用 `@Controller` 而非 `@RestController`
298 0
|
4月前
|
前端开发 Java API
Spring Cloud Gateway Server Web MVC报错“Unsupported transfer encoding: chunked”解决
本文解析了Spring Cloud Gateway中出现“Unsupported transfer encoding: chunked”错误的原因,指出该问题源于Feign依赖的HTTP客户端与服务端的`chunked`传输编码不兼容,并提供了具体的解决方案。通过规范Feign客户端接口的返回类型,可有效避免该异常,提升系统兼容性与稳定性。
298 0
|
4月前
|
SQL Java 数据库连接
Spring、SpringMVC 与 MyBatis 核心知识点解析
我梳理的这些内容,涵盖了 Spring、SpringMVC 和 MyBatis 的核心知识点。 在 Spring 中,我了解到 IOC 是控制反转,把对象控制权交容器;DI 是依赖注入,有三种实现方式。Bean 有五种作用域,单例 bean 的线程安全问题及自动装配方式也清晰了。事务基于数据库和 AOP,有失效场景和七种传播行为。AOP 是面向切面编程,动态代理有 JDK 和 CGLIB 两种。 SpringMVC 的 11 步执行流程我烂熟于心,还有那些常用注解的用法。 MyBatis 里,#{} 和 ${} 的区别很关键,获取主键、处理字段与属性名不匹配的方法也掌握了。多表查询、动态
144 0
|
4月前
|
JSON 前端开发 Java
第05课:Spring Boot中的MVC支持
第05课:Spring Boot中的MVC支持
229 0
|
10月前
|
SQL Java 数据库连接
对Spring、SpringMVC、MyBatis框架的介绍与解释
Spring 框架提供了全面的基础设施支持,Spring MVC 专注于 Web 层的开发,而 MyBatis 则是一个高效的持久层框架。这三个框架结合使用,可以显著提升 Java 企业级应用的开发效率和质量。通过理解它们的核心特性和使用方法,开发者可以更好地构建和维护复杂的应用程序。
499 29

热门文章

最新文章

推荐镜像

更多
  • DNS