上一篇《职责单一原则真的简单吗》中我们认识了 发散式变化 ,它是一个类包含多个维度的变化,职责不单一。本文讨论的代码坏味道是 散弹式修改 ,与 发散式变化 恰好相反,一个维度的变化涉及到多个类。
在商业项目开发过程中,经常会碰到“加个需求,到处改代码”的情况,也就是 散弹式修改 ,典型后果是漏改某些地方,导致整个系统表现不一致。
要解决 散弹式修改 ,对重构/设计技能有较高要求。一如既往,一码上个例子,与你分享其中需要理解的点点滴滴。
例子的背景
该例子来自于一个关于推荐和订单的报表系统。
小伙伴们应该知道,报表系统说白了,就是以各种方式展示各种指标。简单点,假设目前只有下面三个指标:
- 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公众号,点击 核心技术,点击 大话重构。
分类 大话重构
优雅程序员 原创 转载请注明出处