引言
Hello 大家好,這里是Anyin。
在我們微服務(wù)開發(fā)過程中不可避免的會涉及到微服務(wù)之間的調(diào)用,例如:認(rèn)證Auth服務(wù)需要去用戶User服務(wù)獲取用戶信息。在Spring Cloud全家桶的背景下,我們一般都是使用Feign組件進(jìn)行服務(wù)之間的調(diào)用。
關(guān)于一般的Feign組件使用相信大家都很熟悉,但是在搭建整個微服務(wù)架構(gòu)的時(shí)候Feign組件遇到的問題也都熟悉嗎 ? 今天我們來聊一聊。
基礎(chǔ)使用
首先,我們先實(shí)現(xiàn)一個Feign組件的使用方法。
1.導(dǎo)入包
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
這里也導(dǎo)入了一個Loadbalancer組件,因?yàn)樵贔eign底層還會使用到負(fù)載均衡器進(jìn)行客戶端負(fù)載。
2.配置啟用FeignClient
@EnableFeignClients(basePackages = {
"org.anyin.gitee.cloud.center.upms.api"
})
在我們的main入口的類上添加上@EnableFeignClients注解,并指定了包掃描的位置
3.編寫FeignClient接口
@FeignClient(name = "anyin-center-upms",
contextId = "SysUserFeignApi",
configuration = FeignConfig.class,
path = "/api/sys-user")
public interface SysUserFeignApi {
@GetMApping("/info/mobile")
ApiResponse<SysUserResp> infoByMobile(@RequestParam("mobile") String mobile);
}
我們自定義了一個SysUserFeignApi接口,并且添加上了@FeignClient注解。相關(guān)屬性說明如下:
·name 應(yīng)用名,其實(shí)就是spring.application.name,用于標(biāo)識某個應(yīng)用,并且能從注冊中心拿到對應(yīng)的運(yùn)行實(shí)例信息
·contextId 當(dāng)你多個接口都使用了一樣的name值,則需要通過contextId來進(jìn)行區(qū)分
·configuration 指定了具體的配置類
·path 請求的前綴
1.編寫FeignClient接口實(shí)現(xiàn)
@RestController
@RequestMapping("/api/sys-user")
public class SysUserFeignController implements SysUserFeignApi {
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private SysUserConvert sysUserConvert;
@Override
public ApiResponse<SysUserResp> infoByMobile(@RequestParam("mobile") String mobile) {
SysUser user = sysUserRepository.infoByMobile(mobile);
SysUserResp resp = sysUserConvert.getSysUserResp(user);
return ApiResponse.success(resp);
}
}
這個就是一個簡單的Controller類和對應(yīng)的方法,用于根據(jù)手機(jī)號查詢用戶信息。
2.客戶端使用
@Component
@Slf4j
public class MobileInfoHandler{
@Autowired
private SysUserFeignApi sysUserFeignApi;
@Override
public SysUserResp info(String mobile) {
SysUserResp sysUser = sysUserFeignApi.infoByMobile(mobile).getData();
if(sysUser == null){
throw AuthExCodeEnum.USER_NOT_REGISTER.getException();
}
return sysUser;
}
}
這個是我們在客戶端服務(wù)使用Feign組件的代碼,它就像一個Service方法一樣,直接調(diào)用就行。無需處理請求和響應(yīng)過程中關(guān)于參數(shù)的轉(zhuǎn)換。
至此,我們的一個Feign組件基本使用的代碼就完成了。這個時(shí)候我們信心滿滿地趕緊運(yùn)行下我們代碼,測試下接口是否正常。
以上的代碼,是能夠正常運(yùn)行的。但是隨著我們遇到場景的增多,我們會發(fā)現(xiàn),理想很豐滿,顯示很骨感,以上的代碼并不能100%適應(yīng)我們遇到的場景。
接下來,我們來看看我們遇到哪些場景以及這些場景需要怎么解決。
場景一:日志
在以上的代碼中,因?yàn)槲覀兾醋鋈魏闻渲?,所以sysUserFeignApi.infoByMobile方法對于我們來講就像一個黑盒。
雖然我們傳遞了mobile值,但是不知道真實(shí)請求用戶服務(wù)的值是什么,是否有其他信息一起傳遞?雖然方法返回的參數(shù)是SysUserResp實(shí)體,但是我們不知道用戶服務(wù)返回的是什么,是否有其他信息一起返回?雖然我們知道Feign組件底層是http實(shí)現(xiàn),那么請求的過程是否有傳遞header信息?
這一切對我們來講就是一個黑盒,極大阻礙我們拔刀(排查問題)的速度。所以,我們需要配置日志,用于顯示請求過程中的所有信息傳遞。
在剛@FeignClient注解有個參數(shù),configuration 指定了具體的配置類,我們可以在這里指定日志的級別。如下:
public class FeignConfig {
@Bean
public Logger.Level loggerLevel(){
return Logger.Level.FULL;
}
}
接著還需要在配置文件指定具體FeignClient的日志級別為DEBUG。
logging:
level:
root: info
org.anyin.gitee.cloud.center.upms.api.SysUserFeignApi: debug
這個時(shí)候,你在請求接口的時(shí)候,會發(fā)現(xiàn)多了好多日志。
這里就可以詳細(xì)看到,在請求開始的時(shí)候攜帶的所有header信息以及請求參數(shù)信息,在響應(yīng)回來的時(shí)候通用打印了所有的響應(yīng)信息。
場景二:透傳header信息
在上一節(jié)中,我們在日志中看到了很多的請求頭header的信息,這些都是程序自己添加的嗎 ? 很明顯不是。例如x-application-name和x-request-id,這兩個參數(shù)就是我們自己添加的。
需要透傳header信息的場景,一般是出現(xiàn)在租戶ID或者請求ID的場景下。我們這里以請求ID為例,我們知道用戶的一個請求,可能會涉及多個服務(wù)實(shí)例,當(dāng)程序出現(xiàn)問題的時(shí)候?yàn)榱朔奖闩挪椋覀円话銜褂谜埱驣D來標(biāo)識一次用戶請求,并且這個請求ID貫穿所有經(jīng)過的服務(wù)實(shí)例,并且在日志中打印出來。這樣子,當(dāng)出現(xiàn)問題的時(shí)候,根據(jù)該請求ID就可以撈出本次用戶請求的所有日志信息。
關(guān)于請求ID打印到日志可以參考: 不會吧,你還不會用RequestId看日志 ?[1]
基于這種場景,我們需要手動設(shè)置透傳信息,F(xiàn)eign組件也給我們提供了對應(yīng)的方式。 只要實(shí)現(xiàn)了RequestInterceptor接口,即可透傳header信息。
public class FeignRequestInterceptor implements RequestInterceptor {
@Value("${spring.application.name}")
private String app;
@Override
public void apply(RequestTemplate template) {
HttpServletRequest request = WebContext.getRequest();
// job 類型的任務(wù),可能沒有Request
if(request != null && request.getHeaderNames() != null){
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
// Accept值不傳遞,避免出現(xiàn)需要響應(yīng)xml的情況
if ("Accept".equalsIgnoreCase(name)) {
continue;
}
String values = request.getHeader(name);
template.header(name, values);
}
}
template.header(CommonConstants.APPLICATION_NAME, app);
template.header(CommonConstants.REQUEST_ID, RequestIdUtil.getRequestId().toString());
template.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
}
}
場景三:異常處理
在第一節(jié)我們客戶調(diào)用的時(shí)候,我們是沒有處理異常的,我們直接.getData()直接返回了,這其實(shí)是一個非常危險(xiǎn)的操作,.getData()的返回結(jié)果可能是null,很容易造成NPE的情況。
SysUserResp sysUser = sysUserFeignApi.infoByMobile(mobile).getData();
回想下,當(dāng)我們調(diào)用其他當(dāng)前服務(wù)的Service方法的時(shí)候,如果遇到異常,是不是就是直接拋出異常,交由統(tǒng)一異常處理器進(jìn)行處理?所以,這里我們也是期望調(diào)用Feign和Service一樣,遇到異常,交由統(tǒng)一異常進(jìn)行處理。
如何處理這個需求呢? 我們可以在解碼的時(shí)候進(jìn)行處理。
@Slf4j
public class FeignDecoder implements Decoder {
// 代理默認(rèn)的解碼器
private Decoder decoder;
public FeignDecoder(Decoder decoder) {
this.decoder = decoder;
}
@Override
public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException {
// 序列化為json
String json = this.getResponseJson(response);
this.processCommonException(json);
return decoder.decode(response, type);
}
// 處理公共業(yè)務(wù)異常
private void processCommonException(String json){
if(!StringUtils.hasLength(json)){
return;
}
ApiResponse resp = JSONUtil.toBean(json, ApiResponse.class);
if(resp.getSuccess()){
return;
}
log.info("feign response error: code={}, message={}", resp.getCode(), resp.getMessage());
// 拋出我們期望的業(yè)務(wù)異常
throw new CommonException(resp.getCode(), resp.getMessage());
}
// 響應(yīng)值轉(zhuǎn)json字符串
private String getResponseJson(Response response) throws IOException {
try (InputStream inputStream = response.body().asInputStream()) {
return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
}
}
}
這里我們的處理方式是在解碼的時(shí)候,先從響應(yīng)結(jié)果中拿到是否有業(yè)務(wù)異常的判斷,如果有,則構(gòu)造業(yè)務(wù)異常實(shí)例,然后拋出信息。
運(yùn)行下代碼,我們會發(fā)現(xiàn)統(tǒng)一異常那邊還是無法處理由下游服務(wù)返回的異常,原因是雖然我們拋出了一個CommonException,但是其實(shí)最后還是會被Feign捕獲,然后重新封裝為DecodeException異常,再進(jìn)行拋出
Object decode(Response response, Type type) throws IOException {
try {
return decoder.decode(response, type);
} catch (final FeignException e) {
throw e;
} catch (final RuntimeException e) {
// 重新封裝異常
throw new DecodeException(response.status(), e.getMessage(), response.request(), e);
}
}
所以,我們還需要在統(tǒng)一異常那邊再做下處理,代碼如下:
@ResponseStatus(HttpStatus.OK)
@ExceptionHandler(DecodeException.class)
public ApiResponse decodeException(DecodeException ex){
log.error("解碼失敗: {}", ex.getMessage());
String id = RequestIdUtil.getRequestId().toString();
if(ex.getCause() instanceof CommonException){
CommonException ce = (CommonException)ex.getCause();
return ApiResponse.error(id, ce.getErrorCode(), ce.getErrorMessage());
}
return ApiResponse.error(id, CommonExCodeEnum.DATA_PARSE_ERROR.getCode(), ex.getMessage());
}
在運(yùn)行下代碼,我們就可以看到異常從用戶服務(wù)->認(rèn)證服務(wù)->網(wǎng)關(guān)->前端這么一個流程。
·用戶服務(wù)拋出的異常
·認(rèn)證服務(wù)拋出的異常
·前端顯示的異常
場景四:時(shí)區(qū)問題
隨著業(yè)務(wù)的變化,我們可能會在請求參數(shù)或者響應(yīng)參數(shù)中增加關(guān)于Date類型的參數(shù),這個時(shí)候你會發(fā)現(xiàn),它的時(shí)區(qū)不對,少了8個小時(shí)。
這個問題其實(shí)是Jackson組件帶來的,該問題其實(shí)也有不同的解法。
1.在每個Date屬性添加上@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")2.傳參統(tǒng)一轉(zhuǎn)為yyyy-MM-dd HH:mm:ss格式的字符3.統(tǒng)一配置spring.jackson
很明顯,第三種解法最合適,我們在配置文件做如下的配置即可。
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
這里需要注意下,我們@FeignClient的配置是自定義配置的FeignConfig類,在自定義配置類中加載了解碼器,而解碼器依賴的是全局的HttpMessageConverters實(shí)例,和SpringMVC依賴的是同一個實(shí)例,所以該配置生效。有些場景下會自定義HttpMessageConverters,那么該配置則不生效。
public class FeignConfig {
@Autowired
private ObjectFactory<HttpMessageConverters> messageConverters;
// 自定義解碼器
@Bean
public Decoder decoder(ObjectProvider<HttpMessageConverterCustomizer> customizers){
return new FeignDecoder(
new OptionalDecoder(
new ResponseEntityDecoder(
new SpringDecoder(messageConverters, customizers))));
}
}
其他問題
不知道細(xì)心的朋友是否有看到在第一節(jié)定義SysUserFeignApi接口的時(shí)候,我在@FeignClient注解上使用了一個屬性:path,并且接口上沒有使用@RequestMapping注解。
回想下,之前我們在使用Feign的時(shí)候,是不是這么使用的:
@FeignClient(name = "anyin-center-upms",
contextId = "SysUserFeignApi",
configuration = FeignConfig.class)
@RequestMapping("/api/sys-user")
public interface SysUserFeignApi {}
這里不使用這個方式的原因是我當(dāng)前版本的Spring Cloud OpenFeign已經(jīng)不支持識別@RequestMapping注解了,它不會在請求的時(shí)候加入到請求的前綴,所以即使解決了@RequestMapping注解被SpringMVC識別為Controller類也無法正常運(yùn)行。
所以,這里使用了path屬性。
最后
以上,就是我們在使用OpenFeign組件的時(shí)候會遇到的大部分場景,你了解嗎 ?
相關(guān)源碼地址:Anyin Cloud[2]
References
[1] 不會吧,你還不會用RequestId看日志 ?: https:///post/7029880952666980388 [2] Anyin Cloud: https:///anyin/anyin-cloud
|