01.内聚与耦合

37 阅读22分钟

内聚与耦合

1.介绍

现实世界中的项目规模往往相当庞大。例如,一些大型项目可能有数十万行,甚至上百万、上千万行代码。如何才能在这样的项目上良好工作?模块化就是提升代码可理解性、可演进性、可复用性的关键

从设计层面看,模块化分解的最高指导原则是高内聚,模块间协作的最高指导原则是低耦合

高内聚、低耦合是提升代码可理解性、可演进性、可复用性的关键 image.png

2.代码矩阵

image.png

3.内聚

介绍

内聚描述了一个代码元素边界内内容的紧密程度

代码元素视划分粒度的不同而不同,如子系统、模块、类、方法等

内聚反映了设计单元内部的相关性

image.png

级别

image.png

低内聚

介绍

不内聚的代码会增加理解难度,降低演进能力,降低复用的可能性

案例
public AccountService {
    public LoginResultDTO login(String accountName, String securedPassword) {
        Account account = getAccount(accountName, securedPassword);
        if (account == null) {
            throw new RequestedResourceNotFound"账号或密码不正确!");
        }
        LoginResultDTO r = new LoginResultDTO();
        if (AccountType.STUDENT.equals(account.getDomain())) {
            List<RecordConsumptionDTO> consumptionRecords = consumptionService
            .getByAccount(account.getId());
            if (consumptionRecords != null && consumptionRecords.size() > 0) {
                // 存在消费记录, 代码略
            } else {
                // 不存在消费记录, 代码略
            }
        }
        String token = buildSession(account);
        r.setToken(token);
        r.setAccount(helper.toAccountDTO(account));
        return r;
    }
}
为什么是低内聚

为什么低内聚呢,主要是由于:方法名字来看是和登录相关,但是混入了消费记录的相关内容

后果
  • 从易于理解角度看:代码的可理解性下降了。代码阅读者的本意只是搞懂登录的逻辑,却不得不了解和消费记录相关的问题
  • 从易于演进角度看“代码变更的可能性增加了。登录逻辑的变化频率一般较低,但是消费记录的逻辑,以及消费记录的展示是否要和登录动作放在一起都有更多变化的可能。一段代码多了一个变化源,代码变更的可能性必然也会增加
  • 从易于复用的角度看:登录功能本来是一个通用资产,可在各种场景下使用,但是加入了和消费记录相关的信息后,就只能在本系统中使用了
优化方案
  • 让AccountService的login方法聚焦登录相关的业务逻辑
  • 新建或复用消费记录相关的类ConsumptionRecordService,提供和消费记录相关的服务
  • 在外围增加一个面向特定应用场景的类UserLoginService,组合AccountService和ConsumptionRecordService的能力

下图展示了重新分配职责之后的结果。这样的设计让登录模块的职责变得内聚,相应地也增强了这一模块的可理解性、稳定性、可复用性

偶然内聚
介绍

偶然内聚是所有内聚性级别中最低的、也是最差的一种形式

简单来说,偶然内聚是指一个模块(或类)中的各个元素(如方法、属性)之间毫无关联,它们没有任何概念上或逻辑上的联系,仅仅是因为偶然的原因被放在了同一个源文件中。它们之间唯一的共同点就是它们的物理位置相同

危害
  • 职责极度混乱:模块没有明确的目的,这严重违反了单一职责原则(SRP)
  • 极难复用:因为模块包含大量无关功能,其他模块如果只想使用其中一个功能,却不得不引入整个庞大且臃肿的类,造成不必要的依赖。
  • 极难维护:修改其中一个功能可能会意外影响到同一类中的其他不相关功能,导致霰弹式修改或发散式修改的风险增加
案例

为了更直观地理解,我们可以看一个典型的偶然内聚的Java代码示例:一个把所有不知道放哪里的方法都塞进去的MiscHelper类

    public class MiscHelper {
        
        // 功能1:在控制台打印下一行
        public void printNextLine(String text) {
            System.out.println(text);
        }
        
        // 功能2:反转字符串
        public String reverseString(String str) {
            return new StringBuilder(str).reverse().toString();
        }
        
        // 功能3:计算员工税率
        public double calculateTax(double salary) {
            return salary * 0.2;
        }
        
        // 功能4:连接数据库
        public Connection connectToDatabase(String url) {
            // 数据库连接逻辑
            return null;
        }
    }
解决方案

要解决偶然内聚的问题,我们需要使用提取类的重构技巧,将这些毫无关联的元素拆分到独立的、职责单一的类中,使它们达到最高的内聚级别——功能内聚

    // 拆分1:专门处理控制台 IO 
    public class ConsolePrinter {
        public void printNextLine(String text) {
            System.out.println(text);
        }
    }

    // 拆分2:专门处理字符串算法
    public class StringUtils {
        public String reverseString(String str) {
            return new StringBuilder(str).reverse().toString();
        }
    }

    // 拆分3:核心业务逻辑
    public class TaxCalculator {
        public double calculateTax(double salary) {
            return salary * 0.2;
        }
    }

    // 拆分4:基础设施层
    public class DatabaseConnectionManager {
        public Connection connectToDatabase(String url) {
            // 数据库连接逻辑
            return null;
        }
    }
逻辑内聚
介绍

只要各个元素在逻辑概念上分为同一类,不论它们的本质功能是否有很大差异,都会被强行放置在同一个模块或类中

在代码实现中,逻辑内聚最显著的特征是:模块内部包含多个逻辑上相似的任务,通常需要调用方传入一个控制参数(如标志位、枚举值等)来决定具体执行哪一条代码分支

危害
  • 功能差异显著,职责不纯粹:这些操作虽然在概念上相关,但实际执行的功能往往大相径庭。例如,将所有的鼠标和键盘处理逻辑都放在同一个输入处理子程序中,或者将读取磁带、磁盘和网络的逻辑放在同一个组件中
  • 理解与维护成本高:由于需要处理多种情况,代码中通常会包含大量的if-else或switch-case分支逻辑,导致代码极度臃肿,增加了开发者的理解成本
  • 牵一发而动全身:因为多种底层实现逻辑交织在一起,修改其中一个逻辑分支(例如修改网络读取的超时配置)可能会在不经意间破坏同方法内的其他无关逻辑
案例

这里我们使用一个经典的例子:一个负责从不同数据源(磁盘、网络、磁带)读取输入的组件

在这个例子中,DataReader类的readData方法通过传入的sourceType标志位来判断该执行哪个逻辑:

    public class DataReader {
        
        // 逻辑内聚:将所有“读取”逻辑塞在一个方法里,通过标志位区分
        public String readData(String sourceType) {
            if ("DISK".equalsIgnoreCase(sourceType)) {
                // 执行大量的磁盘文件读取逻辑
                System.out.println("Reading data from disk...");
                return "Disk Data";
            } else if ("NETWORK".equalsIgnoreCase(sourceType)) {
                // 执行复杂的网络套接字连接与读取逻辑
                System.out.println("Reading data from network...");
                return "Network Data";
            } else if ("TAPE".equalsIgnoreCase(sourceType)) {
                // 执行磁带设备的读取逻辑
                System.out.println("Reading data from tape...");
                return "Tape Data";
            } else {
                throw new IllegalArgumentException("Unknown source type");
            }
        }
    }

这段代码将磁盘、网络、磁带的读取操作全部放在同一个组件和方法中。虽然它们在逻辑上都属于数据读取,但由于操作的硬件和协议显著不同,它们的实际功能大相径庭。这不仅让readData方法变得异常复杂,也使得该模块在未来极难扩展

解决方案

为了消除逻辑内聚带来的代码异味,在面向对象编程中,我们通常采用以多态替换条件表达式的重构技巧。我们可以将复杂的条件判断拆解,提取出公共接口,并为每种读取方式创建独立的、高度内聚的类

    // 1. 提取公共接口,定义统一的读取行为
    public interface DataReader {
        String readData();
    }

    // 2. 磁盘读取器:只专注负责磁盘读取(功能内聚)
    public class DiskDataReader implements DataReader {
        @Override
        public String readData() {
            System.out.println("Reading data from disk...");
            return "Disk Data";
        }
    }

    // 3. 网络读取器:只专注负责网络读取(功能内聚)
    public class NetworkDataReader implements DataReader {
        @Override
        public String readData() {
            System.out.println("Reading data from network...");
            return "Network Data";
        }
    }

    // 4. 磁带读取器:只专注负责磁带读取(功能内聚)
    public class TapeDataReader implements DataReader {
        @Override
        public String readData() {
            System.out.println("Reading data from tape...");
            return "Tape Data";
        }
    }
时间内聚
介绍

它指的是模块中的各个元素(如函数、代码块)被组合在一起,仅仅是因为它们需要在同一时间段内或相近的时间点执行,而不是因为它们在功能上或数据上有直接的必然联系

在实际开发中,时间内聚最经典的代表就是各种系统的初始化模块、清理/关闭模块,或者是处理周期性定时任务的模块。在这些模块中,往往会在同一时刻发生许多完全不同、跨越多个业务领域的活动

危害
  • 违背单一职责原则:这些操作虽然在时间上相关,但实际执行的业务功能大相径庭,这意味着该模块承担了太多不同的职责
  • 极难复用:如果系统的其他地方只想要使用该模块中的某一个独立功能(比如只想单独清理一次临时文件),往往无法直接调用,因为调用该方法会连带触发同一个时间段内绑定的其他无关操作
  • 难以测试:因为包含了大量不同领域的逻辑,测试该模块需要mock海量的底层依赖,导致单元测试异常困难
案例

这里我们使用一个最常见的例子:一个负责在系统启动时执行所有准备工作的SystemInitializer类

在这个例子中,SystemInitializer的startUp方法把所有需要在系统启动时做的事情全部塞在了一起:

    public class SystemInitializer {
        
        // 时间内聚:将所有需要在“系统启动”这一时间点执行的逻辑强行捆绑
        public void startUp() {
            // 1. 读取全局配置文件
            System.out.println("Loading global configuration files...");
            
            // 2. 建立与数据库的连接
            System.out.println("Initializing database connection pool...");
            
            // 3. 启动后台日志服务
            System.out.println("Starting logging service...");
            
            // 4. 清理昨天遗留的临时缓存文件
            System.out.println("Cleaning up temporary cache files...");
            
            // 5. 渲染用户界面
            System.out.println("Rendering User Interface...");
        }
    }

这段代码中,读取配置、清理缓存、连接数据库、日志服务和前端展示被杂揉在同一个方法内。它们唯一的共同点就是都要在系统启动的这一刻执行。如果系统运行到一半,因为网络波动需要重新初始化数据库连接,开发者会发现他们无法复用startUp方法,因为调用它会导致UI重新渲染、配置被重新加载。这就是时间内聚带来的复用性灾难

解决方案

为了消除时间内聚,我们需要对代码进行解耦和职责分离。重构的核心思想是:将不同领域的逻辑提取到各自专注的、达到功能内聚的类中,然后再通过一个高层协调者(或生命周期管理器)来按顺序调用它们

    // 1. 专注配置管理(功能内聚)
    public class ConfigManager {
        public void loadConfig() {
            System.out.println("Loading global configuration files...");
        }
    }

    // 2. 专注数据库连接(功能内聚)
    public class DatabaseManager {
        public void initializePool() {
            System.out.println("Initializing database connection pool...");
        }
    }

    // 3. 专注文件系统清理(功能内聚)
    public class CacheCleaner {
        public void cleanTempFiles() {
            System.out.println("Cleaning up temporary cache files...");
        }
    }

    // 4. 专注UI渲染(功能内聚)
    public class UIManager {
        public void render() {
            System.out.println("Rendering User Interface...");
        }
    }

    // ==========================================
    // 启动协调者:不再包揽所有逻辑,而是委托给专业的类
    // ==========================================
    public class ApplicationStarter {
        
        private final ConfigManager configManager = new ConfigManager();
        private final DatabaseManager dbManager = new DatabaseManager();
        private final CacheCleaner cacheCleaner = new CacheCleaner();
        private final UIManager uiManager = new UIManager();

        public void startUp() {
            configManager.loadConfig();
            dbManager.initializePool();
            // ... 省略日志服务启动 ...
            cacheCleaner.cleanTempFiles();
            uiManager.render();
        }
    }
过程内聚
介绍

模块中的各个元素被组合在一起,仅仅是因为它们需要遵循一个特定的执行步骤顺序

换句话说,这些操作在功能上往往是弱相关的,它们被塞进同一个类或方法中,只是为了保证按照某种预定的流程(1步、2步、3步)执行。这种内聚类型通常在传统的结构化编程语言中比较常见

危害
  • 弱相关性与极低的复用性:过程内聚的元素只是保证了执行顺序,但彼此之间的业务领域可能毫不相干。如果其他模块只想复用该流程中的某一步,往往无法直接调用,只能被迫复制代码
  • 违反单一职责原则(SRP):模块存在的目的变成了完成一系列流程,而不是完成单一功能。这种模块通常包含各种不同领域的逻辑(如既包含业务计算,又包含UI打印或文件IO)
案例

一个负责处理学生期末档案的模块,它按照流程顺序依次执行计算和打印操作

在这个例子中,StudentProcessor类的processStudent方法将几个必须按特定顺序发生的操作硬凑在了一起:

    public class StudentProcessor {
        
        // 过程内聚:元素被组合在一起仅仅是为了遵循特定的执行步骤顺序
        public void processStudent(Student student) {
            // 步骤 1:计算当前学期的 GPA
            double gpa = calculateGPA(student);
            
            // 步骤 2:打印学生当前学期记录(输出到控制台或报表)
            printStudentRecord(student, gpa);
            
            // 步骤 3:计算学生所有的累计 GPA
            double cumulativeGpa = calculateCumulativeGPA(student);
            
            // 步骤 4:打印累计 GPA 记录
            printCumulativeRecord(student, cumulativeGpa);
        }

        private double calculateGPA(Student student) {
            // ... 计算逻辑 ...
            return 3.5;
        }

        private void printStudentRecord(Student student, double gpa) {
            // ... IO 打印逻辑 ...
        }

        private double calculateCumulativeGPA(Student student) {
            // ... 计算逻辑 ...
            return 3.6;
        }

        private void printCumulativeRecord(Student student, double cgpa) {
            // ... IO 打印逻辑 ...
        }
    }

上述代码就是典型的过程内聚:计算GPA、打印记录、计算累计GPA、打印累计记录

这些步骤因为业务流程上需要按顺序发生,所以被放在了一个方法里。 但是,计算数学成绩(核心业务逻辑)和打印展示(表现层逻辑)在本质上是两码事。如果未来另一个系统(比如一个给学生发送短信的后台任务)只想复用calculateCumulativeGPA的计算逻辑,而不想触发打印操作,那么当前的StudentProcessor是完全无法被复用的

解决方案

为了解决过程内聚的问题,我们需要将不同的职责剥离,将混合在一起的流程拆分到专注于单一任务的高内聚类中

    // 1. 专注计算核心业务逻辑的类(功能内聚)
    public class GPACalculator {
        public double calculateGPA(Student student) {
            // ... 纯粹的计算逻辑 ...
            return 3.5;
        }
        
        public double calculateCumulativeGPA(Student student) {
            // ... 纯粹的计算逻辑 ...
            return 3.6;
        }
    }

    // 2. 专注负责数据展示和报表生成的类(功能内聚)
    public class StudentRecordPrinter {
        public void printStudentRecord(Student student, double gpa) {
            // ... 纯粹的打印逻辑 ...
        }
        
        public void printCumulativeRecord(Student student, double cgpa) {
            // ... 纯粹的打印逻辑 ...
        }
    }

    // ==========================================
    // 高层应用协调者:通过组合不同功能内聚的类来完成流程
    // ==========================================
    public class StudentApplicationService {
        
        private final GPACalculator calculator = new GPACalculator();
        private final StudentRecordPrinter printer = new StudentRecordPrinter();

        public void processStudent(Student student) {
            // 在高层方法中编排顺序,而将具体实现委托给功能内聚的底层模块
            double gpa = calculator.calculateGPA(student);
            printer.printStudentRecord(student, gpa);
            
            double cgpa = calculator.calculateCumulativeGPA(student);
            printer.printCumulativeRecord(student, cgpa);
        }
    }
通信内聚
介绍

在某些文献中也称为联络内聚或信息内聚,是指模块中的各个功能或元素被组合在一起,仅仅是因为它们操作相同的输入数据,或者产生相同的输出数据

特点

基于数据共享的组合:操作的联系建立在数据之上。例如,一个模块中的多个功能都存取同一个文件记录,或者更新数据库中的记录并将其发送到打印机

注意
  • 软件工程先驱们(如Larry Constantine)认为通信内聚是一种可接受的内聚类型,通常优于偶然内聚、逻辑内聚、时间内聚和过程内聚,但其纯度仍不及最理想的功能内聚
  • 在面向对象编程语言中,这种内聚形式非常常见,因为模块(或类)往往是围绕特定的数据结构或外部交互系统来组织的
危害
  • 潜在的职责过载:虽然它们处理同一份数据,但执行的业务动作在本质上可能属于完全不同的领域(例如数据持久化与IO打印),这可能导致模块承担多重职责,降低了单一功能的独立复用性
案例

以更新数据库记录并发送到打印机为例

在这个例子中,CustomerDataProcessor类的process方法将两个完全不同的操作绑在了一起,仅仅因为它们都使用了Customer数据

    public class CustomerDataProcessor {
        
        // 通信内聚:这两个操作被放在同一个方法内,
        // 仅仅是因为它们操作相同的输入数据 (Customer 对象)
        public void process(Customer customer) {
            // 操作1:将客户数据更新到数据库
            updateRecordInDatabase(customer);
            
            // 操作2:将同一份客户数据发送到打印机
            sendToPrinter(customer);
        }

        private void updateRecordInDatabase(Customer customer) {
            System.out.println("Saving customer " + customer.getName() + " to database...");
            // ... 复杂的 JDBC/JPA 持久化逻辑 ...
        }

        private void sendToPrinter(Customer customer) {
            System.out.println("Sending customer " + customer.getName() + " record to printer...");
            // ... 复杂的 I/O 流与打印机通信逻辑 ...
        }
    }

上述代码展示了典型的通信内聚:持久化逻辑(写入数据库)和展现逻辑(发送到打印机)被紧紧耦合在process方法中。它们之间没有固定的顺序依赖(谁先谁后其实并不影响各自的执行结果),它们唯一的纽带就是输入参数customer

解决方案

为了进一步提升系统设计质量,我们可以将基于数据的通信内聚,拆解提升为基于单一职责的功能内聚。我们将操作同一数据的不同行为,剥离到各自专注的类中

    // 1. 专注数据持久化的类(功能内聚)
    public class CustomerRepository {
        public void updateRecord(Customer customer) {
            System.out.println("Saving customer " + customer.getName() + " to database...");
        }
    }

    // 2. 专注数据打印的类(功能内聚)
    public class CustomerPrinter {
        public void printRecord(Customer customer) {
            System.out.println("Sending customer " + customer.getName() + " record to printer...");
        }
    }

    // ==========================================
    // 高层服务协调者
    // ==========================================
    public class CustomerService {
        
        private final CustomerRepository repository = new CustomerRepository();
        private final CustomerPrinter printer = new CustomerPrinter();

        // 在高层编排业务流
        public void processCustomer(Customer customer) {
            repository.updateRecord(customer);
            printer.printRecord(customer);
        }
    }

高内聚

优点

顺序内聚
介绍

顺序内聚是仅次于最理想的功能内聚。在软件工程中,它被广泛认为是一种良好且推荐的设计模式

模块中的各个元素被组合在一起,是因为它们的输入和输出数据紧密相关——前一个元素的输出数据,正好是后一个元素的输入数据

特点
  • 逻辑紧密,易于理解:由于数据流向非常清晰(A的输出给B,B的输出给C),代码的阅读体验很像在读一个故事,开发者很容易顺藤摸瓜理解整个业务流程
  • 高可维护性:各步骤之间通过数据签名(参数和返回值)进行契约传递,没有复杂的共享全局状态
危害

虽然顺序内聚很好,但如果流水线上的某一个独立加工步骤(例如数据清洗)具有普适性,把它和读取或后续计算硬绑在一个模块里,会降低该单一步骤的独立复用性

案例

我们以一个文档处理流水线为例:系统需要读取一个文本文件,将其中的特殊字符清洗掉,然后提取出高频关键字

在这个例子中,DocumentProcessor类的方法形成了一个完美的数据传递链条:

    public class DocumentProcessor {
        
        // 顺序内聚:步骤之间形成了直接的“输出->输入”的数据流依赖
        public List<String> processDocument(String filePath) {
            
            // 步骤 1:读取文件。输出 rawText
            String rawText = readFile(filePath);
            
            // 步骤 2:清洗文本。输入 rawText,输出 cleanedText
            String cleanedText = cleanText(rawText);
            
            // 步骤 3:提取关键字。输入 cleanedText,输出关键字列表
            List<String> keywords = extractKeywords(cleanedText);
            
            return keywords;
        }

        private String readFile(String path) {
            System.out.println("Reading file from: " + path);
            // ... 文件 I/O 逻辑 ...
            return "  Raw text with some #$% special chars!  ";
        }

        private String cleanText(String text) {
            System.out.println("Cleaning up text...");
            // ... 字符串处理逻辑 ...
            return text.replaceAll("[^a-zA-Z0-9 ]", "").trim();
        }

        private List<String> extractKeywords(String text) {
            System.out.println("Extracting keywords...");
            // ... 算法提取逻辑 ...
            return Arrays.asList("Raw", "text", "special", "chars");
        }
    }

这就是标准的顺序内聚。readFile的产物交给了cleanText,cleanText的产物交给了extractKeywords。它比之前的过程内聚(单纯因为业务需要按顺序执行,但无数据流转)要好得多,因为这里的方法彼此之间有着强有力的数据血缘关系

解决方案

虽然顺序内聚已经相当不错了,但在现代面向对象设计(如SOLID原则)中,上述代码仍有瑕疵:DocumentProcessor同时包含了文件IO、字符串操作和核心算法三种不同的职责

如果系统中的另一个模块只想复用cleanText(文本清洗功能),它就不得不实例化整个DocumentProcessor。为了达到完美的功能内聚,我们需要将流水线上的工作站拆分为独立、纯粹的类,再通过高层服务将它们串联起来:

    // 1. 纯粹负责文件 I/O 的类(功能内聚)
    public class FileReader {
        public String read(String path) {
            return "  Raw text with some #$% special chars!  ";
        }
    }

    // 2. 纯粹负责文本清洗的类(功能内聚)
    public class TextCleaner {
        public String clean(String text) {
            return text.replaceAll("[^a-zA-Z0-9 ]", "").trim();
        }
    }

    // 3. 纯粹负责算法提取的类(功能内聚)
    public class KeywordExtractor {
        public List<String> extract(String text) {
            return Arrays.asList("Raw", "text", "special", "chars");
        }
    }

    // ==========================================
    // 协调者类:利用独立的高内聚组件组装流水线
    // ==========================================
    public class DocumentService {
        private final FileReader reader = new FileReader();
        private final TextCleaner cleaner = new TextCleaner();
        private final KeywordExtractor extractor = new KeywordExtractor();

        public List<String> process(String filePath) {
            // 依然保持顺序调用的清晰数据流,但底层实现已完全解耦并实现功能内聚
            String raw = reader.read(filePath);
            String cleaned = cleaner.clean(raw);
            return extractor.extract(cleaned);
        }
    }
功能内聚
介绍

它指的是模块中的所有元素(如类中的方法和属性)共同致力于完成一个单一且明确定义的任务。在这种状态下,模块包含了完成该单一计算或功能所需的所有基本元素,并且几乎没有或完全没有与该核心任务无关的耦合逻辑

特点

职责极其纯粹:模块只专注于做一件特定的事情。由于模块内所有元素都指向同一个且仅一个明确定义的功能,其代码逻辑高度单一

优点
  • 最高级别的可维护性与可复用性:软件工程先驱们(如Larry Constantine、Edward Yourdon和Steve McConnell)的研究一致认为,功能内聚是最理想的模块化状态。因为它不仅让代码变得清晰,更使得该模块可以非常容易地被系统的其他地方或完全不同的项目直接复用
  • 极佳的可测试性:因为模块的行为高度集中、纯粹且自洽,开发者不需要配置复杂的外部依赖即可对其进行隔离和单元测试
案例

以对XML字符串进行词法分析

在这个例子中,XmlLexer(XML词法分析器)类被设计为只负责一个单一、明确的任务:将一段XML文本解析成一个个独立的词法单元(Token)

    public class XmlLexer {
        
        // 功能内聚:该类的核心公开入口,仅为了完成“词法分析”这一单一明确的任务
        public List<Token> tokenize(String xmlString) {
            if (xmlString == null || xmlString.isEmpty()) {
                return Collections.emptyList();
            }
            
            List<Token> tokens = new ArrayList<>();
            // ... 逐字符遍历,执行词法分析的核心逻辑 ...
            
            return tokens;
        }

        // 辅助方法 1:仅为支撑词法分析任务而存在
        private boolean isTagStart(char c) {
            return c == '<';
        }

        // 辅助方法 2:仅为支撑词法分析任务而存在
        private boolean isAttributeDefinition(String str) {
            // ... 复杂的属性解析规则 ...
            return true;
        }
    }

由于XmlLexer实现了功能内聚,如果未来你的系统需要在另一个完全不相关的网络流处理模块中验证一段XML,你可以毫无负担地直接复用XmlLexer。你不需要为了复用它而被迫引入一堆无关的文件IO依赖或日志库依赖

4.耦合

介绍

耦合是设计单元之间相关性的表征。如果两个设计单元之间存在某种关系,使得当一个设计单元发生变化或者出现故障时,另外一个设计单元也会受到影响,那我们就说这二者之间存在耦合

注意

耦合不可避免。只要是模块化设计,就必然会出现耦合——设计单元之间的协作是实现丰富功能的基础

目标

不同设计产生的耦合是不一样的。过度耦合是软件设计不稳定、不健壮的根源,我们应该考虑的是如何避免过度耦合

核心思路

耦合是为了集成,集成就一定存在依赖关系,所以我们可以从依赖关系下手

紧耦合的场景

  • 循环依赖导致的紧耦合
  • 依赖层级过深导致的紧耦合
  • 依赖范围广导致的紧耦合

循环依赖导致的紧耦合

介绍

循环依赖是一种非常紧的耦合。因为A的变化会引起B的变化,B的变化也会引起A的变化,所以A和B本质上是一个整体,而不是两个不同的设计单元

解决方案

循环依赖往往意味着设计不合理,或者依赖粒度过大,我们往往需要重新看一下设计

案例

下图中domain包和包infrastructure之间存在循环依赖

如果仔细分析,就会发现只是类InvoiceRateService依赖了StringUtils,类InvoiceRepoImpl实现了InvoiceRepo定义的接口。只要重新调整包infrastructure的粒度,把它划分为包lang和包database,循环依赖就消失了

依赖层级过深导致的紧耦合

介绍

图中的C依赖D,D依赖E。当E变化时,不仅D会受到影响,C也会。所以,在这种依赖中,C和E也存在耦合关系。依赖链越长,耦合影响的范围就越广。尽管链式依赖在设计中无可避免,但是存在许多能够减少依赖链长度的方法

解决方案

可以通过依赖倒置DIP来减少耦合

案例

下图展示的是一种链式依赖,AccountService要用到数据库封装DBWrapper,DBWrapper又依赖于第三方代码库ThirdPartyLibrary。当ThirdPartyLibrary更新时,AccountService也可能受到影响

AccountService真正关心的并不是DBWrapper如何实现,而只是需要它提供的数据库访问服务。那把这种服务封装成接口AccountRepository,然后让DBWrapper实现这个接口,ThirdPartyLibrary的更新就不会影响AccountService了

依赖范围广导致的紧耦合

介绍

下图中表示F依赖于G、H、I。在这种情况下,只要G、H、I中的任何一个发生变化,F就会发生变化。所以,和依赖范围更小的设计单元相比,F的稳定性相对较弱。此时如果能想办法降低F依赖的设计单元的数量,它的稳定性就可以得到增强

解决方案

依赖范围过大,往往也和设计单元承担的职责过多有关,拆分职责即可

案例

下面是代码清单中类AccountService的部分声明

public class AccountService {
    AccountRepository repo;
    SesssionManager sessionManager;
    StudentService studentService;
    ConsumptionService consumptionService;
    // 其他
    public LoginResultDTO login(String accountName, String securedPassword)
    {...}
}

代码清单中的StudentService和ConsumptionService的依赖显然是有问题的。造成这种问题的原因,是原来login方法的职责不够内聚,增加了不必要的功能

通过高内聚拆分职责,把StudentService和ConsumptionService的依赖干掉就行了,在引入新组件