上一篇《職責單一原則真的簡單嗎》中我們認識了 發散式變化 ,它是一個類包含多個維度的變化,職責不單一。本文討論的代碼壞味道是 散彈式修改 ,與 發散式變化 恰好相反,一個維度的變化涉及到多個類。
在商業項目開發過程中,經常會碰到“加個需求,到處改代碼”的情況,也就是 散彈式修改 ,典型后果是漏改某些地方,導致整個系統表現不一致。
要解決 散彈式修改 ,對重構/設計技能有較高要求。一如既往,一碼上個例子,與你分享其中需要理解的點點滴滴。
例子的背景
該例子來自於一個關於推薦和訂單的報表系統。
小伙伴們應該知道,報表系統說白了,就是以各種方式展示各種指標。簡單點,假設目前只有下面三個指標:
- UM(Unique Member),唯一身份的用戶數,不區分線上線下
- Order,訂單數,區分線上和線下
- Revenue,銷售額,區分線上和線下
每個指標需要到數據倉庫中去查詢具體的值,然后在界面上展示出來。
原始代碼
object QueryFieldBuilder {
def build(fieldName: String): Array[String] = {
if (fieldName.equalsIgnoreCase("order")
|| fieldName.equalsIgnoreCase("revenue"))
Array("online", "instore").map(_ + fieldName.toLowerCase)
else
Array(fieldName.toLowerCase)
}
}
查詢指標值時,要分為兩類處理。一是不需要區分線上和線下的指標,如UM,直接拿um作為查詢字段即可;一是需要區分線上和線下的指標,如Order,需要轉換成onlineorder和instoreorder。
object FiledValueFormatter {
def format(filedName: String, value: String): String = {
if (filedName.equalsIgnoreCase("revenue"))
"$" + value
else
value
}
}
展示指標值時,如果是錢,需要在前面加上美元符號\(。(如果工資前面直接加\)。。。)
加新指標Profit
Profit,利潤額,區分線上和線下。。。
在原始代碼中,為了加上新指標Profit,需要在QueryFiledBuilder和FiledValueFormatter兩個主體中進行修改,額。。。大家都知道這樣不好。
合並
通過移動方法的重構手法,把一個變化維度上的邏輯,移動到一個主體中。如果沒有合適的主體作為方法的載體,則創建一個新主體。
object FieldContext {
def buildQueryField(fieldName: String): Array[String] = {
if (fieldName.equalsIgnoreCase("order")
|| fieldName.equalsIgnoreCase("revenue"))
Array("online", "instore").map(_ + fieldName.toLowerCase)
else
Array(fieldName.toLowerCase)
}
def formatValue(filedName: String, value: String): String = {
if (filedName.equalsIgnoreCase("revenue"))
"$" + value
else
value
}
}
新創建了FieldContext作為主體,承載兩個方法。雖然一眼看過去,代碼簡單易懂,也沒有 散彈式修改 的壞味道了。但是它職責單一嗎?No
FieldContext包含了三個職責:
- 指標到查詢字段的映射
- 指標值的格式化
- 添加指標
解決 散彈式修改 的過程中,通常會導致一點 發散式變化 ,那就又拆開唄。
合久必分
上面的三個職責耦合太緊,前兩個職責完全依賴於第三個職責。
通過引入指標上的分類特性,來倒轉依賴,從而分離上面的三個職責。
指標有兩個分類特性,FieldChannel為OI表示需要區分線上線下,為Single表示不區分。ValueType為Money表示指標值是錢,為Normal表示不是錢。(之所以不用布爾值,是為了考慮以后的擴展)
case class Field(name: String, channel: FieldChannel, valueType: ValueType)
指標有了兩個分類特性后,三個職責都可以依賴指標的分類特性,從而解耦。
object QueryFieldBuilder {
def build(filedName: String): Array[String] = {
val filed = FieldContext.getByName(filedName)
val lowerCaseFiledName = filedName.toLowerCase
if (filed.exists(_.channel.equals(OI)))
Array("online", "instore").map(_ + lowerCaseFiledName)
else
Array(lowerCaseFiledName)
}
}
QueryFieldBuilder依賴於指標的分類特性FieldChannel,承擔職責“指標到查詢字段的映射”。
object FieldValueFormatter {
def format(filedName: String, value: String): String = {
val filed = FieldContext.getByName(filedName)
if (filed.exists(_.valueType.equals(Money)))
"$" + value
else
value
}
}
FieldValueFormatter依賴於指標的分類特性ValueType,承擔職責“指標值的格式化”。
object FieldContext {
private val fields = List(
Field("UM", Single, Normal),
Field("Order", OI, Normal),
Field("Revenue", OI, Money),
Field("Profit", OI, Money)
)
private val filedMap = fields
.map(field => (field.name.toLowerCase, field))
.toMap
def getByName(name: String): Option[Field] = {
filedMap.get(name)
}
}
FieldContext通過給不同的指標配置合適的分類特性,來控制指標在查詢字段映射和值格式化中的具體行為,完美承載職責“新增指標”。
新指標Profit的加入,只是FieldContext中的一行代碼,一個配置而已。其實這是有學名的, 表驅動模式 。
小伙伴,你掌握了嗎?
推薦
查看《大話重構》系列文章,請進入YoyaProgrammer公眾號,點擊 核心技術,點擊 大話重構。
分類 大話重構
優雅程序員 原創 轉載請注明出處