无尘阁日记

无尘阁日记

面条式代码
silcano

凌晨一点,我在改一个「就加个小功能」的需求。

打开那个文件,两千三百行。滚到第 1400 行附近,找到要改的地方。看完前后三十行,我大概明白这段代码在干什么,但我不敢打包票——中间有三个 if 进去之后直接 return,两个 return 埋在 foreach 里,还有一个变量,在不同分支里被赋成了完全不同的意思。

这就是面条式代码。英文 Spaghetti Code,中文一般译成「面条式代码」,也有人叫「意大利面代码」——说的都是同一坨东西。

这词不是骂人,是个正经的技术名词,七十年代末就有了。它跟当年那场「该不该用 goto」的论战基本是同一批产物:那会儿大家刚反应过来,控制流一旦允许随便跳,代码就没法顺着读了——你只能从上往下硬啃,啃到哪算哪。

这篇我不想给你讲「高内聚低耦合」。那些词都对,但对完你该写还是写。我想聊的是:这碗面到底是怎么煮出来的,以及一个更不中听的问题——它真的是技术问题吗。

· · ·

一、先看看这碗面长什么样

我贴一段。不用真实项目代码,那太长也太脏。我把一个下单接口按「最常见的写法」重写一遍,大概这样:

function createOrder($req)
{
    if (empty($req['uid']) || empty($req['items'])) {
        return ['code' => 1, 'msg' => '参数错误'];
    }

    $user = Db::name('user')->where('id', $req['uid'])->find();
    if (!$user) return ['code' => 2, 'msg' => '用户不存在'];
    if ($user['status'] == 0) return ['code' => 2, 'msg' => '账号已冻结'];

    $total = 0;
    foreach ($req['items'] as $item) {
        $goods = Db::name('goods')->where('id', $item['gid'])->find();
        if (!$goods) return ['code' => 3, 'msg' => '商品不存在'];
        if ($goods['stock'] < $item['num']) return ['code' => 3, 'msg' => '库存不足'];

        $price = $goods['price'];
        if ($user['level'] == 2) $price = $price * 0.95;
        if ($user['level'] == 3) $price = $price * 0.90;

        if ($goods['activity_id']) {
            $act = Db::name('activity')->where('id', $goods['activity_id'])->find();
            if ($act && $act['start'] < time() && $act['end'] > time()) {
                $price = $act['price'];   // 活动价直接覆盖,不叠加
            }
        }

        $total += $price * $item['num'];
        Db::name('goods')->where('id', $item['gid'])->dec('stock', $item['num'])->update();
    }

    if (!empty($req['coupon_id'])) {
        $coupon = Db::name('coupon')->where('id', $req['coupon_id'])->find();
        if ($coupon && $coupon['uid'] == $req['uid']
            && $coupon['status'] == 0 && $coupon['expire'] > time()) {
            $total -= $coupon['amount'];
            Db::name('coupon')->where('id', $coupon['id'])->update(['status' => 1]);
        }
    }

    $orderId = Db::name('order')->insertGetId([
        'uid' => $req['uid'], 'total' => $total, 'created' => time(),
    ]);

    (new Sms())->send($user['mobile'], '下单成功,实付 ' . $total);
    Db::name('user')->where('id', $req['uid'])->inc('points', (int)$total)->update();

    return ['code' => 0, 'data' => ['order_id' => $orderId]];
}

这上面四十行,是删过一半的版本。真实项目里它三百行,中间还有积分翻倍、老带新返现、指定商品赠品、跨境税、发票信息。

它能跑。测试过,线上跑了两年。业务方挺满意。

那问题在哪?

不在「代码不好看」。审美不解决任何问题,逼着一群人把缩进调整齐只会让他们更讨厌你。

问题在于:你没办法在不读完全部的前提下,判断改某一行会影响到哪里。

把这段拆开看,至少有五件事被搅在同一根线上:

· 校验——什么算一个合法的请求
· 定价——这一单该收多少钱
· 库存——扣不扣、什么时候扣
· 落库——订单怎么存下来
· 副作用——发短信、加积分、通知仓库

五件事,一条线。它们之间没有边界,只有先后顺序。

这就是面条的本质。不是乱,是只有顺序,没有结构。一根面条从头串到尾,你要吃中间那口,得先把前面全捋一遍。

一条线写起来最快。代价是:任何一个需求只要碰到其中一件事,你就得把整条线重新走一遍。

举个特别具体的。产品说:会员折扣改成阶梯的,按月消费额算。

听着是「改定价」。但你真动手时,会撞上四个问题:

月消费额从哪来?现在没人算这个数,得新加一处统计。
活动价和会员折扣谁优先?原代码里是活动价直接覆盖,$price = $act['price'],连个判断都没有,没人说得清当年为什么这么定。
优惠券是折后减还是折前减?原代码是折后减。为什么?不知道,可能是写的时候没想到。
用户退款了怎么反算?这段代码不存在,因为当时没这个需求。

四个问题,三个不在「改定价」这件事的范围内。这就是面条的税——它不写在报价单上,但你每一单都在付。

· · ·

二、它是怎么长出来的,过程特别合理

说句公道话:几乎没有人是坐下来决定「今天我要写一坨面条」的。

它是这么长出来的。

第一次,需求是「下单」。你写了校验、算钱、扣库存、写库、发短信。三十行,清清爽爽。这会儿它一点都不面条。

第二次,「加个优惠券」。你在算钱后面插一段。合理。

第三次,「加会员等级折扣」。在算钱前面插一段。合理。

第四次,「活动价优先于会员价」。改一行 if。合理。

第五次,「库存要防超卖,加个锁」。外面包一层。合理。

第二十次,你已经不记得这函数完整长什么样了。每一次改动都是局部最优,每一次都对,加起来就是灾难。

这就是局部最优的累积。没什么高深理论,说白了:每一步都没走错,但走到了一个不该去的地方。

爬山最容易这样。你一直盯脚下,哪边高往哪挪,最后会稳稳站在一个小山包上——旁边就是悬崖,而对面那座主峰,你从一开始就没看见。

· · ·

三、三个比「代码丑」更硬的信号

我不建议用「看着乱不乱」来判断。太主观,而且容易把团队带进排版强迫症的沟里。用这三个。

第一,改一行要读多少。
你说「改个会员折扣」,大概要读多少行才敢下手?三十行以内,正常。三百行,那你是在面条里捞面。

第二,你敢不敢删。
函数里有段代码,看不出谁在用,也没注释,日志里搜不到。你敢删吗?不敢,就说明这段代码的真实依赖关系,已经超出任何一个人的认知范围了。这比「乱」危险得多——不是没人整理,是没人知道它为什么还在这儿。

第三,上线要不要挑时间。
正常的功能发布,应该随时能发。如果你们有「周五不发」「大促前一周冻结」「发版要留人盯两小时」这种规矩,那说明系统的可预测性已经很低了。这不是流程谨慎,这是给代码的不确定性买保险。

第三条我再多说一句。很多团队把「发版要盯」当敬业的象征,还写进 OKR。其实那是账单。你在用人肉补系统的窟窿,补得越熟练,窟窿就越没人管。

· · ·

四、它为什么会传染

因为复制粘贴太便宜了。

下一个需求「预售订单」,最省事的写法是什么?把 createOrder 复制一份,改名 createPresaleOrder,改两行价格逻辑。

十分钟搞定,当天上线,需求方很满意,你也很有成就感。

然后这两份代码开始各自演化。半年后它们的定价逻辑不一样了,谁也不知道哪个才是对的。再过半年,第三份出现。

还有一个原因更隐蔽:破窗。

第一段丑陋的代码出现时,大家心里不舒服,会绕着走。等这样的地方攒到二十处,新来的人看到的结论就是「这项目的风格就这样」。他会照着写。

代码风格从来不是被规定出来的,是被模仿出来的。你容忍了什么,什么就会变成标准。

· · ·

五、别搞「重构周」

我见过太多团队,痛定思痛,决定停两周业务,专门重构。

基本都失败了。原因不复杂:重构不产生需求价值。两周后业务方问「这两周做了什么」,你只能说「代码变好了」。而「变好了」是没法验收的。

而且大规模重构本身风险极高。没测试、没监控、没回滚预案,把两千行拆成八个类,上线当天挂掉——从此「重构」这个词在这个团队里就成了脏话,谁提谁是想偷懒。

那怎么办?我的建议比较土,但真能用。

只在你必须动的地方动手,把那一围绕出边界来。

你要加「阶梯会员折扣」?好。别改整条线,只把「算钱」那一段抽出来,变成一个独立的东西,输入是(商品 + 数量 + 用户 + 优惠券),输出是(应付金额 + 明细)。其他一律不碰。

这个改动两小时能做完,风险极低,而且立刻就有回报:下次改定价,你只碰这一个地方。

这种做法有个名字,叫「绞杀者模式」(Strangler Fig)。原意是榕树缠住宿主树,一根枝一根枝地长上去,慢慢把宿主的阳光全占了,最后宿主死了,榕树还在。它不是一斧子砍倒——砍倒的过程中,系统就没了。

缠一根,再缠一根。缠得多了,原来那棵树自然就不在了,而且中间任何一天,系统都是能跑的。

顺手再补两条特别小的,见效快:

一,新代码别复制粘贴。宁可多写一个函数,也别复制。复制出去的那一份,半年后就是你的债主。

二,给每个「不知道为什么在这」的代码块补一行注释,写清它防的是什么。哪怕只写「2023 双十一临时加的,防重复下单,不确定还有没有用」。这句话本身就是资产——它把下一个人的「不敢删」变成了「可以去问」。

· · ·

六、最后一个判断,可能不太好听

面条式代码,绝大多数情况下不是技术问题。

一个团队为什么会写两千行的下单函数?不是因为他们不会分层。是因为:

需求是「明天上线」。
排期是「两个人三天」。
考核是「本周上线的需求数」。
谁提重构,谁就是在拖进度。

在这个约束下,写面条是理性选择。你把任何一个技术顶尖的人换进来,他也会写面条——因为他没时间不写。

所以想让代码真的变好,光骂代码没用。得动那个让面条成为最优解的环境:给重构留出能计工时的空间,把「改一处不崩」当成可以交付的成果,别让「能跑就行」成为唯一的验收标准。

这话说起来比写代码难多了。但不说清楚,你就只能一直在凌晨一点改那两千行。

· · ·

收尾

面条式代码这东西本身没错。饿的时候随手煮一碗,五分钟出锅,这没什么可羞耻的。原型、临时脚本、一次性的数据修复,就该快,就该这么写。

问题只出在一种情况:你打算在这个厨房里干三年。

那灶台就得收拾。不用一次收拾完,今天先把切菜那块板擦干净就行。