重构与DSL:重构代码以引入领域特定语言
说实话,DSL(领域特定语言)这个词,听起来挺唬人的。很多人一听到“语言”两个字,就觉得要发明一门新编程语言,门槛太高。其实不然。
我个人的理解很简单:DSL就是让你的代码,读起来像业务人员在说话。你想想看,如果一段代码里全是“if-else”、“for循环”、“List.contains”,业务方看不懂,你自己过两周也得琢磨半天。但如果代码里写的是“当订单金额超过1000元且用户是VIP时,自动应用折扣”,那几乎不需要注释。
今天我们就聊聊,怎么通过重构,把那些散落在各处的业务逻辑,提炼成一块清晰的DSL。
什么时候该考虑引入DSL?
我在项目中遇到过好几次这样的场景:一个业务规则模块,代码改了又改,每次加新规则都要动好几个文件。测试用例越写越多,但每次上线还是提心吊胆。说白了,就是业务逻辑的表达方式,跟代码的实现方式,耦合得太紧了。
具体来说,出现以下信号时,你就该考虑DSL了:
- 重复的条件判断:同样的“金额>1000 && 等级==VIP”在代码里出现了十几次
- 业务人员无法参与评审:每次改规则都得由开发翻译成代码,沟通成本极高
- 规则组合爆炸:当规则数量超过20条,且存在交叉组合时,纯代码维护基本是噩梦
核心原则:DSL不是炫技,而是为了降低“业务语义”与“代码实现”之间的阻抗失配。
重构步骤:从过程式代码到DSL
我们一步步来。假设你有一段这样的代码,判断一个订单是否满足“大促活动”的参与资格:
public boolean canJoinPromotion(Order order) {
if (order.getAmount() < 100) return false;
if (order.getUserLevel() < 3) return false;
if (order.getCreateTime().before(today)) return false;
if (order.getItems().size() < 2) return false;
// 还有更多条件...
return true;
}
这段代码有什么问题?嗯,它把所有规则硬编码在了一个方法里。每次改规则,你都得改这个方法。而且,业务方根本看不懂“getUserLevel() < 3”是什么意思。
第一步:提取规则元数据
我建议先把每个条件抽象成一个“规则对象”。每个规则只做一件事:判断是否满足,并给出原因。
public interface Rule {
boolean evaluate(Order order);
String getReason();
}
public class MinAmountRule implements Rule {
private double minAmount;
public MinAmountRule(double minAmount) { this.minAmount = minAmount; }
public boolean evaluate(Order order) {
return order.getAmount() >= minAmount;
}
public String getReason() {
return "订单金额需大于等于" + minAmount;
}
}
这一步做完,代码的可读性已经好了一些。但还不够——你还是在用Java的语法来表达业务。
第二步:构建DSL表达式
这里才是重头戏。我们要让业务人员能像写句子一样,描述规则。我个人习惯用链式调用或者构建器模式来实现。
// 目标:让调用方写出这样的代码
Rule rule = RuleBuilder
.when("订单金额").gte(100)
.and("用户等级").gte(3)
.and("下单时间").inRange(startDate, endDate)
.then("允许参与大促");
// 实现方式
public class RuleBuilder {
private List<Condition> conditions = new ArrayList<>();
public static ConditionBuilder when(String field) {
return new ConditionBuilder(field);
}
// 内部类实现链式调用
public static class ConditionBuilder {
private String field;
private Object value;
public ConditionBuilder(String field) { this.field = field; }
public RuleBuilder gte(Number val) {
// 注册一个“大于等于”的条件
conditions.add(new GteCondition(field, val));
return RuleBuilder.this;
}
public RuleBuilder and(String field) {
// 逻辑与连接
return this;
}
}
}
小技巧:DSL的语法设计,最好先跟业务人员聊一聊,看他们平时怎么描述规则。把他们的自然语言直接映射成API,这样学习成本最低。
DSL的两种常见形态
根据使用场景,DSL大致可以分为两类。我整理了一个表格,方便你对比:
| 类型 | 特点 | 适用场景 | 例子 |
|---|---|---|---|
| 内部DSL | 用宿主语言(如Java、Kotlin)的语法构建 | 规则数量中等,团队熟悉宿主语言 | 链式调用、构建器模式 |
| 外部DSL | 自定义语法,需要解析器 | 规则复杂,需要独立编辑/存储 | YAML、JSON配置文件,或自定义脚本 |
我个人更推荐内部DSL起步。为什么呢?因为外部DSL需要写解析器,维护成本高。除非你的规则真的多到需要单独一个系统来管理,否则内部DSL完全够用。
避坑指南
我曾经在一个项目中,把DSL设计得过于灵活。支持了各种嵌套、循环、变量引用。结果呢?业务人员根本用不来,最后还是开发帮着写。DSL变成了“开发人员自嗨的玩具”。
所以,这里有几点忠告:
- 不要追求“万能”:DSL只解决特定领域的问题。如果发现DSL里开始出现“if-else”了,说明你走偏了。
- 保持简单:能用“与或非”解决的,就别引入“循环”。能用“大于小于”解决的,就别引入“正则表达式”。
- 提供反馈:当规则不满足时,DSL应该能明确告诉你“是哪一条没通过”,而不是返回一个false。
注意:DSL不是银弹。如果你的业务规则只有三五条,且几乎不变,那写个简单的if-else就够了。引入DSL反而增加了复杂度。
知识体系图
下面这张图,概括了从原始代码到DSL重构的核心路径。你可以把它当作一个检查清单:
总结
重构引入DSL,本质上是一次语义提升。你把“怎么做”的细节藏起来,把“做什么”的意图暴露出来。代码不再是写给机器看的指令,而是写给人类读的文档。
嗯,最后说一句:别想着一步到位。先从最频繁修改的那一小块规则开始,用DSL重构它。等团队尝到甜头了,再慢慢铺开。这样最稳妥。
公众号:蓝海资料掘金营,微信deep3321