Web项目中经常会用token来进行用户的访问验证,那么在获得token之后,如果有很多地方需要根据token获得对应的用户信息,你会怎么获取?
本文给大家提供N种方式,对照一下,看看你的项目中所使用的方式属于哪个Level,是不是要赶快升级一下?
关于token生成、认证部分的操作本文不会涉及,也就是默认token是经过合法性校验的,本文将重点放在之后进行的业务相关处理,即基于token获取用户信息的方式(部分方式需要基于SpringBoot)。
Level1:手动获取
通常token会放在header当中,最低级的获取方式就是直接从header中获取token,然后通过token转换获得userId,示例代码如下:
@GetMapping("/level1") public Integer level1(HttpServletRequest request) { String token = request.getHeader("token"); log.info("level1 获得的token为:{}", token); Integer userId = TokenUtil.getUserIdByToken(token); log.info("userId={}", userId); return userId; }
这种方式最简单直观,还可以进一步封装,比如提供一个BaseController,封装公共的部分,本质是一样的,但又引入了继承关系。因此,通常适用于有少数地方使用的场景。如果有大量的地方使用,这样写比较麻烦,不推荐使用,也没什么技术含量。
Level2:过滤器token转userId
在上一种方案中,既然每一次调用都需要进行token和userId的转换,那就通过过滤器将这一转换过程统一处理。在过滤器中获得token,然后转换成userId,再把userId写回到header当中,使用时直接从header中拿userId即可。
先定义过滤器,示例代码如下:
@Slf4j @Component public class ArgumentFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; String token = httpRequest.getHeader("token"); Integer userId = TokenUtil.getUserIdByToken(token); log.info("filter获取用户Id={}", userId); HttpServletRequestWrapper requestWrapper = new HttpServletRequestWrapper(httpRequest) { @Override public String getHeader(String name) { if ("userId".equals(name)) { return userId + ""; } return super.getHeader(name); } }; filterChain.doFilter(requestWrapper, httpResponse); } }
这里主要通过实现Filter接口的doFilter方法(JDK8可用实现需要的接口方法即可),在request中获得token之后,通过HttpServletRequestWrapper将转换之后的userId放置在header当中。
SpringBoot项目中,需要对ArgumentFilter进行相应的配置,指定过滤的URL:
@Configuration public class FilterConfig { @Resource private ArgumentFilter argumentFilter; @Bean public FilterRegistrationBean<Filter> registerAuthFilter() { FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>(); registration.setFilter(argumentFilter); registration.addUrlPatterns("/level2"); registration.setName("authFilter"); // 值越小,Filter越靠前 registration.setOrder(1); return registration; } }
此时在Controller中使用如下:
@GetMapping("/level2") public Integer level2(HttpServletRequest request) { Integer userId = Integer.parseInt(request.getHeader("userId")); log.info("userId={}", userId); return userId; }
虽然这种方式已经进步了很多,但每次都要获得HttpServletRequest,然后再从其中获得userId,还是有一些不方便。能不能继续改进一下?那继续往下看。
Level3:参数匹配
上一种方式已经处理获得了userId,那么能不能做的更彻底一些,只需要在Controller方法上出现userId,就直接给它赋值呢?来看一下实现:
@Slf4j @Component public class ArgumentParamFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; String token = httpRequest.getHeader("token"); Integer userId = TokenUtil.getUserIdByToken(token); log.info("filter获取用户Id={}", userId); HttpServletRequestWrapper requestWrapper = new HttpServletRequestWrapper(httpRequest) { @Override public String[] getParameterValues(String name) { if ("userId".equals(name)) { return new String[]{userId.toString()}; } return super.getParameterValues(name); } @Override public Enumeration<String> getParameterNames() { Set<String> paramNames = new LinkedHashSet<>(); paramNames.add("userId"); Enumeration<String> names = super.getParameterNames(); while (names.hasMoreElements()) { paramNames.add(names.nextElement()); } return Collections.enumeration(paramNames); } }; filterChain.doFilter(requestWrapper, httpResponse); } }
这里从header中获取到token,转换为userId,然后匹配方法的参数名称,如果是userId,那么就将转换之后的userId赋值给对应的参数。相关filter配置与上一个方法一样,不再贴代码,来看一下Controller中的使用:
@GetMapping("/level3") public Integer level3(Integer userId) { log.info("userId={}", userId); return userId; }
只需在Controller中的方法参数上定义userId便可直接赋值,看起来是不是方便很多。但很明显上面只支持get请求,如果是Post方法并且参数是通过body体(Json格式)传输,那么参数往往是一个实体对象,比如User。能否直接将userId注入到User实体当中呢?
@Data public class User { private Integer userId; private String name; }
要实现直接注入到User对象中,还需要进一步改造。在上面的filter中再添加上针对body体传输方式的处理,在HttpServletRequestWrapper中再实现getInputStream方法:
@Override public ServletInputStream getInputStream() { byte[] requestBody; try { requestBody = StreamUtils.copyToByteArray(request.getInputStream()); Map map = JsonUtils.toObject(Map.class, new String(requestBody)); map.put("userId", userId); requestBody = JsonUtils.toJson(map).getBytes(); } catch (IOException e) { throw new RuntimeException(e); } final ByteArrayInputStream bais = new ByteArrayInputStream(requestBody); return new ServletInputStream() { @Override public int read() { return bais.read(); } @Override public boolean isFinished() { return false; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { } }; }
先读取流中的(JSON格式)数据,然后将信息解析成Map,在Map中添加上userId,再转换成JSON格式,最后再创建一个流将其写出。对应的Controller实现如下:
@PostMapping("/level3Post") public Integer level3Post(@RequestBody User user) { log.info("userId={}", user.getUserId()); return user.getUserId(); }
通过postman等工具测试一下,就会发现User对象中已经被注入了对应值。
至此,是不是就完美了?好像还有一些瑕疵。
第一个:虽然按照约定定义userId参数即可,但容易误伤,比如某些业务有自身的userId,不小心命名重复了,会有被覆盖的风险。
第二个:参数的名称只能是userId,且不能够灵活的定义其他名称。
第三个:如果想返回更多信息,比如用户(User)的信息,处理就变得更加复杂。而且如果body体传递的参数比较复杂,解析成Map再封装转换有一定的风险和性能问题。
那么,我们再进行改造升级一下,下面示例基于SpringBoot。
Level4:方法参数解析器
Spring提供了多种解析器Resolver,比如常用的统一处理异常的HandlerExceptionResolver。同时,还提供了用来处理方法参数的解析器HandlerMethodArgumentResolver。它包含2个方法:supportsParameter和resolveArgument。其中前者用来判断是否满足某个条件,当满足条件(返回true)则可进入resolveArgument方法进行具体处理操作。
基于HandlerExceptionResolver,我们可以分以下部分来进行实现:
- 自定义注解@CurrentUser,用于Controller方法上的User参数;
- 自定义LoginUserHandlerMethodArgumentResolver,实现HandlerMethodArgumentResolver接口,通过supportsParameter检查符合条件的参数,通过resolveArgument方法来将token转换成User对象,并赋值给参数。
- 注册HandlerMethodArgumentResolver到MVC当中。
下面来看具体的实现,先定义注解@CurrentUser:
@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface CurrentUser { }
注解就是用来做标识用的,标识指定的参数需要进行处理。对于注解了@CurrentUser的参数是由自定义的LoginUserHandlerMethodArgumentResolver来进行判断处理的:
@Component public class LoginUserHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(CurrentUser.class) && parameter.getParameterType().isAssignableFrom(User.class); } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container, NativeWebRequest request, WebDataBinderFactory factory) { // header中获取用户token String token = request.getHeader("token"); Integer userId = TokenUtil.getUserIdByToken(token); // TODO 根据userId获取User信息,这里省略,直接创建一个User对象。 User user = new User(); user.setName("Tom"); user.setUserId(userId); return user; } }
supportsParameter方法中通过两个条件来过滤参数,首先参数需要使用CurrentUser注解,同时参数的类型为User。当满足条件时返回true,进入resolveArgument进行处理。
在resolveArgument中,从header中获取token,然后根据token获取对应User信息,这里可以注入UserService来获得更多的用户信息,然后将构造好的User对象返回。这样,后续就可以将返回的User绑定到Controller中的参数上。
但此时自定义的Resolver并没有生效,还需要添加到MVC当中:
@Configuration public class WebConfig implements WebMvcConfigurer { @Resource private LoginUserHandlerMethodArgumentResolver loginUserHandlerMethodArgumentResolver; @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) { argumentResolvers.add(loginUserHandlerMethodArgumentResolver); } }
至此,便可以在Controller中使用该注解来获取用户信息了,具体使用如下:
@GetMapping("/level4") public Integer level4(@CurrentUser User user) { log.info("userId={},username={}", user.getUserId(), user.getName()); return user.getUserId(); }
上面介绍了直接注入一个User对象,如果你只需要userId,那么将User对象替换成Integer或Long类型即可。
通过这种形式,使用起来更加方便了,当我们需要获取User信息时,只需在请求的参数中使用@CurrentUser User即可。不需要的地方也不会出现误操作的可能,具有了充分的灵活性和可拓展性。
小结
本文通过一个场景的业务场景,从最基础的实现一路演变到具有一定设计性的实现,涉及到了拦截器、过滤器、注解等一些列的知识点和实战经验。这正是我们在项目开发时中不断演进的过程。你的项目中使用的哪种方式?是不是需要升级或体验一下了?