编程原则的工程实践:从 KISS 到正交性

一条元原则

用最简单的方式解决当下的问题,同时不给下一个人制造麻烦。

这句话浓缩了所有编程原则的精神内核。KISS 说简单,YAGNI 说克制,DRY 说一致,关注分离说各管各的,迪米特法则说少打听——它们从不同角度描述同一件事:在复杂性和可维护性之间找到正确的平衡点。

但现实远比一句话复杂。DRY 要求消除重复,KISS 要求保持简单——两者经常指向相反的方向。YAGNI 说不要为未来设计,开闭原则说要为未来留出扩展空间。对原则的机械执行,往往比不用原则更危险。

一个常见的场景:两段业务代码形式上完全相同,有人为了"消除重复"将其抽成通用组件。三个月后,两段代码因为各自的业务需求分别演化,通用组件里堆满了条件分支,变成了一个比原始重复更难维护的怪物。这不是 DRY 的问题,而是没有区分"代码的重复"和"知识的重复"。

本文以一个电商订单系统为主线,将这些原则按目标维度分为四组,逐一拆解它们的工程含义和使用边界。文末的决策框架给出了原则冲突时的判断路径——因为真正的工程判断力,不在于记住所有原则,而在于感知原则之间的张力,并做出权衡。

维度 代表原则 常见误用
控制规模 KISS · YAGNI · Make It Work 过度设计 / 过早优化
统一知识 DRY 合并碰巧相似的代码
划定边界 关注分离 · 单一职责 · 接口隔离 · 正交性 过度拆分 / 胖接口
管理依赖 最小耦合 · 迪米特 · 依赖倒置 · 组合优于继承 · 开闭原则 链式穿透 / 滥用继承 / 依赖方向混乱

控制规模:KISS、YAGNI 与渐进式构建

这组原则的精神内核是一个字:克制。在鼓励"多做"的工程文化里,"少做"反而是最难的事。

KISS:简单不是简陋

KISS(Keep It Simple, Stupid)是最容易被误解的原则之一。"简单"不等于"简陋"或"不做设计",真正的简单是深思熟虑后的结果——更少的活动部件、更少的状态、更少的分支路径。 做到这一点通常比做一个复杂方案更难。

以订单系统中的特性开关为例,对比两种实现:

// Normal: 过度设计——插件式规则引擎
public class FeatureToggleEngine {
    private PluginRegistry pluginRegistry;
    private RuleEvaluator ruleEvaluator;
    private ConfigurationProvider configProvider;
    private FeatureToggleCache cache;

    public boolean isEnabled(String feature, UserContext ctx) {
        Rule rule = configProvider.loadRule(feature);
        List<Plugin> plugins = pluginRegistry.getPlugins(feature);
        EvaluationContext evalCtx = buildContext(ctx, plugins);
        return ruleEvaluator.evaluate(rule, evalCtx);
    }
}
// Better: 当系统只有 6 个开关且无灰度需求
public class FeatureFlags {
    private static final Map<String, Boolean> FLAGS = Map.of(
        "new_checkout_flow", true,
        "dark_mode", false,
        "v2_search", true
    );

    public static boolean isEnabled(String feature) {
        return FLAGS.getOrDefault(feature, false);
    }
}

第一种方案支持插件式规则引擎、动态加载、用户维度灰度——但订单系统一共只有 6 个简单的开/关控制。六行代码就能解决的问题,不需要四个类和一套框架。

"Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." —— Saint-Exupéry

YAGNI:代码是负债,不是资产

YAGNI(You Ain't Gonna Need It)专门针对"未来需求"的过度设计。

订单系统立项初期,有人提出搭建完整的数据库抽象层,"将来可能要从 MySQL 迁移到 PostgreSQL"。自定义 Query Builder、方言转换器、连接池代理——完全屏蔽底层差异。结果三年过去,迁移从未发生,但抽象层的代价实实在在:无法使用 MySQL 特定优化(如 INSERT ... ON DUPLICATE KEY UPDATE)、调试 SQL 要穿透三层封装、ORM 在抽象层下出现诡异行为。

写出来的每一行代码都是负债。 它要维护、要测试、要被后来的人理解。如果它解决的是一个不存在的问题,就是纯粹的负债。

渐进式构建:Make It Work → Right → Fast

这条原则规定了正确的工作顺序:

  1. Make It Work — 用最直接的方式实现功能,验证逻辑正确
  2. Make It Right — 重构结构,处理边界,写测试
  3. Make It Fast — 只在性能确实是问题时优化

顺序错误的代价极高。订单系统优化期间,有人花一周用位运算、对象池、手写内存管理优化金额计算循环,执行时间从 200μs 降到 15μs。但压测发现瓶颈在库存扣减的数据库全表扫描——耗时 800ms,加个索引降到 5ms。那个循环在整个下单链路里占比不到 0.002%。

在优化之前,先量化。 不要凭直觉猜瓶颈在哪里,用 Profiler 去测。Knuth 说"过早优化是万恶之源",比这更重要的是:人类的直觉在性能问题上出奇地不靠谱。

一句话总结:克制是一种能力——不写不需要的代码,不做不需要的抽象,不优化不需要优化的地方。

统一知识:DRY 与它的陷阱

DRY(Don't Repeat Yourself)是被引用最多、同时被误用最多的编程原则。

DRY 的真正含义

DRY 的原始定义来自《程序员修炼之道》:"Every piece of knowledge must have a single, unambiguous, authoritative representation within a system." 关键词是 knowledge(知识),不是 code(代码)。

订单系统中,两段金额校验代码看起来一模一样:

// 订单金额校验
if (amount > 0 && amount <= 100000) {
    processOrder(amount);
}

// 退款金额校验
if (amount > 0 && amount <= 100000) {
    processRefund(amount);
}

形式完全相同,但背后是两条独立的业务规则:订单上限和退款上限今天碰巧都是 10 万,但它们由不同的业务方决定、因不同的原因变化。一旦抽成共享的 validateAmount(),当运营需要差异化调整(订单上限调到 50 万、退款上限降到 5 万),就会陷入尴尬——共享函数里堆满 if-else 分支,比原始重复更难维护。

错误的 DRY 是消除代码的重复;正确的 DRY 是消除知识的重复。

何时该用 DRY

判断标准:两段代码不仅看起来一样,而且"改变的原因"也一样。

订单系统中三个地方做手机号格式校验——用户注册、修改收货信息、绑定支付手机号。这三个场景的校验规则来自同一条业务规则:"合法的中国大陆手机号格式"。号段规则变了,三处必须同步修改。这才是真正的知识重复,应该统一:

// 正确的 DRY:一条业务规则,一个权威来源
public class PhoneValidator {
    private static final Pattern CN_MOBILE =
        Pattern.compile("^1[3-9]\\d{9}$");

    public static boolean isValid(String phone) {
        return phone != null && CN_MOBILE.matcher(phone).matches();
    }
}

判断规则:如果两段代码因为不同的业务需求而各自演化,即使今天一模一样,也不要合并。如果两段代码永远因为同一个原因而同步变化,即使今天有细微差异,也应该统一。

一句话总结:DRY 保护的是知识的一致性,不是代码的形式统一——消除知识重复,容忍代码重复。

划定边界:关注分离、单一职责与正交性

这三条原则本质上在讨论同一个问题:怎么画线。在代码中画出清晰的边界,让每一部分各管各的,互不干扰。

关注分离:一个方法不该知道太多事情

订单系统中一段极其常见的"全能方法":

// Normal: 一个方法做五件事
public Response createOrder(HttpRequest request) {
    // 1. 鉴权
    String token = request.getHeader("Authorization");
    User user = tokenService.verify(token);
    if (user == null) return Response.unauthorized();

    // 2. 参数解析与校验
    OrderDTO dto = parseBody(request);
    if (dto.getAmount() <= 0) return Response.badRequest("金额无效");

    // 3. 业务逻辑
    Order order = new Order(user.getId(), dto.getAmount());
    order.applyDiscount(discountService.calculate(user));
    orderRepository.save(order);

    // 4. 发消息通知
    kafkaTemplate.send("order-created", order.toEvent());

    // 5. 构造响应
    return Response.ok(order.toVO());
}

鉴权、参数校验、核心逻辑、消息发送、响应构造——五件事耦合在一个方法里。鉴权方式换成 OAuth?改这里。消息中间件换成 RocketMQ?改这里。响应格式加个字段?还是改这里。

// Better: 每一层只关心自己的事
@PostMapping("/orders")
public Response createOrder(@Authenticated User user,
                            @Valid OrderDTO dto) {
    Order order = orderService.create(user, dto);
    return Response.ok(order.toVO());
}

// Service:只负责核心业务逻辑
public Order create(User user, OrderDTO dto) {
    Order order = new Order(user.getId(), dto.getAmount());
    order.applyDiscount(discountService.calculate(user));
    orderRepository.save(order);
    eventPublisher.publish(new OrderCreatedEvent(order));
    return order;
}

鉴权交给拦截器,参数校验交给注解,消息发送抽象成事件发布。改变鉴权方式不需要碰业务逻辑,改变消息中间件不需要碰 Controller。

单一职责:谁会要求你改这段代码

单一职责原则(SRP)经常被简化为"一个类只做一件事",但 Robert Martin 的原始表述是:一个类应该只有一个变化的原因(reason to change)。

"变化的原因"不是技术分类,而是谁会要求你改这段代码。订单系统中一个 OrderService 同时处理序列化格式和业务校验规则,它就有两个变化的原因:前端团队可能要求改序列化格式(XML → JSON),业务团队可能要求改校验规则(新增实名认证)。两个变化来自不同的利益相关方,进度不同、频率不同,不应该互相影响。

但 SRP 拆到极致也有代价。订单创建流程被拆成 OrderInputValidatorOrderFactoryOrderPersistenceServiceOrderNotificationSenderOrderEventPublisherOrderOrchestrator 六个类——每个类确实只有一个职责,但理解整个流程需要在六个文件之间跳转。如果核心逻辑放在一个 OrderService 里可能只有 80 行,读一个文件就能看懂。

经验法则:如果两个职责几乎总是同时变化、被同一个人修改、在同一个上下文中讨论,就没必要强行拆开。

接口隔离:不要强迫调用方依赖它不需要的方法

接口隔离原则(ISP)和 SRP 形成互补——SRP 约束的是类的职责,ISP 约束的是接口的粒度。

订单系统中定义了一个"万能"订单服务接口:

// Normal: 胖接口——所有调用方都要依赖完整接口
public interface OrderService {
    Order create(OrderDTO dto);
    void cancel(Long orderId);
    void refund(Long orderId, BigDecimal amount);
    List<Order> queryByUser(Long userId);
    OrderStatistics getStatistics(LocalDate from, LocalDate to);
    void exportToExcel(OutputStream out);
}

交易模块只需要 createcancel,报表模块只需要 getStatisticsexportToExcel,但两者都被迫依赖完整的 OrderService 接口。接口方法签名变化(比如 exportToExcel 加了个参数),交易模块也得重新编译。

// Better: 按调用方需求拆分接口
public interface OrderWriteService {
    Order create(OrderDTO dto);
    void cancel(Long orderId);
    void refund(Long orderId, BigDecimal amount);
}

public interface OrderQueryService {
    List<Order> queryByUser(Long userId);
    OrderStatistics getStatistics(LocalDate from, LocalDate to);
}

public interface OrderExportService {
    void exportToExcel(OutputStream out);
}

交易模块依赖 OrderWriteService,报表模块依赖 OrderQueryService + OrderExportService。实现类可以同时实现多个接口,但调用方只看到自己需要的部分。

ISP 的本质是"最小接口"——调用方只依赖它真正使用的方法,不多也不少。胖接口是隐性耦合的温床。

正交性:改一个维度,不波及其他维度

正交性是所有设计原则中最值得反复强调的一个,但它很少被单独讨论。

正交性的意思是:系统中的一个维度发生变化时,不应该影响其他维度。 借用线性代数的概念——正交的向量互不干扰,改变一个方向上的分量不会影响另一个方向。

订单系统要增加一种通知渠道——从只有短信,扩展到支持邮件和站内信。看两种架构的扩展成本:

// Normal: 通知逻辑散布在业务代码中——非正交
public class OrderService {
    public void create(Order order) {
        orderRepository.save(order);
        // 通知逻辑和业务逻辑耦合
        String msg = "订单" + order.getId() + "已创建";
        smsClient.send(order.getUserPhone(), msg);
        emailClient.send(order.getUserEmail(), "订单通知", msg);
        // 加站内信?继续在这里加...
    }

    public void cancel(Order order) {
        order.setStatus(CANCELLED);
        orderRepository.save(order);
        // 又是一遍通知逻辑
        String msg = "订单" + order.getId() + "已取消";
        smsClient.send(order.getUserPhone(), msg);
        emailClient.send(order.getUserEmail(), "订单通知", msg);
        // 加站内信?这里也要加...
    }
    // refund()、complete() 里也有同样的通知代码...
}

加一个通知渠道(站内信),需要修改 create()cancel()refund()complete() 四个方法。通知渠道(短信/邮件/站内信)和业务事件(创建/取消/退款/完成)是两个独立维度,但在这个设计里它们纠缠在一起。 4 个事件 × 3 个渠道 = 12 个修改点,任何一个维度的变化都产生乘法级别的改动。

// Better: 两个维度正交——各自独立变化
public class OrderService {
    public void create(Order order) {
        orderRepository.save(order);
        eventPublisher.publish(new OrderCreatedEvent(order));
    }
}

// 通知维度独立于业务事件
public class OrderNotificationListener {
    private List<NotificationChannel> channels; // SMS, Email, InApp...

    @EventListener
    public void on(OrderEvent event) {
        String msg = event.toMessage();
        channels.forEach(ch -> ch.send(event.getUser(), msg));
    }
}

interface NotificationChannel {
    void send(User user, String message);
}

加站内信?实现一个 InAppChannel,注册到 channels 列表。改通知文案?只改 toMessage()两个维度完全解耦,变更成本从 O(m×n) 降到 O(1)。

检验正交性的方法:问自己"如果我要替换 X,需要改多少个与 X 无关的文件?" 答案不是零或接近零,就有正交性问题。

一句话总结:关注分离划出边界,单一职责定义边界的粒度,正交性验证边界是否有效。

管理依赖:最小耦合、迪米特法则、组合优于继承与开闭原则

前面说的是怎么划边界,这组原则说的是划完边界之后,边界两侧怎么打交道

最小耦合:依赖数量是复杂度的放大器

一个架构评审中的简单判断标准:打开一个 Service 类,数构造函数参数或注入的依赖。超过 7 个,这个类几乎一定有问题。

订单系统的 OrderService 依赖 15 个服务(UserService、ProductService、InventoryService、PricingService、DiscountService、PaymentService、LogisticsService……),意味着:

  • 15 个潜在的变更源——任一接口变更都可能要修改 OrderService
  • 15 个潜在的故障点——任一服务故障都可能导致订单创建失败
  • 测试时需要 mock 15 个依赖

耦合的代价不是线性增长,而是组合爆炸。 解决办法不是从 15 减到 14,而是重新审视职责划分——拆成 3-4 个更小的服务,每个只依赖 3-4 个接口。

迪米特法则:不要和陌生人说话

迪米特法则(Law of Demeter):一个对象只和直接朋友交流,不和朋友的朋友交流。

订单系统中获取收货地址邮编:

// Normal: 链式调用穿透整个对象图
String zipCode = order.getUser().getAddress().getCity().getZipCode();

这行代码把调用方和 Order、User、Address、City 四个类的内部结构绑死。后来地址模块重构,将 City 拆分为 RegionDistrict——所有使用 .getCity().getZipCode() 的地方全部编译报错。实际影响:47 个文件需要修改,涉及订单、物流、发票、报表四个模块。 一次结构重构变成了一场跨团队协调。

// Better: 告诉对象做什么,而不是向对象要数据再自己做
String zipCode = order.getShippingZipCode();

Order 内部怎么组织 User、Address、City 的关系是它自己的事。外部只知道"可以向 Order 要一个邮编"。当 City 拆分时,只需修改 Order.getShippingZipCode() 的实现——1 个方法,1 个文件。 47 个改动点缩减为 1 个。

迪米特法则的本质是信息隐藏:你不需要知道的结构细节,就不应该知道。知道得越多,耦合得越深。

组合优于继承:继承是最强的耦合

在所有代码关系中,继承是耦合最强的一种——子类不仅依赖父类的接口,还依赖其实现细节。父类改一个方法行为,子类可能就炸了。

// Normal: 继承——看起来合理,但扩展性差
class VIPUser extends User {
    int level;
    void login() {
        super.login();
        recordVIPLogin();
    }
}
// 问题:如果用户同时是 VIP 和企业用户怎么办?单继承困境。
// Better: 组合——会员类型变成可替换的策略
class User {
    String name;
    private MembershipStrategy membership;

    void login() {
        // ...基础登录逻辑
        membership.onLogin(this);
    }
}

interface MembershipStrategy {
    void onLogin(User user);
    double getDiscount();
}

VIP 和企业会员可以自由组合,新增会员类型不需要修改 User 类。用"有一个"代替"是一个",用接口契约代替实现继承。

里氏替换:继承关系的健康检查

里氏替换原则(LSP)为继承设定了底线:子类必须能在所有使用父类的场景中替代父类,而不引发错误或意外行为。

订单系统中有一个经典的反面案例:

// Normal: 违反 LSP——子类改变了父类的行为契约
class Rectangle {
    int width, height;
    void setWidth(int w)  { this.width = w; }
    void setHeight(int h) { this.height = h; }
    int area() { return width * height; }
}

class Square extends Rectangle {
    void setWidth(int w)  { this.width = w; this.height = w; }  // 正方形强制宽高相等
    void setHeight(int h) { this.width = h; this.height = h; }
}

看起来"正方形是矩形"在数学上成立,但在代码里 setWidth 会同时修改 height,调用方如果假设"设置宽度不影响高度"就会得到错误的面积。子类偷偷改变了父类方法的行为契约——这就是 LSP 违反。

当继承关系让你觉得不舒服时,通常意味着应该用组合(上一节的 MembershipStrategy)或重新建模(RectangleSquare 不应该是继承关系,而应该都实现 Shape 接口)。

开闭原则:新增功能不应该修改已有代码

订单系统的支付模块,两种设计:

// Normal: 每加一个支付方式都要改这个方法
public void pay(String channel, BigDecimal amount) {
    switch (channel) {
        case "alipay": /* ... */ break;
        case "wechat": /* ... */ break;
        // 加 Apple Pay?在这里加 case...
    }
}
// Better: 新增支付渠道 = 新增一个类,不碰已有代码
public interface PaymentGateway {
    boolean supports(String channel);
    PayResult pay(BigDecimal amount, PayContext ctx);
}

public class ApplePayGateway implements PaymentGateway {
    public boolean supports(String channel) {
        return "apple_pay".equals(channel);
    }
    public PayResult pay(BigDecimal amount, PayContext ctx) {
        // Apple Pay 的具体逻辑
    }
}

新增支付渠道变成新增一个类。支付宝和微信的逻辑不会因为加了 Apple Pay 而受到任何影响。

开闭原则的实现手段是抽象。 通过定义稳定的接口,让新的变化以"扩展"的形式加入系统,而不是以"修改"的形式侵入已有代码。开闭原则和正交性在这里形成呼应——它们本质上都在降低变更的传播范围。

依赖倒置:高层不应该依赖低层的实现细节

依赖倒置原则(DIP)是开闭原则和组合优于继承的底层支撑:高层模块不应该依赖低层模块,两者都应该依赖抽象。

订单系统中,OrderService 直接依赖 MySQLOrderRepository

// Normal: 高层直接依赖低层实现
public class OrderService {
    private MySQLOrderRepository repository = new MySQLOrderRepository();

    public void create(Order order) {
        repository.save(order);  // 直接依赖 MySQL 实现
    }
}

想把存储从 MySQL 换成 Elasticsearch(订单归档场景),或在测试中用内存数据库——做不到,因为 OrderServiceMySQLOrderRepository 焊死了。

// Better: 双方都依赖抽象
public interface OrderRepository {
    void save(Order order);
    Order findById(Long id);
}

public class OrderService {
    private final OrderRepository repository;  // 依赖接口,不依赖实现

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

// MySQL 实现
public class MySQLOrderRepository implements OrderRepository { /* ... */ }
// ES 实现
public class ESOrderRepository implements OrderRepository { /* ... */ }
// 测试用内存实现
public class InMemoryOrderRepository implements OrderRepository { /* ... */ }

依赖方向反转了:不再是高层依赖低层,而是低层实现依赖高层定义的抽象接口。切换存储引擎只需要替换注入的实现,OrderService 一行不改。

DIP 的判断标准:画出模块之间的依赖箭头,如果箭头从高层指向低层的具体实现类——就该引入接口反转依赖方向。

一句话总结:依赖越少越好,知道得越少越好,依赖方向朝向抽象,继承能不用就不用,扩展靠新增不靠修改。

原则之间的冲突与决策框架

这些原则之间会打架,这不是理论上的可能性,而是每天都在发生的事情。

DRY vs KISS

订单系统中,普通订单和预售订单的处理逻辑有 70% 相似。DRY 说抽出来,KISS 说各写各的。

// DRY 导向:抽共享 handler,但条件分支堆积
public Response handleOrder(Request req, boolean isPresale) {
    // 公共逻辑...
    if (isPresale) { /* 预售的特殊逻辑 */ }
    else { /* 普通订单的特殊逻辑 */ }
    // 更多公共逻辑...
    if (isPresale) { /* 预售的另一段特殊逻辑 */ }
}
// KISS 导向:各自独立,接受重复
public Response handleNormalOrder(Request req) { /* 普通订单完整逻辑 */ }
public Response handlePresaleOrder(Request req) { /* 预售订单完整逻辑 */ }

两个独立方法各 50 行,通常比一个 80 行充满条件分支的"统一方法"更容易理解和维护。

判断标准:重复的是"知识"还是"代码"。知识重复 → DRY 优先;代码碰巧像 → KISS 优先。

YAGNI vs 开闭原则

YAGNI 说"不要为未来设计",开闭原则说"要方便未来扩展"。调和方式:

不要构建功能,但要留下接缝。

不要在第一版就建一个"通用支付网关框架"支持二十种支付方式的动态注册和热加载——但至少把支付逻辑藏在一个接口后面。定义一个接口的成本很低,但它留下的扩展空间很大。接口是轻量的——不包含实现,不需要维护逻辑,不会引入 bug——但给未来的变化留了一扇门。

决策框架

面对原则冲突时,可以按以下路径做判断:

1. 这段代码的变化频率如何?
   → 高频变化:优先正交性和开闭原则,降低变更成本
   → 低频/稳定:优先 KISS,保持简单

2. 重复的是知识还是代码?
   → 同一条业务规则在多处出现:DRY 优先
   → 碰巧相似但各自演化:KISS 优先,容忍重复

3. 抽象带来的复杂度是否超过重复带来的风险?
   → 抽象后更难理解、更难修改:保持重复
   → 不统一会导致不一致性 bug:消除重复

4. 当前团队能否驾驭这个抽象?
   → 团队熟悉相关模式:可以做更精细的设计
   → 团队不熟悉:选择最直接的方案

没有一条原则在所有场景下都正确。 真正的工程判断力是在具体场景下感知到原则之间的张力,然后做出一个"足够好"的决定。这种判断力没有捷径,只能通过持续的实践和复盘来积累。

原则的适用阶段

不同项目阶段,原则的权重不同:

阶段 首要原则 次要原则 原因
MVP / 验证期 KISS、YAGNI Make It Work 快速验证业务假设最重要,不确定会不会活到明天,不要做多余的抽象
增长期 DRY、SRP、ISP 关注分离 团队扩大、需求增多,代码开始有维护压力,需要消除知识重复、理清职责边界
稳定期 / 规模化 正交性、OCP、DIP 迪米特、LSP 系统复杂度高,变更频繁但不能出事,需要降低变更的传播范围、保护已有功能

关键洞察:MVP 阶段过度应用 OCP/DIP 会拖慢交付速度;稳定期还在用 MVP 的"快糙猛"风格会导致技术债爆炸。原则的权重应该随项目阶段动态调整,而非一成不变。

回到开头的元原则:用最简单的方式解决当下的问题,同时不给下一个人制造麻烦。 好的代码不是最聪明的代码,而是下一个人能看懂、能改动、能扩展而不心惊胆战的代码。代码的读写比是 10:1——一段代码被阅读的次数远远超过被编写的次数。为维护者编码不是某一条原则,而是所有原则最终指向的方向。

加载导航中...

评论