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<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 更好(暂时没有找到相关的插件,如果有了解的同学欢迎补充),三来自己实现更加灵活,可以针对项目的特性对语法做不同的取舍。所以最终采用了这个方案。

以上。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,036评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,046评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,411评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,622评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,661评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,521评论 1 304
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,288评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,200评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,644评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,837评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,953评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,673评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,281评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,889评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,011评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,119评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,901评论 2 355

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,656评论 18 139
  • Spring Boot 参考指南 介绍 转载自:https://www.gitbook.com/book/qbgb...
    毛宇鹏阅读 46,811评论 6 342
  • 它躲在云里偷看她很久了,光滑饱满的小脸,裤脚卷起时若隐若现的踝骨,笑起来快要溢出来的甜。 那个拿她的伞给别人遮雨的...
    烟火味儿阅读 289评论 2 3
  • 生活中难免遇到一些人让你除了“臭表脸”以外没有什么词能更贴切地形容。遇到这种人,你会怎么做? AK47姑娘脾气大,...
    绛绛子阅读 493评论 1 1
  • 参考:http://blog.csdn.net/passionkk/article/details/4992988...
    Babyzpj阅读 842评论 0 1