Laravel5.5开发学习笔记

本博文用来整理在开发中遇到的Laravel新特性的笔记。

一、清除缓存命令

php artisan optimize
php artisan cache:clear
php artisan config:clear    // 清除配置文件缓存
php artisan route:clear
php artisan view:clear

二、composer

修改 composer.json 后需使用命令重新加载:

composer dumpautoload

三、事务

DB::transaction() 方法会开启一个数据库事务,在回调函数里的所有 SQL 写操作都会被包含在这个事务里,如果回调函数抛出异常则会自动回滚这个事务,否则提交事务。用这个方法可以帮我们节省不少代码。

 // 开启一个数据库事务
  $order = \DB::transaction(function() use ($user, $request){
     // 具体业务...
  });

四、异常处理

异常指的是在程序运行过程中发生的异常事件,通常是由外部问题所导致的。
异常处理是程序开发中经常遇到的任务,如何优雅地处理异常,从一定程度上反映了你的程序是否足够严谨。

我们将异常大致分为 用户异常 和 系统异常,接下来我们将分别对其讲解和代码实现。

1.用户错误行为触发的异常

比如上章节中已经验证过邮箱的用户再次去申请激活邮件时触发的异常,对于此类异常我们需要把触发异常的原因告知用户。

我们把这类异常命名为 InvalidRequestException,可以通过 make:exception 命令来创建:

$ php artisan make:exception InvalidRequestException

新创建的异常文件保存在 app/Exceptions/ 目录下:

app/Exceptions/InvalidRequestException.php

expectsJson()) {
            // json() 方法第二个参数就是 Http 返回码
            return response()->json(['msg' => $this->message], $this->code);
        }

        return view('pages.error', ['msg' => $this->message]);
    }
}

Laravel 5.5 之后支持在异常类中定义 render() 方法,该异常被触发时系统会调用 render() 方法来输出,我们在 render() 里判断如果是 AJAX 请求则返回 JSON 格式的数据,否则就返回一个错误页面。

现在来创建这个错误页面:

$ touch resources/views/pages/error.blade.php

resources/views/pages/error.blade.php

@extends('layouts.app')
@section('title', '错误')

@section('content')
错误

{{ $msg }}

返回首页
@endsection

当异常触发时 Laravel 默认会把异常的信息和调用栈打印到日志里,比如:

而此类异常并不是因为我们系统本身的问题导致的,不会影响我们系统的运行,如果大量此类日志打印到日志文件里反而会影响我们去分析真正有问题的异常,因此需要屏蔽这个行为。

Laravel 内置了屏蔽指定异常写日志的解决方案:

app/Exceptions/Handler.php

.
.
.
    protected $dontReport = [
        InvalidRequestException::class,
    ];
.
.
.

当一个异常被触发时,Laravel 会去检查这个异常的类型是否在 $dontReport 属性中定义了,如果有则不会打印到日志文件中。

2.系统内部异常

比如连接数据库失败,对于此类异常我们需要有限度地告知用户发生了什么,但又不能把所有信息都暴露给用户(比如连接数据库失败的信息里会包含数据库地址和账号密码),因此我们需要传入两条信息,一条是给用户看的,另一条是打印到日志中给开发人员看的。

新建一个 InternalException 类:

$ php artisan make:exception InternalException

app/Exceptions/InternalException.php

msgForUser = $msgForUser;
    }

    public function render(Request $request)
    {
        if ($request->expectsJson()) {
            return response()->json(['msg' => $this->msgForUser], $this->code);
        }

        return view('pages.error', ['msg' => $this->msgForUser]);
    }
}

这个异常的构造函数第一个参数就是原本应该有的异常信息比如连接数据库失败,第二个参数是展示给用户的信息,通常来说只需要告诉用户 系统内部错误 即可,因为不管是连接 Mysql 失败还是连接 Redis 失败对用户来说都是一样的,就是系统不可用,用户也不可能根据这个信息来解决什么问题。

使用

接下来我们要把之前验证邮箱功能中的异常替换成我们刚刚定义的异常。

app/Http/Controllers/EmailVerificationController.php

use App\Exceptions\InvalidRequestException;
.
.
.
    public function verify(Request $request)
    {

        $email = $request->input('email');
        $token = $request->input('token');
        if (!$email || !$token) {
            throw new InvalidRequestException('验证链接不正确');
        }
        if ($token != Cache::get('email_verification_'.$email)) {
            throw new InvalidRequestException('验证链接不正确或已过期');
        }
        if (!$user = User::where('email', $email)->first()) {
            throw new InvalidRequestException('用户不存在');
        }
        .
        .
        .
    }
    public function send(Request $request)
    {
        $user = $request->user();
        if ($user->email_verified) {
            throw new InvalidRequestException('你已经验证过邮箱了');
        }
        .
        .
        .
    }

五、延迟任务

Laravel 提供了延迟任务(Delayed Job)功能来解决购物车长时间占用库存的问题。当我们的系统触发了一个延迟任务时,Laravel 会用当前时间加上任务的延迟时间计算出任务应该被执行的时间戳,然后将这个时间戳和任务信息序列化之后存入队列,Laravel 的队列处理器会不断查询并执行队列中满足预计执行时间等于或早于当前时间的任务。

1、创建任务

我们通过 make:job 命令来创建一个任务:

$ php artisan make:job CloseOrder

创建的任务类保存在 app/Jobs 目录下,现在编辑刚刚创建的任务类:

app/Jobs/CloseOrder.php

order = $order;
        // 设置延迟的时间,delay() 方法的参数代表多少秒之后执行
        $this->delay($delay);
    }

    // 定义这个任务类具体的执行逻辑
    // 当队列处理器从队列中取出任务时,会调用 handle() 方法
    public function handle()
    {
        // 判断对应的订单是否已经被支付
        // 如果已经支付则不需要关闭订单,直接退出
        if ($this->order->paid_at) {
            return;
        }
        // 通过事务执行 sql
        \DB::transaction(function() {
            // 将订单的 closed 字段标记为 true,即关闭订单
            $this->order->update(['closed' => true]);
            // 循环遍历订单中的商品 SKU,将订单中的数量加回到 SKU 的库存中去
            foreach ($this->order->items as $item) {
                $item->productSku->addStock($item->amount);
            }
        });
    }
}

2. 触发任务

接下来我们需要在创建订单之后触发这个任务:

app/Http/Controllers/OrdersController.php

use App\Jobs\CloseOrder;
    .
    .
    .
    public function store(Request $request)
    {
        .
        .
        .
        $this->dispatch(new CloseOrder($order, config('app.order_ttl')));

        return $order;
    }

CloseOrder 构造函数的第二个参数延迟时间我们从配置文件中读取,为了方便我们测试,把这个值设置成 30 秒:

config/app.php

'order_ttl' => 30,

3. 测试

默认情况下,Laravel 生成的 .env 文件里把队列的驱动设置成了 sync(同步),在同步模式下延迟任务会被立即执行,所以需要先把队列的驱动改成 redis

.env

QUEUE_DRIVER=redis

要使用 redis 作为队列驱动,我们还需要引入 predis/predis 这个包

$ composer require predis/predis

接下来启动队列处理器:

$ php artisan queue:work

Laravel5.5开发学习笔记_第1张图片

六、权限控制

为了安全起见我们只允许订单的创建者可以看到对应的订单信息,这个需求可以通过授权策略类(Policy)来实现。

通过 make:policy 命令创建一个授权策略类:

$ php artisan make:policy OrderPolicy

app/Policies/OrderPolicy.php

user_id == $user->id;
    }
}

然后在 AuthServiceProvider 中注册这个策略:

app/Providers/AuthServiceProvider.php

use App\Models\Order;
use App\Policies\OrderPolicy;
.
.
.
    protected $policies = [
        UserAddress::class => UserAddressPolicy::class,
        Order::class       => OrderPolicy::class,
    ];

最后在 OrdersController@show() 中校验权限:

appHttp/Controllers/OrdersController.php

  public function show(Order $order, Request $request)
    {
        // 权限校验
        $this->authorize('own', $order);
        return view('orders.show', ['order' => $order->load(['items.productSku', 'items.product'])]);
    }

七、封装业务代码

一般项目开始的时候业务比较简单,我们都将业务逻辑写在了控制器,但是随着时间的增加,我们会发现我们在 Controller 里面写了大量的包含复杂逻辑的业务代码,这是一个坏习惯,这样子随着需求的增加,我们的控制器很快就变得臃肿。如果以后我们要开发 App 端,这些代码可能需要在 Api 的 Controller 里再重复一遍,假如出现业务逻辑的调整就需要修改两个或更多地方,这明显是不合理的。因此我们需要对 逻辑复杂业务代码 进行封装。

这里我们将在项目里采用 Service 模式来封装代码。购物车的逻辑,放置于 CartService 类里,将下单的业务逻辑代码放置于 OrderService里。

这里以电商项目的订单做示例:

1、购物车

首先创建一个 CartService 类:

$ mkdir -p app/Services && touch app/Services/CartService.php

app/Services/CartService.php

cartItems()->with(['productSku.product'])->get();
    }

    public function add($skuId, $amount)
    {
        $user = Auth::user();
        // 从数据库中查询该商品是否已经在购物车中
        if ($item = $user->cartItems()->where('product_sku_id', $skuId)->first()) {
            // 如果存在则直接叠加商品数量
            $item->update([
                'amount' => $item->amount + $amount,
            ]);
        } else {
            // 否则创建一个新的购物车记录
            $item = new CartItem(['amount' => $amount]);
            $item->user()->associate($user);
            $item->productSku()->associate($skuId);
            $item->save();
        }

        return $item;
    }

    public function remove($skuIds)
    {
        // 可以传单个 ID,也可以传 ID 数组
        if (!is_array($skuIds)) {
            $skuIds = [$skuIds];
        }
        Auth::user()->cartItems()->whereIn('product_sku_id', $skuIds)->delete();
    }
}

接下来我们要修改 CartController,将其改为调用刚刚创建的 CartService 类:

app/Http/Controllers/CartController.php

cartService = $cartService;
    }

    public function index(Request $request)
    {
        // select * from product_skus where id in (xxxx)
        $cartItems = $this->cartService->get();

        $addresses = $request->user()->addresses()->orderBy('last_used_at', 'desc')->get();

        return view('cart.index', ['cartItems' => $cartItems, 'addresses' => $addresses]);
    }

    public function add(AddCartRequest $request)
    {
        $this->cartService->add($request->input('sku_id'), $request->input('amount'));

        return [];
    }



    public function remove(ProductSku $sku, Request $request)
    {
        $this->cartService->remove($sku->id);
        return [];
    }
}

这里我们使用了 Laravel 容器的自动解析功能,当 Laravel 初始化 Controller 类时会检查该类的构造函数参数,在本例中 Laravel 会自动创建一个 CartService 对象作为构造参数传入给 CartController。

2、订单

原始订单控制器

app/Http/Controllers/OrdersController.php

authorize('own', $order);

        // 这里的 load() 方法与上一章节介绍的 with() 预加载方法有些类似,称为 延迟预加载
        // 不同点在于 load() 是在已经查询出来的模型上调用,而 with() 则是在 ORM 查询构造器上调用。

        return view('orders.show', ['order' => $order->load(['items.productSku', 'items.product'])]);
    }

    public function index(Request $request)
    {
        $orders = Order::query()
            // 使用 with 方法预加载,避免N + 1问题
            ->with(['items.product', 'items.productSku'])
            ->where('user_id', $request->user()->id)
            ->orderBy('created_at', 'desc')
            ->paginate();

        return view('orders.index', ['orders' => $orders]);
    }

    // 利用 Laravel 的自动解析功能注入 CartService 类
    public function store(OrderRequest $request, CartService $cartService)
    {
        $user = $request->user();

        // 开启一个数据库事务
        $order = \DB::transaction(function() use ($user, $request){
            $address = UserAddress::find($request->input('address_id'));

            // 更新此地址的最后使用时间
            $address->update(['last_used_at' => Carbon::now()]);

            // 创建一个订单
            $order   = new Order([
                'address'      => [ // 将地址信息放入订单中
                    'address'       => $address->full_address,
                    'zip'           => $address->zip,
                    'contact_name'  => $address->contact_name,
                    'contact_phone' => $address->contact_phone,
                ],
                'remark'       => $request->input('remark'),
                'total_amount' => 0,
            ]);

            // 订单关联到当前用户
            $order->user()->associate($user);

            // 写入数据库
            $order->save();

            $totalAmount = 0;
            $items       = $request->input('items');
            // 遍历用户提交的 SKU
            foreach ($items as $data) {
                $sku  = ProductSku::find($data['sku_id']);
                // 创建一个 OrderItem 并直接与当前订单关联
                $item = $order->items()->make([
                    'amount' => $data['amount'],
                    'price'  => $sku->price,
                ]);
                $item->product()->associate($sku->product_id);
                $item->productSku()->associate($sku);
                $item->save();
                $totalAmount += $sku->price * $data['amount'];

                // 减库存
                if ($sku->decreaseStock($data['amount']) <= 0) {
                    throw new InvalidRequestException('该商品库存不足');
                }
            }

            // 更新订单总金额
            $order->update(['total_amount' => $totalAmount]);

            // 将下单的商品从购物车中移除
            $skuIds = collect($request->input('items'))->pluck('sku_id');
            // $user->cartItems()->whereIn('product_sku_id', $skuIds)->delete();
            $cartService->remove($skuIds);

            return $order;

        });

        $this->dispatch(new CloseOrder($order, config('app.order_ttl')));

        return $order;
    }
}

封装服务类

首先创建 OrderService 类:

$ touch app/Services/OrderService.php

app/Services/OrderService.php

update(['last_used_at' => Carbon::now()]);
            // 创建一个订单
            $order   = new Order([
                'address'      => [ // 将地址信息放入订单中
                    'address'       => $address->full_address,
                    'zip'           => $address->zip,
                    'contact_name'  => $address->contact_name,
                    'contact_phone' => $address->contact_phone,
                ],
                'remark'       => $remark,
                'total_amount' => 0,
            ]);
            // 订单关联到当前用户
            $order->user()->associate($user);
            // 写入数据库
            $order->save();

            $totalAmount = 0;
            // 遍历用户提交的 SKU
            foreach ($items as $data) {
                $sku  = ProductSku::find($data['sku_id']);
                // 创建一个 OrderItem 并直接与当前订单关联
                $item = $order->items()->make([
                    'amount' => $data['amount'],
                    'price'  => $sku->price,
                ]);
                $item->product()->associate($sku->product_id);
                $item->productSku()->associate($sku);
                $item->save();
                $totalAmount += $sku->price * $data['amount'];
                if ($sku->decreaseStock($data['amount']) <= 0) {
                    throw new InvalidRequestException('该商品库存不足');
                }
            }
            // 更新订单总金额
            $order->update(['total_amount' => $totalAmount]);

            // 将下单的商品从购物车中移除
            $skuIds = collect($items)->pluck('sku_id')->all();
            app(CartService::class)->remove($skuIds);

            return $order;
        });

        // 这里我们直接使用 dispatch 函数
        dispatch(new CloseOrder($order, config('app.order_ttl')));

        return $order;
    }
}

这里大多数的代码都是从 OrdersController 中直接复制过来的,只有些许的变化需要注意:

  • 1、$user、$address 变量改为从参数获取。我们在封装功能的时候有一点一定要注意$request 不可以出现在控制器和中间件以外的地方,根据【职责单一原则】,获取数据这个任务应该由控制器来完成,封装的类只需要专注于业务逻辑的实现
  • 2、CartService 的调用方式改为了通过 app() 函数创建,因为这个 store() 方法是我们手动调用的,无法通过 Laravel 容器的自动解析来注入。在我们代码里调用封装的库时一定 不可以 使用 new 关键字来初始化,而是应该通过 Laravel 的容器来初始化,因为在之后的开发过程中 CartService 类的构造函数可能会发生变化,比如注入了其他的类,如果我们使用 new 来初始化的话,就需要在每个调用此类的地方进行修改;而使用 app() 或者自动解析注入等方式 Laravel 则会自动帮我们处理掉这些依赖。
  • 3、之前在控制器中可以通过 $this->dispatch() 方法来触发任务类,但在我们的封装的类中并没有这个方法,因此关闭订单的任务类改为 dispatch() 辅助函数来触发。

修改后的控制器

app/Http/Controllers/OrdersController.php

user();
        $address = UserAddress::find($request->input('address_id'));

        return $orderService->store($user, $address, $request->input('remark'), $request->input('items'));
    }
}

3、关于 Service 模式

Service 模式将 PHP 的商业逻辑写在对应责任的 Service 类里,解決 Controller 臃肿的问题。并且符合 SOLID 的单一责任原则,购物车的逻辑由 CartService 负责,而不是 CartController ,控制器是调度中心,编码逻辑更加清晰。后面如果我们有 API 或者其他会使用到购物车功能的需求,也可以直接使用 CartService ,代码可复用性大大增加。再加上 Service 可以利用 Laravel 提供的依赖注入机制,大大提高了 Service 部分代码的可测试性,程序的健壮性越佳。

八、容器

容器是现代 PHP 开发的一个重要概念,Laravel 就是在容器的基础上构建的。我们将支付操作类实例注入到容器中,在以后的代码里就可以直接通过 app('alipay') 来取得对应的实例,而不需要每次都重新创建。

在这个示例中,我们引入第三方支付库yansongda/pay,然后使用容器可以直接调用实例代码。

1、引入支付库

yansongda/pay 这个库封装了支付宝和微信支付的接口,通过这个库我们就不需要去关注不同支付平台的接口差异,使用相同的方法、参数来完成支付功能,节省开发时间。

首先通过 composer 引入这个包:

$ composer require yansongda/pay

配置参数:
创建一个新的配置文件来保存支付所需的参数:
config/pay.php

 [
        'app_id'         => '',
        'ali_public_key' => '',
        'private_key'    => '',
        'log'            => [
            'file' => storage_path('logs/alipay.log'),
        ],
    ],

    'wechat' => [
        'app_id'      => '',
        'mch_id'      => '',
        'key'         => '',
        'cert_client' => '',
        'cert_key'    => '',
        'log'         => [
            'file' => storage_path('logs/wechat_pay.log'),
        ],
    ],
];

2、容器创建

我们通常在 AppServiceProviderregister() 方法中往容器中注入实例:

app/Providers/AppServiceProvider.php

app->singleton('alipay', function () {
            $config = config('pay.alipay');

            // $config['notify_url'] = route('payment.alipay.notify');
            $config['notify_url'] = 'http://requestbin.leo108.com/1nj6jt11';
            $config['return_url'] = route('payment.alipay.return');

            // 判断当前项目运行环境是否为线上环境
            if (app()->environment() !== 'production') {
                $config['mode']         = 'dev';
                $config['log']['level'] = Logger::DEBUG;
            } else {
                $config['log']['level'] = Logger::WARNING;
            }
            // 调用 Yansongda\Pay 来创建一个支付宝支付对象
            return Pay::alipay($config);
        });

        $this->app->singleton('wechat_pay', function () {
            $config = config('pay.wechat');
            if (app()->environment() !== 'production') {
                $config['log']['level'] = Logger::DEBUG;
            } else {
                $config['log']['level'] = Logger::WARNING;
            }
            // 调用 Yansongda\Pay 来创建一个微信支付对象
            return Pay::wechat($config);
        });
    }
}

代码解析:

  • $this->app->singleton() 往服务容器中注入一个单例对象,第一次从容器中取对象时会调用回调函数来生成对应的对象并保存到容器中,之后再去取的时候直接将容器中的对象返回。
  • app()->environment() 获取当前运行的环境,线上环境会返回 production。对于支付宝,如果项目运行环境不是线上环境,则启用开发模式,并且将日志级别设置为 DEBUG。由于微信支付没有开发模式,所以仅仅将日志级别设置为 DEBUG。

3、测试

接下来我们来测试一下刚刚注入到容器中的实例,进入 tinker:

> php artisan tinker

然后分别输入 app('alipay')app('wechat_pay')

clipboard.png

可以看到已经OK了。

九、事件与监听器

Laravel 的事件提供了一个简单的观察者实现,能够订阅和监听应用中发生的各种事件。事件类保存在 app/Events 目录中,而这些事件的的监听器则被保存在 app/Listeners 目录下。这些目录只有当你使用 Artisan 命令来生成事件和监听器时才会被自动创建

事件机制是一种很好的应用解耦方式,因为一个事件可以拥有多个互不依赖的监听器。
比如我们的订单系统,支付之后要给订单中的商品增加销量,比如我们要发邮件给用户告知订单支付成功。

商品增加销量和发送邮件并不会影响到订单的支付状态,即使这两个操作失败了也不影响我们后续的业务流程,对于此类需求我们通常使用异步事件来解决。

1、创建支付成功事件

php artisan make:event OrderPaid

app/Events/OrderPaid.php

order = $order;
    }

    public function getOrder()
    {
        return $this->order;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return \Illuminate\Broadcasting\Channel|array
     */
    public function broadcastOn()
    {
        return new PrivateChannel('channel-name');
    }
}
事件本身不需要有逻辑,只需要包含相关的信息即可,在我们这个场景里就只需要一个订单对象

接下来我们在支付成功的服务器端回调里触发这个事件:
app/Http/Controllers/PaymentController.php

use App\Events\OrderPaid;
.
.
.
    public function alipayNotify()
    {
        .
        .
        .
        $this->afterPaid($order);

        return app('alipay')->success();
    }

    protected function afterPaid(Order $order)
    {
        event(new OrderPaid($order));
    }

2、创建监听器

我们希望订单支付之后对应的商品销量会对应地增加,所以创建一个更新商品销量的监听器:

> php artisan make:listener UpdateProductSoldCount --event=OrderPaid

app/Listeners/UpdateProductSoldCount.php

getOrder();

        // 循环遍历订单的商品
        foreach($order->items as $item)
        {
            $product = $item->product;

            // 计算对应商品的销量
            $soldCount = OrderItem::query()
                ->where('product_id', $product->id)
                ->whereHas('order', function ($query) {
                    $query->whereNotNull('paid_at');  // 关联的订单状态是已支付
                })->sum('amount');

            // 更新商品销量
            $product->update([
                'sold_count' => $soldCount,
            ]);
        }
    }
}

3、关联事件和监听器

别忘了在 EventServiceProvider 中将事件和监听器关联起来:

app/Providers/EventServiceProvider.php

 [
            RegisteredListener::class,
        ],

        OrderPaid::class => [
            UpdateProductSoldCount::class,
            SendOrderPaidMail::class,
        ],
    ];

    /**
     * Register any events for your application.
     *
     * @return void
     */
    public function boot()
    {
        parent::boot();

        //
    }
}

4、测试

由于我们定义的事件监听器都是异步的,因此在测试之前需要先启动队列处理器:

> php artisan queue:work

从数据库中找到一条已经支付成功的订单并记录下其 ID:

Laravel5.5开发学习笔记_第2张图片

然后在终端里进入 tinker:

php artisan tinker

在 tinker 中触发订单支付成功的事件,事件对应的订单就是我们刚刚在数据库中找的那一条:

>>> event(new App\Events\OrderPaid(App\Models\Order::find(16)))

Laravel5.5开发学习笔记_第3张图片

这个时候看到启动队列处理的窗口有了输出:

Laravel5.5开发学习笔记_第4张图片

可以看到更新库存的事件监听器已经在队列中执行了。

十、MySQL命令导出数据

因为这是一个一次性的工作,没有必要专门写代码来处理导入和导出,所以我们选择直接用 mysqldump 这个命令行程序来导出数据库中的数据,从成本上来说比较合适:

mysqldump -t laravel-shop admin_menu admin_permissions admin_role_menu admin_role_permissions admin_role_users admin_roles admin_user_permissions admin_users > database/admin.sql

命令解析:

  • -t 选项代表不导出数据表结构,这些表的结构我们会通过 Laravel 的 migration 迁移文件来创建;
  • laravel-shop 代表我们要导出的数据库名称,后面则是要导出的表列表;
  • database/admin.sql 把导出的内容保存到 database/admin.sql 文件中。
在 Homestead 环境中我们执行 Mysql 相关的命令都不需要账号密码,因为 Homestead 都已经帮我们配置好了。在线上执行 Mysql 命令时则需要在命令行里通过 -u 和 -p 参数指明账号密码,如: mysqldump -uroot -p123456 laravel-shop > database/admin.sql

Laravel5.5开发学习笔记_第5张图片

你可能感兴趣的:(laravel)