写了个插件让 Swagger 自动把后端下划线字段转成前端驼峰,顺便标清楚映射关系

后端返回 user_name,前端要的是 userName——这事本身不值得写一篇博客。但当你对接的后端系统不止一个、命名风格五花八门,甚至同一个接口里蛇形驼峰混着来,前端联调时对着 Swagger 文档来回对照字段名,就真的很消耗耐心了。

我写了个 Swagger 插件,能自动把文档里的下划线字段名转成前端驼峰,同时在描述里标注清楚映射关系。下面说说为什么现有方案不够用,以及这个插件的实现细节。

现有方案的痛点

市面上处理这个问题的思路大致两种。第一种是后端配合,在 Swagger 注解里手动写映射关系,比如 @ApiModelProperty(value = "用户名") 里额外注明前端字段名。第二种是前端自己维护一份字段映射表,或者在请求拦截器里统一转换。

第一种方案的问题很明显:后端同事不一定愿意配合,尤其是跨部门协作的时候。而且即便配合了,手动维护几十个 DTO 的映射关系本身也是体力活,漏标、标错是常有的事。

第二种方案看似一劳永逸,但 Swagger 文档里展示的仍然是后端原始字段名。前端开发对着文档写代码时,脑子里得时刻绷着一根弦做转换,遇到嵌套对象或者数组嵌套的情况,稍不留神就写错了。联调时发现字段对不上,回头翻文档才发现是命名风格不一致——这种体验,做过的都懂。

插件做了什么

核心逻辑很简单:接管 Swagger 文档渲染前的数据,遍历所有接口的请求参数和响应字段,检测到含下划线的字段名时自动生成驼峰别名,并把映射关系追加到字段描述里。

具体来说插件干了三件事:

  1. 自动转换snake_casecamelCase,递归处理嵌套对象和数组内的对象元素
  2. 标注映射:在原字段的 description 后面追加 【前端字段: xxx】,一眼能看出对应关系
  3. 保留原始字段名:不做替换只做标注,后端同学看文档不受影响

效果就是 Swagger UI 里原本显示 user_name 的地方,描述变成了类似这样:

用户名 【前端字段: userName】

前端不用再猜、不用再翻代码、不用再问后端"这个字段前端叫什么",直接看文档就行。

以 SpringDoc 为例的实现

实际项目里我用的是 SpringDoc 1.7.0(Spring Boot 3.x 项目),插件通过实现 OperationCustomizerOpenApiCustomiser 接口来介入文档生成过程。如果你用的是 SpringFox,思路类似,只是扩展点不同。

先看核心的转换方法:

public class SnakeToCamelConverter {

    private static final Pattern SNAKE_CASE_PATTERN = Pattern.compile("_([a-z])");

    public static String toCamelCase(String snakeCase) {
        if (snakeCase == null || !snakeCase.contains("_")) {
            return snakeCase;
        }
        Matcher matcher = SNAKE_CASE_PATTERN.matcher(snakeCase);
        StringBuilder sb = new StringBuilder();
        while (matcher.find()) {
            matcher.appendReplacement(sb, matcher.group(1).toUpperCase());
        }
        matcher.appendTail(sb);
        return sb.toString();
    }
}

然后是处理 Schema(DTO 对象)的部分。SpringDoc 里所有模型定义都存放在 Componentsschemas 中,我们需要遍历每个 Schema 的 properties

@Component
public class FieldMappingCustomizer implements OpenApiCustomiser {

    @Override
    public void customise(OpenAPI openApi) {
        Map<String, Schema> schemas = openApi.getComponents().getSchemas();
        if (schemas == null) return;
        
        for (Schema<?> schema : schemas.values()) {
            processSchema(schema);
        }
    }

    private void processSchema(Schema<?> schema) {
        Map<String, Schema> properties = schema.getProperties();
        if (properties == null) return;

        for (Map.Entry<String, Schema> entry : properties.entrySet()) {
            String originalName = entry.getKey();
            Schema<?> propertySchema = entry.getValue();

            // 处理嵌套对象
            if (propertySchema.get$ref() != null) {
                String refSchemaName = propertySchema.get$ref()
                    .replace("#/components/schemas/", "");
                Schema<?> refSchema = schema.getProperties() != null ?
                    // 这里需要从全局 schemas 中获取引用
                    getSchemaFromComponents(refSchemaName) : null;
                if (refSchema != null) {
                    processSchema(refSchema);
                }
            }

            // 处理数组内的对象
            if ("array".equals(propertySchema.getType()) 
                && propertySchema.getItems() != null 
                && propertySchema.getItems().get$ref() != null) {
                // 递归处理数组项
            }

            // 核心:检测下划线并追加映射信息
            if (originalName.contains("_")) {
                String camelName = SnakeToCamelConverter.toCamelCase(originalName);
                String originalDescription = propertySchema.getDescription();
                String mappingInfo = "【前端字段: " + camelName + "】";
                
                if (originalDescription != null && !originalDescription.isEmpty()) {
                    propertySchema.setDescription(originalDescription + "  " + mappingInfo);
                } else {
                    propertySchema.setDescription(mappingInfo);
                }
            }
        }
    }
}

参数部分(请求参数和响应)的处理需要另一个扩展点。通过实现 OperationCustomizer 来拦截每个接口操作:

@Component
public class ParameterMappingCustomizer implements OperationCustomizer {

    @Override
    public Operation customize(Operation operation, HandlerMethod handlerMethod) {
        // 处理请求参数
        if (operation.getParameters() != null) {
            for (Parameter parameter : operation.getParameters()) {
                String name = parameter.getName();
                if (name != null && name.contains("_")) {
                    String camelName = SnakeToCamelConverter.toCamelCase(name);
                    String originalDesc = parameter.getDescription();
                    String mappingInfo = "【前端字段: " + camelName + "】";
                    parameter.setDescription(
                        originalDesc != null ? originalDesc + "  " + mappingInfo : mappingInfo
                    );
                }
            }
        }

        // 处理请求体
        if (operation.getRequestBody() != null) {
            processContent(operation.getRequestBody().getContent());
        }

        // 处理响应体
        if (operation.getResponses() != null) {
            for (ApiResponse response : operation.getResponses().values()) {
                if (response.getContent() != null) {
                    processContent(response.getContent());
                }
            }
        }

        return operation;
    }

    private void processContent(Content content) {
        // 遍历 MediaType,找到 schema 并递归处理
        // 逻辑与 processSchema 类似,这里省略
    }
}

完整的可运行代码我放在 GitHub 上了(仓库地址见文末),直接引入依赖就能用,SpringDoc 会自动扫描到这两个 Bean。

实际使用效果

接入插件后,团队里前端同事的反馈很直接:"早该有了"。

以前对接一个新模块的接口,光是对照字段名就要花十几分钟,遇到那种返回 30 多个字段的列表接口,一个个在脑子里做转换,效率极低。现在打开 Swagger 文档,每个字段后面都清清楚楚标着前端该用什么名字,复制粘贴就完事了。

后端同学也没什么感知,他们看到的仍然是 user_name,描述里多出来的 【前端字段: userName】 反而帮他们理解了前端的命名习惯。有次后端同事重构接口把 is_deleted 改成了 deleted_flag,前端在文档里看到 【前端字段: deletedFlag】 立刻意识到字段名变了,提前改了代码,避免了联调时的踩坑。

一些边界情况的处理

实际落地过程中遇到了几个值得注意的问题:

非标准蛇形命名:比如 API_KEY 这种全大写加下划线的情况。插件默认转成 apiKey,但有时候业务上希望保持全大写。解决方案是在配置里加了个排除列表,支持通过正则匹配跳过特定字段。

已有 @JsonProperty 注解的字段:如果后端已经在字段上标注了 @JsonProperty("customName"),插件应该尊重这个显式映射。实现上需要读取 Jackson 注解,如果发现字段已有自定义映射,就使用注解里的名字而不是自动转换结果。

响应里嵌套的泛型对象:比如 Page<UserVO> 这种,Page 本身是通用封装,records 字段里的元素才是需要处理的业务对象。SpringDoc 对这种结构的 Schema 引用层级较深,需要递归到最内层处理,同时避免死循环。

常见问题

这个插件会影响后端同学看文档吗?

不会。原始字段名保持不变,只是在描述文字里追加了映射信息。后端该用 user_name 还是用 user_name,完全不受影响。

如果字段名本身就是驼峰但包含了下划线怎么办?比如 user_Name

这种情况很少见,属于命名不规范的问题。插件默认仍然会处理,把 user_Name 转成 userName。如果你有这样的字段且不希望被处理,可以通过配置的排除列表跳过。

支持 Swagger 2 还是 Swagger 3 / OpenAPI 3?

基于 SpringDoc 的实现支持 OpenAPI 3 规范(Swagger 3)。SpringFox(Swagger 2)的实现思路一样,只是扩展的接口不同,核心转换逻辑可以复用。

插件能处理请求头里的字段吗?

当前版本默认只处理请求参数、请求体和响应体。Header 参数通常由网关或框架统一处理,如果你的场景需要,可以在 ParameterMappingCustomizer 里加上对 parameter.getIn()"header" 的判断。


插件代码和 Maven 坐标:github.com/your-repo/swagger-field-mapping(示例链接,替换为你的实际仓库地址)。直接引入依赖,SpringDoc 1.7+ 环境下零配置启动。