三、SOLID 设计原则

构建模块化软件需要具有丰富的类设计知识。有很多指导方针,包括我们命名类的方式、它们应该具有的变量数量、方法的大小等等。PHP 生态系统成功地将其打包成官方 PSR 标准,更准确地说是PSR-1:基本编码标准PSR-2:编码风格指南。这些都是保持代码可读性、可理解性和可维护性的通用编程准则。

除了编程指南之外,还有一些更具体的设计原则,我们可以在课堂设计中应用。解决低耦合、高内聚和强封装的概念。我们称之为坚实的设计原则,这是罗伯特·塞西尔·马丁在 21 世纪初创造的一个术语。

SOLID是以下五个原则的首字母缩写:

  • S:单一责任原则(SRP
  • O:开/关原理(OCP
  • L:利斯科夫替代原理(LSP
  • I:接口隔离原则(ISP
  • D:依赖倒置原理(DIP

经过十多年的发展,坚实的原则远未过时,因为它们是优秀课堂设计的核心。在本章中,我们将研究这些原则中的每一条,通过观察一些明显违反这些原则的行为来理解它们。

在本章中,我们将介绍以下主题:

  • 单一责任原则
  • 开闭原理
  • 里氏代换原则
  • 界面分离原理
  • 依赖倒置原则

单一责任原则

单一责任原则处理试图做太多事情的类。这里的责任是指改变的理由。根据 Robert C.Martin 的定义:

“一个类应该只有一个更改的原因。”

以下是违反 SRP 的类的示例:

class Ticket {
    const SEVERITY_LOW = 'low';
    const SEVERITY_HIGH = 'high';
    // ...
    protected $title;
    protected $severity;
    protected $status;
    protected $conn;

    public function __construct(\PDO $conn) {
        $this->conn = $conn;
    }

    public function setTitle($title) {
        $this->title = $title;
    }

    public function setSeverity($severity) {
        $this->severity = $severity;
    }

    public function setStatus($status) {
        $this->status = $status;
    }

    private function validate() {
        // Implementation...
    }

    public function save() {
        if ($this->validate()) {
            // Implementation...
        }
    }

}

// Client
$conn = new PDO(/* ... */);
$ticket = new Ticket($conn);
$ticket->setTitle('Checkout not working!');
$ticket->setStatus(Ticket::STATUS_OPEN);
$ticket->setSeverity(Ticket::SEVERITY_HIGH);
$ticket->save();

Ticket类负责验证ticket实体并将其保存到数据库中。这两项责任是它改变的两个原因。无论何时有关票据验证或票据保存的要求发生变化,Ticket 类都必须修改。为了解决这里的 SRP 冲突,我们可以使用辅助类和接口来划分责任。

以下是符合 SRP 的重构实现示例:

interface KeyValuePersistentMembers {
    public function toArray();
}

class Ticket implements KeyValuePersistentMembers {
    const STATUS_OPEN = 'open';
    const SEVERITY_HIGH = 'high';
    //...
    protected $title;
    protected $severity;
    protected $status;

    public function setTitle($title) {
        $this->title = $title;
    }

    public function setSeverity($severity) {
        $this->severity = $severity;
    }

    public function setStatus($status) {
        $this->status = $status;
    }

    public function toArray() {
        // Implementation...
    }
}

class EntityManager {
    protected $conn;

    public function __construct(\PDO $conn) {
        $this->conn = $conn;
    }

    public function save(KeyValuePersistentMembers $entity)
    {
        // Implementation...
    }
}

class Validator {
    public function validate(KeyValuePersistentMembers $entity) {
        // Implementation...
    }
}

// Client
$conn = new PDO(/* ... */);

$ticket = new Ticket();
$ticket->setTitle('Payment not working!');
$ticket->setStatus(Ticket::STATUS_OPEN);
$ticket->setSeverity(Ticket::SEVERITY_HIGH);

$validator = new Validator();

if ($validator->validate($ticket)) {
    $entityManager = new EntityManager($conn);
    $entityManager->save($ticket);
}

在这里我们介绍了一个简单的KeyValuePersistentMembers接口,该接口有一个toArray方法,然后与EntityManagerValidator类一起使用,这两个类现在都承担着单一的责任。Ticket类变成了一个简单的数据保存模型,而客户端现在控制实例化验证保存为三个不同的步骤。虽然这肯定不是如何划分责任的通用公式,但它确实提供了一个简单而明确的例子,说明了如何处理责任。

在思想中,使用单一责任原则进行设计会产生更小的类,具有更高的可读性和更易于测试的代码。

开/关原理

打开/关闭原则规定,一个类应该打开进行扩展,而关闭进行修改,根据维基百科上的定义:

“软件实体(类、模块、函数等)应开放扩展,关闭修改”

openforextension 部分意味着我们应该设计类,以便在需要时添加新功能。closed for modification 部分意味着这个新功能应该在不修改原始类的情况下适应。该类只应在 bug 修复的情况下修改,而不应添加新功能。

以下是违反打开/关闭原则的类的示例:

class CsvExporter {
    public function export($data) {
        // Implementation...
    }
}

class XmlExporter {
    public function export($data) {
        // Implementation...
    }
}

class GenericExporter {
    public function exportToFormat($data, $format) {
        if ('csv' === $format) {
            $exporter = new CsvExporter();
        } elseif ('xml' === $format) {
            $exporter = new XmlExporter();
        } else {
            throw new \Exception('Unknown export format!');
        }
        return $exporter->export($data);
    }
}

这里我们有两个具体的类,CsvExporterXmlExporter,每个类都有一个单独的职责。然后我们有一个GenericExporter及其exportToFormat方法,它实际上在一个适当的实例类型上触发export函数。这里的问题是,我们不能在不修改GenericExporter类的情况下向组合中添加新类型的出口商。换言之,GenericExporter不是打开进行扩展,而是关闭进行修改。

下面的是一个重构实现的示例,符合 OCP:

interface ExporterFactoryInterface {
    public function buildForFormat($format);
}

interface ExporterInterface {
    public function export($data);
}

class CsvExporter implements ExporterInterface {
    public function export($data) {
        // Implementation...
    }
}

class XmlExporter implements ExporterInterface {
    public function export($data) {
        // Implementation...
    }
}

class ExporterFactory implements ExporterFactoryInterface {
    private $factories = array();

    public function addExporterFactory($format, callable $factory) {
          $this->factories[$format] = $factory;
    }

    public function buildForFormat($format) {
        $factory = $this->factories[$format];
        $exporter = $factory(); // the factory is a callable

        return $exporter;
    }
}

class GenericExporter {
    private $exporterFactory;

    public function __construct(ExporterFactoryInterface $exporterFactory) {
        $this->exporterFactory = $exporterFactory;
    }

    public function exportToFormat($data, $format) {
        $exporter = $this->exporterFactory->buildForFormat($format);
        return $exporter->export($data);
    }
}

// Client
$exporterFactory = new ExporterFactory();

$exporterFactory->addExporterFactory(
'xml',
    function () {
        return new XmlExporter();
    }
);

$exporterFactory->addExporterFactory(
'csv',
    function () {
        return new CsvExporter();
    }
);

$data = array(/* ... some export data ... */);
$genericExporter = new GenericExporter($exporterFactory);
$csvEncodedData = $genericExporter->exportToFormat($data, 'csv');

这里我们增加了两个接口ExporterFactoryInterfaceExporterInterface。然后我们修改了CsvExporterXmlExporter以实现该接口。增加了ExporterFactory,实现了ExporterFactoryInterface。其主要角色由buildForFormat方法定义,该方法将导出器作为回调函数返回。最后,GenericExporter被重写,通过其构造函数接受ExporterFactoryInterface,其exportToFormat方法现在使用出口商工厂构建出口商,并在其上调用execute方法。

客户机本身现在扮演了一个更强大的角色,首先实例化ExporterFactory并向其添加两个导出器,然后将其传递到GenericExporter。现在GenericExporter增加了一个新的导出格式,不再需要修改,因此打开进行扩展,关闭进行修改。同样,这决不是一个通用的公式,而是一个关于满足 OCP 的可能方法的概念。

利斯科夫替代原理

利斯科夫替代原理谈到了遗传。它规定了我们应该如何设计我们的类,以便客户端依赖项可以被子类替换,而客户端看不到差异,正如维基百科上的定义:

“程序中的对象应可替换为其子类型的实例,而不会改变该程序的正确性”

虽然子类中可能添加了一些特定的功能,但它必须遵循与其基类相同的行为。否则就违反了里斯科夫原则。

当谈到 PHP 和子类时,我们必须超越简单的具体类并加以区分:具体类、抽象类和接口。这三个类中的每一个都可以放在基类的上下文中,而扩展或实现它的所有内容都可以看作是派生类。

以下是 LSP 冲突的示例,其中派生类没有针对所有方法的实现:

interface User {
    public function getEmail();
    public function getName();
    public function getAge();
}

class Employee implements User {
    public function getEmail() {
        // Implementation...
    }

    public function getAge() {
        // Implementation...
    }
}

这里我们看到一个employee类,它没有实现接口强制的getName方法。对于getName方法,我们可以很容易地使用一个抽象类来代替接口和抽象方法类型,效果也是一样的。幸运的是,在这种情况下,PHP 会抛出一个错误,警告我们尚未完全实现接口。

下面是 Liskov 原则冲突的一个示例,其中不同的派生类返回不同类型的对象:

class UsersCollection implements \Iterator {
    // Implementation...
}

interface UserList {
    public function getUsers();
}

class Emloyees implements UserList {
    public function getUsers() {
        $users = new UsersCollection();
        //...
        return $users;
    }
}

class Directors implements UserList {
    public function getUsers() {
        $users = array();
        //...
        return $users;
    }
}

这里我们看到一个边缘案例的简单示例。在上调用getUsers两个派生类将返回一个我们可以循环使用的结果。然而,PHP 开发人员往往在数组结构上使用count方法,在Employees实例上使用getUsers结果将不起作用。这是因为Employees类返回实现IteratorUsersCollection,而不是实际的数组结构。由于UsersCollection没有实现Countable,我们无法在其上使用count,这将导致潜在的错误。

在派生类对方法参数的行为不太允许的情况下,我们可以进一步发现 LSP 冲突。通常可以通过type操作符的实例来发现,如下例所示:

interface LoggerProcessor {
    public function log(LoggerInterface $logger);
}

class XmlLogger implements LoggerInterface {
    // Implementation...
}

class JsonLogger implements LoggerInterface {
    // Implementation...
}

class FileLogger implements LoggerInterface {
    // Implementation...
}

class Processor implements LoggerProcessor {
    public function log(LoggerInterface $logger) {
        if ($logger instanceof XmlLogger) {
            throw new \Exception('This processor does not work with XmlLogger');
        } else {
            // Implementation...
        }
    }
}

这里,派生类Processor对方法参数进行了限制,同时它应该接受符合LoggerInterface的所有内容。通过减少许可,它改变了基类所隐含的行为,在本例中为LoggerInterface

概述的示例仅是构成违反 LSP 的部分内容。为了满足这个原则,我们需要确保派生类不会以任何方式改变基类强加的行为。

接口隔离原则

接口隔离原则规定客户端只应实现其实际使用的接口。不应该强迫他们实现他们不使用的接口。根据维基百科上的定义:

“许多特定于客户端的接口比一个通用接口要好”

这意味着我们应该将大而胖的接口拆分为几个小而轻的接口,将其隔离,以便更小的接口基于一组方法,每个方法都提供一个特定的功能。

让我们来看看下列违反 ISP 的泄漏抽象:

interface Appliance {
    public function powerOn();
    public function powerOff();
    public function bake();
    public function mix();
    public function wash();

}

class Oven implements Appliance {
    public function powerOn() { /* Implement ... */ }
    public function powerOff() { /* Implement ... */ }
    public function bake() { /* Implement... */ }
    public function mix() { /* Nothing to implement ... */ }
    public function wash() { /* Cannot implement... */ }
}

class Mixer implements Appliance {
    public function powerOn() { /* Implement... */ }
    public function powerOff() { /* Implement... */ }
    public function bake() { /* Cannot implement... */ }
    public function mix() { /* Implement... */ }
    public function wash() { /* Cannot implement... */ }
}

class WashingMachine implements Appliance {
    public function powerOn() { /* Implement... */ }
    public function powerOff() { /* Implement... */ }
    public function bake() { /* Cannot implement... */ }
    public function mix() { /* Cannot implement... */ }
    public function wash() { /* Implement... */ }
}

这里我们有对几种设备相关方法的接口设置要求。然后我们有几个类实现这个接口。问题很明显,;并非所有的设备都可以压缩到同一个接口中。洗衣机被迫采用烘烤和混合的方法是没有意义的。这些方法需要分别划分为各自的接口。这样,具体的设备类就只能实现真正有意义的方法。

依赖倒置原理

依赖倒置原则规定实体应该依赖抽象,而不是具体。也就是说,高级模块不应该依赖于低级模块,而应该依赖于抽象。根据维基百科上的定义:

“一个人应该依赖抽象,不要依赖具体。”

这一原则很重要,因为它在软件解耦中起着重要作用。

以下是违反 DIP 的类的示例:

class Mailer {
    // Implementation...
}

class NotifySubscriber {
    public function notify($emailTo) {
        $mailer = new Mailer();
        $mailer->send('Thank you for...', $emailTo);
    }
}

在这里,我们可以看到NotifySubscriber类编码中的notify方法依赖于Mailer类。这使得代码紧密耦合,这正是我们试图避免的。为了纠正这个问题,我们可以通过类构造函数传递依赖关系,或者可能通过其他方法传递依赖关系。此外,我们应该从具体的类依赖转移到抽象的类依赖,如下面的示例所示:

interface MailerInterface {
    // Implementation...
}

class Mailer implements MailerInterface {
    // Implementation...
}

class NotifySubscriber {
    private $mailer;

    public function __construct(MailerInterface $mailer) {
        $this->mailer = $mailer;
    }

    public function notify($emailTo) {
        $this->mailer->send('Thank you for...', $emailTo);
    }
}

这里我们看到一个依赖项通过构造函数注入。注入由类型暗示接口和实际的具体类抽象。这使得我们的代码松散耦合。只要一个类需要调用另一个类的方法,或者我们应该说向它发送一条消息,就可以使用 DIP。

总结

当涉及到模块化开发时,可扩展性是需要不断思考的问题。编写一个锁定自身的代码可能会导致将来无法将其与其他项目或库集成。虽然对于某些零件来说,可靠的设计原则可能看起来有些过分,但积极应用这些原则可能会导致组件易于维护并随着时间的推移而扩展。

接受坚实的类设计原则,为将来的更改准备代码。它通过在类中本地化和最小化这些更改来实现,因此使用它的任何集成都不会感受到更改的重大影响。

接下来,在下一章中,我们将研究如何定义我们将在所有其他章节中构建的应用程序规范。