胶水代码:分析、策略与利弊
基于 O2O 平台政策汇总项目的真实解剖
一、什么是胶水代码 胶水代码 指主要作用是连接不同系统/API/库、自身几乎不产生核心业务价值的代码。典型特征:
特征
说明
调用外部服务
核心能力来自第三方 API,项目只负责传参数和收结果
格式转换
A → B → C 的数据搬运,没有注入新的业务价值
无领域建模
数据从头到尾以原始形态传递(dict → dict → dict)
模块间复制
同样的解析逻辑重复出现在多个文件中
意图隐匿
代码只表述”怎么做”,不表述”为什么这么做”
本项目的胶水成分解剖 1 2 3 4 5 6 7 8 整个数据链路: assets/*.png ──→ OCR API ──→ 解析 ──→ CSV ──┬──→ Excel (openpyxl) ├──→ JSON (json.dumps) └──→ HTML (模板拼接) 胶水集中在:OCR 调用、CSV 读写、多格式导出。 非胶水在于:校验规则、审计逻辑、OCR 后处理的业务归一化。
量化结论:约 55% 胶水代码。
模块
胶水程度
定性
ocr_o2o.py
75%
核心动作是调腾讯云 API。非胶水部分:表头别名归一化、平台列消歧、产品白名单过滤
watcher_o2o.py
95%
标准 watchdog 模式,零业务逻辑
config.py
95%
通用 YAML + 环境变量加载
export_json.py
70%
CSV → JSON 映射,含商品分类和日期格式化
export_html.py
50%
前半段数据转换,后半段生成完整交互式 SPA 页面
export_excel.py
90%
CSV → Excel 加样式
verify.py
20%
纯领域逻辑 :数值一致性校验、DG 联动检查
audit.py
15%
纯业务分析 :跨平台最低价发现、风险提示
o2o_cli.py
65%
CLI 命令路由和流程编排
二、胶水代码的根源 胶水代码的产生不是技术能力问题,而是开发顺序问题 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 典型的胶水项目生长过程: 需求1:"帮我识别这张图片里的表格" → 查腾讯云文档,复制示例代码,调 API,拿到 JSON ✓ 需求2:"数据太多了,存到 CSV 方便编辑" → 把 JSON 字段映射成 CSV 列名,写文件 ✓ 需求3:"业务想看 Excel" → CSV 读到 openpyxl,加个边框和表头颜色 ✓ 需求4:"还需要一个网页版本方便分享" → 再读一遍 CSV,拼 HTML 字符串 ✓ 需求5:"再加个校验,免得数据出错" → 再读一遍 CSV,写校验逻辑 ✓ 需求6:"跨平台价格对比" → 再读一遍 CSV,写审计逻辑 ✓
问题在哪? 每个步骤都直接把 CSV 当作数据模型。Dict[str, str] 在管道里从头流到尾,没有任何一个环节说清楚”这份数据本质上是什么”。结果是:
_parse_price() 在 3 个文件里重复定义
_get_category() 在 2 个文件里重复定义
_extract_period() 在 2 个文件里重复定义
改一个字段名要改 5 个文件
新人要读完 6 个脚本才能理解”一行数据到底有哪些字段”
本质原因:没有在代码中显式定义领域模型。
三、避免胶水代码的五个策略 策略 1:先建模,后写管道 在任何 IO 代码之前,先定义数据是什么:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 from dataclasses import dataclassfrom typing import Optional from enum import Enumclass Platform (Enum ): JD = "京东到家" ELEME = "饿了么" MEITUAN = "美团" DOUYIN = "抖音" TMALL = "天猫" class ProductCategory (Enum ): IPHONE = "iPhone" IPAD = "iPad" MAC = "Mac" WATCH = "Watch" AIRPODS = "AirPods" ACCESSORY = "配件" BEATS = "Beats" OTHER = "其他" @dataclass class Offer : """一条 O2O 优惠记录——领域核心实体,不依赖任何外部库""" platform: Platform product: str spec: str alp: int final_price: Optional [int ] discount_amount: Optional [int ] is_installment: bool dealer_burden: Optional [int ] platform_burden: Optional [int ] limit_policy: str dg_related: bool start_date: str end_date: str note: str = "" def discount_via_price (self ) -> Optional [int ]: """ALP - 到手价 = 预期优惠金额""" if self .alp and self .final_price: return self .alp - self .final_price return None def discount_is_consistent (self ) -> bool : """优惠金额 是否与 ALP - 到手价 一致(容差 ±1)""" expected = self .discount_via_price() if expected is None or self .discount_amount is None : return True return abs (self .discount_amount - expected) <= 1 def burden_total (self ) -> Optional [int ]: """经销商承担 + 平台承担""" if self .dealer_burden is not None and self .platform_burden is not None : return self .dealer_burden + self .platform_burden return None def burden_is_consistent (self ) -> bool : """承担合计 是否与优惠金额一致""" total = self .burden_total() if total is None or self .discount_amount is None : return True return abs (total - self .discount_amount) <= 1 def is_price_valid (self ) -> bool : """到手价不应高于 ALP""" if self .alp and self .final_price: return self .final_price <= self .alp return True def category (self ) -> ProductCategory: """根据产品名自动归类""" product = self .product if product.startswith("iPhone" ): return ProductCategory.IPHONE if product.startswith("iPad" ): return ProductCategory.IPAD if product.startswith("MacBook" ): return ProductCategory.MAC if product.startswith("Apple Watch" ): return ProductCategory.WATCH if product.startswith("AirPods" ): return ProductCategory.AIRPODS if product.startswith("Beats" ): return ProductCategory.BEATS if any (k in product for k in ["充电器" ,"保护壳" ,"充电线" ,"EarPods" ,"AirTag" ]): return ProductCategory.ACCESSORY return ProductCategory.OTHER @property def is_dg (self ) -> bool : return self .dg_related @property def needs_limit_policy (self ) -> bool : """DG 商品必须有对应的限购政策""" return not self .is_dg or bool (self .limit_policy)
对比改造前 :verify.py 中散落的 _extract_number + abs(discount_num - expected_discount) > 1,全部收敛为 offer.discount_is_consistent() 一行调用。
策略 2:端口-适配器架构 核心规则:领域核心不 import 任何外部库。 所有文件读写、API 调用、格式导出都是外围适配器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ┌──────────────────────┐ OCR API ──────────→│ o2o/adapters/ │ │ ocr_adapter.py │──────┐ └──────────────────────┘ │ ↓ ┌──────────────────────┐ ┌──────────┐ ┌──────────────────────┐ 手动录入 ──────────→│ o2o/adapters/ │→│ 核心领域 │→│ o2o/adapters/ │──────→ CSV │ manual_adapter.py │ │ Offer │ │ csv_adapter.py │ └──────────────────────┘ │ 规则 │ └──────────────────────┘ │ Auditor │ ┌──────────────────────┐ │ │ ┌──────────────────────┐ │ o2o/adapters/ │←│ │←│ o2o/adapters/ │──────→ Excel │ excel_adapter.py │ └──────────┘ │ html_adapter.py │ └──────────────────────┘ └──────────────────────┘ │ ↓──────→ HTML
抽象的端口定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 from abc import ABC, abstractmethodfrom typing import List from o2o.domain import Offerclass OfferSource (ABC ): """数据来源的抽象——不关心是 OCR 还是人工录入""" @abstractmethod def fetch (self ) -> List [Offer]: ... class OfferSink (ABC ): """数据输出的抽象——不关心导出格式""" @abstractmethod def write (self, offers: List [Offer] ) -> None : ... class OfferValidator (ABC ): """校验规则的抽象""" @abstractmethod def validate (self, offer: Offer ) -> List [str ]: """返回错误信息列表,空列表表示通过""" ...
适配器负责脏活:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class TencentOCROfferSource (OfferSource ): """腾讯云表格识别 V3 适配器——所有 OCR 特有的脏活局限于此""" def fetch (self ) -> List [Offer]: raw_tables = self ._call_api() records = parse_table(raw_tables[0 ]) return [self ._to_offer(r) for r in records if self ._is_valid_product(r)] def _to_offer (self, raw: dict ) -> Offer: return Offer( platform=Platform(resolve_platform(raw.get("平台" , "" ))), product=raw.get("活动商品" , "" ).strip(), spec=raw.get("商品规格" , "" ).strip(), alp=parse_int(raw.get("ALP(最低sku价)" , "" )), final_price=parse_int_or_none(raw.get("到手价(最低sku价)" , "" )), discount_amount=parse_int_or_none(raw.get("优惠金额" , "" )), is_installment="免息" in str (raw.get("优惠金额" , "" )), dealer_burden=parse_int_or_none(raw.get("经销商承担" , "" )), platform_burden=parse_int_or_none(raw.get("平台承担" , "" )), limit_policy=raw.get("限政策" , "" ).strip(), dg_related=raw.get("DG Related" , "" ).strip().upper() == "Y" , start_date=raw.get("开始时间" , "" ).strip(), end_date=raw.get("结束时间" , "" ).strip(), )
效果 :
换 OCR 服务商(百度 OCR、阿里 OCR)→ 只改这一个适配器
测试时用 FakeOfferSource 返回固定数据,不调云 API
核心逻辑的单元测试不需要任何外部依赖
策略 3:管道声明式配置 改造前——命令式硬编码(当前 o2o_cli.py):
1 2 3 4 5 6 7 def cmd_build (args ): result = run_verify(csv_path) audit_report = run_audit(csv_path) out1 = export_excel(csv_path) out2 = export_json(csv_path) out3 = export_html(csv_path)
改造后——声明式管道:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 from dataclasses import dataclass, fieldfrom typing import List , Callable @dataclass class PipelineResult : step_name: str ok: bool message: str = "" data: any = None @dataclass class Pipeline : """声明式构建管道——步骤可配置、可测试、可复用""" steps: List [Callable ] = field(default_factory=list ) abort_on_error: bool = True def run (self, ctx: dict ) -> List [PipelineResult]: results = [] for step in self .steps: result = step(ctx) results.append(result) if self .abort_on_error and not result.ok: break return results BUILD_PIPELINE = Pipeline(steps=[ load_offers_step, verify_step, audit_step, export_excel_step, export_json_step, export_html_step, ])
策略 4:消除模块间复制 当前项目中最典型的胶水气味——完全相同的函数出现在两个文件中:
函数
出现位置
_parse_price
export_json.py:26 + export_html.py:26
_parse_discount
export_json.py:38 + export_html.py:36
_extract_platform_id
export_json.py:51 + export_html.py:48
_get_category
export_json.py:62 + export_html.py:53
_format_date
export_json.py:80 + export_html.py:71
_extract_period
export_json.py:90 + export_html.py:81
_format_period
export_json.py:105 + export_html.py:97
这些函数应该只存在于一个地方。如果采用了策略 1(Offer 数据类),导出器接收的是已经解析好的 Offer 对象列表,这些解析函数自然就消失了——它们会变成 Offer 的类方法或构造函数。
改造前 (每个导出器各自解析):
1 2 3 4 5 6 7 8 9 10 11 12 13 def export (csv_path ): rows = load_csv_data(csv_path) for row in rows: alp = _parse_price(row.get("ALP(最低sku价)" , "" )) ... def _build_data (csv_path ): rows = load_csv_data(csv_path) for row in rows: alp = _parse_price(row.get("ALP(最低sku价)" , "" )) ...
改造后 (统一解析,导出器只负责格式化):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class CSVOfferSource (OfferSource ): def fetch (self ) -> List [Offer]: ... return [Offer(alp=parse_alp(row), ...) for row in raw_rows] def export (offers: List [Offer] ): data = {"platforms" : group_by_platform(offers)} json.dump(data, f) def export (offers: List [Offer] ): data = {"platforms" : group_by_platform(offers)} render_html(data)
策略 5:让代码说”为什么”
胶水写法
改善写法
if "免息" in s: return float('nan')
if self.is_installment: return None # 免息分期不以降价为优惠手段,不参与数值比较
m = re.search(r'(\d+)', s)
extract_first_integer(s) # OCR 可能混入中文描述,只取第一个连续数字
platform_cols.sort(); for col in platform_cols[1:]
# 表头中 col 0 是"平台名",col 10+ 是"平台承担金额",按位置消歧
limit_map.get(pname, "")
PLATFORM_LIMIT_POLICIES: dict[Platform, str] = {...}
胶水代码的通用气味:代码只表述了操作(how),没有表述意图(why)。 改善的方法是把意图写成命名或注释,让后续维护者不需要读完整段代码就能理解为什么这么做。
四、利弊权衡:什么时候不该避免胶水代码
维度
建模优先(避免胶水)
管道优先(接受胶水)
初期速度
❌ 慢 — 先设计实体、接口、适配器,100 行抽象才产出第一份 Excel
✅ 快 — 直接调 API、写 CSV、导 Excel,半小时出活
中期维护
✅ 低 — 改规则只改核心,换 OCR 只换适配器
❌ 高 — 改一个字段要改 5 个文件
可测试性
✅ 核心逻辑可脱离云 API 单测
❌ 几乎所有逻辑和外部依赖耦合
学习曲线
✅ 新人先看 domain.py 就理解全貌
❌ 需在 6 个文件间跳跃拼凑
复用性
✅ 下次做类似项目直接复用实体
❌ 下次只能复制粘贴改字段名
代码量 < 1000 行
⚠️ 过度设计风险高
✅ 性价比最高
一人维护
⚠️ 构架成本由一人承担
✅ 心智负担可接受
生命周期 < 3 月
⚠️ 抽象投资收不回
✅ 快速交付优先
依赖不会变
⚠️ 适配器抽象是浪费
✅ 直接耦合即可
判断拐点 如果你的项目满足以下两条以上 ,胶水代码反而是正确选择:
总代码量预计 < 1000 行
只有你一个人维护
预期生命周期 < 3 个月
外部依赖不会更换(公司统一采购了腾讯云 OCR)
目的是快速验证想法,而非长期运营
当前 O2O 项目恰好部分满足 以上条件(一人维护、依赖固定),所以它目前的胶水程度并非完全不合理。但如果它要长期维护、或者”预分货””促销追踪”等类似项目要复用,就应该重构。
五、渐进式改造路线 不需要一次到位。每个步骤独立发布、独立验证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 第 1 步:创建 o2o/domain.py → 定义 Offer 数据类 → 把 verify.py 的校验逻辑改为 Offer 方法 → 不改动任何其他文件 第 2 步:创建 o2o/parsers.py → 集中 _parse_price / _get_category / _extract_period 等函数 → export_json.py 和 export_html.py 从 parsers 导入 → 删除各自文件中的重复定义 第 3 步:统一下游接口 → export_json / export_html / verify / audit 接收 List[Offer] → 不再各自从 CSV 解析 第 4 步:统一上游接口 → ocr_o2o.py 的 process_image 返回 List[Offer] → CSV 读写成为 Offer 的序列化/反序列化层 第 5 步:引入端口抽象(可选) → OfferSource / OfferSink 接口 → 适配器模式,为"预分货"项目复用做准备
核心原则只有一个:让”数据是什么”的定义只出现在一个地方,而不是每个文件各自重新理解一遍。
常见问题 胶水代码和工具函数库有什么区别?
工具函数库是通用、可复用的,一个函数被多个独立模块调用。胶水代码是专用、粘连的,它只在特定管道中连接 A 和 B——换掉 A 或 B,这层代码就得重写。
一定要用 ports-adapters 架构才能避免胶水代码吗?
不一定。对于代码量小于 1000 行的小项目,一个 domain.py + 简单的函数拆分就足够了。ports-adapters 的抽象层只有在外部依赖可能替换时才值得引入。
怎么判断当前项目的胶水偏离了合理范围?
改一个字段需要改动超过 3 个文件,或新人需要阅读 3 个以上文件才能理解一行数据的完整结构时,就已经偏离了合理范围。此时最简单的做法是创建一个 domain.py 数据类作为统一的数据描述。
小团队项目也需要严格避免胶水代码吗?
不需要。一人维护、生命周期短的「验证型」项目大量使用胶水代码是合理的权衡。关键在于设置一个「信号」:当改一个字段要改 4 个文件时,就是建模的时机到了。
胶水代码会影响项目可测试性吗?
会。如果所有逻辑和外部 API 耦合在一起,单测就必须 mock 云服务。通过将数据转换逻辑从 IO 中剥离出来(如策略 1 的 Offer 数据类),核心验证逻辑就不再依赖任何外部服务,可以直接跑单元测试。
相关文章