SpringMVC Custom ArgumentResolver

章远航
2023-12-01

我想大家做微信开发都涉及到微信授权这个问题。那么对于项目中需要授权的URL大家都是怎么设计开发的呢?我想大家一般都有2个方案。一种是用Servlet中的Filter,还有一种就是Spring MVC中的HandlerInterceptor。当需要获取微信中的用户信息时,我们可以在Cookie中添加相关的code然后使用拦截器机制对面URL进行授权。

1、方案思考

我们先来分析一下这2种方案。

  1. Filter:验证Filter的后面的Filter需要对域名进行判断。而且不能给Controller中的方法设置值。
  2. HandlerInterceptor:能够获取到参数,也不能给方法设置值。

那么有没有更好的办法来解决这个问题呢?答案是有的。我们可以使用Spring MVC中的自定义方法参数解析,然后用于换取微信授权返回的用户信息给HandleMethod也就是定义@RequstMapping的Controller中的方法来使用。这样就可以针对特殊URL,特殊的参数来区分可以是否授权。可以标记,Method将授权结果作为参数传入到方法中。

2、HandlerMethodArgumentResolver

public interface HandlerMethodArgumentResolver {

    /**
     * 方法参数是否被当前解析解析器支持
     */
    boolean supportsParameter(MethodParameter parameter);

    /**
     * 解析这个方法参数
     */
    Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception;

}

3、代码思路

该业务场景使用到的URL。(至少一次,最多三次)
流程1:COOKIE中有code,并且也拿到了正确的用户信息
流程2:COOKIE中有code,但是没有拿到了正确的用户信息(1),跳微信授权(2)授权回来(调用我们的URL回来重定向),CODE拿到了正确的用户信息(3) – 对应场景(以前没有授权,现在授权。或者之前是之前授权现在过期,之前是静默授权现在变成确认授权)。

这2种不同的流程可以思路cookie中的code,进行授权验证然后换取用户信息给HandleMethod使用。

4、代码实现

1)定义Spring MVC中Controller中的方法注解。用于方法参数解析。

/**
 * 微信身份信息
 */
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface WechatAuthInfo {

    public enum AidFrom {
        PATH_VARIABLE,
        REQUEST_PARAM,
        HOSTNAME
    }

    /**
     * 是否强制要求为非空,若为true,则表示没有授权信息时抛出异常,可在异常中进行授权和重定向
     */
    boolean required() default true;

    /**
     * 从哪里获取aid
     */
    AidFrom aidFrom() default AidFrom.PATH_VARIABLE;

    /**
     * 获取aid所用的path,如PathVariable中的名字或者RequestParam中的名字
     */
    String name() default "aid";
}

2)用户信息
定义用户信息可以在HandlerMethod中使用。

@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown=true)
public class SilentAuthorizationResult {
    @JsonProperty("AId")
    private Long aid;
    @JsonProperty("OpenId")
    private String openId;
    @JsonProperty("BizOpenId")
    private String bizOpenId;
}

3)微信地址

@Builder
@Log
public class OAuthProvider {

    private String silentAuthUrl;
    private String silentResultUrl;
    private String confirmAuthUrl;
    private String confirmResultUrl;

    public SilentAuthorizationResult silentAuth(String weimobSID) {
        try {
            String url = String.format(silentResultUrl, URLEncoder.encode(weimobSID, "utf-8"));

            ObjectNode response = HTTPClientUtils.sendHTTPRequest(url, null, "GET");
            SilentAuthorizationResult authInfo = JSON.parseObject(response.toString(), SilentAuthorizationResult.class);
            if (authInfo == null || StringUtils.isEmpty(authInfo.getOpenId())) {
                // 信息不完整
                return null;
            }
            return authInfo;
        } catch (Exception e) {
            // 网络错误或者。。。。
            log.info(e.getLocalizedMessage());
            return null;
        }
    }

    public ConfirmAuthorizationResult confirmAuth(String weimobSID) {
        try {
            String url = String.format(confirmResultUrl, URLEncoder.encode(weimobSID, "utf-8"));

            ObjectNode response = HTTPClientUtils.sendHTTPRequest(url, null, "GET");
            ConfirmAuthorizationResult authInfo = JSON.parseObject(response.toString(), ConfirmAuthorizationResult.class);
            if (authInfo == null || StringUtils.isEmpty(authInfo.getOpenId()) || StringUtils.isEmpty(authInfo.getNickName())) {
                // 信息不完整
                return null;
            }
            return authInfo;
        } catch (Exception e) {
            // 网络错误或者。。。。
            log.info(e.getLocalizedMessage());
            return null;
        }
    }

    public String silentUrl(Long aid, String url) {
        try {
            return String.format(silentAuthUrl, URLEncoder.encode(String.valueOf(aid), "utf-8"), URLEncoder.encode(String.valueOf(aid), "utf-8"), URLEncoder.encode(url, "utf-8"));
        } catch (UnsupportedEncodingException ignored) {
            //不会出现
        }
        return null;
    }

    public String confirmUrl(Long aid, String url) {
        try {
            return String.format(confirmAuthUrl, URLEncoder.encode(String.valueOf(aid), "utf-8"), URLEncoder.encode(String.valueOf(aid), "utf-8"), URLEncoder.encode(url, "utf-8"));
        } catch (UnsupportedEncodingException ignored) {
            //不会出现
        }
        return null;
    }
}

4)自定义HandlerMethodArgumentResolver
实现Spring中的HandlerMethodArgumentResolver。进行微信验证与用户信息获取。

@ControllerAdvice
public class AuthInfoMethodArgumentResolver implements HandlerMethodArgumentResolver {

    private static final Pattern HOST_PATTERN = Pattern.compile("^(\\d+)\\..*$");

    private OAuthProvider provider;

    private String baseUrl;

    public String getBaseUrl() {
        return baseUrl;
    }

    /**
     * 如 计算地址时会在后面加上controller里的地址和contextPath
     *
     * @param baseUrl 基础url
     */
    public void setBaseUrl(String baseUrl) {
        this.baseUrl = baseUrl;
    }

    public void setProvider(OAuthProvider provider) {
        this.provider = provider;
    }

    public OAuthProvider getProvider() {
        return provider;
    }

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> paramType = parameter.getParameterType();
        return parameter.hasParameterAnnotation(WechatAuthInfo.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        if (provider == null) {
            provider = OAuthProvider.builder().silentAuthUrl(Constant.WECHAT_SILENT_OAUTH_URL).confirmAuthUrl(Constant.WECHAT_CONFIRM_OAUTH_URL).silentResultUrl(Constant.WECHAT_SILENT_OAUTH_RESULT_URL).confirmResultUrl(Constant.WECHAT_CONFIRM_OAUTH_RESULT_URL).build();
        }

        WechatAuthInfo annotation = parameter.getParameterAnnotation(WechatAuthInfo.class);
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        boolean found = false;
        Long aid = null;
        String name = annotation.name();
        boolean isConfirm = ConfirmAuthorizationResult.class.isAssignableFrom(parameter.getParameterType());
        // 查找aid
        switch (annotation.aidFrom()) {
            case PATH_VARIABLE:
                Map<String, String> variables = getUriTemplateVariables(webRequest);
                String pathVariable = variables.get(name);
                if (StringUtils.hasText(pathVariable)) {
                    aid = Long.valueOf(pathVariable);
                }
                break;
            case REQUEST_PARAM:
                String requestParam = request.getParameter(name);
                if (StringUtils.hasText(requestParam)) {
                    aid = Long.valueOf(requestParam);
                }
                break;
            case HOSTNAME:
                String host = request.getHeader("Host");
                if (StringUtils.hasText(host)) {
                    String hostValue = HOST_PATTERN.matcher(host).replaceAll("$1");
                    if (StringUtils.hasText(hostValue)) {
                        aid = Long.valueOf(hostValue);
                    }
                }
                break;
            default:
                break;
        }

        // aid没找到
        if (aid == null) {
            if (annotation.required()) {
                throw new MissingServletRequestParameterException(annotation.name(), Long.class.getName());
            }
            // 非必须
            return null;
        }

        // 重新计算url, 修正url
        String url = String.format(getBaseUrl(), aid) +
                (request.getPathInfo() == null ? request.getServletPath() : request.getPathInfo());
        StringBuilder currentUrl = new StringBuilder(url);
        if (StringUtils.hasText(request.getQueryString())) {
            currentUrl.append("?");
            currentUrl.append(request.getQueryString());
        }

        // 查找 cookie
        String weimobSID = null;
        String cookieName = String.format("SessionId_%s", aid);
        if (request.getCookies() != null) {
            for (Cookie cookie : request.getCookies()) {
                if (cookieName.equals(cookie.getName())) {
                    weimobSID = cookie.getValue();
                    if (StringUtils.hasText(weimobSID)) {
                        break;
                    }
                }
            }
        }

        // cookie没找到
        if (!StringUtils.hasText(weimobSID) && annotation.required()) {
            throw new WechatAuthInfoMissingException(isConfirm ? provider.confirmUrl(aid, currentUrl.toString()) : provider.silentUrl(aid, currentUrl.toString()), "Auth into required. redirecting");
        }

        // cookie找到
        SilentAuthorizationResult authInfo;
        if (isConfirm) {
            ConfirmAuthorizationResult confirmAuthInfo = provider.confirmAuth(weimobSID);
            // 信息不完整
            if ((confirmAuthInfo == null || StringUtils.isEmpty(confirmAuthInfo.getNickName())) && annotation.required()) {
                throw new WechatAuthInfoMissingException(provider.confirmUrl(aid, currentUrl.toString()), "Auth not completed. redirecting");
            }
            authInfo = confirmAuthInfo;
        } else {
            authInfo = provider.silentAuth(weimobSID);
            if ((authInfo == null || StringUtils.isEmpty(authInfo.getOpenId())) && annotation.required()) {
                throw new WechatAuthInfoMissingException(provider.silentUrl(aid, currentUrl.toString()), "Not auth. redirecting");
            }
        }

        return authInfo;
    }

    @SuppressWarnings("unchecked")
    protected final Map<String, String> getUriTemplateVariables(NativeWebRequest request) {
        Map<String, String> variables = (Map<String, String>) request.getAttribute(
                HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
        return (variables != null ? variables : Collections.<String, String>emptyMap());
    }

    public static class WechatAuthInfoMissingException extends ServletRequestBindingException {
        private static final long serialVersionUID = 2756877094069648764L;

        public WechatAuthInfoMissingException(String url, String msg) {
            super(msg);
            this.url = url;
        }

        public WechatAuthInfoMissingException(String msg, Throwable cause) {
            super(msg);
            if (cause != null) {
                initCause(cause);
            }
        }

        private String url;

        public String getUrl() {
            return url;
        }
    }

    /**
     * 如果BaseController没有处理,则此处为后备方案
     * fallback
     *
     * @param ex 异常详情
     * @return 处理结果
     * @throws Throwable 不兼容的异常
     */
    @ExceptionHandler(WechatAuthInfoMissingException.class)
    public ResponseEntity<String> onWechatAuthMissing(WechatAuthInfoMissingException ex) throws Throwable {
        if (ex.getUrl() != null) {
            HttpHeaders headers = new HttpHeaders();
            headers.add("Location", ex.getUrl());
            return new ResponseEntity<String>("正在载入", headers, HttpStatus.FOUND);
        }
        throw ex.getCause();
    }
}

5)纳入Spring的解析器管理

<mvc:annotation-driven validator="validator">
    <mvc:argument-resolvers>
        <bean class="com.weimob.common.web.param.AuthInfoMethodArgumentResolver">
            <property name="baseUrl">
                <util:constant static-field="com.weimob.o2o.common.Constant.O2OConstant.O2O_H5_ADDRESS"/>
            </property>
        </bean>
    </mvc:argument-resolvers>
</mvc:annotation-driven>

6)项目应用

@RequestMapping("yoururl")
public ModelAndView get(@WechatAuthInfo(name = "merchantId") SilentAuthorizationResult auth ...) {
    // do something 
    return null;
}

这样可以使用WechatAuthInfo注解进行URL的授权管理,以及获取用户信息。当然你可以只使用微授权,不使用这个方法参数。根据你的具体业务逻辑来考虑。

5、总结

使用这个方式主要是有以下3个考虑点:

  1. out of the box:开箱即用,针对特定的URL,可以选择性的使用。
  2. open:Controller相对于Filter与HandlerInterceptor,可以更接近方法上层(开发人员)。
  3. easy test.模拟授权,Controller可以调试,Controller只有一个注解标记,可高度调试与方便高。修改代码与改代码不是一个地方。当需要授权的时候添加这个注解,不需要的时候可以注释掉。相对于Filter与HandlerInterceptor更加方便。
 类似资料:

相关阅读

相关文章

相关问答