首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >HandlerMethodArgumentResolver(四):自定参数解析器处理特定应用场景,介绍PropertyNamingStrategy的使用【享学Spring MVC】

HandlerMethodArgumentResolver(四):自定参数解析器处理特定应用场景,介绍PropertyNamingStrategy的使用【享学Spring MVC】

作者头像
YourBatman
发布2019-09-03 11:21:06
10.3K1
发布2019-09-03 11:21:06
举报

前言

前面通过三篇文章介绍了HandlerMethodArgumentResolver这个参数解析器以及它的所有内置实现,相信看过的小伙伴对它的加载、初始化、处理原理等等已能够做到了心中有数了。

Spring MVC内置注册了灰常多的处理器给我们的使用,不客气说几乎100%的case我们都是足够用了的。但既然我们已经理解到了HandlerMethodArgumentResolver它深层的作用原理,那么本文就通过自定义参数处理器,来做到屏蔽(隔离)基础实现、更高效的编写业务编码(提效是本文的关注点)。

使用场景

关于它的应用场景可以非常多,本文我总结出最为常见、好理解的两个应用场景作为举例说明:

  1. 获取当前登陆人(当然用户)的基本信息
  2. 调整(兼容)数据结构
场景一:

Controller层获取当前登陆人的基本信息(如id、名字…)是一个必须的、频繁的功能需求,这个时候如果团队内没有提供相关封装好的方法来调用,你便可看到大量的、重复的获取当前用户的代码,这就是各位经常吐槽的垃圾代码~

一般团队的做法是:提供BaseController,在基类里面提供获取当前用户的功能方法,这样业务控制器Controller只需要继承它就有这能力了,使用起来确实也还挺方便的。但是是否还思考过这种通过继承的方式它是有弊端的–>我只想获取当前登陆人我就得继承一个父类?这是不是设计太重了点?更坏的情况是如果此时我已经有父类了呢?

面对我提出的问题,本文针对性的提供一个新的、更加轻量的解决思路:自定义HandlerMethodArgumentResolver来实现获取当前登录用户的解决方案。实施步骤如下:

1、自定义一个参数注解(注解并不是100%必须的,可完全根据类型来决策)

/**
 * 用于获取当前登陆人信息的注解,配合自定义的参数处理器使用
 *
 * @see CurrUserArgumentResolver
 */
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CurrUser {
}

// 待封装的Vo
@Getter
@Setter
@ToString
public class CurrUserVo {
    private Long id;
    private String name;
}

2、自定义参数解析器**CurrUserArgumentResolver**并完成注册

public class CurrUserArgumentResolver implements HandlerMethodArgumentResolver {

    // 只有标注有CurrUser注解,并且数据类型是CurrUserVo/Map/Object的才给与处理
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        CurrUser ann = parameter.getParameterAnnotation(CurrUser.class);
        Class<?> parameterType = parameter.getParameterType();
        return (ann != null &&
                (CurrUserVo.class.isAssignableFrom(parameterType)
                        || Map.class.isAssignableFrom(parameterType)
                        || Object.class.isAssignableFrom(parameterType)));
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        // 从请求头中拿到token
        String token = request.getHeader("Authorization");
        if (StringUtils.isEmpty(token)) {
            return null; // 此处不建议做异常处理,因为校验token的事不应该属于它来做,别好管闲事
        }

        // 此处作为测试:new一个处理(写死的)
        CurrUserVo userVo = new CurrUserVo();
        userVo.setId(1L);
        userVo.setName("fsx");

        // 判断参数类型进行返回
        Class<?> parameterType = parameter.getParameterType();
        if (Map.class.isAssignableFrom(parameterType)) {
            Map<String, Object> map = new HashMap<>();
            BeanUtils.copyProperties(userVo, map);
            return map;
        } else {
            return userVo;
        }

    }
}

// 注册进Spring组件内
@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(new CurrUserArgumentResolver());
    }
}

3、书写测试例子

@Controller
@RequestMapping
public class HelloController {

    @ResponseBody
    @GetMapping("/test/curruser")
    public Object testCurrUser(@CurrUser CurrUserVo currUser) {
        return currUser;
    }
    @ResponseBody
    @GetMapping("/test/curruser/map")
    public Object testCurrUserMap(@CurrUser Map<String,Object> currUser) {
        return currUser;
    }
    @ResponseBody
    @GetMapping("/test/curruser/object")
    public Object testCurrUserObject(@CurrUser Object currUser) {
        return currUser;
    }
}

请求:/test/curruser或者/test/curruser/object 这两个请求得到的答案是一致的且符合预期,结果如下截图:

但是,但是,但是若访问/test/curruser/map,它的结果如下:

so参数类型是Map类型,自定义的参数解析器CurrUserArgumentResolver并没有生效,为什么呢???

带着这个疑问,接下来我说说对此非常重要的使用细节:

如何使用Spring容器内的Bean

在本例中,为了方便,我在CurrUserArgumentResolver里写死的自己new的一个CurrUserVo作为返回。实际应用场景中,此部分肯定是需要根据token去访问DB/Redis的,因此就需要使用到Spring容器内的Bean

有的小伙伴就想当然了,在本例上直接使用@Autowired HelloService helloService;来使用,经测试发现这是注入不进来的,helloService值为null。那么本文就教你正确的使用姿势:

  1. 姿势一:把自定义的参数解析器也放进容器 这是一种十分快捷、见效的解决方案。
@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {

    @Bean
    public CurrUserArgumentResolver currUserArgumentResolver(){
        return new CurrUserArgumentResolver();
    }
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(currUserArgumentResolver());
    }

这样,你在CurrUserArgumentResolver就可以顺理成章的注入想要的组件了,形如这样:

public class CurrUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Autowired
    HelloService helloService;
    @Autowired
    StringRedisTemplate stringRedisTemplate;
    ...
}

这种方案的优点是:在Spring容器内它几乎能解决大部分类似问题,在组件不是很多的情况下,推荐新手使用,因为无需过多的理解Spring内部机制便可轻松使用。

  1. 姿势二:借助AutowireCapableBeanFactory给对象赋能 本着"减轻"Spring容器"负担"的目的,"手动"精细化控制Spring内的Bean组件。像本文的这种解析器其实是完全没必要放进容器内的,需要什么组件让容器帮你完成注入即可,自己本文就没必要放进去喽:
@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {

    @Autowired
    private ApplicationContext applicationContext;
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        CurrUserArgumentResolver resolver = new CurrUserArgumentResolver();
        // 利用工厂给容器外的对象注入所需组件
        applicationContext.getAutowireCapableBeanFactory().autowireBean(resolver);
        argumentResolvers.add(resolver);
    }
}

本姿势的技巧是利用了AutowireCapableBeanFactory巧妙完成了给外部对象赋能,从而即使自己并不是容器内的Bean,也能自由注入、使用容器内Bean的能力(同样可以随意使用@Autowired注解了~)。

这种方式是侵入性最弱的,是我推荐的方式。当然这需要你对Spring容器有一定的了解才能运用自如,做到心中有数才行,否则不建议你使用~

可以和内置的一些注解/类型一起使用吗?(参数类型是Map类型?)

作为一个"合格"coder,理应发出如题这样的疑问。

譬如上例我这么写,你可以猜猜是什么结果:

@ResponseBody
@GetMapping("/test/curruser")
public Object testCurrUser(@CurrUser @RequestParam CurrUserVo currUser) {
    return currUser;
}

表面上看起来木有毛病,但请求:/test/curruser?currUser=fsx。报错如下:

Resolved [org.springframework.web.method.annotation.MethodArgumentConversionNotSupportedException: Failed to convert value of type 'java.lang.String' to required type 'com.fsx.validator.CurrUserVo'; 
nested exception is java.lang.IllegalStateException: Cannot convert value of type 'java.lang.String' to required type 'com.fsx.validator.CurrUserVo': no matching editors or conversion strategy found]

调试源码可以发现它最终使用的参数解析器是:**RequestParamMethodArgumentResolver**,而并非我们自定义的**CurrUserArgumentResolver**。so可得出结论:我们自定义的参数解析器的优先级是低于Spring内置的。

那么到底是什么样的优先级规则呢?我这里不妨给指出如下,供以大家学习:

1、首先就得从**RequestMappingHandlerAdapter**说起,它对参数解析器的加载(初始化)顺序:

RequestMappingHandlerAdapter:
	@Override
	public void afterPropertiesSet() {
		// 显然,也是允许你自己通过setArgumentResolvers()方法手动添加的~~~
		// 加入你调用过set方法,这里就不会执行啦~~~~~(一般不建议手动set)
		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());
		...

		// Type-based argument resolution
		// 比如request、response等等这些的解析器们
		resolvers.add(new ServletRequestMethodArgumentResolver());
		...
		
		// Custom arguments 
		// 加载自定义的解析器们(我们自定义的在这里会被加载进来)
		if (getCustomArgumentResolvers() != null) {
			resolvers.addAll(getCustomArgumentResolvers());
		}

		// Catch-all
		// 加载这两个用于兜底
		resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), true));
		resolvers.add(new ServletModelAttributeMethodProcessor(true));

		return resolvers;
	}

2、**RequestMappingHandlerAdapter**这个**Bean**配置处如下:

WebMvcConfigurationSupport:
	@Bean
	public RequestMappingHandlerAdapter requestMappingHandlerAdapter() {
		RequestMappingHandlerAdapter adapter = createRequestMappingHandlerAdapter();
		// 内容协商管理器
		adapter.setContentNegotiationManager(mvcContentNegotiationManager());
		// 消息转换器们
		adapter.setMessageConverters(getMessageConverters());
		// ConfigurableWebBindingInitializer:配置数据绑定、校验的相关配置项
		adapter.setWebBindingInitializer(getConfigurableWebBindingInitializer());
		// 参数解析器、返回值解析器
		adapter.setCustomArgumentResolvers(getArgumentResolvers());
		adapter.setCustomReturnValueHandlers(getReturnValueHandlers());
		...
	}

WebMvcConfigurationSupport应该没有不熟悉它的了,它用于开启WebMVC的配置支持~

从这个源码(配置顺序)中可以很清晰的得出答案:为何本例加了@RequestParam注解就访问就报错了;同样也解释了为何入参不能是Map(但Object类型是可以的~)。



在介绍场景二之前,我先介绍一个类:PropertyNamingStrategy

PropertyNamingStrategy

它表示序列化/反序列化过程中:Java属性到序列化key的一种命名策略。

默认情况下从字符串反序列为一个Java对象,要求需要完全一样才能反序列赋值成功。但了解了这些策略之后,可以帮你带来更好的兼容性,下面以最为常用的两个JSON库为例分别讲解~

Gson库对应的类叫FieldNamingStrategy,功能类似。因为我个人使用较少,所以此处忽略它~

fastjson中

fastjson在1.2.15版本(2016年6月)中提供了这个功能,它以枚举的形式管理:

public enum PropertyNamingStrategy {
	CamelCase, // 骆驼:
	PascalCase, // 帕斯卡:
	SnakeCase, // 蛇形:
	KebabCase; // 烤肉串:

	// 提供唯一一个实例方法:转换translate
    public String translate(String propertyName) {
	    switch (this) {
	    	case SnakeCase: { ... }
	    	case KebabCase: { ... }
	    	case PascalCase: { ... }
	    	case CamelCase: { ... }
	    }
    }
}

针对此4种策略,给出使用用例如下:

public static void main(String[] args)  {
    String propertyName = "nameAndAge";
    System.out.println(PropertyNamingStrategy.CamelCase.translate(propertyName)); // nameAndAge
    System.out.println(PropertyNamingStrategy.PascalCase.translate(propertyName)); // NameAndAge
    // 下面两种的使用很多的情况:下划线
    System.out.println(PropertyNamingStrategy.SnakeCase.translate(propertyName)); // name_and_age
    System.out.println(PropertyNamingStrategy.KebabCase.translate(propertyName)); // name-and-age
}

继续演示使用Fastjson序列化/反序列化的时候的示例:

public static void main(String[] args) {
    DemoVo vo = new DemoVo();
    vo.setDemoName("fsx");
    vo.setDemoAge(18);
    vo.setDemoNameAndAge("fsx18");

    PropertyNamingStrategy strategy = PropertyNamingStrategy.SnakeCase;
    // 序列化配置对象
    SerializeConfig config = new SerializeConfig();
    config.propertyNamingStrategy = strategy;
    // 反序列化配置对象
    ParserConfig parserConfig = new ParserConfig();
    parserConfig.propertyNamingStrategy = strategy;

    // 序列化对象
    String json = JSON.toJSONString(vo, config);
    System.out.println("序列化vo对象到json -> " + json);

    // 反序列化对象
    vo = JSON.parseObject(json, DemoVo.class, parserConfig);
    System.out.println("反序列化json到vo -> " + vo);
}

运行打印:

序列化vo对象到json -> {"demo_age":18,"demo_name":"fsx","demo_name_and_age":"fsx18"}
反序列化json到vo -> Main.DemoVo(demoName=fsx, demoAge=18, demoNameAndAge=fsx18)

若策略是SnakeCase,它是支持下划线_到驼峰格式的Java属性的相互转换的。若使用另外三种,我把结果摘录如下:

CamelCase:
序列化vo对象到json -> {"demoAge":18,"demoName":"fsx","demoNameAndAge":"fsx18"}
反序列化json到vo -> Main.DemoVo(demoName=fsx, demoAge=18, demoNameAndAge=fsx18)

PascalCase:
序列化vo对象到json -> {"DemoAge":18,"DemoName":"fsx","DemoNameAndAge":"fsx18"}
反序列化json到vo -> Main.DemoVo(demoName=fsx, demoAge=18, demoNameAndAge=fsx18)

KebabCase:
序列化vo对象到json -> {"demo-age":18,"demo-name":"fsx","demo-name-and-age":"fsx18"}
反序列化json到vo -> Main.DemoVo(demoName=fsx, demoAge=18, demoNameAndAge=fsx18)

FastJson默认使用CamelCase

题外话:除了上面那样分别在序列化时临时制定序列化、反序列化策略外,还可以用如下方式指定:

全局指定策略
SerializeConfig.getGlobalInstance().propertyNamingStrategy = PropertyNamingStrategy.PascalCase;
@JSONType指定
@JSONType(naming = PropertyNamingStrategy.SnakeCase)
private static class DemoVo {
    @JSONField(name = "name")
    private String demoName;
    private Integer demoAge;
    private Object demoNameAndAge;
}

@JSONField没有指定name属性,那就会使用PropertyNamingStrategy策略~

jackson中

除了fastjson,作为全球范围内更为流行的jackson自然也是支持此些策略的。

// was abstract until 2.7 在2.7版本之前一直是抽象类
public class PropertyNamingStrategy implements java.io.Serializable {
    public static final PropertyNamingStrategy SNAKE_CASE = new SnakeCaseStrategy();
    public static final PropertyNamingStrategy UPPER_CAMEL_CASE = new UpperCamelCaseStrategy();
    public static final PropertyNamingStrategy LOWER_CAMEL_CASE = new PropertyNamingStrategy();
    public static final PropertyNamingStrategy KEBAB_CASE = new KebabCaseStrategy();
	
	// 上面几个策略都是@since 2.7,这个基于@since 2.4
    public static final PropertyNamingStrategy LOWER_CASE = new LowerCaseStrategy();

 	// 提供的API方法如下:
	public String nameForField(MapperConfig<?> config, AnnotatedField field, String defaultName);
    public String nameForGetterMethod(MapperConfig<?> config, AnnotatedMethod method, String defaultName);
    public String nameForSetterMethod(MapperConfig<?> config, AnnotatedMethod method, String defaultName);
    public String nameForConstructorParameter(MapperConfig<?> config, AnnotatedParameter ctorParam, String defaultName);

	// 所有策略都使用静态内部类来实现(只需要实现translate方法即可)
	public static class SnakeCaseStrategy extends PropertyNamingStrategyBase
	public static class UpperCamelCaseStrategy extends PropertyNamingStrategyBase
	...
}

下面结合它的注解@JsonNaming来演示它的使用:

@Getter
@Setter
@ToString
// 此注解只能标注在类上
@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class)
private static class DemoVo {
    private String demoName;
    private Integer demoAge;
    @JsonProperty("diyProp")
    private Object demoNameAndAge;
}

public static void main(String[] args) throws IOException {
    DemoVo vo = new DemoVo();
    vo.setDemoName("fsx");
    vo.setDemoAge(18);
    vo.setDemoNameAndAge("fsx18");

    // 序列化对象
    ObjectMapper objectMapper = new ObjectMapper();
    String json = objectMapper.writeValueAsString(vo);
    System.out.println("序列化vo对象到json -> " + json);

    // 反序列化对象
    vo = objectMapper.readValue(json,DemoVo.class);
    System.out.println("反序列化json到vo -> " + vo);
}

打印输出结果:

序列化vo对象到json -> {"demo_name":"fsx","demo_age":18,"diyProp":"fsx18"}
反序列化json到vo -> Main.DemoVo(demoName=fsx, demoAge=18, demoNameAndAge=fsx18)

显然基于字段的注解@JsonProperty它的优先级是高于@JsonNaming

除此之外,jackson还提供了更多实用注解,有兴趣的可以自行去了解

我个人意见:jackson可能是由于功能设计得太过于全面了,使用起来有反倒很多不便之处,学习成本颇高。因为个人觉得还是我天朝的Fastjson好用啊~

说明:这些策略在异构的语言交互时是很有用的,因为各种语言命名规范都不尽相同,有了它们就可以有很好的兼容性。 如:.net命名都是大写开头形如DemoName表示属性名 如:js/python喜欢用下划线形全小写如demo_name表示属性名



场景二:

在微服务场景中有个特别常见的现象:跟第三方服务做对接时(如python老系统),你不乏会遇到如下两个痛点:

  1. 对方系统是以下划线形式命名的(和Java命名规范相悖)
  2. 对方系统的参数json串层次较深,而对你有用的仅仅是深处的一小部分

例如这个参数串:

{
    "data": {
        "transport_data": {
            "demo_name": "fsx",
            "demo_age": 18
        },
        "secret_info": {
            "code": "fkldshjfkldshj"
        }
    },
    "code": "200",
    "msg": "this is a message"
}

对你真正有用的只有demo_namedemo_age两个值,怎么破???

我相信绝大部分小伙伴都这么做:按照此结构先定义一个DTO全部接收回来(字段命名也用下划线方式命名),然后再一个个处理。若这么做虽然简单,我觉得还是有如下两个不妥的地方:

  1. Java属性名也必须用下划线命名,看起来影响了命名体系(其实就是看着不爽,哈哈)
  2. 按照参数这种复杂结构书写,使得我们关注点分散,不能聚焦到真真关心的那一块数据上

针对这些痛点,废话不多说,直接上我的处理方案:

1、定义一个模型(只写我自己关注的属性)

@Getter
@Setter
@ToString
public class TranUserVo {
    private String demoName;
    private Long demoAge;
}

定义的模型非常之简单,不仅只关心我要的数据,而且还是标准的java驼峰命名,没必要去迁就别的语言而丧失自己优雅性,否则容易把自己弄得四不像(万一又接python,又接.net呢?)~

2、自定义一个参数解析器并且注册上去

public class TranUserArgumentResolver implements HandlerMethodArgumentResolver {

    // 只处理这个类型,不需要注解
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> parameterType = parameter.getParameterType();
        return TranUserVo.class.isAssignableFrom(parameterType);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        HttpMethod httpMethod = HttpMethod.valueOf(request.getMethod());

        // 本例为了简单,演示get的情况(这里使用key为:demoKey)
        if (httpMethod == HttpMethod.GET) {
            String value = request.getParameter("demoKey");
            JSONObject transportData = (JSONObject) ((JSONObject) JSON.parseObject(value).get("data")).get("transport_data");

            // 采用命名策略,转换TranUserVo实例对象再返回
            // 序列化配置对象
            ParserConfig config = new ParserConfig();
            config.propertyNamingStrategy = PropertyNamingStrategy.SnakeCase;
            TranUserVo tranUserVo = transportData.toJavaObject(TranUserVo.class, config, 0);
            return tranUserVo;
        } else { // 从body提里拿
            // ...
            return null;
        }
    }
}

// 注册此自定义的参数解析器
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
    argumentResolvers.add(new TranUserArgumentResolver());
}

对此部分我说明一点:对于json到对象的解析,理应还加上@Valid校验的能力的,此部分我就省略了,毕竟也不是本文所关心的重点

测试用例:

@ResponseBody
@GetMapping("/test/tranuser")
public Object testCurrUser(TranUserVo tranUser) {
    return tranUser;
}

请求:/test/tranuser?demoKey=上面那一大长串json串,得到的结果就是预期的结果喽:

完美~

说明:这种长传现在需要使用post/put传递,本文只是为了简化演示,所以使用了GET请求,毕竟解析Body体不是本文所需讨论的~

总结

我认为,自定义参数解析器HandlerMethodArgumentResolver最重要不是它本身的实现,而是它的指导思想:分离关注,业务解耦。当然本文我摘出来的两个使用场景案例只是冰山一角,各位需要举一反三,才能融会贯通。

既然我们可以自定义参数处理器HandlerMethodArgumentResolver,自然也就可以自定义返回值处理器HandlerMethodReturnValueHandler喽,作为课后作业,有兴趣者不妨一试,还是非常有作用的。特别在处理"老项目"的兼容性上非常好使,或许能让你大放异彩~

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2019年09月02日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 使用场景
  • PropertyNamingStrategy
    • fastjson中
      • jackson中
        • 总结
          相关产品与服务
          文件存储
          文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档