panda843/laravel-s

v3.7.31 2022-04-18 10:42 UTC

This package is auto-updated.

Last update: 2024-09-24 08:07:06 UTC


README

LaravelS Logo

英文文档 | 中文文档

🚀 LaravelS是Laravel/Lumen与Swoole之间的现成适配器

Latest Version PHP Version Swoole Version Total Downloads Build Status Code Intelligence Status License

持续更新

  • 关注此仓库以获取最新更新。

目录

功能

基准测试

要求

安装

1.通过Composer(packagist)安装包。

composer require "hhxsv5/laravel-s:~3.7.0" -vvv
# Make sure that your composer.lock file is under the VCS

2.注册服务提供者(选择以下两个之一)。

  • Laravel:在config/app.php文件中,Laravel 5.5+支持包自动发现,您应该跳过此步骤

    'providers' => [
        //...
        Hhxsv5\LaravelS\Illuminate\LaravelSServiceProvider::class,
    ],
  • Lumen:在bootstrap/app.php文件中

    $app->register(Hhxsv5\LaravelS\Illuminate\LaravelSServiceProvider::class);

3.发布配置和二进制文件。

升级LaravelS后,您需要重新发布;点击此处查看每个版本的变更说明。

php artisan laravels publish
# Configuration: config/laravels.php
# Binary: bin/laravels bin/fswatch bin/inotify

修改config/laravels.php:监听ip,监听端口,参考设置

性能调整

  • 调整内核参数

  • 工作进程数量:LaravelS使用Swoole的同步IO模式,工作进程数量设置越大,并发性能越好,但会导致更大的内存使用和进程切换开销。如果每个请求需要100ms,为了提供1000QPS的并发,至少需要配置100个工作进程。计算方法是:工作进程数 = 1000QPS/(1s/1ms) = 100,因此需要进行增量压力测试以计算最佳工作进程数

  • 任务工作进程数量

运行

运行前请仔细阅读通知重要通知(IMPORTANT)。

  • 命令:php bin/laravels {start|stop|restart|reload|info|help}
  • 命令startrestart的启动选项。
  • 运行时文件:start将自动执行php artisan laravels config并生成这些文件,开发者通常不需要关注它们,建议将它们添加到.gitignore文件中。

部署

建议通过Supervisord监督主进程,前提是不带-d选项,并将swoole.daemonize设置为false

[program:laravel-s-test]
directory=/var/www/laravel-s-test
command=/usr/local/bin/php bin/laravels start -i
numprocs=1
autostart=true
autorestart=true
startretries=3
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/supervisor/%(program_name)s.log

与Nginx协同(推荐)

示例.

gzip on;
gzip_min_length 1024;
gzip_comp_level 2;
gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml application/x-httpd-php image/jpeg image/gif image/png font/ttf font/otf image/svg+xml;
gzip_vary on;
gzip_disable "msie6";
upstream swoole {
    # Connect IP:Port
    server 127.0.0.1:5200 weight=5 max_fails=3 fail_timeout=30s;
    # Connect UnixSocket Stream file, tips: put the socket file in the /dev/shm directory to get better performance
    #server unix:/yourpath/laravel-s-test/storage/laravels.sock weight=5 max_fails=3 fail_timeout=30s;
    #server 192.168.1.1:5200 weight=3 max_fails=3 fail_timeout=30s;
    #server 192.168.1.2:5200 backup;
    keepalive 16;
}
server {
    listen 80;
    # Don't forget to bind the host
    server_name laravels.com;
    root /yourpath/laravel-s-test/public;
    access_log /yourpath/log/nginx/$server_name.access.log  main;
    autoindex off;
    index index.html index.htm;
    # Nginx handles the static resources(recommend enabling gzip), LaravelS handles the dynamic resource.
    location / {
        try_files $uri @laravels;
    }
    # Response 404 directly when request the PHP file, to avoid exposing public/*.php
    #location ~* \.php$ {
    #    return 404;
    #}
    location @laravels {
        # proxy_connect_timeout 60s;
        # proxy_send_timeout 60s;
        # proxy_read_timeout 120s;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Real-PORT $remote_port;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header Scheme $scheme;
        proxy_set_header Server-Protocol $server_protocol;
        proxy_set_header Server-Name $server_name;
        proxy_set_header Server-Addr $server_addr;
        proxy_set_header Server-Port $server_port;
        # "swoole" is the upstream
        proxy_pass http://swoole;
    }
}

与Apache协同

LoadModule proxy_module /yourpath/modules/mod_proxy.so
LoadModule proxy_balancer_module /yourpath/modules/mod_proxy_balancer.so
LoadModule lbmethod_byrequests_module /yourpath/modules/mod_lbmethod_byrequests.so
LoadModule proxy_http_module /yourpath/modules/mod_proxy_http.so
LoadModule slotmem_shm_module /yourpath/modules/mod_slotmem_shm.so
LoadModule rewrite_module /yourpath/modules/mod_rewrite.so
LoadModule remoteip_module /yourpath/modules/mod_remoteip.so
LoadModule deflate_module /yourpath/modules/mod_deflate.so

<IfModule deflate_module>
    SetOutputFilter DEFLATE
    DeflateCompressionLevel 2
    AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml application/x-httpd-php image/jpeg image/gif image/png font/ttf font/otf image/svg+xml
</IfModule>

<VirtualHost *:80>
    # Don't forget to bind the host
    ServerName www.laravels.com
    ServerAdmin hhxsv5@sina.com

    DocumentRoot /yourpath/laravel-s-test/public;
    DirectoryIndex index.html index.htm
    <Directory "/">
        AllowOverride None
        Require all granted
    </Directory>

    RemoteIPHeader X-Forwarded-For

    ProxyRequests Off
    ProxyPreserveHost On
    <Proxy balancer://laravels>  
        BalancerMember http://192.168.1.1:5200 loadfactor=7
        #BalancerMember http://192.168.1.2:5200 loadfactor=3
        #BalancerMember http://192.168.1.3:5200 loadfactor=1 status=+H
        ProxySet lbmethod=byrequests
    </Proxy>
    #ProxyPass / balancer://laravels/
    #ProxyPassReverse / balancer://laravels/

    # Apache handles the static resources, LaravelS handles the dynamic resource.
    RewriteEngine On
    RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-d
    RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-f
    RewriteRule ^/(.*)$ balancer://laravels%{REQUEST_URI} [P,L]

    ErrorLog ${APACHE_LOG_DIR}/www.laravels.com.error.log
    CustomLog ${APACHE_LOG_DIR}/www.laravels.com.access.log combined
</VirtualHost>

启用WebSocket服务器

WebSocket服务器的监听地址与Http服务器相同。

1. 创建WebSocket处理器类,并实现WebSocketHandlerInterface接口。实例在启动时会自动创建,您不需要手动创建它。

namespace App\Services;
use Hhxsv5\LaravelS\Swoole\WebSocketHandlerInterface;
use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;
/**
 * @see https://www.swoole.co.uk/docs/modules/swoole-websocket-server
 */
class WebSocketService implements WebSocketHandlerInterface
{
    // Declare constructor without parameters
    public function __construct()
    {
    }
    // public function onHandShake(Request $request, Response $response)
    // {
           // Custom handshake: https://www.swoole.co.uk/docs/modules/swoole-websocket-server-on-handshake
           // The onOpen event will be triggered automatically after a successful handshake
    // }
    public function onOpen(Server $server, Request $request)
    {
        // Before the onOpen event is triggered, the HTTP request to establish the WebSocket has passed the Laravel route,
        // so Laravel's Request, Auth information are readable, Session is readable and writable, but only in the onOpen event.
        // \Log::info('New WebSocket connection', [$request->fd, request()->all(), session()->getId(), session('xxx'), session(['yyy' => time()])]);
        // The exceptions thrown here will be caught by the upper layer and recorded in the Swoole log. Developers need to try/catch manually.
        $server->push($request->fd, 'Welcome to LaravelS');
    }
    public function onMessage(Server $server, Frame $frame)
    {
        // \Log::info('Received message', [$frame->fd, $frame->data, $frame->opcode, $frame->finish]);
        // The exceptions thrown here will be caught by the upper layer and recorded in the Swoole log. Developers need to try/catch manually.
        $server->push($frame->fd, date('Y-m-d H:i:s'));
    }
    public function onClose(Server $server, $fd, $reactorId)
    {
        // The exceptions thrown here will be caught by the upper layer and recorded in the Swoole log. Developers need to try/catch manually.
    }
}

2. 修改config/laravels.php文件。

// ...
'websocket'      => [
    'enable'  => true, // Note: set enable to true
    'handler' => \App\Services\WebSocketService::class,
],
'swoole'         => [
    //...
    // Must set dispatch_mode in (2, 4, 5), see https://www.swoole.co.uk/docs/modules/swoole-server/configuration
    'dispatch_mode' => 2,
    //...
],
// ...

3. 使用SwooleTable绑定FD与UserId,可选的,参考Swoole Table 示例。您也可以使用其他全局存储服务,如Redis/Memcached/MySQL,但请注意,多个Swoole服务器之间的FD可能会发生冲突。

4. 与Nginx协同(推荐)

参考WebSocket代理

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
upstream swoole {
    # Connect IP:Port
    server 127.0.0.1:5200 weight=5 max_fails=3 fail_timeout=30s;
    # Connect UnixSocket Stream file, tips: put the socket file in the /dev/shm directory to get better performance
    #server unix:/yourpath/laravel-s-test/storage/laravels.sock weight=5 max_fails=3 fail_timeout=30s;
    #server 192.168.1.1:5200 weight=3 max_fails=3 fail_timeout=30s;
    #server 192.168.1.2:5200 backup;
    keepalive 16;
}
server {
    listen 80;
    # Don't forget to bind the host
    server_name laravels.com;
    root /yourpath/laravel-s-test/public;
    access_log /yourpath/log/nginx/$server_name.access.log  main;
    autoindex off;
    index index.html index.htm;
    # Nginx handles the static resources(recommend enabling gzip), LaravelS handles the dynamic resource.
    location / {
        try_files $uri @laravels;
    }
    # Response 404 directly when request the PHP file, to avoid exposing public/*.php
    #location ~* \.php$ {
    #    return 404;
    #}
    # Http and WebSocket are concomitant, Nginx identifies them by "location"
    # !!! The location of WebSocket is "/ws"
    # Javascript: var ws = new WebSocket("ws://laravels.com/ws");
    location =/ws {
        # proxy_connect_timeout 60s;
        # proxy_send_timeout 60s;
        # proxy_read_timeout: Nginx will close the connection if the proxied server does not send data to Nginx in 60 seconds; At the same time, this close behavior is also affected by heartbeat setting of Swoole.
        # proxy_read_timeout 60s;
        proxy_http_version 1.1;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Real-PORT $remote_port;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header Scheme $scheme;
        proxy_set_header Server-Protocol $server_protocol;
        proxy_set_header Server-Name $server_name;
        proxy_set_header Server-Addr $server_addr;
        proxy_set_header Server-Port $server_port;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_pass http://swoole;
    }
    location @laravels {
        # proxy_connect_timeout 60s;
        # proxy_send_timeout 60s;
        # proxy_read_timeout 60s;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Real-PORT $remote_port;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header Scheme $scheme;
        proxy_set_header Server-Protocol $server_protocol;
        proxy_set_header Server-Name $server_name;
        proxy_set_header Server-Addr $server_addr;
        proxy_set_header Server-Port $server_port;
        proxy_pass http://swoole;
    }
}

5. 心跳设置

  • Swoole的心跳设置

    // config/laravels.php
    'swoole' => [
        //...
        // All connections are traversed every 60 seconds. If a connection does not send any data to the server within 600 seconds, the connection will be forced to close.
        'heartbeat_idle_time'      => 600,
        'heartbeat_check_interval' => 60,
        //...
    ],
  • Nginx的代理读取超时

    # Nginx will close the connection if the proxied server does not send data to Nginx in 60 seconds
    proxy_read_timeout 60s;

6. 在控制器中推送数据

namespace App\Http\Controllers;
class TestController extends Controller
{
    public function push()
    {
        $fd = 1; // Find fd by userId from a map [userId=>fd].
        /**@var \Swoole\WebSocket\Server $swoole */
        $swoole = app('swoole');
        $success = $swoole->push($fd, 'Push data to fd#1 in Controller');
        var_dump($success);
    }
}

监听事件

系统事件

通常,您可以重置/销毁一些global/static变量,或更改当前的Request/Response对象。

  • laravels.received_request在LaravelS将Swoole\Http\Request解析为Illuminate\Http\Request之后,在Laravel的Kernel处理此请求之前。

    // Edit file `app/Providers/EventServiceProvider.php`, add the following code into method `boot`
    // If no variable $events, you can also call Facade \Event::listen(). 
    $events->listen('laravels.received_request', function (\Illuminate\Http\Request $req, $app) {
        $req->query->set('get_key', 'hhxsv5');// Change query of request
        $req->request->set('post_key', 'hhxsv5'); // Change post of request
    });
  • laravels.generated_response在Laravel的Kernel处理请求之后,在LaravelS将Illuminate\Http\Response解析为Swoole\Http\Response之前。

    // Edit file `app/Providers/EventServiceProvider.php`, add the following code into method `boot`
    // If no variable $events, you can also call Facade \Event::listen(). 
    $events->listen('laravels.generated_response', function (\Illuminate\Http\Request $req, \Symfony\Component\HttpFoundation\Response $rsp, $app) {
        $rsp->headers->set('header-key', 'hhxsv5');// Change header of response
    });

自定义异步事件

此功能依赖于SwooleAsyncTask,您需要首先在config/laravels.php中设置swoole.task_worker_num。异步事件处理的性能受Swoole任务进程数的影响,您需要适当设置task_worker_num

1. 创建事件类。

use Hhxsv5\LaravelS\Swoole\Task\Event;
class TestEvent extends Event
{
    protected $listeners = [
        // Listener list
        TestListener1::class,
        // TestListener2::class,
    ];
    private $data;
    public function __construct($data)
    {
        $this->data = $data;
    }
    public function getData()
    {
        return $this->data;
    }
}

2. 创建监听器类。

use Hhxsv5\LaravelS\Swoole\Task\Task;
use Hhxsv5\LaravelS\Swoole\Task\Listener;
class TestListener1 extends Listener
{
    /**
     * @var TestEvent
     */
    protected $event;
    
    public function handle()
    {
        \Log::info(__CLASS__ . ':handle start', [$this->event->getData()]);
        sleep(2);// Simulate the slow codes
        // Deliver task in CronJob, but NOT support callback finish() of task.
        // Note: Modify task_ipc_mode to 1 or 2 in config/laravels.php, see https://www.swoole.co.uk/docs/modules/swoole-server/configuration
        $ret = Task::deliver(new TestTask('task data'));
        var_dump($ret);
        // The exceptions thrown here will be caught by the upper layer and recorded in the Swoole log. Developers need to try/catch manually.
    }
}

3. 触发事件。

// Create instance of event and fire it, "fire" is asynchronous.
use Hhxsv5\LaravelS\Swoole\Task\Event;
$event = new TestEvent('event data');
// $event->delay(10); // Delay 10 seconds to fire event
// $event->setTries(3); // When an error occurs, try 3 times in total
$success = Event::fire($event);
var_dump($success);// Return true if sucess, otherwise false

异步任务队列

此功能依赖于SwooleAsyncTask,您需要首先在config/laravels.php中设置swoole.task_worker_num。任务处理的性能受Swoole任务进程数的影响,您需要适当设置task_worker_num

1. 创建任务类。

use Hhxsv5\LaravelS\Swoole\Task\Task;
class TestTask extends Task
{
    private $data;
    private $result;
    public function __construct($data)
    {
        $this->data = $data;
    }
    // The logic of task handling, run in task process, CAN NOT deliver task
    public function handle()
    {
        \Log::info(__CLASS__ . ':handle start', [$this->data]);
        sleep(2);// Simulate the slow codes
        // The exceptions thrown here will be caught by the upper layer and recorded in the Swoole log. Developers need to try/catch manually.
        $this->result = 'the result of ' . $this->data;
    }
    // Optional, finish event, the logic of after task handling, run in worker process, CAN deliver task 
    public function finish()
    {
        \Log::info(__CLASS__ . ':finish start', [$this->result]);
        Task::deliver(new TestTask2('task2 data')); // Deliver the other task
    }
}

2. 发送任务。

// Create instance of TestTask and deliver it, "deliver" is asynchronous.
use Hhxsv5\LaravelS\Swoole\Task\Task;
$task = new TestTask('task data');
// $task->delay(3);// delay 3 seconds to deliver task
// $task->setTries(3); // When an error occurs, try 3 times in total
$ret = Task::deliver($task);
var_dump($ret);// Return true if sucess, otherwise false

毫秒级定时任务

基于Swoole的毫秒级定时器的cron作业包装器,替换LinuxCrontab

1. 创建cron作业类。

namespace App\Jobs\Timer;
use App\Tasks\TestTask;
use Swoole\Coroutine;
use Hhxsv5\LaravelS\Swoole\Task\Task;
use Hhxsv5\LaravelS\Swoole\Timer\CronJob;
class TestCronJob extends CronJob
{
    protected $i = 0;
    // !!! The `interval` and `isImmediate` of cron job can be configured in two ways(pick one of two): one is to overload the corresponding method, and the other is to pass parameters when registering cron job.
    // --- Override the corresponding method to return the configuration: begin
    public function interval()
    {
        return 1000;// Run every 1000ms
    }
    public function isImmediate()
    {
        return false;// Whether to trigger `run` immediately after setting up
    }
    // --- Override the corresponding method to return the configuration: end
    public function run()
    {
        \Log::info(__METHOD__, ['start', $this->i, microtime(true)]);
        // do something
        // sleep(1); // Swoole < 2.1
        Coroutine::sleep(1); // Swoole>=2.1 Coroutine will be automatically created for run().
        $this->i++;
        \Log::info(__METHOD__, ['end', $this->i, microtime(true)]);

        if ($this->i >= 10) { // Run 10 times only
            \Log::info(__METHOD__, ['stop', $this->i, microtime(true)]);
            $this->stop(); // Stop this cron job, but it will run again after restart/reload.
            // Deliver task in CronJob, but NOT support callback finish() of task.
            // Note: Modify task_ipc_mode to 1 or 2 in config/laravels.php, see https://www.swoole.co.uk/docs/modules/swoole-server/configuration
            $ret = Task::deliver(new TestTask('task data'));
            var_dump($ret);
        }
        // The exceptions thrown here will be caught by the upper layer and recorded in the Swoole log. Developers need to try/catch manually.
    }
}

2. 注册cron作业。

// Register cron jobs in file "config/laravels.php"
[
    // ...
    'timer'          => [
        'enable' => true, // Enable Timer
        'jobs'   => [ // The list of cron job
            // Enable LaravelScheduleJob to run `php artisan schedule:run` every 1 minute, replace Linux Crontab
            // \Hhxsv5\LaravelS\Illuminate\LaravelScheduleJob::class,
            // Two ways to configure parameters:
            // [\App\Jobs\Timer\TestCronJob::class, [1000, true]], // Pass in parameters when registering
            \App\Jobs\Timer\TestCronJob::class, // Override the corresponding method to return the configuration
        ],
        'max_wait_time' => 5, // Max waiting time of reloading
        // Enable the global lock to ensure that only one instance starts the timer when deploying multiple instances. This feature depends on Redis, please see https://laravel.net.cn/docs/7.x/redis
        'global_lock'     => false,
        'global_lock_key' => config('app.name', 'Laravel'),
    ],
    // ...
];

3. 注意:构建服务器集群时,将启动多个定时器,因此您需要确保只启动一个定时器以避免重复执行任务。

4. LaravelS v3.4.0开始支持[重载]热重启Timer进程。LaravelS收到SIGUSR1信号后,将等待max_wait_time(默认5秒)结束进程,然后Manager进程将重新拉起Timer进程。

5. 如果您只需要使用分钟级计划任务,建议启用Hhxsv5\LaravelS\Illuminate\LaravelScheduleJob而不是Linux Crontab,这样您就可以遵循Laravel任务调度的编码习惯并配置Kernel

// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    // runInBackground() will start a new child process to execute the task. This is asynchronous and will not affect the execution timing of other tasks.
    $schedule->command(TestCommand::class)->runInBackground()->everyMinute();
}

修改代码后自动重新加载

  • 通过inotify支持Linux。

    1. 安装inotify扩展。

    2. 在设置中打开开关。

    3.注意:仅在Linux系统中修改文件以接收文件更改事件。建议使用最新的Docker。Vagrant解决方案

  • 通过fswatch,支持OS X/Linux/Windows。

    1.安装fswatch

    2.在项目根目录下运行命令。

    # Watch current directory
    ./bin/fswatch
    # Watch app directory
    ./bin/fswatch ./app
  • 通过inotifywait,支持Linux。

    1.安装inotify-tools

    2.在项目根目录下运行命令。

    # Watch current directory
    ./bin/inotify
    # Watch app directory
    ./bin/inotify ./app
  • 当上述方法不起作用时,最终解决方案:设置max_request=1,worker_num=1,使Worker进程在处理请求后重启。此方法性能非常差,因此仅适用于开发环境

获取项目中SwooleServer的实例。

/**
 * $swoole is the instance of `Swoole\WebSocket\Server` if enable WebSocket server, otherwise `Swoole\Http\Server`
 * @var \Swoole\WebSocket\Server|\Swoole\Http\Server $swoole
 */
$swoole = app('swoole');
var_dump($swoole->stats());
$swoole->push($fd, 'Push WebSocket message');

使用SwooleTable

1.定义表,支持多个。

所有定义的表将在Swoole启动前创建。

// in file "config/laravels.php"
[
    // ...
    'swoole_tables'  => [
        // Scene:bind UserId & FD in WebSocket
        'ws' => [// The Key is table name, will add suffix "Table" to avoid naming conflicts. Here defined a table named "wsTable"
            'size'   => 102400,// The max size
            'column' => [// Define the columns
                ['name' => 'value', 'type' => \Swoole\Table::TYPE_INT, 'size' => 8],
            ],
        ],
        //...Define the other tables
    ],
    // ...
];

2.访问Table:所有表实例都将绑定在SwooleServer上,通过app('swoole')->xxxTable访问。

namespace App\Services;
use Hhxsv5\LaravelS\Swoole\WebSocketHandlerInterface;
use Swoole\Http\Request;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;
class WebSocketService implements WebSocketHandlerInterface
{
    /**@var \Swoole\Table $wsTable */
    private $wsTable;
    public function __construct()
    {
        $this->wsTable = app('swoole')->wsTable;
    }
    // Scene:bind UserId & FD in WebSocket
    public function onOpen(Server $server, Request $request)
    {
        // var_dump(app('swoole') === $server);// The same instance
        /**
         * Get the currently logged in user
         * This feature requires that the path to establish a WebSocket connection go through middleware such as Authenticate.
         * E.g:
         * Browser side: var ws = new WebSocket("ws://127.0.0.1:5200/ws");
         * Then the /ws route in Laravel needs to add the middleware like Authenticate.
         * Route::get('/ws', function () {
         *     // Respond any content with status code 200
         *     return 'websocket';
         * })->middleware(['auth']);
         */
        // $user = Auth::user();
        // $userId = $user ? $user->id : 0; // 0 means a guest user who is not logged in
        $userId = mt_rand(1000, 10000);
        // if (!$userId) {
        //     // Disconnect the connections of unlogged users
        //     $server->disconnect($request->fd);
        //     return;
        // }
        $this->wsTable->set('uid:' . $userId, ['value' => $request->fd]);// Bind map uid to fd
        $this->wsTable->set('fd:' . $request->fd, ['value' => $userId]);// Bind map fd to uid
        $server->push($request->fd, "Welcome to LaravelS #{$request->fd}");
    }
    public function onMessage(Server $server, Frame $frame)
    {
        // Broadcast
        foreach ($this->wsTable as $key => $row) {
            if (strpos($key, 'uid:') === 0 && $server->isEstablished($row['value'])) {
                $content = sprintf('Broadcast: new message "%s" from #%d', $frame->data, $frame->fd);
                $server->push($row['value'], $content);
            }
        }
    }
    public function onClose(Server $server, $fd, $reactorId)
    {
        $uid = $this->wsTable->get('fd:' . $fd);
        if ($uid !== false) {
            $this->wsTable->del('uid:' . $uid['value']); // Unbind uid map
        }
        $this->wsTable->del('fd:' . $fd);// Unbind fd map
        $server->push($fd, "Goodbye #{$fd}");
    }
}

多端口混合协议

有关更多信息,请参阅Swoole Server AddListener

为了使我们的主服务器支持更多协议,而不仅仅是Http和WebSocket,我们在LaravelS中引入了Swoole的multi-port mixed protocol特性,并将其命名为Socket。现在,您可以在Laravel之上轻松构建TCP/UDP应用程序。

  1. 创建Socket处理类,并扩展Hhxsv5\LaravelS\Swoole\Socket\{TcpSocket|UdpSocket|Http|WebSocket}

    namespace App\Sockets;
    use Hhxsv5\LaravelS\Swoole\Socket\TcpSocket;
    use Swoole\Server;
    class TestTcpSocket extends TcpSocket
    {
        public function onConnect(Server $server, $fd, $reactorId)
        {
            \Log::info('New TCP connection', [$fd]);
            $server->send($fd, 'Welcome to LaravelS.');
        }
        public function onReceive(Server $server, $fd, $reactorId, $data)
        {
            \Log::info('Received data', [$fd, $data]);
            $server->send($fd, 'LaravelS: ' . $data);
            if ($data === "quit\r\n") {
                $server->send($fd, 'LaravelS: bye' . PHP_EOL);
                $server->close($fd);
            }
        }
        public function onClose(Server $server, $fd, $reactorId)
        {
            \Log::info('Close TCP connection', [$fd]);
            $server->send($fd, 'Goodbye');
        }
    }

    这些Socket连接与您的HTTP/WebSocket连接共享相同的Worker进程。因此,如果您想要发送任务,使用SwooleTable,甚至是Laravel组件(如DB、Eloquent等),这完全没问题。同时,您可以通过成员属性swoolePort直接访问Swoole\Server\Port对象。

    public function onReceive(Server $server, $fd, $reactorId, $data)
    {
        $port = $this->swoolePort; // Get the `Swoole\Server\Port` object
    }
    namespace App\Http\Controllers;
    class TestController extends Controller
    {
        public function test()
        {
            /**@var \Swoole\Http\Server|\Swoole\WebSocket\Server $swoole */
            $swoole = app('swoole');
            // $swoole->ports: Traverse all Port objects, https://www.swoole.co.uk/docs/modules/swoole-server/multiple-ports
            $port = $swoole->ports[0]; // Get the `Swoole\Server\Port` object, $port[0] is the port of the main server
            foreach ($port->connections as $fd) { // Traverse all connections
                // $swoole->send($fd, 'Send tcp message');
                // if($swoole->isEstablished($fd)) {
                //     $swoole->push($fd, 'Send websocket message');
                // }
            }
        }
    }
  2. 注册Sockets。

    // Edit `config/laravels.php`
    //...
    'sockets' => [
        [
            'host'     => '127.0.0.1',
            'port'     => 5291,
            'type'     => SWOOLE_SOCK_TCP,// Socket type: SWOOLE_SOCK_TCP/SWOOLE_SOCK_TCP6/SWOOLE_SOCK_UDP/SWOOLE_SOCK_UDP6/SWOOLE_UNIX_DGRAM/SWOOLE_UNIX_STREAM
            'settings' => [// Swoole settings:https://www.swoole.co.uk/docs/modules/swoole-server-methods#swoole_server-addlistener
                'open_eof_check' => true,
                'package_eof'    => "\r\n",
            ],
            'handler'  => \App\Sockets\TestTcpSocket::class,
            'enable'   => true, // whether to enable, default true
        ],
    ],

    关于心跳配置,它只能设置在main server上,不能在Socket上配置,但Socket会继承main server的心跳配置。

    对于TCP套接字,当Swoole的dispatch_mode1/3时,onConnectonClose事件将被阻塞,因此如果您想解除这两个事件的阻塞,请将dispatch_mode设置为2/4/5

    'swoole' => [
        //...
        'dispatch_mode' => 2,
        //...
    ];
  3. 测试。

  • TCP: telnet 127.0.0.1 5291

  • UDP: [Linux] echo "Hello LaravelS" > /dev/udp/127.0.0.1/5292

  1. 其他协议的注册示例。

    • UDP
    'sockets' => [
        [
            'host'     => '0.0.0.0',
            'port'     => 5292,
            'type'     => SWOOLE_SOCK_UDP,
            'settings' => [
                'open_eof_check' => true,
                'package_eof'    => "\r\n",
            ],
            'handler'  => \App\Sockets\TestUdpSocket::class,
        ],
    ],
    • Http
    'sockets' => [
        [
            'host'     => '0.0.0.0',
            'port'     => 5293,
            'type'     => SWOOLE_SOCK_TCP,
            'settings' => [
                'open_http_protocol' => true,
            ],
            'handler'  => \App\Sockets\TestHttp::class,
        ],
    ],
    • WebSocket:主服务器必须打开WebSocket,即设置websocket.enabletrue
    'sockets' => [
        [
            'host'     => '0.0.0.0',
            'port'     => 5294,
            'type'     => SWOOLE_SOCK_TCP,
            'settings' => [
                'open_http_protocol'      => true,
                'open_websocket_protocol' => true,
            ],
            'handler'  => \App\Sockets\TestWebSocket::class,
        ],
    ],

协程

Swoole协程

  • 警告:协程中的代码执行顺序是乱序的。请求级别的数据应该通过协程ID进行隔离。然而,Laravel/Lumen中有许多单例和静态属性,不同请求之间的数据会相互影响,这是不安全的。例如,数据库连接是一个单例,相同的数据库连接共享相同的PDO资源。这在同步阻塞模式下是可以的,但在异步协程模式下不行。每个查询都需要创建不同的连接并维护不同连接的IO状态,这需要一个连接池。

  • 不要启用协程,只有自定义进程可以使用协程。

自定义进程

支持开发者为监控、报告或其他特殊任务创建特殊的工作进程。参阅addProcess

  1. 创建实现CustomProcessInterface的Proccess类。

    namespace App\Processes;
    use App\Tasks\TestTask;
    use Hhxsv5\LaravelS\Swoole\Process\CustomProcessInterface;
    use Hhxsv5\LaravelS\Swoole\Task\Task;
    use Swoole\Coroutine;
    use Swoole\Http\Server;
    use Swoole\Process;
    class TestProcess implements CustomProcessInterface
    {
        /**
         * @var bool Quit tag for Reload updates
         */
        private static $quit = false;
    
        public static function callback(Server $swoole, Process $process)
        {
            // The callback method cannot exit. Once exited, Manager process will automatically create the process 
            while (!self::$quit) {
                \Log::info('Test process: running');
                // sleep(1); // Swoole < 2.1
                Coroutine::sleep(1); // Swoole>=2.1: Coroutine & Runtime will be automatically enabled for callback().
                 // Deliver task in custom process, but NOT support callback finish() of task.
                // Note: Modify task_ipc_mode to 1 or 2 in config/laravels.php, see https://www.swoole.co.uk/docs/modules/swoole-server/configuration
                $ret = Task::deliver(new TestTask('task data'));
                var_dump($ret);
                // The upper layer will catch the exception thrown in the callback and record it in the Swoole log, and then this process will exit. The Manager process will re-create the process after 3 seconds, so developers need to try/catch to catch the exception by themselves to avoid frequent process creation.
                // throw new \Exception('an exception');
            }
        }
        // Requirements: LaravelS >= v3.4.0 & callback() must be async non-blocking program.
        public static function onReload(Server $swoole, Process $process)
        {
            // Stop the process...
            // Then end process
            \Log::info('Test process: reloading');
            self::$quit = true;
            // $process->exit(0); // Force exit process
        }
        // Requirements: LaravelS >= v3.7.4 & callback() must be async non-blocking program.
        public static function onStop(Server $swoole, Process $process)
        {
            // Stop the process...
            // Then end process
            \Log::info('Test process: stopping');
            self::$quit = true;
            // $process->exit(0); // Force exit process
        }
    }
  2. 注册TestProcess。

    // Edit `config/laravels.php`
    // ...
    'processes' => [
        'test' => [ // Key name is process name
            'class'    => \App\Processes\TestProcess::class,
            'redirect' => false, // Whether redirect stdin/stdout, true or false
            'pipe'     => 0,     // The type of pipeline, 0: no pipeline 1: SOCK_STREAM 2: SOCK_DGRAM
            'enable'   => true,  // Whether to enable, default true
            //'num'    => 3   // To create multiple processes of this class, default is 1
            //'queue'    => [ // Enable message queue as inter-process communication, configure empty array means use default parameters
            //    'msg_key'  => 0,    // The key of the message queue. Default: ftok(__FILE__, 1).
            //    'mode'     => 2,    // Communication mode, default is 2, which means contention mode
            //    'capacity' => 8192, // The length of a single message, is limited by the operating system kernel parameters. The default is 8192, and the maximum is 65536
            //],
            //'restart_interval' => 5, // After the process exits abnormally, how many seconds to wait before restarting the process, default 5 seconds
        ],
    ],
  3. 注意:callback() 不能退出。如果退出,管理进程将重新创建进程。

  4. 示例:向自定义进程写入数据。

    // config/laravels.php
    'processes' => [
        'test' => [
            'class'    => \App\Processes\TestProcess::class,
            'redirect' => false,
            'pipe'     => 1,
        ],
    ],
    // app/Processes/TestProcess.php
    public static function callback(Server $swoole, Process $process)
    {
        while ($data = $process->read()) {
            \Log::info('TestProcess: read data', [$data]);
            $process->write('TestProcess: ' . $data);
        }
    }
    // app/Http/Controllers/TestController.php
    public function testProcessWrite()
    {
        /**@var \Swoole\Process $process */
        $process = app('swoole')->customProcesses['test'];
        $process->write('TestController: write data' . time());
        var_dump($process->read());
    }

通用组件

Apollo

LaravelS 将在启动时获取 Apollo 配置并将其写入 .env 文件。同时,LaravelS 将启动自定义进程 apollo 来监控配置,并在配置更改时自动 重新加载

  1. 启用 Apollo:将 --enable-apollo 和 Apollo 参数添加到启动参数中。

    php bin/laravels start --enable-apollo --apollo-server=http://127.0.0.1:8080 --apollo-app-id=LARAVEL-S-TEST
  2. 支持热更新(可选)。

    // Edit `config/laravels.php`
    'processes' => Hhxsv5\LaravelS\Components\Apollo\Process::getDefinition(),
    // When there are other custom process configurations
    'processes' => [
        'test' => [
            'class'    => \App\Processes\TestProcess::class,
            'redirect' => false,
            'pipe'     => 1,
        ],
        // ...
    ] + Hhxsv5\LaravelS\Components\Apollo\Process::getDefinition(),
  3. 可用参数列表。

Prometheus

支持 Prometheus 监控和警报,使用 Grafana 可视化查看监控指标。请参考 Docker Compose 了解 Prometheus 和 Grafana 的环境构建。

  1. 需要扩展 APCu >= 5.0.0,请使用 pecl install apcu 安装它。

  2. 将配置文件 prometheus.php 复制到您的项目 config 目录。根据需要修改配置。

    # Execute commands in the project root directory
    cp vendor/hhxsv5/laravel-s/config/prometheus.php config/

    如果您的项目是 Lumen,您还需要在 bootstrap/app.php 中手动加载配置 $app->configure('prometheus');

  3. 配置全局中间件:Hhxsv5\LaravelS\Components\Prometheus\RequestMiddleware::class。为了尽可能精确地统计请求时间消耗,RequestMiddleware 必须是第一个全局中间件,需要将其放在其他中间件之前。

  4. 注册 ServiceProvider:Hhxsv5\LaravelS\Components\Prometheus\ServiceProvider::class

  5. config/laravels.php 中配置 CollectorProcess 以定期收集 Swoole Worker/Task/Timer 进程的指标。

    'processes' => Hhxsv5\LaravelS\Components\Prometheus\CollectorProcess::getDefinition(),
  6. 创建输出指标的路由。

    use Hhxsv5\LaravelS\Components\Prometheus\Exporter;
    
    Route::get('/actuator/prometheus', function () {
        $result = app(Exporter::class)->render();
        return response($result, 200, ['Content-Type' => Exporter::REDNER_MIME_TYPE]);
    });
  7. 完成 Prometheus 的配置并启动它。

    global:
      scrape_interval: 5s
      scrape_timeout: 5s
      evaluation_interval: 30s
    scrape_configs:
    - job_name: laravel-s-test
      honor_timestamps: true
      metrics_path: /actuator/prometheus
      scheme: http
      follow_redirects: true
      static_configs:
      - targets:
        - 127.0.0.1:5200 # The ip and port of the monitored service
    # Dynamically discovered using one of the supported service-discovery mechanisms
    # https://prometheus.ac.cn/docs/prometheus/latest/configuration/configuration/#scrape_config
    # - job_name: laravels-eureka
    #   honor_timestamps: true
    #   scrape_interval: 5s
    #   metrics_path: /actuator/prometheus
    #   scheme: http
    #   follow_redirects: true
      # eureka_sd_configs:
      # - server: http://127.0.0.1:8080/eureka
      #   follow_redirects: true
      #   refresh_interval: 5s
  8. 启动 Grafana,然后导入 panel json

Grafana Dashboard

其他功能

配置Swoole事件

支持的事件

1. 创建一个事件类以实现相应的接口。

namespace App\Events;
use Hhxsv5\LaravelS\Swoole\Events\ServerStartInterface;
use Swoole\Atomic;
use Swoole\Http\Server;
class ServerStartEvent implements ServerStartInterface
{
    public function __construct()
    {
    }
    public function handle(Server $server)
    {
        // Initialize a global counter (available across processes)
        $server->atomicCount = new Atomic(2233);

        // Invoked in controller: app('swoole')->atomicCount->get();
    }
}
namespace App\Events;
use Hhxsv5\LaravelS\Swoole\Events\WorkerStartInterface;
use Swoole\Http\Server;
class WorkerStartEvent implements WorkerStartInterface
{
    public function __construct()
    {
    }
    public function handle(Server $server, $workerId)
    {
        // Initialize a database connection pool
        // DatabaseConnectionPool::init();
    }
}

2. 配置。

// Edit `config/laravels.php`
'event_handlers' => [
    'ServerStart' => [\App\Events\ServerStartEvent::class], // Trigger events in array order
    'WorkerStart' => [\App\Events\WorkerStartEvent::class],
],

无服务器

阿里云函数计算

函数计算.

1. 修改 bootstrap/app.php 并设置存储目录。因为项目目录是只读的,所以 /tmp 目录只能读写。

$app->useStoragePath(env('APP_STORAGE_PATH', '/tmp/storage'));

2. 创建一个 shell 脚本 laravels_bootstrap 并授予权限。

#!/usr/bin/env bash
set +e

# Create storage-related directories
mkdir -p /tmp/storage/app/public
mkdir -p /tmp/storage/framework/cache
mkdir -p /tmp/storage/framework/sessions
mkdir -p /tmp/storage/framework/testing
mkdir -p /tmp/storage/framework/views
mkdir -p /tmp/storage/logs

# Set the environment variable APP_STORAGE_PATH, please make sure it's the same as APP_STORAGE_PATH in .env
export APP_STORAGE_PATH=/tmp/storage

# Start LaravelS
php bin/laravels start

3. 配置 template.xml

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  laravel-s-demo:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'LaravelS Demo for Serverless'
    fc-laravel-s:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: laravels.handler
        Runtime: custom
        MemorySize: 512
        Timeout: 30
        CodeUri: ./
        InstanceConcurrency: 10
        EnvironmentVariables:
          BOOTSTRAP_FILE: laravels_bootstrap

重要通知

单例问题

  • 在 FPM 模式下,单例实例将在每次请求中实例化和回收,请求开始=>实例化实例=>请求结束=>回收实例。

  • 在 Swoole Server 下,所有单例实例都将保留在内存中,其生命周期不同于 FPM,请求开始=>实例化实例=>请求结束=>不回收单例实例。因此,需要开发者在每个请求中维护单例实例的状态。

  • 常见解决方案

    1. 编写一个 XxxCleaner 类来清理单例对象状态。这个类实现了接口 Hhxsv5\LaravelS\Illuminate\Cleaners\CleanerInterface,然后在 laravels.phpcleaners 中注册它。

    2. 通过 Middleware 重置单例实例的状态。

    3. 重新注册 ServiceProvider,将 XxxServiceProvider 添加到 laravels.php 文件的 register_providers 中。这样可以在每个请求中重新初始化单例实例。参考

清理器

配置清理器.

已知问题

已知问题:包含已知问题和解决方案的包。

调试方法

  • 日志记录;如果您想输出到控制台,可以使用 stderr,Log::channel('stderr')->debug('debug message')。

  • Laravel Dump Server(Laravel 5.7 默认已集成)。

读取请求

通过 Illuminate\Http\Request 对象读取请求,$_ENV 可读,$_SERVER 部分可读,不能使用 $_GET/$_POST/$_FILES/$_COOKIE/$_REQUEST/$_SESSION/$GLOBALS。

public function form(\Illuminate\Http\Request $request)
{
    $name = $request->input('name');
    $all = $request->all();
    $sessionId = $request->cookie('sessionId');
    $photo = $request->file('photo');
    // Call getContent() to get the raw POST body, instead of file_get_contents('php://input')
    $rawContent = $request->getContent();
    //...
}

输出响应

通过 Illuminate\Http\Response 对象进行响应,兼容 echo/vardump()/print_r(),不能使用 dd()/exit()/die()/header()/setcookie()/http_response_code() 函数。

public function json()
{
    return response()->json(['time' => time()])->header('header1', 'value1')->withCookie('c1', 'v1');
}

持久连接

单例连接 将驻留在内存中,建议开启 持久连接 以获得更好的性能。

  1. 数据库连接,它 在断开连接后立即自动重连。
// config/database.php
'connections' => [
    'my_conn' => [
        'driver'    => 'mysql',
        'host'      => env('DB_MY_CONN_HOST', 'localhost'),
        'port'      => env('DB_MY_CONN_PORT', 3306),
        'database'  => env('DB_MY_CONN_DATABASE', 'forge'),
        'username'  => env('DB_MY_CONN_USERNAME', 'forge'),
        'password'  => env('DB_MY_CONN_PASSWORD', ''),
        'charset'   => 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
        'prefix'    => '',
        'strict'    => false,
        'options'   => [
            // Enable persistent connection
            \PDO::ATTR_PERSISTENT => true,
        ],
    ],
],
  1. Redis连接,它 不会 在断开连接后立即自动重连,而是会抛出关于丢失连接的异常,下次重连。您需要确保每次操作Redis之前 SELECT DB 正确。
// config/database.php
'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'), // It is recommended to use phpredis for better performance.
    'default' => [
        'host'       => env('REDIS_HOST', 'localhost'),
        'password'   => env('REDIS_PASSWORD', null),
        'port'       => env('REDIS_PORT', 6379),
        'database'   => 0,
        'persistent' => true, // Enable persistent connection
    ],
],

关于内存泄露

  • 避免使用全局变量。如有必要,请手动清理或重置它们。

  • 无限地向 static/global 变量中添加元素会导致 OOM(内存不足)。

    class Test
    {
        public static $array = [];
        public static $string = '';
    }
    
    // Controller
    public function test(Request $req)
    {
        // Out of Memory
        Test::$array[] = $req->input('param1');
        Test::$string .= $req->input('param2');
    }
  • 内存泄露检测方法

    1. 修改 config/laravels.phpworker_num=1, max_request=1000000,记得测试后恢复设置;

    2. 添加无 route middleware 的路由 /debug-memory-leak 以观察 Worker 进程的内存变化;

    Route::get('/debug-memory-leak', function () {
        global $previous;
        $current = memory_get_usage();
        $stats = [
            'prev_mem' => $previous,
            'curr_mem' => $current,
            'diff_mem' => $current - $previous,
        ];
        $previous = $current;
        return $stats;
    });
    1. 启动 LaravelS 并请求 /debug-memory-leak,直到 diff_mem 小于或等于零;如果 diff_mem 总是大于零,这意味着可能存在 Global MiddlewareLaravel Framework 中的内存泄露;

    2. 完成 步骤3 后,交替 请求业务路由和 /debug-memory-leak(建议使用 ab/wrk 对业务路由进行大量请求),内存的初始增加是正常的。在对业务路由进行大量请求后,如果 diff_mem 总是大于零且 curr_mem 继续增加,则很可能存在内存泄露;如果 curr_mem 总是在一定范围内变化而不继续增加,则内存泄露的可能性较低。

    3. 如果您仍然无法解决问题,max_request 是最后的保障。

Linux内核参数调整

Linux内核参数调整

压力测试

压力测试

替代方案

赞助

许可

MIT