Spring Cloud Gateway修改請求和響應body的內容

程式設計師欣宸發表於2021-11-24

歡迎訪問我的GitHub

https://github.com/zq2599/blog_demos

內容:所有原創文章分類彙總及配套原始碼,涉及Java、Docker、Kubernetes、DevOPS等;

本篇概覽

  • 作為《Spring Cloud Gateway實戰》系列的第九篇,我們們聊聊如何用Spring Cloud Gateway修改原始請求和響應內容,以及修改過程中遇到的問題

  • 首先是修改請求body,如下圖,瀏覽器是請求發起方,真實引數只有user-id,經過閘道器時被塞入欄位user-name,於是,後臺服務收到的請求就帶有user-name欄位了

在這裡插入圖片描述

  • 其次是修改響應,如下圖,服務提供方provider-hello的原始響應只有response-tag欄位,經過閘道器時被塞入了gateway-response-tag欄位,最終瀏覽器收到的響應就是response-taggateway-response-tag兩個欄位:

在這裡插入圖片描述

  • 總的來說,今天要做具體事情如下:
  1. 準備工作:在服務提供者的程式碼中新增一個web介面,用於驗證Gateway的操作是否有效
  2. 介紹修改請求body和響應body的套路
  3. 按套路開發一個過濾器(filter),用於修改請求的body
  4. 按套路開發一個過濾器(filter),用於修改響應的body
  5. 思考和嘗試:如何從Gateway返回錯誤?
  • 在實戰過程中,我們們順便搞清楚兩個問題:
  1. 程式碼配置路由時,如何給一個路由新增多個filter?
  2. 程式碼配置路由和yml配置是否可以混搭,兩者有衝突嗎?

原始碼下載

名稱 連結 備註
專案主頁 https://github.com/zq2599/blog_demos 該專案在GitHub上的主頁
git倉庫地址(https) https://github.com/zq2599/blog_demos.git 該專案原始碼的倉庫地址,https協議
git倉庫地址(ssh) git@github.com:zq2599/blog_demos.git 該專案原始碼的倉庫地址,ssh協議
  • 這個git專案中有多個資料夾,本篇的原始碼在spring-cloud-tutorials資料夾下,如下圖紅框所示:

在這裡插入圖片描述

  • spring-cloud-tutorials資料夾下有多個子工程,本篇的程式碼是gateway-change-body,如下圖紅框所示:

在這裡插入圖片描述

準備工作

  • 為了觀察Gateway能否按預期去修改請求和響應的body,我們們給服務提供者provider-hello增加一個介面,程式碼在Hello.java中,如下:
    @PostMapping("/change")
    public Map<String, Object> change(@RequestBody Map<String, Object> map) {
        map.put("response-tag", dateStr());
        return map;
    }
  • 可見新增的web介面很簡單:將收到的請求資料作為返回值,在裡面新增了一個鍵值對,然後返回給請求方,有了這個介面,我們們就能通過觀察返回值來判斷Gateway對請求和響應的操作是否生效

  • 來試一下,先啟動nacos(provider-hello需要的)

  • 再執行provider-hello應用,用Postman向其發請求試試,如下圖,符合預期:

在這裡插入圖片描述

  • 準備工作已完成,開始開發吧

修改請求body的套路

  • 如何用Spring Cloud Gateway修改請求的body?來看看其中的套路:
  1. 修改請求body是通過自定義filter實現的
  2. 配置路由及其filter的時候,有yml配置檔案和程式碼配置兩種方式可以配置路由,官方文件給出的demo是程式碼配置的,因此今天我們們也參考官方做法,通過程式碼來配置路由和過濾器
  3. 在程式碼配置路由的時候,呼叫filters方法,該方法的入參是個lambda表示式
  4. 此lambda表示式固定呼叫modifyRequestBody方法,我們們只要定義好modifyRequestBody方法的三個入參即可
  5. modifyRequestBody方法的第一個入參是輸入型別
  6. 第二個入參是返回型別
  7. 第三個是RewriteFunction介面的實現,這個程式碼需要您自己寫,內容是將輸入資料轉換為返回型別資料具體邏輯,我們們來看官方Demo,也就是上述套路了:
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
    return builder.routes()
        .route("rewrite_request_obj", r -> r.host("*.rewriterequestobj.org")
            .filters(f -> f.prefixPath("/httpbin")
                .modifyRequestBody(String.class, Hello.class, MediaType.APPLICATION_JSON_VALUE,
                    (exchange, s) -> return Mono.just(new Hello(s.toUpperCase())))).uri(uri))
        .build();
}

修改響應body的套路

  • 用Spring Cloud Gateway修改響應body的套路和前面的請求body如出一轍
  1. 通過程式碼來配置路由和過濾器
  2. 在程式碼配置路由的時候,呼叫filters方法,該方法的入參是個lambda表示式
  3. 此lambda表示式固定呼叫modifyResponseBody方法,我們們只要定義好modifyResponseBody方法的三個入參即可
  4. modifyRequestBody方法的第一個入參是輸入型別
  5. 第二個入參是返回型別
  6. 第三個是RewriteFunction介面的實現,這個程式碼要您自己寫,內容是將輸入資料轉換為返回型別資料具體邏輯,我們們來看官方Demo,其實就是上述套路:
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
    return builder.routes()
        .route("rewrite_response_upper", r -> r.host("*.rewriteresponseupper.org")
            .filters(f -> f.prefixPath("/httpbin")
                .modifyResponseBody(String.class, String.class,
                    (exchange, s) -> Mono.just(s.toUpperCase()))).uri(uri))
        .build();
}
  • 套路總結出來了,接下來,我們們一起擼程式碼?

按套路開發一個修改請求body的過濾器(filter)

  • 廢話不說,在父工程spring-cloud-tutorials下新建子工程gateway-change-body,pom.xml無任何特殊之處,注意依賴spring-cloud-starter-gateway即可

  • 啟動類毫無新意:

package com.bolingcavalry.changebody;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ChangeBodyApplication {
    public static void main(String[] args) {
        SpringApplication.run(ChangeBodyApplication.class,args);
    }
}
  • 配置檔案千篇一律:
server:
  #服務埠
  port: 8081
spring:
  application:
    name: gateway-change-body
  • 然後是核心邏輯:修改請求body的程式碼,既RewriteFunction的實現類,程式碼很簡單,將原始的請求body解析成Map物件,取出user-id欄位,生成user-name欄位放回map,apply方法返回的是個Mono:
package com.bolingcavalry.changebody.function;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.cloud.gateway.filter.factory.rewrite.RewriteFunction;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Map;


@Slf4j
public class RequestBodyRewrite implements RewriteFunction<String, String> {

    private ObjectMapper objectMapper;

    public RequestBodyRewrite(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    /**
     * 根據使用者ID獲取使用者名稱稱的方法,可以按實際情況來內部實現,例如查庫或快取,或者遠端呼叫
     * @param userId
     * @return
     */
    private  String mockUserName(int userId) {
        return "user-" + userId;
    }

    @Override
    public Publisher<String> apply(ServerWebExchange exchange, String body) {
        try {
            Map<String, Object> map = objectMapper.readValue(body, Map.class);

            // 取得id
            int userId = (Integer)map.get("user-id");

            // 得到nanme後寫入map
            map.put("user-name", mockUserName(userId));

            // 新增一個key/value
            map.put("gateway-request-tag", userId + "-" + System.currentTimeMillis());

            return Mono.just(objectMapper.writeValueAsString(map));
        } catch (Exception ex) {
            log.error("1. json process fail", ex);
            // json操作出現異常時的處理
            return Mono.error(new Exception("1. json process fail", ex));
        }
    }
}
  • 然後是按部就班的基於程式碼實現路由配置,重點是lambda表示式執行modifyRequestBody方法,並且將RequestBodyRewrite作為引數傳入:
package com.bolingcavalry.changebody.config;

import com.bolingcavalry.changebody.function.RequestBodyRewrite;
import com.bolingcavalry.changebody.function.ResponseBodyRewrite;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import reactor.core.publisher.Mono;

@Configuration
public class FilterConfig {
    @Bean
    public RouteLocator routes(RouteLocatorBuilder builder, ObjectMapper objectMapper) {
        return builder
                .routes()
                .route("path_route_change",
                        r -> r.path("/hello/change")
                                .filters(f -> f
                                        .modifyRequestBody(String.class,String.class,new RequestBodyRewrite(objectMapper))
                                        )
                        .uri("http://127.0.0.1:8082"))
                .build();
    }
}
  • 程式碼寫完了,執行工程gateway-change-body,在postman發起請求,得到響應如下圖,紅框中可見Gateway新增的內容已成功:

在這裡插入圖片描述

  • 現在修改請求body已經成功,接下來再來修改服務提供者響應的body

修改響應body

  • 接下來開發修改響應body的程式碼

  • 新增RewriteFunction介面的實現類ResponseBodyRewrite.java

package com.bolingcavalry.changebody.function;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.cloud.gateway.filter.factory.rewrite.RewriteFunction;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Map;

@Slf4j
public class ResponseBodyRewrite implements RewriteFunction<String, String> {

    private ObjectMapper objectMapper;

    public ResponseBodyRewrite(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public Publisher<String> apply(ServerWebExchange exchange, String body) {
        try {
            Map<String, Object> map = objectMapper.readValue(body, Map.class);

            // 取得id
            int userId = (Integer)map.get("user-id");

            // 新增一個key/value
            map.put("gateway-response-tag", userId + "-" + System.currentTimeMillis());

            return Mono.just(objectMapper.writeValueAsString(map));
        } catch (Exception ex) {
            log.error("2. json process fail", ex);
            return Mono.error(new Exception("2. json process fail", ex));
        }
    }
}
  • 路由配置程式碼中,lambda表示式裡面,filters方法內部呼叫modifyResponseBody,第三個入參是ResponseBodyRewrite:
package com.bolingcavalry.changebody.config;

import com.bolingcavalry.changebody.function.RequestBodyRewrite;
import com.bolingcavalry.changebody.function.ResponseBodyRewrite;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import reactor.core.publisher.Mono;

@Configuration
public class FilterConfig {

    @Bean
    public RouteLocator routes(RouteLocatorBuilder builder, ObjectMapper objectMapper) {
        return builder
                .routes()
                .route("path_route_change",
                        r -> r.path("/hello/change")
                                .filters(f -> f
                                        .modifyRequestBody(String.class,String.class,new RequestBodyRewrite(objectMapper))
                                        .modifyResponseBody(String.class, String.class, new ResponseBodyRewrite(objectMapper))
                                        )
                        .uri("http://127.0.0.1:8082"))
                .build();
    }
}
  • 還記得我們們的第一個問題嗎?通過上面的程式碼,您應該已經看到了答案:用程式碼配置路由時,多個過濾器的配置方法就是在filters方法中反覆呼叫內建的過濾器相關API,下圖紅框中的都可以:

在這裡插入圖片描述

  • 執行服務,用Postman驗證效果,如下圖紅框,Gateway在響應body中成功新增了一個key&value:

在這裡插入圖片描述

程式碼配置路由和yml配置是否可以混搭?

  • 前面有兩個問題,接下來回答第二個,我們們在application.yml中增加一個路由配置:
server:
  #服務埠
  port: 8081
spring:
  application:
    name: gateway-change-body
  cloud:
    gateway:
      routes:
        - id: path_route_str
          uri: http://127.0.0.1:8082
          predicates:
            - Path=/hello/str
  • gateway-change-body服務啟動起來,此時已經有了兩個路由配置,一個在程式碼中,一個在yml中,先試試yml中的這個,如下圖沒問題:

在這裡插入圖片描述

  • 再試試程式碼配置的路由,如下圖,結論是程式碼配置路由和yml配置可以混搭

在這裡插入圖片描述

如何處理異常

  • 還有個問題必須要面對:修改請求或者響應body的過程中,如果發現問題需要提前返回錯誤(例如必要的欄位不存在),程式碼該怎麼寫?

  • 我們們修改請求body的程式碼集中在RequestBodyRewrite.java,增加下圖紅框內容:

在這裡插入圖片描述

  • 再來試試,這次請求引數中不包含user-id,收到Gateway返回的錯誤資訊如下圖:

在這裡插入圖片描述

  • 看看控制檯,能看到程式碼中丟擲的異常資訊:

在這裡插入圖片描述

  • 此時,聰明的您應該發現問題所在了:我們們想告訴客戶端具體的錯誤,但實際上客戶端收到的是被Gateway框架處理後的內容

  • 篇幅所限,上述問題從分析到解決的過程,就留給下一篇文章吧

  • 本篇的最後,請容許欣宸嘮叨兩句,聊聊為何要閘道器來修改請求和響應body的內容,如果您沒興趣還請忽略

閘道器(Gateway)為什麼要做這些?

  • 看過開篇的兩個圖,聰明的您一定發現了問題:為什麼要破壞原始資料,一旦系統出了問題如何定位是服務提供方還是閘道器?

  • 按照欣宸之前的經驗,儘管閘道器會破壞原始資料,但只做一些簡單固定的處理,一般以新增資料為主,閘道器不了解業務,最常見的就是鑑權、新增身份或標籤等操作

  • 前面的圖中確實感受不到閘道器的作用,但如果閘道器後面有多個服務提供者,如下圖,這時候諸如鑑權、獲取賬號資訊等操作由閘道器統一完成,比每個後臺分別實現一次更有效率,後臺可以更加專注於自身業務:

在這裡插入圖片描述

  • 經驗豐富的您可能會對我的狡辯不屑一顧:閘道器統一鑑權、獲取身份,一般會把身份資訊放入請求的header中,也不會修改請求和響應的內容啊,欣宸前面的一堆解釋還是沒說清楚為啥要在閘道器位置修改請求和響應的內容!

  • 好吧,面對聰明的您,我攤牌了:本篇只是從技術上演示Spring Cloud Gateway如何修改請求和響應內容,請不要將此技術與實際後臺業務耦合;

你不孤單,欣宸原創一路相伴

  1. Java系列
  2. Spring系列
  3. Docker系列
  4. kubernetes系列
  5. 資料庫+中介軟體系列
  6. DevOps系列

歡迎關注公眾號:程式設計師欣宸

微信搜尋「程式設計師欣宸」,我是欣宸,期待與您一同暢遊Java世界...
https://github.com/zq2599/blog_demos

相關文章