前言
我們在上一篇文章基于壓測進行Feign調優完成的服務間調用的性能調優,此時我們也關注到一個問題,如果我們統一從網關調用服務,但是網關因為某些原因報錯或者沒有找到服務怎么辦呢?
如下所示,筆者通過網關調用account
服務,但是account
服務還沒起來。此時請求還沒有到達account
就報錯了,這就意味著我們服務中編寫的@RestControllerAdvice
對網關沒有任何作用。
curl 127.0.0.1:8090/account/getByCode/zsy
響應結果如下,可以看到響應結果如下所示,要知道現如今的開發模式為前后端分離模式,前后端交互完全是基于協商好的格式,如果網關響應格式與我們規定的格式完全不一致,前端就需要特殊處理,這使得代碼不僅會變得丑陋,對于后續的功能擴展的交互復雜度也會增加,而gateway默認響應錯誤如下:
{"timestamp":"2023-02-09T15:22:20.278+0000","path":"/account/getByCode/zsy","status":500,"error":"Internal Server Error","message":"Connection refused: no further information: /192.168.43.73:9000"
}
網關異常默認處理
所以我們必須了解一下是什么原因導致網關報錯會響應這個值。
我們在gateway
源碼中找到ErrorWebFluxAutoConfiguration
這個自動裝配類,可以看到下面這段代碼,我們從中得知網關報錯時默認使用DefaultErrorWebExceptionHandler
來返回結果,所以我們不妨看看這個類做了那些事情。
@Bean@ConditionalOnMissingBean(value = ErrorWebExceptionHandler.class, search = SearchStrategy.CURRENT)@Order(-1)public ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes) {//網關默認異常處理的handlerDefaultErrorWebExceptionHandler exceptionHandler = new DefaultErrorWebExceptionHandler(errorAttributes,this.resourceProperties, this.serverProperties.getError(), this.applicationContext);exceptionHandler.setViewResolvers(this.viewResolvers);exceptionHandler.setMessageWriters(this.serverCodecConfigurer.getWriters());exceptionHandler.setMessageReaders(this.serverCodecConfigurer.getReaders());return exceptionHandler;}
我們不妨基于debug
了解一下這個類,當我們服務沒有注冊到nacos
,并通過網關調用報錯時,代碼就會走到下方,route
方法第一個參數是RequestPredicate
謂詞,而后者則是謂詞的處理,進行renderErrorView
,andRoute
同理將報錯的請求通過renderErrorResponse
返回錯誤結果
@Override
//route 方法第一個參數是RequestPredicate謂詞,而后者則是謂詞的處理,進行renderErrorView,然后通過然后通過andRoute將報錯的請求通過renderErrorResponse返回錯誤結果protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {return route(acceptsTextHtml(), this::renderErrorView).andRoute(all(), this::renderErrorResponse);}
我們不妨看看renderErrorResponse
,可以看到一行getErrorAttributes
,一旦步入我們就可以看到上文請求錯誤的結果格式
protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {boolean includeStackTrace = isIncludeStackTrace(request, MediaType.ALL);Map<String, Object> error = getErrorAttributes(request, includeStackTrace);return ServerResponse.status(getHttpStatus(error)).contentType(MediaType.APPLICATION_JSON_UTF8).body(BodyInserters.fromObject(error));}
getErrorAttributes
源碼,可以看到組裝的key
值就是我們調試時響應的參數:
@Overridepublic Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {Map<String, Object> errorAttributes = new LinkedHashMap<>();errorAttributes.put("timestamp", new Date());errorAttributes.put("path", request.path());Throwable error = getError(request);HttpStatus errorStatus = determineHttpStatus(error);errorAttributes.put("status", errorStatus.value());errorAttributes.put("error", errorStatus.getReasonPhrase());errorAttributes.put("message", determineMessage(error));handleException(errorAttributes, determineException(error), includeStackTrace);return errorAttributes;}
自定義異常處理
了解的默認錯誤處理,我們就可以改造,返回一個和普通服務一樣的格式給前端告知網關報錯。從上文我們可知網關默認錯誤處理時DefaultErrorWebExceptionHandler
,通過類圖我們可以發現它繼承了一個ErrorWebExceptionHandler
,所以我們也可以繼承這個類重寫一個Handler
。
以筆者的代碼如下,可以看到筆者使用Order
注解強制獲得最高異常處理優先級,然后使用bufferFactory.wrap
方法傳遞自定義錯誤格式返回給前端。
@Slf4j
@Order(-1)
@Configuration
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class GlobalErrorWebExceptionHandler implements ErrorWebExceptionHandler {private final ObjectMapper objectMapper;@Overridepublic Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {ServerHttpResponse response = exchange.getResponse();if (response.isCommitted()) {return Mono.error(ex);}// 設置返回值類型為jsonresponse.getHeaders().setContentType(MediaType.APPLICATION_JSON);//設置返回編碼if (ex instanceof ResponseStatusException) {response.setStatusCode(((ResponseStatusException) ex).getStatus());}return response.writeWith(Mono.fromSupplier(() -> {DataBufferFactory bufferFactory = response.bufferFactory();try {//writeValueAsBytes 組裝錯誤響應結果return bufferFactory.wrap(objectMapper.writeValueAsBytes(ResultData.fail(500, "網關捕獲到異常:" + ex.getMessage())));} catch (JsonProcessingException e) {log.error("Error writing response", ex);return bufferFactory.wrap(new byte[0]);}}));}
}
最終返回的結果如下所示,可以看到結果和一般的服務調用報錯格式一模一樣,這樣一來前端就無需為了網關報錯加一個特殊處理的邏輯了
curl 127.0.0.1:8090/account/getByCode/zsy
輸出結果
{"status":500,"message":"網關捕獲到異常:503 SERVICE_UNAVAILABLE \"Unable to find instance for account-service\"","data":null,"success":false,"timestamp":1675959617386
}
請求響應日志監控
對于微服務架構來說,監控是很重要的,在高并發場景情況下,很多問題我們都可以在網關請求響應中定位到,所以我們希望能有這么一種方式將用戶日常請求響應的日志信息記錄下來,便于日常運維和性能監控。
查閱了網上的資料發現,基于MongoDB
進行網關請求響應數據采集是一種不錯的方案,所以筆者本篇文章整理一下筆者如何基于網關過濾器結合MongoDB
完成請求日志采集。
本篇文章可能會涉及MongoDB
相關的知識,不了解的讀者可以參考筆者的這篇文章:
MongoDB快速入門
gateway整合MongoDB采集日志步驟
- 添加
MongoDB
依賴并完成MongoDB
配置:
首先在gateway
中添加MongoDB
依賴,需要注意的是,筆者后續的過濾器某些代碼段會用到hutool
的工具類,所以這里也添加了hutool
的依賴。
<!--mongodb依賴--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-mongodb-reactive</artifactId><exclusions><exclusion><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-logging</artifactId></exclusion></exclusions></dependency><dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId></dependency>
然后我們在gateway
的配置中添加MongoDB
的連接參數配置:
# mongodb的ip地址
spring.data.mongodb.host=ip
# mongodb端口號
spring.data.mongodb.port=27017
# mongodb數據庫名稱
spring.data.mongodb.database=accesslog
# 用戶名
spring.data.mongodb.username=xxxx
# 密碼
spring.data.mongodb.password=xxx
- 編寫
MongoDB
保存邏輯:
我們希望保存網關響應的內容到mongodb中,所以我們要把我們需要的內容封裝成一個對象,如下GatewayLog
@Data
public class GatewayLog {/*** 請求相對路徑*/private String requestPath;/***請求方法 :get post*/private String requestMethod;/***請求協議:http rpc*/private String schema;/***請求體內容*/private String requestBody;/***響應內容*/private String responseBody;/***ip地址*/private String ip;/*** 請求時間*/private String requestTime;/***響應時間*/private String responseTime;/***執行時間 單位:毫秒*/private Long executeTime;}
完成對象定義后,我們就可以編寫service層接口和實現類的邏輯了:
public interface AccessLogService {/*** 保存AccessLog* @param gatewayLog 請求響應日志* @return 響應日志*/GatewayLog saveAccessLog(GatewayLog gatewayLog);}
實現類代碼如下,可以看到筆者完全基于mongoTemplate
的save
方法將日志數據存到gatewayLog
表中。
@Service
public class AccessLogServiceImpl implements AccessLogService {@Autowiredprivate MongoTemplate mongoTemplate;//collection名稱private final String collectionName="gatewayLog" ;@Overridepublic GatewayLog saveAccessLog(GatewayLog gatewayLog) {GatewayLog result = mongoTemplate.save(gatewayLog, collectionName);return result;}
}
- 基于
gateway
過濾器完成請求相應日志采集,代碼比較長,首先是CachedBodyOutputMessage
,由于筆者用的是Spring boot 2.x
版本,沒有CachedBodyOutputMessage
這個類,所以筆者從網上找了一份。讀者可以根據注釋進行復制修改即可。
public class CachedBodyOutputMessage implements ReactiveHttpOutputMessage {private final DataBufferFactory bufferFactory;private final HttpHeaders httpHeaders;private Flux<DataBuffer> body = Flux.error(new IllegalStateException("The body is not set. Did handling complete with success? Is a custom \"writeHandler\" configured?"));private Function<Flux<DataBuffer>, Mono<Void>> writeHandler = this.initDefaultWriteHandler();public CachedBodyOutputMessage(ServerWebExchange exchange, HttpHeaders httpHeaders) {this.bufferFactory = exchange.getResponse().bufferFactory();this.httpHeaders = httpHeaders;}public void beforeCommit(Supplier<? extends Mono<Void>> action) {}public boolean isCommitted() {return false;}public HttpHeaders getHeaders() {return this.httpHeaders;}private Function<Flux<DataBuffer>, Mono<Void>> initDefaultWriteHandler() {return (body) -> {this.body = body.cache();return this.body.then();};}public DataBufferFactory bufferFactory() {return this.bufferFactory;}public Flux<DataBuffer> getBody() {return this.body;}public void setWriteHandler(Function<Flux<DataBuffer>, Mono<Void>> writeHandler) {Assert.notNull(writeHandler, "'writeHandler' is required");this.writeHandler = writeHandler;}public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {return Mono.defer(() -> {return (Mono)this.writeHandler.apply(Flux.from(body));});}public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {return this.writeWith(Flux.from(body).flatMap((p) -> {return p;}));}public Mono<Void> setComplete() {return this.writeWith(Flux.empty());}
}
過濾器代碼如下,筆者將核心內容都已注釋了,讀者可以基于此代碼進行修改
@Slf4j
@Component
public class AccessLogGlobalFilter implements GlobalFilter, Ordered {private final List<HttpMessageReader<?>> messageReaders = HandlerStrategies.withDefaults().messageReaders();//todo 存在線程安全問題,后續需要優化掉SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");@Autowiredprivate AccessLogService accessLogService;@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {GatewayLog gatewayLog = new GatewayLog();ServerHttpRequest request = exchange.getRequest();//獲取請求的ip,url,method,bodyString requestPath = request.getPath().pathWithinApplication().value();String clientIp = request.getRemoteAddress().getHostString();String scheme = request.getURI().getScheme();String method = request.getMethodValue();//數據記錄到gatwayLog中gatewayLog.setSchema(scheme);gatewayLog.setRequestMethod(method);gatewayLog.setRequestPath(requestPath);gatewayLog.setRequestTime(simpleDateFormat.format(new Date().getTime()));gatewayLog.setIp(clientIp);MediaType contentType = request.getHeaders().getContentType();if (MediaType.APPLICATION_FORM_URLENCODED.isCompatibleWith(contentType) || MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {return writeBodyLog(exchange, chain, gatewayLog);} else {//寫入日志信息到mongoDbreturn writeBasicLog(exchange, chain, gatewayLog);}}private Mono<Void> writeBasicLog(ServerWebExchange exchange, GatewayFilterChain chain, GatewayLog accessLog) {StringBuilder builder = new StringBuilder();MultiValueMap<String, String> queryParams = exchange.getRequest().getQueryParams();for (Map.Entry<String, List<String>> entry : queryParams.entrySet()) {builder.append(entry.getKey()).append("=").append(StringUtils.join(entry.getValue(), ","));}//記錄響應內容accessLog.setRequestBody(builder.toString());// 獲取響應體ServerHttpResponseDecorator decoratedResponse = recordResponseLog(exchange, accessLog);return chain.filter(exchange.mutate().response(decoratedResponse).build()).then(Mono.fromRunnable(() -> {//打印日志writeAccessLog(accessLog);}));}/*** 解決request body 只能讀取一次問題** @param exchange* @param chain* @param gatewayLog* @return*/private Mono writeBodyLog(ServerWebExchange exchange, GatewayFilterChain chain, GatewayLog gatewayLog) {ServerRequest serverRequest = ServerRequest.create(exchange, messageReaders);Mono<String> modifiedBody = serverRequest.bodyToMono(String.class).flatMap(body -> {gatewayLog.setRequestBody(body);return Mono.just(body);});// 通過 BodyInsert 插入 body(支持修改body), 避免 request body 只能獲取一次BodyInserter bodyInserter = BodyInserters.fromPublisher(modifiedBody, String.class);HttpHeaders headers = new HttpHeaders();headers.putAll(exchange.getRequest().getHeaders());headers.remove(HttpHeaders.CONTENT_LENGTH);CachedBodyOutputMessage outputMessage = new CachedBodyOutputMessage(exchange, headers);return bodyInserter.insert(outputMessage, new BodyInserterContext()).then(Mono.defer(() -> {// 重新封裝請求ServerHttpRequest decoratedRequest = requestDecorate(exchange, headers, outputMessage);// 記錄響應日志ServerHttpResponseDecorator decoratedResponse = recordResponseLog(exchange, gatewayLog);// 記錄普通的return chain.filter(exchange.mutate().request(decoratedRequest).response(decoratedResponse).build()).then(Mono.fromRunnable(() -> {// 打印日志writeAccessLog(gatewayLog);}));}));}/*** 打印日志并將日志內容寫入mongodb** @param gatewayLog*/private void writeAccessLog(GatewayLog gatewayLog) {log.info("寫入網關日志,日志內容:" + JSON.toJSONString(gatewayLog));accessLogService.saveAccessLog(gatewayLog);}/*** 請求裝飾器,重新計算 headers** @param exchange* @param headers* @param outputMessage* @return*/private ServerHttpRequestDecorator requestDecorate(ServerWebExchange exchange, HttpHeaders headers,CachedBodyOutputMessage outputMessage) {return new ServerHttpRequestDecorator(exchange.getRequest()) {@Overridepublic HttpHeaders getHeaders() {long contentLength = headers.getContentLength();HttpHeaders httpHeaders = new HttpHeaders();httpHeaders.putAll(super.getHeaders());if (contentLength > 0) {httpHeaders.setContentLength(contentLength);} else {httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");}return httpHeaders;}@Overridepublic Flux<DataBuffer> getBody() {return outputMessage.getBody();}};}/*** 記錄響應日志** @param exchange* @param gatewayLog* @return*/private ServerHttpResponseDecorator recordResponseLog(ServerWebExchange exchange, GatewayLog gatewayLog) {ServerHttpResponse response = exchange.getResponse();DataBufferFactory bufferFactory = response.bufferFactory();return new ServerHttpResponseDecorator(response) {@SneakyThrows@Overridepublic Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {if (body instanceof Flux) {String responseTime = simpleDateFormat.format(new Date().getTime());gatewayLog.setResponseTime(responseTime);// 計算執行時間long executeTime = (simpleDateFormat.parse(responseTime).getTime() - simpleDateFormat.parse(gatewayLog.getRequestTime()).getTime());gatewayLog.setExecuteTime(executeTime);// 獲取響應類型,如果是 json 就打印String originalResponseContentType = exchange.getAttribute(ServerWebExchangeUtils.ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR);if (ObjectUtils.equals(this.getStatusCode(), HttpStatus.OK)&& StringUtils.isNotBlank(originalResponseContentType)&& originalResponseContentType.contains("application/json")) {Flux<? extends DataBuffer> fluxBody = Flux.from(body);return super.writeWith(fluxBody.buffer().map(dataBuffers -> {// 合并多個流集合,解決返回體分段傳輸DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();DataBuffer join = dataBufferFactory.join(dataBuffers);byte[] content = new byte[join.readableByteCount()];join.read(content);// 釋放掉內存DataBufferUtils.release(join);String responseResult = new String(content, StandardCharsets.UTF_8);gatewayLog.setResponseBody(responseResult);return bufferFactory.wrap(content);}));}}return super.writeWith(body);}};}/*** 調小優先級使得該過濾器最先執行* @return*/@Overridepublic int getOrder() {return -100;}
}
- 測試
以筆者項目為例,通過網關調用order
服務:
curl 127.0.0.1:8090/order/getByCode/zsy
可以看到響應成功了,接下來我們就確認一下mongoDb中是否有存儲網關請求響應信息
{"status":100,"message":"操作成功","data":{"id":1,"accountCode":"zsy","accountName":"zsy","amount":10000.00},"success":true,"timestamp":1676439102837}
通過數據庫連接工具查詢,可以看到網關請求響應日志也成功存儲到MongoDB
中。
參考文獻
SpringCloud Alibaba微服務實戰二十四 - SpringCloud Gateway的全局異常處理
軟件開發設計中的上游與下游
SpringCloud Alibaba實戰二十九 | SpringCloud Gateway 請求響應日志
MongoDB 數據查詢操作
實戰 | MongoDB的安裝配置
spring cloud gateway中實現請求、響應參數日志打印