美文网首页
SpringMVC 如何优雅地进行 301 跳转(续)

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

作者: Boreasy | 来源:发表于2018-01-26 16:21 被阅读0次

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

    以上。

    相关文章

      网友评论

          本文标题:SpringMVC 如何优雅地进行 301 跳转(续)

          本文链接:https://www.haomeiwen.com/subject/ckgiaxtx.html