概述
经过一段时间对 Swoole & Swoft 的学习和实践,发现 Swoft 是一个优秀的开发框架,其设计思想借鉴了 Laravel、Yii 等传统 WEB 框架,但很多特性和他们有着较大的区别。Swoft 借助 Swoole 的驱动,大大提高了 PHP 应用的性能,也让开发过程变动轻松、愉快。本文是对我在使用 Swoft 构建应用过程遇到的一些问题、开发技巧、项目优化过程的进行总结,这些技巧谈不上非常的高大上和最佳实践,但是为你提供了一种思路,在 Swoft 丰富多彩的百宝箱面前,不至于手足无措。 本文侧重实战分享,代码占了较大篇幅,适用于对 Swoole & Swoft 有一定了解的读者。 没有了解过的,还是建议看官方文档或小白教程。
Swoole
是 "面向生产环境的 PHP 异步网络通信引擎",是用 C 实现的高性能 PHP 扩展,支持 TCP、UDP、HTTP、WebSocket 等多种协议服务端、客户端,支持多进程、协程、多线程、进程池;支持毫秒定时器、内存表等便捷工具。
参考文档:
Swoole 中文文档
Swoole 编程须知
Swoft
首个基于 Swoole 原生协程的新时代 PHP 高性能协程全栈框架,内置协程网络服务器及常用的协程客户端,常驻内存,不依赖传统的 PHP-FPM,全异步非阻塞 IO 实现,以类似于同步客户端的写法实现异步客户端的使用,没有复杂的异步回调,没有繁琐的 yield, 有类似 Go 语言的协程、灵活的注解、强大的全局依赖注入容器、完善的服务治理、灵活强大的 AOP、标准的 PSR 规范实现等等,可以用于构建高性能的Web系统、API、中间件、基础服务等等。
主要特性
协程框架
连接池
切面编程
RPC
数据库
微服务
参考文档:
Swoft 中文文档
Swoft 主仓库
Swoft issues
学习资料:
优质资料
代码组织
良好的目录组织,可以使得代码结构清晰,这里基本参考了官方推荐的目录结构。
app/Annotation 自定义注解
app/Console 自定义命令
app/Crontab 自定义定时任务
app/Exception 自定义异常 & 异常处理
app/Helper 助手类 & 全局助手函数
app/Http 控制器 & 中间件 & 转换类 & 验证器等和 HTTP 相关的类
app/Http/Controller/Backend 管理端 API(Frontend 用户端 API)
app/Http/Middleware 全局 & 路由 & 控制中间件
app/Http/Validator 自定义验证器
app/Http/Validator/Rule 自定义验证规则
app/Listener 事件监听器
app/Model 模型 & 业务逻辑相关类
app/Model/Entity 模型
app/Model/Logic 业务逻辑代理类
app/Model/Service 具体业务逻辑处理类
app/Model/Task 异步任务 & 协Swoft HTTP-Server.note程任务组件
app/Model/bean.php 应用服务类配置信息
app/Application.php 应用全局配置 & 拦截器等
app/Autoload.php 组件自动扫描加载引导文件
app
├── Annotation
│ ├── Mapping
│ └── Parser
├── Application.php
├── AutoLoader.php
├── Console
│ └── Command
├── Crontab
│ └── CronTask.php
├── Exception
│ ├── ApiException.php
│ ├── Handler
│ └── HttpException.php
├── Helper
├── Http
│ ├── Controller
│ │ ├── Backend
│ │ ├── Frontend
│ │ └── HomeController.php
│ ├── Middleware
│ ├── Transformer
│ └── Validator
├── Listener
│ ├── ConfirmUserPurposeListener.php
│ ├── EventTag.php
│ ├── ForgetCacheListener.php
│ ├── TaskFinishListener.php
│ └── WorkerStartListener.php
├── Model
│ ├── Dao
│ ├── Entity
│ ├── Logic
│ └── Service
├── Service
│ └── MailService.php
├── Task
│ ├── CaptchaTask.php
│ ├── ExportTask.php
│ └── SendMailTask.php
└── bean.php
说明:
控制接收请求后,将处理权交给 Logic(Logic 由 @inject 在框架启动时实例化并注入)
/**
* Class AdminUserController
*
* @since 2.0
*
* @Controller(prefix="/back")
*/
class AdminUserController
{
/**
* @Inject()
* @var AdminUserLogic
*/
private $logic;
/**
* @RequestMapping(route="login", method={"POST"})
* @Validate(AdminUserValidator::class, fields={"username", "password"})
* @return Response
* @throws \Exception
*/
public function login(): Response
{
return $this->logic->login();
}
}
Logic 并不直接处理业务,而是从 Bean 容器中取出具体负责的类。
/**
* Class AdminUserLogic
* @Bean()
* @package App\Model\Logic
*/
class AdminUserLogic
{
/**
* @return Response
* @throws Exception
*/
public function login(): Response
{
return bean(AdminUserLoginService::class)->handle();
}
}
Service 执行具体的业务逻辑
/**
* Class AdminUserLoginService
* @Bean()
* @package App\Model\Service\Concrete\Home
*/
class AdminUserLoginService implements ServiceInterface
{
use JWTHelper;
/**
* @return Response
* @throws Exception
*/
public function handle(): Response
{
// 具体业务
// 输出响应
return context()->getResponse();
}
}
优势:
Controller、Service 之间加一层 Logic 代理,让框架结构更灵活,Logic 可对 Service 结构做进一步处理,可重复利用相似的 Service,如 "列表" 和 "导出" 大部分代码一样,只有输出部分不同,很适合重用。
将具体的业务封装到 Service,方便重用也适合做微服务,在 TCP 层重用 HTTP 的 Service,也方便多个接口重用同一个 Service.
利用 PHP 的 trait 特性,将于重复的、业务无关的代码片段封装为 Trait,减少代码量,增强可读性。
参考文档:
目录结构
路由
说明:
@Controller 指定路由前缀(prefix)
@ResuestMapping 指定路由端点(end point)
@ResuestMapping 的 method 指定路由的请求类型
注意:
method 支持多个,支持字符串和常量写法,推荐使用:RequestMethod::GET/POST 等
/**
* Class AdminUserController
*
* @since 2.0
*
* @Controller(prefix="/back/users")
*/
class AdminUserController
{
/**
* @Inject()
* @var AdminUserLogic
*/
private $logic;
/**
* @RequestMapping(route="login", method={RequestMethod::POST})
* @Validate(AdminUserValidator::class, fields={"username", "password"})
* @return Response
* @throws \Exception
*/
public function login(): Response
{
return $this->logic->login();
}
}
参考文档:
Swoft-Http-路由
swoft2 小白教程系列-HTTP Server
中间件
移除 Favicon 中间件
使用 Chrome 等浏览器访问网站,都会携带一个 Favicon.ico,如果不加以屏蔽,则会占用计算资源。 所以,需要分配一个全局中间件做这个事情。 当然也可以在 @base/static/ 放置 favicon.ico 文件。在 nginx 层面处理,不将该请求转发到 Swoft.
/**
* Class FavIconMiddleware
*
* @Bean()
*/
class FavIconMiddleware implements MiddlewareInterface
{
/**
* Process an incoming server request.
*
* @param ServerRequestInterface|Request $request
* @param RequestHandlerInterface $handler
*
* @return ResponseInterface
* @inheritdoc
* @throws SwoftException
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->getUriPath() === '/favicon.ico') {
return context()->getResponse()->withStatus(404)->withData([
'name' => '找不到',
]);
}
return $handler->handle($request);
}
}
统一处理 Options 请求
使用 Chrome 发送 Ajax 请求,在跨域的情况下,都会发送一个 Options 预请求,非简单请求会先发起一次空 body 的 OPTIONS 请求,称为"预检"请求,用于向服务器请求权限信息,等预检请求被成功响应后,才发起真正的 http 请求。
/**
* Class OptionMethodMiddleware
*
* @Bean()
*/
class OptionMethodMiddleware implements MiddlewareInterface
{
/**
* Process an incoming server request.
*
* @param ServerRequestInterface|Request $request
* @param RequestHandlerInterface $handler
*
* @return ResponseInterface
* @inheritdoc
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->getMethod() == 'OPTIONS') {
return context()->getResponse();
}
return $handler->handle($request);
}
}
认证中间件
/**
* Class AuthMiddlewareMiddleware - Custom middleware
* @Bean()
* @package App\Http\Middleware
*/
class AuthMiddlewareMiddleware implements MiddlewareInterface
{
/**
* Process an incoming server request.
*
* @param ServerRequestInterface|Request $request
* @param RequestHandlerInterface $handler
*
* @return ResponseInterface
* @inheritdoc
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$authorization = $request->getHeaderLine('Authorization');
$publicKey = config('jwt.public_key');
try {
$prefix = 'Bearer ';
if (empty($authorization) || !is_string($authorization) || strpos($authorization, $prefix) !== 0) {
throwApiException('Token 错误', 401);
}
$jwt = substr($authorization, strlen($prefix));
if (strlen(trim($jwt)) <= 0) {
throwApiException('Token 为空', 401);
}
$payload = JWT::decode($jwt, $publicKey, ['RS256']);
if (!isset($payload->user) && !is_numeric($payload->user)) {
throwApiException('没有找到用户 ID', 401);
}
$request->user = $payload->user;
} catch (\Exception $exception) {
return context()->getResponse()->withData([
'error' => $exception->getCode(),
'message' => $exception->getMessage(),
]);
}
return $handler->handle($request);
}
}
参考资料:
关于浏览器预检(OPTIONS)请求
验证器
Swoft 的验证器用注解 @Validator 表示
/**
* @RequestMapping(route="/back/users/export", method=RequestMethod::GET)
* @Validate(UserValidator::class)
* @return Response
* @throws \Exception
*/
public function export(): Response
{
return $this->logic->export();
}
每个模型对应的一个验证器,验证器的成员,就是该模型的所有字段。
UserValidator:
<?php
/*
* (c) svenhe <heshiweij@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Http\Validator;
use Swoft\Validator\Annotation\Mapping\ChsAlpha;
use Swoft\Validator\Annotation\Mapping\ChsAlphaNum;
use Swoft\Validator\Annotation\Mapping\Email;
use Swoft\Validator\Annotation\Mapping\Enum;
use Swoft\Validator\Annotation\Mapping\IsInt;
use Swoft\Validator\Annotation\Mapping\IsString;
use Swoft\Validator\Annotation\Mapping\NotEmpty;
use Swoft\Validator\Annotation\Mapping\Validator;
use App\Model\Entity\User;
/**
* Class UserValidator
* @Validator()
* @package App\Http\Validator
*/
class UserValidator
{
/**
* @IsString(message="ID 必须填写")
* @var integer
*/
protected $id;
/**
* @IsString(message="姓名必须填写")
* @ChsAlpha(message="只能包含中文、大小写英文")
* @var string
*/
protected $name;
/**
* @IsString(message="验证码必须填写")
* @var string
*/
protected $captcha;
/**
* @IsString(message="公司必须填写")
* @ChsAlphaNum(message="只能包含中文、大小写英文")
* @var string
*/
protected $company;
/**
* @IsString(message="部门必须填写")
* @ChsAlpha(message="只能包含中文、大小写英文")
* @var string
*/
protected $department;
/**
* @IsString(message="职位必须填写")
* @ChsAlpha(message="只能包含中文、大小写英文")
* @var string
*/
protected $job;
/**
* @NotEmpty(message="手机号必须填写")
* @IsInt(message="手机号必须是数字")
* @var string
*/
protected $phone;
/**
* @IsString(message="邮箱必须填写")
* @Email(message="邮箱格式不正确")
* @var string
*/
protected $email;
/**
* @IsString(message="电话必须填写")
* @var string
*/
protected $tel = '';
/**
* @IsString(message="目的必须填写")
* @Enum(values={User::PURPOSE_EXHIBITION, User::PURPOSE_FORUM},message="目的格式不正确")
* @var string
*/
protected $purpose;
/**
* @IsInt(message="请您选择是否同意隐私声明")
* @Enum(values={1,2}, message="隐私声明格式不正确")
* @var int
*/
protected $privacy_protected;
}
使用时,只需要指定 fields 即可,这样可以重复利用。
* @Validate(UserValidator::class,fields={"phone", "captcha"})
注意:
对于 status、type 等字段,应该使用 @Enum,限定其取值范围。 而其取值范围的每一个枚举值,都应该使用 Model 的常量。
/**
* @IsString(message="目的必须填写")
* @Enum(values={User::PURPOSE_EXHIBITION, User::PURPOSE_FORUM},message="目的格式不正确")
* @var string
*/
protected $purpose;
自定义验证器,则应该根据文档,将 Parser、Mapping 定义在 app/Annotation。
字段的验证尽量使用验证器,对于无法验证的内容,只能在 Service 进一步验证,如 "此 ID 是否存在数据库",还未被 Swoft 支持,需要自己定义或者在 Service 中验证,或者写助手函数,或者手动调用 validate
参考文档:
swoft-验证器
转换器
转换器(Transformer) 在 API 开发中必不可少,他的作用是对即将输出到客户端的响应数据做一次最后的转换,保留有用的字段,去除无用的字段,还可以在 Transformer 内部进一步做查询,构建更加复杂的响应格式。不仅如此,Transformer 对于导出 Excel 非常友好。再也不用通过复杂的嵌套循环,构建 Excel 的列数据,但由此带来的问题是,查询过多,而且导出是一个 IO 操作,在项目中用的是异步任务,下面会详细介绍。
首先,引入一个包
composer.json
...
required: {
...
"league/fractal": "^0.18.0",
...
}
...
更新:
composer update league/fractal
自定义 Trait
FractalHelper
<?php
/*
* (c) svenhe <heshiweij@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Helper;
use League\Fractal\Manager;
use League\Fractal\Resource\Collection;
use League\Fractal\Resource\Item;
use League\Fractal\Serializer\ArraySerializer;
trait FractalHelper
{
/**
* transform collection
*
* @param $items
* @param $transformer
* @return array
*
*/
public function collect($items, $transformer)
{
$resource = new Collection($items, new $transformer());
$fractal = new Manager();
$fractal->setSerializer(new ArraySerializer());
$result = $fractal->createData($resource)->toArray();
return $result['data'] ?? [];
}
/**
* transform item
*
* @param $item
* @param $transformer
* @return array
*/
public function item($item, $transformer)
{
$resource = new Item($item, new $transformer());
$fractal = new Manager();
$fractal->setSerializer(new ArraySerializer());
return $fractal->createData($resource)->toArray();
}
}
在 Service 中使用:
$user = User::find(1);
return json_response($this->item($user, UserTransformer::class));
或者
$user = User::get();
return json_response($this->collection($user, UserTransformer::class));
参考文档:
league/fractal
跨域
全局中间件
官方文档有详细的 CorsMiddleware 的例子。
app/Http/Middleware/CorsMiddleware.php:
namespace App\Http\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Swoft\Bean\Annotation\Mapping\Bean;
use Swoft\Http\Server\Contract\MiddlewareInterface;
/**
* @Bean()
*/
class CorsMiddleware implements MiddlewareInterface
{
/**
* Process an incoming server request.
* @param ServerRequestInterface $request
* @param RequestHandlerInterface $handler
* @return ResponseInterface
* @inheritdoc
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ('OPTIONS' === $request->getMethod()) {
$response = Context::mustGet()->getResponse();
return $this->configResponse($response);
}
$response = $handler->handle($request);
return $this->configResponse($response);
}
private function configResponse(ResponseInterface $response)
{
return $response
->withHeader('Access-Control-Allow-Origin', 'http://mysite')
->withHeader('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Accept, Origin, Authorization')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
}
}
配置:
'httpDispatcher' => [
'middlewares' => [
CorsMiddlewareMiddleware::class
],
],
注意:
当发生异常时候,将不会走此中间件,因为当业务抛出异常,前端会提示跨域问题。解决办法:
1、nginx.conf;
2、try catch 处理 swoft-issue-#1190
Nginx.conf 跨域
在 nginx.conf 的 server 节点下加入下面配置即可:
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Headers *;
add_header Access-Control-Allow-Methods *;
异常处理
异常处理在 Swoole 系列框架中尤为重要,因为 Swoole 的进程模型原因,工作进程无法使用 exit 和 die 等,遇到需要终止的业务,需要抛出异常。不同的错误可以定义不同 Exception。也可以统一不同的 Exception Handlers 来接收这些异常。为了书写方便起见,该项目只定义了两个 Exception:ApiException、HTTPException。
app\Exception\ApiException.php:
<?php
/*
* (c) svenhe <heshiweij@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Exception;
use Exception;
use Throwable;
class ApiException extends Exception
{
public function __construct(string $message = "error~", int $code = 0, Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
app\Exception\Handler\ApiExceptionHandler.php:
<?php declare(strict_types=1);
namespace App\Exception\Handler;
use App\Exception\ApiException;
use Swoft\Db\Exception\DbException;
use Swoft\Error\Annotation\Mapping\ExceptionHandler;
use Swoft\Http\Message\Response;
use Swoft\Http\Server\Exception\Handler\AbstractHttpErrorHandler;
use Swoft\Log\Helper\CLog;
use Swoft\Log\Helper\Log;
use Swoft\Validator\Exception\ValidatorException;
use Throwable;
use function sprintf;
use const APP_DEBUG;
/**
* Class ApiExceptionHandler
*
* @ExceptionHandler({ApiException::class,ValidatorException::class,DbException::class})
*/
class ApiExceptionHandler extends AbstractHttpErrorHandler
{
/**
* @param Throwable $e
* @param Response $response
*
* @return Response
*/
public function handle(Throwable $e, Response $response): Response
{
// Log
Log::error($e->getMessage());
CLog::error($e->getMessage());
$data = [
'code' => $e->getCode(),
'error' => sprintf('%s', $e->getMessage()),
];
if (APP_DEBUG) {
$data = array_merge($data, [
'file' => sprintf('At %s line %d', $e->getFile(), $e->getLine()),
'trace' => $e->getTrace(),
]);
}
return $response->withData($data);
}
}
为了方便书写,定义了助手函数
Functions.php:
if (!function_exists('throw_api_exception')) {
/**
* @param $message
* @param int $code
* @throws ApiException
*/
function throwApiException($message, $code = 0)
{
throw new ApiException($message, $code);
}
}
使用:
/** @var CouponCode $couponCode */
$couponCode = CouponCode::whereNull('deleted_at')->find(get_route_params('id', 0));
if (!$couponCode) {
throwApiException('优惠券不存在');
}
说明:
调试模式下,输出更加详细错误路径(这里使用 getTrace() 替换默认的 getTraceString(),方便阅读)
捕获到错误后,可以选择输出到控制台(stderr、stdout),也可以选择输出到 ES 和 MongoDB(由于 MongoDB 目前还未实现非阻塞 IO 版本,因此推荐使用异步任务投递)
参考文档:
Swoole 进程模型
Swoft 注意事项
认证
JWT 作为一种便捷的认证协议,在 API 中应用很广泛,JWT 的实现非常简单,主要是将需要客户端存储的参数进行封装并用 base64 编码。
安装
composer.json
...
required: {
...
"firebase/php-jwt": "^5.0.0",
...
}
...
登录场景
app/Helper/JWTHelper.php:
<?php
/*
* (c) svenhe <heshiweij@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Helper;
use App\Exception\ApiException;
use Firebase\JWT\JWT;
trait JWTHelper
{
/**
* @param int $userId
* @return string
* @throws ApiException
*/
public static function encrypt(int $userId): string
{
$privateKey = config('jwt.private_key', '');
if (empty($privateKey)) {
throwApiException('The private key is invalid!');
}
$payload = array(
"iss" => config('name'),
"aud" => config('name'),
"user" => $userId,
);
return JWT::encode($payload, $privateKey, 'RS256');
}
/**
* @param string $jwt
* @return int user id
* @throws ApiException
*/
public static function decrypt(string $jwt): int
{
$publicKey = config('jwt.public_key', '');
if (empty($publicKey)) {
throwApiException('The public key is invalid!');
}
$payload = JWT::decode($jwt, $publicKey, ['RS256']);
return $payload->user ?? 0;
}
}
登录成功后,返回携带 user_id 是 JWT。
$jwt = self::encrypt($user->getId());
return json_response([
'jwt' => $jwt,
'user' => $user,
]);
访问 API 场景
自定义认证中间件
app/Http/Middleware/AuthMiddlewareMiddleware.php:
<?php declare(strict_types=1);
/**
* This file is part of Swoft.
*
* @link https://swoft.org
* @document https://swoft.org/docs
* @contact group@swoft.org
* @license https://github.com/swoft-cloud/swoft/blob/master/LICENSE
*/
namespace App\Http\Middleware;
use App\Exception\ApiException;
use Firebase\JWT\JWT;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Swoft\Bean\Annotation\Mapping\Bean;
use Swoft\Http\Message\Request;
use Swoft\Http\Server\Contract\MiddlewareInterface;
use function config;
use function context;
/**
* Class AuthMiddlewareMiddleware - Custom middleware
* @Bean()
* @package App\Http\Middleware
*/
class AuthMiddlewareMiddleware implements MiddlewareInterface
{
/**
* Process an incoming server request.
*
* @param ServerRequestInterface|Request $request
* @param RequestHandlerInterface $handler
*
* @return ResponseInterface
* @inheritdoc
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$authorization = $request->getHeaderLine('Authorization');
$publicKey = config('jwt.public_key');
try {
$prefix = 'Bearer ';
if (empty($authorization) || !is_string($authorization) || strpos($authorization, $prefix) !== 0) {
throwApiException('Token 错误', 401);
}
$jwt = substr($authorization, strlen($prefix));
if (strlen(trim($jwt)) <= 0) {
throwApiException('Token 为空', 401);
}
$payload = JWT::decode($jwt, $publicKey, ['RS256']);
if (!isset($payload->user) && !is_numeric($payload->user)) {
throwApiException('没有找到用户 ID', 401);
}
$request->user = $payload->user;
} catch (\Exception $exception) {
return context()->getResponse()->withData([
'error' => $exception->getCode(),
'message' => $exception->getMessage(),
]);
}
return $handler->handle($request);
}
}
将该中间件加到需要认证的控制器方法上:
/**
* 完善个人信息
* @RequestMapping(route="profile", method=RequestMethod::POST)
* @Validate(UserValidator::class, fields={"company", "job", "phone", "email", "tel", "privacy_protected"})
* @Middleware(AuthMiddlewareMiddleware::class)
* @return Response
* @throws Exception
*/
public function completeProfile(): Response
{
return $this->logic->completeProfile();
}
在 Service 使用 AuthHelper 简化该过程:
<?php
/*
* (c) svenhe <heshiweij@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Helper;
use App\Exception\ApiException;
use App\Model\Entity\AdminUser;
use App\Model\Entity\User;
use Swoft\Db\Eloquent\Builder;
use Swoft\Db\Eloquent\Collection;
use Swoft\Db\Eloquent\Model;
use Swoft\Db\Exception\DbException;
/**
* Trait AuthHelper
* @package App\Helper
*/
trait AuthHelper
{
/**
* @return User|object|Builder|Collection|Model
* @throws ApiException
* @throws DbException
*/
public function user()
{
/** @var integer */
if (!is_int($userId = req()->user)) {
throwApiException('The user id is invalid!');
}
/** @var User $user */
$user = User::whereNull('deleted_at')->find($userId);
if (!$user) {
throwApiException('用户不存在');
}
return $user;
}
/**
* @return AdminUser|object|Builder|Collection|Model
* @throws ApiException
* @throws DbException
*/
public function adminUser()
{
/** @var integer */
if (!is_int($userId = req()->user)) {
throwApiException('The user id is invalid!');
}
/** @var AdminUser $adminUser */
$adminUser = AdminUser::whereNull('deleted_at')->find($userId);
if (!$adminUser) {
throwApiException('用户不存在');
}
return $adminUser;
}
}
获取用户信息:
class UserProfileService {
use AuthHelper;
public function handle(){
// 获取已登录的用户模型
$user = $this->user();
}
}
说明:
这里用 user() adminUser() 区分两个端口的用户,并不很恰当,推荐做法是,在 JWT 增加一个标志,用户表示该 Token 是哪个端口发放的,那就用哪个端口的方法去认证。
参考文档:
REST API Authentication in PHP JWT Tutorial
firebase/php-jwt
缓存
实现 WEB 高性能应用,有两个不可缺少的条件:
异步非阻塞 IO
缓存
用好缓存,以空间换时间,极大提升应用的响应速度。
Swoole 提供了 MemoryTable,用于跨进程的访问,MemoeryTable 将数据缓存在静态区,对于所有的进程都是可见的,而且自带锁,可以解决并发问题。
Swoft 没有封装 MemoryTable。于是这里就简单封装一下:
app/Helper/MemoryTable.php
/**
* Class MemoryTable
* @Bean()
* @package App\Helper
*/
class MemoryTable
{
/**
* Max lines for table.
*/
const MAX_SIZE = 1024;
/**
* @var Table
*/
private $table;
public function __construct()
{
$this->table = new Table(self::MAX_SIZE);
$this->table->column('value', Table::TYPE_STRING, 1024 * 32);
$this->table->create();
}
/**
* Store string value in table.
* @param string $key
* @param string $value
* @return bool
*/
public function store(string $key, string $value): bool
{
return $this->table->set($key, compact('value'));
}
/**
* Forget string value in table.
* @param $key
* @return bool
*/
public function forget(string $key): bool
{
return $this->table->del($key);
}
/**
* Get string value from table.
* @param $key
* @return mixed
*/
public function get(string $key): string
{
return $this->table->get($key, 'value');
}
}
和 Redis 搭配实现,实现三级缓存:
首先,从本地内存查找数据,存在即返回
然后,从 Redis 查找数据,存在即返回,并写入本地内存
如果都不存在,则从数据库拉取数据,存入 Redis、本地内存
(这里注意,Redis 也是内存缓存,但 Redis 可能存在于远程主机,还是得走 TCP,所以性能并没有 MemeryTable 高)
说明:
定义了一个 config('cache.enable') 来控制缓存,一键切换
定义了一系列的缓存 KEY 和过期时间的常量,而不是 hardcode
参考资料:
Swoole-table
定时任务
CrontabTask
CrontabTask 是 Swoft 提供的,基于 Swoole Timer 组件的一个定时异步计划任务。它的书写规则继承了 Linux 的 Crontab 语法,并在此基础上增加了 "秒级"。
此处,主要用它来清理 "导出 Excel" 产生的临时文件。 关于导出 Excel 的方法,之后会有章节详细介绍。
app/Crontab/CronTask.php:
/**
* Class CronTask
* @package App\Crontab
* @Scheduled()
*/
class CronTask
{
/**
* @Cron("0 1 0 * * *")
*/
public function cleanExcel()
{
printf("Clear excel task run: %s ", date('Y-m-d H:i:s', time()));
$directory = alias('@base/static/excel');
$files = scandir($directory);
foreach ($files as $file) {
if (str_end_with($file, '.xlsx')) {
unlink($directory . '/' . $file);
}
}
}
}
说明:
CrontabTask 完全不需要依赖 Linux 的 Crontab 组件。
CronTask 自己在独立的进程中跑,不会影响应用的整体性能。
参考文档:
Swoft-Task-Crontab
Time 组件
Time 组件是 Swoole 提供的,Swoft 做了简单的封装,主要是增加了几个事件触发点。 Timer 的应用场景分为两种:Ticker、After,类似 JS 是 setInterval() 和 setTimeout() 即每隔一段时间执行、多少时间后执行一次。 Ticker 多用在 Worker 进程启动的时候启动,周期性的做清理、汇报等任务。 而 After 多用来做定时任务,比如 "订单 30 分钟未付款自动取消"
订单 30 分钟未付款自动取消:
app/Model/Service/Concrete/Order/OrderCreateService.php:
// install a tick, make the order timeout after 30 minutes.
Timer::after(1000 * 60 * 30, function () use ($order) {
if ($order) {
if (!$order->isPaid()) {
// timeout & failure.
$order->setStatus(Order::STATUS_FAILURE);
$order->save();
}
} else {
CLog::info('The order is null.');
}
});
但是,这样做有个问题,定时器在后台创建后是依赖于进程的,终止进程,Timer 自动停止。这样会导致部分订单漏处理。解决办法是,在 Worker 进程启动时,先处理已经过期的订单,再拉起新的 Timer
app/Listener/WorkerStartListener.php:
<?php
/*
* (c) svenhe <heshiweij@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Listener;
use App\Model\Entity\Order;
use Swoft\Co;
use Swoft\Event\Annotation\Mapping\Listener;
use Swoft\Event\EventHandlerInterface;
use Swoft\Event\EventInterface;
use Swoft\Server\ServerEvent;
use Swoft\Timer;
/**
* @Listener(ServerEvent::WORK_PROCESS_START)
* Class WorkerStartListener
* @package App\Listener
*/
class WorkerStartListener implements EventHandlerInterface
{
/**
* Maximum number of items per processing
* @var int
*/
const MAX_CHUNK_ITEMS = 5;
/**
* The seconds 30 minutes.
* @var int
*/
const SECONDS_PER_30_MINUTES = 1800;
/**
* @param EventInterface $event
* @throws \ReflectionException
* @throws \Swoft\Bean\Exception\ContainerException
* @throws \Swoft\Db\Exception\DbException
*/
public function handle(EventInterface $event): void
{
if (false && 0 === context()->getWorkerId()) {
// make the order timeout.
$time = date('Y-m-d H:i:s', time() - self::SECONDS_PER_30_MINUTES);
$builder = Order::whereNull('deleted_at')
->where('status', Order::STATUS_DEFAULT);
$builder->where('created_at', '<', $time)->update([
'status' => Order::STATUS_FAILURE
]);
// Restart the timer which handling order timeout.
$builder->where('created_at', '>', $time)->chunk(self::MAX_CHUNK_ITEMS, function ($orders) {
/** @var Order $order */
foreach ($orders as $order) {
$diffSeconds = strtotime($order->getCreatedAt()) + self::SECONDS_PER_30_MINUTES - time();
Timer::after($diffSeconds * 1000, function () use ($order) {
if (!$order->isPaid()) {
$order->setStatus(Order::STATUS_FAILURE);
$order->save();
}
});
}
});
}
}
}
模型
模型组件 Model 高度兼容 Laravel,支持非常优雅的查询构造器、增删改查等 API。这里介绍几种具体的实践和注意点。
Getter Setter
Swoft 参考了 Java 的 Sprint Cloud 框架,对模型中的字段,使用 getter setter 对外提供 API。创建模型,可以使用 Swoft 提供的 php bin/swoft entity:c xxx 命令行创建,它支持从数据库表,直接创建模型,会将字段类型、字段说明、Setter、Getter 等统统定义好。如果之后需要补充字段,可以先使用 Migration 改表,然后手动增加字段即可,这时可以通过 PHPStorm 的快捷键创建 Getter Setter。
/**
* 用户表
* Class Users
*
* @since 2.0
*
* @Entity(table="users")
*/
class User extends Model {
/**
* 手机
*
* @Column()
*
* @var string
*/
private $phone;
/**
* @param string $phone
*
* @return void
*/
public function setPhone(string $phone): void
{
$this->phone = $phone;
}
/**
* @return string
*/
public function getPhone(): ?string
{
return $this->phone;
}
}
枚举常量 & mapping
业务中需要大量用到数据库字段的枚举常量,比如 status,type 等。
如,判断当前订单是否支付
if ($order->getStatus() == 1){
// ...
}
这种做法不推荐,因为 1 2 3 这些常量,直接 hardcode,大大降低了代码的可读性。因此,推荐在 Model 中,定义 const 常量。
/**
* 用户表
* Class Users
*
* @since 2.0
*
* @Entity(table="users")
*/
class User extends Model
{
/**
* 目的:参展
* @var string
*/
const PURPOSE_EXHIBITION = 'exhibition';
/**
* 目的:参加论坛
* @var string
*/
const PURPOSE_FORUM = 'forum';
}
使用:
if ($order->getType() == Order:: PURPOSE_EXHIBITION){
// ...
}
有些时候,需要将枚举常量转为为对应为文本,返回给前端,如:status = 1,则返回 "已支付",那么可以封装一下 Mapping.
class Order {
/**
* 订单类型和文本映射
*/
const TYPE_TEXT_MAPPING = [
self::TYPE_FROM_SCHOOL => '学校来宾会议通票',
self::TYPE_NOT_FROM_SCHOOL => '非学校来宾会议通票',
];
/**
* @return string
*/
public function getTypeText(): string
{
return self::TYPE_TEXT_MAPPING[$this->type] ?? '未知';
}
}
使用:
class Transformer {
public function transform(User $item)
{
$basic = [
...
'票价类别' => $item->getTypeText($item),
...
];
return array_merge($basic, $this->getQuestions($item));
}
}
当然,这里可以进一步通过魔术方法,让代码更简洁。
封装细粒度的业务逻辑
class Order {
/**
* Determine if order has already paid.
* @return bool
*/
public function isPaid(): bool
{
return $this->status == self::STATUS_SUCCESS && !empty($this->getPayCode());
}
}
使用:
if ($order->isPaid()){
throwApiException('订单已支付');
}
模型事件
Swoft 为模型提供了各种各样的事件,当数据被创建、被修改、删除,或者执行某个查询前、后,都会触发一定的事件,开发者可以监听这些事件,实现一些特殊的业务。当然不仅仅是模型,框架的启动、组件的生命周期也有各种各样的事件。
最典型的就是数据库记录被更新的同时更新缓存。
app/Listener/ForgetCacheListener.php:
/**
* Class ForgetCacheListener
* @Listener("swoft.model.*")
* @package App\Listener
*/
class ForgetCacheListener implements EventHandlerInterface
{
/**
* @param EventInterface $event
*/
public function handle(EventInterface $event): void
{
if (in_array($event->getName(), [
DbEvent::MODEL_CREATED,
DbEvent::MODEL_DELETED,
DbEvent::MODEL_UPDATED,
// DbEvent::MODEL_SAVED,
])) {
$target = $event->getTarget();
if ($target instanceof User) {
Redis::del(OrderStatisticService::CACHE_KEY);
$table = bean(MemoryTable::class);
$table->forget(OrderStatisticService::CACHE_KEY);
}
if ($target instanceof Order) {
Redis::del(OrderStatisticService::CACHE_KEY);
$table = bean(MemoryTable::class);
$table->forget(OrderStatisticService::CACHE_KEY);
}
if ($target instanceof UserAnswer) {
Redis::del(QuestionStatisticService::CACHE_KEY);
}
}
}
}
注意:
整形字段的对比,不建议使用 ===,因为 Swoft 字段会根据 @var 注解转成对应的类型。
Swoft 模型不提供软删除(因为官方始终认为软删除交给业务去实现,更加灵活 Swoft-issue-1183),因此只能自己实现。 但是表中的 deleted_at 字段,Blueprint 提供了 softDeleted() 方法,高度兼容 Laravel.
$users = User::whereNull('deleted_at')->get();
参考资料:
Swoft-Model
SWoft-Event
事件编程
正如前文所述, Swoft 提供了一系列的事件机制。 极大的解耦的业务逻辑。 我们可以将单独的,和业务主流程没有太大关系的功能模块定义成一个个事件,减少代码冗余。 而且两者没有依赖关系。
比如,在项目中,当用户 "完成答题流程" 或 "下单" 后,将确定用户身份,那么 "确定用户身份" 的业务逻辑,不需要在两个地方重复定义。只需要定义一个事件监听。当需要调用的地方,发送一个通知即可。
/**
* Class ConfirmUserPurposeListener
* @Listener(EventTag::CONFIRM_USER_PURPOSE)
* @package App\Listener
*/
class ConfirmUserPurposeListener implements EventHandlerInterface {
public function handle(EventInterface $event): void
{
// 确定用户身份
}
}
使用:
Swoft::trigger(EventTag::CONFIRM_USER_PURPOSE, $this->user(), $purpose);
而项目中,所有的事件,都作为常量定义在 EventTag 中,增强代码可读性。
/**
* Class EventTag
* @package App\Listener
*/
class EventTag
{
/**
* 事件:发送验证码
* @var string
*/
const SEND_CAPTCHA = 'EVENT_SEND_CAPTCHA';
/**
* 事件:确定用户目的
*/
const CONFIRM_USER_PURPOSE = 'CONFIRM_USER_PURPOSE';
}
支付
支付组件用了一个非常好用的第三方包(yansongda/pay),代码非常优雅。
为了统一,支付宝、微信支付用了同一个接口,通过传递不同的 type 来区分。详见如下:
<?php
/*
* (c) svenhe <heshiweij@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model\Service\Concrete\Order;
use App\Model\Entity\Order;
use App\Model\Service\Abstracts\ServiceInterface;
use Exception;
use Swoft\Bean\Annotation\Mapping\Bean;
use Swoft\Http\Message\Response;
use Yansongda\Pay\Pay;
/**
* Class OrderPayService
* @Bean()
* @package App\Model\Service\Concrete\Order
*/
class OrderPayService implements ServiceInterface
{
/**
* Pay gateway: alipay
*/
const PAY_GATEWAY_ALIPAY = 'alipay';
/**
* Pay gateway: alipay-web
*/
const PAY_GATEWAY_ALIPAY_WEB = 'alipay-web';
/**
* Pay gateway: wechat
*/
const PAY_GATEWAY_WECHAT = 'wechat';
/**
* @return Response
* @throws Exception
*/
public function handle(): Response
{
$id = intval(req()->input('id', 0));
// check order
...
// determine which gateway to pay.
switch ($type = req()->input('type')) {
case self::PAY_GATEWAY_ALIPAY:
case self::PAY_GATEWAY_WECHAT:
return call_user_func([$this, $type], $order);
case self::PAY_GATEWAY_ALIPAY_WEB:
return $this->alipayWeb($order);
default:
throwApiException('不支持的支付类型');
}
return html_response();
}
/**
* @param Order $order
* @return Response
*/
protected function alipay(Order $order): Response
{
$alipay = Pay::alipay(config('pay.alipay'))->wap([
'out_trade_no' => $order->getNumber(),
'total_amount' => sprintf('%.2f', $order->getPrice()),
'subject' => sprintf('%s', $order->getTypeText()),
]);
return html_response($alipay->getContent());
}
/**
* @param Order $order
* @return Response
*/
protected function alipayWeb(Order $order): Response
{
$alipay = Pay::alipay(config('pay.alipay'))->web([
'out_trade_no' => $order->getNumber(),
'total_amount' => sprintf('%.2f', $order->getPrice()),
'subject' => sprintf('%s', $order->getTypeText()),
]);
return html_response($alipay->getContent());
}
/**
* @param Order $order
* @return Response
*/
protected function wechat(Order $order): Response
{
$wechat = Pay::wechat(config('pay.wechat'))->wap([
'out_trade_no' => $order->getNumber(),
'total_fee' => $order->getPrice() * 100,
'body' => sprintf('%s', $order->getTypeText()),
]);
$content = $wechat->getContent();
// remove 'redirect to' text at bridge page.
$content = str_replace('<body>', '<body style="display: none;">', $content);
return html_response($content);
}
}
同样,回调地址也同一个,通过检测 XML 的头,来区分微信和支付宝,微信的数据是 XML
/**
* Class OrderNotifyService
* @Bean()
* @package App\Model\Service\Concrete\Order
*/
class OrderNotifyService implements ServiceInterface
{
/**
* @return Response
* @throws Exception
*/
public function handle(): Response
{
if ($this->isWechat()) {
return $this->wechat();
} else {
return $this->alipay();
}
}
/**
* Determine if wechat & alipay gateway
* @return bool
*/
protected function isWechat(): bool
{
$body = req()->getRawBody();
if (!empty($body) && str_start_with($body, '<xml>')) {
return true;
}
return false;
}
/**
* @return Response
*/
protected function wechat(): Response
{
}
protected function alipay(): Response
{
}
}
注意事项:
不同于传统的 Laravel 和 TP,Swoole 不提供超全局变量 $GET、$POST 等,但是第三方组件内部却是从超全局变量中获得数据,因此需要在调用前进行赋值:
$_GET = req()->get();
$_POST = req()->post();
不同于传统框架,Swoft 的响应只能通过 Response 对象输出,而 echo、var_dump 输出的内容,是显示在控制台的。因此,对于第三方组件产生的 Response,必须转成 Swoft 的 Response 才可以输出。
/**
* @param Order $order
* @return Response
*/
protected function alipay(Order $order): Response
{
$alipay = Pay::alipay(config('pay.alipay'))->wap([
'out_trade_no' => $order->getNumber(),
'total_amount' => sprintf('%.2f', $order->getPrice()),
'subject' => sprintf('%s', $order->getTypeText()),
]);
return html_response($alipay->getContent());
}
另外,微信 JSSDK 网页支付,需要获得 openid 才可以签名,但是 'yansongda/pay' 不提供获取 openid 的方法。 因此需要借助另一个 "easywechat" 组件,两者配合使用。
第一步:在 A 接口中,根据授权信息 OpenID 并跳转到 B 进行 userinfo 授权
A 接口:
$response = $app->oauth->scopes(['snsapi_userinfo'])
->redirect(sprintf('%s/front/orders/wechat/pay?id=%s', config('app_url'), $id));
$content = $response->getContent();
// remove 'redirect to' text at bridge page.
$content = str_replace('<body>', '<body style="display: none;">', $content);
return html_response($content);
第二步:在 B 接口中获取到 OpenId,再调用 yansongda/pay 产生 JSSDK 需要的支付参数
B 接口:
$_GET = req()->input(); // 注意:EasyWechat 中就是获取的 $_GET 的组件,所以要转一下
$app = Factory::officialAccount(config('pay.wechat-oauth'));
$user = $app->oauth->user();
// openid
$id = intval(req()->input('id', 0));
$wechat = Pay::wechat(config('pay.wechat'))->mp([
'out_trade_no' => $order->getNumber(),
'total_fee' => $order->getPrice() * 100,
'body' => sprintf('%s', $order->getTypeText()),
'openid' => $user->getId(),
]);
return view('pay', [
'jsApiParameters' => $wechat->toArray(),
'return_url' => sprintf(config('pay.redirect_url'), $order->getNumber())
]);
这里用到了一个模板 pay,其中的 JS 用于调起微信支付。
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>微信支付</title>
<script type="text/javascript">
//调用微信JS api 支付
function jsApiCall() {
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
<?php echo json_encode($jsApiParameters);?>,
function (res) {
WeixinJSBridge.log(res.err_msg);
var type = '';
if (res.err_msg == 'get_brand_wcpay_request:ok') {
type = 'success';
// alert('支付成功');
} else {
type = 'error';
// alert('支付失败');
}
window.location.href = "<?=$return_url ?>&type=" + type;
}
);
}
function callpay() {
if (typeof WeixinJSBridge == "undefined") {
if (document.addEventListener) {
document.addEventListener('WeixinJSBridgeReady', jsApiCall, false);
} else if (document.attachEvent) {
document.attachEvent('WeixinJSBridgeReady', jsApiCall);
document.attachEvent('onWeixinJSBridgeReady', jsApiCall);
}
} else {
jsApiCall();
}
}
callpay();
</script>
</head>
<body>
<br/>
<!-- <font color="#9ACD32"><b>该笔订单支付金额为<span style="color:#f00;font-size:50px">元</span>钱</b></font><br/><br/> -->
<!-- <div align="center">
<button style="width:210px; height:50px; border-radius: 15px; border:0px #FE6714 solid; cursor: pointer; color:white; font-size:16px;" type="button" >正在支付</button>
</div> -->
</body>
</html>
参考资料:
yansongda/pay
easywechat
迁移
Swoft 的迁移高度兼容 Laravel,用法基本雷同。可以通过命令行 php bin/swoft migrate:c xxx 创建。
<?php declare(strict_types=1);
namespace Database\Migration;
use ReflectionException;
use Swoft\Bean\Exception\ContainerException;
use Swoft\Db\Exception\DbException;
use Swoft\Db\Schema\Blueprint;
use Swoft\Devtool\Annotation\Mapping\Migration;
use Swoft\Devtool\Migration\Migration as BaseMigration;
/**
* Class CreateUsersTable
*
* @since 2.0
*
* @Migration(time=20191219125600)
*/
class CreateUsersTable extends BaseMigration
{
const TABLE = 'users';
/**
* @throws ContainerException
* @throws DbException
* @throws ReflectionException
* @throws \Swoft\Db\Exception\DbException
*/
public function up(): void
{
$this->schema->createIfNotExists(self::TABLE, function (Blueprint $blueprint) {
$blueprint->comment('用户表');
$blueprint->increments('id')->comment('primary');
$blueprint->string('phone')->default('')->comment('手机');
$blueprint->string('name')->default('')->comment('姓名');
$blueprint->string('company')->default('')->comment('公司');
$blueprint->string('department')->default('')->comment('部门');
$blueprint->string('job')->default('')->comment('职位');
$blueprint->string('email')->default('')->comment('邮箱');
$blueprint->string('tel')->default('')->comment('电话');
$blueprint->string('channel')->default('')->comment('渠道');
$blueprint->string('purpose')->default('')->comment('目的(exhibition:展会;forum:论坛)');
$blueprint->softDeletes();
$blueprint->timestamps();
$blueprint->index(['name', 'phone', 'purpose']);
$blueprint->engine = 'Innodb';
$blueprint->charset = 'utf8mb4';
});
}
/**
* @throws ReflectionException
* @throws ContainerException
* @throws DbException
*/
public function down(): void
{
$this->schema->dropIfExists(self::TABLE);
}
}
说明:
定义了 const TABLE,统一表名
每个字段如果(除 JSON、TEXT),都应该给默认值
每个表都建议加上软删除
每个表都建议加上时间戳(timestamp())
针对查询频繁字段,应该建立索引(不支持 JSON、TEXT 字段)
每个字段都应该写上清晰、简洁明了的注释,如果是枚举,则应该注明每个值的含义
不同的部分,应该用空行分割
每一次对表结构的调整,都应该通过 Migration 解决
填充
Sowft 没有提供 Laravel 非常好用的 Seeder,所以这里用 Command 自己实现了一个。Seeder 非常的好用,特别是对于数据有清空场景,如项目上线,需要清空测试数据。
<?php declare(strict_types=1);
/*
* (c) svenhe <heshiweij@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Console\Command;
use ReflectionClass;
use Swoft\Console\Annotation\Mapping\Command;
use Swoft\Console\Annotation\Mapping\CommandMapping;
use Swoft\Console\Input\Input;
use Swoft\Console\Output\Output;
use Swoft\Db\DB;
use Swoft\Stdlib\Helper\JsonHelper;
/**
* This is description for the command group
*
* @Command(coroutine=true)
*/
class SeederCommand
{
/**
* Install initialize seeder
*
* @CommandMapping(alias="seed")
*
* @param Input $input
* @param Output $output
* @return int The exit code
*
* @throws \ReflectionException
* @throws \Swoft\Bean\Exception\ContainerException
* @throws \Swoft\Db\Exception\DbException
*/
public function seed(Input $input, Output $output): int
{
// truncate all tables data
if ($input->hasOpt('t') || $input->hasOpt('truncate')) {
$tables = [
'admin_users',
'channel_codes',
'coupon_codes',
'coupon_code_details',
'invoices',
'orders',
'questions',
'settings',
'user_answers',
'logs',
'users',
];
foreach ($tables as $table) {
DB::table($table)->truncate();
$output->success('Table '. $table . ' truncated!');
}
}
// install seeders
$ref = new ReflectionClass(self::class);
$methods = $ref->getMethods();
foreach ($methods as $method) {
if (str_end_with($method->getName(), 'Seeder')) {
$method->invoke($this);
$output->success('Success seed: ' . $method->getName());
}
}
return 0;
}
/**
* @throws \ReflectionException
* @throws \Swoft\Bean\Exception\ContainerException
* @throws \Swoft\Db\Exception\DbException
*/
public function createAdminUsersSeeder()
{
DB::table('admin_users')->insert([
[
'id' => 1,
'username' => 'admin',
'password' => hash_make('123456'),
'created_at' => now(),
'updated_at' => now(),
]
]);
}
...
使用:
php bin/swoole seeder:seed -t
注意:
对于项目初始数据,后面修改了,别忘记修改 Seeder,不然一重置又回去了。
说明:
加了 -t 参数,表示原有的数据,比 Laravel 的 Seeder 更灵活
使用反射特性,查看并批量执以 Seeder 结尾的方法
视图
Swoft 提供了视图组件。有时候并非想渲染 HTML 给浏览器展示,只是想获取视图,注入变量,用做别的用途,如:发送邮件等。 这是可以封装一个 Helper 助手函数用于获取注入变量并且解析后的 HTML 内容。
app/Helper/Functions.php:
if (!function_exists('render')) {
/**
* render template with data.
* @param string $template
* @param array $data
* @return string
*/
function render(string $template, array $data): string
{
$renderer = Swoft::getSingleton('view');
return $renderer->render(Swoft::getAlias($template), $data);
}
}
使用:
$content = render('exhibition', $this->user()->toArray())
return html_response($content);
注意:
视图里,用原生 PHP:<?=$name ?> ,并没有太多的模板语法,官方文档对这块没有描述。
视图文件默认定义在 resources/view/xxx.php 默认位置可以在 bean.php 修改,详见文档。
参考文档:
Swoft-View
导出 Excel
导出 Excel 是一个耗时并且很重的 IO 操作,因为导出具备这几个特点:
为了拼接合适的字段,需要同时进行好几个查询。行数越多,查询就越多,对数据库造成压力
导出的文件需要输出响应,触发浏览器下载。数据量越大,耗时越长,用户体验不好
PHP 有最大执行时间、最大执行内存的限制,一旦数据超出限制,则会抛出 Fatal Error
Excel 数据行数、列数收版本限制。
为了满足这些特点,项目中用了 Swoft 提供的异步任务。
首先,封装一个导出 Helper 助手函数:
app/Helper/Functions.php:
if (!function_exists('create_excel_writer')) {
/**
* @param array $rows 数据
* @return IWriter 已保存的文件绝对路径
* @throws ExcelException
* @throws Exception
* @example
* $rows =>
*
* [
* ["name", "age", "gender"], // table head
* ["hsw", "10", "boy"] // table body
* ["wnm", "11", "girl"]
* ["sven", "12", "boy"]
* ]
*/
function create_excel_writer(array $rows): IWriter
{
// validate format
$counts = array_map(function (array $row) {
return count($row);
}, $rows);
if (count($counts) < 1) {
throw new Exception('The data is empty!');
}
$counts = array_unique($counts);
if (count($counts) > 1) {
throw new Exception('The length of data is not uniform!');
}
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
$worksheet->setTitle('Sheet01');
// for table head
foreach ($rows[0] as $key => $value) {
$worksheet->setCellValueByColumnAndRow($key + 1, 1, $value);
}
// for table body
unset($rows[0]);
$line = 2;
foreach ($rows as $row) {
$column = 1;
foreach ($row as $value) {
// $worksheet->setCellValueByColumnAndRow($column, $line, $value);
$cell = $worksheet->getCellByColumnAndRow($column, $line, true);
$cell->setValueExplicit($value, DataType::TYPE_STRING);
$column++;
}
$line++;
}
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
return $writer;
}
}
接口业务中,主要处理查询条件、Builder 构建,筛选出符合要求的行。
app/Model/Service/Concrete/Order/OrderExportService.php:
/**
* Class OrderExportService
* @Bean()
* @package App\Model\Service\Concrete\Order
*/
class OrderExportService implements ServiceInterface
{
use FractalHelper;
use PaginateHelper;
/**
* @return Response
* @throws Exception
*/
public function handle(): Response
{
/** @var Builder $builder */
$builder = Order::whereNull('orders.deleted_at')->whereNull('users.deleted_at');
// handle paginate
$builder = $this->forPage($builder);
$builder = $builder->leftJoin('users', 'users.id', 'orders.user_id');
// handle order
$builder = $builder->orderBy('orders.created_at', 'desc');
$builder = $builder->whereIn('orders.type', [Order::TYPE_FROM_SCHOOL, Order::TYPE_NOT_FROM_SCHOOL]);
// $builder = $builder->where('status', Order::STATUS_SUCCESS);
// handle filter
if (!empty($condition = req()->input('condition'))) {
$builder = $builder->where(function ($query) use ($condition) {
/** @var \Swoft\Db\Query\Builder $query */
$query->where('users.name', 'like', '%' . $condition . '%')
->orWhere('users.number', 'like', '%' . $condition . '%')
->orWhere('orders.number', 'like', '%' . $condition . '%')
->orWhere('users.phone', 'like', '%' . $condition . '%');
});
}
$data = $this->collect($builder->get([
'orders.id',
'orders.number',
'orders.user_id',
'users.number',
'users.phone',
'users.name',
'orders.origin_price',
'orders.price',
'orders.coupon_id',
'orders.coupon_type',
'orders.pay_type',
'orders.status',
'orders.created_at',
'orders.updated_at',
]), OrderExportTransformer::class);
$keys = array_keys($data[0] ?? []);
array_unshift($data, $keys);
$filename = 'Order-' . time() . '.xlsx';
$alias = sprintf('@base/static/excel/%s', $filename);
$downloadUrl = str_replace('@base/static', config('app_url'), $alias);
$writer = create_excel_writer($data);
if ($writer) {
$writer->save(alias($alias));
}
return json_response([
'download_url' => $downloadUrl,
], 200, 'ok');
}
}
接着,这些数据将通过 Transformer 重新整理。每个 key 就是 Excel 的列名。
/**
* Class OrderExportTransformer
* @package App\Http\Transformer
*/
class OrderExportTransformer extends TransformerAbstract
{
/**
* @param array $item
* @return array
* @throws \Swoft\Db\Exception\DbException
*/
public function transform(array $item)
{
return [
'编号' => $item['number'],
'登记号' => $this->getUserNumber($item['user_id']),
'手机号' => $item['phone'],
'姓名' => $item['name'],
'原价' => $item['origin_price'],
'实付' => $item['price'],
'折扣类型' => CouponCode::getCouponTypeText($item['coupon_type']),
'支付类型' => Order::getPayTypeText($item['pay_type']),
'下单时间' => $item['created_at'],
'状态' => Order::getStatusText($item['status']),
];
}
/**
* @param int $userId
* @return string
* @throws \Swoft\Db\Exception\DbException
*/
private function getUserNumber(int $userId): string
{
/** @var User $user */
$user = User::whereNull('deleted_at')->find($userId);
if ($user) {
return $user->getNumber();
}
return '';
}
}
接着,整理为 Excel 导出函数所需要的格式:
$keys = array_keys($data[0] ?? []);
array_unshift($data, $keys);
$filename = 'Order-' . time() . '.xlsx';
$alias = sprintf('@base/static/excel/%s', $filename);
$downloadUrl = str_replace('@base/static', config('app_url'), $alias);
下一步,将数据整理好的数据,发送个异步任务,并在后台生成目标文件
Task::async(ExportTask::class, $writer);
最后,将目标文件路径转为 URL,提供给前端下载
$filename = 'Order-' . time() . '.xlsx';
$alias = sprintf('@base/static/excel/%s', $filename);
$downloadUrl = str_replace('@base/static', config('app_url'), $alias);
return json_response([
'download_url' => $downloadUrl,
], 200, 'ok');
Nginx 配置
server {
listen 80;
server_name <your domain>;
#charset koi8-r;
error_log <your error log file>;
access_log <your access log file>
# define web root
set $web_root <your web root>;
root $web_root/public;
location / {
# proxy_redirect off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
proxy_set_header Connection "keep-alive";
proxy_pass http://127.0.0.1:18306;
}
location ~* \.(js|css|map|png|jpg|jpeg|gif|ico|ttf|woff2|woff)$ {
expires max;
root $web_root/static;
access_log off;
}
}
说明:
访问域名,自动代理 18306 的 Swoft Http-server
静态资源放置在 static 目录下
前后端分离项目,前端 dist 资源都放在 static 下
Swagger
Swagger 是一套在线 API 生产和预览工具,支持各种语言。 Swagger 分为两个部分:Swagger API 生成器、Swagger UI,前者是用来将代码中的注解生成 yaml 和 JSON 格式,后者导入此文件,渲染 API,Swagger UI 还支持模拟请求、Mock 等,极大的方便 API 的开发者和使用者。
预览:
Swagger UI
安装包
"zircote/swagger-php": "^3.0.0"
描述 API
Swoft 本身是以注解驱动的,可能和 Swagger 的注解会有冲突。 所以不能和 Controller 写在一起,这里单独写在一个目录中 (public/docs/apis),每个类,对应一个 Controller。
tree public/docs/apis:
public/docs/apis/
├── backend
│ ├── AdminUser.php
│ ├── Channel.php
│ ├── Coupon.php
│ ├── Invoice.php
│ ├── Order.php
│ ├── Question.php
│ └── User.php
└── frontend
├── Invoice.php
├── Order.php
├── Question.php
└── User.php
以 AdminUser 的 Login 接口为例,描述一个 API:
/**
* @OA\Info(title="My First API", version="0.1")
*/
class AdminUser
{
/**
* @OA\Post(
* path="/api/form/storeForm/",
* summary="表单提交数据接口",
* tags={"insert"},
* @OA\Parameter(
* name="vistor_name",
* required=true,
* in="query",
* description="姓名",
* @OA\Schema(
* type="string",
* default="测试",
* )
* ),
* @OA\Parameter(
* name="code",
* required=true,
* in="query",
* description="表单加密信息",
* @OA\Schema(
* type="string",
* default="d130zDVHsmoO8niFgiEAZbe2LRnOf9HC7j3VVeOAnuCGA8RDGLdF2/LhQt2po3sum2nXq4tr3Nue+fqbO6LAJP37cCr3gLW7rjVasJMQNX8oBNJWsmp",
* )
* ),
* @OA\Parameter(
* name="form_id",
* required=true,
* in="query",
* description="表单ID",
* @OA\Schema(
* type="integer",
* default=119,
* )
* ),
* @OA\RequestBody(
* @OA\MediaType(
* mediaType="application/json",
* @OA\Schema(
* @OA\Property(
* property="vistor_name",
* default="测试"
* ),
* @OA\Property(
* property="code",
* default="d130zDVHsmoO8niFgiEAZbe2LRnOf9HC7j3VVeOAnuCGAM8RDGLdF2/LhQt2po3sum2nXq4tr3Nue+fqbO6LAJP37cCr3gLW7rjVasJMQNX8oBNJWsmp"
* ),
* @OA\Property(
* property="form_id",
* default=119
* ),
* example={"vistor_name": "测试", "code": "d130zDVHsmoO8niFgiEAZbe2LRnOf9HC7j3VVeOAnuCGAM8RDGLdF2/LhQt2po3sum2nXq4tr3Nue+fqbO6LAJP37cCr3gLW7rjVasJMQNX8oBNJWsmp","form_id":119}
* )
* )
* ),
* @OA\Response(
* response=200,
* description="OK"
* )
* )
*
*/
public function login()
{
}
}
生成 JSON
这里通过 WEB 的方式,需要单独定义一个接口 (/api),当用户访问该接口时,即可以获得最新的 API 文档
/**
* Class SwaggerApiService
* @Bean()
* @package App\Model\Service\Concrete\Home
*/
class SwaggerApiService implements ServiceInterface
{
/**
* @return Response
* @throws HttpException
*/
public function handle(): Response
{
$docPath = alias('@base/public/docs');
if (!file_exists($docPath)) {
throw new HttpException("The docs path not found!");
}
return json_response(scan($docPath)->toJson());
}
}
Swagger UI 渲染
Swagger UI 是根据上一步产生的 JSON,渲染 API 文档的,Swagger UI 是一个单独的项目,需要提前部署。
部署后,导入上一步的 API 接口 URL,即可渲染。
参考资料:
Swagger 生成 PHP API 接口文档
Docker & Docker compose
Docker
Docker 是时下比较火的容器技术,他的优势:
部署简单
秒级启动
性能优异
镜像分层构建,体积占用小
高隔离性
支持容器内部组网
Docker 的应用场景
简化配置
将配置放到代码中,在不同环境做到相同的配置
在测试环境中配置好的应用,可以直接打包发布到正式环境 代码流水线管理
为代码从开发到部署阶段,提供一致的环境 提高开发效率
可以让开发环境尽量贴近生产环境
快速大件开发环境 隔离应用
将多个应用整合到一台机器上时,可以做到应用的隔离
正因为应用隔离,可以将多个应用部署到不同的机器上 整合服务器
由于应用隔离,可以将多个应用放到一台服务器,获得更大的资源利用率 调试能力
Docker 提供了很多工具,可以为容器设置检查点,比较两个容器的差异,方便调试应用 多租户环境
为每个租户构建隔离的应用 快速部署
Docker 启动仅仅启动了一个容器,不需要启动整个操作系统,速度达到秒级,快速部署
Docker-compose
Docker compose 是一个使用 Python 开发的,基于 Docker 的容器编排工具。它依赖一个 Docker-compose.yaml 的文件,管理项目主容器以及所依赖的其他容器。
version: "3"
services:
redis:
image: redis:alpine
container_name: redis
ports:
- 6379:6379
volumes:
- redisdb:/data
- /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime
swoft:
image: swoft/swoft
# for local develop
command: php -S 127.0.0.1:13300
container_name: swoft-test
environment:
- APP_ENV=dev
- TIMEZONE=Asia/Shanghai
ports:
- "18306:18306"
- "18307:18307"
- "18308:18308"
volumes:
- ./:/var/www/swoft
mysql:
image: mysql
container_name: mysql-srv
environment:
- MYSQL_ROOT_PASSWORD=123456
ports:
- "3306:3306"
volumes:
- mysqldb:/var/lib/mysql
volumes:
redisdb:
mysqldb:
启动容器:
docker-compose up -d
注意:
Docker-compose 中,在容器内部访问其他容器,使用服务名即可
参考资料:
swoft2 小白教程系列-搭建开发环境
Docker 基础 & 容器编排
二维码 & 条形码
项目中用到了条形码,用到了一个第三方的库,支持条形码和二维码.
"codeitnowin/barcode": "^3.0",
同时封装了一个助手函数:
该函数返回生成的条形码的 BASE64 编码,直接赋值给 标签即可。
if (!function_exists('bar_code_128')) {
/**
* generate base64 code for image element with bar code.
* @param string $number
* @return string
*/
function bar_code_128(string $number): string
{
$barcode = new BarcodeGenerator();
$barcode->setText(sprintf("%s", $number));
$barcode->setType(BarcodeGenerator::Code128);
$barcode->setScale(2);
$barcode->setThickness(25);
$barcode->setFontSize(10);
return $barcode->generate();
}
}
使用:
echo sprint("<img src="data: %s" />", bar_code_128('12345678));
任务
异步任务
Swoole 提供了 Task 异步任务,在服务启动时,通过 task_num 设置进程数量,每个 Task 都是一个独立的进程,专门用来处理耗时的任务。
$server->set([
'task_num' => 6,
]);
在 Worker 进程中,只需要 执行 $server->task() 就可以把任务投递到 Task 进程。
而在 Swoft,运用注解,将这一过程做了进一步封装:
定义 Task 任务:
/**
* Class CaptchaTask
* @Task()
* @package App\Task
*/
class CaptchaTask
{
/**
* @TaskMapping(name="send")
* @param $phone
* @param $captcha
* @throws \Exception
*/
public function send($phone, $captcha)
{
CLog::debug('异步任务: 开始发送验证码: %s %s', $phone, $captcha);
if (!empty($phone) && !empty($captcha)) {
send_sms_captcha($phone, $captcha);
}
CLog::debug('异步任务: 发送完成');
}
}
投递任务:
// send captcha here!
Task::async(CaptchaTask::class, 'send', [
$phone,
$captcha,
]);
说明:
异步任务的处理结果、抛出的异常不会返回 Worker 进程,而是需要通过监听 TaskEvent::FINISH 事件获得,建议通过 WebSocket 等异步通知前端(以导出文件为例,导出前建立 WebSocket 连接,下载完成后,服务端图推送下载完成事件给前端,前端接收通知后,根据后端返回的 URL 下载。)
/**
* Class TaskFinishListener
* @Listener(TaskEvent::FINISH)
* @package App\Listener
*/
class TaskFinishListener implements EventHandlerInterface
{
/**
* @param EventInterface $event
*/
public function handle(EventInterface $event): void
{
CLog::debug('异步任务结束: ' . $event->getTarget());
CLog::debug('异步任务结果: ', context()->getTaskData());
}
}
参考资料:
Swoole-Task
协程任务
协程任务和异步任务用法相同,原理不同,协程任务顾名思义是开启了一个协程去做任务。 两者的差异就是多进程和协程的差异,可以根据他们各自的特性选型。在 Swoft 里两者只能选其一。具体的参加文档即可。
Swoole 协程
HTTP 客户端
Swoft 官方并不推荐使用 curl 和 GuzzleHTTP 组件进行 HTTP 请求。 因为 Swoft 是全协程框架,每个请求都是协程,而 curl 等函数会导致底层协程无法切换,从而阻塞整个应用,导致服务大面积超时。如果非要使用,那么应该在异步任务中请求第三方接口,但这样会不方便获取返回结果,增加开发复杂度。
因此,Swoft 推荐使用 Saber 作为 HTTP 请求组件,Saber 是 Swoole 官方仓库作者贡献的一个机遇 Swoole HTTP 客户端封装的 HTTP 组件,完美兼容 Swoole、支持协程,并提供了优雅的 API 接口。
这里,根据 Saber 进一步封装了两个简单的助手函数。
尽管如此,像调用第三方接口的场景,如:发送短信、调用其他服务等,这些耗时的操作, 建议用异步任务。
GET:
if (!function_exists('http_get')) {
/**
* Send GET http request.
* @param string $url
* @return array
*/
function http_get(string $url): array
{
$saber = Saber::create([
'headers' => [
'Accept-Language' => 'en,zh-CN;q=0.9,zh;q=0.8',
'Content-Type' => ContentType::JSON,
'User-Agent' => config('name')
]
]);
$response = $saber->get($url);
return $response->getParsedJsonArray();
}
}
POST:
if (!function_exists('http_post')) {
/**
* send POST http request.
* @param string $url
* @param array $data
* @return array
*/
function http_post(string $url, array $data): array
{
$saber = Saber::create([
'headers' => [
'Accept-Language' => 'en,zh-CN;q=0.9,zh;q=0.8',
'Content-Type' => ContentType::JSON,
'User-Agent' => config('name')
]
]);
$response = $saber->post($url, $data ?? []);
return $response->getParsedJsonArray();
}
}
查询封装
写了几个 API 之后,发现 list (列表) 接口的逻辑非常相似。 无非是:
过滤
搜索
分页
转换
这里封装了一个 QueryBuilder 助手类,将这些操作进一步封装
app/Helper/BuilderHelper.php:
<?php
/*
* (c) svenhe <heshiweij@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Helper;
/**
* Class BuilderHelper
* @package App\Helper
*/
trait BuilderHelper
{
/**
* @param $builder
* @param array $candidate
* @return mixed
* @throws \Swoft\Db\Exception\DbException
*/
public function filter($builder, array $candidate = [])
{
if (empty($candidate)) {
return $builder;
}
/** @var \Swoft\Db\Query\Builder $builder */
foreach ($candidate as $key => $value) {
if (is_int($key)) {
if (!empty($purpose = req()->input($value))) {
$builder = $builder->where($value, $purpose);
}
} else {
if (!empty($purpose = req()->input($key))) {
$builder = $builder->where($value, $purpose);
}
}
}
return $builder;
}
/**
* @param $builder
* @param array $candidate
* @param string $field
* @return mixed
* @throws \Swoft\Db\Exception\DbException
*/
public function condition($builder, array $candidate = [], $field = 'condition')
{
if (empty($candidate)) {
return $builder;
}
if (!empty($condition = req()->input($field))) {
/** @var \Swoft\Db\Query\Builder $builder */
$builder = $builder->where(function ($query) use ($condition, $candidate) {
/** @var \Swoft\Db\Query\Builder $query */
$first = array_shift($candidate);
$query->where($first, 'like', '%' . $condition . '%');
foreach ($candidate as $item) {
$query = $query->orWhere($item, 'like', '%' . $condition . '%');
}
});
}
return $builder;
}
/**
* @param $builder
* @param array $fields
* @return mixed
*/
public function sort($builder, $fields = [])
{
if (empty($fields)) {
return $builder;
}
foreach ($fields as $field => $rank) {
/** @var \Swoft\Db\Query\Builder $builder */
$builder = $builder->orderBy($field, $rank);
}
return $builder;
}
}
app/Helper/QueryBuilder.php:
<?php
/*
* (c) svenhe <heshiweij@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Helper;
class QueryBuilder
{
use FractalHelper;
use PaginateHelper;
use BuilderHelper;
private $builder;
private function __construct($builder)
{
$this->builder = $builder;
}
private function __clone()
{
}
public static function new($builder)
{
return new self($builder);
}
/**
* @param array $candidate
* @param string $field
* @return QueryBuilder
* @throws \Swoft\Db\Exception\DbException
*/
public function withCondition(array $candidate = [], $field = 'condition'): QueryBuilder
{
$this->builder = $this->condition($this->builder, $candidate, $field);
return $this;
}
/**
* @param array $candidate
* @return QueryBuilder
* @throws \Swoft\Db\Exception\DbException
*/
public function withFilter(array $candidate = []): QueryBuilder
{
$this->builder = $this->filter($this->builder, $candidate);
return $this;
}
public function withSort($fields = []): QueryBuilder
{
$this->builder = $this->sort($this->builder, $fields);
return $this;
}
public function toPaginate(string $transformerClass, array $columns = ['*'])
{
return json_response($this->paginate($this->builder, $transformerClass, $columns));
}
}
使用助手类前:
/**
* Class UserListService
* @Bean()
* @package App\Model\Service\Concrete\User
*/
class UserListService implements ServiceInterface
{
use FractalHelper;
use PaginateHelper;
/**
* @return Response
* @throws Exception
*/
public function handle(): Response
{
$builder = User::whereNull('deleted_at');
$total = $builder->count();
// handle paginate
$builder = $this->paginate($builder);
// handle order
$builder = $builder->orderBy('created_at', 'desc');
// handle filter
if (!empty($condition = req()->input('condition'))) {
$builder = $builder->where(function ($query) use ($condition) {
/** @var \Swoft\Db\Query\Builder $query */
$query->where('users.name', 'like', '%' . $condition . '%')
->orWhere('users.number', 'like', '%' . $condition . '%')
->orWhere('orders.number', 'like', '%' . $condition . '%')
->orWhere('users.phone', 'like', '%' . $condition . '%');
});
}
if (!empty($id = req()->input('id'))) {
$builder = $builder->where('id', $id);
}
if (!empty($name = req()->input('name'))) {
$builder = $builder->where('name', 'like', '%' . $name . '%');
}
if (!empty($phone = req()->input('phone'))) {
$builder = $builder->where('phone', 'like', '%' . $phone . '%');
}
if (!empty($purpose = req()->input('purpose'))) {
$builder = $builder->where('purpose', $purpose);
}
if (!empty($channel = req()->input('channel'))) {
$builder = $builder->where('channel', $channel);
}
$wrapper = $this->paginateResponseWrapper($this->collect($builder->get(), UserTransformer::class), $total);
return json_response($wrapper);
}
}
使用助手类后:
/**
* Class UserListService
* @Bean()
* @package App\Model\Service\Concrete\User
*/
class UserListService implements ServiceInterface
{
/**
* @return Response
* @throws Exception
*/
public function handle(): Response
{
return QueryBuilder::new(User::whereNull('deleted_at'))
->withFilter(['purpose'])
->withCondition(['id', 'name', 'number', 'phone', 'channel', 'purpose'])
->withSort(['created_at' => 'desc'])->toPaginate(UserTransformer::class);
}
}
助手函数
打印原生 SQL
if (!function_exists('raw_sql')) {
/**
* Get raw sql
* @param \Swoft\Db\Eloquent\Builder|\Swoft\Db\Query\Builder $builder
* @return string
* @throws ReflectionException
* @throws \Swoft\Bean\Exception\ContainerException
*/
function raw_sql($builder): string
{
$sql = $builder->toSql();
$bindings = $builder->getBindings();
if (empty($bindings)) {
return $sql;
}
foreach ($bindings as $name => $value) {
if (is_int($name)) {
$name = '?';
}
if (is_string($value) || is_array($value)) {
$param = quote_string($value);
} elseif (is_bool($value)) {
$param = ($value ? 'TRUE' : 'FALSE');
} elseif ($value === null) {
$param = 'NULL';
} else {
$param = (string)$value;
}
$sql = StringHelper::replaceFirst($name, $param, $sql);
}
return $sql;
}
}
if (!function_exists('quote_string')) {
/**
* Quote the given string literal.
* @param array|string $value
* @return string
*/
function quote_string($value): string
{
if (is_array($value)) {
return implode(', ', array_map('quote_string', $value));
}
return "'$value'";
}
}
使用:
$builder = User::whereNull('deleted_at')
->where('name', 'hsw')
->orderBy('created_at', 'desc')
->where('id', '>', 1);
var_dump($builder);
打印调试信息到浏览器
简单版:
if (!function_exists('dump_simple')) {
/**
* print debug info in browser.
* @param mixed ...$vars
* @return Response
*/
function dump_simple(...$vars)
{
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
$line = $trace[0]['line'];
$pos = $trace[1]['class'] ?? $trace[0]['file'];
if ($pos) {
echo "CALL ON $pos($line):\n";
}
ob_start();
/** @noinspection ForgottenDebugOutputInspection */
var_dump(...$vars);
$string = ob_get_clean();
return html_response(preg_replace(["/Array[\s]*\(/", "/=>[\s]+/i"], ['Array (', '=> '], $string));
}
}
使用:
必须加上 return
class HomeController{
public index():Response
{
return dump_simple("hello", "world");
}
}
美化版:
需要安装:
symfony/var-dump:4.4.0 (最新版和 Swoft 冲突)
if (!function_exists('dump_pretty')) {
/**
* print pretty debug info in browser.(wrap symfony-var-dump)
* @param array $args
* @return Response
*/
function dump_pretty(...$args): Response
{
ob_start();
foreach ($args as $value) {
$dumper = new HtmlDumper;
$dumper->dump((new VarCloner)->cloneVar($value));
}
return html_response(ob_get_clean());
}
}
使用:
class HomeController{
public index():Response
{
return dump_pretty("hello", "world");
}
}
Hash 签名、校验
由于 Swoft 不提供密码相关的函数封装,这里移植了 Laravel 的 bcrypt() 和 Hash:check() ,这是一种非常安全的签名算法。
if (!function_exists('hash_make')) {
/**
* @param $value
* @return string|bool
*/
function hash_make($value): string
{
return password_hash($value, PASSWORD_BCRYPT, [
'cost' => 10,
]);
}
}
if (!function_exists('hash_check')) {
/**
* @param $value
* @param $hashedValue
* @return bool
*/
function hash_check($value, $hashedValue): bool
{
return password_verify($value, $hashedValue);
}
}
使用:
// 加密
$hashed = hash_make('123456);
// 校验
if (hash_check('123456'), $hashed) {
echo 'success';
}
运维技巧
高可用
在某些情况下,如系统负载过大swoole无法申请到内存而挂掉、swoole底层发生段错误、Server占用内存过大被内核Kill,或者被某些程序误杀。那swoole-server将无法提供服务,导致业务中断,公司收入出现损失。
check.sh
count=`ps -fe |grep "server.php" | grep -v "grep" | grep "master" | wc -l`
echo $count
if [ $count -lt 1 ]; then
ps -eaf |grep "server.php" | grep -v "grep"| awk '{print $2}'|xargs kill -9
sleep 2
ulimit -c unlimited
/usr/local/bin/php /data/webroot/server.php
echo "restart";
echo $(date +%Y-%m-%d_%H:%M:%S) >/data/log/restart.log
fi
* * * * * /path/to/your/project/check.sh
参考资料:
swoole服务器如何做到无人值守100%可用
别名
~/.bashrc:
alias ngx='cd /usr/local/nginx/conf/vhost'
alias sw='cd /home/wwwroot/<your project path> && swoftcli serve:run'
alias sd='cd /home/wwwroot/<your project path> && php bin/swoft http:start -d'
alias st='cd /home/wwwroot/<your project path> && php bin/swoft http:stop'
alias sr='cd /home/wwwroot/<your project path> && php bin/swoft http:restart'
sw
开发专用,热更新 & 自动重启
sd
部署专用,服务后台启动
st
部署专用,一键停止
sr
部署专用,一键重启
说明:
开发时,建议使用 sw,并且配置 PHPStorm 自动上传。服务会随着文件的变更不断重启。此时,服务不太稳定。
测试阶段,使用 sd 让服务在后台运行,如需要更新代码,则使用 sr 即可。建议修改后的代码在本地或者虚拟机测试过能启动的,再在服务器上执行 sr,否则以为语法问题,导致 Swoft 无法启动,会影响测试和线上体验。
线上阶段,使用 sd 正式上线,再配合 "Crontab 定时检测",保证服务高可用。之后再更新版本,则需要在本地或者测试环境测试好后,再更新到线上。并且强烈建议开启 CronTask 数据库定时备份。