Laravel使用ftp上传

接手一个laravel项目,使用ftp上传报错如下

`ftp_rawlist(): php_connect_nonb() failed: Operation now in progress (115)`

原因配置文件中选项没有写全

'driver' => 'ftp',
'host' => '*******',
'port' => 21,
'username' => '*******',
'password' => '*******',
'passive' => false, //此选项需要加
'ignorePassiveAddress' => true, //此选项需要加

参考文章

How to fix `ftp_rawlist(): php_connect_nonb() failed: Operation now in progress (115)`

原理解析

有时间补充

报错如下

500 Illegal PORT command.
配置项改为true
'passive' => true,

Laravel发送邮件操作流程笔记

官方文档描述比较全面,但是不够简洁以及没有操作流程,所以记录一下备忘。

本文是简单实用的快捷配置方式,使用smtp服务,具体需求看官方文档。

申请发件邮箱开启SMTP服务

以qq邮箱为例,授权码即使邮箱发送时验证的密码

网易163邮箱配置

以客户留言接收邮件通知为例 markdown邮件文档

//生成Mailables Markdown 邮件
php artisan make:mail CustomerFeedbackMail --markdown=emails.customer.feedback
如图会生成两个文件,然后编写代码

配置在浏览器中预览邮件 文档

//添加路由
Route::get('mailable', 'Pc\PageController@mailable');//预览mark邮件

//控制器方法
public function mailable(Request $request)
{
   return new CustomerFeedbackMail();
}

//简单编辑邮件模板feedback.blade.php
@component('mail::message')
# 邮件通知

The body of your message.

@component('mail::button', ['url' => ''])
进入官网后台管理
@endcomponent

@endcomponent

引入通知用到模型数据 文档

//预览页面控制器
class PageController extends Controller
{
    public function mailable(Request $request)
    {
        $message = Message::find(1);

        return new CustomerFeedbackMail($message);
    }
}

//邮件mailable
class CustomerFeedbackMail extends Mailable
{
    use Queueable, SerializesModels;

    public $message;

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

    /**
     * Build the message.
     *
     * @return $this
     */
    public function build()
    {   
        //emails.customer.feedback是视图文件目录和文件
        return $this->markdown('emails.customer.feedback', ['feedback' => $this->message])->subject('邮件标题');
    }
}

//邮件视图文件markdown
@component('mail::message')
# 客户留言通知
## 姓名:{{$feedback->name}},
## 电话:{{$feedback->mobile}},
## 留言:{{$feedback->content}},
## 提交终端:{{$feedback->terminal}},
## 提交页面名称:{{$feedback->page_name}},

@component('mail::button', ['url' => ''])
进入官网后台管理
@endcomponent

@endcomponent
预览效果

发送到指定邮箱 遍历收件人列表

//修改config/mail.php 配置,增加默认收件邮箱
'to' => [
   'address' => explode(',', env('MAIL_DEFAULT_TO_ADDRESS', '')),
   'name' => env('MAIL_TO_NAME', ''),
],

//修改env配置
MAIL_MAILER=smtp
MAIL_HOST=smtp.qq.com
MAIL_PORT=465
MAIL_USERNAME=xxxx@qq.com
MAIL_PASSWORD=xxxx
MAIL_ENCRYPTION=ssl
MAIL_FROM_ADDRESS=xxx@qq.com
MAIL_FROM_NAME=xxx
MAIL_DEFAULT_TO_ADDRESS=xxx@foxmail.com
MAIL_TO_NAME=留言通知

//发送代码,此处使用terminable中间件延时发送
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use App\Mail\CustomerFeedbackMail;
use App\Models\Message;
class SendFeedbackMailTerminable
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        return $next($request);
    }

    public function terminate($request, $response)
    {
        $response_data = json_decode($response->getContent(), true);

        if (!isset($response_data['data']['message_id']) || empty($response_data['data']['message_id'])) {
            return;
        }

        Log::info('留言id=>'.$response_data['data']['message_id']);

        $message = Message::find($response_data['data']['message_id']);

        if (!$message instanceof Message) {
            return;
        }

        $toAddress = config('mail.to.address');

        foreach ($toAddress  as $value) {
            try {
                Mail::to($value)->send(new CustomerFeedbackMail($message));
            } catch (\Throwable $th) {
                Log::channel('sendmail')->info('发送邮件失败'.$th->getMessage());
                continue;
            }

            Log::channel('sendmail')->info('[官网留言邮件通知记录]:', ['email' => $value, 'message_id' => $response_data['data']['message_id']]);
        }
    }
}

TDD测试驱动开发笔记

教程

learnku社区 Laravel TDD

TDD 构建 Laravel 论坛笔记

Testing Laravel 单元测试入门笔记

总结

在使用 TDD 进行开发时,请牢记以下三项法则:

  1. 在编写失败的测试之前,不要编写任何业务代码;
  2. 只要有一个单元测试失败了,就不要再写测试代码。无法通过编译也是一种失败情况;
  3. 业务代码恰好能够让当前失败的测试成功通过即可,不要多写;

要掌握PHPUnit的用法,如何测试方法,如何测试类,等等…

根据需求结果逆向分析开发的方式。先假设已经实现了需求的方法,然后一步步倒退,缺少什么就创建什么。

需求结果 => 结果方法 => 按需(缺少创建)代码的元素

根据需求准备好测试用例数据

非常适合复杂逻辑需求的推理分析。对于简单的一下就能想出方案的需求,处于节省时间的考虑,可以不用。

测试覆盖率不一定要100%,个人想法,测试要首先覆盖核心复杂迭代频率较高主要业务,简单的业务可以跳过。

和普通开发方式一样,首先要对项目的整体需求和完整逻辑有详细了解,要有基本的解决方案思路

使用渐进式的编码思路,在mvc框架做业务开发时,可以先在控制器方法写出完整的逻辑,即使代码非常多,然后再根据需求迭代使用设计模式或面向对象设计方法,按需要拆分封装成类或方法,完成由面向过程=>面向对象的演变

项目案例说明

要实现一个产品型号搜索候选项功能

需要给前端提供一个http api ,并且要满足各种情景的搜索条件,如图

1.假设这个接口已经存在了,然后创建一个商品接口测试类,写好接口测试方法和各种情境下的测试用例数据。

namespace Tests\Feature;
class GoodsPcApiTest extends TestCase
{ 
/**
     * A basic feature test example.
     *
     * @return void
     */
    public function test_goods_search_option()
    {
        //只有分隔符
        $response = $this->post('/api/goods/search-option', [
            'keywords'=>'-',
        ]);
       //使用dump打印结果是为了方便调试,调试完毕可以注释掉
        dump('只有分隔符', $response->json());
        $response->assertStatus(200);

        //只有产品型号
        $response = $this->post('/api/goods/search-option', [
            'keywords'=>'AC013',
        ]);
        dump('只有产品型号', $response->json());
        $response->assertStatus(200);

        //产品型号+1分隔符
        $response = $this->post('/api/goods/search-option', [
            'keywords'=>'AC013-',
        ]);
        dump('产品型号+1分隔符', $response->json());
        $response->assertStatus(200);

        //产品型号+1分隔符和参数
        $response = $this->post('/api/goods/search-option', [
            'keywords'=>'AC013-d',
        ]);
        dump('产品型号+1分隔符和1参数', $response->json());
        $response->assertStatus(200);
       ...
    }
...
}

然后运行测试,此时测试必然报错,就需要去创建这个接口的控制器和方法以及路由

//路由文件routes/api.php
Route::prefix('goods')->group(function () {
...
    Route::post('search-option', [GoodsController::class, 'searchOption']); //搜索候选项
...
});
//控制器
namespace App\Http\Controllers\Api;
class GoodsController extends Controller
{
  ...
  public function searchOption(Request $request)
    {   
        //验证参数
        $request->validate([
            'keywords'=>'bail|required|string',
        ]);
        //需要根据分隔符-拆分数据
        $keywords = $request->input('keywords');
        $attribute = array_filter(explode($sep, $keywords));
        //要从模型查询,直接调用,如果测试时报错模型不存在就去创建模型
        //如果运行测试报错数据表不存在,就创建数据迁移
        GoodsParamsSku::select()->where()->get();
        //需要一个方法或类来实现某个功能,先调用,然后运行测试,报错functionName方法不存在
        $this->functionName()
 
    }
  ...
}

根据报错创建上述functionName的单元测试

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class xxxxFunctionTest extends TestCase
{
    /**
     * A basic unit test example.
     *
     * @return void
     */
    public function test_function_name()
    {
        $this->assertTrue(true);
    }
}

在mvc框架中如何封装业务和功能代码

在laravel中如何使用repository模式

单一的CRUD使用ORM,没有封装的必要,比如banner的增删改查

将单一的功能封装成类,比如验证码的发送和验证,数学公式计算,

具有复杂逻辑的同一类需求,购物车整套逻辑, 商品SKU筛选,订单价格计算器(优惠,抵扣,积分等),这里需求可能需要查询多个模型,或者需要复杂的处理数据

一般在框架中封装成单个类放到services目录中

//验证码肯定具有两个方法,发送和验证
//这种固定方法的类可以使用静态方法方便调用
class VerificationCode
{
    const KEY_TEMPLATE = 'verify_code_of_%s';

    /**
     * 创建并存储验证码
     *
     * @param string $phone
     * @return int
     */
    public static function create($phone)
    {
        ...
    }

    /**
     * 检查手机号与验证码是否匹配.
     *
     * @param string $phone
     * @param int    $code
     *
     * @return bool
     */
    public static function validate($phone, $code)
    {
        ...
    }
}

第三方接口调用的封装

只有几个接口,封装成单个类,放到services目录中

需要调用很多接口,有场景和功能的划分,新建SDK目录,按接口功能类型或需求,使用设计模式(也可以不用)封装成简易SDK

一个项目示例

需要快速调用第三方接口完成业务需求,但是又没有顺手的sdk,或者过于复杂文档不全,学习成本过高,可以使用trait快速完成业务分割解耦,实现功能同时又易于维护。

使用http客户端guzzle,主文件sdkclient,实现签名认证参数组合 ,发送请求等方法。

<?php
/**
 * 契约锁SDK
 * https://open.qiyuesuo.com/document?id=2279605053767548928
 */

namespace App\Services\QiYueSuo;

use App\Exceptions\QiYueSuoException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Storage;

class SDKClient
{
   //使用trait 引用template业务相关接口
    use Template,Helps;

    public $config;

    public function __construct()
    {
        $this->config = config('services.qiyuesuo');
    }

    /**
     * 发送请求
     * 使用laravel封装的http客户端,DOC:https://learnku.com/docs/laravel/7.x/http-client/7487#introduction
     *
     * @param string $method 请求方法 'get', 'post', 'put', 'patch', 'delete'
     * @param string $subUrl 接口url,不包含域名
     * @param array $params 请求参数
     * @param string $content_type 请求类型 'asJson', 'asForm', 'asMultipart'
     * @param array $files 文件上传 $files['name']上传字段名称 $files['contents']文件内容,$files['filename']文件名,可选
     * @param bool $debug 是否开启debug日志,true是 false否
     * @return mixed
     */
    public function sendRequest(string $method, string $subUrl, array $params, string $content_type = '', array $files = [], bool $debug = false)
    {
        if (! in_array($method, ['get', 'post', 'put', 'patch', 'delete'])) {
            throw new QiYueSuoException('Error Request Method');
        }

        if ($content_type && ! in_array($content_type, ['asJson', 'asForm', 'asMultipart'])) {
            throw new QiYueSuoException('Error Method');
        }

        $url = $this->config['api_host'].$subUrl;
        $headers = $this->playloadHeader();

        $http = Http::withHeaders($headers)->timeout($this->config['timeout']);

        //上传文件附件
        if ($files) {
            $http = $http->attach($files['name'], $files['contents'], $files['filename'] ?? null);
        }

        //请求类型
        if ($content_type) {
            $http = $http->$content_type();
        }

        //请求方法
        $response = $http->$method($url, $params);
        $headers = $response->headers();

        //记录debug日志
        if ($debug) {
            Log::channel('qiyueuso')
            ->info($url.'---->',
             [
                 'params' => $params,
                 'response_json'=>$response->json(),
                 'response_status' => $response->status(),
             ]);
        }

        //响应json
        if (isset($headers['Content-Type'][0]) && strpos($headers['Content-Type'][0], 'application/json') !== false) {
            $result_arr = $response->json();
        } else {
            abort(422, '契约锁,接口响应数据类型不是json');
        }

        //dd(json_encode($params), $result_arr);
        if (! $response->successful()) {
            //转换成422http异常
            abort(422, $result_arr['message']);
        } else {
            //状态码不是成功的响应错误信息
            if (isset($result_arr['code']) && $result_arr['code'] != 0) {
                abort(422, $result_arr['message']);
            } else {
                return $result_arr['result'] ?? [];
            }
        }
    }

    /**
     * 下载文件
     *
     * @param string $method 请求方法 get
     * @param string $subUrl 接口路径
     * @param array $params 请求参数
     * @param mixed $save_path 保存路径
     * @param string $disk
     * @return void
     */
    public function downloadFile(string $subUrl, array $params, string $save_path, string $disk = 'public')
    {
        $url = $this->config['api_host'].$subUrl;
        $headers = $this->playloadHeader();
        $response = Http::withHeaders($headers)
            ->timeout($this->config['timeout'])
            ->send(
                'get',
                $url,
                [
                    'query' => $params,
                    'save_to' => $save_path,
                ]
            );

        if (isset($headers['Content-Type'][0]) && strpos($headers['Content-Type'][0], 'application/json') !== false) {
            $result_arr = $response->json();

            if (isset($result_arr['code']) && $result_arr['code'] != 0) {
                //响应错误信息
                abort(422, $result_arr['message']);
            }
        }

        if ($response->successful()) {
            if ($save_path) {
                Storage::disk($disk)->put($save_path, $response->body());

                return $save_path;
            } else {
                return $response->body();
            }
        }

        return false;
    }

    protected function playloadHeader()
    {
        $headers['x-qys-open-timestamp'] = $this->timestamp();
        $headers['x-qys-open-accesstoken'] = $this->config['app_token'];
        $headers['x-qys-open-nonce'] = $this->nonce();
        $headers['x-qys-open-signature'] = $this->signature(
            $this->config['app_token'],
            $this->config['app_secret'],
            $headers['x-qys-open-timestamp'],
            $headers['x-qys-open-nonce']
        );

        return $headers;
    }

    private function timestamp()
    {
        $timestamp = time() * 1000;

        return $timestamp;
    }

    private function nonce()
    {
        return (string) Str::uuid();
    }

    private function signature($app_token, $app_secret, $timestamp, $nonce)
    {
        return md5($app_token.$app_secret.$timestamp.$nonce);
    }
}

使用trait解耦业务方法,模板管理相关业务,可以在方法中进行相关业务处理

<?php
/**
 * 模板管理
 */

namespace App\Services\QiYueSuo;

trait Template
{
    /**
     * 创建word模板
     * DOC:https://open.qiyuesuo.com/document?id=2784729992477544720
     *
     * @param array $params 请求参数
     * @param array $files 合同文件
     * @return array
     */
    public function v3TemplateCreatebyword(array $params, array $files)
    {
        return $this->sendRequest('post', '/v3/template/createbyword', $params, 'asMultipart', $files);
    }

    /**
     * 模板列表
     * DOC:https://open.qiyuesuo.com/document?id=2657160660600767485
     *
     * @param int $page 	  查询起始位置,默认为0 第几页
     * @param int $per_page   查询列表大小,默认1000
     * @param string $tenant_name  	查询条件:子公司名称,若传递了则查询子公司下的模板
     * @param string $modify_time_start  数据变更(创建、变更)时间的起始时间,格式为yyyy-MM-dd HH:mm:ss,默认无限制
     * @param string $modify_time_end  数据变更(创建、更新)时间的结束时间,格式为yyyy-MM-dd HH:mm:ss,默认无限制
     * @return array
     */
    public function v2TemplateList(int $page = 1, int $per_page = 15, string $tenant_name = '', string $modify_time_start = '', string $modify_time_end = '')
    {
        return $this->sendRequest('get', '/v2/template/list', [
            'selectOffset' => $page,
            'selectLimit' => $per_page,
            'tenantName' => $tenant_name,
            'modify_time_start' => $modify_time_start,
            'modify_time_end' => $modify_time_end,
        ]);
    }

    /**
     * 模板详情接口
     * DOC:https://open.qiyuesuo.com/document?id=2657160735708155950
     *
     * @param int $templateId 模板id
     *
     * @return array
     */
    public function v2TemplateDetail(int $templateId)
    {
        return $this->sendRequest('get', '/v2/template/detail', ['templateId'=> $templateId]);
    }

    /**
     * 编辑模板
     * DOC:https://open.qiyuesuo.com/document?id=2784730224355451710
     *
     * @param int $templateId 模板id
     * @param array $params
     *
     * @return array
     */
    public function v2TemplateEdit(int $templateId, array $params)
    {
        return $this->sendRequest('post', '/v2/template/edit',
            array_merge(['templateId' => $templateId], $params),
        );
    }

    /**
     * 下载模板
     * DOC:https://open.qiyuesuo.com/document?id=2784730224355451710
     *
     * @param int $templateId 模板id
     *
     * @return array
     */
    public function v2TemplateDownload(int $templateId)
    {
        return $this->downloadFile('/v2/template/download', ['templateId' => $templateId], false);
    }

    /**
     * 删除模板
     * DOC:https://open.qiyuesuo.com/document?id=2786490624780538307
     *
     * @param int $templateId 模板id
     *
     * @return array
     */
    public function v2TemplateRemove(int $templateId)
    {
        return $this->sendRequest('get', '/v2/template/remove', ['templateId' => $templateId]);
    }
}

以单一模型为主逻辑或功能封装到model中模型中封装方法过多时,可以用trait进行拆分

商品扣库存封装到商品模型中,

添加实体有很多关联表必须依赖主表,将添加主表数据和关联数据的方法封装到主表对应的模型中,举例:添加商品时,要添加商品规格,商品属性等等,将添加完整数据的方法,封装到商品模型中

更新商品的销量统计,需要查询其他关联表,在商品模型封装一个刷新统计的方法

Laravel中使用Repository的相关文章和总结

参考文章

在 Laravel 5.8 中正确地应用 Repository 设计模式

Laravel repository

Laravel 设计模式:Repository + Service 实战

关于 Repository 的设计模式

Laravel 中的存储库模式(Repository)

laravel5-repository 怎么拼接搜索条件

推荐:好用的 Laravel Repository 包

关于项目中 Repository 层的思考

为什么要在 Laravel 中使用存储库模式(Repository)?

在 Laravel 5 中使用仓库模式

使用该模式的项目

大商创电商

应用场景

抽象通用的查询,很多页面或接口,要展示相同内容,每次都要select筛选一堆字段和with一堆关联,还有加相同的查询条件,在需要的控制器中直接调用repository就能复用数据

todo,写个试用demo

数据迁移使用规范

目的,为了使命名更具有语义化和可读性,方便维护

以laravel框架为例,按实际操作行为划分

  • 添加 删除 修改(主要参考迁移功能,提供的api方法) 对应名称create drop modify
  • 2种以上的操作行为,都算修改
  • 给单一操作增加后缀,字段 column,索引 index,视图view, 存储过程 ,函数
  • 命名重复情况增加版本号 v1.0.0 …

操作表

表字段,标注注释,表索引

创建表

create_xxxx_table.php

删除表

drop_xxxx_table.php

修改表

包含添加删除字段或索引或其他 ,2种以上行为

modify_xxxx_table.php

操作字段

字段名称,字段数据类型,字段注释

添加字段

create_xxxx_table_column.php

删除字段

drop_xxxx_table_column.php

修改字段

包含添加和删除和修改字段,2种以上行为

modify_xxxx_table_column.php

操作索引

操作普通索引,全文索引,空间索引等;

索引优化是业务开发中,修改频率很高的行为,所以需要单独列出来,有很多场景需要,单独的变更索引,而不修改字段

添加索引

create_xxxx_table_index.php

删除索引

drop_xxxx_table_index.php

修改索引

包含添加 删除 修改 和自定义 2种以上行为时

modify_xxxx_table_index.php

单一操作行为的规则,参考上文以此类推。

多次重复行为时增加语义化版本后缀

需要改进

比如第一次业务变更 增加了字段 create_users_table_column.php

第二次业务变更,又要对users表增加字段,则命名为 create_users_table_column_v1.0.0.php

laravel8.x以上 可以使用压缩迁移

// 转储当前数据库架构并删除所有现有迁移。。。
php artisan schema:dump --prune

laravel广播使用笔记,基于laravel-websocket扩展包

阅读文档

Laravel 广播系统文档

laravel-websockets文档

安装需要的用的依赖

//websocket服务端
composer require beyondcode/laravel-websockets

//发布数据迁移文件
php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="migrations"

//发布配置文件
php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="config"

//安装队列管理面板 或者 使用php artisan queue:work也可以
composer require laravel/horizon
php artisan horizon:install

//执行迁移
php artisan migrate

//前端依赖
npm install laravel-echo pusher-js

修改配置

.env 配置

//广播驱动设置为pusher
BROADCAST_DRIVER=pusher

//队列驱动改为redis
QUEUE_CONNECTION=redis

//使用laravel-websockets做后端,pusher配置随便填写
PUSHER_APP_ID=yangliuan
PUSHER_APP_KEY=yangliuan
PUSHER_APP_SECRET=yangliuan
//注意一定要注释这行,否则laravel-websockets不生效
#PUSHER_APP_CLUSTER=mt1

//websocket端口号
LARAVEL_WEBSOCKETS_PORT=6001

config/app.php 配置

取消如下Provider的注释

/*
 * Application Service Providers...
 */
...
// App\Providers\BroadcastServiceProvider::class,

config/broadcasting.php 配置

按如下修改

'connections' => [

        'pusher' => [
            'driver' => 'pusher',
            'key' => env('PUSHER_APP_KEY'),
            'secret' => env('PUSHER_APP_SECRET'),
            'app_id' => env('PUSHER_APP_ID'),
            'options' => [
                'cluster' => env('PUSHER_APP_CLUSTER'),
                 //本地开发关闭安全连接
                'useTLS' => false,
                //本地host配置
                'host' => '127.0.0.1',
                //端口
                'port' => env('LARAVEL_WEBSOCKETS_PORT', 6001),
                //协议
                'scheme' => 'http',
            ],
        ],

config/websockets.php

'apps' => [
        [
            'id' => env('PUSHER_APP_ID'),
            'name' => env('APP_NAME'),
            'key' => env('PUSHER_APP_KEY'),
            'secret' => env('PUSHER_APP_SECRET'),
            'path' => env('PUSHER_APP_PATH'),
            'capacity' => null,
            //是否开启客户端发送消息
            'enable_client_messages' => false,
            //是否开启统计
            'enable_statistics' => true,
        ],
    ],

测试案例场景

使用laravel excel 队列导出文件后,自动提示并下载,使用公共频道 demo地址

后端代码

注册频道

route/channels.php 文件,可以自定义频道名称

Broadcast::channel('excel', function () {
    return true;
});

创建ExcelExportCompletedEvent事件

php artisan make:event ExcelExportCompletedEvent

<?php
use Illuminate\Broadcasting\Channel;//公共频道
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel; //存在频道可以加入和离开,需要授权
use Illuminate\Broadcasting\PrivateChannel; //私有频道,需要授权
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

//注意事件一定要实现ShouldBroadcast接口
class ExcelExportCompletedEvent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    //public属性会自动转换为广播数据
    //自定义广播数据  文档 https://learnku.com/docs/laravel/9.x/broadcasting/12223#b2f5d1
   
    public $excel_path;

    public $disk;

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct(string $excel_path, string $disk)
    {    
        //文件路径
        $this->excel_path = $excel_path;
        //磁盘
        $this->disk = $disk;
    }

    /**
     * 监听频道
    *  监听自己的定义频道名称
     * 
     *
     * @return \Illuminate\Broadcasting\Channel|array
     */
    public function broadcastOn()
    {
        return new Channel('excel');
    }
}

创建导出文件 ExcelDemoPictureQueryExport 细节略过详情看 laravel excel文档

创建通知队列,用于触发时间

php artisan make:job ExcelNotifyJob

class ExcelNotifyJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $attach;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct(array $attach)
    {
        $this->attach = $attach;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        //发送广播通知
        ExcelExportCompletedEvent::dispatch($this->attach['file_name'], $this->attach['disk']);
    }
}

控制器代码

/**
 * 字段导出图片 使用队列 并接受广播通知
 *
 * @param Request $request
 * @return void
 */
public function queueImages(Request $request)
{
  $file_name = 'excel-demo-'.date('YmdHis').\mt_rand(100000, 999999).'.xlsx';
  $disk = 'public';
  Excel::queue(new ExcelDemoPictureQueryExport(), $file_name, $disk)
  //导出成功后,使用任务链调用excel通知job
  //DOC:https://learnku.com/docs/laravel/8.5/queues/10395#dispatching-jobs
  //DOC:https://docs.laravel-excel.com/3.1/exports/queued.html#appending-jobs
  ->chain([
      new ExcelNotifyJob(compact('file_name', 'disk'))
  ]);

  return response()->json();
}

//下载文件方法
public function store(Request $request)
{
    $request->validate([
       'storage_path' => 'bail|required|string',
       'disk' => 'bail|nullable|string',
    ]);
    $realPath = Storage::disk($request->input('disk') ?? 'public')
       ->path($request->input('storage_path'));

    return response()->download($realPath)->deleteFileAfterSend();
}

前端代码

使用 Laravel Jetstream Inertia.js  构建

封装laravel-echo.js

import Echo from 'laravel-echo';

window.pusher = require('pusher-js');

const echo = new Echo({
    broadcaster: 'pusher',
    key: 'yangliuan',
    wsHost: window.location.hostname,
    wsPort: 6001,
    forceTLS: false,
    enabledTransports: ['ws', 'wss'],
})

export { echo }

Excel.vue

<template>
 <app-layout title="Dashboard">
  ...
  <a href="#" @click="queueImagesClick">
  <div class="mt-3 flex items-center text-sm font-semibold text-indigo-700">
  <div>队列导出图片并用广播接受通知</div>
  <div class="ml-1 text-indigo-500">
  <svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
 </div>
 </div>
 </a>
 </app-layout>
</template>

<script>
 ...
 //导入laravel echo 
 import { echo } from '@/laravel-echo'

 export default defineComponent({
    components: {
       AppLayout,
       JetApplicationLogo,
       Link
    },
    created() {
      //监听公共频道excel,响应下载excel文件
       echo.channel('excel')
           .listen('ExcelExportCompletedEvent', (e) => {
               alert('ExcelExportCompletedEvent')
               this.downloadExcel(e.excel_path,e.disk)
               console.log(e);
           })
    },
    methods: {
       //下载方法
       downloadExcel(excel_path,disk) {
          const download_url =  '/api/files/download?storage_path=' + excel_path + '&disk=' + disk
          window.open(download_url)
       },
      //点击时间调用队列导航图片接口
       queueImagesClick() {
          axios.post('/api/excel/export/queue-images').then( response => {
             console.log(response)
          })
       }
  }
})
</script>
//开启websocket服务
php artisan websockets:serve

//开启horizon队列
php artisan horizon

演示

授权频道案例

nginx代理websocket

laravel websocket文档

websocket单独使用一个域名不和http接口共享域名,配置如下

server {
    listen 80;
    server_name websocket.exhibition.demo;

    location / {
        proxy_pass             http://127.0.0.1:6001;
        proxy_read_timeout     60;
        proxy_connect_timeout  60;
        proxy_redirect         off;

        # Allow the use of websockets
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

第二种方式websocket和http接口公用一个域名

map $http_upgrade $type {
  default "";
  websocket "ws";
}

server {
    listen 80;
    server_name api2.exhibition.demo;
    root /home/yangliuan/Code/Php/business-logic/laravel-exhibition/public;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-XSS-Protection "1; mode=block";
    add_header X-Content-Type-Options "nosniff";

    index index.php;
    charset utf-8;

    location = /favicon.ico {
        access_log off; log_not_found off;
    }

    location = /robots.txt {
        access_log off; log_not_found off;
    }
   
   //http
    location /  {
        try_files $uri $uri/ /index.php?$query_string;
    }
 
   //websocket
    location @ws  {
        proxy_pass             http://127.0.0.1:6001;
        proxy_set_header Host  $host;
        proxy_read_timeout     60;
        proxy_connect_timeout  60;
        proxy_redirect         off;

        # Allow the use of websockets
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    location ~ [^/]\.php(/|$) {
        fastcgi_pass unix:/dev/shm/php-cgi.sock;
        fastcgi_index index.php;
        include fastcgi.conf;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

参考

websocket实现手机扫码登陆

PHP开发生态 常用笔记

软件包生态介绍

其实PHP有三套软件包生态:PEARPECLComposer

当然PEAR可以忽略不计:作为软件包,其托管的代码基本移植成了Composer包;

作为拓展安装工具,pecl是pear的别名,直接用pecl就完事。

所以PHP软件生态主要是两套:PECL和Composer。

PECL 扩展库 官方手册

开发辅助工具

Composer使用经验总结

PHP辅助工具扩展 使用笔记

phpstan 静态检测工具

PHP-CS-Fixer 编码格式化修复工具

laravel-ide-helper laravel IDE工具

PHP

m9rco/algorithm-php php实现算法

Guzzle Http客户端

Intervention/image 最好用图片工具

所有很棒的 PHP 功能的直接总结

guanguans/notify 项目出现异常时,发送通知到钉钉等…

ezyang/htmlpurifier XSS 过滤

php-casbin 权限控制

BaconQrCode 二维码工具

vinkla/hashids 唯一hashid生成工具

ratchetphp/Ratchet websocket库 php5.4起,适合旧版本

文件压缩

https://github.com/maennchen/ZipStream-PHP zip压缩

https://github.com/alchemy-fr/Zippy 支持.zip,.tar,.tar.gz,tar.bz2,

https://github.com/wapmorgan/UnifiedArchive 支持zip,rar,7z,tar

框架选择总结

业务快速开发用laravel,性能选webman,想用协程选hyperf,异步用ReactPHP

Laravel

php-fpm模式

PHP生态最好的web开发框架,社区活跃,简单实用,具备敏捷开发特质,扩展包和解决方案多,本身集成功能特别多。start数最多。

缺点性能比较差

适合业务复杂或对性能要求不高的场景,提升性能需要单独处理。

什么是laravel

LX1 Laravel / PHP 扩展包视频教程

框架方法API文档 https://laravel.com/api/8.x/

laravel编码最佳实践

laravel编码技巧

laravel-boilerplate laravel样板项目

laravel-devinit 项目初始化工具

stechstudio/laravel-zipstream ZipStream-PHP的laravel封装

yangliuan/generator crud代码生成器

laravel-permission RBAC权限扩展包

php-casbin/laravel-authz 权限控制

laravel-enum enum类型支持

mews/purifier XSS 过滤

overtrue/laravel-lang 语言包支持

spatie/laravel-translatable 数据库多语言包

Astrotomic/laravel-translatable 数据库多语言包

jenssegers laravel-mongodb mongodb ORM 支持

barryvdh/laravel-snappy html转pdf 使用 wkhtmltopdf

barryvdh/laravel-dompdf html转pdf 使用 dompdf

simple-qrcode 二维码生成工具 在线文档

Eloquent Filter – 模型关联查询过滤

Maatwebsite/Laravel-Excel excel 导入导出

yajra/laravel-oci8 Oracle DB driver for Laravel 4|5|6|7|8 via OCI8

protonemedia/laravel-ffmpeg ffmpeg

Laravel-Phone– 全球手机号和电话验证

laravel-search 搜索扩展包 驱动支持  ElasticsearchAlgolia, and ZendSearch 

laravel-geoip 根据访问者的 IP 地址确定网站访问者的位置

laravel-queue-rabbitmq laravel rabbitmq驱动

 l5-repository Laravel 5 – Repositories to abstract the database layer

超大文件 xml/JSON/CSV 读取解析的方案 参考

阿里云文件存储 laravel-filesystem-oss

七牛云文件存储 flysystem-qiniu

Xethron/migrations-generator 数据库转换迁移文件 <= 5.5

kitloong/laravel-migrations-generator 数据库转换迁移文件 >=5.5

Laravel集成的Faker数据模拟 参考文章 Github https://fakerphp.github.io/

laravel-exception-notify 多种通道的 laravel 异常通知(钉钉群机器人、飞书群机器人、Server 酱、企业微信群机器人、息知)

laravel-modules laravel-plugin 插件机制

codestudiohq / laravel-totem laravel 定时任务管理仪表盘

性能加速组件

laravel-s laravel-swoole Laravel Octane

symfony

Symfony 是一个用于 Web 和控制台应用程序的 PHP框架和一组可重用的 PHP 组件。企业级开发框架。引领了很多php业界标准,很多框架的底层组件都使用symfony。

累计下载上亿次

ThinkPHP

基础入门框架,简单实用尤其是3.2.3版本,生态

php-casbin/think-authz 权限控制

好用的CMS框架 https://www.thinkcmf.com/

Yii

没用过不做介绍

Swoole

Swoole 是一个使用 C++ 语言编写的基于异步事件驱动和协程的并行网络通信引擎(PHP底层扩展),为 PHP 提供协程高性能网络编程支持。提供了多种通信协议的网络服务器和客户端模块,可以方便快速的实现 TCP/UDP服务高性能WebWebSocket服务物联网实时通讯游戏微服务等,使 PHP 不再局限于传统的 Web 领域。

CLI模式常驻内存运行

API发展由swoole公司维护和决定. 社区产生分裂 国内原开发者为主swoole 国外开发者为主 openswoole

官方文档 社区

学习成本较高,从业务开发和就业角度(岗位少)来说,不如学golang。对于掌握了底层知识的人来说,学习起来很快。岗位较少。

缺点对传统php-fpm模式下的原有生态组件有不兼容情况,需要重新造轮子

通过开启一键协程功能,对php阻塞函数进行Hook,来达到兼容目的。

使用问题

学习Swoole要掌握的知识

学习Swoole 其中编程须知必须要看

退出终止和阻塞函数不能使用,静态变量非必要不能使用,会增加内存溢出风险

用户案例 大厂用的多

hyperf

简单对比测试了几个基于 swoole 的框架

Workerman (工人)

高性能php容器,网络引擎框架,性能非常高,php框架中 benchmark (基准) 排行第一 。生态相对冷门,对底层基础有要求,很多东西需要自己构建,没有开箱即用的现成轮子,需要其他第三方组件

CLI模式常驻内存运行

workerman 基础容器框架

gatewayworker 基于Workerman开发的一个项目框架 用于快速开发TCP长连接应用,例如app推送服务端、即时IM服务端、游戏服务端、物联网、智能家居等等

webman 基于workerman开发的高性能HTTP服务框架。webman用于替代传统的php-fpm架构,提供超高性能可扩展的HTTP服务。你可以用webman开发网站,也可以开发HTTP接口或者微服务。适合中小型对性能要求高项目

以最小内核提供最大的扩展性与最强的性能。

webman仅提供最核心的功能(路由、中间件、session、自定义进程接口)。其余功能全部复用composer生态,这意味着你可以在webman里使用最熟悉的功能组件,例如在数据库方面开发者可以选择使用Laravel的illuminate/database,也可以是ThinkPHP的ThinkORM,还可以是其它组件如Medoo。在webman里集成他们是非常容易的事情。

workerman原理

ReactPHP

ReactPHP是PHP中用于事件驱动编程的底层库。它的核心是一个事件循环,在此基础上它提供了底层实用程序,例如:流抽象、异步DNS解析器、网络客户端/服务器、HTTP客户端/服务器以及进程间通信。第三方库可以使用这些组件创建异步网络客户端/服务器等。

官方文档

Yar

Yar(yet another RPC framework, 教主问我为啥都是Ya打头, 呵呵, 因为这样名字好起)是我在3个多月前, 为了解决一个实际的问题, 而开发的一个PHP扩展的, RPC框架, 和现有的RPC框架(xml-rpc, soap)不同, 这是一个轻量级的框架, 支持多种打包协议(msgpack, json, php), 并且最重要的一个特点是, 它是可并行化的..

github地址 鸟哥博客 php官方文档

Yaf

PHP framework  written in c and built as a PHP extension.

github地址 php官方文档

Yaf and Phalcon, which is faster?

Phalcon

Phalcon is an open source web framework delivered as a C extension for the PHP language providing high performance and lower resource consumption.

github地址 官网 官方文档

参考

chiraggude/awesome-laravel

吐槽一下PHP的生态

Mysql FAQ

隐式类型转换

例如 pid 字段类型是 int,我们 where pid = “1”,这样就会触发隐式类型转换。字段为数字类型,字符串数字转换成数字时,并不会导致索引失效如下图

当where查询操作符**左边为字符类型**时发生了隐式转换,那么会导致索引失效,造成全表扫描效率极低。

name为字符串类型,传入了数字类型001 转换后索引失效

导致查询结果不符合预期

类型隐式转换规则

当操作符与不同类型的操作数一起使用时,会发生类型转换以使操作数兼容,某些转换是隐式发生的

如果不与数字比较,则将十六进制值视为二进制字符串


如果参数之一是a TIMESTAMP或 DATETIME column,而另一个参数是常量,则在执行比较之前,该常量将转换为时间戳。这样做是为了使ODBC更友好。对于的参数,此操作未完成 IN()。为了安全起见,在进行比较时请始终使用完整的日期时间,日期或时间字符串。例如,要在BETWEEN与日期或时间值一起使用时获得最佳结果 ,请使用CAST()将值显式转换为所需的数据类型。


一个或多个表中的单行子查询不被视为常量。例如,如果子查询返回要与DATETIME 值进行比较的整数,则比较将作为两个整数进行。整数不转换为时间值。要将操作数作为DATETIME值进行比较 ,请使用 CAST()将子查询值显式转换为DATETIME


如果参数之一是十进制值,则比较取决于另一个参数。如果另一个参数是十进制或整数值,则将参数作为十进制值进行比较;如果另一个参数是浮点值,则将参数作为浮点值进行比较。


在所有其他情况下,将参数作为浮点数(实数)进行比较。例如,将字符串和数字操作数进行比较,将其作为浮点数的比较。

条件的由字符转为浮点时候

不以数字开头的字符串都将转换为0。如’abc’、’a123bc’、’abc123’都会转化为0;
以数字开头的字符串转换时会进行截取,从第一个字符截取到第一个非数字内容为止。比如’123abc’会转换为123,’012abc’会转换为012也就是12,’5.3a66b78c’会转换为5.3,其他同理。

避免规则,通过程序处理,尽量使类型与数据库字段保持一致

参考

mysql-8.隐式转换导致索引失效或查出不符合where条件结果

类型转换官方文档