SpringMVC 如何优雅地进行 301 跳转(续)

在上一篇文章 SpringMVC 如何优雅地进行 301 跳转 中,我们讲到了如何通过修改 SpringMVC 配置,实现优雅的 301 跳转。但在实际应用过程中我们可以发现一些问题。

先来看第一个场景:需要把 /product.htm?id=123 301 重定向到 /product/123.htm。对于这个场景,上篇文章中的实现可以非常容易实现:

@RequestMapping("/product.htm")
public String product(Long id) {
    // 省略校验
    return "redirect 301:/product/" + id + ".htm";
}

再来看第二个场景:需要把 /activityOld/123.htm?from=index 301 重定向到 /activity/123.htm?from=index。 在这个场景下,上篇文章中提到的配置方法就不够用了。

@RequestMapping("/activityOld/{id}.htm)
public String activityOld(@PathVariable Long id) {
    // 省略校验
    return "redirect 301:/activity/{id}.htm";
}

通过这种方式,from 参数在重定向过程中会丢失。那么如何解决参数丢失的问题呢?

首先最容易想到的方案是,将需要拼的参数直接加在 redirect 的地址后。这似乎是最符合直觉的方法,但是问题也非常显而易见:

  1. 可能需要对参数是否存在做判断,否则会拼空参数。
  2. 不利于维护,如果需要保持的参数增多,拼参数会变得十分繁琐。
  3. 不通用,没有在框架层面解决这个问题。

所以,为了更好的解决重定向过程中的参数保留问题,我们需要从框架层面入手解决。提到参数保留,很多同学第一印象是如 RedirectAttributes 之类的机制。但因为我们直接使用了 Spring 的 RedirectView,所以可以直接从这个类入手,看看 Spring 提供了哪些重定向参数保留机制。

阅读源码可以发现,RedirectView 中提供了这些配置参数以提供参数保留功能。

exposeModelAttributes

默认为 true,但前文的解决方案中设置为 false。当设置为 true 时,Spring 会将 Model 中的部分键值对作为 queryProperties 拼到参数中。那哪些键值对能成为 queryProperties 呢?Spring 提供了默认的检查条件以及扩展的可能性,先来看看默认条件:

  1. 值不为空,且类型为「简单」类型。「简单」类型的定义由 Spring 的 BeanUtils.isSimpleValueType(Class) 给出,总结一下有:
    a. 8 种原始数据类型及其包装类型
    b. void & Void
    c. 枚举类型
    d. CharSequence 及其子类
    e. Number 及其子类
    f. java.util.Date 及其子类
    g. URI, URL, Locale, Class
  2. 1 中所有类型的数组或集合。

这种方式可以较好的解决上面提出的一些问题:

  1. 可以在基类 Controller 定义 @ModelAttribute 方法,在其中向 Model 放入需要拼接的参数,有一定的通用性。
  2. 可以自动排除值为空的参数。

但这个方案依然存在几个问题:

  1. 通过白名单管理参数传递,不够灵活。
  2. 需要类继承,不够灵活。可以考虑通过接口的默认方法来实现,所以这个问题不大
  3. 存在安全风险,可能会意外地将 Model 中的某些不应该暴露的数据暴露到 URL 参数中.

expandUriTemplateVariables

这个配置只能扩展 UriTemplate 中的参数,即上面第二个例子。使用这种方式要求参数名和顺序固定,不够灵活,在此不详细讨论。

propagateQueryParams

是否传播 query 参数。从命名上也可以看出这个配置应该是最符合预期的。当配置为 true 时,会把 query 部分拼到重定向后的请求上,下面是实现代码:

protected void appendCurrentQueryParams(StringBuilder targetUrl, HttpServletRequest request) {
    String query = request.getQueryString();
    if (StringUtils.hasText(query)) {
        // Extract anchor fragment, if any.
        String fragment = null;
        int anchorIndex = targetUrl.indexOf("#");
        if (anchorIndex > -1) {
            fragment = targetUrl.substring(anchorIndex);
            targetUrl.delete(anchorIndex, targetUrl.length());
        }

        if (targetUrl.toString().indexOf('?') < 0) {
            targetUrl.append('?').append(query);
        }
        else {
            targetUrl.append('&').append(query);
        }
        // Append anchor fragment, if any, to end of URL.
        if (fragment != null) {
            targetUrl.append(fragment);
        }
    }
}

可以看到,实际上的处理逻辑是把整个查询部分剔除了锚点部分后,拼到新链接上。所以这个实现可以满足我们上面提到的几个问题,不需要白名单管理,只会传递存在的参数,以及良好的重用性。

这个方法可以实现我们的需求了吗?再考虑一个场景,需要把 /product.htm?id=123&from=index&app=1&... 301 重定向到 /product/123.htm?from=index&app=1&...。如果使用上面的配置,那么最终重定向的结果为 /product/123.htm?id=123&from=index&app=1&...。我们不希望出现的参数 id 也被传递过来。

但是好在,管理黑名单比白名单要轻松很多。我们可以定义一套简洁的语法来声明黑名单。我采用的语法是在 redirect 地址后使用 -参数名 来排除特定的参数,如

@RequestMapping("/answer.htm")
public String answer(Long questionId, Long answerId) {
    // 省略校验
    return "redirect 301:/question/" + questionId + "/answer/" + answerId 
        + " -questionId -answerId";
}

定义好了语法,实现起来也非常简单,无非是重写 RedirectView.appendCurrentQueryParams 方法。下面是实现类的部分代码

// ExtendedRedirectView.java
@Setter
private Set excludedParameters;

@Override
protected void appendCurrentQueryParams(StringBuilder targetUrl, HttpServletRequest request) {
    String query = determineQuery(request);
    // 以下逻辑和父类方法一致
}

private String determineQuery(HttpServletRequest request) {
    String query = request.getQueryString();
    if (StringUtils.isEmpty(query) || CollectionUtils.isEmpty(excludedParameters)) {
        return query;
    }
    try {
        List parameters = URLEncodedUtils.parse(query, CHARSET).stream()
                .filter(p -> !excludedParameters.contains(p.getName()))
                .collect(Collectors.toList());
        return URLEncodedUtils.format(parameters, CHARSET);
    } catch (Exception e) {
        logger.error("parse query error", e);
        // 失败后放弃 exclude 操作,返回原值
        return query;
    }
}
// CustomViewResolver.java
@Override
protected View createView(String viewName, Locale locale) throws Exception {
    if (!canHandle(viewName, locale)) {
        return null;
    }
    if (viewName.startsWith(REDIRECT_301_URL_PREFIX)) {
        String[] args = viewName.substring(REDIRECT_301_URL_PREFIX.length()).trim().split("\\s+");
        String redirectUrl = args[0];
        ExtendedRedirectView view = new ExtendedRedirectView(redirectUrl,
                isRedirectContextRelative(), isRedirectHttp10Compatible(), false);
        view.setStatusCode(HttpStatus.MOVED_PERMANENTLY);
        if (args.length > 1) {
            Set excludedParameters = Arrays.stream(ArrayUtils.subarray(args, 1, args.length))
                    .filter(s -> s.startsWith("-"))
                    .map(s -> s.substring(1))
                    .filter(StringUtils::isNotEmpty)
                    .collect(Collectors.toSet());
            view.setExcludedParameters(excludedParameters);
        }
         return (View) getApplicationContext().getAutowireCapableBeanFactory().initializeBean(view, viewName);
    }
    return super.createView(viewName, locale);
}

通过这种方式,截至目前的所有需求都得到了满足。代码的易用性、可读性、重用性都得到了满足。另外也保留了将来扩展的能力,例如假设新增需求,部分场景下需要保留锚点信息,也可以自定义语法来实现。

实际上,这类需求使用 UrlRewriter 也可以实现。但一来对新接手项目的同学来说学习成本会比较高(可能连某个链接对应的代码都找不到),二来 IDE 对 SpringMVC 的支持比 UrlRewriter 更好(暂时没有找到相关的插件,如果有了解的同学欢迎补充),三来自己实现更加灵活,可以针对项目的特性对语法做不同的取舍。所以最终采用了这个方案。

以上。

你可能感兴趣的:(SpringMVC 如何优雅地进行 301 跳转(续))