编写可维护的代码:用PHP(Laravel)解释SOLID原则

编写计算机程序非常有趣。除非你不得不处理其他人的代码。

如果你作为专业开发者工作超过三天,你就知道我们的工作与创造性和激动人心无关。原因之一是公司领导层(即从不理解的人),另一部分是我们必须处理的代码的复杂性。现在,虽然我们对前者几乎无能为力,但对后者我们可以做很多。

那么,为什么代码库如此复杂,让我们感觉自己胆战心惊呢?简单地说,这是因为编写第一个版本的人匆忙行事,后来的人只是继续加入这个混乱。最终结果是,只有很少人愿意接触和理解的一团糟。

欢迎来到工作的第一天!

“没人说这会这么难……”

但事实并非如此。

Writing good code,易于维护的模块化代码并不难。只要遵循五个简单的原则(长期以来广为人知),并且有纪律地遵守,你的代码就会在六个月后令他人和你自己能够读得懂。😂

这些指导原则由SOLID缩写表示。也许你之前听说过“SOLID原则”的术语,也可能没有。如果你有听说过,但一直没有学习,那么让我们确保今天就是那一天!

所以,话不多说,让我们来看看这个SOLID到底是什么以及它如何帮助我们编写一个真正整洁的代码。

“S”代表单一职责

如果你查看描述单一职责原则的不同来源,你会发现对其定义有一些差异。然而,简单来说,它归结为这样一点:代码库中的每个类应该具有非常具体的角色;也就是说,它只负责一个单一的目标。而且每当该类需要进行更改时,这意味着我们只需要更改那个具体的责任。

当我第一次接触到这个原则时,我看到的定义是:“一个类只有一个原因需要发生变化”。我当时就想,“什么?发生变化?为什么变化?为什么要变化?”,这就是为什么我之前说过,如果你在不同的地方阅读有关此原则的信息,你会得到相关但有些不同且可能令人困惑的定义。

不管怎样,别再说这些了。是时候来点严肃的事了:如果你和我一样,你可能会想,“好吧,一切都好。但我为什么要在明天开始完全不同的编码风格,只因为一本书的作者(现已去世)说要这样。”

太棒了!

这就是我们学习事物时要保持的精神。那么,“单一职责”这些花哨的东西到底有什么意义呢?不同的人解释得有所不同,但对我来说,这个原则主要是为了在代码中引入纪律和专注。

专注,小伙子。专注!

在我解释我的理解之前,让我们先看一个例子。与网络上其他资源不同,这些资源提供的例子你可以理解,但会让你不知如何应用到实际情况中。我们来看看一种特定的编码风格,这是我们在Laravel应用程序中一次又一次地看到甚至写的。

当Laravel应用程序接收到一个网络请求时,URL会与你在web.phpapi.php中定义的路由进行匹配,如果匹配成功,请求数据将到达控制器。下面是一个在实际的生产级应用程序中典型的控制器方法:

类UserController扩展了Controller类:
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
‘first_name' => ‘required',
‘last_name' => ‘required',
'email' => ‘required|email|unique:users',
‘phone' => ‘nullable'
]);

if ($validator->fails()) {
Session::flash(‘error', $validator->messages()->first());
return redirect()->back()->withInput();
}

// 创建新用户
$user = User::create([
‘first_name' => $request->first_name,
‘last_name' => $request->last_name,
'email' => $request->email,
‘phone' => $request->phone,
]);

return redirect()->route(‘login');
}

我们都写过这样的代码。很容易看出它的功能:注册新用户。看起来不错,也可以正常工作,但是有一个问题——它不具备未来性。这里的未来性是指它不能处理变化而不造成混乱。

为什么会这样呢?

你可以看出这个函数是为在web.php文件中定义的路由而设计的,也就是传统的服务器渲染页面。几天过去了,现在你的客户/雇主正在开发一个移动应用,这意味着该路由对于从移动设备注册的用户没有用处。你该怎么办?在api.php文件中创建一个类似的路由,并为它编写一个以JSON为驱动的控制器函数?好吧,然后呢?复制所有的代码,做一些修改,然后就可以了?这确实是很多开发者所做的,但他们正在为自己制造失败的前提。

问题是HTML和不是世界上唯一的API格式(为了论证起见,我们将HTML页面视为一个API)。那么对于一个使用XML格式的旧系统的客户又该怎么办呢?然后还有一个使用SOAP的系统。还有gRPC。上帝知道明天会出现什么其他格式。

你可能仍然考虑为每种API类型创建一个单独的文件,并复制现有的代码,稍作修改。是的,你可能会争辩说,有十个文件,但都运行得很好,为什么要抱怨呢?但是接下来的打击,软件开发的天敌来了——变化。假设现在,你的客户/雇主的需求发生了变化。现在,他们希望在用户注册时,记录用户的IP地址,并添加一个字段的选项,表示用户已阅读并理解了条款和条件。

哦哦!现在我们有了十个文件要编辑,而且我们必须确保逻辑在所有这些文件中都完全相同。甚至一个都可能导致重大业务损失。想象一下在大规模SaaS应用中的恐惧,因为代码的复杂性已经相当高了。

我们是如何陷入这个地狱的呢?

答案是这个看似无害的控制器方法实际上在做很多不同的事情:它验证传入的请求,处理重定向,并创建新用户。

它做了太多的事情!是的,正如你可能已经注意到的,知道如何在系统中创建新用户实际上不应该是控制器方法的工作。如果我们将这个逻辑从函数中抽出来,放到一个单独的类中,我们现在将有两个类,每个类负责一个单独的责任。虽然这些类可以通过调用它们的方法来互相帮助,但它们不允许知道对方内部正在发生的事情。
class UserController extends Controller {
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
‘first_name' => ‘required',
‘last_name' => ‘required',
'email' => ‘required|email|unique:users',
‘phone' => ‘nullable'
]);

if ($validator->fails()) {
Session::flash(‘error', $validator->messages()->first());
return redirect()->back()->withInput();
}

UserService::createNewUser($request->all());
return redirect()->route(‘login');
}
}

现在看看代码:更加简洁,易于理解……最重要的是,适应变化。继续我们之前的讨论,我们有十种不同类型的API,每个都会调用一个单独的函数UserService::createNewUser($request->all());,就这样完成了。如果需要更改用户注册逻辑,UserService类将负责,而控制器方法则无需更改。如果需要在用户注册后设置短信确认,则UserService会处理它(通过调用其他知道如何发送短信的类),控制器也不需要改动。

这就是我所说的关注和纪律:代码关注于一件事情(一件事只做一件事),开发人员要有纪律(不要追求短期解决方案)。

好吧,这次的讲解有点长!我们只覆盖了五个原则中的一个。继续吧!

“O”代表开放封闭原则

我必须说,提出这些原则定义的人肯定没有考虑过经验不足的开发人员。开放封闭原则也是如此,接下来的原则更加奇怪。😂😂

不管怎样,让我们看一下关于这个原则的定义:类应该对扩展开放,对修改关闭。嗯??是的,我第一次看到时也没觉得好笑,但随着时间的推移,我开始理解并欣赏这个规则想要传达的意思:代码只需要编写一次,就不需要再修改。

在哲学意义上,这个规则是很好的——如果代码不变,它将保持可预测性,不会引入新的错误。但是,当我们作为开发人员一直在追逐变化时,怎么可能梦想着不变的代码呢?

首先,这个原则并不意味着不允许修改一行现有代码;这是纯粹的幻想。世界在变化,业务在变化,因此代码也在变化——无法避免。但是这个原则的意思是尽可能限制修改现有代码的可能性。而且它还告诉你如何做到这一点:类应该对扩展开放,对修改关闭。

这里的“扩展”意味着重用,无论是通过子类继承父类的功能,还是其他类存储类的实例并调用其方法。

那么,回到百万美元的问题:如何编写经受得住变化的代码?在这里,恐怕没有人有一个明确的答案。在面向对象编程中,已经发现和完善了多种技术来实现这个目标,从我们正在学习的这些SOLID原则到常见的设计模式、企业模式、架构模式等等。没有完美的答案,所以开发人员必须不断地提高,收集尽可能多的工具,并尽力做到最好。

考虑到这一点,让我们来看看一种这样的技术。假设我们需要添加将给定的HTML内容(可能是发票?)转换为PDF文件并强制在浏览器中立即下载的功能。假设我们还订阅了一个名为MilkyWay的虚拟服务,它将执行实际的PDF生成。我们可能会编写以下控制器方法:

class InvoiceController extends Controller {
    public function generatePDFDownload(Request $request) {
        $pdfGenerator = new MilkyWay();
        $pdfGenerator->apiKey = env('MILKY_WAY_API_KEY');
        $pdfGenerator->setContent($request->content); // HTML格式
        $pdfFile = $pdfGenerator->generateFile('invoice.pdf');

        return response()->download($pdfFile, [
            'Content-Type' => 'application/pdf',
        ]);
    }
}

为了专注于核心问题,我忽略了请求验证等。您会注意到,这种方法很好地遵循了单一职责原则:它不会尝试遍历传递给它的HTML内容并创建PDF(事实上,它甚至不知道它已经被赋予了HTML);相反,它将这个责任传递给专门的MilkyWay类,并以下载的方式呈现任何它得到的内容。

但是有一个小问题。

我们的控制器方法过于依赖MilkyWay类。如果MilkyWay API的下一个版本更改了接口,我们的方法将停止工作。如果有一天我们希望使用其他服务,我们将不得不在代码编辑器中全局搜索并更改所有提到MilkyWay的代码片段。这为什么是不好的?因为这极大地增加了出错的机会,并且对业务(开发人员在解决混乱的过程中花费的时间)造成了负担。

由于我们创建了一个不适合变化的方法,所以产生了所有这些浪费。

我们能做得更好吗?

可以!

在这种情况下,我们可以利用一种做法,大致是这样的 – 面向接口编程,而不是实现。

是的,我知道,这又是一个在第一次看起来毫无意义的OOPS主义。但是它的意思是,我们的代码应该依赖于事物的类型,而不是特定的事物。在我们的例子中,我们需要摆脱对MilkyWay类的依赖,而是依赖于一个通用的、PDF类的类型(在一秒钟内这一切都会变得清楚)。

那么,在PHP中我们有哪些用于创建新类型的工具?从广义上来说,我们有继承和接口。在我们的例子中,创建一个所有PDF类的基类并不是一个好主意,因为很难想象不同类型的PDF引擎/服务共享相同的行为。也许它们可以共享setContent()方法,但即使在那里,对于每个PDF服务类来说,获取内容的过程也可能有所不同,因此将所有内容都归类到一个继承层次结构中只会让事情变得更糟。

在理解这一点之后,让我们创建一个接口,指定我们希望所有PDF引擎类具有的方法:

interface IPDFGenerator {
    public function setup(); // API密钥等
    public function setContent($content);
    public function generatePDF($fileName = null);
}

那么,我们在这里有什么?

通过这个接口,我们表明我们希望所有的PDF类至少具有这三个方法。现在,如果我们想要使用的服务(在我们的例子中是MilkyWay)没有遵循这个接口,那么我们的任务就是编写一个遵循该接口的类。我们可能会为我们的MilkyWay服务编写一个包装类的草图,如下所示:

class MilkyWayPDFGenerator实现了IPDFGenerator接口 {
public function __construct() {
$this->setup();
}

public function setup() {
$this->generator = new MilkyWay();
$this->generator->api_key = env(‘MILKY_WAY_API_KEY');
}

public function setContent($content) {
$this->generator->setContent($content);
}

public function generatePDF($fileName) {
return $this->generator->generateFile($fileName);
}
}

并且就像这样,每当我们有一个新的PDF服务,我们都会为其编写一个包装类。因此,所有这些类都被视为IPDFGenerator类型。

那么,所有这些与开闭原则和Laravel有什么关联呢?

要达到这一点,我们必须了解另外两个关键概念:Laravel的容器绑定和一种非常常见的依赖注入技术。再次,很大的词汇,但是依赖注入简单地意味着,您不必自己创建类的对象,而是在函数参数中提及它们,有人将自动为您创建它们。这使您无需一直编写像$account = new Account();这样的代码,并使代码更具可测试性(这是另一天的主题)。我提到的这个”something”在Laravel世界中采取了Service Container的形式。

目前,只需将其视为可以为我们创建新的类实例的东西。让我们看看这如何帮助我们。

在我们的示例中的服务容器中,我们可以这样写:

$this->app->bind('AppInterfacesIPDFGenerator', 'AppServicesPDFMilkyWayPDFGenerator');

这基本上是说,每当有人请求一个IPDFGenerator时,将给他们提供MilkyWayPDFGenerator类的实例。在这一切的歌舞表演之后,女士们先生们,我们终于到了一切都合拍并揭示开闭原则工作的地方!

凭借所有这些知识,我们可以像这样重写我们的PDF下载控制器方法:

class InvoiceController extends Controller {
public function generatePDFDownload(Request $request, IPDFGenerator $generator) {
$generator->setContent($request->content);
$pdfFile = $generator->generatePDF('invoice.pdf');

return response()->download($pdfFile, [
'Content-Type' => 'application/pdf',
]);
}
}

注意到了吗?

首先,我们在函数参数中接收到了我们的PDF生成器类实例。这是由服务容器创建并传递给我们的,正如前面讨论的那样。代码也更清晰,没有提及API密钥等内容。但是,最重要的是,没有MilkyWay类的痕迹。这还有一个额外的好处,使得代码更易于阅读(第一次阅读的人不会说:“喔!这是什么鬼MilkyWay??”并且一直在背后不断为此担心)。

但最大的好处是什么?

这个方法现在对修改和变动是封闭的,具有抗变性。请允许我解释。假设明天我们觉得MilkyWay服务太贵了(或者,经常发生的情况是,他们的客户支持变得很差);因此,我们尝试了另一个名为SilkyWay的服务,并希望转向它。我们现在只需要为SilkyWay编写一个新的IPDFGenerator包装类,并更改我们服务容器代码中的绑定:

$this->app->bind('AppInterfacesIPDFGenerator', 'AppServicesPDFSilkyWayPDFGenerator');

就是这样!

其他的东西不需要改变,因为我们的应用程序是按照接口(IPDFGenerator接口)而不是具体类编写的。业务需求发生了变化,添加了一些新代码(包装类),只改变了一行代码 – 其他所有内容保持不变,整个团队都可以放心地回家睡觉。

想要安心睡觉?遵循开闭原则!🤭😆

“L”代表Liskov替换

Liskov-什么??

这听起来像是直接从有机化学教科书中出来的东西。它甚至可能让你后悔选择软件开发作为职业,因为你认为它完全是实践而没有理论。

“快,孩子!能够表示为两个质数之和的最大质数是多少?”

但请稍等一下!相信我,这个原则和它的名字一样容易理解。实际上,它可能是五个原则中最容易理解的(嗯,如果不是最容易理解的,那至少它将有最短、最直接的解释)。

这个规则只是说,与父类(或接口)一起工作的代码在这些类被子类(或实现接口的类)替换时不应该中断。我们刚刚在本节结束之前完成的示例就是一个很好的例证:如果我用特定的MilkyWayPDFGenerator实例替换方法参数中的通用IPDFGenerator类型,你会期望代码中断还是继续工作?

当然是继续工作!那是因为区别只在于名称上,接口和类都以相同的方式使用相同的方法工作,所以我们的代码将像以前一样工作。

那么,这个原则有什么了不起的地方呢?好吧,简单来说,这个原则所说的就是:确保你的子类按照要求完全实现所有方法,具有相同数量和类型的参数,以及相同的返回类型。如果有一个参数不同,我们将不知不觉地在其上构建更多的代码,而有一天我们将拥有一堆令人作呕的混乱代码,唯一的解决方法就是将其删除。

就是这样。这并不糟糕,是吧?😇

对于Liskov替代原则还有很多可以说的(如果你真的感到勇敢,可以查询其理论并阅读协变类型),但在我看来,对于首次接触这些神秘的模式和原则的普通开发人员来说,这就足够了。

“I”代表接口隔离

接口隔离…嗯,听起来并不那么糟糕,对吧?听起来好像与隔离…嗯,分离…接口有关。我只是想知道在哪里和如何分离。

如果你沿着这些思路思考,相信我,你几乎已经理解并使用了这个原则。如果五个SOLID原则都是投资工具,那么这个原则将在学习良好编码方面提供最长期价值(好吧,我意识到我说过每个原则都是如此,但是你知道,你明白我的意思)。

剥离高深的术语,浓缩成最基本的形式,接口隔离原则的意思是:你的应用程序中接口越多、越专业化,你的代码就越模块化、越不奇怪。

让我们看一个非常常见和实用的例子。每个Laravel开发人员都会在他们的职业生涯中遇到所谓的仓储模式,然后经历几周的高潮和低谷的循环阶段,最后放弃这个模式。为什么?在所有涵盖仓储模式的教程中,你被建议创建一个通用接口(称为仓储),该接口将定义访问或操作数据所需的方法。这个基础接口可能是这样的:

interface IRepository {
    public function getOne($id);
    public function getAll();
    public function create(array $data);
    public function update(array $data, $id);
    public function delete($id);
}

现在,对于你的User模型,你需要创建一个实现该接口的UserRepository; 然后,对于你的Customer模型,你需要创建一个实现该接口的CustomerRepository; 你懂的。

现在,在我参与的一个项目中碰巧遇到一些模型不允许除系统之外的任何人对其进行写操作。在你开始翻白眼之前,先考虑一下记录日志或维护审计跟踪就是这种“只读”模型的一个好的现实世界的例子。我面临的问题是,由于我需要创建所有实现IRepository接口的存储库,比如LoggingRepository,接口中的至少两个方法update()delete()对我没有任何用处。

是的,一个快速解决方法是无论如何都实现这些方法,要么让它们保持空白,要么引发异常,但是如果我依赖于这种临时解决方案,那我一开始就不会遵循存储库模式!

救命啊!我被卡住了。 :'(

这是否意味着这都是存储库模式的错?

不,一点也不!

事实上,存储库是一种众所周知和被广泛接受的模式,为数据访问模式带来了一致性、灵活性和抽象性。问题在于我们创建的接口 – 或者说在几乎每个教程中普遍使用的接口 – 过于宽泛。

有时候这个思想被表达为接口“过于臃肿”,但它的意思是一样的 – 接口做出了太多的假设,因此添加了对某些类来说是无用的方法,但这些类仍然被强制实现它们,导致脆弱、混乱的代码。我们的例子可能相对简单,但想象一下当多个类实现了它们不想要的方法,或者它们想要的方法在接口中缺失时,会出现什么混乱。

解决方法很简单,也是我们正在讨论的原则的名字:接口隔离。

重点是,我们不应该盲目地创建接口。我们也不应该做出任何假设,无论我们认为自己有多么有经验或聪明。相反,我们应该创建多个较小、专门的接口,让类实现那些需要的接口,而省略那些不需要的接口。

在我们讨论的例子中,我可以创建两个接口而不是一个:IReadOnlyRespository(包含函数getOne()getAll()),和 IWriteModifyRepository(包含其余的函数)。对于常规存储库,我可以这样说class UserRepository implements IReadOnlyRepository, IWriteModifyRepository { . .. }。(附注:特殊情况可能仍然存在,那是可以的,因为没有设计是完美的。你甚至可能希望为每个方法创建一个单独的接口,那也是可以的,前提是你的项目需要那么细粒度的接口。)

是的,现在有了更多的接口,有人可能会说记住太多或类声明现在太长(或看起来很丑),等等,但看看我们获得了什么:专门的、小型的、自包含的接口,可以根据需要组合,互不干扰。只要你以编写软件为生,记住这是每个人都在追求的理想。

“D”代表依赖倒置

如果您已经阅读了本文的前几部分,您可能会感觉自己理解了这个原则想要表达的意思。您是对的,从某种意义上说,这个原则更或多或少地重复了我们到目前为止讨论过的内容。它的正式定义并不可怕,所以让我们来看一下:高层模块不应该依赖于低层模块,而是应该依赖于抽象。

是的,有道理。如果我有一个高层类(高层意味着它使用其他更小、更专门的类来执行某些操作,然后做出一些决策),我们不应该让这个高层类依赖于某个特定的低层类来执行某种工作。相反,它们应该编码以依赖于抽象(如基类、接口等)。

为什么呢?

在本文的前一部分,我们已经看到了一个很好的例子。如果您使用了一个生成PDF的服务,而您的代码中到处都是new ABCService()类,如果业务决定使用其他服务,那将是一个永远记得的日子,但却是因为错误的原因!相反,我们应该使用这种依赖的一般形式(创建一个PDF服务的接口),并让其他东西来处理它的实例化并将其传递给我们(在“链接_5”中,我们看到了服务容器是如何帮助我们做到这一点的)。

总而言之,我们之前控制创建低层类实例的高层类现在必须寻找其他东西。情况已经发生了变化,这就是为什么我们称之为依赖的反转。

如果您正在寻找一个实际的例子,请回到本文中我们讨论如何使我们的代码不再完全依赖于MilkyWay PDF类的部分。

……

猜猜看,就是这样!我知道,我知道,这是一篇相当长而困难的阅读,我为此向您道歉。但我非常关心那些凭直觉(或者按照教给他们的方式)做事的普通开发人员,他们对SOLID原则一头雾水。我已尽力使示例尽可能接近工作日常,毕竟,当我们没有人要编写库时,包含车辆和汽车类等示例对我们有什么用呢,甚至高级泛型、反射等。

如果您觉得本文有用,请留下评论。这将证实我的想法,即开发人员真的在努力理解这些“高级”概念,我会有动力写更多这样的主题。待会儿见!🙂

类似文章