重构与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重构核心路径 原始代码 if-else 硬编码 业务语义模糊 规则对象化 每个规则一个类 可组合、可测试 领域特定语言 链式调用 业务可读 内部DSL 宿主语言语法 推荐起步 外部DSL 自定义语法 需解析器 关键原则 简单 > 灵活 | 业务可读 > 技术炫酷 | 渐进式引入 常见陷阱 过度设计 | 忽略业务方参与 | 缺乏错误反馈

总结

重构引入DSL,本质上是一次语义提升。你把“怎么做”的细节藏起来,把“做什么”的意图暴露出来。代码不再是写给机器看的指令,而是写给人类读的文档。

嗯,最后说一句:别想着一步到位。先从最频繁修改的那一小块规则开始,用DSL重构它。等团队尝到甜头了,再慢慢铺开。这样最稳妥。


公众号:蓝海资料掘金营,微信deep3321