【故障現(xiàn)場】將變更收斂在一處,避免散彈式更新
1. 問題&分析
使用 code 真香,終于不用擔心枚舉重構(gòu)了,但還是高興的太早了,一個線上bug正在路上….
1.1. 案例
經(jīng)過連續(xù)多天奮戰(zhàn),系統(tǒng)終于上線了訂單手工取消功能,剛剛上線便收到客服部門的反饋:訂單列表中訂單狀態(tài)出現(xiàn)問題,顯示未 undefine。小艾趕緊查看后端日志,沒有發(fā)現(xiàn)任何異常,并緊急給前端負責人虎哥掛了個電話,很快虎哥便定位原因并進行緊急修復(fù)。
事后復(fù)盤,原因是這樣的:
- 在訂單列表接口中,后端只返回了枚舉的 name
- 前端維護了一個配置文件,key 是 name,value 是顯示名稱,從接口獲取 name 后會基于配置文件進行轉(zhuǎn)換,最終展示為 描述信息
- 本次修改,只改了主站的 js 配置,遺漏了客服系統(tǒng)。所以,主站沒有問題,而客服系統(tǒng)由于找不到新加的name,所以展示為 undefine
后端返回結(jié)果如下圖所示:
圖片
默認情況下,枚舉只會返回 Name,非常不利于展示,所以在前端會進行一次翻譯,將 Name 翻譯成展示文案。
在這個接口的基礎(chǔ)上引起的問題如下圖所示:
圖片
由于業(yè)務(wù)發(fā)展,OrderStatus 的枚舉值發(fā)生了變化,但只對主站頁面進行調(diào)整,而客服系統(tǒng)被遺漏。所以:
- 主站頁面有最新的全量配置,信息展示準確沒有任何問題
- 客服系統(tǒng)由于被遺忘使用的還是之前的配置,導致后端返回的 Name 和 配置信息不一致,由于找不到 Name 而出現(xiàn) undefine 錯誤
1.2. 問題分析
深入思考,該問題的本質(zhì)就是:對信息沒有進行統(tǒng)一維護,導致同一份數(shù)據(jù)在多個地方進行管理,當發(fā)生變化時只要有一處未及時更新便會出現(xiàn)問題。
那解法也就很簡單了,將信息收口到后端進行統(tǒng)一管理!
除這個問題外,還有一個非常類似的問題:前端下拉列表,也需要和后端定義保持一致,一般情況下:
- 前端單獨維護,寫死在頁面,當后端發(fā)生變化后,前端跟著一起調(diào)整。這個方案就會出現(xiàn)兩者不一致的問題,不鼓勵使用;
- 后端提供一個接口用于獲取數(shù)據(jù),然后在渲染到前端組件。這個是鼓勵的方案,但每個枚舉都需要提供一個接口,增加了后端的開發(fā)負擔;
2. 解決方案
和 code 方案一致,可以使用接口對枚舉進行約束。
2.1. 構(gòu)建統(tǒng)一接口
首先,定義統(tǒng)一的接口,用于提供描述信息:
public interface SelfDescribedEnum {
default String getName(){
return name();
}
String name();
/**
* 獲取描述信息
*/
String getDescription();
}
2.2. 枚舉實現(xiàn)接口
然后,讓我們的枚舉實現(xiàn) SelfDescribedEnum 接口,具體如下:
public enum SelfDescribedEnumBasedOrderStatus implements SelfDescribedEnum {
CREATED("待支付"),
TIMEOUT_CANCELLED("超時取消"),
MANUAL_CANCELLED("手工取消"),
PAID("支付成功"),
FINISHED("已完成");
private final String description;
SelfDescribedEnumBasedOrderStatus(String description) {
this.description = description;
}
@Override
public String getDescription() {
return description;
}
}
2.3. 集成 Spring MVC 返回結(jié)果
在完成上述工作后,我們將 OrderVO 中的 status 屬性類型更新為 SelfDescribedEnumBasedOrderStatus,具體如下:
@Data
public class OrderVO {
private Long id;
private SelfDescribedEnumBasedOrderStatus status;
}
最后一步也是最關(guān)鍵的一步便是,對 Jackson 序列化器進行定制,核心代碼如下:
@Configuration
public class SelfDescribedEnumJacksonCustomizer {
@Bean
public Jackson2ObjectMapperBuilderCustomizer commonEnumBuilderCustomizer(){
return builder ->{
// 注冊自定義枚舉序列化器
builder.serializerByType(SelfDescribedEnum.class, new SelfDescribedEnumJsonSerializer());
};
}
static class SelfDescribedEnumJsonSerializer extends JsonSerializer {
@Override
public void serialize(Object o, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
SelfDescribedEnum selfDescribedEnum = (SelfDescribedEnum) o;
SelfDescribedEnumVO selfDescribedEnumVO = SelfDescribedEnumVO.from(selfDescribedEnum);
jsonGenerator.writeObject(selfDescribedEnumVO);
}
}
}
// SelfDescribedEnumVO 為定義的一個 VO,具體如下:
@Data
public class SelfDescribedEnumVO {
@ApiModelProperty(notes = "Name")
private final String name;
@ApiModelProperty(notes = "描述")
private final String desc;
public static SelfDescribedEnumVO from(SelfDescribedEnum selfDescribedEnum){
if (selfDescribedEnum == null){
return null;
}
return new SelfDescribedEnumVO(selfDescribedEnum.getName(), selfDescribedEnum.getDescription());
}
public static List<SelfDescribedEnumVO> from(List<SelfDescribedEnum> commonEnums){
if (CollectionUtils.isEmpty(commonEnums)){
return Collections.emptyList();
}
return commonEnums.stream()
.filter(Objects::nonNull)
.map(SelfDescribedEnumVO::from)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
}
最后,啟動服務(wù)查看新返回值,具體如下:
圖片
可以看,status 字段原本只返回了 name,現(xiàn)在返回的是一個包括 name 和 desc 的對象。前端無需進行轉(zhuǎn)換,只需直接讀取 status.desc 信息即可。
2.4. 提供統(tǒng)一字典服務(wù)
對于下來列表、選擇框的場景,最優(yōu)方案是為前端提供一個統(tǒng)一的字典接口,由該接口來返回所有字典信息。
核心代碼如下:
public class EnumDictController {
private Map<String, List<SelfDescribedEnum>> enumDict = new HashMap<String, List<SelfDescribedEnum>>();
public EnumDictController(){
add("OrderStatus", SelfDescribedEnumBasedOrderStatus.values());
}
private void add(String type, SelfDescribedEnumBasedOrderStatus[] values) {
this.enumDict.put(type, Arrays.asList(values));
}
/**
* 獲取所有字典信息
* @return
*/
@GetMapping("all")
public RestResult<Map<String, List<SelfDescribedEnumVO>>> allEnums(){
Map<String, List<SelfDescribedEnumVO>> dictVo = Maps.newHashMapWithExpectedSize(enumDict.size());
for (Map.Entry<String, List<SelfDescribedEnum>> entry : enumDict.entrySet()){
dictVo.put(entry.getKey(), SelfDescribedEnumVO.from(entry.getValue()));
}
return RestResult.success(dictVo);
}
/**
* 獲取支持的全部字典類型
* @return
*/
@GetMapping("types")
public RestResult<List<String>> enumTypes(){
return RestResult.success(Lists.newArrayList(enumDict.keySet()));
}
/**
* 獲取指定字典的全部值
* @param type
* @return
*/
@GetMapping("/{type}")
public RestResult<List<SelfDescribedEnumVO>> dictByType(@PathVariable("type") String type){
List<SelfDescribedEnum> enums = enumDict.get(type);
return RestResult.success(SelfDescribedEnumVO.from(enums));
}
}
啟動服務(wù),驗證字典接口。
獲取全部字典信息,返回結(jié)果如下:
圖片
一次性返回全部字典對性能有損耗,那可以返回指定字典,結(jié)果如下:
圖片
此時,前端只需從接口中獲取所需要的數(shù)據(jù),無需在 js 中進行單獨維護。
3. 示例&源碼
代碼倉庫:https://gitee.com/litao851025/learnFromBug
代碼地址:https://gitee.com/litao851025/learnFromBug/tree/master/src/main/java/com/geekhalo/demo/enums/descr