PHP编程之活用魔术方法

Java有注解和反射,Ruby有代码生成代码的元编程,Scala有Monad函子,而PHP有魔术方法。这些都是非常强大的武器,有人喜欢它的强大,但也有人讨厌它的复杂以及伴随而来的难以理解、万丈深渊。例如Ruby中的猴子补丁,非线性顺序的执行经常会让人摸不着头脑。另一方面,如果能够深入理解PHP的魔法方法,并加以灵活、恰当地使用,你将能节省很多重复性的代码编写,具备在陌生环境更好的代码生存能力,还能对某些看似神奇的现象做出合理的解释。
下面,我们将来一起探索这块魔法之地。

继续探讨DI容器背后的技巧

前面有说到Phalcon和PhalApi这两个PHP开源框架的DI容器,也见识了它的数组访问形式。但它的使用方式不止这一种,还有两种是和本次要讨论的魔法方法有关。我们先来看最终客户端的使用效果,再反过来追寻它背后的实现和原理。
以PhalApi框架为例,对于服务资源的注册和获取,还可以通过类属性以及类成员函数来操作。例如:

// 通过类属性方式操作
$di->request = new \PhalApi\Request();
var_dump($di->request);

// 通过类成员函数方式操作
$di->setRequest(new \PhalApi\Request());
var_dump($di->getRequest());

这样是不是很酷?!开发工程师完全可以根据自己的喜爱来选择操作方式,不用再担心会忘记如何使用DI容器。那么这些炫酷的特效是如何实现的呢?
如果查看PhalApi框架中DependenceInjection类的源代码,是找不到上面这些类属性和类成员函数的。事实上,它也不可能穷举全部开发人员会用到哪些资源服务。为此,只能使用动态的方式来维护。如果细心品读DependenceInjection类的源代码,我们可以找到魔法方法的影子,顺着这些蛛丝马迹,我们就能领略魔法方法的美妙之处。
在给不可访问属性赋值时,__set() 会被调用。读取不可访问属性的值时,__get() 会被调用。 所以,当对$di->request进行赋值时,会触发DependenceInjection内的__set() 方法,对应代码是:

public function __set($name, $value) {
        $this->set($name, $value);
    }

而当通过$di->request获取不存在的属性时,会触发DependenceInjection内的__get() 方法,对应代码是:

public function __set($name, $value) {
        $this->set($name, $value);
    }

而当通过$di->request获取不存在的属性时,会触发DependenceInjection内的__get() 方法,对应代码是:

public function __get($name) {
        return $this->get($name, NULL);
    }

通常情况,__set() 和__get() 是配套使用的。
再来看下另外一个魔法方法——__call() ,当在对象中调用一个不可访问方法时,就会触发这个魔法方法。例如,执行$di->setRequest()操作时,就会触发DependenceInjection内的__call() 方法,对应代码是:

public function __call($name, $arguments) {
        if (substr($name, 0, 3) == 'set') {
            $key = lcfirst(substr($name, 3));
            return $this->set($key, isset($arguments[0]) ? $arguments[0] : NULL);
        } else if (substr($name, 0, 3) == 'get') {
            $key = lcfirst(substr($name, 3));
            return $this->get($key, isset($arguments[0]) ? $arguments[0] : NULL);
        }
        throw new InternalServerErrorException(
            T('Call to undefined method DependenceInjection::{name}() .', array('name' => $name))
        );
    }

稍微解释一下。__call() 方法的第一个参数是要调用的方法名称,第二个参数是数组类型,即传递过来的参数列表。在这里里面,先判断调用的方法是以set还是以get开头,然后如果有传递参数再将参数列表传递下去。最后如果既不是set也不get操作,则抛出异常,告知开发人员非法调用。

魔法方法与代码生成

顺便说一下,魔法方法都是以双下划线开头的。此外,引申两点。先说简短的,再说稍长的。第一点, 当调用对象中一个不存在的方法时,会触发__call() 魔法方法,那如果尝试调用的是类的静态方法,又会触发哪个魔术方法呢?答案是:__callStatic() 。它的参数以及功能,和__call() 类似,唯一不同点是名称以及需要使用static关键字,它的函数签名是:

public static mixed __callStatic ( string $name , array $arguments )

有兴趣的同学可以自行实现一个具体的示例,并尝试对它进行使用。
第二点是,有人担心过多调用魔法方法会影响性能,因此会禁用魔法方法。但我觉得,既然选择了PHP这门语言,就不会过多关注相差几毫秒的性能。事实上,大型系统的性能瓶颈都不在于语言的执行层面,而主要集中于I/O方面,例如文件I/O,网络I/O,数据库I/O。但这也给了我们另一个启发,如果确实需要关注性能,我们也可以对于常见的setter/getter提前生成相应的PHP代码。例如针对数据传输对象DTO,就可以使用这一招。
先来看下,使用魔术方法的实现方式。很简单,起一个合适的类名,然后重载__call() 这个方法即可,非常简单。

<?php
class DTO {
    public function __call($method, $params) {
        if (substr($method, 0, 3) == 'set') {
            $key = lcfirst(substr($method, 3));
            $this->$key = $params[0];
        } else if (substr($method, 0, 3) == 'get') {
            $key = lcfirst(substr($method, 3));
            return isset($this->$key) ? $this->$key : NULL;
        }
    }
}

出于简单性,这里暂时不对异常的情况作过多的预防和处理。同样,客户端使用setter/getter也是非常简单的。例如这样:

$dto = new DTO();
$dto->setName('dogstar');
var_dump($dto->getName());

这些都是没什么难度的,一旦你熟悉魔法方法后。如果在大型企业系统中,想获得更多细致的控制权,也可以为此提前自动生成setter/getter的代码。编写一个代码生成器,对于初学者来说会有点难度,甚至对于从没接触过这块的同学来说也会有点陌生。但一旦在实际项目中应用过后,你就会发现其实代码自动生成也是很简单的,而且应用场景很多。这里以自动生成setter/getter代码为例,先简单说一下实现的思路,再来介绍代码生成在各大开源框架中的应用场景。
每个DTO的类代码,类名是不一样的,另外各自的类属性也是不尽相同的。如果我们能手动编写其中一个DTO的类代码,就能知道其它DTO的类代码要如何生成了。快速来写一个代码生成器脚本 ,命名为:generate_dto_class.php,并在内放置以下实现代码:

<?php
// DTO简易代码生成器

$class = $argv[1];
$properties = array_slice($argv, 2);

$code = "<?php
class $class {
";

foreach ($properties as $it) {
    $itUpper = ucfirst($it);

    $code .= "
    public function set{$itUpper}(\${$it}) {
        \$this->{$it} = \${$it};
    }

    public function get{$itUpper}() {
        return \$this->{$it};
    }
";
}

$code .= "}";

file_put_contents(dirname(__FILE__) . '/' . $class . '.php', $code);

echo "OK!\n";

开发完成后,执行以下命令:

$ php ./generate_dto_class.php Student name age

就可以生成一个Student的DTO类,里面有两个类成员属性,分别是name和age。并且,可以看到在生成的Student.php文件里有以下自动生成的PHP代码:

<?php
class Student {

    public function setName($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }

    public function setAge($age) {
        $this->age = $age;
    }

    public function getAge() {
        return $this->age;
    }
}

是不是很有趣?
在代码生成这一领域,不同的开源框架有不同的做法。Yii框架提供了Gii,一个强大的基于Web 的代码生成器,可以生成Model类的代码,以及CRUD代码。Symfony框架则可以使用Doctrine组件提供的命令来创建Entity实体类的代码。例如输入以下命令并按提示操作:

$ php bin/console make:entity

Class name of the entity to create or update:
> Product

最后可以生成类似这样的代码:

// src/Entity/Product.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="App\Repository\ProductRepository")
 */
class Product
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    public function getId()
    {
        return $this->id;
    }

    // ... getter and setter methods
}

代码自动生成更多是应用在与数据库操作相关的层级上,例如DTO、实体Entity、模型Model。在我曾经任职的第一家公司里,也提供了一个强大的命令,可以根据xml的配置,自动生成相应的整套数据库相关操作的代码库。另一方面,在其他场景也可以发现代码生成的身影。例如,在PhalApi框架中,提供了phalapi-buildtest命令,可自动生成测试代码。
如果想提升自己的开发效率,提升整个项目的交付速度,魔术方法或者代码生成,都是值得推荐的策略。前者可以节省编写重复的代码,后者则可以直接帮你生成重复的代码。

发表评论