├── .gitignore ├── .gitattributes ├── .phpstorm.meta.php ├── .php-cs-fixer.php ├── src ├── SoarService.php ├── Exec.php ├── ConfigProvider.php ├── Listener │ └── QueryExecListener.php └── Aspect │ └── ResponseAspect.php ├── LICENSE.md ├── publish └── soar.php ├── composer.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /vendor/ 3 | composer.lock -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /tests export-ignore 2 | /.github export-ignore 3 | 4 | -------------------------------------------------------------------------------- /.phpstorm.meta.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 16 | ->setRules(['@PSR12' => true]) 17 | ->setFinder( 18 | PhpCsFixer\Finder::create() 19 | ->exclude('public') 20 | ->exclude('runtime') 21 | ->exclude('vendor') 22 | ->in(__DIR__) 23 | )->setUsingCache(false); -------------------------------------------------------------------------------- /src/SoarService.php: -------------------------------------------------------------------------------- 1 | [ 26 | ResponseAspect::class, 27 | ], 28 | 'dependencies' => [ 29 | ], 30 | 'commands' => [ 31 | ], 32 | 'annotations' => [ 33 | 'scan' => [ 34 | 'paths' => [ 35 | __DIR__, 36 | ], 37 | ], 38 | ], 39 | 'publish' => [ 40 | [ 41 | 'id' => 'config', 42 | 'description' => 'The config for soar.', 43 | 'source' => __DIR__.'/../publish/soar.php', 44 | 'destination' => BASE_PATH.'/config/autoload/soar.php', 45 | ], 46 | ], 47 | ]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /publish/soar.php: -------------------------------------------------------------------------------- 1 | env('SOAR_ENABLED', true), 18 | 'cut_classes' => [ 19 | 'Hyperf\HttpServer\Response::json', 20 | ], 21 | // soar 二进制文件的绝对路径 22 | '-soar-path' => env('SOAR_PATH'), 23 | // 测试环境配置 24 | '-test-dsn' => [ 25 | 'host' => env('SOAR_TEST_DSN_HOST', '127.0.0.1'), 26 | 'port' => env('SOAR_TEST_DSN_PORT', '3306'), 27 | 'dbname' => env('SOAR_TEST_DSN_DBNAME', 'database'), 28 | 'username' => env('SOAR_TEST_DSN_USER', 'root'), 29 | 'password' => env('SOAR_TEST_DSN_PASSWORD', ''), 30 | 'disable' => env('SOAR_TEST_DSN_DISABLE', true), 31 | ], 32 | // 是否开启数据采样开关 33 | '-sampling' => env('SOAR_SAMPLING', true), 34 | // 允许输出删除重复索引的建议 35 | '-allow-drop-index' => env('SOAR_ALLOW_DROP_INDEX', true), 36 | // 是否清理测试环境产生的临时库表 37 | '-drop-test-temporary' => env('SOAR_DROP_TEST_TEMPORARY', true), 38 | // 日志输出文件 39 | '-log-output' => BASE_PATH.'/runtime/logs/soar.log', 40 | /* 41 | * 启发式算法相关配置 42 | */ 43 | '-max-join-table-count' => 5, 44 | '-max-group-by-cols-count' => 5, 45 | '-max-distinct-count' => 5, 46 | '-max-index-cols-count' => 5, 47 | '-max-total-rows' => 9999999, 48 | '-spaghetti-query-length' => 2048, 49 | // '-allow-drop-index' => false, 50 | ]; 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wilbur/hyperf-soar", 3 | "type": "library", 4 | "license": "MIT", 5 | "keywords": [ 6 | "php", 7 | "hyperf", 8 | "soar", 9 | "php-soar", 10 | "hyperf-soar", 11 | "wilbur.yu", 12 | "wilbur" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "wilbur.yu", 17 | "email": "wilbur.yu@creative-life.club", 18 | "homepage": "https://creative-life.club", 19 | "role": "Developer" 20 | } 21 | ], 22 | "description": "SQL optimizer and rewriter for Hyperf component.", 23 | "autoload": { 24 | "psr-4": { 25 | "Wilbur\\HyperfSoar\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | }, 30 | "require": { 31 | "php": ">=8.1", 32 | "ext-json": "*", 33 | "guanguans/soar-php": "^3.0", 34 | "hyperf/config": "^3.0", 35 | "hyperf/database": "^3.0", 36 | "hyperf/di": "^3.0", 37 | "hyperf/event": "^3.0", 38 | "hyperf/http-message": "^3.0" 39 | }, 40 | "require-dev": { 41 | "friendsofphp/php-cs-fixer": "^3.0", 42 | "mockery/mockery": "^1.0", 43 | "phpunit/phpunit": ">=7.0", 44 | "swoole/ide-helper": "^5.0", 45 | "symfony/var-dumper": "^5.1" 46 | }, 47 | "config": { 48 | "sort-packages": true 49 | }, 50 | "scripts": { 51 | "test": "phpunit -c phpunit.xml --colors=always", 52 | "analyse": "phpstan analyse --memory-limit 1024M -l 0 ./src", 53 | "cs-fix": "php-cs-fixer fix $1" 54 | }, 55 | "extra": { 56 | "hyperf": { 57 | "config": "Wilbur\\HyperfSoar\\ConfigProvider" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Listener/QueryExecListener.php: -------------------------------------------------------------------------------- 1 | soarIsEnabled = $config->get('soar.enabled', false); 36 | } 37 | 38 | public function listen(): array 39 | { 40 | return [ 41 | QueryExecuted::class, 42 | ]; 43 | } 44 | 45 | public function process(object $event): void 46 | { 47 | if ($event instanceof QueryExecuted && $this->soarIsEnabled) { 48 | $sql = str_replace('`', '', $event->sql); 49 | if (!Arr::isAssoc($event->bindings)) { 50 | foreach ($event->bindings as $value) { 51 | $sql = Str::replaceFirst('?', "'$value'", $sql); 52 | } 53 | } 54 | $eventSqlList = (array)Context::get(self::SQL_RECORD_KEY); 55 | $eventSqlList[] = $sql; 56 | Context::set(self::SQL_RECORD_KEY, $eventSqlList); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hyperf-soar 2 | 3 | ## 安装 4 | 5 | ~~~bash 6 | # hyperf 1.* 7 | composer require wilbur/hyperf-soar:1.0 --dev 8 | 9 | # hyperf 2.* 10 | composer require wilbur/hyperf-soar --dev 11 | ~~~ 12 | 13 | ## 发布配置文件 14 | [详细配置](https://github.com/XiaoMi/soar/blob/master/doc/config.md) 15 | ~~~ 16 | php bin/hyperf.php vendor:publish wilbur/hyperf-soar 17 | ~~~ 18 | 19 | ## 下载 soar 20 | 21 | ~~~bash 22 | # macOS 23 | * wget https://github.com/XiaoMi/soar/releases/download/0.11.0/soar.darwin-amd64 -O vendor/bin/soar 24 | # linux 25 | * wget https://github.com/XiaoMi/soar/releases/download/0.11.0/soar.linux-amd64 -O vendor/bin/soar 26 | # windows 27 | * wget https://github.com/XiaoMi/soar/releases/download/0.11.0/soar.windows-amd64 -O vendor/bin/soar 28 | # authorization 29 | * chmod +x vendor/bin/soar 30 | ~~~ 31 | 32 | ## env 增加配置 33 | 34 | ~~~env 35 | SOAR_ENABLED=true 36 | SOAR_TEST_DSN_DISABLE=false 37 | SOAR_PATH=your_soar_path 38 | SOAR_TEST_DSN_HOST=127.0.0.1 39 | SOAR_TEST_DSN_PORT=3306 40 | SOAR_TEST_DSN_DBNAME=yourdb 41 | SOAR_TEST_DSN_USER=root 42 | SOAR_TEST_DSN_PASSWORD= 43 | SOAR_REPORT_TYPE=json 44 | ~~~ 45 | 46 | ## 执行方式 47 | > 在 `hyperf start` 后,监听 `QueryExec` 事件, 在全局的响应中插入了监听到的 `sql` 列表对应的优化建议 48 | > 目前只对response()->json()进行了插入 49 | 50 | ## 样例 51 | ```json 52 | { 53 | "code": 200, 54 | "message": "success", 55 | "data": { 56 | "id": 0, 57 | "title": "谢谢参与", 58 | "type": "none", 59 | "value": "0" 60 | }, 61 | "soar": [ 62 | { 63 | "query": "select snapshot from u_awards where user_id = '41' and json_unquote(json_extract(snapshot, '$.\"type\"')) = 'cash'", 64 | "explain": [ 65 | { 66 | "Item": "FUN.001", 67 | "Severity": "L2", 68 | "Summary": "避免在 WHERE 条件中使用函数或其他运算符", 69 | "Content": "虽然在 SQL 中使用函数可以简化很多复杂的查询,但使用了函数的查询无法利用表中已经建立的索引,该查询将会是全表扫描,性能较差。通常建议将列名写在比较运算符左侧,将查询过滤条件放在比较运算符右侧。也不建议在查询比较条件两侧书写多余的括号,这会对阅读产生比较大的困扰。", 70 | "Case": "select id from t where substring(name,1,3)='abc'", 71 | "Position": 0, 72 | "Score": 90 73 | } 74 | ] 75 | }, 76 | { 77 | "query": "select id, v, amount, balance, type, value, image, title from awards where balance > '0' and is_enabled = '1'", 78 | "explain": [ 79 | { 80 | "Item": "OK", 81 | "Severity": "L0", 82 | "Summary": "OK", 83 | "Content": "OK", 84 | "Case": "OK", 85 | "Position": 0, 86 | "Score": 100 87 | } 88 | ] 89 | }, 90 | { 91 | "query": "select id, user_id, value from user_points where user_points.user_id in (41)", 92 | "explain": [ 93 | { 94 | "Item": "OK", 95 | "Severity": "L0", 96 | "Summary": "OK", 97 | "Content": "OK", 98 | "Case": "OK", 99 | "Position": 0, 100 | "Score": 100 101 | } 102 | ] 103 | }, 104 | { 105 | "query": "update u_points set value = value - 20, u_points.updated_at = '2021-01-22 16:05:06' where id = '26'", 106 | "explain": [ 107 | { 108 | "Item": "OK", 109 | "Severity": "L0", 110 | "Summary": "OK", 111 | "Content": "OK", 112 | "Case": "OK", 113 | "Position": 0, 114 | "Score": 100 115 | } 116 | ] 117 | }, 118 | { 119 | "query":"insert into u_awards (award_id, snapshot, client_ip, used_point, expired_at, extra, user_id, updated_at, created_at) values ('0', '{\"id\":0,\"v\":100,\"type\":\"none\",\"image\":\"\",\"value\":\"0\",\"title\":\"\谢\谢\参\与\"}', '127.0.0.1', '20', '2021-04-22 16:05:06', '[]', '41', '2021-01-22 16:05:06', '2021-01-22 16:05:06')", 120 | "explain": [ 121 | { 122 | "Item": "LIT.001", 123 | "Severity": "L2", 124 | "Summary": "用字符类型存储IP地址", 125 | "Content": "字符串字面上看起来像IP地址,但不是 INET_ATON() 的参数,表示数据被存储为字符而不是整数。将IP地址存储为整数更为有效。", 126 | "Case": "insert into tbl (IP,name) values('10.20.306.122','test')", 127 | "Position": 207, 128 | "Score": 90 129 | } 130 | ] 131 | } 132 | ] 133 | } 134 | ``` 135 | 136 | ## 感谢 137 | 138 | * [soar](https://github.com/XiaoMi/soar) 139 | * [soar-php](https://github.com/guanguans/soar-php) -------------------------------------------------------------------------------- /src/Aspect/ResponseAspect.php: -------------------------------------------------------------------------------- 1 | service = $container->get(SoarService::class); 59 | $this->config = $container->get(ConfigInterface::class)->get('soar'); 60 | if (isset($this->config['cut_classes']) && !empty($this->config['cut_classes'])) { 61 | $this->classes = $this->config['cut_classes']; 62 | } 63 | } 64 | 65 | /** 66 | * @throws Exception 67 | * @throws JsonException 68 | */ 69 | public function process(ProceedingJoinPoint $proceedingJoinPoint) 70 | { 71 | $sqlKey = QueryExecListener::SQL_RECORD_KEY; 72 | if (!$this->config['enabled']) { 73 | return $proceedingJoinPoint->process(); 74 | } 75 | $response = $proceedingJoinPoint->process(); 76 | if (!($eventSqlList = Context::get($sqlKey))) { 77 | return $response; 78 | } 79 | 80 | $oldBody = json_decode( 81 | $response->getBody()->getContents(), 82 | true, 83 | 512, 84 | JSON_THROW_ON_ERROR 85 | ); 86 | $explains = $this->parallel($eventSqlList); 87 | 88 | $newBody = json_encode( 89 | array_merge($oldBody, ['soar' => $explains]), 90 | JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE 91 | ); 92 | 93 | return $response->withBody(new SwooleStream($newBody)); 94 | } 95 | 96 | protected function channel(array $eventSqlList): array 97 | { 98 | $explains = []; 99 | $channel = new Channel(); 100 | try { 101 | foreach ($eventSqlList as $sql) { 102 | co(function () use ($sql, $channel) { 103 | $soar = $this->service->score($sql); 104 | $explain = $this->formatting($soar); 105 | $channel->push($explain); 106 | }); 107 | $explains[] = $channel->pop(); 108 | } 109 | } catch (Throwable $throwable) { 110 | $explains = [ 111 | 'code' => $throwable->getCode(), 112 | 'message' => $throwable->getMessage(), 113 | 'file' => $throwable->getFile(), 114 | 'line' => $throwable->getLine(), 115 | ]; 116 | } 117 | 118 | return $explains; 119 | } 120 | 121 | protected function parallel(array $eventSqlList): array 122 | { 123 | $parallel = new Parallel(); 124 | try { 125 | foreach ($eventSqlList as $sql) { 126 | $parallel->add(function () use ($sql) { 127 | $soar = $this->service->jsonScores($sql); 128 | 129 | return $this->formatting($soar); 130 | }); 131 | } 132 | 133 | $explains = $parallel->wait(); 134 | } catch (ParallelExecutionException $throwable) { 135 | $explains = [ 136 | 'throwables' => $throwable->getThrowables(), 137 | 'result' => $throwable->getResults(), 138 | ]; 139 | } 140 | 141 | return $explains; 142 | } 143 | 144 | protected function getScore(?string $severity = null): ?int 145 | { 146 | if (!$severity) { 147 | return null; 148 | } 149 | $fullScore = 100; 150 | $unitScore = 5; 151 | $levels = explode(',', $severity); 152 | $subScore = 0; 153 | foreach ($levels as $level) { 154 | $level = (int)Str::after($level, 'L'); 155 | $subScore += ($level * $unitScore); 156 | } 157 | 158 | return $fullScore - $subScore; 159 | } 160 | 161 | /** 162 | * @throws JsonException 163 | */ 164 | protected function formatting(string $json): array 165 | { 166 | return json_decode($json, true, 512, JSON_THROW_ON_ERROR); 167 | // $results = Arr::flatten($results, 1); 168 | // 169 | // $items = []; 170 | // foreach ($results as $result) { 171 | // $score = $this->getScore($result['Severity']); 172 | // if ($score) { 173 | // $result['Score'] = $score; 174 | // } 175 | // $items[] = $result; 176 | // } 177 | // 178 | // unset($results); 179 | // 180 | // return $items; 181 | } 182 | } 183 | --------------------------------------------------------------------------------