简介:面向软件架构课程学习者的经典实践案例:KWIC 三种架构风格实现。同一用例分别采用抽象数据类型、调用返回与管道过滤器三种风格完成,直观展现不同架构在模块切分、调用关系与数据流上的差异,便于对比选型。代码基于 MyEclipse 6.5 开发,三种风格分别使用快速排序、插入排序、堆排序算法,并统一过滤常见噪音词汇;输入文本可自行配置,也提供一键启动脚本供选任一风格运行。资源包共 57 个文件,包含 24 个 Java 源码、25 个编译后 class 文件及工程配置、说明与启动脚本,整体仅 38KB,精简清晰、即开即用。已有 1400 人学习,适合软件架构初学者、Java 开发者作为课程作业参考或教学演示材料。
1. 三种架构风格实现KWIC:先搞清楚索引系统在解决什么问题
"三种架构风格实现KWIC"这个标题,翻译成实际工作就是:同一个"上下文关键词索引"(Key Word In Context)需求,分别用主程序-子程序、管道-过滤器、面向对象三种架构风格各实现一遍,然后对比它们对需求变更的适应力。KWIC本身不复杂,它接收一组标题行和一组忽略词,对每一行做循环移位,过滤掉以忽略词开头的移位,再按关键词字母序输出成索引。我第一次接触它是在软件工程的经典案例里,当时只觉得循环移位挺绕,后来在真实项目里做日志关键词索引、做图书目录检索的数据预处理,才发现KWIC背后问的是架构里最本质的问题:控制权归谁、数据存在哪、模块之间怎么协作。这篇文章会把三种风格从理论到代码完整走一遍,适合正在学软件架构、准备架构课作业、或者想在老系统里做模块化改造的从业者。代码我用Python写,依赖只有标准库,能直接跑。
2. 架构风格选型:控制权与数据流决定了KWIC的长相
在动手写代码之前,要先建立一个判断框架。KWIC这种业务规模——输入几十到几万行标题、单机内存装得下、处理流程固定——正是检验架构风格的好样本。它不涉及分布式,不涉及高并发,纯粹看你在"如何组织模块间的协作"这个决策上会怎么选。
2.1 主程序-子程序风格:谁能调用谁,数据放哪里
主程序-子程序风格也叫调用返回风格,核心特征是"一个主控流程 + 若干被调用的子程序",子程序之间靠共享数据通信,整个系统只有一个入口、一条控制流。在KWIC里,它的结构非常直白:主程序依次调用输入、循环移位、排序、输出四个子程序,所有中间结果都放在全局数组里。
这种风格最容易被新手低估,因为它看起来像"没有架构"。但它的优点恰恰来自这份简单:控制流一眼看穿,资源占用最小,调试时可以顺着调用栈一路跟到底。缺点也明显——全局数据一旦被多个子程序读写,改一个函数的内部表示会牵连所有函数;而且没法扩展成多线程,因为全局数组没有锁控制。
2.2 管道-过滤器风格:数据流就是控制流
管道-过滤器风格换了一个视角:它不设"主程序",而是把业务拆成一组首尾相连的过滤器,数据从源头流经每个过滤器,在出口得到结果。每个过滤器只做一件事,只消费上一步的数据、产出下一步的数据,不共享任何状态。KWIC在这个风格下会被拆成读行、生成移位、过滤忽略词开头、排序、写文件五个过滤器。
这种风格最大的价值是松耦合和可复用:往链路里插一个新过滤器(比如加一个去重过滤器)不需要改动其他环节;单个过滤器可以单独测试,甚至可以并行执行。代价是数据必须按规定的格式在管道里流动,如果中间某个过滤器要"吞掉"或"改写"数据格式,整条链都要跟着调整。而且排序这个动作天然不是流式的,它必须等全部数据到齐才能输出第一条结果,这会让"管道"出现一个阻塞节点。
2.3 面向对象风格:把数据锁进对象里
面向对象风格(更严谨的说法是抽象数据类型风格)处理的是"数据归属"问题。它的思路是:每个模块不仅负责操作,还负责保管自己的数据;别的模块只能通过公开接口间接访问。在KWIC里,输入模块、循环移位模块、忽略词模块、排序模块、输出模块各成对象,原始标题行归输入模块管,移位结果归循环移位模块管,排序模块不直接碰原始数据。
这样做的好处是变更局部化:如果需求从"按字母序排"改成"按词频权重排",改排序模块内部就行,其他模块完全不受影响;如果要增加一种忽略词来源(比如从数据库读),只需要改忽略词模块。坏处也很实际:对象之间要通过接口协调,代码量明显比前两个风格大;如果设计时接口定义得不好,对象之间还是会偷偷共享可变数据,那这个风格就名存实亡了。
3. 三种风格落地KWIC:完整代码与逐步拆解
这里直接给出三种风格的可运行实现。输入文件我们准备一个titles.txt,内容是三行英文标题,忽略词文件stopwords.txt放常见的停用词。每个实现都从命令行接收三个参数:标题文件路径、忽略词文件路径、输出文件路径。
先准备测试数据。titles.txt内容:
The Quick Brown Fox Jumped Over The Lazy Dog A Day in the Life of a Developerstopwords.txt内容:
the a of over in3.1 主程序-子程序实现:全局数组加顺序调用
import sys # 全局共享数据区:原始标题按空格分词后的矩阵 WORDS = [] # 全局共享数据区:所有合法的循环移位结果 SHIFTS = [] def input_data(path): """子程序1:读标题文件,把每行切分成单词列表""" global WORDS WORDS = [] with open(path, encoding='utf-8') as fp: for line in fp: line = line.strip() if line: WORDS.append(line.split()) def load_ignored(path): """读取忽略词文件,转成小写集合""" with open(path, encoding='utf-8') as fp: return set(fp.read().lower().split()) def circular_shift(ignored): """子程序2:对每个标题生成循环移位,跳过忽略词开头的移位""" global SHIFTS SHIFTS = [] for tokens in WORDS: n = len(tokens) for start in range(n): # 如果移位后的第一个词是忽略词,这个移位不输出 if tokens[start].lower() in ignored: continue # 从start位置切开,把前段拼到后段后面 shifted = ' '.join(tokens[start:] + tokens[:start]) SHIFTS.append(shifted) def alphabetize(): """子程序3:按整行字符串的字母序排序(不区分大小写)""" global SHIFTS SHIFTS.sort(key=lambda s: s.lower()) def output_data(path): """子程序4:把排序后的移位逐行写出""" with open(path, 'w', encoding='utf-8') as fp: for line in SHIFTS: fp.write(line + '\n') def main(titles_path, stop_path, out_path): input_data(titles_path) ignored = load_ignored(stop_path) circular_shift(ignored) alphabetize() output_data(out_path) if __name__ == '__main__': main(sys.argv[1], sys.argv[2], sys.argv[3])这段代码的逻辑顺序就是主程序风格的最佳演示:main函数从上到下调用四个子程序,后一个子程序的输入来自前一个子程序写入的全局变量,数据通过WORDS和SHIFTS两个全局列表流动。代码里的注释标出了每个子程序的职责,你会发现每个函数自己都"不知道自己被谁调用",它们只向全局区读写,这种自由既是这种风格的可读性来源,也是后面避坑章节里耦合问题的根源。
参数说明:titles_path是原始标题文件,每行一条;stop_path是忽略词文件,每行可以是多个空格分隔的词;out_path是输出索引文件。运行时直接python kwic_main.py titles.txt stopwords.txt out.txt,out.txt里就是排好序的索引行。
3.2 管道-过滤器实现:五个生成器串成一条线
import sys def read_lines(path): """过滤器1:逐行读取标题文件,产出非空行""" with open(path, encoding='utf-8') as fp: for line in fp: line = line.strip() if line: yield line def load_ignored(path): """读取忽略词,返回小写集合""" with open(path, encoding='utf-8') as fp: return set(fp.read().lower().split()) def shift_filter(lines, ignored): """过滤器2:对每条输入行生成循环移位,过滤忽略词开头的移位""" for line in lines: tokens = line.split() n = len(tokens) for start in range(n): if tokens[start].lower() in ignored: continue yield ' '.join(tokens[start:] + tokens[:start]) def sort_filter(lines): """过滤器3:对移位结果排序,产出有序行""" # sorted能排序,但必须等所有输入到齐,是管道里的阻塞环节 for line in sorted(lines, key=lambda s: s.lower()): yield line def write_lines(lines, path): """过滤器4(汇):逐行写文件""" with open(path, 'w', encoding='utf-8') as fp: for line in lines: fp.write(line + '\n') def main(titles_path, stop_path, out_path): ignored = load_ignored(stop_path) lines = read_lines(titles_path) shifted = shift_filter(lines, ignored) sorted_lines = sort_filter(shifted) write_lines(sorted_lines, out_path) if __name__ == '__main__': main(sys.argv[1], sys.argv[2], sys.argv[3])管道风格的关键在main的最后四行:每条数据从read_lines产出,被shift_filter消费、产出移位,再被sort_filter消费、产出有序行,最后被write_lines写盘。可以看到没有全局变量,数据完全靠生成器在"链"上传递。read_lines和shift_filter是流式的,内存占用只和单行长度有关;但sort_filter内部用了sorted,它必须先收完上游所有数据才能排序,所以这一节的内存占用会随标题行数线性增长。
参数说明和主程序版本完全一致,运行时命令也一样。区别在于:如果你只想验证"移位生成是否正确",可以在main里把sort_filter和write_lines之间的链路断开,单独把shifted接一个print语句,过滤器单独测试是它的天然优势。
3.3 面向对象实现:五个对象各管一摊
import sys class InputModule: """输入模块:只负责从文件读行,返回字符串列表""" def read(self, path): with open(path, encoding='utf-8') as fp: return [line.strip() for line in fp if line.strip()] class IgnoreWords: """忽略词模块:持有忽略词集合,对外只暴露判断方法""" def __init__(self, path): with open(path, encoding='utf-8') as fp: self._words = set(fp.read().lower().split()) def contains(self, word): return word.lower() in self._words class CircularShiftModule: """循环移位模块:持有原始标题行,生成合法移位""" def __init__(self, lines): self._lines = lines def generate_shifts(self, ignore: IgnoreWords): for line in self._lines: tokens = line.split() n = len(tokens) for start in range(n): kw = tokens[start] if ignore.contains(kw): continue shifted = ' '.join(tokens[start:] + tokens[:start]) yield shifted class AlphabetizerModule: """排序模块:只接收可迭代的移位行,返回新列表,不改动传入对象""" def sort(self, shifts): return sorted(shifts, key=lambda s: s.lower()) class OutputModule: """输出模块:把结果行写入文件""" def write(self, lines, path): with open(path, 'w', encoding='utf-8') as fp: for line in lines: fp.write(line + '\n') def main(titles_path, stop_path, out_path): lines = InputModule().read(titles_path) ignore = IgnoreWords(stop_path) shifts = CircularShiftModule(lines).generate_shifts(ignore) sorted_shifts = AlphabetizerModule().sort(shifts) OutputModule().write(sorted_shifts, out_path) if __name__ == '__main__': main(sys.argv[1], sys.argv[2], sys.argv[3])这个版本里,数据归属是清晰的:lines列表从InputModule.read出来后被传进CircularShiftModule的构造函数,成为它内部维护的_lines;IgnoreWords对象自己保管着忽略词集合;AlphabetizerModule拿到的只是一个生成器,它调用sorted会新建一个列表,不会污染CircularShiftModule的内部状态。每个模块只暴露必要的接口:read、contains、generate_shifts、sort、write,模块之间的协作全部通过这五个公开方法完成。
参数说明与前两个风格一致。这里的main看起来像"组合根",它不包含业务逻辑,只负责创建对象、调用方法,这也是面向对象风格里常见的写法。你可能会觉得代码量更大了,这正是这种风格的成本——用更多的类型定义换来每个模块独立的可测试性和可替换性。
4. KWIC实现的避坑:索引错位、排序大小写和管道阻塞
三种代码都跑通之后,真正的血泪经验才开始。下面五条坑我都在不同项目里踩过,每一条都按"现象→原因→解决"的顺序写清楚。
4.1 忽略词过滤:跳过还是事后删,内存差一倍
现象:数据量到几千条标题时,程序内存一路涨,甚至卡死。检查代码发现,有些实现先把所有循环移位都生成到列表里,再统一删除忽略词开头的行,导致列表里堆了大量无用的移位。
原因:循环移位的数量等于标题单词总数之和。一个 10 个词的标题会产生 10 个移位,如果其中 3 个以忽略词开头,那么这 3 条在生成阶段就该丢弃;事后删意味着这 3 条白白在内存里存在过。这在标题少时不明显,标题数量大了内存翻倍甚至更多。
解决:在生成循环移位时直接判断首词,命中忽略词就continue不产出,也就是前两个实现里if tokens[start].lower() in ignored: continue这一行。养成习惯:过滤条件能前置就前置,不要让无效数据进入后续管道。
4.2 字典序排序的大小写翻车
现象:输出结果里The Quick Brown Fox排到了a day in the life...前面,看起来"字典序"不太对。
原因:Python 的sorted默认按 Unicode 码位排序,大写字母的编码比小写字母小,所以所有大写开头的行会集体排到小写开头的行前面。这在纯英文标题里很常见,如果不显式指定key=str.lower,KWIC 的字母序就是错的。
解决:排序统一加key=lambda s: s.lower(),并且只在排序时转换大小写,输出保留原始样式。另外注意list.sort()是原地排序,会直接改动列表;主程序-子程序风格里如果你后面还想用原始顺序的SHIFTS,就直接用sorted()生成新列表,别用SHIFTS.sort()。
4.3 管道式排序是天然阻塞环节
现象:管道风格的代码明明用了生成器,为什么程序运行后没有任何输出,感觉像卡住了?等了几秒后结果才一次性全部出现。
原因:sort_filter内部用sorted(lines)对生成器做全量排序,sorted必须先迭代完整个生成器才能返回第一个元素。所以管道在这个环节被截停,这不是 bug,是比较排序算法的语义约束。
解决:接受现实,在管道里把这种环节叫"半流式过滤器"。如果要真正做到无限流式输出,就得换思路——比如用归并排序配合外部存储做分桶,或者按首字母分词桶再组内排序。对 KWIC 这种规模,最简单可靠的做法是在注释里标明"此环节有全量缓冲",让后来维护的人有个心理预期。
4.4 面向对象的共享可变对象是隐性耦合
现象:在面向对象版本里调用AlphabetizerModule().sort(shifts)之后,再想重新调用CircularShiftModule.generate_shifts(ignore)拿到的结果为空,或者第二次调用把内部列表的顺序改了,导致第一次输出的索引顺序不对。
原因:generate_shifts返回的是一个生成器(generator),生成器是一次性的,迭代完就耗尽;如果排序模块内部把它转成了list再排序,看起来没改原始数据,但生成器状态已经被消费完了。另一种更隐蔽的情况是排序模块直接对传入的列表调用list.sort()原地排序,而那个列表正好是循环移位模块内部的某个引用,两边就耦合上了。
解决:明确约定"模块间传递的数据是值还是引用"。我这里让sort用sorted生成新列表,绝不原地改传入对象;generate_shifts保持生成器语义,需要复用就重新创建CircularShiftModule或重新生成。面向对象风格的核心价值是隔离,不要在实现里用手滑的原地操作把它破坏掉。
4.5 输出格式:空格、换行和大小写保留
现象:输出行出现单词粘连,比如BrownFoxTheQuick,或者行尾多了一个空格,再或者换行符在 Windows 下变成\r\r\n导致文本比对失败。
原因:如果读入时用line.strip()只去首尾空白,没有规范化行内连着的多个空格,split()与join(' ')组合又要求每一步都正确处理。更常见的坑是拼接移位时用了tokens[start:] + tokens[:start],如果 tokens 里有空字符串,join会多输出空格或漏词。换行问题则来自不同平台的文件读写模式。
解决:统一在分词前用split()按任意空白切分,再join(' ')拼回,这样行内和行尾的空格都被标准化;写文件时统一用\n,如果需要 Windows 兼容可以再单独转换。KWIC 经常要求关键词列对齐,那就是输出模块的职责——提前算好关键词最长宽度,用格式化字符串补空格,别在业务模块里混进排版逻辑。
5. 验证与进阶:回归测试、性能对比和一次扩展实践
三种风格都实现了,接下来要回答"该用哪个"。这一章给出一个可复用的验证方法和性能观察基准,最后再做一个稍微进阶的改造。
5.1 用最小样本把三种实现的输出对齐
写一个简单的回归脚本,输入固定的三行标题和两个忽略词,期望输出是手工算好的结果,三种实现各跑一遍断言一致:
EXPECTED = [ "A Day in the Life of a Developer", "Brown Fox The Quick", "Day in the Life of a Developer A", "Developer A Day in the Life of a", "Fox The Quick Brown", "Jumped Over The Lazy Dog", "Life of a Developer A Day in the", "Over The Lazy Dog Jumped", "Quick Brown Fox The", ] def verify(main_func): main_func('titles.txt', 'stopwords.txt', 'out.txt') with open('out.txt', encoding='utf-8') as fp: got = [line.strip() for line in fp if line.strip()] assert got == EXPECTED, f"expected {EXPECTED}, got {got}"验证脚本的价值在于,它把三种风格的可观察行为钉死在同一份规格上,后续动架构的时候只要跑一遍verify就知道有没有弄坏功能。
5.2 性能对比:调用返回最快,管道内存占大头
我用 1000 条标题、每条 8 到 12 个词的数据在同样的环境里各跑了一次,重点看两件事:耗时和峰值内存。结果符合预期:主程序-子程序版本最快,因为函数调用和全局变量访问的路径最短;管道版本因为在sort_filter里要缓冲全部移位,峰值内存明显高于另外两个;面向对象版本的耗时居中,但每个模块的独立测试成本最低。具体数字依赖机器环境没有普适性,我建议你自己跑了再定。选择建议是:临时脚本用主程序风格,数据量可控看重吞吐用管道风格,系统要长期演进、需求会变用面向对象风格。三种风格不是三选一的关系,在真实系统里经常混用——管道对外,面向对象在管道内做状态管理,主程序风格做启动编排,这都很常见。
5.3 再往前走一步:风格混用和并发改造
我想分享一个实战里很顺手的混用方式:用面向对象的CircularShiftModule做移位生成,但让它的generate_shifts返回生成器,这样它既能面向对象地持有状态,又能像管道过滤器一样把数据流式喂给排序模块。代码不用改,只是把它接进管道的链路里:
shifts = CircularShiftModule(InputModule().read('titles.txt')) stream = shifts.generate_shifts(ignore) sorted_stream = sorted(stream, key=lambda s: s.lower())这个习惯我保持了挺久:先用管道想清楚数据流,再用对象的边界约束每段数据的归属。如果哪天数据量大到单机排序扛不住,就把sorted_stream换成能落盘的归并排序,或者把它丢给分布式排序服务,KWIC 的外壳换了,里面那套过滤、移位、排序的拆分逻辑完全不用动。回看最初对着教材敲代码的时候,我总觉得三种架构风格只是代码摆放姿势不一样,直到有一次要改一个老系统里的索引模块,改主程序版本的时候全局数组牵连了一堆函数,才意识到选风格其实是在选"当需求变化时,改动会被关在哪个笼子里"。希望这篇三种风格的KWIC拆解能让你少交点学费,也希望你手头那个需要索引、排序、过滤的模块,能带着这份判断去选架构——祝顺利。
本文还有配套的精品资源,点击获取