Spring Boot 中使用 GraphQL 实现文件上传

1、简介

GraphQL 改变了开发人员与 API 交互的方式,为传统的 REST 方法提供了一个精简、强大的替代方案。

然而,在 Java 中使用 GraphQL 处理文件上传,特别是在 Spring Boot 应用中,由于 GraphQL 对二进制数据的处理方式,需要进行一些设置。本文将带你了解如何在 Spring Boot 应用中使用 GraphQL 上传文件。

2、GraphQL文件上传 与 HTTP 文件上传

在使用 Spring Boot 开发 GraphQL API 时,遵循最佳实践通常涉及利用标准的 HTTP 请求来处理文件上传。

通过专用的 HTTP 端点管理文件上传,然后通过 URL 或 ID 等标识符将这些上传链接到 GraphQL Mutation,开发人员可以有效地将直接嵌入 GraphQL Query 的文件上传的复杂性和处理开销降至最低。这种方法不仅简化了上传过程,还有助于避免与文件大小限制和序列化需求相关的潜在问题,从而有助于实现更加精简和可扩展的应用结构。

不过,在某些情况下,有必要将文件上传直接整合到 GraphQL 查询中。在这种情况下,需要进行一些特别的定制,在用户体验和应用程序性能之间取得平衡。因此,我们需要定义一种专门的量 Scalar Type 来处理上传。此外,这种方法还需要部署特定的机制来验证输入,并将上传的文件正确映射到 GraphQL 操作中的变量。此外,文件上传需要使用 Content Type 为 multipart/form-data 的请求体,因此需要实现一个自定义的 HttpHandler

3、 在 GraphQL 中实现文件上传

首先,需要使用 Spring Boot 官方的 GraphQL Starter。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
    <version>3.3.0</version>
</dependency>

3.1、自定义 Upload Scalar 类型

首先,在 GraphQL Schema 定义一个自定义 Scalar 类型 Upload。引入 Upload scalar 类型扩展了 GraphQL 处理二进制文件数据的能力,使 API 能够接受文件上传。自定义 Scalar 是客户端文件上传请求与服务器处理逻辑之间的桥梁,可确保以类型安全和结构化的方式处理文件上传。

src/main/resources/file-upload/graphql/upload.graphqls 文件中定义它:

scalar Upload

type Mutation {
    uploadFile(file: Upload!, description: String!): String
}

type Query {
    getFile: String
}

在上面的定义中,还使用了 description 参数来说明与文件一起传递附加数据。

3.2、实现 UploadCoercing

在 GraphQL 中,Coercing 指的是将一个值从一种类型转换为另一种类型的过程。在处理自定义 scalar 类型(如我们的 Upload 类型)时,这一点尤为重要。在这种情况下,我们需要定义如何解析(从输入转换)和序列化(转换为输出)与该类型相关的值。

实现 UploadCoercing,对于以与 GraphQL API 中的文件上传操作要求一致的方式管理这些转换至关重要。

public class UploadCoercing implements Coercing<MultipartFile, Void> {
    @Override
    public Void serialize(Object dataFetcherResult) {
        throw new CoercingSerializeException("Upload is an input-only type and cannot be serialized");
    }

    @Override
    public MultipartFile parseValue(Object input) {
        if (input instanceof MultipartFile) {
            return (MultipartFile) input;
        }
        throw new CoercingParseValueException("Expected type MultipartFile but was " + input.getClass().getName());
    }

    @Override
    public MultipartFile parseLiteral(Object input) {
        throw new CoercingParseLiteralException("Upload is an input-only type and cannot be parsed from literals");
    }
}

如上,这涉及到将输入值(来自 query 或 mutation)转换为我们的应用可以理解和使用的 Java 类型。对于 Upload scalar 来说,这意味着从客户端获取文件输入,并确保在服务器端代码中将其正确表示为 MultipartFile

3.3、MultipartGraphQlHttpHandler:处理 Multipart 请求

GraphQL 的标准规范旨在处理 JSON 格式的请求。这种格式能很好地处理典型的 CRUD 操作,但在处理文件上传时就显得不足了,因为文件上传本质上是二进制数据,不容易用 JSON 表示。通过 HTTP 提交表单和上传文件的标准 Content Type 是 multipart/form-data,但处理这些请求需要对请求体进行不同于 GraphQL 标准请求的解析。

默认情况下,GraphQL 服务器无法直接理解或处理 multipart 请求,这通常会导致此类请求出现 404 Not Found 响应。因此,我们需要实现一个 Handler 来弥补这一缺陷,确保我们的应用能正确处理 multipart/form-data Content Type。

实现:

public ServerResponse handleMultipartRequest(ServerRequest serverRequest) throws ServletException {
    HttpServletRequest httpServletRequest = serverRequest.servletRequest();

    Map<String, Object> inputQuery = Optional.ofNullable(this.<Map<String, Object>>deserializePart(httpServletRequest, "operations", MAP_PARAMETERIZED_TYPE_REF.getType())).orElse(new HashMap<>());

    final Map<String, Object> queryVariables = getFromMapOrEmpty(inputQuery, "variables");
    final Map<String, Object> extensions = getFromMapOrEmpty(inputQuery, "extensions");

    Map<String, MultipartFile> fileParams = readMultipartFiles(httpServletRequest);

    Map<String, List<String>> fileMappings = Optional.ofNullable(this.<Map<String, List<String>>>deserializePart(httpServletRequest, "map", LIST_PARAMETERIZED_TYPE_REF.getType())).orElse(new HashMap<>());

    fileMappings.forEach((String fileKey, List<String> objectPaths) -> {
        MultipartFile file = fileParams.get(fileKey);
        if (file != null) {
            objectPaths.forEach((String objectPath) -> {
                MultipartVariableMapper.mapVariable(objectPath, queryVariables, file);
            });
        }
    });

    String query = (String) inputQuery.get("query");
    String opName = (String) inputQuery.get("operationName");

    Map<String, Object> body = new HashMap<>();
    body.put("query", query);
    body.put("operationName", StringUtils.hasText(opName) ? opName : "");
    body.put("variables", queryVariables);
    body.put("extensions", extensions);

    WebGraphQlRequest graphQlRequest = new WebGraphQlRequest(serverRequest.uri(), serverRequest.headers().asHttpHeaders(), body, this.idGenerator.generateId().toString(), LocaleContextHolder.getLocale());

    if (logger.isDebugEnabled()) {
        logger.debug("Executing: " + graphQlRequest);
    }

    Mono<ServerResponse> responseMono = this.graphQlHandler.handleRequest(graphQlRequest).map(response -> {
        if (logger.isDebugEnabled()) {
            logger.debug("Execution complete");
        }
        ServerResponse.BodyBuilder builder = ServerResponse.ok();
        builder.headers(headers -> headers.putAll(response.getResponseHeaders()));
        builder.contentType(selectResponseMediaType(serverRequest));
        return builder.body(response.toMap());
    });

    return ServerResponse.async(responseMono);
}

MultipartGraphQlHttpHandler 类中的 handleMultipartRequest 方法用于处理 multipart/form-data 请求。首先,我们从服务器请求对象中提取 HTTP 请求,这样就可以访问请求中包含的 multipart 文件和其他表单数据。然后,我们尝试反序列化请求的 “operations” Part(包含 GraphQL query 或 mutation)和 “map” Part(指定如何将文件映射到 GraphQL 操作中的变量)。

对这些 Part 进行反序列化后,该方法继续从请求中读取实际上传的文件,并使用 ““map”” 中定义的映射将每个上传的文件与 GraphQL 操作中的变量正确地关联起来。

3.4、实现文件上传 DataFetcher

由于我们有用于上传文件的 uploadFile mutation,因此我们需要实现特定的逻辑来接受来自客户端的文件和附加元数据并保存文件。在 GraphQL 中,schema 中的每个字段都与 DataFetcher 相关联,DataFetcher 是一个负责检索与该字段相关联数据的组件。

虽然有些字段可能需要专门的 DataFetcher 实现,才能从数据库或其他持久存储系统中获取数据,但许多字段只需从内存对象中提取数据即可。这种提取通常依赖于字段名称,并利用标准 Java 对象模式来访问所需的数据。

实现 DataFetcher 接口:

@Component
public class FileUploadDataFetcher implements DataFetcher<String> {
    private final FileStorageService fileStorageService;

    public FileUploadDataFetcher(FileStorageService fileStorageService) {
        this.fileStorageService = fileStorageService;
    }

    @Override
    public String get(DataFetchingEnvironment environment) {
        MultipartFile file = environment.getArgument("file");
        String description = environment.getArgument("description");
        String storedFilePath = fileStorageService.store(file, description);
        return String.format("File stored at: %s, Description: %s", storedFilePath, description);
    }
}

当 GraphQL 框架调用该 DataFetcher 的 get 方法时,它会从 mutation 参数中获取文件和可选的描述。然后,它会调用 FileStorageService 来存储文件,同时传递文件及其描述。

4、Spring Boot 配置 GraphQL 文件上传

使用 Spring Boot 将文件上传集成到 GraphQL API 是一个多方面的过程,需要配置多个关键组件。

根据实现来定义配置:

@Configuration
public class MultipartGraphQlWebMvcAutoconfiguration {

    private final FileUploadDataFetcher fileUploadDataFetcher;

    public MultipartGraphQlWebMvcAutoconfiguration(FileUploadDataFetcher fileUploadDataFetcher) {
        this.fileUploadDataFetcher = fileUploadDataFetcher;
    }

    @Bean
    public RuntimeWiringConfigurer runtimeWiringConfigurer() {
        return (builder) -> builder
          .type(newTypeWiring("Mutation").dataFetcher("uploadFile", fileUploadDataFetcher))
          .scalar(GraphQLScalarType.newScalar()
            .name("Upload")
            .coercing(new UploadCoercing())
            .build());
    }

    @Bean
    @Order(1)
    public RouterFunction<ServerResponse> graphQlMultipartRouterFunction(
      GraphQlProperties properties,
      WebGraphQlHandler webGraphQlHandler,
      ObjectMapper objectMapper
    ) {
        String path = properties.getPath();
        RouterFunctions.Builder builder = RouterFunctions.route();
        MultipartGraphQlHttpHandler graphqlMultipartHandler = new MultipartGraphQlHttpHandler(webGraphQlHandler, new MappingJackson2HttpMessageConverter(objectMapper));
        builder = builder.POST(path, RequestPredicates.contentType(MULTIPART_FORM_DATA)
          .and(RequestPredicates.accept(SUPPORTED_MEDIA_TYPES.toArray(new MediaType[]{}))), graphqlMultipartHandler::handleMultipartRequest);
        return builder.build();
    }
}

RuntimeWiringConfigurer 在此设置中发挥了关键作用,它使我们能够将 GraphQL schema 的操作(如 mutation 和 query)与相应的 DataFetcher 联系起来。这种关联对于 uploadFile mutation 至关重要,我们在此使用 FileUploadDataFetcher 来处理文件上传过程。

此外,RuntimeWiringConfigurer 用于在 GraphQL schema 中定义和集成自定义 Upload scalar 类型。该 scalar 类型与 UploadCoercing 相关联,使 GraphQL API 能够理解并正确处理文件数据,确保文件在上传过程中被正确序列化和反序列化。

为了处理传入请求,特别是那些携带 multipart/form-data Content Type(用于文件上传)的请求,我们使用 RouterFunction Bean。这个 Function 能够拦截这些特定类型的请求,使我们能够通过 MultipartGraphQlHttpHandler 来处理它们。这个 Handler 是解析 multipart 请求、提取文件并将它们映射到 GraphQL 操作中的适当变量的关键,从而促进文件上传 mutation 的执行。我们还通过使用 @Order(1) 注解来指定正确的顺序。

5、使用 Postman 测试文件上传

通过 Postman 测试 GraphQL API 中的文件上传功能需要一种非标准的方法,因为内置的 GraphQL Payload 并不直接支持 multipart/form-data 请求,而这对于上传文件至关重要。相反,我们必须手动构建一个 multipart 请求,模拟客户端通过 GraphQL mutation 上传文件的方式。在 “Body” 选项卡中,选择应设置为 “form-data”。需要三个键值对:operations、map 和带有与 “map” 值相对应的键名的文件变量。

对于 operations 键,其值应是一个封装了 GraphQL query 和变量的 JSON 对象,file 部分用 null 表示,作为占位符。该部分的类型仍为 Text,即文本。

{"query": "mutation UploadFile($file: Upload!, $description: String!) { uploadFile(file: $file, description: $description) }","variables": {"file": null,"description": "Sample file description"}}

接下来,map key 需要另一个 JSON 对象的值。这一次,要将文件变量映射到包含文件的表单字段。如果我们将文件附加到 key 0,那么 map 就会明确地将此 key 与 GraphQL 变量中的文件(file)变量关联起来,确保服务器正确理解表单数据的哪个部分包含文件。该值也具有文本类型。

{"0": ["variables.file"]}

最后,为文件本身添加一个 key 值,该 key 值与 map 射对象中的引用相匹配。在本例中,我们使用 0 作为该值的 key。与前面的文本值不同,这部分的类型是 File,文件。

执行请求后,我们应该会得到一个 JSON 响应:

{
    "data": {
        "uploadFile": "File stored at: File uploaded successfully: C:\\Development\\TutorialsBaeldung\\tutorials\\uploads\\2023-06-21_14-22.bmp with description: Sample file description, Description: Sample file description"
    }
}

6、总结

本文介绍了如何使用 Spring Boot 为 GraphQL API 添加文件上传功能。

我们首先介绍了一个名为 Upload 的自定义 scalar type,它可以在 GraphQL mutation 中处理文件数据。

然后,我们实现了 MultipartGraphQlHttpHandler 类来管理 multipart/form-data 请求,这是通过 GraphQL mutation 上传文件所必需的。与使用 JSON 的标准 GraphQL 请求不同,文件上传需要 multipart 请求来处理二进制文件数据。

FileUploadDataFetcher 类处理 uploadFile mutation。它提取并存储上传的文件,并向客户端发送有关文件上传状态的明确响应。

通常,使用普通 HTTP 请求上传文件并通过 GraphQL 查询传递生成的 ID 会更有效。不过,有时也有必要直接使用 GraphQL 进行文件上传。


Ref:https://www.baeldung.com/java-graphql-upload-file