一个接口管三端用 ConverterFactory 统一枚举在 HTTP/JSON/Excel 中的转换前言前阵子接了个需求做一个 Excel 导入功能用户从模板填好数据上传系统自动解析入库。模板里有一列叫合同状态填的是已签署、“待签署”、已过期这种中文词但后端 DB 存的是整数 code1、2、3。类似的情况不止 Excel——HTTP 查询参数里枚举传的是 code 字符串?status1JSON body 里传的是 code 整数status: 1到处是枚举。而且你没发现吗同一个枚举在这三个渠道的面孔完全不一样渠道表现形式例子HTTP 查询参数字符串数字?status1JSON body整数status: 1Excel 单元格中文文本已签署数据库TINYINT1三张面孔、三种输入格式、三种解析方式但要映射到同一个枚举常量。之前我在第六篇聊过如何用 Jackson SPI 解决 JSON body 的枚举反序列化。但那只解决了一个渠道。如果 HTTP 查询参数和 Excel 也各搞一套方案项目里的枚举转换代码就会散落在三个不同地方维护起来还是头疼。今天这篇就聊三端统一的方案——一个ConverterFactory搞定三个渠道的枚举转换。枚举的三张面孔先把问题铺开讲清楚。假设项目里定义了一个合同状态枚举/** * 合同状态枚举 * DB 存 codeJSON 传 codeExcel 导 label查询参数也用 code */publicenumContractStatusimplementsBaseEnum{DRAFT(1,草稿),SIGNING(2,签署中),SIGNED(3,已签署),EXPIRED(4,已过期),TERMINATED(5,已终止);privatefinalintcode;privatefinalStringlabel;ContractStatus(intcode,Stringlabel){this.codecode;this.labellabel;}OverridepublicintgetCode(){returncode;}OverridepublicStringgetLabel(){returnlabel;}}这个枚举会出现在三个不同的交互场景场景一HTTP 查询参数。前端调用合同列表接口传?status1,2筛选草稿和签署中的合同。Spring MVC 需要把字符串1解析成ContractStatus枚举。场景二JSON body。前端创建合同时POST body 里status: 1表示合同状态。Jackson 需要把整数1反序列化成枚举。场景三Excel 导入导出。用户下载模板填好数据上传Excel 里合同状态列写的是中文已签署。程序需要把已签署映射到ContractStatus.SIGNED。三个场景三种输入格式但都需要转成同一个枚举。最粗暴的做法是对每个枚举、每个渠道都写一套转换代码。笨办法有多痛先看看如果不做统一抽象代码会变成什么样子。HTTP 参数Spring MVC 里最原生的做法是给每个枚举写一个Converter/** * 给 ContractStatus 写一个专属转换器 * 如果项目有 50 个枚举就要写 50 个这样的类 */publicclassContractStatusConverterimplementsConverterString,ContractStatus{OverridepublicContractStatusconvert(Stringsource){intcodeInteger.parseInt(source);for(ContractStatusstatus:ContractStatus.values()){if(status.getCode()code){returnstatus;}}thrownewIllegalArgumentException(Unknown code: code);}}JSON body每个枚举加一个JsonCreator第六篇已经吐槽过了不重复。Excel 导入假设用的 EasyExcel一般是在监听器里写一个 switch-case/** * Excel 导入时的枚举映射 * 每个枚举都要写一个这样的方法每新增一个 code 就要改 switch */privateContractStatusparseStatus(StringcellValue){switch(cellValue){case草稿:returnContractStatus.DRAFT;case签署中:returnContractStatus.SIGNING;case已签署:returnContractStatus.SIGNED;case已过期:returnContractStatus.EXPIRED;case已终止:returnContractStatus.TERMINATED;default:thrownewIllegalArgumentException(Unknown status: cellValue);}}三个场景三个不同的实现方式。枚举数量一多代码量直接乘三。而且很难保证一致——万一某个枚举在 HTTP 参数里写漏了一个值、在 Excel 映射里写错了一个中文名定位问题得翻三个地方。需要从根本上统一它。基石BaseEnum 接口不管是 HTTP 参数、JSON 还是 Excel它们的本质都是把一个外部格式转换成枚举常量。要做统一抽象首先所有枚举必须有一个共同的接口约定。/** * 枚举基类接口 * * 所有枚举必须实现此接口后续的 ConverterFactory、Jackson SPI、Excel 转换 * 都基于这个接口统一处理 * * getCode() — DB 和 JSON 传输用的整数值 * getLabel() — VO 展示和 Excel 导出的中文描述 */publicinterfaceBaseEnum{/** * 枚举的整数值 * 对应数据库 TINYINT、JSON 中的 code 字段、查询参数的 code */intgetCode();/** * 枚举的中文描述 * 用于 VO 展示、Excel 导入/导出时的文本映射 */StringgetLabel();}有了这层抽象就可以针对接口编程而不是针对每个具体枚举编程。第一端HTTP 查询参数 → ConverterFactorySpring MVC 提供了一个叫ConverterFactory的接口。乍一看名字有点绕但其实它的职责很单一根据目标类型自动创建对应的 Converter。/** * ConverterFactory 接口定义 * S 是源类型StringR 是目标类型的上限BaseEnum * getConverter(ClassT) 返回一个按目标类型参数化的 Converter */publicinterfaceConverterFactoryS,R{TextendsRConverterS,TgetConverter(ClassTtargetType);}对比Converter和ConverterFactoryConverterString, ContractStatus——只能把 String 转成ContractStatusConverterFactoryString, BaseEnum——可以把 String 转成任意BaseEnum子类工厂的优势在于注册一次所有实现 BaseEnum 的枚举自动生效。来看实现importorg.springframework.core.convert.converter.Converter;importorg.springframework.core.convert.converter.ConverterFactory;/** * BaseEnum ConverterFactory * * 注册到 Spring MVC 后所有 BaseEnum 子类的 HTTP 参数绑定自动生效。 * 前端传 ?status1Controller 参数写 ContractStatus status自动解析。 * * 不限定具体枚举类型通过泛型参数 T 由 Spring MVC 在运行时确定。 */publicclassBaseEnumConverterFactoryimplementsConverterFactoryString,BaseEnum{OverridepublicTextendsBaseEnumConverterString,TgetConverter(ClassTtargetType){// 返回一个 Lambda Converter每次调用时按目标类型遍历枚举常量returnsource-{// 查询参数本质是字符串先转成整数 codeintcodeInteger.parseInt(source);// 遍历目标枚举的所有常量按 code 匹配for(BaseEnumconstant:targetType.getEnumConstants()){if(constant.getCode()code){// 找到匹配的常量返回编译器会做类型推断SuppressWarnings(unchecked)Tresult(T)constant;returnresult;}}thrownewIllegalArgumentException(Unknown code code for enum targetType.getSimpleName());};}}核心逻辑很简单targetType.getEnumConstants()拿到目标枚举的所有常量然后按 code 匹配。但也确实有个可以优化的地方每次调用都遍历枚举常量O(n) 的查表效率在请求量大的时候会有影响。改成预构建的 HashMap 会好很多OverridepublicTextendsBaseEnumConverterString,TgetConverter(ClassTtargetType){returnsource-{intcodeInteger.parseInt(source);// 从预构建的 code → enum 映射中查找O(1)TresultBaseEnumCache.getByCode(targetType,code);if(resultnull){thrownewIllegalArgumentException(Unknown code code for enum targetType.getSimpleName());}returnresult;};}这里的BaseEnumCache就是一个基于ConcurrentHashMap的线程安全缓存importjava.util.concurrent.ConcurrentHashMap;/** * 枚举常量缓存 * 每个枚举类的 code→常量 映射只初始化一次线程安全 */publicclassBaseEnumCache{/** 缓存结构Class → (code → enum常量) */privatestaticfinalConcurrentHashMapClass?,ConcurrentHashMapInteger,BaseEnumCACHEnewConcurrentHashMap();/** * 根据 code 获取枚举常量 * 首次访问时自动构建映射并缓存 */SuppressWarnings(unchecked)publicstaticTextendsBaseEnumTgetByCode(ClassTenumClass,intcode){// computeIfAbsent只有第一次会执行构建函数后续直接返回缓存ConcurrentHashMapInteger,BaseEnummappingCACHE.computeIfAbsent(enumClass,clazz-{ConcurrentHashMapInteger,BaseEnummapnewConcurrentHashMap();for(BaseEnumconstant:((Class?extendsBaseEnum)clazz).getEnumConstants()){map.put(constant.getCode(),constant);}returnmap;});return(T)mapping.get(code);}}computeIfAbsent是 ConcurrentHashMap 最优雅的方法——“有则返回无则计算后存入并返回”。多个线程同时访问时只有一个线程执行构建函数其他线程等待它完成。这个工厂怎么注册到 Spring MVC 呢importorg.springframework.web.servlet.config.annotation.WebMvcConfigurer;importorg.springframework.format.FormatterRegistry;/** * Spring MVC 配置注册 BaseEnum ConverterFactory * 一注册所有实现 BaseEnum 的枚举在 HTTP 参数绑定中自动生效 */ConfigurationpublicclassWebConfigimplementsWebMvcConfigurer{OverridepublicvoidaddFormatters(FormatterRegistryregistry){// 这句代码之后Controller 方法参数里的 BaseEnum 枚举// 都能自动从查询参数中的 code 字符串解析registry.addConverterFactory(newBaseEnumConverterFactory());}}完成之后Controller 就可以直接写枚举类型了RestControllerRequestMapping(/contracts)publicclassContractController{/** * 查询参数 ?status3 自动解析为 ContractStatus.SIGNED * 不需要任何 RequestParam 的类型转换配置 */GetMappingpublicResultListContractVOlist(RequestParam(requiredfalse)ContractStatusstatus){// status 参数已经被 ConverterFactory 自动解析好了returncontractService.listByStatus(status);}}不用写任何注解、不用给每个枚举配任何 Converter。定义好枚举、实现 BaseEnum、注册一次 ConverterFactory完事。第二端JSON body → Jackson SPIJSON 端的处理在第六篇已经详细讲过了这里只简单带过。/** * Jackson 模块实现 BaseEnum 的枚举自动按 code 序列化和反序列化 */ConfigurationpublicclassJacksonConfig{BeanpublicJackson2ObjectMapperBuilderCustomizerjsonCustomizer(){returnbuilder-builder.modules(baseEnumModule());}privateModulebaseEnumModule(){SimpleModulemodulenewSimpleModule();// 序列化枚举 → 整数 codemodule.addSerializer(BaseEnum.class,newJsonSerializerBaseEnum(){Overridepublicvoidserialize(BaseEnumvalue,JsonGeneratorgen,SerializerProviderserializers)throwsIOException{gen.writeNumber(value.getCode());}});// 反序列化整数 code → 枚举通过 SPI 分发到具体类型module.setupModule(context-{context.addDeserializers(newDeserializers.Base(){OverridepublicJsonDeserializer?findEnumDeserializer(Class?type,DeserializationConfigconfig,BeanDescriptionbeanDesc){if(BaseEnum.class.isAssignableFrom(type)){returnnewJsonDeserializerBaseEnum(){OverridepublicBaseEnumdeserialize(JsonParserp,DeserializationContextctxt)throwsIOException{intcodep.getValueAsInt();// 复用前面定义的 BaseEnumCache保证缓存一致returnBaseEnumCache.getByCode(type.asSubclass(BaseEnum.class),code);}};}returnnull;}});});returnmodule;}}注意这里复用了BaseEnumCache——HTTP 参数和 JSON body 用的是同一套 code→枚举的缓存。这意味着如果将来要改映射规则只改一个地方。到此为止枚举在 HTTP 参数和 JSON body 两端的转换已经统一。还剩下第三端——Excel。第三端Excel 导入导出Excel 相比前两个渠道有个不同它用的是中文 label不是 code。用户下载模板时看得懂中文导出填模板时也写中文导入。所以 Excel 端的映射是label ↔ 枚举而不是code ↔ 枚举。先看导出方向。EasyExcel 支持给字段加ContentConverter或者实现Converter接口在写入时做转换importcom.alibaba.excel.converters.Converter;importcom.alibaba.excel.metadata.GlobalConfiguration;importcom.alibaba.excel.metadata.data.WriteCellData;importcom.alibaba.excel.metadata.property.ExcelContentProperty;/** * BaseEnum Excel 转换器 * * 导出枚举 → 中文 label用户看得懂 * 导入中文 label → 枚举按 label 匹配 */publicclassBaseEnumExcelConverterimplementsConverterBaseEnum{OverridepublicClassBaseEnumsupportJavaTypeKey(){// 声明支持 BaseEnum 类型returnBaseEnum.class;}OverridepublicCellDataTypeEnumsupportExcelTypeKey(){returnCellDataTypeEnum.STRING;}/** * 导出时枚举 → 中文 label */OverridepublicWriteCellData?convertToExcelData(BaseEnumvalue,ExcelContentPropertyproperty,GlobalConfigurationglobalConfig){// 直接取 getLabel() 写入 Excel 单元格returnnewWriteCellData(value.getLabel());}/** * 导入时中文 label → 枚举 */OverridepublicReadCellData?convertToJavaData(ReadCellData?cellData,ExcelContentPropertyproperty,GlobalConfigurationglobalConfig){// 从 Excel 单元格中读出中文文本StringlabelcellData.getStringValue();// 根据 property 中定义的字段类型查找对应的枚举Class?targetTypeproperty.getField().getType();// 按 label 匹配枚举常量需要另一个缓存BaseEnumresultBaseEnumCache.getByLabel(targetType.asSubclass(BaseEnum.class),label);if(resultnull){thrownewIllegalArgumentException(Unknown label label for enum targetType.getSimpleName());}returnresult;}}导出看起来简单但导入时有一个问题EasyExcel 的Converter实现类在配置阶段需要确定supportJavaTypeKey()的类型不能像ConverterFactory那样靠泛型参数动态分发。所以这里需要用property.getField().getType()在运行时感知目标字段的类型。不过我们不需要在每个 DTO 字段上加注解。可以全局配置默认转换器importcom.alibaba.excel.write.handler.WriteHandler;/** * Excel 写入配置全局默认使用 BaseEnumExcelConverter * 所有实现 BaseEnum 的枚举字段自动使用此转换器 */ConfigurationpublicclassExcelConfig{BeanpublicWriteHandlerexcelWriteHandler(){// 这里用 EasyExcel 的 Converter 注册机制// 当字段类型是 BaseEnum 子类时自动使用 BaseEnumExcelConverterreturnnewAbstractWriteHandler(){Overridepublicvoidsheet(intsheetNo){// 注册自定义转换器到全局配置}};}}当然更省事的做法是在 Excel DTO 的字段上加ExcelProperty配合converter属性或者直接在ExcelProperty的converter里指定publicclassContractImportDTO{/** * Excel 的合同状态列自动用 BaseEnumExcelConverter 做导入转换 */ExcelProperty(value合同状态,converterBaseEnumExcelConverter.class)privateContractStatusstatus;}虽然这里在 DTO 里引用了转换器类但至少转换逻辑是统一的——你再也不需要给每个枚举写一个 switch-case 了。三端统一的全景图把三个渠道串联起来整个架构是这样的┌──────────────────────┐ │ BaseEnum │ │ ┌──────────────────┐ │ │ │ int getCode() │ │ │ │ String getLabel() │ │ │ └──────────────────┘ │ └───────┬──────┬────────┘ │ │ ┌───────────┼──────┼───────────────┐ ▼ │ │ ▼ ┌───────────┐ │ │ ┌───────────────┐ │ HTTP 参数 │ │ │ │ Excel │ │ String→Enum│ │ │ │ label→Enum │ │ ?status1 │ │ │ │ 已签署→SIGNED│ └─────┬─────┘ │ │ └───────┬───────┘ │ │ │ │ ▼ │ │ ▼ ┌──────────────┐ │ │ ┌──────────────┐ │Converter │ │ │ │Excel │ │Factory │ │ │ │Converter │ │String,Enum │ │ │ │BaseEnum │ └──────────────┘ │ │ └──────────────┘ │ │ ▼ ▼ ┌──────────────────┐ │ Jackson SPI │ │ (Deserializers │ │ .Base) │ │ int→Enum │ └──────────────────┘ │ ▼ ┌──────────────────┐ │ BaseEnumCache │ │ code→enum 缓存 │ │ label→enum 缓存 │ │ ConcurrentHashMap│ └──────────────────┘三个渠道往上都基于BaseEnum接口往下都复用BaseEnumCache。新增枚举只需要实现BaseEnum三个渠道自动适配一行额外代码不用写。这样做的收益第一新枚举零成本接入。新加一个枚举类让它实现BaseEnum定义好 code 和 label。HTTP 参数的查询绑定、JSON 的序列化反序列化、Excel 的导入导出全部自动生效。第二行为一致。HTTP 查询参数里?status3和 JSON body 里status: 3表示同一个枚举值的3——都是ContractStatus.SIGNED。不会出现查询参数里 3 表示已签署、JSON 里 3 表示已过期这种低级错误。第三维护集中。三个渠道共用一个缓存BaseEnumCache。如果哪天getCode()的语义变了比如从 1-based 改成 0-based只需要改枚举本身的 code 值不需要去三个渠道分别修改映射逻辑。第四往前端暴露的 API 更清晰。前端开发者只需要记住每个枚举对应一个整数 code不管是在 URL 参数里还是在请求 body 里规则都一样。不像以前那样——“传参用数字body 用字符串我看看文档怎么说……”什么情况下这套方案不够用凡事都有例外。这套统一方案在绝大多数场景下是够用的但有几种情况需要单独处理无 code 的简单枚举。如果项目里有些纯标志位枚举不需要 DB 存 TINYINT也不需要多语言 label直接用enum关键字定义就好了不需要强行实现BaseEnum。这类枚举通常只出现在代码逻辑判断里不跨越任何渠道边界。多 label 映射国际化。如果系统需要多种语言的 label比如中文版 Excel 用已签署、英文版用Signed那么 label 就不应该是枚举类上固定的字符串而需要走国际化资源文件。这种情况可以扩展BaseEnum接口把getLabel()改成getLabel(Locale locale)或者干脆把 label 映射逻辑从枚举类中抽离出来放到配置文件中。Excel 导入时 label 模糊匹配。用户手工填的 Excel 难免有多余空格、全角半角不一致等问题。如果要做智能匹配比如用户填已签 署也期望匹配到 SIGNED那纯字符串等值比较就不够了。这种情况可以给BaseEnumCache.getByLabel()加一层预处理trim、全角半角归一化或者干脆用字符串相似度做模糊匹配。但这些都是边界情况。对于大多数业务系统来说code 数字传输 label 中文展示的模型已经够用了。总结说回开头那个 Excel 导入的需求。实现了三端统一之后最终的效果是这样的HTTP 端ConverterFactory处理?status1→ 注册一次所有枚举通用JSON 端Deserializers.BaseSPI 处理status: 1→ 第六篇的方案Excel 端BaseEnumExcelConverter处理已签署→ 基于 label 映射三个端共享同一个BaseEnum接口和BaseEnumCache缓存。整个代码量很小——核心类就四个BaseEnum接口定义、BaseEnumCache缓存、BaseEnumConverterFactoryHTTP 参数、BaseEnumExcelConverterExcel 转换。加起来不到 200 行但覆盖了项目中所有枚举在三个渠道上的转换需求。每当项目里新加一个枚举只需要定义好 code 和 label然后就可以在任何地方直接用了。传参、序列化、Excel 导入导出——它们自己会搞定。你会慢慢忘记枚举转换这件事的存在这大概就是好的抽象设计不是解决一个具体问题而是消灭一整类问题。
一个接口管三端:用 ConverterFactory 统一枚举在 HTTP/JSON/Excel 中的转换
一个接口管三端用 ConverterFactory 统一枚举在 HTTP/JSON/Excel 中的转换前言前阵子接了个需求做一个 Excel 导入功能用户从模板填好数据上传系统自动解析入库。模板里有一列叫合同状态填的是已签署、“待签署”、已过期这种中文词但后端 DB 存的是整数 code1、2、3。类似的情况不止 Excel——HTTP 查询参数里枚举传的是 code 字符串?status1JSON body 里传的是 code 整数status: 1到处是枚举。而且你没发现吗同一个枚举在这三个渠道的面孔完全不一样渠道表现形式例子HTTP 查询参数字符串数字?status1JSON body整数status: 1Excel 单元格中文文本已签署数据库TINYINT1三张面孔、三种输入格式、三种解析方式但要映射到同一个枚举常量。之前我在第六篇聊过如何用 Jackson SPI 解决 JSON body 的枚举反序列化。但那只解决了一个渠道。如果 HTTP 查询参数和 Excel 也各搞一套方案项目里的枚举转换代码就会散落在三个不同地方维护起来还是头疼。今天这篇就聊三端统一的方案——一个ConverterFactory搞定三个渠道的枚举转换。枚举的三张面孔先把问题铺开讲清楚。假设项目里定义了一个合同状态枚举/** * 合同状态枚举 * DB 存 codeJSON 传 codeExcel 导 label查询参数也用 code */publicenumContractStatusimplementsBaseEnum{DRAFT(1,草稿),SIGNING(2,签署中),SIGNED(3,已签署),EXPIRED(4,已过期),TERMINATED(5,已终止);privatefinalintcode;privatefinalStringlabel;ContractStatus(intcode,Stringlabel){this.codecode;this.labellabel;}OverridepublicintgetCode(){returncode;}OverridepublicStringgetLabel(){returnlabel;}}这个枚举会出现在三个不同的交互场景场景一HTTP 查询参数。前端调用合同列表接口传?status1,2筛选草稿和签署中的合同。Spring MVC 需要把字符串1解析成ContractStatus枚举。场景二JSON body。前端创建合同时POST body 里status: 1表示合同状态。Jackson 需要把整数1反序列化成枚举。场景三Excel 导入导出。用户下载模板填好数据上传Excel 里合同状态列写的是中文已签署。程序需要把已签署映射到ContractStatus.SIGNED。三个场景三种输入格式但都需要转成同一个枚举。最粗暴的做法是对每个枚举、每个渠道都写一套转换代码。笨办法有多痛先看看如果不做统一抽象代码会变成什么样子。HTTP 参数Spring MVC 里最原生的做法是给每个枚举写一个Converter/** * 给 ContractStatus 写一个专属转换器 * 如果项目有 50 个枚举就要写 50 个这样的类 */publicclassContractStatusConverterimplementsConverterString,ContractStatus{OverridepublicContractStatusconvert(Stringsource){intcodeInteger.parseInt(source);for(ContractStatusstatus:ContractStatus.values()){if(status.getCode()code){returnstatus;}}thrownewIllegalArgumentException(Unknown code: code);}}JSON body每个枚举加一个JsonCreator第六篇已经吐槽过了不重复。Excel 导入假设用的 EasyExcel一般是在监听器里写一个 switch-case/** * Excel 导入时的枚举映射 * 每个枚举都要写一个这样的方法每新增一个 code 就要改 switch */privateContractStatusparseStatus(StringcellValue){switch(cellValue){case草稿:returnContractStatus.DRAFT;case签署中:returnContractStatus.SIGNING;case已签署:returnContractStatus.SIGNED;case已过期:returnContractStatus.EXPIRED;case已终止:returnContractStatus.TERMINATED;default:thrownewIllegalArgumentException(Unknown status: cellValue);}}三个场景三个不同的实现方式。枚举数量一多代码量直接乘三。而且很难保证一致——万一某个枚举在 HTTP 参数里写漏了一个值、在 Excel 映射里写错了一个中文名定位问题得翻三个地方。需要从根本上统一它。基石BaseEnum 接口不管是 HTTP 参数、JSON 还是 Excel它们的本质都是把一个外部格式转换成枚举常量。要做统一抽象首先所有枚举必须有一个共同的接口约定。/** * 枚举基类接口 * * 所有枚举必须实现此接口后续的 ConverterFactory、Jackson SPI、Excel 转换 * 都基于这个接口统一处理 * * getCode() — DB 和 JSON 传输用的整数值 * getLabel() — VO 展示和 Excel 导出的中文描述 */publicinterfaceBaseEnum{/** * 枚举的整数值 * 对应数据库 TINYINT、JSON 中的 code 字段、查询参数的 code */intgetCode();/** * 枚举的中文描述 * 用于 VO 展示、Excel 导入/导出时的文本映射 */StringgetLabel();}有了这层抽象就可以针对接口编程而不是针对每个具体枚举编程。第一端HTTP 查询参数 → ConverterFactorySpring MVC 提供了一个叫ConverterFactory的接口。乍一看名字有点绕但其实它的职责很单一根据目标类型自动创建对应的 Converter。/** * ConverterFactory 接口定义 * S 是源类型StringR 是目标类型的上限BaseEnum * getConverter(ClassT) 返回一个按目标类型参数化的 Converter */publicinterfaceConverterFactoryS,R{TextendsRConverterS,TgetConverter(ClassTtargetType);}对比Converter和ConverterFactoryConverterString, ContractStatus——只能把 String 转成ContractStatusConverterFactoryString, BaseEnum——可以把 String 转成任意BaseEnum子类工厂的优势在于注册一次所有实现 BaseEnum 的枚举自动生效。来看实现importorg.springframework.core.convert.converter.Converter;importorg.springframework.core.convert.converter.ConverterFactory;/** * BaseEnum ConverterFactory * * 注册到 Spring MVC 后所有 BaseEnum 子类的 HTTP 参数绑定自动生效。 * 前端传 ?status1Controller 参数写 ContractStatus status自动解析。 * * 不限定具体枚举类型通过泛型参数 T 由 Spring MVC 在运行时确定。 */publicclassBaseEnumConverterFactoryimplementsConverterFactoryString,BaseEnum{OverridepublicTextendsBaseEnumConverterString,TgetConverter(ClassTtargetType){// 返回一个 Lambda Converter每次调用时按目标类型遍历枚举常量returnsource-{// 查询参数本质是字符串先转成整数 codeintcodeInteger.parseInt(source);// 遍历目标枚举的所有常量按 code 匹配for(BaseEnumconstant:targetType.getEnumConstants()){if(constant.getCode()code){// 找到匹配的常量返回编译器会做类型推断SuppressWarnings(unchecked)Tresult(T)constant;returnresult;}}thrownewIllegalArgumentException(Unknown code code for enum targetType.getSimpleName());};}}核心逻辑很简单targetType.getEnumConstants()拿到目标枚举的所有常量然后按 code 匹配。但也确实有个可以优化的地方每次调用都遍历枚举常量O(n) 的查表效率在请求量大的时候会有影响。改成预构建的 HashMap 会好很多OverridepublicTextendsBaseEnumConverterString,TgetConverter(ClassTtargetType){returnsource-{intcodeInteger.parseInt(source);// 从预构建的 code → enum 映射中查找O(1)TresultBaseEnumCache.getByCode(targetType,code);if(resultnull){thrownewIllegalArgumentException(Unknown code code for enum targetType.getSimpleName());}returnresult;};}这里的BaseEnumCache就是一个基于ConcurrentHashMap的线程安全缓存importjava.util.concurrent.ConcurrentHashMap;/** * 枚举常量缓存 * 每个枚举类的 code→常量 映射只初始化一次线程安全 */publicclassBaseEnumCache{/** 缓存结构Class → (code → enum常量) */privatestaticfinalConcurrentHashMapClass?,ConcurrentHashMapInteger,BaseEnumCACHEnewConcurrentHashMap();/** * 根据 code 获取枚举常量 * 首次访问时自动构建映射并缓存 */SuppressWarnings(unchecked)publicstaticTextendsBaseEnumTgetByCode(ClassTenumClass,intcode){// computeIfAbsent只有第一次会执行构建函数后续直接返回缓存ConcurrentHashMapInteger,BaseEnummappingCACHE.computeIfAbsent(enumClass,clazz-{ConcurrentHashMapInteger,BaseEnummapnewConcurrentHashMap();for(BaseEnumconstant:((Class?extendsBaseEnum)clazz).getEnumConstants()){map.put(constant.getCode(),constant);}returnmap;});return(T)mapping.get(code);}}computeIfAbsent是 ConcurrentHashMap 最优雅的方法——“有则返回无则计算后存入并返回”。多个线程同时访问时只有一个线程执行构建函数其他线程等待它完成。这个工厂怎么注册到 Spring MVC 呢importorg.springframework.web.servlet.config.annotation.WebMvcConfigurer;importorg.springframework.format.FormatterRegistry;/** * Spring MVC 配置注册 BaseEnum ConverterFactory * 一注册所有实现 BaseEnum 的枚举在 HTTP 参数绑定中自动生效 */ConfigurationpublicclassWebConfigimplementsWebMvcConfigurer{OverridepublicvoidaddFormatters(FormatterRegistryregistry){// 这句代码之后Controller 方法参数里的 BaseEnum 枚举// 都能自动从查询参数中的 code 字符串解析registry.addConverterFactory(newBaseEnumConverterFactory());}}完成之后Controller 就可以直接写枚举类型了RestControllerRequestMapping(/contracts)publicclassContractController{/** * 查询参数 ?status3 自动解析为 ContractStatus.SIGNED * 不需要任何 RequestParam 的类型转换配置 */GetMappingpublicResultListContractVOlist(RequestParam(requiredfalse)ContractStatusstatus){// status 参数已经被 ConverterFactory 自动解析好了returncontractService.listByStatus(status);}}不用写任何注解、不用给每个枚举配任何 Converter。定义好枚举、实现 BaseEnum、注册一次 ConverterFactory完事。第二端JSON body → Jackson SPIJSON 端的处理在第六篇已经详细讲过了这里只简单带过。/** * Jackson 模块实现 BaseEnum 的枚举自动按 code 序列化和反序列化 */ConfigurationpublicclassJacksonConfig{BeanpublicJackson2ObjectMapperBuilderCustomizerjsonCustomizer(){returnbuilder-builder.modules(baseEnumModule());}privateModulebaseEnumModule(){SimpleModulemodulenewSimpleModule();// 序列化枚举 → 整数 codemodule.addSerializer(BaseEnum.class,newJsonSerializerBaseEnum(){Overridepublicvoidserialize(BaseEnumvalue,JsonGeneratorgen,SerializerProviderserializers)throwsIOException{gen.writeNumber(value.getCode());}});// 反序列化整数 code → 枚举通过 SPI 分发到具体类型module.setupModule(context-{context.addDeserializers(newDeserializers.Base(){OverridepublicJsonDeserializer?findEnumDeserializer(Class?type,DeserializationConfigconfig,BeanDescriptionbeanDesc){if(BaseEnum.class.isAssignableFrom(type)){returnnewJsonDeserializerBaseEnum(){OverridepublicBaseEnumdeserialize(JsonParserp,DeserializationContextctxt)throwsIOException{intcodep.getValueAsInt();// 复用前面定义的 BaseEnumCache保证缓存一致returnBaseEnumCache.getByCode(type.asSubclass(BaseEnum.class),code);}};}returnnull;}});});returnmodule;}}注意这里复用了BaseEnumCache——HTTP 参数和 JSON body 用的是同一套 code→枚举的缓存。这意味着如果将来要改映射规则只改一个地方。到此为止枚举在 HTTP 参数和 JSON body 两端的转换已经统一。还剩下第三端——Excel。第三端Excel 导入导出Excel 相比前两个渠道有个不同它用的是中文 label不是 code。用户下载模板时看得懂中文导出填模板时也写中文导入。所以 Excel 端的映射是label ↔ 枚举而不是code ↔ 枚举。先看导出方向。EasyExcel 支持给字段加ContentConverter或者实现Converter接口在写入时做转换importcom.alibaba.excel.converters.Converter;importcom.alibaba.excel.metadata.GlobalConfiguration;importcom.alibaba.excel.metadata.data.WriteCellData;importcom.alibaba.excel.metadata.property.ExcelContentProperty;/** * BaseEnum Excel 转换器 * * 导出枚举 → 中文 label用户看得懂 * 导入中文 label → 枚举按 label 匹配 */publicclassBaseEnumExcelConverterimplementsConverterBaseEnum{OverridepublicClassBaseEnumsupportJavaTypeKey(){// 声明支持 BaseEnum 类型returnBaseEnum.class;}OverridepublicCellDataTypeEnumsupportExcelTypeKey(){returnCellDataTypeEnum.STRING;}/** * 导出时枚举 → 中文 label */OverridepublicWriteCellData?convertToExcelData(BaseEnumvalue,ExcelContentPropertyproperty,GlobalConfigurationglobalConfig){// 直接取 getLabel() 写入 Excel 单元格returnnewWriteCellData(value.getLabel());}/** * 导入时中文 label → 枚举 */OverridepublicReadCellData?convertToJavaData(ReadCellData?cellData,ExcelContentPropertyproperty,GlobalConfigurationglobalConfig){// 从 Excel 单元格中读出中文文本StringlabelcellData.getStringValue();// 根据 property 中定义的字段类型查找对应的枚举Class?targetTypeproperty.getField().getType();// 按 label 匹配枚举常量需要另一个缓存BaseEnumresultBaseEnumCache.getByLabel(targetType.asSubclass(BaseEnum.class),label);if(resultnull){thrownewIllegalArgumentException(Unknown label label for enum targetType.getSimpleName());}returnresult;}}导出看起来简单但导入时有一个问题EasyExcel 的Converter实现类在配置阶段需要确定supportJavaTypeKey()的类型不能像ConverterFactory那样靠泛型参数动态分发。所以这里需要用property.getField().getType()在运行时感知目标字段的类型。不过我们不需要在每个 DTO 字段上加注解。可以全局配置默认转换器importcom.alibaba.excel.write.handler.WriteHandler;/** * Excel 写入配置全局默认使用 BaseEnumExcelConverter * 所有实现 BaseEnum 的枚举字段自动使用此转换器 */ConfigurationpublicclassExcelConfig{BeanpublicWriteHandlerexcelWriteHandler(){// 这里用 EasyExcel 的 Converter 注册机制// 当字段类型是 BaseEnum 子类时自动使用 BaseEnumExcelConverterreturnnewAbstractWriteHandler(){Overridepublicvoidsheet(intsheetNo){// 注册自定义转换器到全局配置}};}}当然更省事的做法是在 Excel DTO 的字段上加ExcelProperty配合converter属性或者直接在ExcelProperty的converter里指定publicclassContractImportDTO{/** * Excel 的合同状态列自动用 BaseEnumExcelConverter 做导入转换 */ExcelProperty(value合同状态,converterBaseEnumExcelConverter.class)privateContractStatusstatus;}虽然这里在 DTO 里引用了转换器类但至少转换逻辑是统一的——你再也不需要给每个枚举写一个 switch-case 了。三端统一的全景图把三个渠道串联起来整个架构是这样的┌──────────────────────┐ │ BaseEnum │ │ ┌──────────────────┐ │ │ │ int getCode() │ │ │ │ String getLabel() │ │ │ └──────────────────┘ │ └───────┬──────┬────────┘ │ │ ┌───────────┼──────┼───────────────┐ ▼ │ │ ▼ ┌───────────┐ │ │ ┌───────────────┐ │ HTTP 参数 │ │ │ │ Excel │ │ String→Enum│ │ │ │ label→Enum │ │ ?status1 │ │ │ │ 已签署→SIGNED│ └─────┬─────┘ │ │ └───────┬───────┘ │ │ │ │ ▼ │ │ ▼ ┌──────────────┐ │ │ ┌──────────────┐ │Converter │ │ │ │Excel │ │Factory │ │ │ │Converter │ │String,Enum │ │ │ │BaseEnum │ └──────────────┘ │ │ └──────────────┘ │ │ ▼ ▼ ┌──────────────────┐ │ Jackson SPI │ │ (Deserializers │ │ .Base) │ │ int→Enum │ └──────────────────┘ │ ▼ ┌──────────────────┐ │ BaseEnumCache │ │ code→enum 缓存 │ │ label→enum 缓存 │ │ ConcurrentHashMap│ └──────────────────┘三个渠道往上都基于BaseEnum接口往下都复用BaseEnumCache。新增枚举只需要实现BaseEnum三个渠道自动适配一行额外代码不用写。这样做的收益第一新枚举零成本接入。新加一个枚举类让它实现BaseEnum定义好 code 和 label。HTTP 参数的查询绑定、JSON 的序列化反序列化、Excel 的导入导出全部自动生效。第二行为一致。HTTP 查询参数里?status3和 JSON body 里status: 3表示同一个枚举值的3——都是ContractStatus.SIGNED。不会出现查询参数里 3 表示已签署、JSON 里 3 表示已过期这种低级错误。第三维护集中。三个渠道共用一个缓存BaseEnumCache。如果哪天getCode()的语义变了比如从 1-based 改成 0-based只需要改枚举本身的 code 值不需要去三个渠道分别修改映射逻辑。第四往前端暴露的 API 更清晰。前端开发者只需要记住每个枚举对应一个整数 code不管是在 URL 参数里还是在请求 body 里规则都一样。不像以前那样——“传参用数字body 用字符串我看看文档怎么说……”什么情况下这套方案不够用凡事都有例外。这套统一方案在绝大多数场景下是够用的但有几种情况需要单独处理无 code 的简单枚举。如果项目里有些纯标志位枚举不需要 DB 存 TINYINT也不需要多语言 label直接用enum关键字定义就好了不需要强行实现BaseEnum。这类枚举通常只出现在代码逻辑判断里不跨越任何渠道边界。多 label 映射国际化。如果系统需要多种语言的 label比如中文版 Excel 用已签署、英文版用Signed那么 label 就不应该是枚举类上固定的字符串而需要走国际化资源文件。这种情况可以扩展BaseEnum接口把getLabel()改成getLabel(Locale locale)或者干脆把 label 映射逻辑从枚举类中抽离出来放到配置文件中。Excel 导入时 label 模糊匹配。用户手工填的 Excel 难免有多余空格、全角半角不一致等问题。如果要做智能匹配比如用户填已签 署也期望匹配到 SIGNED那纯字符串等值比较就不够了。这种情况可以给BaseEnumCache.getByLabel()加一层预处理trim、全角半角归一化或者干脆用字符串相似度做模糊匹配。但这些都是边界情况。对于大多数业务系统来说code 数字传输 label 中文展示的模型已经够用了。总结说回开头那个 Excel 导入的需求。实现了三端统一之后最终的效果是这样的HTTP 端ConverterFactory处理?status1→ 注册一次所有枚举通用JSON 端Deserializers.BaseSPI 处理status: 1→ 第六篇的方案Excel 端BaseEnumExcelConverter处理已签署→ 基于 label 映射三个端共享同一个BaseEnum接口和BaseEnumCache缓存。整个代码量很小——核心类就四个BaseEnum接口定义、BaseEnumCache缓存、BaseEnumConverterFactoryHTTP 参数、BaseEnumExcelConverterExcel 转换。加起来不到 200 行但覆盖了项目中所有枚举在三个渠道上的转换需求。每当项目里新加一个枚举只需要定义好 code 和 label然后就可以在任何地方直接用了。传参、序列化、Excel 导入导出——它们自己会搞定。你会慢慢忘记枚举转换这件事的存在这大概就是好的抽象设计不是解决一个具体问题而是消灭一整类问题。