ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

主程序-子过程、管道-过滤器、面向对象:三种架构风格实现KWIC对比

主程序-子过程、管道-过滤器、面向对象:三种架构风格实现KWIC对比 简介KWICKey Word in Context索引系统是软件架构课程中的经典案例。压缩包内含三种架构风格实现抽象数据类型风格、调用返回风格与管道过滤风格均基于Java编写并提供完整可运行的MyEclipse工程。资源共有57个文件以Java源文件与编译后的class文件为主附带输入输出示例文本、工程配置及一键启动脚本压缩包仅38KB适合软件架构初学者或课程设计者对比同一功能在不同风格下的模块划分与数据交互方式。已有1399人浏览学习。三种实现各具特色抽象数据类型风格采用快速排序并封装在AlphabetizerImpl中调用返回风格使用插入排序管道过滤器风格则应用堆排序同时均对常见噪音词汇进行过滤且输入文本可自由配置。通过阅读源码可直观体会架构风格对算法组织、可扩展性和性能的影响。1. 架构风格选型从“找作业”到“做设计”的转变如果你在搜索引擎里敲下“KWIC”大概率会先看到一串学生作业题——输出所有轮转后的短语并按字母排序像一段加了循环和字符串切片的初级编程练习。但把这个题目放到“架构风格”的语境下重读它就从一个“怎么写循环”的问题变成了“代码该怎么组织、模块之间该怎么通信、将来老板改需求时你该怎么活”的问题。KWICKey Word In Context上下文关键词索引系统最早是Parnas在1972年那篇经典论文里用来讨论模块分解的案例。它的原始需求很老派把一堆标题里的每个单词轮流放到开头生成所有可能的轮转短语去掉无意义的介词停用词然后按字母序排列输出。听起来像是图书索引系统里的一个小工具但它恰好包含了几种最典型的软件结构要素——输入输出、数据变换、数据存储、顺序控制。这篇文章要聊的不是怎么写出一个能跑的KWIC而是三种架构风格分别怎么做KWIC以及做完之后代码在面对需求变更时表现出的截然不同的“体质”。我用自己的方式把三种方案都实现了一遍过程中踩了不少坑也理清了它们各自的适用场景。接下来按顺序拆解每一步都会附带完整的思路、代码和实际测试结论。2. 方案一主程序-子过程风格Main Program and Subroutine2.1 核心思路按“操作步骤”切分系统主程序-子过程风格是最朴素、也最容易被新手自然采用的架构。它的核心逻辑是把整个任务看成一条流水线主程序像车间主任一样按顺序调用各个工序每个子程序只负责一道工序。对应到KWIC流水线被我拆成了四道工序读取输入把原始标题从文件或内存中读出来。生成轮转对每个标题在其每个单词处切一刀生成所有可能的前后交换结果。停用词过滤把以停用词开头的轮转结果删掉。排序输出剩下的轮转短语按字母序排列拼成索引。每一道工序就是一个独立的函数或子程序函数之间通过共享的数据结构传递中间结果。我用的共享数据结构是“存放所有标题的数组”和“存放所有轮转结果的数组”。2.2 代码落地一份结构清晰的命令式实现我按上面四个步骤实现了一版Python代码没有用类和装饰器就是纯函数加全局数据区刻意模拟传统主程序-子过程风格的感觉# KWIC 主程序-子过程风格实现 titles [] # 原始标题列表 circs [] # 轮转结果列表 stop_words {the, and, or, of, to, a, in} def read_titles(lines: list[str]) - None: 工序1: 读取原始标题 global titles titles lines def build_circular_shifts() - None: 工序2: 生成所有轮转 global circs circs [] for title in titles: words title.split() n len(words) for i in range(n): shifted .join(words[i:] words[:i]) circs.append(shifted) def filter_stop_words() - None: 工序3: 过滤以停用词开头的轮转 global circs circs [c for c in circs if c.split()[0].lower() not in stop_words] def sort_and_output() - list[str]: 工序4: 排序并输出 return sorted(circs) # 主程序按顺序调用各工序 lines [ The quick brown fox, A tale of two cities, Software architecture in practice ] read_titles(lines) build_circular_shifts() filter_stop_words() for line in sort_and_output(): print(line)输出结果Software architecture in practice architecture in practice Software brown fox The quick in practice Software architecture of two cities A tale practice Software architecture in quick brown fox The tale of two cities A two cities A tale of注意“A tale of two cities”里的“A”是停用词所以“A tale...”这个排列被过滤了“of”和“the”开头的排列也被过滤了。这个结果符合预期。2.3 优点与隐患为什么这种风格“形同虚设”却如此普遍主程序-子过程风格最大的优点是直观且易调试——每个工序就是一个函数函数之间通过全局数据区传递信息调用顺序一目了然。对KWIC这种规模的小项目这种结构完全够用甚至比引入复杂框架更合适。但它有一个致命问题数据是共享的控制是集中的改一个环节往往牵一发动全身。举个例子如果需求方说“我想看所有轮转结果别过滤停用词”那可以用参数控制但如果说“我想在轮转的时候同时保留原始标题的行号方便回溯来源”麻烦就出现了——行号字段需要贯穿所有四个工序build_circular_shifts 要改filter_stop_words 要改sort 也要跟着改。这就是典型的“修改从一个点扩散到全局”的症状。还有一个我认为更隐蔽的问题这种风格天然会把“数据表示”暴露给所有模块。任何一个函数都可以直接修改全局数据区的内容模块之间完全没有隔离。在KWIC这种小系统中这不是一个严重问题但当系统规模扩大时这几乎就是结构崩塌的开始。3. 方案二管道-过滤器风格Pipes and Filters3.1 核心思路把每道工序变成独立“过滤器”管道-过滤器风格的核心思想是系统被拆成一系列过滤器每个过滤器处理一种数据变换过滤器之间通过管道连接。前一个过滤器的输出是后一个过滤器的输入数据不是在“共享区”中转而是在管道中有方向地流动。对应到KWIC我把四个工序都改成过滤器读取过滤器Reader负责把原始标题一行行输出到管道。轮转过滤器CircularShifter从管道读标题生成轮转结果输出到下一根管道。停用词过滤器StopWordFilter读入轮转结果过滤掉以停用词开头的输出。排序过滤器Alphabetizer读入过滤后的轮转结果排序输出到终端。关键区别在于每个过滤器只依赖“上游数据格式”和“下游数据格式”不依赖上游过滤器的具体实现。数据以“数据流”的形式在各过滤器间流动。3.2 代码落地定义数据流边界让每个过滤器的输入输出都清晰管道-过滤器风格的关键在于“数据流边界”的约定。我在实现时定义了一个最简单也最可靠的接口——每个过滤器接收一个可迭代对象返回一个可迭代对象。这样管道的接缝自然就出现了# KWIC 管道-过滤器风格实现 from typing import Iterable, Iterator StopWords {the, and, or, of, to, a, in} def read_filter(lines: Iterable[str]) - Iterator[str]: 过滤器1: 读取原始标题 yield from lines def circular_shift_filter(titles: Iterable[str]) - Iterator[str]: 过滤器2: 生成所有轮转 for title in titles: words title.split() n len(words) for i in range(n): yield .join(words[i:] words[:i]) def stop_word_filter(circs: Iterable[str]) - Iterator[str]: 过滤器3: 过滤以停用词开头的轮转 for c in circs: if c.split()[0].lower() not in StopWords: yield c def alphabetizer_filter(circs: Iterable[str]) - Iterator[str]: 过滤器4: 排序输出 yield from sorted(circs) # 管道组装像Shell管道一样把过滤器串起来 source [ The quick brown fox, A tale of two cities, Software architecture in practice ] pipeline alphabetizer_filter( stop_word_filter( circular_shift_filter( read_filter(source) ) ) ) for line in pipeline: print(line)这段代码和方案一处理同样的输入应该产生完全相同的输出。我用嵌套调用的方式模拟管道而不是显式定义复杂的管道类目的是尽量保持轻量。如果项目中需要可视化管道或动态组装可以在此基础上做一层关于“管道”的封装。3.3 优点与隐患灵活的数据流但小心“链式回调嵌套地狱”管道-过滤器风格最大的优势是模块可独立替换、可复用、可单独测试。因为每个过滤器只关心自己的输入输出格式你可以把排序过滤器替换成逆序排序过滤器或者调整过滤器的顺序以支持“先过滤再排序”的预处理而不用修改其他过滤器。这一点在方案一里是比较难做到的。它还有另一个我很喜欢的特性支持惰性数据流。因为过滤器返回的是迭代器而不是完整的列表KWIC处理超大型语料时内存占用非常可控。我可以从file object一路yield到排序器期间不会出现“把所有数据都load到内存”的情况。这一点在真实场景下极其重要——KWIC的原始用途本来就是处理图书标题库数据量一上来方案一的全局数组方案就会变得很吃力。但它也有问题。第一个问题是调试比较麻烦如果某个过滤器的输出不对你得在管道中手动加日志或者打印每个阶段的输出无法像主程序-子过程风格那样直接设断点观察全局变量。第二个问题是错误处理没有统一的机制管道中任何一个环节挂了整个数据流都断了而且很难定位具体是哪个环节出的问题。还有一个我实际踩过的坑嵌套调用过深。当过滤器数量超过五六个时代码的可读性会急剧下降。我在整理KWIC时只用了四个过滤器所以问题不明显但你可以试着想象一个由十几个过滤器组成的ETL管道直接嵌套会导致一套噩梦般的缩进层级。真实的工业级做法是用一个“管道调度器”来统一管理过滤器的连接顺序而不是简单嵌套。但那就引出了另一个命题——为了灵活性牺牲了直观性这个取舍需要根据项目规模来决定。4. 方案三面向对象风格Object-Oriented4.1 核心思路按“数据与职责”切分系统面向对象风格主张把系统看成一组协作的对象每个对象封装自己的数据和行为通过对象间的方法调用进行通信。对应到KWIC我不再按“操作步骤”拆分系统而是按“职责主体”拆分标题存储对象TitleStorage保存原始标题提供添加、获取标题的接口。轮转生成器对象CircularShiftGenerator从标题存储对象读取数据生成轮转结果内部持有轮转列表。停用词过滤器对象StopWordFilter判断给定轮转是否应该被过滤。排序器对象Alphabetizer接收轮转列表输出排序结果。主控制对象KWICController负责对象间的协作流程。在这种风格下数据不再“裸露”地传来传去而是由各自的对象负责保管和维护。主控对象只负责“调度”而不过问具体的数据变换逻辑。4.2 代码落地类的边界就是需求变化的边界我实现了一版面向对象风格的KWIC。这里的关键决策是把“停用词过滤”从“轮转生成”中独立出来而不是像很多版本那样直接在生成时过滤。这样做的好处是——如果将来需要一个“不过滤停用词的索引”轮转生成器对象可以原封不动被复用。# KWIC 面向对象风格实现 class TitleStorage: def __init__(self): self._titles [] def add_title(self, title: str) - None: self._titles.append(title) def get_titles(self) - list[str]: return list(self._titles) class CircularShiftGenerator: def __init__(self, storage: TitleStorage): self._storage storage self._shifts [] def generate(self) - None: self._shifts [] for title in self._storage.get_titles(): words title.split() n len(words) for i in range(n): self._shifts.append( .join(words[i:] words[:i])) def get_shifts(self) - list[str]: return list(self._shifts) class StopWordFilter: def __init__(self, stop_words: set[str]): self._stop_words stop_words def filter(self, shifts: list[str]) - list[str]: return [s for s in shifts if s.split()[0].lower() not in self._stop_words] class Alphabetizer: staticmethod def sort(shifts: list[str]) - list[str]: return sorted(shifts) class KWICController: def __init__(self, storage: TitleStorage, generator: CircularShiftGenerator, filter_: StopWordFilter, alphabetizer: Alphabetizer): self._storage storage self._generator generator self._filter filter_ self._alphabetizer alphabetizer def run(self) - list[str]: self._generator.generate() shifts self._generator.get_shifts() filtered self._filter.filter(shifts) return self._alphabetizer.sort(filtered) if __name__ __main__: storage TitleStorage() for title in [ The quick brown fox, A tale of two cities, Software architecture in practice ]: storage.add_title(title) generator CircularShiftGenerator(storage) stop_words {the, and, or, of, to, a, in} filter_ StopWordFilter(stop_words) alphabetizer Alphabetizer() controller KWICController(storage, generator, filter_, alphabetizer) for line in controller.run(): print(line)4.3 优点与隐患扩展的乐园但过度设计的深渊也在招手从可扩展性角度看面向对象风格在三种风格中是最强的。每一种对象都是一个独立的演化单元“停用词过滤规则要改成依赖上下文判断”那么只需要去修改StopWordFilter内部逻辑Controller、Storage、Generator都不受影响。“标题来源要从文件变成数据库”只需要改storage的构造方式甚至不需要改其他对象。它也有显而易见的代价代码量和概念数量显著增加。方案一里四个函数就解决的问题到了这里变成了五个类、一个主控制器、和一堆“对象间协作”的约定。对于KWIC这种规模的需求面向对象风格看起来确实是“杀鸡用牛刀”。我在实现过程中的体验是真正需要想清楚的并不是“要建哪些类”而是“哪些变化是未来可能需要应对的”。如果你预判未来需求会频繁变化、且变化点分散在不同领域那么为每个变化点建立对象边界是值得的如果需求基本固定那引入更多层次只会增加认知负担。面向对象风格的核心收益是“推迟决策”的能力——它允许你在不破坏整体结构的前提下逐步替换系统中的某个局部。5. 三种架构风格的对比与选型建议5.1 一张表看透三种风格的差异为了更直观地展示三种方案的差异我整理了一张对照表从多个维度评估它们在KWIC场景中的表现维度主程序-子过程风格管道-过滤器风格面向对象风格模块划分依据按操作步骤按数据处理阶段按职责主体数据传递方式共享全局数据区管道数据流对象间消息调用模块耦合程度高共享数据耦合低只依赖数据格式中低依赖接口定义可测试性一般很好每环独立测试好每个类单独测试可扩展性差改性一处牵连多处中方便增删过滤器好局部替换对象调试难度易设断点看全局难数据流不透明中对象间协作稍复杂实现成本低低高适用场景需求固定、规模小数据流清晰、批次处理需求变化多、系统持续演进这个表格的三个结论很直接主程序-子过程风格胜在简单直接管道-过滤器风格胜在灵活解耦和惰性流式处理面向对象风格胜在隔离变化和长期演进。5.2 我踩过的坑过度追求“风格正确”反而误事初次实现时我走了一个误区——为了展示三种风格差异在面向对象版本里强行加入了抽象基类、工厂模式和依赖注入容器。结果代码量翻了三倍真正与KWIC业务相关的逻辑不到200行剩下的全是为“设一个可能永远用不到的扩展点”而付出的基础设施成本。这是三个版本中调试最痛苦的一版。所以如果你的目标是“用架构风格解决KWIC”而不是“展示架构风格的教科书案例”我的建议是遵守以下选型逻辑团队第一次接触这个项目、需求基本明确优先考虑主程序-子过程风格。简单直接不折腾就是最大的效率。如果数据量可能不断增长或者你将来的输入源不再只是内存数组管道-过滤器风格会让数据流处理顺畅很多。但管道数量超过五条时务必引入显式的管道调度器别用嵌套硬堆。如果这个KWIC系统是某个大型系统里一个未来会长期演进的子模块面向对象风格提供的对象边界能帮你把频繁变化的部分隔离起来。但请坚持“让每个类承担一个明确职责”的原则别让类多到没人记得住。6. 变体与扩展从KWIC看架构风格的通用规律KWIC这个案例最迷人的地方在于它虽然小却完整地映射了大型系统的组织难题。做完三种实现后我对架构风格的理解也有一些新的延伸这部分可以作为大家的扩展思路管道-过滤器风格的现实投影如果你把KWIC的四个过滤器换成正则清洗、HTML标签剥离、情感分类器、关键词抽取器它就是一个标准的信息抽取管道。很多文本处理框架里的Processor链本质就是在跑管道-过滤器风格。面向对象风格的现实投影把TitleStorage换成UserRepository把CircularShiftGenerator换成RecommendationEngine把Controller换成Facade层这就几乎是一个标准的领域模型设计。对象边界的本质就是把数据与行为绑定在一起让变化局部化。三种风格并不互斥真实项目中这几种风格往往共存。比如管道-过滤器负责数据采集和清洗面向对象负责业务模型外层用主程序风格串联启动流程。架构风格是一种“局部组织原则”不是非得全局唯一。7. 最后的实操心得回到开头的那个问题用三种架构风格实现KWIC收获到底是什么对我来说这个练习最大的价值不在“能跑”而在于它迫使你想清楚每一种系统组织方式的取舍。KWIC虽然简单但“输入—变换—过滤—排序—输出”的骨架几乎出现在所有信息处理类项目中。你今天在KWIC上学会的是如何在变更来临时让代码系统有序地“迎接”变化而不是在混乱中疲于修补。实际做下来我的体会是架构风格没有绝对的好坏只有合适的错配。哪怕是同一个KWIC需求在不同语境下三种风格都可能有一个“最优解”——一个小工具用主程序风格最快交付是最优解一个要对接多数据源的批处理任务用管道-过滤器是最优解一个要长期维护并且经常加新规则的服务用面向对象是最优解。最后分享一个决定架构风格前常用的小技巧先把未来可能的需求变化列成清单再挑出一种风格让这些变化中的大多数都能被局限在单个模块里。如果找不到这样一个风格那就说明这个系统的核心不确定性太高盲选哪种架构风格都只是赌博。先用最小成本搭出主程序-子过程风格的骨架跑通业务再在需要的地方逐步演进永远是最稳妥的路径。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进