zoukankan      html  css  js  c++  java
  • Swoft HTTPServer 使用经验分享

    
    
    概述
    经过一段时间对 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="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 数据库定时备份。
    
  • 相关阅读:
    Linux下的crontab定时执行任务命令详解
    TP5使用Composer安装PhpSpreadsheet类库实现导入导出
    在本地创建分支并发布到远程仓库
    Linux中文件的可读,可写,可执行权限的解读以及chmod,chown,chgrp命令的用法
    crontab 定时写法整理
    Linux && Windows下基于ThinkPHP5框架实现定时任务(TP5定时任务)-结合Crontab任务
    Echarts环形图、折线图通过ajax动态获取数据
    javascript另类方法高效实现htmlencode()与htmldecode()函数,附带PHP请求完整操作
    PHP获取本月开始、结束时间,近七天所有时间
    关于sql中case when用法
  • 原文地址:https://www.cnblogs.com/chengfengchi/p/15573443.html
Copyright © 2011-2022 走看看