Swoft HTTP-Server 使用经验分享



概述
经过一段时间对 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 数据库定时备份。
posted @ 2021-11-18 17:29  酷酷的城池  阅读(512)  评论(0编辑  收藏  举报