擼了一個(gè) Feign 增強(qiáng)包 V2.0 升級版
前言
大概在兩年前我寫過一篇 擼了一個(gè) Feign 增強(qiáng)包,當(dāng)時(shí)準(zhǔn)備是利用 SpringBoot + K8s 構(gòu)建應(yīng)用,這個(gè)庫可以類似于 SpringCloud 那樣結(jié)合 SpringBoot 使用聲明式接口來達(dá)到服務(wù)間通訊的目的。
但后期由于技術(shù)棧發(fā)生變化(改為 Go),導(dǎo)致該項(xiàng)目只實(shí)現(xiàn)了基本需求后就擱置了。
巧合的時(shí)最近內(nèi)部有部分項(xiàng)目又計(jì)劃采用 SpringBoot + K8s 開發(fā),于是便著手繼續(xù)維護(hù);現(xiàn)已經(jīng)內(nèi)部迭代了幾個(gè)版本比較穩(wěn)定了,也增加了一些實(shí)用功能,在此分享給大家。
??https://github.com/crossoverJie/feign-plus。??
首先是新增了一些 features:
- 更加統(tǒng)一的 API。
- 統(tǒng)一的請求、響應(yīng)、異常日志記錄。
- 自定義攔截器。
- Metric 支持。
- 異常傳遞。
示例
結(jié)合上面提到的一些特性做一些簡單介紹,統(tǒng)一的 API 主要是在使用層面:
在上一個(gè)版本中聲明接口如下:
@FeignPlusClient(name = "github", url = "${github.url}")
public interface Github {
@RequestLine("GET /repos/{owner}/{repo}/contributors")
List<GitHubRes> contributors(@Param("owner") String owner, @Param("repo") String repo);
}
其中的 @RequestLine 等注解都是使用 feign 包所提供的。
這次更新后改為如下方式:
@RequestMapping("/v1/demo")
@FeignPlusClient(name = "demo", url = "${feign.demo.url}", port = "${feign.demo.port}")
public interface DemoApi {
@GetMapping("/id")
String sayHello(@RequestParam(value = "id") Long id);
@GetMapping("/id/{id}")
String id(@PathVariable(value = "id") Long id);
@PostMapping("/create")
Order create(@RequestBody OrderCreateReq req);
@GetMapping("/query")
Order query(@SpringQueryMap OrderQueryDTO dto);
}
熟悉的味道,基本都是 Spring 自帶的注解,這樣在使用上學(xué)習(xí)成本更低,同時(shí)與項(xiàng)目中原本的接口寫法保持一致。
@SpringQueryMap(top.crossoverjie.feign.plus.contract.SpringQueryMap) 是由 feign-plus 提供,其實(shí)就是從 SpringCloud 中 copy 過來的。
我這里寫了兩個(gè) demo 來模擬調(diào)用:
provider: 作為服務(wù)提供者提供了一系列接口供消費(fèi)方調(diào)用,并對外提供了一個(gè) api 模塊。
demo:作為服務(wù)消費(fèi)者依賴 provider-api 模塊,根據(jù)其中聲明的接口進(jìn)行遠(yuǎn)程調(diào)用。
配置文件:
server:
port: 8181
feign:
demo:
url : http://127.0.0.1
port: 8080
logging:
level:
top:
crossoverjie: debug
management:
endpoints:
web:
base-path: /actuator
exposure:
include: '*'
metrics:
distribution:
percentiles:
all: 0.5,0.75,0.95,0.99
export:
prometheus:
enabled: true
step: 1m
spring:
application:
name: demo
當(dāng)我們訪問 http://127.0.0.1:8181/hello/2 接口時(shí)從控制臺可以看到調(diào)用結(jié)果:
日志記錄
從上圖中可以看出 feign-plus 會用 debug 記錄請求/響應(yīng)結(jié)果,如果需要打印出來時(shí)需要將該包下的日志級別調(diào)整為 debug:
logging:
level:
top:
crossoverjie: debug
由于內(nèi)置了攔截器,也可以自己繼承 top.crossoverjie.feign.plus.log.DefaultLogInterceptor 來實(shí)現(xiàn)自己的日志攔截記錄,或者其他業(yè)務(wù)邏輯。
@Component
@Slf4j
public class CustomFeignInterceptor extends DefaultLogInterceptor {
@Override
public void request(String target, String url, String body) {
super.request(target, url, body);
log.info("request");
}
@Override
public void exception(String target, String url, FeignException feignException) {
super.exception(target, url, feignException);
}
@Override
public void response(String target, String url, Object response) {
super.response(target, url, response);
log.info("response");
}
}
監(jiān)控 metric
feign-plus 會自行記錄每個(gè)接口之間的調(diào)用耗時(shí)、異常等情況。
訪問 http://127.0.0.1:8181/actuator/prometheus 會看到相關(guān)埋點(diǎn)信息,通過 feign_call* 的 key 可以自行在 Grafana 配置相關(guān)面板,類似于下圖:
異常傳遞
rpc(遠(yuǎn)程調(diào)用)要使用起來真的類似于本地調(diào)用,異常傳遞必不可少。
// provider
public Order query(OrderQueryDTO dto) {
log.info("dto = {}", dto);
if (dto.getId().equals("1")) {
throw new DemoException("provider test exception");
}
return new Order(dto.getId());
}
// consumer
try {
demoApi.query(new OrderQueryDTO(id, "zhangsan"));
} catch (DemoException e) {
log.error("feignCall:{}, sourceApp:[{}], sourceStackTrace:{}", e.getMessage(), e.getAppName(), e.getDebugStackTrace(), e);
}
比如 provider 中拋出了一個(gè)自定義的異常,在 consumer 中可以通過 try/catch 捕獲到該異常。
為了在 feign-plus 中實(shí)現(xiàn)該功能需要幾個(gè)步驟:
- 自定義一個(gè)通用異常。
- 服務(wù)提供方需要實(shí)現(xiàn)一個(gè)全局?jǐn)r截器,當(dāng)發(fā)生異常時(shí)統(tǒng)一對外響應(yīng)數(shù)據(jù)。
- 服務(wù)消費(fèi)方需要自定義一個(gè)異常解碼器的 bean。
這里我在 provider 中自定義了一個(gè) DemoException:
通常這個(gè)類應(yīng)該定義在公司內(nèi)部的通用包中,這里為了演示方便。
接著定義了一個(gè) HttpStatus 的類用于統(tǒng)一對外響應(yīng)。
@Data
@AllArgsConstructor
@NoArgsConstructor
public class HttpStatus {
private String appName;
private int code;
private String message;
private String debugStackTrace;
}
這個(gè)也應(yīng)該放在通用包中。
然后在 provider 中定義全局異常處理:
當(dāng)出現(xiàn)異常時(shí)便會返回一個(gè) http_code=500 的數(shù)據(jù):
到這一步又會出現(xiàn)一個(gè)引戰(zhàn)話題:HTTP 接口返回到底是全部返回 200 然后通過 code 來來判斷,還是參考 http_code 進(jìn)行返回?
這里不做過多討論,具體可以參考耗子叔的文章: “一把梭:REST API 全用 POST”
feign-plus 默認(rèn)采用的 http_code !=200 才會認(rèn)為發(fā)生了異常。
而這里的 http_status 也是參考了 Google 的 api 設(shè)計(jì):
具體可以參考這個(gè)鏈接: https://cloud.google.com/apis/design/errors#propagating_errors。
然后定義一個(gè)異常解析器:
@Configuration
public class FeignExceptionConfig {
@Bean
public FeignErrorDecoder feignExceptionDecoder() {
return (methodName, response, e) -> {
HttpStatus status = JSONUtil.toBean(response, HttpStatus.class);
return new DemoException(status.getAppName(), status.getCode(), status.getMessage(), status.getDebugStackTrace());
};
}
}
通常這塊代碼也是放在基礎(chǔ)包中。
這樣當(dāng)服務(wù)提供方拋出異常時(shí),消費(fèi)者便能成功拿到該異常:
實(shí)現(xiàn)原理
實(shí)現(xiàn)原理其實(shí)也比較簡單,了解 rpc 原理的話應(yīng)該會知道,服務(wù)提供者返回的異常調(diào)用方是不可能接收到的,這和是否由一種語言實(shí)現(xiàn)也沒關(guān)系。
畢竟兩個(gè)進(jìn)程之間的棧是完全不同的,不在一臺服務(wù)器上,甚至都不在一個(gè)地區(qū)。
所以 provider 拋出異常后,消費(fèi)者只能拿到一串報(bào)文,我們只能根據(jù)這段報(bào)文解析出其中的異常信息,然后再重新創(chuàng)建一個(gè)內(nèi)部自定義的異常(比如這里的 DemoException),也就是我們自定義異常解析器所干的事情。
下圖就是這個(gè)異常傳遞的大致流程:
code message 模式
由于 feign-plus 默認(rèn)是采用 http_code != 200 的方式來拋出異常的,所以采用 http_code=200, code message 的方式響應(yīng)數(shù)據(jù)將不會傳遞異常,依然會任務(wù)是一次正常調(diào)用。
不過基于該模式傳遞異常也是可以實(shí)現(xiàn)的,但沒法做到統(tǒng)一,比如有些團(tuán)隊(duì)習(xí)慣 code !=0 表示異常,甚至字段都不是 code;再或者異常信息有些是放在 message 或 msg 字段中。
每個(gè)團(tuán)隊(duì)、個(gè)人習(xí)慣都不相同,所以沒法抽象出一個(gè)標(biāo)準(zhǔn),因此也就沒做相關(guān)適配。
這也印證了使用國際標(biāo)準(zhǔn)所帶來的好處。
限于篇幅,如果有相關(guān)需求的朋友也可以在評論區(qū)溝通,實(shí)現(xiàn)上會比現(xiàn)在稍微復(fù)雜一點(diǎn)點(diǎn)。
總結(jié)
項(xiàng)目源碼: https://github.com/crossoverJie/feign-plus。
基于2022年云原生這個(gè)背景,當(dāng)然更推薦大家使用 gRPC 來做服務(wù)間通信,這樣也不需要維護(hù)類似于這樣的庫了。
不過在一些調(diào)用第三方接口而對方也沒有提供 SDK 時(shí),這個(gè)庫也有一定用武之地,雖然使用原生 feign 也能達(dá)到相同目的,但使用該庫可以使得與 Spring 開發(fā)體驗(yàn)一致,同時(shí)內(nèi)置了日志、metric 等功能,避免了重復(fù)開發(fā)。