├── LICENSE ├── README-CN.md ├── README.md ├── composer.json ├── config └── trigger.php ├── phpstan.neon ├── routes └── trigger.php └── src ├── Console ├── InstallCommand.php ├── ListCommand.php ├── StartCommand.php ├── StatusCommand.php └── TerminateCommand.php ├── EventSubscriber.php ├── Facades └── Trigger.php ├── Manager.php ├── Subscribers ├── Heartbeat.php ├── Terminate.php └── Trigger.php ├── Trigger.php └── TriggerServiceProvider.php /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 | -------------------------------------------------------------------------------- /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 | 7 | 像jQuery一样订阅MySQL事件, 基于 [php-mysql-replication](https://github.com/krowinski/php-mysql-replication) 8 | 9 | [English Document](README.md) 10 | 11 | ## MySQL 配置 12 | 13 | 同步配置: 14 | 15 | ~~~bash 16 | [mysqld] 17 | server-id = 1 18 | log_bin = /var/log/mysql/mysql-bin.log 19 | expire_logs_days = 10 20 | max_binlog_size = 100M 21 | binlog_row_image = full 22 | binlog-format = row #Very important if you want to receive write, update and delete row events 23 | Mysql replication events explained https://dev.mysql.com/doc/internals/en/event-meanings.html 24 | ~~~ 25 | 26 | 用户权限: 27 | 28 | ~~~bash 29 | GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'user'@'host'; 30 | 31 | GRANT SELECT ON `dbName`.* TO 'user'@'host'; 32 | ~~~ 33 | 34 | ## 安装 35 | 36 | ### Laravel 37 | 38 | composer 安装 39 | 40 | ~~~bash 41 | composer require "huangdijia/laravel-trigger:^4.0" 42 | ~~~ 43 | 44 | 发布配置 45 | 46 | ~~~bash 47 | php artisan vendor:publish --provider="Huangdijia\Trigger\TriggerServiceProvider" 48 | ~~~ 49 | 50 | ### Lumen 51 | 52 | composer 安装 53 | 54 | ~~~bash 55 | composer require "huangdijia/laravel-trigger:^4.0" 56 | ~~~ 57 | 58 | 编辑 `bootstrap/app.php`,注册服务及加载配置: 59 | 60 | ~~~php 61 | $app->register(Huangdijia\Trigger\TriggerServiceProvider::class); 62 | ... 63 | $app->configure('trigger'); 64 | ~~~ 65 | 66 | publish config and route 67 | 68 | ~~~bash 69 | php artisan trigger:install [--force] 70 | ~~~ 71 | 72 | ### 配置 73 | 74 | 编辑 `.env`, 配置以下内容: 75 | 76 | ~~~env 77 | TRIGGER_HOST=192.168.xxx.xxx 78 | TRIGGER_PORT=3306 79 | TRIGGER_USER=username 80 | TRIGGER_PASSWORD=password 81 | ... 82 | ~~~ 83 | 84 | ## 启动服务 85 | 86 | ~~~bash 87 | php artisan trigger:start [-R=xxx] 88 | ~~~ 89 | 90 | ## 事件订阅 91 | 92 | ~~~php 93 | on('database.table', 'write', function($event) { /* do something */ }); 127 | ~~~ 128 | 129 | ### 多表多事件 130 | 131 | ~~~php 132 | $trigger->on('database.table1,database.table2', 'write,update', function($event) { /* do something */ }); 133 | ~~~ 134 | 135 | ### 多事件 136 | 137 | ~~~php 138 | $trigger->on('database.table1,database.table2', [ 139 | 'write' => function($event) { /* do something */ }, 140 | 'update' => function($event) { /* do something */ }, 141 | ]); 142 | ~~~ 143 | 144 | ### 路由到操作 145 | 146 | ~~~php 147 | $trigger->on('database.table', 'write', 'App\\Http\\Controllers\\ExampleController'); // call default method 'handle' 148 | $trigger->on('database.table', 'write', 'App\\Http\\Controllers\\ExampleController@write'); 149 | ~~~ 150 | 151 | ### 路由到回调 152 | 153 | ~~~php 154 | class Foo 155 | { 156 | public static function bar($event) 157 | { 158 | dump($event); 159 | } 160 | } 161 | 162 | $trigger->on('database.table', 'write', 'Foo@bar'); // call default method 'handle' 163 | $trigger->on('database.table', 'write', ['Foo', 'bar']); 164 | ~~~ 165 | 166 | ### 路由到任务 167 | 168 | 任务 169 | 170 | ~~~php 171 | namespace App\Jobs; 172 | 173 | class ExampleJob extends Job 174 | { 175 | private $event; 176 | 177 | public function __construct($event) 178 | { 179 | $this->event = $event; 180 | } 181 | 182 | public function handle() 183 | { 184 | dump($this->event); 185 | } 186 | } 187 | 188 | ~~~ 189 | 190 | 路由 191 | 192 | ~~~php 193 | $trigger->on('database.table', 'write', 'App\Jobs\ExampleJob'); // call default method 'dispatch' 194 | $trigger->on('database.table', 'write', 'App\Jobs\ExampleJob@dispatch_now'); 195 | ~~~ 196 | 197 | ## 查看事件列表 198 | 199 | ~~~bash 200 | php artisan trigger:list [-R=xxx] 201 | ~~~ 202 | 203 | ## 终止服务 204 | 205 | ~~~bash 206 | php artisan trigger:terminate [-R=xxx] 207 | ~~~ 208 | 209 | ## 鸣谢 210 | 211 | [JetBrains](https://www.jetbrains.com/?from=huangdijia/laravel-trigger) 212 | -------------------------------------------------------------------------------- /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 MySQL events like jQuery, base on [php-mysql-replication](https://github.com/krowinski/php-mysql-replication) 9 | 10 | [中文说明](README-CN.md) 11 | 12 | ## MySQL server settings 13 | 14 | In your MySQL server configuration file you need to enable replication: 15 | 16 | ~~~bash 17 | [mysqld] 18 | server-id = 1 19 | log_bin = /var/log/mysql/mysql-bin.log 20 | expire_logs_days = 10 21 | max_binlog_size = 100M 22 | binlog_row_image = full 23 | binlog-format = row #Very important if you want to receive write, update and delete row events 24 | Mysql replication events explained https://dev.mysql.com/doc/internals/en/event-meanings.html 25 | ~~~ 26 | 27 | Mysql user privileges: 28 | 29 | ~~~bash 30 | GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'user'@'host'; 31 | 32 | GRANT SELECT ON `dbName`.* TO 'user'@'host'; 33 | ~~~ 34 | 35 | ## Installation 36 | 37 | ### Laravel 38 | 39 | install 40 | 41 | ~~~bash 42 | composer require "huangdijia/laravel-trigger:^4.0" 43 | ~~~ 44 | 45 | publish config 46 | 47 | ~~~bash 48 | php artisan vendor:publish --provider="Huangdijia\Trigger\TriggerServiceProvider" 49 | ~~~ 50 | 51 | ### Lumen 52 | 53 | install 54 | 55 | ~~~bash 56 | composer require "huangdijia/laravel-trigger:^4.0" 57 | ~~~ 58 | 59 | edit `bootstrap/app.php` add: 60 | 61 | ~~~php 62 | $app->register(Huangdijia\Trigger\TriggerServiceProvider::class); 63 | ... 64 | $app->configure('trigger'); 65 | ~~~ 66 | 67 | publish config and route 68 | 69 | ~~~bash 70 | php artisan trigger:install [--force] 71 | ~~~ 72 | 73 | ### Configure 74 | 75 | edit `.env`, add: 76 | 77 | ~~~env 78 | TRIGGER_HOST=192.168.xxx.xxx 79 | TRIGGER_PORT=3306 80 | TRIGGER_USER=username 81 | TRIGGER_PASSWORD=password 82 | ... 83 | ~~~ 84 | 85 | ## Usage 86 | 87 | ~~~bash 88 | php artisan trigger:start [-R=xxx] 89 | ~~~ 90 | 91 | ## Subscriber 92 | 93 | ~~~php 94 | on('database.table', 'write', function($event) { /* do something */ }); 128 | ~~~ 129 | 130 | ### multi-tables and multi-evnets 131 | 132 | ~~~php 133 | $trigger->on('database.table1,database.table2', 'write,update', function($event) { /* do something */ }); 134 | ~~~ 135 | 136 | ### multi-events 137 | 138 | ~~~php 139 | $trigger->on('database.table1,database.table2', [ 140 | 'write' => function($event) { /* do something */ }, 141 | 'update' => function($event) { /* do something */ }, 142 | ]); 143 | ~~~ 144 | 145 | ### action as controller 146 | 147 | ~~~php 148 | $trigger->on('database.table', 'write', 'App\\Http\\Controllers\\ExampleController'); // call default method 'handle' 149 | $trigger->on('database.table', 'write', 'App\\Http\\Controllers\\ExampleController@write'); 150 | ~~~ 151 | 152 | ### action as callable 153 | 154 | ~~~php 155 | class Foo 156 | { 157 | public static function bar($event) 158 | { 159 | dump($event); 160 | } 161 | } 162 | 163 | $trigger->on('database.table', 'write', 'Foo@bar'); // call default method 'handle' 164 | $trigger->on('database.table', 'write', ['Foo', 'bar']); 165 | ~~~ 166 | 167 | ### action as job 168 | 169 | Job 170 | 171 | ~~~php 172 | namespace App\Jobs; 173 | 174 | class ExampleJob extends Job 175 | { 176 | private $event; 177 | 178 | public function __construct($event) 179 | { 180 | $this->event = $event; 181 | } 182 | 183 | public function handle() 184 | { 185 | dump($this->event); 186 | } 187 | } 188 | 189 | ~~~ 190 | 191 | Route 192 | 193 | ~~~php 194 | $trigger->on('database.table', 'write', 'App\Jobs\ExampleJob'); // call default method 'dispatch' 195 | $trigger->on('database.table', 'write', 'App\Jobs\ExampleJob@dispatch_now'); 196 | ~~~ 197 | 198 | ## Event List 199 | 200 | ~~~bash 201 | php artisan trigger:list [-R=xxx] 202 | ~~~ 203 | 204 | ## Terminate 205 | 206 | ~~~bash 207 | php artisan trigger:terminate [-R=xxx] 208 | ~~~ 209 | 210 | ## Thanks to 211 | 212 | [JetBrains](https://www.jetbrains.com/?from=huangdijia/laravel-trigger) 213 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "huangdijia/laravel-trigger", 3 | "description": "MySQL trigger base on MySQLReplication.", 4 | "type": "library", 5 | "keywords": [ 6 | "laravel", 7 | "mysql", 8 | "trigger" 9 | ], 10 | "homepage": "https://github.com/huangdijia/laravel-trigger", 11 | "license": "MIT", 12 | "authors": [{ 13 | "name": "huangdijia", 14 | "email": "huangdijia@gmail.com" 15 | }], 16 | "require": { 17 | "php": "^8.2", 18 | "illuminate/support": "^11.0 || ^12.0", 19 | "illuminate/console": "^11.0 || ^12.0", 20 | "krowinski/php-mysql-replication": "^8.0" 21 | }, 22 | "require-dev": { 23 | "huangdijia/php-coding-standard": "^2.1", 24 | "orchestra/testbench": "^9.0 || ^10.0", 25 | "phpstan/phpstan": "^2.0" 26 | }, 27 | "autoload": { 28 | "files": [], 29 | "psr-4": { 30 | "Huangdijia\\Trigger\\": "src/" 31 | } 32 | }, 33 | "minimum-stability": "dev", 34 | "extra": { 35 | "laravel": { 36 | "providers": [ 37 | "Huangdijia\\Trigger\\TriggerServiceProvider" 38 | ] 39 | }, 40 | "branch-alias": { 41 | "dev-main": "6.x-dev" 42 | } 43 | }, 44 | "config": { 45 | "allow-plugins": { 46 | "composer/package-versions-deprecated": true, 47 | "ergebnis/composer-normalize": true 48 | }, 49 | "sort-packages": true 50 | }, 51 | "scripts": { 52 | "analyse": "phpstan analyse --memory-limit 300M -l 0 -c phpstan.neon ./src", 53 | "cs-fix": "php-cs-fixer fix $1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /routes/trigger.php: -------------------------------------------------------------------------------- 1 | on('*', 'heartbeat', function ($event) use ($trigger) { 12 | $trigger->heartbeat($event); 13 | }); 14 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/EventSubscriber.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/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 | -------------------------------------------------------------------------------- /src/Subscribers/Trigger.php: -------------------------------------------------------------------------------- 1 | trigger->dispatch($event); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/TriggerServiceProvider.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 | --------------------------------------------------------------------------------