在上一篇文章 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 的地址后。这似乎是最符合直觉的方法,但是问题也非常显而易见:
- 可能需要对参数是否存在做判断,否则会拼空参数。
- 不利于维护,如果需要保持的参数增多,拼参数会变得十分繁琐。
- 不通用,没有在框架层面解决这个问题。
所以,为了更好的解决重定向过程中的参数保留问题,我们需要从框架层面入手解决。提到参数保留,很多同学第一印象是如 RedirectAttributes
之类的机制。但因为我们直接使用了 Spring 的 RedirectView
,所以可以直接从这个类入手,看看 Spring 提供了哪些重定向参数保留机制。
阅读源码可以发现,RedirectView
中提供了这些配置参数以提供参数保留功能。
exposeModelAttributes
默认为 true,但前文的解决方案中设置为 false。当设置为 true 时,Spring 会将 Model
中的部分键值对作为 queryProperties
拼到参数中。那哪些键值对能成为 queryProperties
呢?Spring 提供了默认的检查条件以及扩展的可能性,先来看看默认条件:
- 值不为空,且类型为「简单」类型。「简单」类型的定义由 Spring 的
BeanUtils.isSimpleValueType(Class)
给出,总结一下有:
a. 8 种原始数据类型及其包装类型
b.void
&Void
c. 枚举类型
d. CharSequence 及其子类
e. Number 及其子类
f.java.util.Date
及其子类
g. URI, URL, Locale, Class - 1 中所有类型的数组或集合。
这种方式可以较好的解决上面提出的一些问题:
- 可以在基类 Controller 定义
@ModelAttribute
方法,在其中向 Model 放入需要拼接的参数,有一定的通用性。 - 可以自动排除值为空的参数。
但这个方案依然存在几个问题:
- 通过白名单管理参数传递,不够灵活。
- 需要类继承,不够灵活。可以考虑通过接口的默认方法来实现,所以这个问题不大
- 存在安全风险,可能会意外地将 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<String> 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<NameValuePair> 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<String> 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 更好(暂时没有找到相关的插件,如果有了解的同学欢迎补充),三来自己实现更加灵活,可以针对项目的特性对语法做不同的取舍。所以最终采用了这个方案。
以上。
网友评论