罗伯特·C·马丁在 21 世纪早期引入了名为「SOLID」的设计原则,指代了面向对象编程和面向对象设计的五个基本原则:
- 单一职责原则(Single Responsibility Principle)
- 开放封闭原则(Open Closed Principle)
- 里氏替换原则(Liskov Substitution Principle)
- 接口隔离原则(Interface Segregation Principle)
- 依赖反转原则(Dependency Inversion Principle)
下面我们将深入探索以上每个设计原则,并且通过示例代码来阐述各个原则。我们将看到,每个原则之间都有联系。如果其中一个原则没有被遵循,那么其他大部分(可能不会是全部)的原则也会出问题。
单一职责原则
单一职责原则规定一个类有且仅有一个理由使其改变。
换句话说,一个类的边界和职责应当是十分狭窄且集中的。我们之前就提到过,在类的职责问题上,无知是福。一个类应当做它该做的事,并且不应当被它的任何依赖的变化所影响。
考虑下面这个类:
class OrderProcessor
{
public function __construct(BillerInterface $biller)
{
$this->biller = $biller;
}
public function process(Order $order)
{
$recent = $this->getRecentOrderCount($order);
if($recent > 0)
{
throw new Exception('Duplicate order likely.');
}
$this->biller->bill($order->account->id, $order->amount);
DB::table('orders')->insert(array(
'account' => $order->account->id,
'amount' => $order->amount,
'created_at'=> Carbon::now()
));
}
protected function getRecentOrderCount(Order $order)
{
$timestamp = Carbon::now()->subMinutes(5);
return DB::table('orders')->where('account', $order->account->id)
->where('created_at', '>=', $timestamps)
->count();
}
}
上面这个类的职责是什么?很显然,顾名思义,它是用来处理订单的。但是,通过 getRecentOrderCount 方法,这个类又有了在数据库中审查某个帐号的订单历史以便判断是否有重复订单的职责。这个额外的验证职责意味着当我们的存储方式改变或者订单验证规则改变时,这个订单处理器也要跟着改变。
我们应该将这个验证职责提取出来放到其它类中,比如 OrderRepository 类:
class OrderRepository
{
public function getRecentOrderCount(Account $account)
{
$timestamp = Carbon::now()->subMinutes(5);
return DB::table('orders')->where('account', $account->id)
->where('created_at', '>=', $timestamp)
->count();
}
public function logOrder(Order $order)
{
DB::table('orders')->insert(array(
'account' => $order->account->id,
'amount' => $order->amount,
'created_at'=> Carbon::now()
));
}
}
然后,我们可以将这个仓库类注入到 OrderProcessor 里,以便分担后者对账户订单历史进行检查的责任:
class OrderProcessor {
public function __construct(BillerInterface $biller, OrderRepository $orders)
{
$this->biller = $biller;
$this->orders = $orders;
}
public function process(Order $order)
{
$recent = $this->orders->getRecentOrderCount($order->account);
if($recent > 0)
{
throw new Exception('Duplicate order likely.');
}
$this->biller->bill($order->account->id, $order->amount);
$this->orders->logOrder($order);
}
}
现在,我们已经提取出了收集订单数据的职责,当获取和记录订单的方法改变时,就不再需要修改 OrderProcessor 这个类了。我们的类的职责变得更加专注和集中,同时也让代码变得更简洁、更优雅、更容易维护。
需要注意的是,单一职责原则的核心并不仅仅是让代码变短,而是要写出职责更加明确、方法更加内聚的类,所以要确保类里面所有的方法都隶属于该类的职责之内。在构建一个小巧、清晰且职责明确的类库以后,我们的代码会更加解耦,更容易测试,并且更易于修改。
开放封闭原则
开放封闭原则,又称开闭原则,规定代码对扩展是开放的,对修改是封闭的。
在一个应用的生命周期里,大部分时间都花在了向现有代码库增加功能,而非一直从零开始写新功能。你可能已经意识到了,这会是一个繁琐且令人痛苦的过程。无论何时你修改代码,都有可能引入新的bug,或者破坏原有的旧功能。理想情况下,我们应该可以像写全新的代码一样来快速修改现有的代码。如果采用开放封闭原则来正确设计我们的应用程序,那么这是可以做到的!
为了演示开放封闭原则,我们来继续编写前面实现的 OrderProcecssor 类。考虑下面的 process 方法:
$recent = $this->orders->getRecentOrderCount($order->account);
if($recent > 0)
{
throw new Exception('Duplicate order likely.');
}
这段代码的可读性很好,由于我们使用了依赖注入,也很容易测试。但是,如果我们关于订单验证的业务规则改变了呢?如果我们又有新的规则了呢?更进一步,如果随着我们的业务发展,要增加一大堆新规则呢?那我们的 process 方法将很快变成难以维护的意大利面条式代码。因为这段代码必须随着每次业务规则的改变而改变,它对修改是开放的,这违反了开放封闭原则。记住,我们希望代码对扩展开放,而不是修改。
所以我们要避免把订单验证代码直接写在 process 方法里面,下面我们来定义一个新的接口 OrderValidator:
interface OrderValidatorInterface
{
public function validate(Order $order);
}
接下来,我们来定义一个防止重复订单的实现类:
class RecentOrderValidator implements OrderValidatorInterface
{
public function __construct(OrderRepository $orders)
{
$this->orders = $orders;
}
public function validate(Order $order)
{
$recent = $this->orders->getRecentOrderCount($order->account);
if ($recent > 0)
{
throw new Exception('Duplicate order likely.');
}
}
}
很好!我们封装了一个小巧的、可测试的单一业务规则。然后再创建一个用来验证账号是否停用的实现类:
class SuspendedAccountValidator implements OrderValidatorInterface
{
public function validate(Order $order)
{
if($order->account->isSuspended())
{
throw new Exception("Suspended accounts may not order.");
}
}
}
现在,我们有两个不同的类实现了 OrderValidatorInterface 接口。我们将在 OrderProcessor 中使用它们。我们只需简单注入一个验证器数组到订单处理器实例,今后就可以从代码库中轻松添加和移除验证规则。
class OrderProcessor
{
public function __construct(BillerInterface $biller, OrderRepository $orders, array $validators = array())
{
$this->biller = $bller;
$this->orders = $orders;
$this->validators = $validators;
}
}
接下来,我们只要在 process 方法里面遍历这个验证器数组即可:
public function process(Order $order)
{
foreach($this->validators as $validator)
{
$validator->validate($order);
}
// Process valid order...
}
最后,我们在服务容器中注册 OrderProcessor 类:
$this->app->bind('OrderProcessor', function($app)
{
return new OrderProcessor(
$app->make('BillerInterface'),
$app->make('OrderRepository'),
[
$app->make('RecentOrderValidator'),
$app->make('SuspendedAccountValidator'),
]
);
});
有了这些修改之后,我们就可以实现在不修改任何一行现有代码的情况下添加和移除新的验证规则。每一个新的验证规则就是 OrderValidatorInterface 接口的一个实现类,然后将其注册到服务容器里。不必再对那个又庞大又笨重的process 方法做单元测试了,我们现在可以单独测试每一个验证规则。现在,我们的代码对扩展是开放的,对修改是封闭的。
要小心那些泄露实现细节的依赖。当一个依赖的实现需要改变时,不应该要求它的调用者做任何修改。如果需要调用者进行修改的话,往往意味着该依赖「泄露」了实现的细节。当你的抽象泄露时,开放封闭原则就不管用了。
里氏替换原则
里氏替换原则规定对象可以被其子类的实例所替换,并且不会影响到程序的正确性。
该原则可以描述为:一个抽象的任意实现都可以在声明该抽象的地方替换它。读起来有点绕口,通俗点说就是:如果一个类使用了某个接口的实现,那么一定可以通过该接口的其它实现来替换它,不用做出任何修改。
为了说明该原则,我们继续使用前面编写的 OrderProcessor 类作为示例。请看下面的方法:
public function process(Order $order)
{
// Validate order...
$this->orders->logOrder($order);
}
注意,当 Order 通过验证后,我们就会通过 OrderRepositoryInterface 的实现类实例将其记录下来。假设订单处理业务刚起步时,我们将所有订单都存储到了 CSV 格式的文件系统中。对应的,我们的 OrderRepositoryInterface 的实现类就应该是CsvOrderRepository。现在,随着订单增多,我们想用一个关系数据库来存储订单。下面我们就来看看新的订单资料库类该怎么编写吧:
class DatabaseOrderRepository implements OrderRepositoryInterface
{
protected $connection;
public function connect($username, $password)
{
$this->connection = new DatabaseConnection($username, $password);
}
public function logOrder(Order $order)
{
$this->connection->run('insert into orders values (?, ?)', array(
$order->id, $order->amount
));
}
}
现在,我们来研究如何使用这个实现类:
public function process(Order $order)
{
// Validate order...
if($this->repository instanceof DatabaseOrderRepository)
{
$this->repository->connect('root', 'password');
}
$this->repository->logOrder($order);
}
注意在这段代码中,我们不得不在调用的地方检查 OrderRepositoryInterface 接口是否是通过数据库实现的。如果是的话,则必须连接到数据库。在很小的应用中,这可能看起来没什么问题,但如果OrderRepositoryInterface 在很多类中被调用呢?我们可能就要把这段「启动」代码在每一个调用的地方重复实现。这让人非常头疼,不仅难以维护,而且非常容易出错误,并且一旦我们忘了将所有调用的地方进行同步修改,那程序恐怕就会出问题。
很明显,上面的例子违背了里氏替换原则。因为我们不能在不修改调用方代码的情况下注入接口的实现。所以,既然已经定位到问题所在,接下来就要修复它。下面就是新的 DatabaseOrderRepository 实现:
class DatabaseOrderRepository implements OrderRepositoryInterface
{
protected $connector;
public function __construct(DatabaseConnector $connector)
{
$this->connector = $connector;
}
public function connect()
{
return $this->connector->bootConnection();
}
public function logOrder(Order $order)
{
$connection = $this->connect();
$connection->run('insert into orders values (?, ?)', array(
$order->id, $order->amount
));
}
}
现在 DatabaseOrderRepository 自己接管了数据库连接,这样我们就可以把数据库「启动」代码从 OrderProcessor 中移除了
public function process(Order $order)
{
// Validate order...
$this->repository->logOrder($order);
}
这样一改,我们就可以在 CsvOrderRepository 和 DatabaseOrderRepository 实现之间进行切换了,不用对 OrderProcessor 做任何修改。我们的代码终于实现了里氏替换原则!需要注意的是,我们讨论过的许多架构概念都和「知识」相关。具体来说,一个类所具备的「周边」知识,例如外围代码和依赖,会帮助这个类完成它的工作。当你想要构建一个健壮的大型应用时,限制类的知识会是一个反复出现、非常重要的主题。
还要注意如果不遵守里氏替换原则,那么可能会影响到我们之前已经讨论过的其他原则。不遵守里氏替换原则,那么开放封闭原则一定也会被打破。因为,如果调用者必须检查实例属于哪个子类,则一旦有了新的子类,调用者就得做出改变。
接口隔离原则
接口隔离原则规定,不应该强制接口的实现依赖于它不使用的方法。
你是否曾被迫去实现一些你用不到的接口方法?如果答案是肯定的,那么你可能会在实现里创建一个空方法。这就是一个使用了违背接口隔离原则的接口的例子。
在实际操作中,这个原则要求接口必须粒度很细,且专注于一个领域。听起来很耳熟?记住,所有五个「SOLID」原则都是相关的,也就是说违背了其中一个原则,通常意味着也违背了其他的原则。当你违背了接口隔离原则,肯定也违背了单一职责原则。
与其保有一个包含不是所有实现都需要的方法的「臃肿」接口,不如将其拆分成多个更细粒度的接口,然后各自按需独立实现。这样将臃肿接口拆分成细粒度、功能集中的接口后,调用方也可以依赖更小的接口,而不必为我们不需要的功能买单。
为了说明该原则,我们来看一个会话处理类库的例子。实际上,我们将要考察 PHP 自带的 SessionHandlerInterface。下面是该接口定义的方法,它们是从 PHP 5.4 版才开始有的:
interface SessionHandlerInterface {
public function close();
public function destroy($sessionId);
public function gc($maxLifetime);
public function open($savePath, $name);
public function read($sesssionId);
public function write($sessionId, $sessionData);
}
现在,我们已经知道接口里面都定义了什么方法了,我们打算基于 Memcached 来实现它。Memcached 实现类需要实现这个接口里的所有方法吗?不,里面一半的方法对于 Memcached 来说都是不需要实现的!
因为 Memcached 会自动清除存储的过期数据,我们不需要实现 gc 方法。我们也不需要实现 open 和 close 方法。所以,我们不得不在实现类中将相应的实现定义为空方法。为了解决在这个问题,我们来定义一个小巧的、专门用来回收过期 Session 数据的接口:
interface GarbageCollectorInterface
{
public function gc($maxLifetime);
}
现在我们有了一个更细粒度的接口,功能单一而专注。调用方只需要依赖它就可以了,而不必去依赖整个会话处理器。
为了更深入理解该原则,我们用另一个例子来强化理解。想象我们有一个名为Contact 的 Eloquent 模型类,定义成这样:
class Contact extends Eloquent
{
public function getNameAttribute()
{
return $this->attributes['name'];
}
public function getEmailAttribute()
{
return $this->attributes['email'];
}
}
现在,我们再假设应用里还有一个叫 PasswordReminder 的类来负责给用户发送密码找回邮件。下面是 PasswordReminder 类一种可能的定义方式:
class PasswordReminder
{
public function remind(Contact $contact, $view)
{
// Send password reminder e-mail...
}
}
你可能注意到了,PasswordReminder 依赖 Contact 类,也就是依赖 Eloquent ORM。将密码找回系统和一个特定的 ORM 实现耦合到一起实在是没必要,也是不可取的。切断这个依赖后,就可以自由地改变后台存储机制或者 ORM,同时不会对密码找回组件造成影响。重申一遍,违背了「SOLID」原则的任何一条,都会导致调用方了解更多应用其它部分的它不改知道的实现细节。
要切断这种依赖,需要创建一个 RemindableInterface 接口。事实上,Laravel 已经自带了这个接口,并且默认被 User 模型类实现:
interface RemindableInterface
{
public function getReminderEmail();
}
接口创建好了之后,我们就可以在模型类中实现它:
class Contact extends Eloquent implements RemindableInterface
{
public function getReminderEmail()
{
return $this->email;
}
}
这样,我们就可以在 PasswordReminder 里面依赖这个小巧且专注的接口了:
class PasswordReminder
{
public function remind(RemindableInterface $remindable, $view)
{
// Send password reminder e-mail...
}
}
通过这样的改动之后,我们已经移除了密码找回组件里不必要的依赖,并且使它足够灵活,可以使用任何实现了 RemindableInterface 接口的类或 ORM。这其实正是 Laravel 密码找回组件如何保持与数据库和 ORM 无关的秘诀!
依赖反转原则
依赖反转原则,它规定高层次的代码不应该依赖低层级的代码。这个原则的另一层意思是,抽象接口不应该依赖具体实现,但具体实现应该依赖抽象接口。
换句话说,高层次的代码应该依赖抽象接口,抽象接口就像是「中间人」一样,负责连接着高层次和低层次代码。如果这些理论听起来让你极端困惑,别担心,接下来我们会将围绕这两个方面将这个原则详细介绍给你。
为了说明本原则,我们来看看下面这个类:
class Authenticator {
public function __construct(DatabaseConnection $db)
{
$this->db = $db;
}
public function findUser($id)
{
return $this->db->exec('select * from users where id = ?', array($id));
}
public function authenticate($credentials)
{
// Authenticate the user...
}
}
你可能猜到了,Authenticator 是用来查找和认证用户的。我们来看一下它的构造函数,你会发现它使用了类型提示,要求传入一个DatabaseConnection 对象,所以该认证器和数据库被紧密地耦合在一起,并且用户数据只能通过支持 SQL 的关系型数据库提供。此外,我们的高层次代码(Authenticator)直接依赖低层次代码(DatabaseConnection)。
首先,我们需要来谈谈「高层次代码」和「低层次代码」。低层次代码用于实现一些底层的基本操作,比如从磁盘读文件、操作数据库等。高层次代码用于封装复杂的业务逻辑并且依靠低层次代码来实现功能,但不能直接和低层次代码耦合在一起。换句话说,高层次代码需要依赖低层次代码的顶层抽象,比如接口。不仅如此,低层次代码也应当依赖抽象接口。所以,我们来写个可以在 Authenticator 中使用的接口:
interface UserProviderInterface
{
public function find($id);
public function findByUsername($username);
}
接下来我们将该接口注入到 Authenticator 里面:
class Authenticator {
public function __construct(UserProviderInterface $users, HasherInterface $hash)
{
$this->hash = $hash;
$this->users = $users;
}
public function findUser($id)
{
return $this->users->find($id);
}
public function authenticate($credentials)
{
$user = $this->users->findByUsername($credentials['username']);
return $this->hash->make($credentials['password']) == $user->password;
}
}
做了以上改动后,Authenticator 现在依赖于两个高层级的抽象:UserProviderInterface 和 HasherInterface。我们可以向Authenticator 注入这两个接口的任何实现类。例如,如果我们的用户存储在 Redis 里面,我们只需写一个 RedisUserProvider 来实现UserProviderInterface 接口即可。Authenticator 不再直接依赖低层次的存储操作了。
此外,我们的低层次代码现在依赖高层次的 UserProviderInterface 抽象,因为它实现了接口本身:
class RedisUserProvider implements UserProviderInterface
{
public function __construct(RedisConnection $redis)
{
$this->redis = $redis;
}
public function find($id)
{
$this->redis->get('users:'.$id);
}
public function findByUsername($username)
{
$id = $this->redis->get('user:id:'.$username);
return $this->find($id);
}
}
反转的思想:使用这一原则会反转很多开发者设计应用的方式。不再将高层次代码直接和低层次代码以「自上而下」的方式耦合在一起,这个原则规定不论高层级还是低层次代码都要依赖于一个高层次的抽象,从而使得低层次代码依赖于高层次代码的需求抽象。
强制让 Authenticator 依赖一个存储层的抽象接口之后,我们就可以通过任何实现了 UserProviderInterface 接口的存储系统来使用它,而且不用对 Authenticator 本身做任何修改。传统的依赖关系链已经被反转了,代码变得更灵活,可以更好的拥抱变化!