写了个插件让 Swagger 自动把后端下划线字段转成前端驼峰,顺便标清楚映射关系
后端返回 user_name,前端要的是 userName——这事本身不值得写一篇博客。但当你对接的后端系统不止一个、命名风格五花八门,甚至同一个接口里蛇形驼峰混着来,前端联调时对着 Swagger 文档来回对照字段名,就真的很消耗耐心了。
我写了个 Swagger 插件,能自动把文档里的下划线字段名转成前端驼峰,同时在描述里标注清楚映射关系。下面说说为什么现有方案不够用,以及这个插件的实现细节。
现有方案的痛点
市面上处理这个问题的思路大致两种。第一种是后端配合,在 Swagger 注解里手动写映射关系,比如 @ApiModelProperty(value = "用户名") 里额外注明前端字段名。第二种是前端自己维护一份字段映射表,或者在请求拦截器里统一转换。
第一种方案的问题很明显:后端同事不一定愿意配合,尤其是跨部门协作的时候。而且即便配合了,手动维护几十个 DTO 的映射关系本身也是体力活,漏标、标错是常有的事。
第二种方案看似一劳永逸,但 Swagger 文档里展示的仍然是后端原始字段名。前端开发对着文档写代码时,脑子里得时刻绷着一根弦做转换,遇到嵌套对象或者数组嵌套的情况,稍不留神就写错了。联调时发现字段对不上,回头翻文档才发现是命名风格不一致——这种体验,做过的都懂。
插件做了什么
核心逻辑很简单:接管 Swagger 文档渲染前的数据,遍历所有接口的请求参数和响应字段,检测到含下划线的字段名时自动生成驼峰别名,并把映射关系追加到字段描述里。
具体来说插件干了三件事:
- 自动转换:
snake_case→camelCase,递归处理嵌套对象和数组内的对象元素 - 标注映射:在原字段的
description后面追加【前端字段: xxx】,一眼能看出对应关系 - 保留原始字段名:不做替换只做标注,后端同学看文档不受影响
效果就是 Swagger UI 里原本显示 user_name 的地方,描述变成了类似这样:
用户名 【前端字段: userName】
前端不用再猜、不用再翻代码、不用再问后端"这个字段前端叫什么",直接看文档就行。
以 SpringDoc 为例的实现
实际项目里我用的是 SpringDoc 1.7.0(Spring Boot 3.x 项目),插件通过实现 OperationCustomizer 和 OpenApiCustomiser 接口来介入文档生成过程。如果你用的是 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 里所有模型定义都存放在 Components 的 schemas 中,我们需要遍历每个 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+ 环境下零配置启动。