ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

抽象不是设计出来的:从使用经验到订单系统的三步演化

抽象不是设计出来的:从使用经验到订单系统的三步演化 1. 抽象不是设计出来的是用出来的作为一个写了十多年 Python 的人我被问过最多的问题之一就是你是怎么想到要这样抽象的问这话的人往往刚学完 OOP看了一堆设计模式的书发现书上那些类图画得明明白白但一回到自己的项目里就蒙了——我的业务到底该抽哪些类接口该怎么定才标准我对这个问题的答案可能跟很多人想的不一样你根本不需要想到要抽象你只需要先用起来用着用着抽象自己就会浮现出来。所谓的抽象源于使用经验说得更直白一点就是你还没用过三只不同的支付渠道之前是设计不出一个好的支付接口的。你还没搬过三次家之前是规划不出一个真正好用的收纳系统的。这不是谦虚而是因为抽象的本质是归纳不是演绎——你得先有足够的样本才归纳得出共性。1.1 先反驳一个流行认知抽象不是画出来的市面上很多教材给学生传递了一种错觉写代码之前先画类图、先定义接口、先设计好继承体系然后照着图填代码这就是面向对象设计。我见过太多初学 OOP 的同事栽在这上面项目还没写几行先搞了一个三层继承的结构急着把各种可能的变化全预判进去结果半年后被迫全部推翻。为什么前瞻式设计在抽象这件事上特别不靠谱原因很简单**你还没用过具体实现就不知道该抽什么。**比如你只写过银行转账这一种支付方式你脑子里的支付接口大概长这样def pay(money): # 调银行接口但等你写了第二个——微信支付的二维码拉起——你才会发现原来支付这个动作在不同渠道里差距巨大微信的回调是异步的银行可能是同步的。等你写了第三种你可能才会意识到支付方式这件事真正稳定的部分其实是订单状态流转和回调通知而不是支付那头。这个过程就叫使用经验。你把三种渠道都实现一遍你会清晰地看到哪一部分代码是复制粘贴改改字段的哪一部分是每次重写逻辑的。复制粘贴的三次说明这是共性每次重写的那块说明这是差异。抽象不过是把复制粘贴了三遍的东西提取出来而已。1.2 抽象本质上是找共性、隔变化共性依赖样本量我可以给你一个非常朴素的观察方式下次你在写代码的时候发现自己又一次按下Ctrl C、Ctrl V先别急着骂自己反而可以停下来想一想——这已经是第几次粘贴了第一次出现重复代码是正常的不抽象先忍着。第二次出现相似逻辑你也先忍着但开始在脑子里给这片区域打标记。第三次出现同一结构的时候你就有了足够的信息量——这时候你再动手抽象往往能一次抽中。为什么非要等到第三次很多人不耐烦觉得第二次就可以抽了。我的经验是第二份重复代码往往只是看起来相似的陷阱两个需求可能只在字段级别一样行为上其实已经分化了。没有第三个样本你根本判断不了哪部分是真正的共性哪部分是偶然的相似。举一个很典型的例子两个函数都有一段算价格的逻辑但一个要算折扣、一个要算阶梯价如果你在第二次出现时就抽了个统一的价格计算方法结果就是函数里塞了一堆 if 分支反而把差异给搅混了。等到第三个样本出现你一眼就能看出折扣、阶梯价、会员价其实他们共享的是基于原价做变换这个骨架具体变换规则才是变化的。这样抽出来的接口才会稳。1.3 使用经验不只是用得多还包括被用得难受抽象还有一个很隐蔽的来源调用方的吐槽。很多好接口不是定义者设计出来的是被使用者逼出来的。我以前维护过一个内部组件最初结构是数据类加一堆游离的函数调用方每次都要按特定顺序调用四个函数才能拿结果结果一个季度内被三个团队同时投诉太容易漏步骤。后来我把四个函数收敛成一个上下文管理器让调用方只需写三行。这个设计改动不是我坐在那冥想出来的是使用者的痛苦反推出来的。所以如果你问我怎么培养抽象能力我的建议是少看设计模式书多去被使用。你把模块发出去让别人调用然后认真听他们的抱怨——抱怨这个字段为什么必须传、抱怨为什么有两个函数名这么像——所有抱怨里都藏着抽象的信号。因为这些抱怨背后都有一个共性问题**调用方对模块的预期跟模块暴露的形态不一致。**而抽象的目标恰恰就是让接口形态贴合大部分调用方的预期。2. 使用中的三个信号该动手抽象了说清楚抽象为什么要等使用经验之后下一个自然的问题就是**那使用经验积累到什么程度才算到了该动手的时机呢**这里我总结了三个观察点来自这些年实战里踩过的坑。你写代码时碰到任何一个就意味着该停下复制粘贴的手认真考虑抽象了。信号一同一段逻辑出现了第二次你开始想复制粘贴。严格来说第一次出现写得有点复杂的代码是正常的。第二次出现跟上次差不多的代码就要警惕了。第三次出现基本可以确定是抽象的红线。为什么是三条线的标准因为这一次次的重复实际上是在给你提供判断依据你看第一次和第二次的差异如果差异集中在几个字段赋值那说明共性是流程框架后续抽象统一流程即可如果差异涉及判断分支、算法逻辑都不一样了那说明现在的重复只是表面现象真正的共性和差异还没分清楚还需要再等一等。复制粘贴得越多你对这段代码里到底什么在变就越清楚。信号二业务里出现了清晰可感的变化点。这个信号比复制粘贴更值得留意。所谓变化点是指你的业务过程中出现了一个位置大部分流程都是一样的只有其中一小段在不同场景下有不同表现。最常见的例子就是数据源切换同一个报表功能今天连 MySQL明天连 Oracle后天要支持文件导入。筛选、计算、格式化的逻辑全一样就是读数据这一步在变。再比如日志输出本地开发的时候往终端打测试环境打到文件线上要采集到日志中心——其他都一样只有日志去向这一截在变。这种变化点的存在早期靠 if 分支还能勉强支撑加一两个场景还好加到第五个场景代码里面全是如果走这个数据源、否则走那个数据源的分流根本没法维护。抽象在这时候的作用就是把这一个变化点封装到接口后面让调用方完全无感。信号三数据和操作开始绑定在一起了。第三个信号比较微妙但极常见你的代码里开始频繁出现一些数据字典、数据列表被若干个不同的函数转来转去每个函数都对它做一小步操作。比如user { name: 张三, email: zhangsanexample.com, level: 3, } def send_welcome_email(user): ... def update_level(user, new_level): ... def check_permission(user, action): ...这三个函数都以 user 字典作为入参彼此之间没有任何约束谁都可以在任何地方塞一个缺字段的字典进来。运行时报错了你还要一层层追查是哪个调用方漏了字段。这种数据裸奔 一堆围绕它的函数的形态实际上就是在告诉你**这块数据应该有自己的管家了。**把它收进一个类把相关操作变成这个类的方法数据和行为从此绑定外部再也无法绕过约束去操作它。我把这三个信号整理成一个简单的自检表方便你写代码的时候对照信号观察点应对时机抽象动作重复复制同一函数或逻辑出现在两处以上出现第三次时提取公共函数或基类方法变化点流程相同、中间一小段在变出现了两种不同分支把变化段封装为接口/策略数据裸奔结构被多个函数转手、无归属出错定位困难时收进类附带行为约束入口注意这三个信号有时候是叠加出现的。一个模块连续出现两个信号你基本可以确定这块确实该动手了。3. 订单系统演化实录从写死到分层只用了三步光说信号可能还是有点抽象我用一个非常常见的业务场景——订单系统——完整走一遍从具体到抽象的过程。这是我在实际项目里总结出来的通用三步演化法你可以直接拿去套用。3.1 阶段一先把它写死老老实实一个函数搞定假设你的产品一开始只有一种订单普通商品订单。最朴素、最直接的写法长这样def create_order(user_id, product_id, quantity): # 1. 校验商品状态 product get_product(product_id) if not product.is_available(): raise ProductNotAvailableError(product_id) # 2. 算价格 price product.price * quantity # 3. 扣库存 reduce_stock(product_id, quantity) # 4. 生成订单记录 order_id uuid4().hex save_order(user_id, product_id, quantity, price, order_id) # 5. 发通知 notify_user(user_id, f您的订单 {order_id} 已创建) return order_id我知道有些同学看到这里会本能地想这个函数太长、职责不单一要不要拆我的答案是**不着急。**你手里只有一个订单类型你不知道这个业务未来会往哪个方向演化这时候强行拆成五个小函数只会让你的代码凭空多出五个还不确定边界的抽象。先跑起来先让业务验证这是企业里做真实项目的节奏。3.2 阶段二第二个相似需求出现先拆共用部分和差异部分很快产品经理来了说会员下单时要按会员等级打折。这时候你有了第二个订单类型会员订单。仔细看一眼它跟普通订单相比90% 的逻辑都一样只有算价格这一步不同——要按会员折扣计算。这时候你已经有第一次和第二次的样本了可以开始做第一次抽象。但请注意这一步的抽象粒度很关键只提取雷打不动的公共部分差异部分坚决不强行统一。def _validate_product(product_id, quantity): product get_product(product_id) if not product.is_available(): raise ProductNotAvailableError(product_id) return product def _reduce_stock(product_id, quantity): reduce_stock(product_id, quantity) def _create_order_record(user_id, product, quantity, price): order_id uuid4().hex save_order(user_id, product.id, quantity, price, order_id) return order_id def _notify(user_id, order_id): notify_user(user_id, f您的订单 {order_id} 已创建)然后两个具体的订单函数各自调用这些公共函数只留下算价格这一段各自实现def create_normal_order(user_id, product_id, quantity): product _validate_product(product_id, quantity) price product.price * quantity _reduce_stock(product_id, quantity) order_id _create_order_record(user_id, product, quantity, price) _notify(user_id, order_id) return order_id def create_member_order(user_id, product_id, quantity, member_level): product _validate_product(product_id, quantity) price product.price * quantity * member_discount(member_level) _reduce_stock(product_id, quantity) order_id _create_order_record(user_id, product, quantity, price) _notify(user_id, order_id) return order_id这一步做完了代码里不再有重复两个新函数都变短了。这就是第二次使用阶段该有的动作。有些学习者会问为什么不直接在第一次就写全套类结构还是那句话第一个版本的时候你连第二个订单类型的面貌都不知道设计的类容易出现自以为抽象、实则抽象错方向的问题。3.3 阶段三第三个样本出现才谈类和多态时间又过了两个迭代团队要上团购订单——多人下单、价格是阶梯价。团购订单跟前面两个的区别更大一些计算规则的差别越来越多三个函数虽然共用了公共小函数但验证、计价、扣库存、生成订单、通知这个整体流程在三个函数里反复出现代码的骨架重复率已经很高了。这时候才到了引入类 多态的时机。为什么是现在因为你自己已经有了三个关于订单怎么创建的真实样例你可以回头看它们发现共性是那五步流程差异都集中在calc_price、validate等少数行为上。差异点你已经摸清了抽象成基类才会命中靶心from abc import ABC, abstractmethod class OrderCreator(ABC): def __init__(self, user_id, product_id, quantity): self.user_id user_id self.product_id product_id self.quantity quantity def validate(self) - None: product get_product(self.product_id) if not product.is_available(): raise ProductNotAvailableError(self.product_id) self.product product abstractmethod def calc_price(self) - float: 不同订单类型各自实现计价逻辑 def create(self): self.validate() price self.calc_price() reduce_stock(self.product_id, self.quantity) order_id uuid4().hex save_order(self.user_id, self.product.id, self.quantity, price, order_id) notify_user(self.user_id, f您的订单 {order_id} 已创建) return order_id class NormalOrderCreator(OrderCreator): def calc_price(self) - float: return self.product.price * self.quantity class MemberOrderCreator(OrderCreator): def __init__(self, user_id, product_id, quantity, member_level): super().__init__(user_id, product_id, quantity) self.member_level member_level def calc_price(self) - float: return self.product.price * self.quantity * member_discount(self.member_level) class GroupOrderCreator(OrderCreator): def __init__(self, user_id, product_id, quantity, group_size): super().__init__(user_id, product_id, quantity) self.group_size group_size def validate(self) - None: super().validate() if self.quantity self.group_size: raise GroupOrderTooSmallError() def calc_price(self) - float: return self.product.price * self.quantity * group_discount(self.quantity)调用方统一走create()外部代码对每个子类的差异是彻底无感的。到了这一步你真正获得了 OOP 抽象的好处新增第四种订单时比如预售订单只需要继承OrderCreator、实现自己的calc_price主流程一动不用动。这个加一个子类就能扩展的能力恰恰是前面两个阶段积累使用经验换来的。3.4 这个演化过程揭示的抽象节奏复盘上面三步你会发现一个规律**抽象是分层的不是一次到位的。**第一次抽象只收敛了公共函数第二次抽象才收敛到类结构。而且每一次抽象时机的出现都对应一个真实的业务需求落地而不是架构师坐在那里的脑补。很多人一上来就把第三步做完导致的问题是你根本没有三个真实案例作为依据抽象基类的方法签名很容易定错。我见过一个项目上线前架构师就把订单、退款、物流、对账全部做成了抽象类结果三个月之后退货流程一大改所有抽象层的接口被迫跟着改下游子类全部重写那个宏伟设计反而成了最大的技术债。4. 好的抽象长什么样一张可以自查的清单假设你按照上面的节奏抽了一轮抽象那怎么判断你抽得好不好这里有一个容易让人迷失的点很多人喜欢用符不符合某种设计模式类图画得帅不帅来评判抽象我的标准反而非常朴素就三条。4.1 换实现不换调用方这叫稳最直观的标准底层实现换了一遍调用方代码不用动一行说明你把变化点封对了。举个例子你的日志模块最开始是往文件里写的统一提供了log.info(...)、log.error(...)这样的接口。某个迭代里线上要求把日志推送到日志中心你只需要在模块内部把写文件的实现替换成走网络推送业务代码毫无感知。这就说明当初的抽象把日志输出方式这个变化点成功隔离了。反过来如果每次底层一换调用方就要跟着改参数、改调用顺序那就说明抽象层把不该暴露的细节暴露出来了。所谓的抽象好坏在这一点上跟开闭原则是同一个意思对扩展开放、对修改关闭——这个修改关闭指的就是调用方代码不被动。4.2 加一个新变化只需要增不需要改第二点同样关键而且可以直接自查你新增一个具体类/实现时要不要动已有的父类或者兄弟类好的抽象新增实现就像插一根新的充电线——插头插进同一个接口通电完成原来的线一根都不用动。不好的抽象你新加一个礼品卡支付发现父类的authorize方法签名要加一个参数于是所有已存在的支付子类都得跟着改签名这就说明抽象抽取的共性并不到位。实操中我给自己定了一条规则同一个抽象接口下**只要出现第三次新增子类导致老代码改动就必须回头重新审视抽象边界而不是继续打补丁。**因为在这个位置共性没有真正被提炼出来而是被硬压进了一个共同的接口。4.3 抽象的名字必须精准这第三条最容易被忽略但我认为是长期维护里最能暴露问题的抽象层的命名要能精确反映它承载的唯一一种变化。通常接口暴露的是业务领域的概念名像DataSource、PaymentChannel、NotificationSender一个名字对应一种职责。如果你发现自己给类起名字的时候很纠结这个类既要处理支付又要处理发票还得带一点物流查询——那不用怀疑这个抽象的边界一定是歪的。名字纠结本质上是因为抽象承载了多种变化维度脑子都感知到了但没有落成一个清晰的词。Python 标准库里其实有很多值得学习的抽象定义。比如collections.abc下的几个抽象基类Iterable约束能被迭代这一个能力Container约束能判断成员包含关系这一个能力Sized约束有长度这一个能力。每一个抽象基类都对应一种单一的、清晰的能力。一个具体的类可以用class MyCollection(Iterable, Container)来组合但每一个抽象层本身都必须是单一概念的。这就是抽象命名的教科书。自查项具体动作理想状态变更隔离替换底层实现不碰调用方代码调用方零改动扩展成本新增一个子类/实现只新增不修改已有代码命名精度问自己这个抽象承载了几种变化一种名字能直接说清子类落地检查已有子类中是否存在用不上的方法没有空方法、没有 NotImplementedError5. 抽象过度的三种病以及止损实操讲完什么时候该抽象还必须讲什么时候不该抽象。因为 OOP 这条路上走过头比走不到更常见。我见过太多团队吃过抽象过度的亏甚至我自己也亲手埋过这种雷。这里给你盘点三种最常见的抽象病。5.1 病一为未来预留的抽象这种病的典型症状是项目里只有一个实现但代码外面套了三层抽象。你问作者为什么这么做他会说以后可能要换方案嘛先留好扩展点。我在一个内部项目里见过最典型的例子缓存模块只有一个 Redis 实现但设计了CacheInterface抽象基类还有一个工厂函数负责根据配置选择缓存实现。听起来挺合理对吧问题是两年代码运行下来从来只有 Redis 这一条路被走到。结果就是每次有人要改缓存逻辑都得穿过三层源码才能定位到 Redis 那个类。这个抽象不是帮了忙而是变成了纯粹的阅读障碍。止损动作很简单先把抽象层下的唯一实现找出来确认真的只有一个。把工厂函数改成直接使用实现类删掉抽象基类。把抽象基类里唯一实现中用不到的方法一并删掉。运行测试确认功能不变。我知道有人舍不得删觉得万一以后要用呢我的回答是**抽象是可逆的操作担心以后要用等以后真的出现了第二个实现你再用第 3 章的三步法重新抽象也不迟。**留着过度抽象等于让所有同事每天为你的万一买单。5.2 病二全家桶抽象第二种病更隐蔽——一个抽象基类聚合了多种本不相关的能力。最典型的现象是子类里出现一堆raise NotImplementedError。子类一多你翻代码时看到几个空方法、几个抛异常的 stub就可以断定你抽错边界了。举一个实际例子一个DataSource抽象基类定义了connect()、read()、write()、parse()、validate()五个方法。设计者当时觉得所有数据源都要做这些事。结果做日志数据源时这个子类只需要read()和parse()connect()根本不存在、write()永远不让用。于是你被迫在子类里这样写class LogDataSource(DataSource): def connect(self): raise NotImplementedError(日志数据源无需连接) def write(self, data): raise NotImplementedError(日志数据源不可写) ...这种代码一看就是病。止损的方法是拆分抽象把DataSource拆成ReadableDataSource、WriteableDataSource、ConnectableDataSource多个单一能力的接口子类组合需要的部分。这跟 Python 标准库collections.abc的做法是一脉相承的——小而独立的抽象通过组合使用这是维护性最好的形态。5.3 病三接口不稳定反复改签名第三种病出现在抽象时机太早的时候抽象层定义了两周接口签名就改了三次所有子类跟着来回改。与其说这是抽象问题不如说是你根本还不知道业务长什么样就急着定了边界。我处理过最令人抓狂的一个案例某个支付对接模块的抽象基类叫PayProvider里面定义了authorize(token, amount)、capture(token, amount)。等到真正接入微信支付时发现微信支付是用户扫码、异步回调根本没有预授权-捕获这两个动作。于是团队硬给微信这个实现加了个空 shell最后整个抽象名存实亡。面对这种病止损动作跟你想的相反——**不是再包一层抽象而是先拆掉抽象。**把接口去掉直接让具体的微信支付类、支付宝类裸奔一段时间等业务跑顺、真实使用经验攒到三四个渠道了再回头看哪些方法是各个渠道都有的重新做抽象。这恰恰呼应了第 1 章的核心观点抽象必须建立在真实使用经验之上没有经验就强行抽象接口一定翻车成筛子。5.4 止损的通用步骤不管中了哪种病止损流程我都建议按这个顺序走停止扩散先冻结该项抽象下新增的实现避免病态范围扩大。写行为测试把现有功能用测试固定住确保重构不改变外部行为。逐层拆除抽象每次拆一层就运行测试观察调用方是否有感知。真有必要保留的抽象会在一层层的拆除中自动暴露出来。记录教训把这段经历写成代码注释或团队文档说明为什么这里不抽象防止后来人又拍脑袋加上去。说起来有点残酷但我在十个项目里看到最后能被时间验证留下来的抽象基本都是被三个使用场景逼出来的而那些启动阶段就精心设计好的抽象多数变成了后续迭代路上的绊脚石。给所有人的一句总结式体会这篇聊下来最想传达的其实是我自己长期实践得出的一句话**做项目的时候别急着当设计师先当好使用者——包括自己代码的使用者、模块接口的使用者以及业务变化的使用者。**把具体的东西反复用起来把过程里的不适和重复当成信号抽象就会在你手头自然成型并且比任何提前规划都更贴合真实业务。我至今还在自己的项目里维持一个习惯每个复杂模块旁边专门留一段注释写清楚这个模块目前只有一两个使用场景暂不抽象当第三个场景出现时应该按下述方向做抽象。这份清单既是给自己的提醒也是在给团队传递一个共识——抽象不是越早越好、越多越好而是越用过越好。这个方法也成了我做技术评审时推荐给同事频率最高的一条经验希望对你同样管用。
返回列表