石头杨 2018-03-12 00:27:07 3283次浏览 1条评论 1 0 0

本篇是Web Application Development with Yii2 and PHP(简写WADWYAP)的读书笔记,会有多处删减,不过不会影响主体。英文能力不错的推荐去看原版:链接

项目简介

实现对customer的增加和根据电话号码查询。

为了方便,规定一个customer只有name,birth_date,notes,phone number四个字段,而phone number可以有多个。所以有两张表:

  1. customer: id,name,birth_date,notes
  2. phone:id,customer_id,number

环境准备

  1. 安装Composer,并将Composer加入系统path环境变量。任何目录下打开命令行,执行composer -v会输入版本信息。
  2. 安装PHP,并将PHP加入系统path环境变量。任何目录下打开命令行,执行php -v会输入版本信息。由于是案列开发展示,为了方便,使用PHP内置的服务器,不需要安装Apache等WEB服务器。
  3. 安装Mysql。并用账户密码登陆mysql查看是否安装成功。

必须安装完已上三个程序再进行下一步。不熟悉的自行百度谷歌解决

测试实践

安装测试环境

测试先行(test first)开发方法已经耳熟能详,因此本书进行尝试,全部采用基于acceptance的测试进行开发。对于测试开发的详细概念并不做深究,推荐小伙伴自行拓展。Yii2 内置了Codeception测试框架,并提供了一系列的助手类方便和Yii2结合进行测试。但是本书中并不才用他,而是只接使用Codeception官方类库。

首先建立项目根文件夹,然后在项目根文件夹下打开命令行执行composer require "codeception/codeception:2.3.8",该命令会自动建立vendor文件夹并下载相应文件。(请确保下载相同版本,以免运行出现错误。)接着打开vendor\bin文件夹,会看到有codecept等一系列可执行文件,然后把该目录加入系统path环境变量,以使在系统任何目录下不必输入全路径直接使用codecept就可以执行该命令。

codeception是一个复杂的系统。因此在项目根文件夹下执行codecept bootstrap,该命令讲建立一个test目录并配置默认的codeception。

建立第一个测试

项目跟文件夹下执行codecept g:cept acceptance SmokeTest,该命令在tests\acceptance文件夹下创建SmokeTest.php文件。打开并修改其中内容如下

$I = new AcceptanceTester($scenario);

//提示我们想要执行什么操作
$I->wantTo('查看主页');
//访问链接,相对路径,默认会和下面的设置组成 http://localhost:8080/index.php 
$I->amOnPage('/');

//查看打开的页面是否有改内容
$I->see('Our CRM');

AcceptanceTester是拥有所有测试方法的类,我们可以使用该类去模拟一个浏览器上的真实用户,以便测试我们的应用。

现在我们运行codecept run,提示错误。因为我们并没有开启web服务器及配置codeception。
打开tests/acceptance.suite.yml修改如下

actor: AcceptanceTester
modules:
    enabled:
        - PhpBrowser:
            url: http://localhost:8080/index.php
        - \Helper\Acceptance

配置使用PhpBrowser模拟用户行为,url是默认访问地址。codeception有两种模拟用户的行为,一种是PhpBrowser软件模拟,二是WebDriver,打开一个真是浏览器访问。详细对比看链接

接着在项目根文件夹打开命令行,执行php -S localhost:8080建立一个web服务器。然后在新建一个index.php文件。内容为Our CRM.

命令行执行codecept run。显示执行成功。

建立测试

改案例执行下列操作:

  1. 打开增加顾客页面
  2. 增加顾客customer1到数据库,顾客列表应该显示一个顾客
  3. 增加顾客customer2到数据库,顾客列表应该显示两个顾客
  4. 打开通过手机号查询顾客页面
  5. 输入customer1的手机号,查询结果页面应该显示customer1的信息而没有customer2的信息

把操作转换为测试操作

执行 codecept g:cept acceptance QueryCustomerByPhoneNumber,生成文件修改内容如下

<?php
$I = new  \Step\Acceptance\CRMOperatorSteps($scenario);
$I->wantTo('add two different customers to database');
//增加第一个用户到数据库
$I->amInaddCustomerUI();
$first_customer = $I->imagineCustomer();
$I->fillCustomerDataForm($first_customer);
$I->submitCustomerDataForm();
$I->seeIAmInListCustomersUI();

//增加第二个用户到数据库
$I->amInaddCustomerUI();
$second_customer = $I->imagineCustomer();
$I->fillCustomerDataForm($second_customer);
$I->submitCustomerDataForm();
$I->seeIAmInListCustomersUI();

//查询用户
$I = new \Step\Acceptance\CRMUserSteps($scenario);
$I->wantTo('query the customers info using his phone number');
$I->amInQueryCustomerUI();
$I->fillInPhoneFieldWithDataFrom($first_customer);
$I->clickSearchButton();

$I->seeIAmInListCustomersUI();
$I->seeCustomerInList($first_customer);
$I->dontSeeCustomerInList($second_customer);

该测试中使用了StepObject,StepObject是AcceptanceTester的子类,继承了其所有方法,我们可以在其中添加方法,达到复用的目的。详细教程

执行 codecept g:stepobject acceptance CRMOperatorSteps一路回车,不用输入。然后根据提示找到该文件增加方法如下。

    public function amInAddCustomerUI()
    {
        $I = $this;
        $I->amOnPage('/customers/add');
        # code...
    }

    public function imagineCustomer()
    {
        $faker = \Faker\Factory::create();
        return [
            'CustomerRecord[name]' =>$faker->name,
            'CustomerRecord[birth_date]'=>$faker->date('Y-m-d'),
            'CustomerRecord[notes]'=>$faker->sentence(8),
            'PhoneRecord[number]'=>$faker->phoneNumber
        ];
    }

    public function fillCustomerDataForm($fieldsData)
    {
        $I = $this;
        foreach ($fieldsData as $key => $value) {
            $I->fillField($key,$value);
        }
    }

    public function submitCustomerDataForm()
    {
        $I = $this;
        $I->click('Submit');
    }

    public function seeIAmInListCustomersUi()
    {
        $I = $this;
        $I->seeCurrentUrlMatches('/customers/');
    }
    public function amInListCustomersUi()
    {
        $I = $this;
        $I->amOnPage('/customers');
    }

其中我们使用了Faker库生成模拟数据。执行composer require "fzaninotto/faker:1.7.1"导入该库。

执行 codecept g:stepobject acceptance CRMUserSteps一路回车,不用输入。然后根据提示找到该文件增加方法如下。

    public function seeLargeBodyOfText()
    {
        $I =$this;
        $text = $I->grabTextFrom('p');
        $I->seeContentIsLong($text);
    }
    public function amInQueryCustomerUI()
    {
        $I = $this;
        $I->amOnPage('/customers/query');
    }
    public function fillInPhoneFieldWithDataFrom($customer_data)
    {
        $I = $this;
        $I->fillField(
            'phone_number',
            $customer_data['PhoneRecord[number]']
        );
    }

    public function clickSearchButton()
    {
        $I =$this;
        $I->click('Search');
    }
    public function seeIAmInListCustomersUi()
    {
        $I = $this;
        $I->seeCurrentUrlMatches('/customers/');
    }

    public function seeCustomerInList($customer_data)
    {
        $I = $this;
        $I->see($customer_data['CustomerRecord[name]'],
         '#search_results');
    }
    public function dontSeeCustomerInList($customer_data)
    {
        $I = $this;
        $I->dontSee(
            $customer_data['CustomerRecord[name]'],
             '#search_results');
    }

仔细看看这三个类,方法名都说明了功能。然后我们需要做的就是让测试通过。

导入Yii2

本书是将Yii2作为一个包引入,并不是直接使用Yii提供的模板。下面事详细步骤。

安装Yii2

执行composer require "yiisoft/yii2:2.0.14"。注意的是安装过程中需要下载大量文件,可能要求输入github token,这时用自己的github账号生成一个粘贴过来就行了。具体步骤谷歌。运行完后可以看到有个vendor/yiisoft/yii2。说明Yii2已经导入了。

配制Yii2

Yii2是一个单一入口的MVC的框架(有疑问自行谷歌)。因此一个请求的流程如下:

  1. WEB服务器收到请求发送给入口文件 index.php
  2. 一个Yii Application对象被实例化。它决定处理该请求的Controller。
  3. 该Controller实例化,然后决定处理该请求的action,并运行该action
  4. 该action运行,开发者可以在该action返回一个view,或者是xml,json,甚至不返回任何东西。
  5. 特殊的组件可以在数据返回给用户前格式化该数据。

知道这些步骤来建立我们的目录结构:

+-- config
|   +-- web.php
+-- controllers
+-- view
+-- models
+-- tests
+-- vender
+-- web
|   +-- index.php

修改index.php如下

//设置开发模式,会提示错误
define('YII_DEBUG', 'true');

//导入composer自动加载,可以加载composer require进来的包
require __DIR__.'\\..\\vendor\\autoload.php';

//导入Yii框架自身
require(__DIR__.'\\..\\vendor\\yiisoft\\yii2\\Yii.php');

//载入配置文件
$config = require(__DIR__.'\\..\\config\\web.php');

//实例化一个应用并运行
(new yii\web\Application($config))->run();

config/web.php配置文件如下。

<?php
return [
    //id是必须设置的项目,是该应用的唯一标识
    'id' => 'app',
    //必须设置的属性。告诉Yii自身存在系统中的位置。
    'basePath' => dirname(__DIR__),

    'components'=>[
        'request'=>[
            //这是安全设置,防止应用被恶意攻击。自由填写。可以通过设置enableCookieValidation 为 false 关闭。
            'cookieValidationKey'=>'qweqweqw21',
        ],
    ],
];

测试Yii控制器

理解Yii如何寻找控制类是重要的,Yii2自动载入兼容PSR-4标准。Yii定义了我们的项目根目录为\app命名空间,因此默认的控制器命名空间是\app\controllers

建立controller/SiteController.php,内容如下

<?php
namespace app\controllers;

use \yii\web\Controller;
class SiteController extends Controller
{
    public function actionIndex()
    {
        return 'Our CRM';
    }
}

然后进入web文件夹,执行php -S localhost:8080建立web服务器。

运行codecept run tests\acceptance\SmokeTestCept,查看是否成功,成功则说明Yii已经正常运行了。

建立控制器

由前面测试可以。我们首先需要提供两个路由 /customers/add/customers

建立 controllers\CustomersController.php,内容如下

<?php
namespace app\controllers;
use yii\web\Controller;
class CustomersController extends Controller
{
    public function actionIndex()
    {
        $records = $this->getRecordsAccordingToQuery();
        $this->render('index', compact('records'));
    }

    public function actionAdd()
    {
        $this->render('add');
    }
}

为了获得数据,我们需要定义Model。在数据层创立一个customer模型(采用了领域模型设计模式,能从ORM中实现解耦,不过究竟如何实现解耦的,我依旧没研究透,希望有大神分析一下。)
建立 models\customer\Customer.php,内容如下:

<?php
namespace app\models\customer;

/**
*
*/
class Customer
{
    /** @var string [string] */
    public $name;

    /** @var \DateTime [\DateTime] */
    public $birth_date;

    /** @var string [description] */
    public $notes;

    /** @var array [description] */
    public $phones= [];

    public function __construct($name, $birth_date)
    {
        $this->name = $name;
        $this->birth_date = $birth_date;
    }

}

再建立 models\customer\Phone.php,内容如下:

<?php
namespace app\models\customer;

/**
*
*/
class Phone
{
    /** @var string [string] */
    public $number;

    public function __construct($number)
    {
        $this->number = $number;
    }
}

创键数据表

传统的我们手动使用sql语句去建立数据库,但是Yii提供了自动化创建数据库的方法。并能将sql纳入版本管理。

在项目根目录下建立yii文件,无后缀名。内容如下

#!/usr/bin/env php
<?php
define('YII_DEBUG', true);
require(__DIR__ . '/vendor/autoload.php');
require(__DIR__ . '/vendor/yiisoft/yii2/Yii.php');
$config = require(__DIR__ . '/config/console.php');
//yii提供了两种环境下的运行方式,一种是web,一种是命令行
$application = new yii\console\Application($config);
$exitCode = $application->run();
exit($exitCode);

linux下,直接./yii就能运行,windows下执行php yii运行。注意linux还必须赋予执行权限。大家可以运行下看看有多少操作命令及功能。

由上建立config/console.php配置文件。

<?php
return [
    'id' => 'crmapp-console',
    'basePath' => dirname(__DIR__),
    'components' => [
        'db' => require(__DIR__ . '/db.php'),
    ],
];

在建立config/db.php配置文件。

<?php
return [
    'class' => '\yii\db\Connection',
    'dsn' => 'mysql:host=localhost;dbname=crmapp',
    'username' => 'root',
    'password' => 'cheesy/hamburger'
];

然后执行./yii migrate/create init_customer_table,windows下执行php yii migrate/create init_customer_table,后不赘述。该命令讲自动生成migrations文件夹,并生成名字如m140204_190825_init_ customer_tabled的文件。修改内容如下。

<?php

use yii\db\Migration;

/**
 * Class m180222_125258_init_customer_table
 */
class m180222_125258_init_customer_table extends Migration
{
    /**
     * {@inheritdoc}
     */
    public function safeUp()
    {
        $this->createTable(
            'customers',[
                'id'=>'pk',
                'name'=>'string',
                'birth_date'=>'date',
                'notes'=>'text',
            ],
            'ENGINE=InnoDB'
        );
        echo "ok";
        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function safeDown()
    {
        $this->dropTable('customers');
        echo "m180222_125258_init_customer_table cannot be reverted.\n";

        return false;
    }

    /*
    // Use up()/down() to run migration code without a transaction.
    public function up()
    {

    }

    public function down()
    {
        echo "m180222_125258_init_customer_table cannot be reverted.\n";

        return false;
    }
    */
}

safeUp()up()的区别是有无事务,没用事务的话可能执行一半被打断会产生错误。

再执行./yii migrate/create init_Phone_table。修改内容:

<?php

use yii\db\Migration;

/**
 * Class m180222_130137_init_phone_table
 */
class m180222_130137_init_phone_table extends Migration
{
    /**
     * {@inheritdoc}
     */
    public function safeUp()
    {
        $this->createTable(
        'phone',
        [
        'id' => 'pk',
        'customer_id' => 'int unique',
        'number' => 'string',
        ],
        'ENGINE=InnoDB'
        );
        $this->addForeignKey('customer_phone_numbers', 'phone',
        'customer_id', 'customers', 'id');
    }

    /**
     * {@inheritdoc}
     */
    public function safeDown()
    {
        $this->dropForeignKey('customer_phone_numbers', 'phone');
        $this->dropTable('phone');
        echo "m180222_130137_init_phone_table cannot be reverted.\n";

        return false;
    }

    /*
    // Use up()/down() to run migration code without a transaction.
    public function up()
    {

    }

    public function down()
    {
        echo "m180222_130137_init_phone_table cannot be reverted.\n";

        return false;
    }
    */
}

执行 ./yii migrate在数据库建立这两个表。想要删除时。执行./yii migrate/down

建立AR类

建立models\customer\CustomerRecord.php:

<?php
namespace app\models\customer;
use yii\db\ActiveRecord;
class CustomerRecord extends ActiveRecord
{
    public static function tableName()
    {
          return 'customer';
    }
    
    //增加字段限制规则,具体可以查看手册。
    public function rules()
    {
        return [
            ['id', 'number'],
            ['name', 'required'],
            ['name', 'string', 'max' => 256],
            ['birth_date', 'date', 'format' => 'Y-m-d'],
            ['notes', 'safe']
        ];
    }
}

同时建立models\customer\PhoneRecord.php

<?php
namespace app\models\customer;
use yii\db\ActiveRecord;

class PhoneRecord extends ActiveRecord
{
    public static function tableName()
    {
        return 'phone';
    }
    public function rules()
    {
        return [
            ['customer_id', 'number'],
            ['number', 'string'],
            [['customer_id', 'number'], 'required'],
        ];
    }
}

从ORM(即Yii中的AR)中去耦

为了从Yii框架中解耦,我们建立了Customer和Phone两个微小的领域模型。因此我们需要一个把Yii2中的ORM和领域模型进行转换的转换层。然而她需要大量的篇幅去说明,因此本书采取一个这种的办法,仅仅在customersController中添加两个方法。但是在大型应用中你必须建立适当的转换层。原因如下:

  1. ORM使用是方便的,但是他产生了太多对数据库的请求,这是应该避免的。因此,你可以使用更低的一层比如DAO,或者绕过Yii使用原生PDO。如果没有这个领域模型,你将非常麻烦。
  2. 如果未来你打算使用其他的ORM而不使用Yii的,没有领域模型,你需要做大量重写。
  3. 未来Yii可能从2升级到3,大量的API改变,如果没有与ORM解耦,那么就不能升级。
    (此处不是很明白,望大佬指点)。

customersController增加方法

private function store(Customer $customer)
    {
        $customer_record = new CustomerRecord();
        $customer_record->name = $customer->name;
        $customer_record->birth_date = $customer->birth_date->format('Y-m-d');
        $customer_record->notes = $customer->notes;

        $customer_record->save();

        foreach ($customer->phones as $phone)
        {
            $phone_record = new PhoneRecord();
            $phone_record->number = $phone->number;
            $phone_record->customer_id = $customer_record->id;
            if (!$phone_record->save()){
                var_dump($phone_record->errors);
                exit();
            }

        }

    }

    private function makeCustomer(CustomerRecord $customer_record,PhoneRecord $phone_record)
    {
        $name = $customer_record->name;
        $birth_date = new \DateTime($customer_record->birth_date);
        $customer = new Customer($name,$birth_date);
        $customer->notes = $customer_record->notes;
        $customer->phones[] = new Phone($phone_record->number);

        return $customer;

    }

建立视图

前面已经建立了/customer/add对应的方法。现在建立对应渲染的视图。
建立views/customer/add.php

<?php
/**
 * Created by PhpStorm.
 * User: xiyaozhe
 * Date: 2018/2/23
 * Time: 17:59
 */

use app\models\customer\CustomerRecord;
use app\models\customer\PhoneRecord;
use yii\web\View;
use yii\helpers\Html;
use yii\widgets\ActiveForm;
/**
 * Add customers UI.
 *
 * @var View $this
 * @var CustomerRecord $customer
 * @var PhoneRecord $phone
 */
$form = ActiveForm::begin([
    'id' => 'add-customers-form',
]);
echo $form->errorSummary([$customer, $phone]);
echo $form->field($customer, 'name');
echo $form->field($customer, 'birth_date');
echo $form->field($customer, 'notes');
echo $form->field($phone, 'number');
echo Html::submitButton('Submit', ['class' => 'btn btn-primary']);
ActiveForm::end();

接着修改customersController中actionAdd()方法

public function actionAdd()
{
    $customer = new CustomerRecord();
    $phone = new PhoneRecord();

    if ($this->load($customer,$phone,$_POST))
    {
        $this->store($this->makeCustomer($customer,$phone));
        return $this->redirect('/customers');
    }
    return $this->render('add',compact('customer','phone'));
}

//验证两个输入都符合验证规则才能提交
private function load(CustomerRecord $customer, PhoneRecord
$phone, array $post)
{
    return $customer->load($post)
        and $phone->load($post)
        and $customer->validate()
        and $phone->validate(['number']);
}

现在访问是通过http://yourdomain/index.php?r=controller/action访问,这样并不美观。
我们可以在配置文件中的components中加入

'urlManager' => [
    'enablePrettyUrl' => true,
    'showScriptName' => false
]

现在可以通过http://yourdomain/index.php/customers/add访问。当然你可以进一步隐藏隐藏index.php,不过这里就不做详细介绍了。

布局文件

当使用render()时,他会假定有一个布局文件。默认为views/layouts/main.php,建立该文件。内容为下

<!DOCTYPE html>
<html>
<head>
<title>CRM</title>
</head>
<body>
<?= $content; ?>
</body>
</html>

<?= $content; ?>就是add.php等渲染出来放置的位置。现在访问该页面,应该已经能看到前台界面了

显示数据

接着在customersController中添加

//actionIndex()调用
private function findRecordsByQuery()
{
    $number = Yii::$app->request->get('phone_number');
    $records = $this->getRecordsByPhoneNumber($number);
    $dataProvider = $this->wrapIntoDataProvider($records);
    return $dataProvider;
}

   private function warpIntoDataProvider($data)
    {
        return new ArrayDataProvider(
            ['allModels' => $data,
             'pagination'=>false]
        );
    }

    private function getRecordsByPhoneNumber($number)
    {
        $phone_record = PhoneRecord::findOne(['number'=>$number]);
        if (!$phone_record)
            return [];
        $customer_record = CustomerRecord::findOne($phone_record->customer_id);
        if (!$customer_record)
            return [];
        return [$this->makeCustomer($customer_record, $phone_record)];
    }

建立views/customers/index.php

echo \yii\widgets\ListView::widget(
[
'options' => [
'class' => 'list-view',
'id' => 'search_results'
],
'itemView' => '_customer',
'dataProvider' => $records
]
);

建立views/customers/_customer.php文件。

echo \yii\widgets\DetailView::widget(
[
    'model' => $model,
    'attributes' => [
        ['attribute' => 'name'],
        ['attribute' => 'birth_date', 'value' => $model->birth_
        date->format('Y-m-d')],
        'notes:text',
        ['label' => 'Phone Number', 'attribute' =>
        'phones.0.number']
    ]
]);

关于widgets的详细使用,大家就查查官方文档吧,这里做过多解释。

建立查询界面

customersController中添加

public function actionQuery()
{
    return $this->render('query');
}


建立views/customers/query.php文件。

<?php
use yii\helpers\Html;
echo Html::beginForm(['/customers'], 'get');
echo Html::label('Phone number to search:', 'phone_number');
echo Html::textInput('phone_number');
echo Html::submitButton('Search');
echo Html::endForm();

运行测试

执行codecept run acceptance,此时显示全部通过测试则大功告成。

最后

文章写作匆忙,难免有所纰漏,大家见谅。最近要准备考试,下一篇不知什么时候才能发布,大家就期待一下吧。

您需要登录后才可以评论。登录 | 立即注册