数据库迁移文件Phinx
如何刷新所有回滚
不能直接刷新所有回滚,创建完第一个迁移之后,使用断点命令打上断点,方便后边刷新所有回滚,这样只会滚两次就可以了。
phinx breakpoint -e development -t 20120103083322(第一个迁移的时间版本)
数据模拟的库
改用 https://fakerphp.github.io/#installation,原先的过时了。
不能直接刷新所有回滚,创建完第一个迁移之后,使用断点命令打上断点,方便后边刷新所有回滚,这样只会滚两次就可以了。
phinx breakpoint -e development -t 20120103083322(第一个迁移的时间版本)
改用 https://fakerphp.github.io/#installation,原先的过时了。
phpinfo(); 页面调用函数
php -m 查看已安装模块
php –ini 查看php.ini的位置
php –ri gd 查看模块信息
查看是否支持webp
php --ri gd | grep WebP
举例,查看imagick是否安装webp支持
php --ri imagick | grep WEBP
wget http://pear.php.net/go-pear.phar
#指定php目录安装pear
sudo /usr/local/php/bin/php go-pear.phar
安装之后可以使用pear 和pecl命令安装扩展
PEAR命令 安装扩展文档
pear install extension_name
pear upgrade extension_name
pear install --onlyreqdeps html_page2
参数
-onlyreqdeps (install required dependencies (依赖) only) 安装需要依赖
–alldeps (install all dependencies (依赖) ) 安装全部依赖
phpize源码编译安装 (共享/动态 扩展) 以redis为例
上https://pecl.php.net/下载好扩展的源码包 redis-6.2.7.tar.gz
tar -zxvf redis-6.2.7.tar.gz
cd redis-6.2.7
/usr/local/php/bin/phpize
./configure --with-php-config=/usr/local/php/bin/php-config
make && make install
#写入配置
echo 'extension=redis.so' > /usr/local/php/etc/php.d/redis.ini
参考文章
接手一个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,
官方文档描述比较全面,但是不够简洁以及没有操作流程,所以记录一下备忘。
本文是简单实用的快捷配置方式,使用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 进行开发时,请牢记以下三项法则:
要掌握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);
}
}
单一的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]);
}
}
商品扣库存,封装到商品模型中,
添加实体有很多关联表必须依赖主表,将添加主表数据和关联数据的方法封装到主表对应的模型中,举例:添加商品时,要添加商品规格,商品属性等等,将添加完整数据的方法,封装到商品模型中
更新商品的销量统计,需要查询其他关联表,在商品模型封装一个刷新统计的方法
参考文章
在 Laravel 5.8 中正确地应用 Repository 设计模式
Laravel 设计模式:Repository + Service 实战
为什么要在 Laravel 中使用存储库模式(Repository)?
使用该模式的项目
大商创电商
应用场景
抽象通用的查询,很多页面或接口,要展示相同内容,每次都要select筛选一堆字段和with一堆关联,还有加相同的查询条件,在需要的控制器中直接调用repository就能复用数据
$data = $model->select($select)
->paginate($request->get('per_page'));
foreach ($data->items() as $key => & $value) {
//操作value
...
}
目的,为了使命名更具有语义化和可读性,方便维护
以laravel框架为例,按实际操作行为划分
表字段,标注注释,表索引
创建表
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
// 转储当前数据库架构并删除所有现有迁移。。。
php artisan schema:dump --prune
调用接口
主要功能,很多sdk都是使用该类库开发
写爬虫抓取页面
项目中应用案例
java的古籍PC网站,该项目无人维护,无法提供书籍数据的接口。分析页面结构和接口使用guzzle库爬取书籍数据,完成数据对接。
在所用请求中共享cookie功能 文档
//创建客户端
$this->client = new Client([
'base_uri' => $this->config['base_uri'],
'timeout' => 20.0,
'cookies' => true, //共享cookie会话
);
//登录
protected function login()
{
$response = $this->client->request('POST', 'XXX', [
'headers' => [
'Accept' => 'application/json'
],
'form_params' => [
'loginName' => $this->config['login_name'],
'loginPassword' => $this->config['login_password']
]
]);
$json = json_decode($response->getBody(), true);
if (isset($json['operateMsg']) && $json['operateMsg'] !== '登录成功!') {
throw new GujiException('原古籍系统账号故障');
}
}
//请求接口数据
protected function request(string $pathUrl, array $param)
{
$this->login(); //首先登录获取Cookies
$response = $this->client->request('POST', $pathUrl, [
'headers' => [
'Accept' => 'application/json'
],
'form_params' => $param
]);
$contents = $response->getBody()->getContents();
$json = json_decode($contents, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $json;
} elseif (json_last_error() == 10) {
//解决json_decode错误Single unpaired UTF-16 surrogate in unicode escape
$contents = \preg_replace('/(?<!\\\)\\\u[a-f0-9]{4}/iu', '', $contents);
$json = \json_decode($contents, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$json = $this->customJsonDecode($contents);
}
return $json;
}
{
throw new GujiException("请求古籍系统接口失败");
}
}
//抓取页面数据
protected function capture(string $pathUrl, array $param = [])
{
$this->login(); //首先登录获取Cookies
$response = $this->client->request('GET', $pathUrl, $param);
if ($response->getStatusCode() == 200) {
//获取页面内容
return $response->getBody()->getContents();
} else {
throw new GujiException("古籍系统故障");
}
}
跟随重定向
https://docs.guzzlephp.org/en/stable/faq.html#how-can-i-track-redirected-requests
https://docs.guzzlephp.org/en/stable/request-options.html#allow-redirects
调用非知名第三方支付系统,前后端分离架构,前端重定向到接口,接口调用第三方支付接口,成功后跟随响应到成功页面
use Illuminate\Support\Facades\Http;
use Psr\Http\Message\UriInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
//Http::withOptions laravel对guzzle的封装,详情看文档
$response = Http::withOptions([
'allow_redirects' => [
'max' => 1,
'on_redirect' => function (
RequestInterface $request,
ResponseInterface $response,
UriInterface $uri
) use ($data) {
//自动跟随重定向响应
header('Location:' . $uri);
},
]
])->asForm()->post($this->config['base_uri'] . '/multipay/h5.do', $data);