人人都能看懂的全栈开发教程——创建 Symfony 命令

人人都能看懂的全栈开发教程——创建 Symfony 命令

Chris Yue No Comment
Posts

前面几篇文章已经给读者展示了几个 Symfony 自带的命令,而本篇文章的目的就是让大家了解,如何在 Symfony 里写一个自己的命令,比如我们老项目里的用户注册命令。

还记得我们创建控制器时说可以直接通过命令 make:controller 来自动生成代码吗?命令本身也跟控制器类似,我们可以自己创建代码,也可以选择通过命令帮我们创建,这一次,我们直接选择命令生成的方式。

我们先执行命令:

bin/console make:command

运行之后会提示你输入你想创建的命令的名称。我们就按照 Symfony 的风格,将命令命名为 app:user:register。回车之后,会提示创建了 src/Command/UserRegisterCommand.php 文件。

实际上这个时候,大家就可以通过 bin/console app:user:register 来运行我们刚刚创建的命令了,当然,只是给我们展示了一个例子,我们依然需要修改命令文件的代码来实现我们注册用户的需求:

<?php

namespace App\Command;

use Domain\UserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

class UserRegisterCommand extends Command
{
    protected static $defaultName = 'app:user:register';

    private $userManager;

    // 此命令依然需要 UserManager 来运行,所以将其作为参数传给构造函数
    public function __construct(UserManager $userManager)
    {
        $this->userManager = $userManager;
        // 如果不写下面这一行,运行命令时会提示你命令都需要运行父类的构造函数
        parent::__construct();
    }

    protected function configure()
    {
        // 配置命令的描述和参数,应该很直白就不多说了
        $this
            ->setDescription('Register a new user')
            // addArgument 的第三个参数是参数的描述,我们这里没必要再描述,就省略了
            ->addArgument('username', InputArgument::REQUIRED)
            ->addArgument('password', InputArgument::REQUIRED)
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $username = $input->getArgument('username');
        $password = $input->getArgument('password');

        $this->userManager->register($username, $password);

        // $io 对象提供一些方法,可以让执行结果更好看
        // 比如 success 即是用绿色的底色显示信息,一般用来显示成功的信息
        $io->success(sprintf('User %s registered successfully.', $username));

        return 0;
    }
}

当然,目前这个命令依然不能使用,因为 UserManager 需要的 UserRepositoryPasswordEncoder 目前还没有。

我们先把老项目里的 DefaultPasswordEncoder 类文件复制到新项目的 src/Security 目录里(如果没有 Security 目录就先创建)。因为放的位置变了,类的命名空间也需要更新一下:

<?php

// 只需要修改这里,其他都没变
namespace App\Security;

...

class DefaultPasswordEncoder implements PasswordEncoderInterface
{
    ...
}

接下来是 UserRepository 的迁移,按理说,我们也应该将老项目里的 UserRepository 直接复制到新项目里,然后将原来的 PDO 改成用 Doctrine 来获取用户就行。不过为了给大家展示更多的内容,我打算换一种方式。

Symfony 框架在用户登录和权限方面,其实做了很多事情。Symfony 的用户系统会要求开发者创建用户对象,并且用户对象需要能返回加密密码,返回用户角色等功能,但这些功能具体应该如何做,是开发者自己来定的,并不属于框架要负责的事情(不过 Symfony 也提供了一个例子 Symfony\Component\Security\Core\User\User 类,可以参考一下),所以 Symfony 提供了一个用户接口来让使用 Symfony 框架的开发者来实现,这个思路跟我们在老项目里创建 UserRepositoryInterfacePasswordEncoderInterface 接口定义其实是一样的。这个用户接口叫 Symfony\Component\Security\Core\User\UserInterface,读者如果有兴趣,可以自己去看看这个接口都定义了些什么方法,如何在库里找到指定的类源文件这里就不说了,大家可以试着找一下,没那么困难。

总之为了后面我们做用户登录的功能,我们需要实现用户接口。实现这个接口是因为我们用了 Symfony 框架导致的,即跟具体做法有关,而跟业务没关系,所以我们不应该在 Domain\User 上去实现这些接口,不过我们可以新创建一个用户类,继承我们的 Domain\User,并且实现 Symfony 的 UserInterface 里的方法。

我们可以自己创建新用户类,也可以通过 Symfony 的命令来为我们创建一些便利。我们只需要执行下面命令:

bin/console make:user

命令会提出几个问题,依次是:

The name of the security user class (e.g. User) [User]

即用户类名。结尾中括号里面的是默认值,这里就直接回车接受默认值就行。

Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]

是否用 Doctrine 来保存用户数据,也默认值即可。

Enter a property name that will be the unique “display” name for the user (e.g. email, username, uuid) [email]:

用来表示用户唯一性的字段,比如说 email,或者用户名,或者 uuid,我们的项目目前只有用户名,所以回答 username

Does this app need to hash/check user passwords? (yes/no) [yes]

Symfony 的登录系统实际上自带密码加密和验密的功能,这里便是问你需不需要加密验密功能。因为我们自己已经提供,所以回答 no

最后可以看到命令运行的反馈,已经为我们创建好了用户类和用户仓库类(UserRepository),不过我们还是需要做一些修改来满足我们的需求。

首先来看 src/Entity/User.php。这个类里出现了很多 @ 开头的注释,这些就是使用 annotation 来定义数据库表映射的方式,大家可以参考一下,不过最终我们需要将代码改成这样:

<?php

namespace App\Entity;

// 我们已经有用户类了,此类的主要目的是继承我们自己的用户类,并且实现
// Symfony 要求实现的 UserInterface
use Domain\Entity\User as BaseUser;

use Symfony\Component\Security\Core\User\UserInterface;

// UserInterface 里我们需要实现的只有 getPassword,getRoles,getSalt,eraseCredentials
// 四个方法,所以我们需要确保此类有这四个方法。
// 自动生成的代码的注释是一些有帮助的信息,不过看完之后我们还是将这些注释清理干净
class User extends BaseUser implements UserInterface
{
    private $roles = [];

    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

    /**
     * @see UserInterface
     */
    public function getSalt()
    {
    }

    /**
     * @see UserInterface
     */
    public function eraseCredentials()
    {
    }

    // 前面说了 UserInterface 还要求实现 getPassword 方法
    // 因为我们业务本身也需要返回加密后的密码,只不过之前为了说明类属性权限设置的概念,没有在 Domain\Entity\User 上写 getPassword 方法
    // 实际上 Domain\Entity\User 还是需要这个方法的,大家就自己加上吧
}

光改 App\Entity\User 类还不够,我们依然要告诉 Doctrine,App\Entity\User 也是一个需要存库的实体类。我们在 config/doctrine/mappings 目录创建一个新的目录 app,并且创建文件 config/doctrine/mappings/app/User.orm.xml 并写下以下配置:

<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
        https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
    <entity name="App\Entity\User">
    </entity>
</doctrine-mapping>

以及告诉 Doctrine 要去什么地方找这些配置:

# config/packages/doctrine.yaml
doctrine:
    ...
    orm:
        ...
        mappings:
            ...
            # 其实就是把上面的 Domain 部分复制了一份,把 domain 全改成 app
            # 但注意保持大小写一致
            App:
                is_bundle: false
                type: xml
                dir: '%kernel.project_dir%/config/doctrine/mappings/app'
                prefix: 'App\Entity'
                alias: App

因为原来的 Domain\Entity\User 类已经不是我们最终用的实体了,所以我们需要告诉 Doctrine Domain\Entity\User 是一个『被映射的父类』(mapped-superclass):

<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping ...>
    <!-- 这是 XML 文件的注释,跟 HTML 一样 -->
    <!-- 只用把 entity 标签改成 mapped-superclass 标签 -->
    <!-- 表示此类已经不是一个实体了,而是一个实体的父类 -->
    <mapped-superclass ...>
        ...
    </mapped-superclass>
</doctrine-mapping>

还记得前一篇我有提到,如果实体一些属性,或者方法,并不是业务要求的,而是具体实现的时候要求的,应该怎么处理,是的,使用实体继承,就是一种方式,你可以将具体实现所要求的属性或者方法,添加到子类里就行。而因为子类就『是』父类(猫是哺乳动物的子类,猫也就『是』哺乳动物),所以代码在实际运行中虽然用户数据是子类对象,但依然没毛病,业务代码也能处理。

接下来我们需要修改 src/Repository/UserRepository.php 文件:

<?php

namespace App\Repository;

use App\Entity\User;
use Domain\Entity\User as BaseUser;
use Domain\UserRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Persistence\ManagerRegistry;

/**
 * @method User|null find($id, $lockMode = null, $lockVersion = null)
 * @method User|null findOneBy(array $criteria, array $orderBy = null)
 * @method User[]    findAll()
 * @method User[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class UserRepository extends ServiceEntityRepository implements UserRepositoryInterface
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, User::class);
    }

    // 下面三个方法便是我们自己的 UserRepository 类需要的方法

    public function add(string $username, string $password): void
    {
        // $this->_em 是 Doctrine 的『实体管理器』对象,即 Entity Manager
        // 使用 em 来保存(persist)一个用户对象,等于执行 insert SQL 语句
        // 但这么写是不是感觉方便很多,这便是 ORM 提供的便捷之一
        $this->_em->persist(new User($username, $password));
        // em 的 persist 方法只是『标记』一个用户对象要被保存
        // 而真正被保存是在 flush 方法调用之时
        $this->_em->flush();
    }

    public function hasUsernameExisted(string $username): bool
    {
        // countByUsername 也是一个不存在的『魔术方法』
        return (bool) $this->countByUsername($username);
    }

    // 此方法要求返回一个 Domain\Entity\User 对象
    // 实际上此方法只可能返回 App\Entity\User 对象
    // 但因为目前版本 PHP 的语言缺陷,只能必须跟 Interface 一致写返回 BaseUser
    public function findOneByUsername(string $username): ?BaseUser
    {
        return $this->findOneBy(compact('username'));
    }
}

还记得在老项目里,我们是在注册用户命令里面,先自己创建了 UserRepositoryDefaultPasswordEncoder 对象之后,才创建的 UserManager 对象吗?在 Symfony 里这个过程还能更简单,只要在 config/services.yaml 文件里添加几行配置:

services:
    ...
    Domain\:
        resource: '../domain/*'

这个时候,Symfony 框架自己知道我们创建的命令 App\Command\UserRegisterCommand 的构造函数需要 Domain\UserManager 对象作为参数(有一种更『专业』的说法叫做『注入』,后面我们都这么说,另外关于注入后面会有专门的文章介绍),并且尝试创建 Domain\UserManager 对象,这个时候 Symfony 框架又会发现 Domain\UserManager 又需要 Domain\UserRepositoryInterfaceDomain\PasswordEncoderInterface,而 Symfony 也知道当前项目下实现了这俩接口的类也只有 App\Security\DefaultPasswordEncoderApp\Repository\UserRepository,所以就会自动创建这两个类…… Symfony 在很多方面,都会用这么一点我管它叫『小智能』的东西,来为开发者提供一些方便,节省没必要的代码量,这也是使用 Symfony 或者说使用框架(其他的框架或多或少也有,或者将会有类似的功能)的好处。

到这一步,我们再执行注册用户命令做一下尝试:

bin/console app:user:register kyo 123456

如果一切正常,将会出现注册成功的提示。

另外我们的 domain 代码自带重复用户名检查,我们尝试再次执行上面的命令而且不改用户名,我们应该能看到红色的报错信息。

最后我想说的是,把 Symfony 创建命令的功能拿出来单独说一下的意义是,Symfony 框架有很多功能,都是由 Symfony 组件(Components)作为核心提供的,而 Symfony 命令行组件(Symfony Console Component)是其中最有名的组件之一,很多 PHP 项目,甚至其他的 PHP 框架,命令行功能都是由 Symfony 命令行组件提供的。你以后的工作中即使使用的是别的框架,但依然很有可能会『遇上熟人』;另外如果你自己的项目也需要大量的命令,也可以单独使用 Symfony 命令行组件。

本章完整代码见这里

人人都能看懂的全栈开发教程——创建 Symfony 命令 by Chris Yue is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

微信赞赏码

如果觉得文章还不错,就请扫码鼓励一下作者吧
天使打赏人

发表评论

90 − = 89