2

效果

image.png

前言

最近想实现一个类似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
image.png

是form-data
image.png

这里你可能会注意到为什么除了上传的文件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方法中是如何进行参数构造的,注意里面的keyfilename的设置

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客户端


Awbeci
3.1k 声望213 粉丝

Awbeci