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;
    }
}

在类中我们提供了属性的 gettersetter 方法,这也是面向对象中获取内部属性的常用做法。

原来的 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 协议》,转载必须注明作者和本文链接
你应该了解真相,真相会让你自由。
本帖由系统于 9个月前 自动加精
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 29

:+1: :+1: :+1: :+1:

9个月前 评论

优化永不过时。 个人一般都是一个一个传进去或者扔个数组

9个月前 评论

高端的代码往往选择最朴素的传参方式,我选择一个个传

9个月前 评论
快乐的皮拉夫 (楼主) 9个月前

个人理解:

方法参数太多的话,先看看是不是方法功能职责太多,可不可以再拆解为多个方法。实在需要的话可改为传一个 ValueObject。改为数组(不推荐)的话要写好数组参数包括的成员及其类型的注释文档。

9个月前 评论
快乐的皮拉夫 (楼主) 9个月前

方法与方法之间传递的参数,如果参数很多,又有扩展和维护需求,可以把它封装为一个 POPO 对象,就是我们常说的 DTO

9个月前 评论
快乐的皮拉夫 (楼主) 9个月前
CodingHePing

合理

9个月前 评论
陈先生

为什么不使用 DTO 来完成呢?

9个月前 评论
快乐的皮拉夫 (楼主) 9个月前
陈先生 (作者) 9个月前

我怎么感觉好像看过这篇文章呢

9个月前 评论
快乐的皮拉夫 (楼主) 9个月前

laravel data这个拓展了解一下

9个月前 评论
快乐的皮拉夫 (楼主) 9个月前
sinmu 9个月前
springlee (作者) 9个月前
sanders

如果是处理业务逻辑的类型方法,带 ID 的 我一般就传模型进去了,避免在传递的各个位置去查询数据。

9个月前 评论
快乐的皮拉夫 (楼主) 9个月前

先收藏了再说

9个月前 评论

我一般不会让函数入参超过3个,超过了3个意味着需要重构了。

8个月前 评论
快乐的皮拉夫 (楼主) 8个月前
a393870945 (作者) 8个月前

我差不多也是这样,只不过我只是使用了一个参数类,然后把每个需要的参数都定义为一个属性。

8个月前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!