├── routes └── trigger.php ├── src ├── EventSubscriber.php ├── Subscribers │ ├── Trigger.php │ ├── Heartbeat.php │ └── Terminate.php ├── Console │ ├── TerminateCommand.php │ ├── StatusCommand.php │ ├── InstallCommand.php │ ├── StartCommand.php │ └── ListCommand.php ├── Facades │ └── Trigger.php ├── TriggerServiceProvider.php ├── Manager.php └── Trigger.php ├── LICENSE ├── phpstan.neon ├── config └── trigger.php ├── composer.json ├── README-CN.md └── README.md /routes/trigger.php: -------------------------------------------------------------------------------- 1 | on('*', 'heartbeat', function ($event) use ($trigger) { 12 | $trigger->heartbeat($event); 13 | }); 14 | -------------------------------------------------------------------------------- /src/EventSubscriber.php: -------------------------------------------------------------------------------- 1 | trigger->dispatch($event); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Subscribers/Heartbeat.php: -------------------------------------------------------------------------------- 1 | trigger->heartbeat($event); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Subscribers/Terminate.php: -------------------------------------------------------------------------------- 1 | trigger->isReseted()) { 22 | $this->trigger->clearCurrent(); 23 | } 24 | 25 | if ($this->trigger->isTerminated()) { 26 | exit('Terminated'); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 D.J.Hwang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | # Magic behaviour with __get, __set, __call and __callStatic is not exactly static analyser-friendly :) 2 | # Fortunately, You can ingore it by the following config. 3 | # 4 | # vendor/bin/phpstan analyse app --memory-limit 200M -l 0 5 | # 6 | parameters: 7 | reportUnmatchedIgnoredErrors: false 8 | ignoreErrors: 9 | # - '#Unsafe usage of new static\(\)#' 10 | - 11 | message: '#Undefined variable: \$this#' 12 | paths: 13 | - src/collection.php 14 | - src/stringable.php 15 | - 16 | message: '#Calling static::\w+\(\) outside of class scope#' 17 | path: src/collection.php 18 | - 19 | message: '#Using static outside of class scope#' 20 | paths: 21 | - src/collection.php 22 | - src/stringable.php 23 | - 24 | message: '#Call to an undefined static method Hyperf\\Utils\\Arr::hasMacro\(\).#' 25 | path: src/arr.php 26 | - 27 | message: '#Call to an undefined static method Hyperf\\Utils\\Arr::macro\(\).#' 28 | path: src/arr.php 29 | - 30 | message: '#Accessing static::\$\w+Cache outside of class scope.#' 31 | path: src/str.php -------------------------------------------------------------------------------- /config/trigger.php: -------------------------------------------------------------------------------- 1 | 'default', 13 | 14 | 'replications' => [ 15 | 'default' => [ 16 | 'host' => env('TRIGGER_HOST', ''), 17 | 'port' => env('TRIGGER_PORT', 3306), 18 | 'user' => env('TRIGGER_USER', ''), 19 | 'password' => env('TRIGGER_PASSWORD', ''), 20 | 21 | // detect from trigger routers 22 | 'detect' => (bool) env('TRIGGER_DETECT', false), 23 | // or set database and tables 24 | 'databases' => env('TRIGGER_DATABASES', '') ? explode(',', env('TRIGGER_DATABASES')) : [], 25 | 'tables' => env('TRIGGER_TABLES', '') ? explode(',', env('TRIGGER_TABLES')) : [], 26 | 27 | 'heartbeat' => (int) env('TRIGGER_HEARTBEAT', 3), 28 | 'subscribers' => [ 29 | // Huangdijia\Trigger\Subscribers\Heartbeat::class, 30 | ], 31 | 'route' => app()->basePath('routes/trigger.php'), 32 | ], 33 | ], 34 | ]; 35 | -------------------------------------------------------------------------------- /src/Console/TerminateCommand.php: -------------------------------------------------------------------------------- 1 | option('replication')); 37 | 38 | $trigger->terminate(); 39 | $this->info('Broadcasting restart signal.'); 40 | 41 | if ($this->option('reset')) { 42 | $trigger->reset(); 43 | $this->info('Replication position reseted.'); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Console/StatusCommand.php: -------------------------------------------------------------------------------- 1 | option('replication'); 34 | $trigger = Trigger::replication($replication); 35 | $binLogCurrent = $trigger->getCurrent(); 36 | 37 | if (is_null($binLogCurrent)) { 38 | $this->warn('binlog info of ' . $replication . ' is empty.'); 39 | return; 40 | } 41 | 42 | $this->table( 43 | ['Name', 'Value'], 44 | [ 45 | ['BinLogPosition', $binLogCurrent->getBinLogPosition()], 46 | ['BinFileName', $binLogCurrent->getBinFileName()], 47 | // ['Gtid', $binLogCurrent->getGtid()], 48 | // ['MariaDbGtid', $binLogCurrent->getMariaDbGtid()], 49 | ] 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Console/InstallCommand.php: -------------------------------------------------------------------------------- 1 | option('force'); 33 | 34 | collect([ 35 | __DIR__ . '/../../config/trigger.php' => app()->basePath('config/trigger.php'), 36 | __DIR__ . '/../../routes/trigger.php' => app()->basePath('routes/trigger.php'), 37 | ]) 38 | ->reject(function ($target, $source) use ($force) { 39 | if (! $force && file_exists($target)) { 40 | $this->warn("{$target} already exists!"); 41 | return true; 42 | } 43 | 44 | return false; 45 | }) 46 | ->each(function ($target, $source) { 47 | file_put_contents($target, file_get_contents($source)); 48 | $this->info($target . ' installed successfully.'); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Facades/Trigger.php: -------------------------------------------------------------------------------- 1 | configure(); 25 | $this->registerCommands(); 26 | 27 | $this->app->bind('trigger.manager', fn ($app) => new Manager($app->make('config')->get('trigger'))); 28 | } 29 | 30 | public function configure() 31 | { 32 | $this->mergeConfigFrom(__DIR__ . '/../config/trigger.php', 'trigger'); 33 | 34 | if ($this->app->runningInConsole()) { 35 | $this->publishes([__DIR__ . '/../config/trigger.php' => $this->app->basePath('config/trigger.php')]); 36 | $this->publishes([__DIR__ . '/../routes/trigger.php' => $this->app->basePath('routes/trigger.php')]); 37 | } 38 | } 39 | 40 | public function registerCommands() 41 | { 42 | if ($this->app->runningInConsole()) { 43 | $this->commands([ 44 | Console\InstallCommand::class, 45 | Console\StatusCommand::class, 46 | Console\StartCommand::class, 47 | Console\ListCommand::class, 48 | Console\TerminateCommand::class, 49 | ]); 50 | } 51 | } 52 | 53 | public function provides() 54 | { 55 | return [ 56 | 'trigger.manager', 57 | ]; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Manager.php: -------------------------------------------------------------------------------- 1 | replication()->{$method}(...$parameters); 38 | } 39 | 40 | /** 41 | * Create new replication. 42 | */ 43 | public function replication(?string $name = null): Trigger 44 | { 45 | $name ??= $this->config['default'] ?? 'default'; 46 | 47 | if (! isset($this->replications[$name])) { 48 | if (! isset($this->config['replications'][$name])) { 49 | throw new InvalidArgumentException("Config 'trigger.replications.{$name}' is undefined", 1); 50 | } 51 | 52 | // load config 53 | $config = $this->config['replications'][$name]; 54 | 55 | /* @var Trigger[] */ 56 | $this->replications[$name] = new Trigger($name, $config); 57 | 58 | // load routes 59 | $this->replications[$name]->loadRoutes(); 60 | 61 | // auto detect 62 | if ($this->replications[$name]->getConfig('detect')) { 63 | $this->replications[$name]->detectDatabasesAndTables(); 64 | } 65 | } 66 | 67 | return $this->replications[$name]; 68 | } 69 | 70 | /** 71 | * Get all replications. 72 | * 73 | * @return Trigger[] 74 | */ 75 | public function replications(): array 76 | { 77 | return $this->replications; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Console/StartCommand.php: -------------------------------------------------------------------------------- 1 | option('reset') ? false : true; 40 | $trigger = Trigger::replication($this->option('replication')); 41 | 42 | start: 43 | try { 44 | if ($this->option('verbose')) { 45 | $this->info('Configure'); 46 | $this->table( 47 | ['Name', 'Value'], 48 | collect($trigger->getConfig()) 49 | ->merge(['bootat' => date('Y-m-d H:i:s')]) 50 | ->transform(function ($item, $key) { 51 | if (! is_scalar($item)) { 52 | $item = json_encode($item, JSON_THROW_ON_ERROR); 53 | } 54 | 55 | return [ucfirst($key), $item]; 56 | }) 57 | ); 58 | 59 | $binLogCurrent = $trigger->getCurrent(); 60 | 61 | if ($keepUp && ! is_null($binLogCurrent)) { 62 | $this->info('BinLog'); 63 | 64 | $this->table( 65 | ['Name', 'Value'], 66 | [ 67 | ['BinLogPosition', $binLogCurrent->getBinLogPosition()], 68 | ['BinFileName', $binLogCurrent->getBinFileName()], 69 | ] 70 | ); 71 | } 72 | 73 | $this->info('Subscribers'); 74 | $this->table( 75 | ['Subscriber', 'Registered'], 76 | collect($trigger->getSubscribers()) 77 | ->transform(fn ($subscriber) => [$subscriber, '√']) 78 | ); 79 | } 80 | 81 | $trigger->start($keepUp); 82 | } catch (MySQLReplicationException $e) { 83 | $this->error($e->getMessage()); 84 | 85 | // clear replication cache 86 | $trigger->clearCurrent(); 87 | 88 | // retry 89 | $this->info('Retry now'); 90 | sleep(1); 91 | 92 | goto start; 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Console/ListCommand.php: -------------------------------------------------------------------------------- 1 | option('replication'))->getEvents(); 40 | 41 | collect(Arr::dot($actions)) 42 | ->transform(function ($action, $key) use ($actions) { 43 | [$database, $table, $event, $num, $action] = explode('.', $key . '.' . $this->transformActionToString($action)); 44 | 45 | $key = sprintf('%s.%s.%s.%s', $database, $table, $event, $num); 46 | 47 | if (is_numeric($action)) { 48 | $action = Arr::get($actions, $key); 49 | $action = $this->transformActionToString($action); 50 | } 51 | 52 | return [ 53 | 'key' => $key, 54 | 'database' => $database, 55 | 'table' => $table, 56 | 'event' => $event, 57 | 'num' => $num, 58 | 'action' => $action, 59 | ]; 60 | }) 61 | ->when($this->option('database'), fn ($collection, $database) => $collection->where('database', $database)) 62 | ->when($this->option('table'), fn ($collection, $table) => $collection->where('table', $table)) 63 | ->when($this->option('event'), fn ($collection, $event) => $collection->where('event', $event)) 64 | ->unique('key') 65 | ->transform(fn ($item) => [ 66 | $item['database'], 67 | $item['table'], 68 | $item['event'], 69 | $item['num'], 70 | $item['action'], 71 | ]) 72 | ->tap(function ($items) { 73 | $this->table(['Database', 'Table', 'Event', 'Num', 'Action'], $items); 74 | }); 75 | } 76 | 77 | /** 78 | * Transform action to string. 79 | * 80 | * @param array|Closure|object|string $action 81 | * @return string 82 | */ 83 | public function transformActionToString($action) 84 | { 85 | if ($action instanceof Closure) { 86 | $action = Closure::class; 87 | } elseif (is_object($action)) { 88 | $action = $action::class; 89 | } elseif (is_array($action)) { 90 | if (is_object($action[0])) { 91 | $action[0] = $action[0]::class; 92 | } 93 | $action = sprintf('%s@%s', $action[0], $action[1] ?? 'handle'); 94 | } elseif (is_string($action)) { 95 | if (! str_contains($action, '@')) { 96 | if (is_subclass_of($action, ShouldQueue::class)) { 97 | } else { 98 | $action .= '@handle'; 99 | } 100 | } 101 | } 102 | 103 | return $action; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /README-CN.md: -------------------------------------------------------------------------------- 1 | # laravel-trigger 2 | 3 | [![Latest Test](https://github.com/huangdijia/laravel-trigger/workflows/tests/badge.svg)](https://github.com/huangdijia/laravel-trigger/actions) 4 | [![Latest Stable Version](https://poser.pugx.org/huangdijia/laravel-trigger/version.png)](https://packagist.org/packages/huangdijia/laravel-trigger) 5 | [![Total Downloads](https://poser.pugx.org/huangdijia/laravel-trigger/d/total.png)](https://packagist.org/packages/huangdijia/laravel-trigger) 6 | [![GitHub license](https://img.shields.io/github/license/huangdijia/laravel-trigger)](https://github.com/huangdijia/laravel-trigger) 7 | 8 | 像jQuery一样订阅MySQL事件,基于 [php-mysql-replication](https://github.com/krowinski/php-mysql-replication) 9 | 10 | [English Document](README.md) 11 | 12 | ## 快速开始 13 | 14 | 1. 安装包: `composer require "huangdijia/laravel-trigger:^4.0"` 15 | 2. 配置MySQL服务器进行复制 (参见 [MySQL 配置](#mysql-配置)) 16 | 3. 发布配置: `php artisan vendor:publish --provider="Huangdijia\Trigger\TriggerServiceProvider"` 17 | 4. 在 `.env` 文件中配置数据库凭证 18 | 5. 开始监听: `php artisan trigger:start` 19 | 20 | ## 目录 21 | 22 | - [快速开始](#快速开始) 23 | - [MySQL 配置](#mysql-配置) 24 | - [安装](#安装) 25 | - [启动服务](#启动服务) 26 | - [事件订阅](#事件订阅) 27 | - [事件路由](#事件路由) 28 | - [管理命令](#管理命令) 29 | - [鸣谢](#鸣谢) 30 | 31 | ## MySQL 配置 32 | 33 | ### 同步配置 34 | 35 | ~~~bash 36 | [mysqld] 37 | server-id = 1 38 | log_bin = /var/log/mysql/mysql-bin.log 39 | expire_logs_days = 10 40 | max_binlog_size = 100M 41 | binlog_row_image = full 42 | binlog-format = row #Very important if you want to receive write, update and delete row events 43 | ~~~ 44 | 45 | 更多信息请参考: [MySQL 复制事件说明](https://dev.mysql.com/doc/internals/en/event-meanings.html) 46 | 47 | ### 用户权限 48 | 49 | ~~~bash 50 | GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'user'@'host'; 51 | 52 | GRANT SELECT ON `dbName`.* TO 'user'@'host'; 53 | ~~~ 54 | 55 | ## 安装 56 | 57 | ### Laravel 58 | 59 | composer 安装 60 | 61 | ~~~bash 62 | composer require "huangdijia/laravel-trigger:^4.0" 63 | ~~~ 64 | 65 | 发布配置 66 | 67 | ~~~bash 68 | php artisan vendor:publish --provider="Huangdijia\Trigger\TriggerServiceProvider" 69 | ~~~ 70 | 71 | ### Lumen 72 | 73 | composer 安装 74 | 75 | ~~~bash 76 | composer require "huangdijia/laravel-trigger:^4.0" 77 | ~~~ 78 | 79 | 编辑 `bootstrap/app.php`,注册服务及加载配置: 80 | 81 | ~~~php 82 | $app->register(Huangdijia\Trigger\TriggerServiceProvider::class); 83 | ... 84 | $app->configure('trigger'); 85 | ~~~ 86 | 87 | publish config and route 88 | 89 | ~~~bash 90 | php artisan trigger:install [--force] 91 | ~~~ 92 | 93 | ### 配置 94 | 95 | 编辑 `.env`, 配置以下内容: 96 | 97 | ~~~env 98 | TRIGGER_HOST=192.168.xxx.xxx 99 | TRIGGER_PORT=3306 100 | TRIGGER_USER=username 101 | TRIGGER_PASSWORD=password 102 | ... 103 | ~~~ 104 | 105 | ## 启动服务 106 | 107 | ~~~bash 108 | php artisan trigger:start [-R=xxx] 109 | ~~~ 110 | 111 | ## 事件订阅 112 | 113 | 创建自定义事件订阅器,继承 `EventSubscriber` 类: 114 | 115 | ~~~php 116 | on('database.table', 'write', function($event) { /* do something */ }); 152 | ~~~ 153 | 154 | ### 多表多事件 155 | 156 | ~~~php 157 | $trigger->on('database.table1,database.table2', 'write,update', function($event) { /* do something */ }); 158 | ~~~ 159 | 160 | ### 多事件 161 | 162 | ~~~php 163 | $trigger->on('database.table1,database.table2', [ 164 | 'write' => function($event) { /* do something */ }, 165 | 'update' => function($event) { /* do something */ }, 166 | ]); 167 | ~~~ 168 | 169 | ### 路由到操作 170 | 171 | ~~~php 172 | $trigger->on('database.table', 'write', 'App\\Http\\Controllers\\ExampleController'); // call default method 'handle' 173 | $trigger->on('database.table', 'write', 'App\\Http\\Controllers\\ExampleController@write'); 174 | ~~~ 175 | 176 | ### 路由到回调 177 | 178 | ~~~php 179 | class Foo 180 | { 181 | public static function bar($event) 182 | { 183 | dump($event); 184 | } 185 | } 186 | 187 | $trigger->on('database.table', 'write', 'Foo@bar'); // call default method 'handle' 188 | $trigger->on('database.table', 'write', ['Foo', 'bar']); 189 | ~~~ 190 | 191 | ### 路由到任务 192 | 193 | 任务 194 | 195 | ~~~php 196 | namespace App\Jobs; 197 | 198 | class ExampleJob extends Job 199 | { 200 | private $event; 201 | 202 | public function __construct($event) 203 | { 204 | $this->event = $event; 205 | } 206 | 207 | public function handle() 208 | { 209 | dump($this->event); 210 | } 211 | } 212 | 213 | ~~~ 214 | 215 | 路由 216 | 217 | ~~~php 218 | $trigger->on('database.table', 'write', 'App\Jobs\ExampleJob'); // call default method 'dispatch' 219 | $trigger->on('database.table', 'write', 'App\Jobs\ExampleJob@dispatch_now'); 220 | ~~~ 221 | 222 | ## 管理命令 223 | 224 | ### 查看事件列表 225 | 226 | ~~~bash 227 | php artisan trigger:list [-R=xxx] 228 | ~~~ 229 | 230 | ### 终止服务 231 | 232 | ~~~bash 233 | php artisan trigger:terminate [-R=xxx] 234 | ~~~ 235 | 236 | ## 鸣谢 237 | 238 | [JetBrains](https://www.jetbrains.com/?from=huangdijia/laravel-trigger) 239 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-trigger 2 | 3 | [![Latest Test](https://github.com/huangdijia/laravel-trigger/workflows/tests/badge.svg)](https://github.com/huangdijia/laravel-trigger/actions) 4 | [![Latest Stable Version](https://poser.pugx.org/huangdijia/laravel-trigger/version.png)](https://packagist.org/packages/huangdijia/laravel-trigger) 5 | [![Total Downloads](https://poser.pugx.org/huangdijia/laravel-trigger/d/total.png)](https://packagist.org/packages/huangdijia/laravel-trigger) 6 | [![GitHub license](https://img.shields.io/github/license/huangdijia/laravel-trigger)](https://github.com/huangdijia/laravel-trigger) 7 | 8 | Subscribe to MySQL events like jQuery, based on [php-mysql-replication](https://github.com/krowinski/php-mysql-replication) 9 | 10 | [中文说明](README-CN.md) 11 | 12 | ## Quick Start 13 | 14 | 1. Install the package: `composer require "huangdijia/laravel-trigger:^4.0"` 15 | 2. Configure your MySQL server for replication (see [MySQL Server Configuration](#mysql-server-configuration)) 16 | 3. Publish the config: `php artisan vendor:publish --provider="Huangdijia\Trigger\TriggerServiceProvider"` 17 | 4. Configure your `.env` file with database credentials 18 | 5. Start listening: `php artisan trigger:start` 19 | 20 | ## Table of Contents 21 | 22 | - [Quick Start](#quick-start) 23 | - [MySQL Server Configuration](#mysql-server-configuration) 24 | - [Installation](#installation) 25 | - [Usage](#usage) 26 | - [Event Subscribers](#event-subscribers) 27 | - [Event Routes](#event-routes) 28 | - [Management Commands](#management-commands) 29 | - [Thanks to](#thanks-to) 30 | 31 | ## MySQL Server Configuration 32 | 33 | ### Replication Settings 34 | 35 | In your MySQL server configuration file, you need to enable replication: 36 | 37 | ~~~bash 38 | [mysqld] 39 | server-id = 1 40 | log_bin = /var/log/mysql/mysql-bin.log 41 | expire_logs_days = 10 42 | max_binlog_size = 100M 43 | binlog_row_image = full 44 | binlog-format = row #Very important if you want to receive write, update and delete row events 45 | ~~~ 46 | 47 | For more information: [MySQL replication events explained](https://dev.mysql.com/doc/internals/en/event-meanings.html) 48 | 49 | ### User Privileges 50 | 51 | Grant the necessary privileges to your MySQL user: 52 | 53 | ~~~bash 54 | GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'user'@'host'; 55 | 56 | GRANT SELECT ON `dbName`.* TO 'user'@'host'; 57 | ~~~ 58 | 59 | ## Installation 60 | 61 | ### Laravel 62 | 63 | Install via Composer: 64 | 65 | ~~~bash 66 | composer require "huangdijia/laravel-trigger:^4.0" 67 | ~~~ 68 | 69 | Publish the configuration file: 70 | 71 | ~~~bash 72 | php artisan vendor:publish --provider="Huangdijia\Trigger\TriggerServiceProvider" 73 | ~~~ 74 | 75 | ### Lumen 76 | 77 | Install via Composer: 78 | 79 | ~~~bash 80 | composer require "huangdijia/laravel-trigger:^4.0" 81 | ~~~ 82 | 83 | Edit `bootstrap/app.php` and add: 84 | 85 | ~~~php 86 | $app->register(Huangdijia\Trigger\TriggerServiceProvider::class); 87 | ... 88 | $app->configure('trigger'); 89 | ~~~ 90 | 91 | Publish configuration and routes: 92 | 93 | ~~~bash 94 | php artisan trigger:install [--force] 95 | ~~~ 96 | 97 | ### Configure 98 | 99 | Edit your `.env` file and add the following configuration: 100 | 101 | ~~~env 102 | TRIGGER_HOST=192.168.xxx.xxx 103 | TRIGGER_PORT=3306 104 | TRIGGER_USER=username 105 | TRIGGER_PASSWORD=password 106 | ... 107 | ~~~ 108 | 109 | ## Usage 110 | 111 | Start the trigger service to begin listening for MySQL events: 112 | 113 | ~~~bash 114 | php artisan trigger:start [-R=xxx] 115 | ~~~ 116 | 117 | The service will monitor your MySQL binary log and trigger registered event handlers when database changes occur. 118 | 119 | ## Event Subscribers 120 | 121 | Create a custom event subscriber by extending the `EventSubscriber` class: 122 | 123 | ~~~php 124 | on('database.table', 'write', function($event) { /* do something */ }); 160 | ~~~ 161 | 162 | ### Multi-tables and Multi-events 163 | 164 | ~~~php 165 | $trigger->on('database.table1,database.table2', 'write,update', function($event) { /* do something */ }); 166 | ~~~ 167 | 168 | ### Multi-events 169 | 170 | ~~~php 171 | $trigger->on('database.table1,database.table2', [ 172 | 'write' => function($event) { /* do something */ }, 173 | 'update' => function($event) { /* do something */ }, 174 | ]); 175 | ~~~ 176 | 177 | ### Action as Controller 178 | 179 | ~~~php 180 | $trigger->on('database.table', 'write', 'App\\Http\\Controllers\\ExampleController'); // calls default method 'handle' 181 | $trigger->on('database.table', 'write', 'App\\Http\\Controllers\\ExampleController@write'); 182 | ~~~ 183 | 184 | ### Action as Callable 185 | 186 | ~~~php 187 | class Foo 188 | { 189 | public static function bar($event) 190 | { 191 | dump($event); 192 | } 193 | } 194 | 195 | $trigger->on('database.table', 'write', 'Foo@bar'); 196 | $trigger->on('database.table', 'write', ['Foo', 'bar']); 197 | ~~~ 198 | 199 | ### Action as Job 200 | 201 | Define your job class: 202 | 203 | ~~~php 204 | namespace App\Jobs; 205 | 206 | class ExampleJob extends Job 207 | { 208 | private $event; 209 | 210 | public function __construct($event) 211 | { 212 | $this->event = $event; 213 | } 214 | 215 | public function handle() 216 | { 217 | dump($this->event); 218 | } 219 | } 220 | ~~~ 221 | 222 | Register the job route: 223 | 224 | ~~~php 225 | $trigger->on('database.table', 'write', 'App\Jobs\ExampleJob'); // calls default method 'dispatch' 226 | $trigger->on('database.table', 'write', 'App\Jobs\ExampleJob@dispatch_now'); 227 | ~~~ 228 | 229 | ## Management Commands 230 | 231 | ### List Events 232 | 233 | View all registered event listeners: 234 | 235 | ~~~bash 236 | php artisan trigger:list [-R=xxx] 237 | ~~~ 238 | 239 | ### Terminate Service 240 | 241 | Stop the trigger service gracefully: 242 | 243 | ~~~bash 244 | php artisan trigger:terminate [-R=xxx] 245 | ~~~ 246 | 247 | ## Thanks to 248 | 249 | [JetBrains](https://www.jetbrains.com/?from=huangdijia/laravel-trigger) 250 | -------------------------------------------------------------------------------- /src/Trigger.php: -------------------------------------------------------------------------------- 1 | bootTime = time(); 52 | 53 | $this->resetCacheKey = sprintf('triggers:%s:reset', $name); 54 | $this->restartCacheKey = sprintf('triggers:%s:restart', $name); 55 | $this->replicationCacheKey = sprintf('triggers:%s:replication', $name); 56 | 57 | $this->cache = Cache::store(); 58 | } 59 | 60 | /** 61 | * Auto detect databases and tables. 62 | */ 63 | public function detectDatabasesAndTables() 64 | { 65 | $this->config['databases'] = $this->getDatabases(); 66 | $this->config['tables'] = $this->getTables(); 67 | } 68 | 69 | /** 70 | * Get config. 71 | * 72 | * @param mixed $default 73 | * 74 | * @return array|mixed 75 | */ 76 | public function getConfig(string $key = '', $default = null) 77 | { 78 | if ($key) { 79 | return $this->config[$key] ?? $default; 80 | } 81 | 82 | return $this->config; 83 | } 84 | 85 | /** 86 | * Get subscribers. 87 | */ 88 | public function getSubscribers(): array 89 | { 90 | return array_merge( 91 | $this->getConfig('subscribers') ?: [], 92 | $this->defaultSubscribers 93 | ); 94 | } 95 | 96 | /** 97 | * Builder config. 98 | */ 99 | public function configure(bool $keepUp = true): \MySQLReplication\Config\Config 100 | { 101 | return tap(new ConfigBuilder(), function (ConfigBuilder $builder) use ($keepUp) { 102 | $builder->withSlaveId(time()) 103 | ->withHost($this->getConfig('host')) 104 | ->withPort($this->getConfig('port')) 105 | ->withUser($this->getConfig('user')) 106 | ->withPassword($this->getConfig('password')) 107 | ->withDatabasesOnly($this->getConfig('databases')) 108 | ->withTablesOnly($this->getConfig('tables')) 109 | ->withHeartbeatPeriod($this->getConfig('heartbeat') ?: 3); 110 | 111 | if ($keepUp && $binLogCurrent = $this->getCurrent()) { 112 | $builder->withBinLogFileName($binLogCurrent->getBinFileName()) 113 | ->withBinLogPosition($binLogCurrent->getBinLogPosition()); 114 | } 115 | })->build(); 116 | } 117 | 118 | /** 119 | * Load routes of trigger. 120 | */ 121 | public function loadRoutes(): void 122 | { 123 | $routeFile = $this->config['route'] ?? ''; 124 | 125 | if (! $routeFile || ! is_file($routeFile)) { 126 | return; 127 | } 128 | 129 | $trigger = $this; 130 | 131 | require $routeFile; 132 | } 133 | 134 | /** 135 | * Start. 136 | */ 137 | public function start(bool $keepUp = true): void 138 | { 139 | tap(new MySQLReplicationFactory($this->configure($keepUp)), function (MySQLReplicationFactory $binLogStream) { 140 | collect($this->getSubscribers()) 141 | ->reject(fn ($subscriber) => ! is_subclass_of($subscriber, EventSubscriber::class)) 142 | ->unique() 143 | ->each(fn ($subscriber) => $binLogStream->registerSubscriber(new $subscriber($this))); 144 | })->run(); 145 | } 146 | 147 | /** 148 | * Reset. 149 | */ 150 | public function reset(): void 151 | { 152 | $this->cache->forever($this->resetCacheKey, time()); 153 | } 154 | 155 | /** 156 | * IsReseted. 157 | */ 158 | public function isReseted(): bool 159 | { 160 | return $this->cache->get($this->resetCacheKey, 0) > $this->bootTime; 161 | } 162 | 163 | /** 164 | * Terminate. 165 | */ 166 | public function terminate(): void 167 | { 168 | $this->cache->forever($this->restartCacheKey, time()); 169 | } 170 | 171 | /** 172 | * Is terminated. 173 | */ 174 | public function isTerminated(): bool 175 | { 176 | return $this->cache->get($this->restartCacheKey, 0) > $this->bootTime; 177 | } 178 | 179 | /** 180 | * Remember current by heartbeat. 181 | */ 182 | public function heartbeat(EventDTO $event): void 183 | { 184 | $this->rememberCurrent($event->getEventInfo()->binLogCurrent); 185 | } 186 | 187 | /** 188 | * Remember current. 189 | */ 190 | public function rememberCurrent(BinLogCurrent $binLogCurrent): void 191 | { 192 | $this->cache->put($this->replicationCacheKey, serialize($binLogCurrent), Carbon::now()->addHours(1)); 193 | } 194 | 195 | /** 196 | * Get current. 197 | */ 198 | public function getCurrent(): ?BinLogCurrent 199 | { 200 | if (! $cache = $this->cache->get($this->replicationCacheKey)) { 201 | return null; 202 | } 203 | 204 | try { 205 | return unserialize($cache); 206 | } catch (Throwable $e) { 207 | $this->clearCurrent(); 208 | return null; 209 | } 210 | } 211 | 212 | /** 213 | * Clear current. 214 | */ 215 | public function clearCurrent() 216 | { 217 | $this->cache->forget($this->replicationCacheKey); 218 | } 219 | 220 | /** 221 | * Bind events. 222 | */ 223 | public function on(string $table, array|string $eventType, array|callable|Closure|string|null $action = null): void 224 | { 225 | // table as db.tb1,db.tb2,... 226 | if (str_contains($table, ',')) { 227 | collect(explode(',', $table))->transform(fn ($table) => trim($table)) 228 | ->filter() 229 | ->each(fn ($table) => $this->on($table, $eventType, $action)); 230 | return; 231 | } 232 | 233 | // * to *.* 234 | if ($table == '*') { 235 | $table .= '.*'; 236 | } 237 | 238 | // default database 239 | $table = ltrim($table, '.'); 240 | if (! str_contains($table, '.')) { // table to database.table 241 | $table = sprintf('%s.%s', $this->config['databases'][0] ?? '*', $table); 242 | } elseif (substr($table, -1) == '.') { // database. to database.* 243 | $table .= '*'; 244 | } 245 | 246 | // eventType as array 247 | if (is_array($eventType)) { 248 | collect($eventType)->each(fn ($action, $eventType) => $this->on($table, $eventType, $action)); 249 | return; 250 | } 251 | 252 | // eventType as string 253 | if (is_string($eventType)) { 254 | // to lower 255 | $eventType = strtolower($eventType); 256 | 257 | // eventType as write,update,delete... 258 | if (str_contains($eventType, ',')) { 259 | collect(explode(',', $eventType)) 260 | ->transform(fn ($eventType) => trim($eventType)) 261 | ->filter() 262 | ->each(fn ($eventType) => $this->on($table, $eventType, $action)); 263 | return; 264 | } 265 | } 266 | 267 | $key = sprintf('%s.%s', $table, $eventType); 268 | 269 | // append to actions 270 | $actions = Arr::get($this->events, $key) ?: []; 271 | $actions[] = $action; 272 | 273 | // restore to array 274 | Arr::set($this->events, $key, $actions); 275 | } 276 | 277 | /** 278 | * Fire events. 279 | */ 280 | public function dispatch(EventDTO $event): void 281 | { 282 | $events = []; 283 | $eventType = $event->getType(); 284 | 285 | if (is_callable([$event, 'getTableMap'])) { 286 | /** @var \MySQLReplication\Event\DTO\RowsDTO $event */ 287 | $database = $event->getTableMap()->getDatabase(); 288 | $table = $event->getTableMap()->getTable(); 289 | $events[] = sprintf('%s.%s.%s', $database, $table, $eventType); 290 | $events[] = sprintf('%s.%s.%s', $database, $table, '*'); 291 | $events[] = sprintf('%s.%s.%s', $database, '*', $eventType); 292 | } 293 | 294 | $events[] = "*.*.{$eventType}"; 295 | $events[] = '*.*.*'; 296 | 297 | $this->fire($events, $event); 298 | } 299 | 300 | /** 301 | * Fire events. 302 | * 303 | * @param mixed $events 304 | */ 305 | public function fire($events, ?EventDTO $event = null): void 306 | { 307 | collect($events)->each(function ($e) use ($event) { 308 | collect(Arr::get($this->events, $e))->each(fn ($action) => $this->call(...$this->parseAction($action, $event))); 309 | }); 310 | } 311 | 312 | /** 313 | * Get all events. 314 | */ 315 | public function getEvents(): array 316 | { 317 | return $this->events ?: []; 318 | } 319 | 320 | /** 321 | * Get all databases. 322 | */ 323 | public function getDatabases(): array 324 | { 325 | $databases = array_keys($this->getEvents()); 326 | $databases = array_filter($databases, fn ($item) => $item != '*'); 327 | 328 | return array_values($databases); 329 | } 330 | 331 | /** 332 | * Get all tables. 333 | */ 334 | public function getTables(): array 335 | { 336 | $tables = []; 337 | 338 | collect($this->getEvents())->each(function ($listeners, $database) use (&$tables) { 339 | if (is_array($listeners) && ! empty($listeners)) { 340 | $tables = [...$tables, ...array_filter(array_keys($listeners), fn ($item) => $item != '*')]; 341 | } 342 | }); 343 | 344 | return $tables; 345 | } 346 | 347 | /** 348 | * Parse action. 349 | * 350 | * @param mixed $action 351 | * @param mixed $event 352 | * @return array [callable $callback, array $parameters] 353 | */ 354 | private function parseAction($action, $event): array 355 | { 356 | // callable 357 | if (is_callable($action)) { 358 | return [$action, [$event]]; 359 | } 360 | 361 | // parse class from action 362 | $action = explode('@', $action); 363 | $class = $action[0]; 364 | 365 | // class is not exists 366 | if (! class_exists($class)) { 367 | throw new Exception("class '{$class}' is not exists", 1); 368 | } 369 | 370 | // action as job 371 | if (is_subclass_of($class, ShouldQueue::class)) { 372 | $method = $action[1] ?? ''; 373 | $method = in_array($method, ['dispatch', 'dispatch_now']) ? $method : 'dispatch'; 374 | 375 | return [$method, [new $class($event)]]; 376 | } 377 | 378 | // action as common callable 379 | $method = $action[1] ?? 'handle'; 380 | 381 | // check is method callable 382 | if (! is_callable([$class, $method])) { 383 | throw new Exception("{$class}::{$method}() is not callable or not exists", 1); 384 | } 385 | 386 | $reflectionMethod = new ReflectionMethod($class, $method); 387 | 388 | if (! $reflectionMethod->isPublic()) { 389 | throw new ReflectionException("{$class}::{$method}() is not public", 1); 390 | } 391 | 392 | // static method 393 | if ($reflectionMethod->isStatic()) { 394 | return [ 395 | [$class, $method], 396 | [$event], 397 | ]; 398 | } 399 | 400 | return [ 401 | [Container::getInstance()->make($class), $method], 402 | [$event], 403 | ]; 404 | } 405 | 406 | /** 407 | * Execute action. 408 | * 409 | * @return mixed 410 | */ 411 | private function call(callable $action, array $parameters = []) 412 | { 413 | return call_user_func_array($action, $parameters); 414 | } 415 | } 416 | --------------------------------------------------------------------------------