SpringMVC 如何優(yōu)雅地進(jìn)行 301 跳轉(zhuǎn)(續(xù))

在上一篇文章 SpringMVC 如何優(yōu)雅地進(jìn)行 301 跳轉(zhuǎn) 中,我們講到了如何通過修改 SpringMVC 配置,實(shí)現(xiàn)優(yōu)雅的 301 跳轉(zhuǎn)。但在實(shí)際應(yīng)用過程中我們可以發(fā)現(xiàn)一些問題。

先來看第一個場景:需要把 /product.htm?id=123 301 重定向到 /product/123.htm。對于這個場景,上篇文章中的實(shí)現(xiàn)可以非常容易實(shí)現(xiàn):

@RequestMapping("/product.htm")
public String product(Long id) {
    // 省略校驗(yàn)
    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) {
    // 省略校驗(yàn)
    return "redirect 301:/activity/{id}.htm";
}

通過這種方式,from 參數(shù)在重定向過程中會丟失。那么如何解決參數(shù)丟失的問題呢?

首先最容易想到的方案是,將需要拼的參數(shù)直接加在 redirect 的地址后。這似乎是最符合直覺的方法,但是問題也非常顯而易見:

  1. 可能需要對參數(shù)是否存在做判斷,否則會拼空參數(shù)。
  2. 不利于維護(hù),如果需要保持的參數(shù)增多,拼參數(shù)會變得十分繁瑣。
  3. 不通用,沒有在框架層面解決這個問題。

所以,為了更好的解決重定向過程中的參數(shù)保留問題,我們需要從框架層面入手解決。提到參數(shù)保留,很多同學(xué)第一印象是如 RedirectAttributes 之類的機(jī)制。但因?yàn)槲覀冎苯邮褂昧?Spring 的 RedirectView,所以可以直接從這個類入手,看看 Spring 提供了哪些重定向參數(shù)保留機(jī)制。

閱讀源碼可以發(fā)現(xiàn),RedirectView 中提供了這些配置參數(shù)以提供參數(shù)保留功能。

exposeModelAttributes

默認(rèn)為 true,但前文的解決方案中設(shè)置為 false。當(dāng)設(shè)置為 true 時,Spring 會將 Model 中的部分鍵值對作為 queryProperties 拼到參數(shù)中。那哪些鍵值對能成為 queryProperties 呢?Spring 提供了默認(rèn)的檢查條件以及擴(kuò)展的可能性,先來看看默認(rèn)條件:

  1. 值不為空,且類型為「簡單」類型?!负唵巍诡愋偷亩x由 Spring 的 BeanUtils.isSimpleValueType(Class) 給出,總結(jié)一下有:
    a. 8 種原始數(shù)據(jù)類型及其包裝類型
    b. void & Void
    c. 枚舉類型
    d. CharSequence 及其子類
    e. Number 及其子類
    f. java.util.Date 及其子類
    g. URI, URL, Locale, Class
  2. 1 中所有類型的數(shù)組或集合。

這種方式可以較好的解決上面提出的一些問題:

  1. 可以在基類 Controller 定義 @ModelAttribute 方法,在其中向 Model 放入需要拼接的參數(shù),有一定的通用性。
  2. 可以自動排除值為空的參數(shù)。

但這個方案依然存在幾個問題:

  1. 通過白名單管理參數(shù)傳遞,不夠靈活。
  2. 需要類繼承,不夠靈活??梢钥紤]通過接口的默認(rèn)方法來實(shí)現(xiàn),所以這個問題不大
  3. 存在安全風(fēng)險,可能會意外地將 Model 中的某些不應(yīng)該暴露的數(shù)據(jù)暴露到 URL 參數(shù)中.

expandUriTemplateVariables

這個配置只能擴(kuò)展 UriTemplate 中的參數(shù),即上面第二個例子。使用這種方式要求參數(shù)名和順序固定,不夠靈活,在此不詳細(xì)討論。

propagateQueryParams

是否傳播 query 參數(shù)。從命名上也可以看出這個配置應(yīng)該是最符合預(yù)期的。當(dāng)配置為 true 時,會把 query 部分拼到重定向后的請求上,下面是實(shí)現(xiàn)代碼:

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);
        }
    }
}

可以看到,實(shí)際上的處理邏輯是把整個查詢部分剔除了錨點(diǎn)部分后,拼到新鏈接上。所以這個實(shí)現(xiàn)可以滿足我們上面提到的幾個問題,不需要白名單管理,只會傳遞存在的參數(shù),以及良好的重用性。

這個方法可以實(shí)現(xiàn)我們的需求了嗎?再考慮一個場景,需要把 /product.htm?id=123&from=index&app=1&... 301 重定向到 /product/123.htm?from=index&app=1&...。如果使用上面的配置,那么最終重定向的結(jié)果為 /product/123.htm?id=123&from=index&app=1&...。我們不希望出現(xiàn)的參數(shù) id 也被傳遞過來。

但是好在,管理黑名單比白名單要輕松很多。我們可以定義一套簡潔的語法來聲明黑名單。我采用的語法是在 redirect 地址后使用 -參數(shù)名 來排除特定的參數(shù),如

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

定義好了語法,實(shí)現(xiàn)起來也非常簡單,無非是重寫 RedirectView.appendCurrentQueryParams 方法。下面是實(shí)現(xiàn)類的部分代碼

// 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);
}

通過這種方式,截至目前的所有需求都得到了滿足。代碼的易用性、可讀性、重用性都得到了滿足。另外也保留了將來擴(kuò)展的能力,例如假設(shè)新增需求,部分場景下需要保留錨點(diǎn)信息,也可以自定義語法來實(shí)現(xiàn)。

實(shí)際上,這類需求使用 UrlRewriter 也可以實(shí)現(xiàn)。但一來對新接手項(xiàng)目的同學(xué)來說學(xué)習(xí)成本會比較高(可能連某個鏈接對應(yīng)的代碼都找不到),二來 IDE 對 SpringMVC 的支持比 UrlRewriter 更好(暫時沒有找到相關(guān)的插件,如果有了解的同學(xué)歡迎補(bǔ)充),三來自己實(shí)現(xiàn)更加靈活,可以針對項(xiàng)目的特性對語法做不同的取舍。所以最終采用了這個方案。

以上。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容