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

前言

前面通过三篇文章介绍了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喽,作为课后作业,有兴趣者不妨一试,还是非常有作用的。特别在处理"老项目"的兼容性上非常好使,或许能让你大放异彩~

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券