Laravel 实用小技巧 —— 如何优雅地设计方法传参?
简介
今天在阅读项目中的老代码的时候,发现一些方法的参数特别多,有的甚至超过了十个以上。比如下面这个方法:
...
/**
* 创建订单
*
* @param int $storeId 店铺ID
* @param string $storeName 店铺名称
* @param int $buyerId 购买者ID
* @param string $buyerName 购买者名称
* @param int $goodsId 商品ID
* @param string $goodsName 商品名称
* @param float $price 商品单价
* @param float $amount 商品总价
* @param int $quantity 商品数量
*/
public function createOrder (int $storeId, string $storeName, int $buyerId, string $buyerName, int $goodsId, string $goodsName, float $price, float $amount, int $quantity) {
...
}
...
这是一个创建订单的方法。通过观察我们不难发现,在这个方法中,传递给方法的参数特别多,这使得代码看上去有些「凌乱」,不知道屏幕前的小伙伴有没有遇到过类似的代码呢?
今天这篇文章,我门就来讨论下关于「优化方法传参」的问题。
弊端
先不说如何优化,我们先来看一下这样子传参会有哪些问题。
可读性
首先一个问题就是「可读性」的问题。
想象一下,当我们作为方法的「调用者」来使用这个方法时我们会怎么做?
我需要按照固定顺序传递一堆的参数,每一个参数都必须与定义的类型相匹配。那传递这堆参数做什么用呢?不晓得。
仅从字面意思我们可以大概了解一些参数的用途,但是如果我们想了解它的真正含义,我们必须阅读方法的完整代码。
我们知道,方法就像一个「处理器」。简单来讲,主要由输入参数、处理逻辑和输出参数组成。而在我们阅读一个方法时,我们通常会根据方法名、输入参数和输出参数来了解方法的基本用途。而当我们面对一堆过于复杂的输入参数时,我们往往会不知所措。
扩展性
可能有过「亲身体会」的小伙伴会说:起初的方法其实并没有这么复杂,只不过随着需求的调整,一步步「扩张」成现在这个样子的。
没错,很多方法在设计的时候,可能只有几个参数,看上去也是简单易懂。但是当某一天,我们发现我们要实现的功能依赖于外部调用方的某个参数时,怎么办呢?直接加个参数传进来呗。
今天加一个,明天加一个,加来加去,当某一天我们回头来看时,发现我们的参数列表已经变成了一列望不尽头「小火车」了。
不仅如此,想象一下:当某一天我们不想做「加法」了,想做个「减法」时,会是怎样的场景呢?
比如,在我们长长的「小火车」中,我们中间的某一节「车厢」不需要了,应该怎么办呢?
可能你会说:去掉不就行了?额,如果我告诉你,这个方法有一百个「调用方」呢?(打个比方,别当真)
那不好意思了,只能去调整每一个调用方的传参方式了(加油吧,少年)。
约束性
其实我们在说「扩展性」问题的时候,做「减法」这个问题也是一个「约束性」问题的体现。
作为「调用方」,传参的顺序必须和「定义方」保持一致。所以,当「定义方」参数做出调整时,如果参数是必传参数,「调用方」也需要作出相应调整,正所谓,牵一发而动全身。
而且,在众多参数中,每个参数的「重要性」都是不尽相同的。有一些参数需要设计成必传参数,而有一些参数则可以设计成可选参数,这就要求我们在设计参数顺序的时候,必传的参数往前放,可选的参数往后放。
那现在问题又来了:当我们在已经有了必传参数和可选参数的情况下,又需要加一个必传参数该怎么办呢?
没办法,必传参数必须放在可选参数前,可这样一来,整体的参数顺序又发生变化了。这就意味着所有的「调用方」又该来一遍「大洗牌」了。
可能这就是为什么我们有时候会看到一个方法设计了一堆参数,但是每个参数都是必传了。因为虽然不够合理,但是在增加参数的时候直接往最后边追加就可以了。
依赖传递
还有一种情况,有时我们方法处理的逻辑比较复杂,导致方法体比较「庞大」。所以我们需要将代码中的部分逻辑抽离到单独的方法中去,而抽离出去的方法实现又依赖于原方法的大部分参数,这就会出现以下这种「依赖传递」的情况:
...
/**
* 创建订单
*
* @param int $storeId 店铺ID
* @param string $storeName 店铺名称
* @param int $buyerId 购买者ID
* @param string $buyerName 购买者名称
* @param int $goodsId 商品ID
* @param string $goodsName 商品名称
* @param float $price 商品单价
* @param float $amount 商品总价
* @param int $quantity 商品数量
*/
public function createOrder (int $storeId, string $storeName, int $buyerId, string $buyerName, int $goodsId, string $goodsName, float $price, float $amount, int $quantity)
{
// 检查订单参数
$this->_checkOrderParameter($storeId, $storeName, $buyerId, $buyerName, $goodsId, $goodsName, $price, $amount, $quantity);
}
/**
* 检查订单参数
*
* @param int $storeId 店铺ID
* @param string $storeName 店铺名称
* @param int $buyerId 购买者ID
* @param string $buyerName 购买者名称
* @param int $goodsId 商品ID
* @param string $goodsName 商品名称
* @param float $price 商品单价
* @param float $amount 商品总价
* @param int $quantity 商品数量
*/
private function _checkOrderParameter (int $storeId, string $storeName, int $buyerId, string $buyerName, int $goodsId, string $goodsName, float $price, float $amount, int $quantity)
{
...
}
...
如果拆分的参数过多的话,你会发现在一个类很快就变成了一个「火车站」:一排排的「小火车」你连着我,我连着他,相当壮观。
当然,我也见过有些小伙伴为了解决「依赖传递」的问题,将参数都设计成了类变量。这样看来,方法之间的依赖性是减少了,但是类却变的更「复杂」了——依赖已经无形中从局部转移到全局。这就好比,当你把两个人沟通的问题搬到大工作群以后,解决效率是比以前更快了,但可能你同时也失去了一位信任你的朋友。
优化
那讲了这么多「问题」,应该怎么去优化呢?
数组传参
其实相信很多「经历」过的小伙伴都尝试过以下这种方案:
既然主要问题就是参数传递的太多,那么我们减少参数的个数不就行了么?在保证「信息量」的基础上做瘦身不就妥了么?—— 用更复杂的变量替代简单类型的变量,比如使用「数组变量」。
于是,原有的代码就被优化成了以下这样:
...
/**
* 创建订单
*
* @param array $buyerInfo 购买者信息
* @param array $goodsInfo 商品信息
* @param array $storeInfo 店铺信息
*/
public function createOrder (array $buyerInfo, array $goodsInfo, array $storeInfo)
{
...
}
...
我们可以看到,在上面的优化中,我们将参数分别整理到三个数组中,分别代表购买者信息、商品信息和店铺信息。
这样一来,参数个数一下子就从原来的九个变成了现在的三个,看上去简洁了很多。
但是这样改造也是存在一些问题的。
首先,用数组来代替普通类型的变量具有「不确定性」。
比如当我们需要使用购买者 ID($buyerId
)这个字段时,不能直接使用数组下标的方式来获取:
$buyerId = $buyerInfo['buyerId'];
因为 $buyerInfo
变量并不是在上下文中直接定义的,而是通过传参的方式获取的。这就意味着作为「接收方」不能完全信任来自「传递方」的数据:因为当 $buyerInfo
中没有传递 buyerId
这个索引时,将会抛出数组索引未定义的异常。
所以,我们在使用时需要增加索引判断的逻辑:
$buyerId = $buyerInfo['buyerId'] ?? '';
或者使用 Laravel 的数组封装的方法来获取:
$buyerId = Arr::get($buyerInfo, 'buyerId', '');
如此,虽然解决了使用异常的问题。但是当我们将数组参数传递给其他使用的方法时,原来的方法又成了参数的「传地方」:这就意味着在新的「接收方」中,又要做一遍参数的验证处理。
另外,因为 PHP 数组本身比较灵活,可以随时定义新的索引,这就意味着传递给方法的数组变量可能比实际要「庞大」很多:因为在实际开发中,可能为了省事,$buyerInfo
这种变量是通过数据库查询之后,直接转换成数组就丢给了调用的方法。虽然数组包含了方法需要的索引信息,但同时也冗余了很多其他信息。
那有没有更好一点的传参方式呢?
对象传参
其实,除了数组可以「包含变量」,对象也可以。
而且,对象传参这种方式并不罕见。在 Laravel 代码中随处可见,比如下面这些场景:
app/Console/Kernel.php
/**
* Define the application's command schedule.
*
* @param Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
...
}
app/Exceptions/Handler.php
/**
* @param Throwable $e
*/
public function report(Throwable $e)
{
...
}
甚至是构造方法的参数:
/**
* 构造方法
*
* @param Request $request
*/
public function __construct(Request $request)
{
...
}
看到这里,你是不是若有所思:这不就是「依赖注入」吗?
其实,了解「依赖注入」的小伙伴应该都清楚它的核心思想:
依赖注入能够消除程序开发中的硬编码式的对象间依赖关系,使应用程序松散耦合、可扩展和可维护,将依赖性问题的解决从编译时转移到运行时。
「依赖注入」主要解决的是模块之间的依赖关系。而在我们今天讨论的话题中,侧重的是方法之间的调用。但是我们也可以借鉴这种处理思想:即通过对象或者接口的方式传参,从而减少方法对实际使用参数的依赖性。
回到我们正文讨论的这种场景上来,我们应该如何设计「对象参数」呢?
其实,对象是具体的实参,我们需要关心的是对象背后「类」的设计,这也是一个「抽象化」设计的过程。
在这个场景中,我们可以将参数进行「抽象化」处理,抽象成请求实体类的属性。如下:
- 购买者请求类: 购买者 ID、购买者名称
- 商品请求类: 商品 ID、商品名称、商品单价、商品总价、商品数量
- 店铺请求类: 店铺 ID、店铺名称
以 购买者请求类
为例,类代码设计如下:
/*
|--------------------------------------------------------------------------
| 购买者请求类
|--------------------------------------------------------------------------
*/
namespace App\Entities\Request;
use http\Exception\InvalidArgumentException;
class BuyerRequest
{
/**
* @var string 购买者ID
*/
private $buyerId;
/**
* @var string 购买者名称
*/
private $buyerName;
/**
* 获取购买者名称
*
* @return string
*/
public function getBuyerId(): string
{
if(is_null($this->buyerId)){
throw new InvalidArgumentException('购买者ID不得为空');
}
return $this->buyerId;
}
/**
* 设置购买者ID
*
* @param string $buyerId 购买者ID
* @return BuyerRequest
*/
public function setBuyerId(string $buyerId): BuyerRequest
{
$this->buyerId = $buyerId;
return $this;
}
/**
* 获取购买者名称
*
* @return string
*/
public function getBuyerName(): string
{
return $this->buyerName ?: '';
}
/**
* 设置购买者名称
*
* @param string $buyerName 购买者名称
* @return BuyerRequest
*/
public function setBuyerName(string $buyerName): BuyerRequest
{
$this->buyerName = $buyerName;
return $this;
}
}
在类中我们提供了属性的 getter
和 setter
方法,这也是面向对象中获取内部属性的常用做法。
原来的 createOrder
方法调整参数结构如下:
...
/**
* 创建订单
*
* @param BuyerRequest $buyerRequest 购买者请求实体
* @param GoodsRequest $goodsRequest 商品请求实体
* @param StoreRequest $storeRequest 店铺请求实体
*/
public function createOrder (BuyerRequest $buyerRequest, GoodsRequest $goodsRequest, StoreRequest $storeRequest)
{
...
}
...
在调用方法之前,我们需要先实例化请求对象,然后再传递给方法:
...
// 参数实例化
$buyerRequest = (new BuyerRequest())
->setBuyerId('123')
->setBuyerName('测试');
$goodsRequest = new GoodsRequest();
$storeRequest = new StoreRequest();
// 方法调用
$this->createOrder($buyerRequest, $goodsRequest, $storeRequest);
...
在 createOrder
方法中,可以通过对象的 getter
方法来获取具体的参数:
$buyerId = $buyerRequest->getBuyerId();
因为在 getter
方法中包含了参数的处理逻辑,所以可以直接放心使用。
这样,在开篇提到的那些「弊端」基本上都得到解决了。只不过,我们需要花更多的时间去「抽象化」我们的请求类,但是一旦我们设计好了,无论是对于上游的「调用者」还是对于下游的「使用者」来说,都是十分便捷的。
总结
本篇文章我们讨论了关于程序开发中传参的设计。通过分析几种不同的传参方式,我们大致了解了各种方式的利弊。虽然「对象传参」的方式看上去更优雅,但是也要考虑实际情况的需要,避免陷入「过度设计」的怪圈。
所有的设计最终都要从实际生产环境出发。优雅值得关注,性能更不能忽视。
感谢大家的持续关注~
本作品采用《CC 协议》,转载必须注明作者和本文链接
推荐文章: