首頁 > 軟體

解決Spring Cloud feign GET請求無法用實體傳參的問題

2023-01-04 14:00:43

Spring Cloud feign GET請求無法用實體傳參

程式碼如下:

@FeignClient(name = "eureka-client", fallbackFactory = FallBack.class, decode404 = true, path = "/client")
public interface FeignApi {
//    @PostMapping("/hello/{who}")
//    String hello(@PathVariable(value = "who") String who) throws Exception;

    @GetMapping("/hello")
    String hello(Params params) throws Exception;
}

呼叫報錯:

feign.FeignException: status 405 reading FeignApi#hello(Params)

解決辦法

改用post請求,新增@RequestBodey註解

新增@SpringQueryMaq註解,如下:

@GetMapping("/hello")
String hello(@SpringQueryMap Params params) throws Exception;

Spring Cloud Feign非同步呼叫傳參問題

各個子系統之間通過feign呼叫,每個服務提供方需要驗證每個請求header裡的token。

public void invokeFeign() throws Exception {
    feignService1.method();
    feignService2.method();
    feignService3.method();
....
}

定義攔截每次傳送feign呼叫攔截器RequestInterceptor的子類,每次傳送feign請求前將token帶入請求頭

@Configuration
public class FeignTokenInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        public void apply(RequestTemplate template) {
            //上下文環境保持器,拿到剛進來這個請求包含的資料,而不會因為遠端資料請求頭被清除
            ServletRequestAttributes attributes = (ServletRequestAttributes)                  RequestContextHolder.getRequestAttributes();
            HttpServletRequest request = attributes.getRequest();//老的請求
            if (request != null) {
                //同步老的請求頭中的資料,這裡是獲取cookie
                String cookie = request.getHeader("token");
                template.header("token", cookie);
            }
        }
  .....
    }

這樣便能實現系統間通過同步方式feign呼叫的認證問題。但是如果需要在invokeFeign方法中feignService3的方法呼叫比較耗時,並且invokeFeign業務並不關心feignService3.method()方法的執行結果,此時該怎麼辦。

方案1

修改feignService3.method()方法,將其內部實現修改為非同步,這種方案依賴服務的提供方,如果feignService3服務是其他業務部門維護,並且無法修改實現為非同步,此時只能採取方案2.

方案2

通過執行緒池呼叫feignServie3.method()

public void invokeFeign() throws Exception {
    feignService1.method();
    feignService2.method();
    executor.submit(()->{
        feignService3.method();
    });
....
}

懷著期待的心情開啟了嘗試,你會發現呼叫feignService3方法並沒有成功,檢視紀錄檔你將會發現是由於feign傳送request請求的header中未攜帶token導致。於是百度了下feign非同步呼叫傳參,網上大部分的解決方案,如下

public void invokeFeign() throws Exception {
        feignService1.method();
        feignService2.method();
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
                .getRequestAttributes();
        executor.submit(()->{
            RequestContextHolder.setRequestAttributes(RequestContextHolder.getRequestAttributes(), true);
            feignService3.method();
        });
    }
}

新增了上面的程式碼後,實測無效,此時確實有些束手無策。但是真的沒無效嗎?我仔細比對通過上述手段解決問題的部落格,他們的業務程式碼和我的程式碼不同之處。確實有不同,比如這篇。其程式碼如下

@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
    OrderConfirmVo confirmVo = new OrderConfirmVo();
    MemberResVo memberResVo = LoginUserInterceptor.loginUser.get();
    //從主執行緒中獲得所有request資料
    RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
    CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
        //1、遠端查詢所有地址列表
        RequestContextHolder.setRequestAttributes(requestAttributes);
        List<MemberAddressVo> address = memberFeignService.getAddress(memberResVo.getId());
        confirmVo.setAddress(address);
    }, executor);
 
    //2、遠端查詢購物車所選的購物項,獲得所有購物項資料
    CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
        //放入子執行緒中request資料
        RequestContextHolder.setRequestAttributes(requestAttributes);
        List<OrderItemVo> items = cartFeginService.getCurrentUserCartItems();
        confirmVo.setItem(items);
    }, executor).thenRunAsync(()->{
        RequestContextHolder.setRequestAttributes(requestAttributes);
        List<OrderItemVo> items = confirmVo.getItem();
        List<Long> collect = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
        //遠端呼叫查詢是否有庫存
        R hasStock = wmsFeignService.getSkusHasStock(collect);
        //形成一個List集合,獲取所有物品是否有貨的情況
        List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {
        });
        if (data!=null){
            //收集起來,Map<Long,Boolean> stocks;
            Map<Long, Boolean> map = data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
            confirmVo.setStocks(map);
        }
    },executor);
    //feign遠端呼叫在呼叫之前會呼叫很多攔截器,因此遠端呼叫會丟失很多請求頭
 
    //3、查詢使用者積分
    Integer integration = memberResVo.getIntegration();
    confirmVo.setIntegration(integration);
    //其他資料自動計算
 
    CompletableFuture.allOf(getAddressFuture,cartFuture).get();
    return confirmVo;
}

我們看的出來,他的業務程式碼即使是開啟多執行緒,也是等最後執行緒裡的任務都執行完成後,業務方法才結束返回,而我的業務方法並不會等feignService3呼叫完成結束,抱著嘗試的心態,我調整了下程式碼新增了CountDownLatch,讓業務方法等待feign呼叫結束後在返回。

public void invokeFeign() throws Exception {
        feignService1.method();
        feignService2.method();
        CountDownLatch latch = new CountDownLatch(1);
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
                .getRequestAttributes();
        executor.submit(()->{
            RequestContextHolder.setRequestAttributes(RequestContextHolder.getRequestAttributes(), true);
            feignService3.method();
            latch.countDown();
        });
        latch.await();
    }
}

不如所料,呼叫成功了。到這裡看似是解決了問題,但是與我想象的非同步差別太大了,最終業務執行緒還是需要等待feignService3.method()呼叫業務方法才能返回,而且非同步場景如傳送簡訊、訊息推播,記錄紀錄檔可能呼叫耗時,業務方法可不想等待他們執行結束,此時該怎麼解決?

只能翻原始碼 ServletRequestAttributes.java

首先看到了註釋,這給了我靈感

Servlet-based implementation of the {@link RequestAttributes} interface. <p>Accesses objects from servlet request and HTTP session scope,
with no distinction between "session" and "global session".

從servlet請求和HTTP對談範圍存取物件,"session"和"global session"作用域沒有區別。對呀會不會是因為header中的引數是request作用域的原因呢,因為請求結束,所以即使在子執行緒設定請求頭,也取不到原因。回到請求攔截器RequestInterceptor檢視獲取token地方

ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    //老的請求
    HttpServletRequest request = attributes.getRequest();
if (request != null) {
        //同步老的請求頭中的資料,這裡是獲取cookie
        String cookie = request.getHeader("token");
        template.header("token", cookie);
        }

果然如此,從attributes中獲取request,然後從request中獲取token。但是沒有考慮到request請求結束,request作用域的問題,此時肯定取不到header裡的token了。

那麼該怎麼解決呢?思路不能變,肯定還是圍繞著ServletRequestAttributes展開,發現他有兩個方法getAttributes和setAttribute,而且這倆方法都支援兩個作用域request、session。

@Override
public Object getAttribute(String name, int scope) {
    if (scope == SCOPE_REQUEST) {
        if (!isRequestActive()) {
            throw new IllegalStateException(
                    "Cannot ask for request attribute - request is not active anymore!");
        }
        return this.request.getAttribute(name);
    }
    else {
        HttpSession session = getSession(false);
        if (session != null) {
            try {
                Object value = session.getAttribute(name);
                if (value != null) {
                    this.sessionAttributesToUpdate.put(name, value);
                }
                return value;
            }
            catch (IllegalStateException ex) {
                // Session invalidated - shouldn't usually happen.
            }
        }
        return null;
    }
}
 
@Override
public void setAttribute(String name, Object value, int scope) {
    if (scope == SCOPE_REQUEST) {
        if (!isRequestActive()) {
            throw new IllegalStateException(
                    "Cannot set request attribute - request is not active anymore!");
        }
        this.request.setAttribute(name, value);
    }
    else {
        HttpSession session = obtainSession();
        this.sessionAttributesToUpdate.remove(name);
        session.setAttribute(name, value);
    }
}

既然我們的業務方法呼叫(HttpServletRequest)不會等待feignService3.method,我們可以通過
ServletRequestAttributes.setAttributes指定作用域為session呀。

此時invokeFeign程式碼如下

public void invokeFeign() throws Exception {
        feignService1.method();
        feignService2.method();
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
                .getRequestAttributes();
        //在ServeletRequestAttributes中設定token,作用域為session                 
        attributes.setAttribute("token",attributes.getRequest().getHeader("token"),1);
        executor.submit(()->{
            RequestContextHolder.setRequestAttributes(RequestContextHolder.getRequestAttributes(), true);
            feignService3.method();
        });
    }
}

然後RequestInterceptor.apply方法也做響應調整,如下

ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    //老的請求
    HttpServletRequest request = attributes.getRequest();
    String token = (String) attributes.getAttribute("token",1);
template.header("token",token);
        if (request != null) {
        //同步老的請求頭中的資料,這裡是獲取cookie
        String cookie = request.getHeader("token");
        template.header("token", cookie);
        }

問題得以圓滿解決。

總結

以上為個人經驗,希望能給大家一個參考,也希望大家多多支援it145.com。


IT145.com E-mail:sddin#qq.com