资讯动态

三种软件架构风格对比:从KWIC看管道过滤器、ADT与调用返回的工程取舍

发布时间:2026/10/13 20:50:17 来源:尧图企业网站定制
简介面向软件架构课程学习者的KWIC多风格实现资源以“关键字在上下文”经典程序为载体完整呈现管道过滤器、抽象数据类型与调用返回三种架构风格在同一问题下的组织与执行差异便于对比主流架构设计思想。工程基于MyEclipse 6.5编写可直接导入IDE运行也可通过start.bat选定任一风格启动。三种风格分别采用快速排序、插入排序与堆排序算法完成字母排序并对a、an、and、is、the等噪音词汇进行过滤SA/input.txt可自行更换输入文本便于扩展不同语料的解析实验。资源共57个文件含24个Java源码、25个class文件以及工程配置、输入输出文本与启动脚本压缩包仅38KB结构轻量清晰。已有1400人学习下载适合软件架构初学者对照源码理解各风格的数据流与控制流差异也可用作课程设计或实验报告的参考样例。1. 为什么拿 KWIC 练架构一个越改越乱的“简单需求”用三种架构风格实现 KWIC我指的是把同一个需求分别按管道过滤器、抽象数据类型、调用返回三套思路各做一遍。KWIC 是 Key Word in Context 的缩写做的事情很直白给一批文本行比如书名、论文标题把每一个非噪声词当作关键字旋转到行首生成索引条目再按字母序输出。需求听起来只在文本处理层转圈可第一版代码几个小时就能跑通第二版要加停用词、去重、限制输出条数时改动点开始在一个个 if 和全局数组里乱窜。后来我把同一份 KWIC 分别用三种架构风格实现完才看清不是需求难而是架构风格的边界决定了后续改动的成本。接下来会直接给出三套可运行的 Python 工程和配套样例数据适合正在做架构对比课程设计、备面试案例、或者给老系统找重构方向的开发者。2. 管道过滤器风格把 KWIC 拆成一条可插拔的数据管线2.1 先想清楚数据怎么流再动手管道过滤器风格的核心不是先画类图而是先回答一个问题一行文本从进到出依次经历哪几次变换对 KWIC 来说这个链条很清晰读取原始行 → 生成循环移位 → 排序 → 格式化输出。如果从“过滤器”视角展开读原始行是第一个过滤器它的职责只是按行切分、去掉空行、记录行号第二个过滤器是循环移位它在每行里把每个非停用词先旋转到行首同时丢弃以停用词开头的移位第三个过滤器是排序按移位后的文本做字母序比较第四个过滤器是渲染把结果拼成一行行索引。停用词表加载更像配置参数不算独立的处理步骤但它直接影响第二、第三环节的行为。为什么把数据格式固定下来我在实践中通常让管线上的每一格数据都保持三元组(line_no, text, kw_pos)其中line_no是原始行号text是移位后的完整行kw_pos是关键字在原行中的起始位置。有些 KWIC 实现后期要输出“第 3 行第 2 个词引出的索引”如果没有提前保留行号和位置就只能重新遍历原始文本。三元组的槽位约定比字典更轻生成器链的每一环都无需感知字段名只需要按位置消费。Python 的生成器是表达管道最顺手的方式。生成器惰性求值下游每次请求才推进会和命令行里把一个命令的输出接到另一个命令的输入感觉一致。需要格外强调生成器里不能为了拿到中间结果把list()到处套否则每个过滤器都变成全量收集惰性优势就没了内存也会随着输入量线性上涨。2.2 五个过滤器的落地实现与参数说明资源包里pipe_filter/run_pipe.py的实现如下我保留了可运行骨架import sys DEFAULT_STOPWORDS {a, an, the, of, in, on, and, for} def read_source(stream): # 过滤空行并给每条有效行一个从 1 计数的行号 for idx, raw in enumerate(stream, start1): line raw.strip() if line: yield idx, line def generate_shifts(line_iter, stopwords): # 对每一行做循环移位位移后关键字必须在 stopwords 之外 for line_no, line in line_iter: words line.split() if not words: continue for i, word in enumerate(words): if word.lower() in stopwords: continue yield line_no, .join(words[i:] words[:i]), i def alphabetize(shift_iter): # sorted 会一次性收集全部条目这是全局排序的固有代价 yield from sorted(shift_iter, keylambda item: item[1].lower()) def render(shift_iter): for line_no, text, _ in shift_iter: yield f{line_no:04d} | {text} def main(data_path, extra_stopwordsNone): stopwords set(DEFAULT_STOPWORDS) if extra_stopwords: stopwords.update(extra_stopwords) with open(data_path, encodingutf-8) as f: stream read_source(f) stream generate_shifts(stream, stopwords) stream alphabetize(stream) print(\n.join(render(stream))) if __name__ __main__: main(sys.argv[1] if len(sys.argv) 1 else ../testdata/sample.txt)逻辑上整个main就是四个过滤器的串联。read_source不关心文本内容只负责编号generate_shifts把噪声词过滤并进移位逻辑避免生成无意义索引再丢弃alphabetize是全局排序它必须等所有条目到齐render负责展示。参数说明里有两个值得关注的点。extra_stopwords允许运行时补充停用词比如某本书的专有词 “vol”“ed” 不需要进入索引直接通过命令行传入集合即可不用改过滤器代码。DEFAULT_STOPWORDS按英文语料常见停用词选了 8 个换到中文场景必须替换成中文停用词集合因为英文用split()按空格分词即可中文必须先分词再判断。运行下面的命令输出会写进/tmp/out_pipe.txtcd pipe_filter python run_pipe.py ../testdata/sample.txt /tmp/out_pipe.txt head -20 /tmp/out_pipe.txt注意sample.txt的格式是一行一个标题。如果你的数据带标点建议在generate_shifts之前先做标点剥离否则Architecture,会被当成与Architecture完全不同的单词排序结果会和你预想的不一致。2.3 可扩展性与边界条件管道过滤器风格的扩展性在三者当中最直观。以“去重”为例只需要在alphabetize之后插入一个deduplicate过滤器排序后相同文本必然相邻只保留前一个条目即可已有过滤器不需要动。要限制每本书最多输出 N 条索引也是新增一个按line_no分组的过滤器就能完成原有的四段管线全部保留。边界条件同样清晰。管道过滤器不适合交互式系统数据一旦向下游推进就没有回退机制用户想临时改变停用词表并立刻重算索引管道结构反而不方便。错误处理也比较脆任何一层抛异常都会导致整条管线中断排错时只能沿着数据流一路加日志。对 KWIC 这个命题来说如果只是批量生成索引文件管道过滤器风格就是最轻量、最好讲清楚的选择。3. 抽象数据类型风格用封装把“改坏”的范围收住3.1 设计起点谁拥有数据抽象数据类型风格与管道过滤器风格完全相反。管道先看数据流向ADT 先看数据归属。在 KWIC 里有三份关键数据原始行集合、移位后的索引条目集合、排序后的输出集合。ADT 的思路是把每份数据连同对它的操作绑定成一个对象外部只能通过公开方法访问内部表示可以自由替换。这样的划分逻辑是如果某天原始行不再来自文件而是来自数据库或者排序算法要从内置sort换成更节省内存的归并排序这些变化应当被封装在某个对象内部不让其他模块感知。ADT 风格的目标正是把一次改动的爆炸半径控制在对象边界以内。这里还要区分一个容易混淆的点ADT 和调用返回风格都会有“调用方法”的动作但 ADT 强调数据归对象私有调用返回风格的数据往往共享在全局作用域里。后者的“模块”只是过程的组合前者的“模块”是数据和过程的整体。3.2 四个核心对象与可运行代码资源包中adt/kwic_adt.py按四个对象组织代码class LineStorage: 拥有原始行提供按索引访问和迭代的接口。 def __init__(self): self._lines [] def add_line(self, line: str) - int: line line.strip() if not line: return -1 self._lines.append(line) return len(self._lines) - 1 def get_line(self, idx: int) - str: return self._lines[idx] def __len__(self): return len(self._lines) def __iter__(self): return iter(self._lines) class ShiftStore: 根据 LineStorage 生成并保存所有循环移位条目。 def __init__(self, storage: LineStorage, stopwords): self._storage storage self._stopwords set(stopwords) self._shifts self._build() def _build(self): result [] for line_no, line in enumerate(self._storage, start1): words line.split() for i, word in enumerate(words): if word.lower() in self._stopwords: continue result.append((line_no, .join(words[i:] words[:i]), i)) return result def shifts(self): return iter(self._shifts) class Alphabetizer: 持有排序后的索引条目。 def __init__(self, shift_iter): self._sorted sorted(shift_iter, keylambda item: item[1].lower()) def output(self): return iter(self._sorted) def main(data_path, stopwordsNone): storage LineStorage() with open(data_path, encodingutf-8) as f: for line in f: storage.add_line(line) stopwords stopwords or {a, an, the, of, in, on, and, for} shifts ShiftStore(storage, stopwords) for line_no, text, _ in Alphabetizer(shifts.shifts()).output(): print(f{line_no:04d} | {text}) if __name__ __main__: main(../testdata/sample.txt)这段代码的关键点是ShiftStore._build()在构造阶段就遍历整个LineStorage并生成全部移位条目。这样排序阶段拿到的是稳定快照不会出现某个模块修改原始行、排序模块还在用旧数据的情况。shifts()返回的是iter(self._shifts)而不是直接暴露列表避免外部绕过接口破坏索引的不可变性。参数说明集中在ShiftStore的stopwords和行号起点。行号这里要特别注意enumerate(self._storage, start1)让输出行号从 1 开始和管道过滤器版本保持一致否则同一份输入会得到不同的 diff 结果。如果后续LineStorage数据量变大我建议给LineStorage增加一个版本号计数器每次add_line递增ShiftStore记录自己构建时的版本排序前比对版本不一致就自动刷新快照。这是处理“缓存失效”比较顺手的方案。3.3 封装带来的替换与测试便利ADT 风格最大的收益是替换和测试都很干净。替换场景如果LineStorage要从纯内存换成 SQLite对外只要保留add_line、get_line、__iter__这几个接口ShiftStore和Alphabetizer不需要改一行。测试场景构造一个只放 5 行文本的LineStorage可以直接断言ShiftStore.shifts()的条目数量和顺序不会受到任何全局状态的干扰。ADT 的代价在 KWIC 这个规模里也很明显管道过滤器 40 行左右就能跑通ADT 至少要维护三个类。把简单问题做重前期设计成本确实偏高。但如果你判断后续会有数据来源变化、输出格式变化这类需求这个成本就是提前支付给可维护性的。4. 调用返回风格一个主管控全程的“过程乐园”与代价4.1 以控制流为骨架的设计调用返回风格通俗说就是我们最熟悉的“主程序子程序”写法。它不从数据归属切入而是先画主流程图读取原始行 → 循环移位 → 排序 → 格式化输出再把每一段流程写成普通函数由main按顺序调用。与 ADT 相比它没有对象边界数据往往放在模块级全局变量中子程序彼此共享这些全局状态。为什么还要把它单独列出来做对比因为它代表了一个非常普遍的真实开发习惯功能简单时直接用全局变量加函数就够了KWIC 第一版最自然的写法就是这种风格。拿它作为第三种参照能直接暴露这类写法的优点和隐患。从架构角度看调用返回风格只有一个控制中心就是main。这带来一个好处执行顺序一目了然断点可以打在任意一行调试成本是三种风格里最低的。但代价是数据共享是隐式的任何一个函数都可以修改全局列表又没有任何接口约束后续改动靠的是“人记得住”。4.2 全局数据结构与主流程代码资源包中call_return/run_call_return.py的核心代码# 刻意使用模块级全局变量体现调用返回风格的共享状态 raw_lines [] shifts [] def load(path): global raw_lines raw_lines [] # 复位避免二次运行时带上前一次的数据 with open(path, encodingutf-8) as f: for line in f: if line.strip(): raw_lines.append(line.strip()) shifts.clear() def shift(stopwords): for line_no, line in enumerate(raw_lines, start1): words line.split() for i, word in enumerate(words): if word.lower() in stopwords: continue shifts.append((line_no, .join(words[i:] words[:i]), i)) def sort_index(): shifts.sort(keylambda item: item[1].lower()) def render(): for line_no, text, _ in shifts: print(f{line_no:04d} | {text}) def main(path, stopwordsNone): load(path) stopwords stopwords or {a, an, the, of, in, on, and, for} shift(stopwords) sort_index() render() if __name__ __main__: main(../testdata/sample.txt)main的四次调用就是整个系统的执行流程。load负责把原始数据装进全局raw_linesshift遍历raw_lines生成移位结果放进全局shiftssort_index原地排序render负责打印。整套流程不需要在多个对象之间跳来跳去这是它最直观的优势。参数说明load开头复位全局变量是必须的动作。太多翻车的根因是没有清理上次运行残留在交互式环境里重复调用main输出行数会逐次增多或者行号错乱。shift和sort_index的顺序不能交换因为sort_index依赖shifts已经包含全部候选条目先排序再移位会打乱原始行的顺序。如果一时无法立即弃用这种风格我通常会做一个中间态改造让main显式构造一个上下文对象比如state {raw_lines: [], shifts: []}所有函数都接收state而不是直接引用全局变量。这算是从调用返回风格过渡到 ADT 风格的中间形态能明显降低数据被无意覆盖的风险。4.3 这种风格何时还值得选何时该放手调用返回风格最适合的场景是一次性命令行工具、需求基本稳定、数据规模小、不打算做复杂的模块复用。KWIC 如果只跑一次生成索引文件这套代码比前两种都短而且最好调试。但问题也很明显。全局变量被所有子程序共享任何一个函数失误改乱全局数组排查范围就覆盖整个程序。要让 KWIC 增加“过滤重复索引”功能就必须在全局shifts上做二次遍历既要保留第一次出现的条目又要处理排序后相邻比较的逻辑。如果以后还要替换停用词表并热刷新这种风格几乎没有优雅的路径。我自己的判别标准是当代码里开始出现多个函数都写global shifts而且每个函数都要先猜测当前shifts里装的是哪个阶段的数据说明调用返回风格的适用边界已经过了。5. 常见问题与避坑三套实现里的取舍陷阱5.1 先给一张对比表当结论维度管道过滤器风格抽象数据类型风格调用返回风格设计视角数据流经过哪些变换数据归谁管理主流程如何调用子功能数据存放在生成器之间传递各对象内部私有存储模块级全局变量增加去重功能插入新过滤器即可在 Alphabetizer 加新方法改主流程和共享数组遍历主要失败模式任意过滤器异常导致整条管线中断缓存失效、对象职责过细全局状态被意外修改、二次运行污染推荐场景批量数据加工、ETL数据源和展示变化频繁一次性脚本、教学演示这张表不是告诉你哪一种最好而是提醒你先回答一个问题这个系统未来半年的变化主要发生在哪个环节数据源变化多考虑 ADT处理步骤增加多考虑管道几乎没有变化调用返回最省事。5.2 避坑记录一停用词过滤放在了移位之后现象输出里 “the”“and”“of” 排在列表最前面索引条目里有大量无意义的关键词。原因对 KWIC 来说停用词过滤必须发生在“决定哪些单词可以作为关键字”的阶段也就是循环移位时。如果过滤做在移位之后再去删整行索引会把原本由有效单词引出的索引一起误删。解决把停用词判断放进generate_shifts的枚举循环里也就是if word.lower() in stopwords: continue不要先完整生成移位再统一过滤。这一点在三种风格里都一样。5.3 避坑记录二管道过滤器里“等待全部输入”的内存翻车现象输入数据从几十行换成几万行后程序耗时暴增内存占用接近系统上限。原因generate_shifts或read_source里不小心写了list(...)把惰性生成器转成了列表管道优势瞬间消失。数据量小时这种写法也能跑通问题容易被掩盖。解决保持生成器链的惰性只在alphabetize里用sorted做一次性全量收齐因为全局排序必然需要所有条目参与。如果数据大到连排序都扛不住就不要用一次性的sorted改成外部排序或者把索引写入数据库再ORDER BY。5.4 避坑记录三ADT 缓存失效导致排序拿到旧数据现象向LineStorage追加新行后再次运行输出里没有出现新行对应的索引。原因ShiftStore在构造时就把_shifts算好了之后外部再往LineStorage加数据ShiftStore仍然持有旧快照。这不是排序算法的问题而是缓存没有失效机制。解决给LineStorage加_version计数器每次add_line递增ShiftStore在调用shifts()时比对版本版本不一致就重建_shifts。快照机制保留但它必须保证数据更新后能被感知。5.5 避坑记录四调用返回风格二次运行数据被污染现象在交互式环境里反复执行main()输出行数逐次增多或者每行索引重复出现。原因raw_lines和shifts是全局列表load没有先复位第一次的数据仍然挂在全局变量里二次加载后直接叠加。解决在load开头执行global raw_lines; raw_lines []并在load结束时添加shifts.clear()。如果代码被封装成可导入的模块也要确保模块级初始数据为空避免导入后残留测试数据。5.6 避坑记录五大小写、标点和停用词规则不统一现象三套实现的输出做 diff结果不一致。同样的标题一种排在前面另一种排在后面甚至索引条目数量都不同。原因排序时有的实现做了text.lower()有的直接用原字符串分词时有的按空格split有的保留了句尾标点停用词表也不一致。KWIC 的正确性高度依赖这两个规则的统一。解决定义一条规则基线大小写统一后再排序、用re.findall(r\w, line)分词、三套实现从同一份停用词表读取。资源包里的testdata/golden.txt就是按这条基线生成的预期输出三套实现都必须与它 diff 一致不一致时优先检查这一条。6. 更深一点用同一组测试输入把三种实现放到生产思维下验收6.1 三个工程必须对同一份输入产生一致输出拿到资源包后先建立的不是代码而是验证方法。把三套工程分别跑同一份testdata/sample.txt把输出记录到三个临时文件再用diff检查cd three_arch_kwic python pipe_filter/run_pipe.py testdata/sample.txt /tmp/out_pipe.txt python adt/kwic_adt.py testdata/sample.txt /tmp/out_adt.txt python call_return/run_call_return.py testdata/sample.txt /tmp/out_call.txt diff /tmp/out_pipe.txt /tmp/out_adt.txt echo OK: pipe adt diff /tmp/out_adt.txt /tmp/out_call.txt echo OK: adt call_return如果 diff 输出有差异优先检查第 5.6 条提到的规则基线而不是急着改代码。我习惯把这个diff命令写进一个check_all.sh以后每改任何一套实现都强制跑一遍一致性防止三套代码在迭代中悄悄分叉。6.2 用“去重需求”当探针观察改动半径验证一个架构风格是否适合你的场景最好的方式不是看概念而是亲手加一个功能并记录改动半径。以“去掉完全重复的索引条目”为例管道过滤器风格只需在alphabetize之后加一个deduplicate过滤器ADT 风格是在Alphabetizer增加去重选项调用返回风格则要在main的流程里增加一个遍历全局shifts的去重函数还要小心它不能影响已经排好的顺序。同一个功能三种风格的改动范围和牵连风险一目了然。从那以后我用 KWIC 评估任何一种新的架构风格都会先问自己三个问题数据归谁管执行顺序由谁控制加一个功能最少要碰几个模块想清楚这三件事再动手后面基本不会走大弯路。这套跨风格对比方法一直是我判断架构选型的起点希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑