效果
前言
最近想实现一个类似Postman的功能,可以进行Http请求,通过设置请求头,请求体设置接口返回信息等等,于是把实现重要思路和过程分享给大家,以及在实现过程中遇到的坑及解决方法。
什么是逐条返回?什么是一次性返回?
我们使用通义千问或者其它AI的时候,你会发现它回答的时候都是逐条返回的,这里它用到的技术是content-type:text/event-stream
,而其它接口返回content-type
则是一次性返回的。
前端操作
前端的话,我是使用@monaco-editor
组件来实现文本输入,下面是实现思路:
1、前端收集url、method、headers、body(none 、json、form-data)和Content-Type
2、发起Post请求:
普通Post请求:/crud/httpRequest/proxy
Form-Data Post请求:/crud/httpRequest/proxy/formData
3、返回接口结果给前端展示
不过有几个点需要注意:
1、monaco-editor默认语言是html时格式化成json没问题,但是默认是json再格式化成html就有问题
2、monaco-editor格式化前要设置编辑器为可编辑状态,否则格式化不起作用
3、monaco-editor的值必须是字符串,否则报:$.create的错
4、根据接口返回来的content-type来判断如何渲染,比如:如果是文件展示文件流,如果是text/event-stream则逐条渲染
5、样式方面,做好monaco-editor编辑的高度限制
6、根据接口返回来的是字符串还是json来做好格式化工作
可能会有人问:为什么你通过两个接口来实现?
我的回答是:普通参数后台接收时,可以使用:@RequestBody
,但是因为form-data请求时可能会带上文件,所以接口必须是Flux<FilePart> files
来接收文件,所以只能写两个方法来实现。
然后,我们点击发送请求按钮,把参数发送到后台,这里也分是否是form-data
请求带参也不同,如下所示:
不是form-data
是form-data
这里你可能会注意到为什么除了上传的文件files
其它参数为什么和上面的不一样?
是这样的,因为可能用户会上传多个文件,而只要设置同一个名称的files就可以集合多个文件,这是http特性,而用户可能每个文件名都会自定义,比如:file1: (二进制),file2:(二进制),所以真实的文件列表其实是放到files中,而文件的名称则是按照文件的顺序保存到fileParams中,后台到时解析出来一一对应即可,而其它参数,如requestParams和params,一个是系统的请求参数,如:url、headers、method等等,而后面的则是另外的form-data
数据,我这样解释大家是否能理解?
上面的解释中,大家是否会问:为什么你要把上传的多个文件放到files中,而不是file1: (二进制),file2:(二进制)这样传呢?
我的回答是:因为我不知道用户会传什么fileName,因为是动态的,所以后台在接收的时候就不知道该如何以什么文件名来接收!所以我只能将用户的fileName和file分开收集成数组形式传递给后台。
后台操作
好了,前端已经将数据传递给了后台,现在我们来看看如何实现。
1、非form-data请求
@PostMapping("/crud/httpRequest/proxy")
public Mono<ResponseEntity<?>> proxyRequest(@RequestBody HttpRequestRequest httpRequestRequest) {
String url = httpRequestRequest.getUrl();
String headers = httpRequestRequest.getHeaders();
String body = httpRequestRequest.getBody();
String method = httpRequestRequest.getMethod();
String contentType = httpRequestRequest.getContentType();
LoginUser loginUser = UserContext.getUser();
if (loginUser == null) {
return Mono.just(ResponseEntity.ok(Flux.just(ResultStatus.DATA_EMPTY.getMessage())));
}
if (!StringUtils.hasLength(method) || !StringUtils.hasLength(url)) {
return Mono.just(ResponseEntity.ok(Flux.just(ResultStatus.DATA_EMPTY.getMessage())));
}
WebClient webClient = WebClient.create();
try {
Map<String, String> headersMap = null;
if (StringUtils.hasLength(headers)) {
// 将headers的json字符串写入到Map
ObjectMapper objectMapper = new ObjectMapper();
headersMap = objectMapper.readValue(headers, Map.class);
}
WebClient.RequestBodySpec requestSpec = webClient.method(HttpMethod.valueOf(method.toUpperCase()))
.uri(URI.create(url));
if (headersMap != null && !headersMap.isEmpty()) {
headersMap.forEach(requestSpec::header);
}
if ("put".equalsIgnoreCase(method) || "post".equalsIgnoreCase(method)) {
if (StringUtils.hasLength(contentType)) {
requestSpec.header("Content-Type", contentType);
// return Mono.just(ResponseEntity.ok(Flux.just(ResultStatus.DATA_EMPTY.getMessage())));
}
if (StringUtils.hasLength(body)) {
requestSpec.bodyValue(body);
}
}
return handleWebClientResponse(requestSpec);
} catch (Exception ex) {
log.info("proxyRequest ex ={}", ex.getMessage());
return Mono.just(ResponseEntity.ok(Flux.just(ex.getMessage())));
}
}
public static Mono<ResponseEntity<?>> handleWebClientResponse(WebClient.RequestBodySpec requestBodySpec) {
return requestBodySpec
.retrieve()
.onStatus(HttpStatusCode::isError, clientResponse -> clientResponse.createException().flatMap(Mono::error))
.toEntityFlux(String.class)
.flatMap(responseEntity -> {
HttpHeaders headers = responseEntity.getHeaders();
MediaType contentType = headers.getContentType();
if (MediaType.TEXT_EVENT_STREAM.isCompatibleWith(contentType)) {
return Mono.just(ResponseEntity.ok()
.headers(headers)
.contentType(MediaType.TEXT_EVENT_STREAM)
.body(Flux.from(Objects.requireNonNull(responseEntity.getBody()))));
} else {
return Objects.requireNonNull(responseEntity.getBody()).reduce("", String::concat)
.map(body2 -> ResponseEntity.ok()
.headers(headers)
.body(body2));
}
})
.onErrorResume(WebClientResponseException.class, e -> Mono.just(ResponseEntity.status(e.getStatusCode())
.headers(e.getHeaders())
.body(e.getResponseBodyAsString())))
.onErrorResume(e -> {
HttpHeaders errorHeaders = new HttpHeaders();
errorHeaders.set("error-status", "500");
errorHeaders.set("error-message", e.getMessage());
return Mono.just(ResponseEntity.status(500)
.headers(errorHeaders)
.body(Map.of(
"status", "500",
"error", "Internal Server Error",
"message", e.getMessage()
)));
});
}
2、form-data请求
private DataBuffer createFilePartDataBuffer(DataBuffer dataBuffer, String key, String filename, String boundary, String contentType) {
String header = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + key + "\"; filename=\"" + filename + "\"\r\nContent-Type: " + contentType + "\r\n\r\n";
DataBuffer headerBuffer = new DefaultDataBufferFactory().wrap(header.getBytes(StandardCharsets.UTF_8));
DataBuffer footerBuffer = new DefaultDataBufferFactory().wrap("\r\n".getBytes(StandardCharsets.UTF_8));
return new DefaultDataBufferFactory().join(List.of(headerBuffer, dataBuffer, footerBuffer));
}
private Flux<DataBuffer> endBoundaryFlux(String boundary) {
String endBoundary = "--" + boundary + "--\r\n";
DataBuffer endBoundaryBuffer = new DefaultDataBufferFactory().wrap(endBoundary.getBytes(StandardCharsets.UTF_8));
return Flux.just(endBoundaryBuffer);
}
/**
* @param files 所有的文件都在files中,注意:即使名称相同,文件也可以是多个,而不是覆盖
* @param fileParams 文件对应的files的key值参数,后面需要解析并对应起来
* @param params 这个是除了文件之外的key-value参数
* @param requestParams 这个是包含请求的必须参数,如:url method contentType headers
* @return
*/
@PostMapping("/crud/httpRequest/proxy/formData")
public Mono<ResponseEntity<?>> proxyRequest2(@RequestPart("files") Flux<FilePart> files,
@RequestPart("fileParams") String fileParams,
@RequestPart("params") String params,
@RequestPart("requestParams") String requestParams) {
log.info("files={}", files);
log.info("fileParams={}", fileParams);
log.info("params={}", params);
log.info("requestParams={}", requestParams);
String boundary = "Boundary-" + System.currentTimeMillis();
try {
if (!StringUtils.hasLength(requestParams)) {
return Mono.just(ResponseEntity.ok(new ResultInfo<>(ResultStatus.DATA_EMPTY)));
}
// 解析requestParams
JSONObject requestParamsMap = JSONObject.parseObject(requestParams);
String url = (String) requestParamsMap.get("url");
String method = (String) requestParamsMap.get("method");
String contentType = (String) requestParamsMap.get("contentType");
String headers = (String) requestParamsMap.get("headers");
if (!StringUtils.hasLength(method) || !StringUtils.hasLength(url)) {
return Mono.just(ResponseEntity.ok(new ResultInfo<>(ResultStatus.DATA_EMPTY)));
}
if (!method.equals("put") && !method.equals("post")) {
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Method不正确!提示:Form-Data请求Method只能是POST或者PUT"));
}
// 获取文件个数
Mono<Long> fileCountMono = files.count();
// 判断文件数量是否大于0
return fileCountMono.flatMap(fileCount -> {
Flux<DataBuffer> paramsPublisherFlux = getParamsContentFlux(params, boundary);
// 有文件的情况
if (fileCount > 0 && StringUtils.hasLength(fileParams)) {
List<String> fileParamsList = JSON.parseObject(fileParams, new TypeReference<>() {
});
if (fileParamsList.size() != fileCount) {
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("文件数量和文件Key数量不一致!"));
}
Flux<DataBuffer> fileBodyPublisherFlux = files.index()
.flatMap(indexedFilePart -> {
long index = indexedFilePart.getT1();
FilePart filePart = indexedFilePart.getT2();
return filePart.content()
.map(dataBuffer -> {
String fileKey = fileParamsList.get((int) index);
return createFilePartDataBuffer(dataBuffer, fileKey, filePart.filename(), boundary, filePart.headers().getContentType().toString());
});
});
Flux<DataBuffer> combinedFlux;
if (paramsPublisherFlux != null) {
combinedFlux = Flux.concat(fileBodyPublisherFlux, paramsPublisherFlux, endBoundaryFlux(boundary));
} else {
combinedFlux = Flux.concat(fileBodyPublisherFlux, endBoundaryFlux(boundary));
}
return startRequest(boundary, url, method, headers, combinedFlux);
}
// 没有文件的情况
else {
if (paramsPublisherFlux != null) {
Flux<DataBuffer> combinedFlux = Flux.concat(paramsPublisherFlux, endBoundaryFlux(boundary));
return startRequest(boundary, url, method, headers, combinedFlux);
} else {
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("请求参数和文件参数不能同时为空!"));
}
}
});
} catch (Exception ex) {
return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("请求失败!错误信息: " + ex.getMessage()));
}
}
/**
* 解析参数后构造成form-data形式返回
*
* @param params
* @param boundary
* @return
*/
private Flux<DataBuffer> getParamsContentFlux(String params, String boundary) {
List<Map<String, String>> paramList;
if (StringUtils.hasLength(params)) {
paramList = JSON.parseObject(params, new TypeReference<>() {
});
} else {
paramList = null;
}
if (paramList != null && !paramList.isEmpty()) {
return Flux.fromIterable(paramList)
.map(param -> {
String partContent = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + param.get("key") + "\"\r\n\r\n" + param.get("value") + "\r\n";
return new DefaultDataBufferFactory().wrap(partContent.getBytes(StandardCharsets.UTF_8));
});
} else {
return Flux.empty();
}
}
/**
* 开始请求
* @param boundary
* @param url
* @param method
* @param headers
* @param bodyPublisher
* @return
*/
private Mono<ResponseEntity<?>> startRequest(String boundary, String url, String method, String headers,Flux<DataBuffer> bodyPublisher) {
WebClient webClient = WebClient.create();
WebClient.RequestBodySpec requestSpec = webClient.method(HttpMethod.valueOf(method.toUpperCase()))
.uri(url)
.header("Content-Type", "multipart/form-data; boundary=" + boundary);
// 添加自定义头
if (StringUtils.hasLength(headers)) {
ObjectMapper objectMapper = new ObjectMapper();
try {
Map<String, String> headersMap = objectMapper.readValue(headers, Map.class);
if (headersMap != null && !headersMap.isEmpty()) {
log.info("headersMap={}", JSONArray.toJSONString(headersMap));
headersMap.forEach(requestSpec::header);
}
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
requestSpec.body(BodyInserters.fromDataBuffers(bodyPublisher));
return handleWebClientResponse(requestSpec);
}
大家应该能看懂上面的实现吧,我只挑几个重点的来讲讲。
1、form-data请求时createFilePartDataBuffer方法中是如何进行参数构造的,注意里面的key
和filename
的设置
String header = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + key + "\"; filename=\"" + filename + "\"\r\nContent-Type: " + contentType + "\r\n\r\n";
2、form-data请求时有文件和无文件时,如何构造参数?如果不是问Ai我也不知道!
// 获取文件个数
Mono<Long> fileCountMono = files.count();
// 判断文件数量是否大于0
return fileCountMono.flatMap(fileCount -> {
Flux<DataBuffer> paramsPublisherFlux = getParamsContentFlux(params, boundary);
// 有文件的情况
if (fileCount > 0 && StringUtils.hasLength(fileParams)) {
Flux<DataBuffer> fileBodyPublisherFlux = files.index()
.flatMap(indexedFilePart -> {
long index = indexedFilePart.getT1();
FilePart filePart = indexedFilePart.getT2();
return filePart.content()
.map(dataBuffer -> {
String fileKey = fileParamsList.get((int) index);
return createFilePartDataBuffer(dataBuffer, fileKey, filePart.filename(), boundary, filePart.headers().getContentType().toString());
});
});
Flux<DataBuffer> combinedFlux;
if (paramsPublisherFlux != null) {
combinedFlux = Flux.concat(fileBodyPublisherFlux, paramsPublisherFlux, endBoundaryFlux(boundary));
} else {
combinedFlux = Flux.concat(fileBodyPublisherFlux, endBoundaryFlux(boundary));
}
return startRequest(boundary, url, method, headers, combinedFlux);
}
// 没有文件的情况
else {
if (paramsPublisherFlux != null) {
Flux<DataBuffer> combinedFlux = Flux.concat(paramsPublisherFlux, endBoundaryFlux(boundary));
return startRequest(boundary, url, method, headers, combinedFlux);
}
}
}
3、form-data请求时webclient设置body使用的是body()方法,而非form-data请求使用的是bodyValue()方法
4、如果想即可以逐条返回又可以一次性全部返回必须使用webclient的toEntityFlux方法,中间我使用了好多方式都实现不了,只有这个方法才能实现,如下所示:
1、这段代码可以逐条显示,但是也只能逐条返回
Flux<String> responseBodyFlux = requestSpec
.retrieve()
.onStatus(HttpStatusCode::isError, clientResponse -> clientResponse.createException().flatMap(Mono::error))
.bodyToFlux(String.class);
return Mono.just(ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE)
.body(responseBodyFlux));
2、这段代码可以一次性全部返回,但是如果是content-type:text/event-stream时,则不会逐条返回
return requestSpec.exchangeToMono(response -> {
HttpHeaders responseHeaders = response.headers().asHttpHeaders();
MediaType responseType = responseHeaders.getContentType();
if (responseType != null && MediaType.TEXT_EVENT_STREAM.isCompatibleWith(responseType)) {
Flux<String> responseBodyFlux = response.bodyToFlux(String.class);
return Mono.just(ResponseEntity.ok()
.headers(responseHeaders)
.body(responseBodyFlux));
} else {
Mono<String> responseBodyMono = response.bodyToMono(String.class);
return responseBodyMono.map(bodyContent -> ResponseEntity.ok()
.headers(responseHeaders)
.body(Flux.just(bodyContent)));
}
});
3、这段代码可以一次性全部返回,但是如果是content-type:text/event-stream,则会报错:
java.lang.IllegalArgumentException: Only a single ResponseEntity supported
at org.springframework.util.Assert.isTrue(Assert.java:122)
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
return requestSpec.exchangeToFlux(response -> {
HttpHeaders responseHeaders = response.headers().asHttpHeaders();
MediaType responseType = responseHeaders.getContentType();
if (MediaType.TEXT_EVENT_STREAM.equals(responseType)) {
// 处理 text/event-stream 类型的响应,逐条返回
Flux<String> responseBodyFlux = response.bodyToFlux(String.class);
return Flux.just(ResponseEntity.ok()
.headers(responseHeaders)
.body(responseBodyFlux));
} else {
// 处理其他类型的响应,一次性返回
return response.bodyToMono(String.class)
.flatMap(body -> Mono.just(ResponseEntity.ok()
.headers(responseHeaders)
.body(body)));
}
});
总结
1、我之所以能够实现全靠ChatGPT还有文心一言、通义千问,这里我表扬下文心一言toEntityFlux
就是它想出来的,要不然我都准备放弃了!
引用
Spring boot3 中使用Spring WebFlux 响应式请求ChatGPT 接收text/event-stream流的数据(原来流式这样玩)
别再使用 RestTemplate了,来了解一下官方推荐的 WebClient !
【代码小抄】如何使用WebClient开发响应式接口
Spring WebClient 中的 exchange() 和 retrieve() 方法
【WebClient、spring】WebClient—Spring5引入的一个非阻塞、反应式类型的安全HTTP客户端
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。