初始化

This commit is contained in:
2026-01-06 13:37:07 +08:00
parent c3435595fe
commit 00d7a381aa
70 changed files with 3913 additions and 1 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
composer.lock
vendor/
.idea/

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

139
README.md
View File

@@ -1,2 +1,139 @@
# think-worker
ThinkPHP Workerman 扩展
===============
交流群981069000 [![点击加群](https://pub.idqqimg.com/wpa/images/group.png "点击加群")](https://qm.qq.com/q/A8YNpzrzC8)
## 安装
```
composer require topthink/think-worker
```
## 说明
> 由于windows下无法在一个文件里启动多个worker所以本扩展不支持windows平台
## 使用方法
### HttpServer
在命令行启动服务端
~~~
php think worker
~~~
然后就可以通过浏览器直接访问当前应用
~~~
http://localhost:8080
~~~
如果需要使用守护进程方式运行建议使用supervisor来管理进程
## 访问静态文件
> 建议使用nginx来支持静态文件访问也可使用路由输出文件内容下面是示例可参照修改
1. 添加静态文件路由:
```php
Route::get('static/:path', function (string $path) {
$filename = public_path() . $path;
return new \think\worker\response\File($filename);
})->pattern(['path' => '.*\.\w+$']);
```
2. 访问路由 `http://localhost/static/文件路径`
## 队列支持
使用方法见 [think-queue](https://github.com/top-think/think-queue)
以下配置代替think-queue里的最后一步:`监听任务并执行`,无需另外起进程执行队列
```php
return [
// ...
'queue' => [
'enable' => true,
//键名是队列名称
'workers' => [
//下面参数是不设置时的默认配置
'default' => [
'delay' => 0,
'sleep' => 3,
'tries' => 0,
'timeout' => 60,
'worker_num' => 1,
],
//使用@符号后面可指定队列使用驱动
'default@connection' => [
//此处可不设置任何参数,使用上面的默认配置
],
],
],
// ...
];
```
### websocket
> 使用路由调度的方式可以让不同路径的websocket服务响应不同的事件
#### 配置
```
worker.websocket = true 时开启
```
#### 路由定义
```php
Route::get('path1','controller/action1');
Route::get('path2','controller/action2');
```
#### 控制器
```php
use \think\worker\Websocket;
use \think\worker\websocket\Frame;
class Controller {
public function action1(){
return (new \think\worker\response\Websocket())
->onOpen(...)
->onMessage(function(Websocket $websocket, Frame $frame){
...
})
->onClose(...);
}
public function action2(){
return (new \think\worker\response\Websocket())
->onOpen(...)
->onMessage(function(Websocket $websocket, Frame $frame){
...
})
->onClose(...);
}
}
```
## 自定义worker
监听`worker.init`事件 注入`Manager`对象调用addWorker方法添加
~~~php
use think\worker\Manager;
use \think\worker\Worker;
//...
public function handle(Manager $manager){
$worker = $manager->addWorker(function(Worker $worker){
//..其他回调或处理
//动态添加监听可参考 https://www.workerman.net/doc/workerman/worker/listen.html
});
}
//...
~~~

55
composer.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "topthink/think-worker",
"description": "workerman extend for thinkphp",
"license": "Apache-2.0",
"authors": [
{
"name": "liu21st",
"email": "liu21st@gmail.com"
}
],
"require": {
"php": ">=8.2",
"workerman/workerman": "~5.0.0",
"topthink/framework": "^8.0",
"revolt/event-loop": "^1.0",
"workerman/redis": "^2.0",
"symfony/finder": ">=4.3"
},
"autoload": {
"psr-4": {
"think\\worker\\": "src"
}
},
"extra": {
"think": {
"services": [
"think\\worker\\Service"
],
"config": {
"worker": "src/config/worker.php"
}
}
},
"require-dev": {
"pestphp/pest": "^3.7",
"guzzlehttp/guzzle": "^7.0",
"topthink/think-queue": "^3.0",
"phpstan/phpstan": "^2.0",
"ratchet/pawl": "^0.4.1"
},
"autoload-dev": {
"psr-4": {
"app\\": "tests/stub/app"
}
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"scripts": {
"analyze": "phpstan --memory-limit=1G",
"test": "pest --colors=always"
}
}

16
phpstan.neon Normal file
View File

@@ -0,0 +1,16 @@
parameters:
level: 5
paths:
- src
- tests
universalObjectCratesClasses:
- PHPUnit\Framework\TestCase
ignoreErrors:
- '#Function root_path not found.#'
- '#Function env not found.#'
- '#Function app_path not found.#'
- '#Function config_path not found.#'
- '#Function public_path not found.#'
- '#Function json not found.#'
- '#Function runtime_path not found.#'
- '#Constant STUB_DIR not found.#'

18
phpunit.xml Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<coverage/>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>

23
src/App.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
namespace think\worker;
class App extends \think\App
{
protected $inConsole = true;
public function setInConsole($inConsole = true)
{
$this->inConsole = $inConsole;
}
public function runningInConsole(): bool
{
return $this->inConsole;
}
public function clearInstances()
{
$this->instances = [];
}
}

22
src/Conduit.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace think\worker;
/**
* @mixin \think\worker\conduit\Driver
*/
class Conduit extends \think\Manager
{
protected $namespace = "\\think\\worker\\conduit\\driver\\";
protected function resolveConfig(string $name)
{
return $this->app->config->get("worker.conduit.{$name}", []);
}
public function getDefaultDriver()
{
return $this->app->config->get('worker.conduit.type', 'socket');
}
}

17
src/Http.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace think\worker;
class Http extends \think\Http
{
protected function loadMiddleware(): void
{
}
protected function loadRoutes(): void
{
}
}

41
src/Ipc.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace think\worker;
class Ipc
{
protected $workerId;
public function __construct(protected Manager $manager, protected Conduit $conduit)
{
}
public function listenMessage()
{
$this->subscribe();
return $this->workerId;
}
public function sendMessage($workerId, $message)
{
if ($workerId === $this->workerId) {
$this->manager->triggerEvent('message', $message);
} else {
$this->publish($workerId, $message);
}
}
public function subscribe()
{
$this->workerId = $this->conduit->inc('ipc:worker');
$this->conduit->subscribe("ipc:message:{$this->workerId}", function ($message) {
$this->manager->triggerEvent('message', unserialize($message));
});
}
public function publish($workerId, $message)
{
$this->conduit->publish("ipc:message:{$workerId}", serialize($message));
}
}

27
src/Manager.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace think\worker;
use think\worker\concerns\InteractsWithConduit;
use think\worker\concerns\InteractsWithHttp;
use think\worker\concerns\InteractsWithQueue;
use think\worker\concerns\InteractsWithServer;
use think\worker\concerns\WithApplication;
use think\worker\concerns\WithContainer;
class Manager
{
use InteractsWithServer,
InteractsWithHttp,
InteractsWithQueue,
InteractsWithConduit,
WithApplication,
WithContainer;
protected function initialize(): void
{
$this->prepareHttp();
$this->prepareQueue();
$this->prepareConduit();
}
}

210
src/Sandbox.php Normal file
View File

@@ -0,0 +1,210 @@
<?php
namespace think\worker;
use Closure;
use InvalidArgumentException;
use ReflectionObject;
use RuntimeException;
use think\Config;
use think\Container;
use think\Event;
use think\exception\Handle;
use think\worker\App;
use think\worker\concerns\ModifyProperty;
use think\worker\contract\ResetterInterface;
use think\worker\resetters\ClearInstances;
use think\worker\resetters\ResetConfig;
use think\worker\resetters\ResetEvent;
use think\worker\resetters\ResetModel;
use think\worker\resetters\ResetPaginator;
use think\worker\resetters\ResetService;
use Throwable;
use WeakMap;
class Sandbox
{
use ModifyProperty;
/** @var App|null */
protected $snapshot;
/** @var WeakMap */
protected $snapshots;
/** @var App */
protected $app;
/** @var Config */
protected $config;
/** @var Event */
protected $event;
/** @var ResetterInterface[] */
protected $resetters = [];
protected $services = [];
public function __construct(App $app)
{
$this->app = $app;
$this->snapshots = new WeakMap();
$this->initialize();
}
protected function initialize()
{
Container::setInstance(function () {
return $this->getSnapshot();
});
$this->setInitialConfig();
$this->setInitialServices();
$this->setInitialEvent();
$this->setInitialResetters();
}
public function run(Closure $callable, ?object $key = null)
{
$this->snapshot = $this->createApp($key);
try {
$this->snapshot->invoke($callable, [$this]);
} catch (Throwable $e) {
$this->snapshot->make(Handle::class)->report($e);
} finally {
if (empty($key)) {
$this->snapshot->clearInstances();
}
$this->snapshot = null;
$this->setInstance($this->app);
}
}
protected function createApp(?object $key = null)
{
if (!empty($key)) {
if (isset($this->snapshots[$key])) {
return $this->snapshots[$key]->app;
}
}
$app = clone $this->app;
$this->setInstance($app);
$this->resetApp($app);
if (!empty($key)) {
$this->snapshots[$key] = new class($app) {
public function __construct(public App $app)
{
}
public function __destruct()
{
$this->app->clearInstances();
}
};
}
return $app;
}
protected function resetApp(App $app)
{
foreach ($this->resetters as $resetter) {
$resetter->handle($app, $this);
}
}
protected function setInstance(App $app)
{
$app->instance('app', $app);
$app->instance(Container::class, $app);
$reflectObject = new ReflectionObject($app);
$reflectProperty = $reflectObject->getProperty('services');
$services = $reflectProperty->getValue($app);
foreach ($services as $service) {
$this->modifyProperty($service, $app);
}
}
/**
* Set initial config.
*/
protected function setInitialConfig()
{
$this->config = clone $this->app->config;
}
protected function setInitialEvent()
{
$this->event = clone $this->app->event;
}
protected function setInitialServices()
{
$services = $this->config->get('worker.services', []);
foreach ($services as $service) {
if (class_exists($service) && !in_array($service, $this->services)) {
$serviceObj = new $service($this->app);
$this->services[$service] = $serviceObj;
}
}
}
/**
* Initialize resetters.
*/
protected function setInitialResetters()
{
$resetters = [
ClearInstances::class,
ResetConfig::class,
ResetEvent::class,
ResetService::class,
ResetModel::class,
ResetPaginator::class,
];
$resetters = array_merge($resetters, $this->config->get('worker.resetters', []));
foreach ($resetters as $resetter) {
$resetterClass = $this->app->make($resetter);
if (!$resetterClass instanceof ResetterInterface) {
throw new RuntimeException("{$resetter} must implement " . ResetterInterface::class);
}
$this->resetters[$resetter] = $resetterClass;
}
}
public function getSnapshot()
{
$snapshot = $this->snapshot;
if ($snapshot instanceof App) {
return $snapshot;
}
throw new InvalidArgumentException('The app object has not been initialized');
}
/**
* Get config snapshot.
*/
public function getConfig()
{
return $this->config;
}
public function getEvent()
{
return $this->event;
}
public function getServices()
{
return $this->services;
}
}

23
src/Service.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
namespace think\worker;
use think\worker\command\Server;
class Service extends \think\Service
{
public function boot()
{
$this->commands([
Server::class,
]);
}
}

33
src/Watcher.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace think\worker;
use think\worker\watcher\Driver;
/**
* @mixin Driver
*/
class Watcher extends \think\Manager
{
protected $namespace = '\\think\\worker\\watcher\\';
protected function getConfig(string $name, $default = null)
{
return $this->app->config->get('worker.hot_update.' . $name, $default);
}
protected function resolveParams($name): array
{
return [
array_filter($this->getConfig('include', []), function ($dir) {
return is_dir($dir);
}),
$this->getConfig('exclude', []),
$this->getConfig('name', []),
];
}
public function getDefaultDriver()
{
return $this->getConfig('type', 'scan');
}
}

121
src/Websocket.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
namespace think\worker;
use RuntimeException;
use think\Event;
use think\worker\websocket\Pusher;
use think\worker\websocket\Room;
use Workerman\Connection\TcpConnection;
/**
* Class Websocket
*/
class Websocket
{
/**
* @var \think\App
*/
protected $app;
/**
* @var Room
*/
protected $room;
/**
* @var string
*/
protected $sender;
/** @var Event */
protected $event;
/** @var TcpConnection|null */
protected $connection;
protected $connected = true;
/**
* Websocket constructor.
*
* @param \think\App $app
* @param Room $room
* @param Event $event
*/
public function __construct(\think\App $app, Room $room, Event $event, ?TcpConnection $connection = null)
{
$this->app = $app;
$this->room = $room;
$this->event = $event;
$this->connection = $connection;
}
/**
* @return Pusher
*/
protected function makePusher()
{
return $this->app->invokeClass(Pusher::class);
}
public function to(...$values)
{
return $this->makePusher()->to(...$values);
}
public function push($data)
{
$this->makePusher()->to($this->getSender())->push($data);
}
public function emit(string $event, ...$data)
{
$this->makePusher()->to($this->getSender())->emit($event, ...$data);
}
public function join(...$rooms): self
{
$this->room->add($this->getSender(), ...$rooms);
return $this;
}
public function leave(...$rooms): self
{
$this->room->delete($this->getSender(), ...$rooms);
return $this;
}
public function setConnected($connected)
{
$this->connected = $connected;
}
public function isEstablished()
{
return $this->connected;
}
public function close()
{
if ($this->connection) {
$this->connection->close();
}
}
public function setSender(string $fd)
{
$this->sender = $fd;
return $this;
}
public function getSender()
{
if (empty($this->sender)) {
throw new RuntimeException('Cannot use websocket as current client before handshake!');
}
return $this->sender;
}
}

19
src/Worker.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace think\worker;
class Worker extends \Workerman\Worker
{
protected static function init(): void
{
static::$pidFile = runtime_path() . 'worker.pid';
static::$statusFile = runtime_path() . 'worker.status';
static::$logFile = runtime_path() . 'worker.log';
parent::init();
}
protected static function parseCommand(): void
{
}
}

35
src/command/Server.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
namespace think\worker\command;
use think\console\Command;
use think\worker\Manager;
/**
* Worker Server 命令行类
*/
class Server extends Command
{
protected $config = [];
public function configure()
{
$this->setName('worker')
->setDescription('Workerman Server for ThinkPHP');
}
public function handle(Manager $manager)
{
$manager->start();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace think\worker\concerns;
use think\worker\Conduit;
trait InteractsWithConduit
{
/** @var Conduit */
protected $conduit;
protected function prepareConduit()
{
$this->conduit = $this->container->make(Conduit::class);
$this->conduit->prepare();
$this->onEvent('workerStart', function () {
$this->app->instance(Conduit::class, $this->conduit);
});
}
}

View File

@@ -0,0 +1,321 @@
<?php
namespace think\worker\concerns;
use think\App;
use think\Cookie;
use think\Event;
use think\exception\Handle;
use think\helper\Str;
use think\Http;
use think\response\View;
use think\worker\App as WorkerApp;
use think\worker\Http as WorkerHttp;
use think\worker\response\File as FileResponse;
use think\worker\response\Iterator as IteratorResponse;
use think\worker\websocket\Frame;
use think\worker\Worker;
use Throwable;
use Workerman\Connection\TcpConnection;
use Workerman\Protocols\Http\Chunk;
use Workerman\Protocols\Http\Request as WorkerRequest;
use Workerman\Protocols\Http\Response;
use function substr;
/**
* Trait InteractsWithHttp
* @property App $app
* @property App $container
*/
trait InteractsWithHttp
{
use ModifyProperty, InteractsWithWebsocket;
protected $wsEnable = false;
protected function prepareHttp()
{
if ($this->getConfig('http.enable', true)) {
$this->wsEnable = $this->getConfig('websocket.enable', false);
if ($this->wsEnable) {
$this->prepareWebsocket();
}
$workerNum = $this->getConfig('http.worker_num', 4);
$this->addWorker([$this, 'createHttpServer'], 'http server', $workerNum);
}
}
public function createHttpServer()
{
$this->preloadHttp();
$host = $this->getConfig('http.host');
$port = $this->getConfig('http.port');
$options = $this->getConfig('http.options', []);
$server = new Worker("\\think\\worker\\protocols\\FlexHttp://{$host}:{$port}", $options);
$server->reusePort = true;
$server->onMessage = function (TcpConnection $connection, $data) {
if ($data instanceof WorkerRequest) {
if ($this->wsEnable && $this->isWebsocketRequest($data)) {
$this->onHandShake($connection, $data);
} else {
$this->onRequest($connection, $data);
}
} elseif ($data instanceof Frame) {
$this->onMessage($connection, $data);
}
};
$server->onClose = function (TcpConnection $connection) {
if ($this->wsEnable) {
$this->onClose($connection);
}
};
$server->listen();
}
protected function preloadHttp()
{
$http = $this->app->http;
$this->app->invokeMethod([$http, 'loadMiddleware'], [], true);
if ($this->app->config->get('app.with_route', true)) {
$this->app->invokeMethod([$http, 'loadRoutes'], [], true);
$route = clone $this->app->route;
unset($this->app->route);
$this->app->resolving(WorkerHttp::class, function ($http, App $app) use ($route) {
$newRoute = clone $route;
$this->modifyProperty($newRoute, $app);
$app->instance('route', $newRoute);
});
}
$middleware = clone $this->app->middleware;
unset($this->app->middleware);
$this->app->resolving(WorkerHttp::class, function ($http, App $app) use ($middleware) {
$newMiddleware = clone $middleware;
$this->modifyProperty($newMiddleware, $app);
$app->instance('middleware', $newMiddleware);
});
unset($this->app->http);
$this->app->bind(Http::class, WorkerHttp::class);
}
public function onRequest(TcpConnection $connection, WorkerRequest $wkRequest)
{
$this->runInSandbox(function (Http $http, Event $event, WorkerApp $app) use ($connection, $wkRequest) {
$app->setInConsole(false);
$request = $this->prepareRequest($wkRequest);
try {
$response = $this->handleRequest($http, $request);
$this->prepareResponse($response);
} catch (Throwable $e) {
$handle = $app->make(Handle::class);
$handle->report($e);
$response = $handle->render($request, $e);
}
$this->sendResponse($connection, $request, $response, $app->cookie);
//关闭连接
$connection->close();
$http->end($response);
});
}
protected function handleRequest(Http $http, $request)
{
$level = ob_get_level();
ob_start();
$response = $http->run($request);
if (ob_get_length() > 0) {
$content = $response->getContent();
$response->content(ob_get_contents() . $content);
}
while (ob_get_level() > $level) {
ob_end_clean();
}
return $response;
}
protected function prepareRequest(WorkerRequest $wkRequest)
{
$header = $wkRequest->header();
$server = [];
foreach ($header as $key => $value) {
$server['http_' . str_replace('-', '_', $key)] = $value;
}
// 重新实例化请求对象 处理请求数据
/** @var \think\Request $request */
$request = $this->app->make('request', [], true);;
$queryString = $wkRequest->queryString();
return $request
->setMethod($wkRequest->method())
->withHeader($header)
->withServer($server)
->withGet($wkRequest->get())
->withPost($wkRequest->post())
->withCookie($wkRequest->cookie())
->withFiles($wkRequest->file())
->withInput($wkRequest->rawBody())
->setBaseUrl($wkRequest->uri())
->setUrl($wkRequest->uri() . (!empty($queryString) ? '?' . $queryString : ''))
->setPathinfo(ltrim($wkRequest->path(), '/'));
}
protected function prepareResponse(\think\Response $response)
{
switch (true) {
case $response instanceof View:
$response->getContent();
break;
}
}
protected function sendResponse(TcpConnection $connection, \think\Request $request, \think\Response $response, Cookie $cookie)
{
switch (true) {
case $response instanceof IteratorResponse:
$this->sendIterator($connection, $response, $cookie);
break;
case $response instanceof FileResponse:
$this->sendFile($connection, $request, $response, $cookie);
break;
default:
$this->sendContent($connection, $response, $cookie);
}
}
protected function sendIterator(TcpConnection $connection, IteratorResponse $response, Cookie $cookie)
{
$wkResponse = $this->createResponse($response, $cookie);
$connection->send($wkResponse);
foreach ($response as $content) {
$connection->send($content, true);
}
}
protected function sendFile(TcpConnection $connection, \think\Request $request, FileResponse $response, Cookie $cookie)
{
$ifNoneMatch = $request->header('If-None-Match');
$ifRange = $request->header('If-Range');
$code = $response->getCode();
$file = $response->getFile();
$eTag = $response->getHeader('ETag');
$lastModified = $response->getHeader('Last-Modified');
$fileSize = $file->getSize();
$offset = 0;
$length = -1;
if ($ifNoneMatch == $eTag) {
$code = 304;
} elseif (!$ifRange || $ifRange === $eTag || $ifRange === $lastModified) {
$range = $request->header('Range', '');
if (Str::startsWith($range, 'bytes=')) {
[$start, $end] = explode('-', substr($range, 6), 2) + [0];
$end = ('' === $end) ? $fileSize - 1 : (int) $end;
if ('' === $start) {
$start = $fileSize - $end;
$end = $fileSize - 1;
} else {
$start = (int) $start;
}
if ($start <= $end) {
$end = min($end, $fileSize - 1);
if ($start < 0 || $start > $end) {
$code = 416;
$response->header([
'Content-Range' => sprintf('bytes */%s', $fileSize),
]);
} elseif ($end - $start < $fileSize - 1) {
$length = $end < $fileSize ? $end - $start + 1 : -1;
$offset = $start;
$code = 206;
$response->header([
'Content-Range' => sprintf('bytes %s-%s/%s', $start, $end, $fileSize),
'Content-Length' => $end - $start + 1,
]);
}
}
}
}
$wkResponse = $this->createResponse($response, $cookie);
if ($code >= 200 && $code < 300 && $length !== 0) {
$wkResponse->withFile($file->getPathname(), $offset, $length);
}
$connection->send($wkResponse);
}
protected function sendContent(TcpConnection $connection, \think\Response $response, Cookie $cookie)
{
$response->header(['Transfer-Encoding' => 'chunked']);
$wkResponse = $this->createResponse($response, $cookie);
$connection->send($wkResponse);
$content = $response->getContent();
if ($content) {
$contentSize = strlen($content);
$chunkSize = 8192;
if ($contentSize > $chunkSize) {
$sendSize = 0;
do {
if (!$connection->send(new Chunk(substr($content, $sendSize, $chunkSize)))) {
break;
}
} while (($sendSize += $chunkSize) < $contentSize);
} else {
$connection->send(new Chunk($content));
}
}
$connection->send(new Chunk(''));
}
protected function createResponse(\think\Response $response, Cookie $cookie, $body = '')
{
$code = $response->getCode();
$header = $response->getHeader();
$wkResponse = new Response($code, $header, $body);
foreach ($cookie->getCookie() as $name => $val) {
[$value, $expire, $option] = $val;
$wkResponse->cookie($name, $value, $expire, $option['path'], $option['domain'], (bool) $option['secure'], (bool) $option['httponly'], $option['samesite']);
}
return $wkResponse;
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace think\worker\concerns;
use think\helper\Arr;
use think\queue\event\JobFailed;
use think\queue\Worker;
use Workerman\Timer;
trait InteractsWithQueue
{
protected function createQueueWorkers()
{
$workers = $this->getConfig('queue.workers', []);
foreach ($workers as $queue => $options) {
if (strpos($queue, '@') !== false) {
[$queue, $connection] = explode('@', $queue);
} else {
$connection = null;
}
$workerNum = Arr::get($options, 'worker_num', 1);
$this->addWorker(function () use ($options, $connection, $queue) {
$delay = Arr::get($options, 'delay', 0);
$sleep = Arr::get($options, 'sleep', 3);
$tries = Arr::get($options, 'tries', 0);
$timeout = Arr::get($options, 'timeout', 60);
$qWorker = $this->app->make(Worker::class);
if ($this->supportsAsyncSignals()) {
pcntl_signal(SIGALRM, function () {
\think\worker\Worker::stopAll();
});
pcntl_alarm($timeout);
}
$this->createRunTimer(function () use ($connection, $queue, $delay, $sleep, $tries, $qWorker) {
$this->runInSandbox(function () use ($connection, $queue, $delay, $sleep, $tries, $qWorker) {
$qWorker->runNextJob($connection, $queue, $delay, $sleep, $tries);
});
if ($this->supportsAsyncSignals()) {
pcntl_alarm(0);
}
});
}, "queue [$queue]", $workerNum);
}
}
protected function supportsAsyncSignals()
{
return extension_loaded('pcntl');
}
protected function createRunTimer($func)
{
Timer::add(0.1, function () use ($func) {
$func();
$this->createRunTimer($func);
}, [], false);
}
protected function prepareQueue()
{
if ($this->getConfig('queue.enable', false)) {
$this->listenForEvents();
$this->createQueueWorkers();
}
}
/**
* 注册事件
*/
protected function listenForEvents()
{
$this->container->event->listen(JobFailed::class, function (JobFailed $event) {
$this->logFailedJob($event);
});
}
/**
* 记录失败任务
* @param JobFailed $event
*/
protected function logFailedJob(JobFailed $event)
{
$this->container['queue.failer']->log(
$event->connection,
$event->job->getQueue(),
$event->job->getRawBody(),
$event->exception
);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace think\worker\concerns;
use think\App;
use think\worker\Ipc;
use think\worker\Watcher;
use think\worker\Worker;
use Workerman\Redis\Client;
/**
* Trait InteractsWithServer
* @property App $container
*/
trait InteractsWithServer
{
/** @var Ipc */
protected $ipc;
protected $workerId = null;
protected $stopping = false;
public function addWorker(callable $func, $name = 'none', int $count = 1): Worker
{
$worker = new Worker();
$worker->name = $name;
$worker->count = $count;
$worker->onWorkerStart = function (Worker $worker) use ($func) {
$this->clearCache();
$this->prepareApplication();
$this->conduit->connect();
$this->workerId = $this->ipc->listenMessage();
$this->triggerEvent('workerStart', $worker);
$func($worker);
};
$worker->onWorkerReload = function () {
$this->stopping = true;
};
return $worker;
}
public function getWorkerId()
{
return $this->workerId;
}
public function isStopping()
{
return $this->stopping;
}
/**
* 启动服务
*/
public function start(): void
{
$this->initialize();
$this->prepareIpc();
$this->triggerEvent('init');
//热更新
if ($this->getConfig('hot_update.enable', false)) {
$this->addHotUpdateWorker();
}
Worker::runAll();
}
protected function prepareIpc()
{
$this->ipc = $this->container->make(Ipc::class);
}
public function sendMessage($workerId, $message)
{
$this->ipc->sendMessage($workerId, $message);
}
/**
* 热更新
*/
protected function addHotUpdateWorker()
{
$worker = new Worker();
$worker->name = 'hot update';
$worker->reloadable = false;
$worker->onWorkerStart = function () {
$watcher = $this->container->make(Watcher::class);
$watcher->watch(function () {
posix_kill(posix_getppid(), SIGUSR1);
});
};
}
/**
* 清除apc、op缓存
*/
protected function clearCache()
{
if (extension_loaded('apc')) {
apc_clear_cache();
}
if (extension_loaded('Zend OPcache')) {
opcache_reset();
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace think\worker\concerns;
use think\App;
use think\Event;
use think\helper\Arr;
use think\Http;
use think\worker\message\PushMessage;
use think\worker\Websocket;
use think\worker\contract\websocket\HandlerInterface;
use think\worker\websocket\Frame;
use think\worker\websocket\Handler;
use Throwable;
use Workerman\Connection\TcpConnection;
use Workerman\Protocols\Http\Request as WorkerRequest;
use Workerman\Protocols\Http\Response;
trait InteractsWithWebsocket
{
protected $messageSender = [];
protected function prepareWebsocket()
{
$this->onEvent('workerStart', function () {
$handlerClass = $this->getConfig('websocket.handler', Handler::class);
$this->app->bind(HandlerInterface::class, $handlerClass);
$this->onEvent('message', function ($message) {
if ($message instanceof PushMessage) {
if (isset($this->messageSender[$message->to])) {
$this->messageSender[$message->to]($message->data);
}
}
});
});
}
public function onHandShake(TcpConnection $connection, WorkerRequest $wkRequest)
{
$this->runInSandbox(function (App $app, Http $http, Event $event) use ($connection, $wkRequest) {
$request = $this->prepareRequest($wkRequest);
$response = $http->run($request);
if (!$response instanceof \think\worker\response\Websocket) {
$connection->close();
return;
}
$event->subscribe([$response]);
$this->upgrade($connection, $wkRequest);
$websocket = $app->make(Websocket::class, [$connection], true);
$app->instance(Websocket::class, $websocket);
$id = "{$this->workerId}.{$connection->id}";
$websocket->setSender($id);
$websocket->join($id);
$handler = $app->make(HandlerInterface::class);
$this->messageSender[$connection->id] = function ($data) use ($connection, $handler) {
$connection->send($handler->encodeMessage($data));
};
try {
$handler->onOpen($request);
} catch (Throwable $e) {
$this->logServerError($e);
}
}, $connection);
}
public function onMessage(TcpConnection $connection, Frame $frame)
{
$this->runInSandbox(function (App $app) use ($frame) {
$handler = $app->make(HandlerInterface::class);
try {
$handler->onMessage($frame);
} catch (Throwable $e) {
$this->logServerError($e);
}
}, $connection);
}
public function onClose(TcpConnection $connection)
{
$this->runInSandbox(function (App $app) use ($connection) {
if ($app->exists(Websocket::class)) {
$websocket = $app->make(Websocket::class);
$handler = $app->make(HandlerInterface::class);
try {
$handler->onClose();
} catch (Throwable $e) {
$this->logServerError($e);
}
// leave all rooms
$websocket->leave();
unset($this->messageSender[$connection->id]);
$websocket->setConnected(false);
}
}, $connection);
}
protected function isWebsocketRequest(WorkerRequest $request)
{
$header = $request->header();
return strcasecmp(Arr::get($header, 'connection', ''), 'upgrade') === 0 &&
strcasecmp(Arr::get($header, 'upgrade', ''), 'websocket') === 0;
}
protected function upgrade(TcpConnection $connection, WorkerRequest $request)
{
$key = $request->header('Sec-WebSocket-Key');
$headers = [
'Upgrade' => 'websocket',
'Sec-WebSocket-Version' => '13',
'Connection' => 'Upgrade',
'Sec-WebSocket-Accept' => base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true)),
];
if ($protocol = $request->header('Sec-Websocket-Protocol')) {
$headers['Sec-WebSocket-Protocol'] = $protocol;
}
$response = new Response(101, $headers);
$connection->send($response);
// Websocket data buffer.
$connection->context->websocketDataBuffer = '';
// Current websocket frame length.
$connection->context->websocketCurrentFrameLength = 0;
// Current websocket frame data.
$connection->context->websocketCurrentFrameBuffer = '';
$connection->context->websocketHandshake = true;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace think\worker\concerns;
use ReflectionObject;
trait ModifyProperty
{
protected function modifyProperty($object, $value, $property = 'app')
{
$reflectObject = new ReflectionObject($object);
if ($reflectObject->hasProperty($property)) {
$reflectProperty = $reflectObject->getProperty($property);
$reflectProperty->setAccessible(true);
$reflectProperty->setValue($object, $value);
}
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace think\worker\concerns;
use Closure;
use think\App;
use think\worker\App as WorkerApp;
use think\worker\Manager;
use think\worker\Sandbox;
use Throwable;
/**
* Trait WithApplication
* @property App $container
*/
trait WithApplication
{
/**
* @var WorkerApp
*/
protected $app;
protected function prepareApplication()
{
if (!$this->app instanceof WorkerApp) {
$this->app = new WorkerApp($this->container->getRootPath());
$this->app->bind(WorkerApp::class, App::class);
$this->app->bind(Manager::class, $this);
$this->app->initialize();
$this->app->instance('request', $this->container->request);
$this->prepareConcretes();
}
}
/**
* 预加载
*/
protected function prepareConcretes()
{
$defaultConcretes = ['db', 'cache', 'event'];
$concretes = array_merge($defaultConcretes, $this->getConfig('concretes', []));
foreach ($concretes as $concrete) {
$this->app->make($concrete);
}
}
public function getApp()
{
return $this->app;
}
/**
* 获取沙箱
* @return Sandbox
*/
protected function getSandbox()
{
return $this->app->make(Sandbox::class);
}
/**
* 在沙箱中执行
* @param Closure $callable
*/
public function runInSandbox(Closure $callable, ?object $key = null)
{
try {
$this->getSandbox()->run($callable, $key);
} catch (Throwable $e) {
$this->logServerError($e);
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace think\worker\concerns;
use think\App;
use think\exception\Handle;
use Throwable;
trait WithContainer
{
/**
* @var App
*/
protected $container;
/**
* Manager constructor.
* @param App $container
*/
public function __construct(App $container)
{
$this->container = $container;
}
protected function getContainer()
{
return $this->container;
}
/**
* 获取配置
* @param string $name
* @param mixed $default
* @return mixed
*/
public function getConfig(string $name, $default = null)
{
return $this->container->config->get("worker.{$name}", $default);
}
/**
* 触发事件
* @param string $event
* @param mixed $params
*/
public function triggerEvent(string $event, $params = null): void
{
$this->container->event->trigger("worker.{$event}", $params);
}
/**
* 监听事件
* @param string $event
* @param $listener
* @param bool $first
*/
public function onEvent(string $event, $listener, bool $first = false): void
{
$this->container->event->listen("worker.{$event}", $listener, $first);
}
/**
* Log server error.
*
* @param Throwable $e
*/
public function logServerError(Throwable $e)
{
/** @var Handle $handle */
$handle = $this->container->make(Handle::class);
$handle->report($e);
}
}

26
src/conduit/Driver.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace think\worker\conduit;
abstract class Driver
{
abstract public function prepare();
abstract public function connect();
abstract public function get(string $name);
abstract public function set(string $name, $value);
abstract public function inc(string $name, int $step = 1);
abstract public function sAdd(string $name, ...$value);
abstract public function sRem(string $name, $value);
abstract public function sMembers(string $name);
abstract public function publish(string $name, $value);
abstract public function subscribe(string $name, $callback);
}

View File

@@ -0,0 +1,186 @@
<?php
namespace think\worker\conduit\driver;
use Exception;
use Revolt\EventLoop;
use Revolt\EventLoop\Suspension;
use think\worker\conduit\Driver;
use think\worker\conduit\driver\socket\Command;
use think\worker\conduit\driver\socket\Event;
use think\worker\conduit\driver\socket\Result;
use think\worker\conduit\driver\socket\Server;
use think\worker\Manager;
use Workerman\Connection\AsyncTcpConnection;
use Workerman\Protocols\Frame;
use Workerman\Timer;
class Socket extends Driver
{
protected $id = 0;
protected $domain;
/** @var AsyncTcpConnection|null */
protected $connection = null;
protected $reconnectTimer;
protected $pingInterval = 55;
/** @var array<int, array{0: Suspension, 1: int}> */
protected $suspensions = [];
protected $events = [];
public function __construct(protected Manager $manager)
{
$filename = runtime_path() . 'conduit.sock';
@unlink($filename);
$this->domain = "unix://{$filename}";
}
public function prepare()
{
//启动服务端
Server::run($this->domain);
}
public function connect()
{
$suspension = EventLoop::getSuspension();
$this->connection = $this->createConnection($suspension);
$suspension->suspend();
Timer::add($this->pingInterval, function () {
if ($this->connection) {
$this->connection->send('');
}
});
Timer::add(1, function () {
//检查是否超时
foreach ($this->suspensions as $id => $suspension) {
if (time() - $suspension[1] > 10) {
$suspension[0]->throw(new Exception('conduit connection is timeout'));
unset($this->suspensions[$id]);
}
}
});
}
public function get(string $name)
{
return $this->sendAndRecv(Command::create('get', $name));
}
public function set(string $name, $value)
{
$this->send(Command::create('set', $name, $value));
}
public function inc(string $name, int $step = 1)
{
return $this->sendAndRecv(Command::create('inc', $name, $step));
}
public function sAdd(string $name, ...$value)
{
$this->send(Command::create('sAdd', $name, $value));
}
public function sRem(string $name, $value)
{
$this->send(Command::create('sRem', $name, $value));
}
public function sMembers(string $name)
{
return $this->sendAndRecv(Command::create('sMembers', $name));
}
public function publish(string $name, $value)
{
$this->send(Command::create('publish', $name, $value));
}
public function subscribe(string $name, $callback)
{
$this->send(Command::create('subscribe', $name));
$this->events[$name] = $callback;
}
protected function sendAndRecv(Command $command)
{
$suspension = EventLoop::getSuspension();
$id = $this->id++;
$command->id = $id;
$this->suspensions[$id] = [$suspension, time()];
$this->send($command);
return $suspension->suspend();
}
protected function send(Command $command)
{
if (!$this->connection) {
throw new Exception('conduit connection is disconnected');
}
$this->connection->send(serialize($command));
}
protected function createConnection(?Suspension $suspension = null)
{
$connection = new AsyncTcpConnection($this->domain);
$connection->protocol = Frame::class;
$connection->onConnect = function () use ($suspension) {
$this->clearTimer();
if ($suspension) {
$suspension->resume();
}
//补订阅
foreach ($this->events as $name => $callback) {
$this->send(Command::create('subscribe', $name));
}
};
$connection->onMessage = function ($connection, $buffer) {
/** @var Result|Event $result */
$result = unserialize($buffer);
if ($result instanceof Event) {
if (isset($this->events[$result->name])) {
$this->events[$result->name]($result->data);
}
} elseif (isset($result->id) && isset($this->suspensions[$result->id])) {
[$suspension] = $this->suspensions[$result->id];
$suspension->resume($result->data);
unset($this->suspensions[$result->id]);
}
};
$connection->onClose = function () {
$this->connection = null;
//重连
$this->clearTimer();
$this->reconnectTimer = Timer::add(1, function () {
$this->connection = $this->createConnection();
});
};
$connection->connect();
return $connection;
}
protected function clearTimer()
{
if ($this->reconnectTimer) {
Timer::del($this->reconnectTimer);
$this->reconnectTimer = null;
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace think\worker\conduit\driver\socket;
class Command
{
public $id;
public $name;
public $key;
public $data;
public static function create($name, $key, $data = null)
{
$packet = new self();
$packet->name = $name;
$packet->key = $key;
$packet->data = $data;
return $packet;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace think\worker\conduit\driver\socket;
class Event
{
public function __construct(public $name, public $data)
{
}
public static function create($name, $data)
{
return new self($name, $data);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace think\worker\conduit\driver\socket;
class Result
{
public function __construct(public $id = null, public $data = null)
{
}
public static function create($id = null)
{
return new self($id);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace think\worker\conduit\driver\socket;
use think\worker\Worker;
use Workerman\Connection\TcpConnection;
use Workerman\Protocols\Frame;
class Server
{
protected $data = [];
/** @var array<string,TcpConnection[]> */
protected $subscribers = [];
public function onMessage(TcpConnection $connection, $buffer)
{
if (empty($buffer)) {
return;
}
/** @var Command $command */
$command = unserialize($buffer);
$result = Result::create($command->id);
switch ($command->name) {
case 'get':
$result->data = $this->data[$command->key] ?? null;
break;
case 'set':
$this->data[$command->key] = $command->data;
break;
case 'inc':
if (!isset($this->data[$command->key]) || !is_integer($this->data[$command->key])) {
$this->data[$command->key] = 0;
}
$result->data = $this->data[$command->key] += $command->data ?? 1;
break;
case 'sAdd':
if (!isset($this->data[$command->key]) || !is_array($this->data[$command->key])) {
$this->data[$command->key] = [];
}
$this->data[$command->key] = array_merge($this->data[$command->key], $command->data);
break;
case 'sRem':
if (!isset($this->data[$command->key]) || !is_array($this->data[$command->key])) {
$this->data[$command->key] = [];
}
$this->data[$command->key] = array_diff($this->data[$command->key], [$command->data]);
break;
case 'sMembers':
if (!isset($this->data[$command->key]) || !is_array($this->data[$command->key])) {
$this->data[$command->key] = [];
}
$result->data = $this->data[$command->key];
break;
case 'subscribe':
if (!isset($this->subscribers[$command->key])) {
$this->subscribers[$command->key] = [];
}
$this->subscribers[$command->key][] = $connection;
break;
case 'publish':
if (!empty($this->subscribers[$command->key])) {
foreach ($this->subscribers[$command->key] as $conn) {
$conn->send(serialize(Event::create($command->key, $command->data)));
}
}
break;
}
if (isset($result->id)) {
$connection->send(serialize($result));
}
}
public function onClose(TcpConnection $connection)
{
if (!empty($this->subscribers)) {
foreach ($this->subscribers as $key => $connections) {
$this->subscribers[$key] = array_udiff($connections, [$connection], function ($a, $b) {
return $a <=> $b;
});
}
}
}
public static function run($domain)
{
//启动服务端
$server = new self();
$worker = new Worker($domain);
$worker->name = 'conduit';
$worker->protocol = Frame::class;
$worker->reloadable = false;
$worker->onMessage = [$server, 'onMessage'];
$worker->onClose = [$server, 'onClose'];
}
}

39
src/config/worker.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
use think\worker\websocket\Handler;
return [
'http' => [
'enable' => true,
'host' => '0.0.0.0',
'port' => 8080,
'worker_num' => 4,
'options' => [],
],
'websocket' => [
'enable' => false,
'handler' => Handler::class,
'ping_interval' => 25000,
'ping_timeout' => 60000,
],
//队列
'queue' => [
'enable' => false,
'workers' => [],
],
'hot_update' => [
'enable' => env('APP_DEBUG', false),
'name' => ['*.php'],
'include' => [app_path(), config_path(), root_path('route')],
'exclude' => [],
],
];

View File

@@ -0,0 +1,17 @@
<?php
namespace think\worker\contract;
use think\App;
use think\worker\Sandbox;
interface ResetterInterface
{
/**
* "handle" function for resetting app.
*
* @param \think\App $app
* @param Sandbox $sandbox
*/
public function handle(App $app, Sandbox $sandbox);
}

View File

@@ -0,0 +1,31 @@
<?php
namespace think\worker\contract\websocket;
use think\Request;
use think\worker\websocket\Frame;
interface HandlerInterface
{
/**
* "onOpen" listener.
*
* @param Request $request
*/
public function onOpen(Request $request);
/**
* "onMessage" listener.
*
* @param Frame $frame
*/
public function onMessage(Frame $frame);
/**
* "onClose" listener.
*/
public function onClose();
public function encodeMessage($message);
}

View File

@@ -0,0 +1,15 @@
<?php
namespace think\worker\message;
class PushMessage
{
public $to;
public $data;
public function __construct($to, $data)
{
$this->to = $to;
$this->data = $data;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace think\worker\protocols;
use think\worker\websocket\Frame;
use Workerman\Connection\TcpConnection;
use Workerman\Protocols\Http;
use Workerman\Protocols\Websocket;
class FlexHttp
{
public static function input(string $buffer, TcpConnection $connection)
{
if (empty($connection->context->websocketHandshake)) {
return Http::input($buffer, $connection);
} else {
return Websocket::input($buffer, $connection);
}
}
public static function decode(string $buffer, TcpConnection $connection)
{
if (empty($connection->context->websocketHandshake)) {
return Http::decode($buffer, $connection);
} else {
$data = Websocket::decode($buffer, $connection);
return new Frame($connection->id, $data);
}
}
public static function encode(mixed $response, TcpConnection $connection): string
{
if (empty($connection->context->websocketHandshake)) {
return Http::encode($response, $connection);
} else {
return Websocket::encode($response, $connection);
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ClearInstances implements ResetterInterface
{
public function handle(App $app, Sandbox $sandbox)
{
$instances = ['log', 'session', 'view', 'response', 'cookie'];
$instances = array_merge($instances, $sandbox->getConfig()->get('worker.instances', []));
foreach ($instances as $instance) {
$app->delete($instance);
}
return $app;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ResetConfig implements ResetterInterface
{
public function handle(App $app, Sandbox $sandbox)
{
$app->instance('config', clone $sandbox->getConfig());
return $app;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\worker\concerns\ModifyProperty;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ResetEvent implements ResetterInterface
{
use ModifyProperty;
public function handle(App $app, Sandbox $sandbox)
{
$event = clone $sandbox->getEvent();
$this->modifyProperty($event, $app);
$app->instance('event', $event);
return $app;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\Model;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ResetModel implements ResetterInterface
{
public function handle(App $app, Sandbox $sandbox)
{
if (class_exists(Model::class)) {
Model::setInvoker(function (...$args) use ($sandbox) {
return $sandbox->getSnapshot()->invoke(...$args);
});
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\Paginator;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ResetPaginator implements ResetterInterface
{
public function handle(App $app, Sandbox $sandbox)
{
Paginator::currentPathResolver(function () use ($sandbox) {
return $sandbox->getSnapshot()->request->baseUrl();
});
Paginator::currentPageResolver(function ($varPage = 'page') use ($sandbox) {
$page = $sandbox->getSnapshot()->request->param($varPage);
if (filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1) {
return (int) $page;
}
return 1;
});
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\worker\concerns\ModifyProperty;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ResetService implements ResetterInterface
{
use ModifyProperty;
/**
* "handle" function for resetting app.
*
* @param App $app
* @param Sandbox $sandbox
*/
public function handle(App $app, Sandbox $sandbox)
{
foreach ($sandbox->getServices() as $service) {
$this->modifyProperty($service, $app);
if (method_exists($service, 'register')) {
$service->register();
}
if (method_exists($service, 'boot')) {
$app->invoke([$service, 'boot']);
}
}
}
}

112
src/response/File.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
namespace think\worker\response;
use DateTime;
use RuntimeException;
use SplFileInfo;
use think\Response;
class File extends Response
{
public const DISPOSITION_ATTACHMENT = 'attachment';
public const DISPOSITION_INLINE = 'inline';
protected $header = [
'Content-Type' => 'application/octet-stream',
'Accept-Ranges' => 'bytes',
];
/**
* @var SplFileInfo
*/
protected $file;
public function __construct($file, ?string $contentDisposition = null, bool $autoEtag = true, bool $autoLastModified = true, bool $autoContentType = true)
{
$this->setFile($file, $contentDisposition, $autoEtag, $autoLastModified, $autoContentType);
}
public function getFile()
{
return $this->file;
}
public function setFile($file, ?string $contentDisposition = null, bool $autoEtag = true, bool $autoLastModified = true, bool $autoContentType = true)
{
if (!$file instanceof SplFileInfo) {
$file = new SplFileInfo((string) $file);
}
if (!$file->isReadable()) {
throw new RuntimeException('File must be readable.');
}
$this->header['Content-Length'] = $file->getSize();
$this->file = $file;
if ($autoEtag) {
$this->setAutoEtag();
}
if ($autoLastModified) {
$this->setAutoLastModified();
}
if ($contentDisposition) {
$this->setContentDisposition($contentDisposition);
}
if ($autoContentType) {
$this->setAutoContentType();
}
return $this;
}
public function setAutoContentType()
{
if (extension_loaded('fileinfo')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $this->file->getPathname());
if ($mimeType) {
$this->header['Content-Type'] = $mimeType;
}
}
}
public function setContentDisposition(string $disposition, string $filename = '')
{
if ('' === $filename) {
$filename = $this->file->getFilename();
}
$this->header['Content-Disposition'] = "{$disposition}; filename=\"{$filename}\"";
return $this;
}
public function setAutoLastModified()
{
$mTime = $this->file->getMTime();
if ($mTime) {
$date = DateTime::createFromFormat('U', (string) $mTime);
$this->lastModified($date->format('D, d M Y H:i:s') . ' GMT');
}
return $this;
}
public function setAutoEtag()
{
$eTag = "W/\"" . sha1_file($this->file->getPathname()) . "\"";
return $this->eTag($eTag);
}
protected function sendData(string $data): void
{
readfile($this->file->getPathname());
}
}

22
src/response/Iterator.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace think\worker\response;
use IteratorAggregate;
use think\Response;
use Traversable;
class Iterator extends Response implements IteratorAggregate
{
protected $iterator;
public function __construct(Traversable $iterator)
{
$this->iterator = $iterator;
}
public function getIterator(): Traversable
{
return $this->iterator;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace think\worker\response;
use think\Event;
use think\Response;
class Websocket extends Response
{
protected $listeners = [];
public function onOpen($listener)
{
$this->listeners['Open'] = $listener;
return $this;
}
public function onMessage($listener)
{
$this->listeners['Message'] = $listener;
return $this;
}
public function onEvent($listener)
{
$this->listeners['Event'] = $listener;
return $this;
}
public function onClose($listener)
{
$this->listeners['Close'] = $listener;
return $this;
}
public function onConnect($listener)
{
$this->listeners['Connect'] = $listener;
return $this;
}
public function onDisconnect($listener)
{
$this->listeners['Disconnect'] = $listener;
return $this;
}
public function onPing($listener)
{
$this->listeners['Ping'] = $listener;
return $this;
}
public function onPong($listener)
{
$this->listeners['Pong'] = $listener;
return $this;
}
public function subscribe(Event $event)
{
foreach ($this->listeners as $eventName => $listener) {
$event->listen('worker.websocket.' . $eventName, $listener);
}
}
}

8
src/watcher/Driver.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
namespace think\worker\watcher;
interface Driver
{
public function watch(callable $callback);
}

72
src/watcher/Find.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
namespace think\worker\watcher;
use Symfony\Component\Process\Process;
use Workerman\Timer;
class Find implements Driver
{
protected $name;
protected $directory;
protected $exclude;
public function __construct($directory, $exclude, $name)
{
$this->directory = $directory;
$this->exclude = $exclude;
$this->name = $name;
}
public function watch(callable $callback)
{
$ms = 2000;
$seconds = ceil(($ms + 1000) / 1000);
$minutes = sprintf('-%.2f', $seconds / 60);
$dest = implode(' ', $this->directory);
$name = empty($this->name) ? '' : ' \( ' . join(' -o ', array_map(fn($v) => "-name \"{$v}\"", $this->name)) . ' \)';
$notName = '';
$notPath = '';
if (!empty($this->exclude)) {
$excludeDirs = $excludeFiles = [];
foreach ($this->exclude as $directory) {
$directory = rtrim($directory, '/');
if (is_dir($directory)) {
$excludeDirs[] = $directory;
} else {
$excludeFiles[] = $directory;
}
}
if (!empty($excludeFiles)) {
$notPath = ' -not \( ' . join(' -and ', array_map(fn($v) => "-name \"{$v}\"", $excludeFiles)) . ' \)';
}
if (!empty($excludeDirs)) {
$notPath = ' -not \( ' . join(' -and ', array_map(fn($v) => "-path \"{$v}/*\"", $excludeDirs)) . ' \)';
}
}
$command = "find {$dest}{$name}{$notName}{$notPath} -mmin {$minutes} -type f -print";
Timer::add($ms / 1000, function () use ($callback, $command) {
$stdout = $this->exec($command);
if (!empty($stdout)) {
call_user_func($callback);
}
});
}
public function exec($command)
{
$process = Process::fromShellCommandline($command);
$process->run();
if ($process->isSuccessful()) {
return $process->getOutput();
}
return false;
}
}

52
src/watcher/Scan.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace think\worker\watcher;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Workerman\Timer;
class Scan implements Driver
{
protected $finder;
protected $files = [];
public function __construct($directory, $exclude, $name)
{
$this->finder = new Finder();
$this->finder
->files()
->name($name)
->in($directory)
->exclude($exclude);
}
protected function findFiles()
{
$files = [];
/** @var SplFileInfo $f */
foreach ($this->finder as $f) {
$files[$f->getRealpath()] = $f->getMTime();
}
return $files;
}
public function watch(callable $callback)
{
$this->files = $this->findFiles();
Timer::add(2, function () use ($callback) {
$files = $this->findFiles();
foreach ($files as $path => $time) {
if (empty($this->files[$path]) || $this->files[$path] != $time) {
call_user_func($callback);
break;
}
}
$this->files = $files;
});
}
}

15
src/websocket/Event.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
namespace think\worker\websocket;
class Event
{
public $type;
public $data;
public function __construct($type, $data = null)
{
$this->type = $type;
$this->data = $data;
}
}

10
src/websocket/Frame.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace think\worker\websocket;
class Frame
{
public function __construct(public int $fd, public string $data)
{
}
}

71
src/websocket/Handler.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
namespace think\worker\websocket;
use think\Event;
use think\Request;
use think\worker\contract\websocket\HandlerInterface;
use think\worker\websocket\Event as WsEvent;
class Handler implements HandlerInterface
{
protected $event;
public function __construct(Event $event)
{
$this->event = $event;
}
/**
* "onOpen" listener.
*
* @param Request $request
*/
public function onOpen(Request $request)
{
$this->event->trigger('worker.websocket.Open', $request);
}
/**
* "onMessage" listener.
*
* @param Frame $frame
*/
public function onMessage(Frame $frame)
{
$this->event->trigger('worker.websocket.Message', $frame);
$event = $this->decode($frame->data);
if ($event) {
$this->event->trigger('worker.websocket.Event', $event);
}
}
/**
* "onClose" listener.
*/
public function onClose()
{
$this->event->trigger('worker.websocket.Close');
}
protected function decode($payload)
{
$data = json_decode($payload, true);
if (!empty($data['type'])) {
return new WsEvent($data['type'], $data['data'] ?? null);
}
return null;
}
public function encodeMessage($message)
{
if ($message instanceof WsEvent) {
return json_encode([
'type' => $message->type,
'data' => $message->data,
]);
}
return $message;
}
}

67
src/websocket/Pusher.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
namespace think\worker\websocket;
use think\worker\Manager;
use think\worker\message\PushMessage;
/**
* Class Pusher
*/
class Pusher
{
/** @var Room */
protected $room;
/** @var Manager */
protected $manager;
protected $to = [];
public function __construct(Manager $manager, Room $room)
{
$this->manager = $manager;
$this->room = $room;
}
public function to(...$values)
{
foreach ($values as $value) {
if (is_array($value)) {
$this->to(...$value);
} elseif (!in_array($value, $this->to)) {
$this->to[] = $value;
}
}
return $this;
}
/**
* Push message to related descriptors
* @param $data
* @return void
*/
public function push($data): void
{
$fds = [];
foreach ($this->to as $room) {
$clients = $this->room->getClients($room);
if (!empty($clients)) {
$fds = array_merge($fds, $clients);
}
}
foreach (array_unique($fds) as $fd) {
[$workerId, $fd] = explode('.', $fd);
$this->manager->sendMessage((int) $workerId, new PushMessage((int) $fd, $data));
}
}
public function emit(string $event, ...$data): void
{
$this->push(new Event($event, $data));
}
}

54
src/websocket/Room.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace think\worker\websocket;
use think\worker\Conduit;
class Room
{
public function __construct(protected Conduit $conduit)
{
}
public function add($fd, ...$rooms)
{
$this->conduit->sAdd($this->getClientKey($fd), ...$rooms);
foreach ($rooms as $room) {
$this->conduit->sAdd($this->getRoomKey($room), $fd);
}
}
public function delete($fd, ...$rooms)
{
$rooms = count($rooms) ? $rooms : $this->getRooms($fd);
$this->conduit->sRem($this->getClientKey($fd), ...$rooms);
foreach ($rooms as $room) {
$this->conduit->sRem($this->getRoomKey($room), $fd);
}
}
public function getClients(string $room)
{
return $this->conduit->sMembers($this->getRoomKey($room)) ?: [];
}
public function getRooms(string $fd)
{
return $this->conduit->sMembers($this->getClientKey($fd)) ?: [];
}
protected function getClientKey(string $key)
{
return "ws:client:{$key}";
}
protected function getRoomKey($room)
{
return "ws:room:{$room}";
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace think\worker\websocket\socketio;
class EnginePacket
{
/**
* Engine.io packet type `open`.
*/
const OPEN = 0;
/**
* Engine.io packet type `close`.
*/
const CLOSE = 1;
/**
* Engine.io packet type `ping`.
*/
const PING = 2;
/**
* Engine.io packet type `pong`.
*/
const PONG = 3;
/**
* Engine.io packet type `message`.
*/
const MESSAGE = 4;
/**
* Engine.io packet type 'upgrade'
*/
const UPGRADE = 5;
/**
* Engine.io packet type `noop`.
*/
const NOOP = 6;
public $type;
public $data = '';
public function __construct($type, $data = '')
{
$this->type = $type;
$this->data = $data;
}
public static function open($payload)
{
return new self(self::OPEN, $payload);
}
public static function pong($payload = '')
{
return new self(self::PONG, $payload);
}
public static function ping()
{
return new self(self::PING);
}
public static function message($payload)
{
return new self(self::MESSAGE, $payload);
}
public static function fromString(string $packet)
{
return new self(substr($packet, 0, 1), substr($packet, 1) ?: '');
}
public function toString()
{
return $this->type . $this->data;
}
}

View File

@@ -0,0 +1,198 @@
<?php
namespace think\worker\websocket\socketio;
use Exception;
use think\Config;
use think\Event;
use think\Request;
use think\worker\contract\websocket\HandlerInterface;
use think\worker\Websocket;
use think\worker\websocket\Event as WsEvent;
use think\worker\websocket\Frame;
use Workerman\Timer;
class Handler implements HandlerInterface
{
/** @var Config */
protected $config;
protected $event;
protected $websocket;
protected $eio;
protected $pingTimeoutTimer = 0;
protected $pingIntervalTimer = 0;
protected $pingInterval;
protected $pingTimeout;
public function __construct(Event $event, Config $config, Websocket $websocket)
{
$this->event = $event;
$this->config = $config;
$this->websocket = $websocket;
$this->pingInterval = $this->config->get('worker.websocket.ping_interval', 25000);
$this->pingTimeout = $this->config->get('worker.websocket.ping_timeout', 60000);
}
/**
* "onOpen" listener.
*
* @param Request $request
*/
public function onOpen(Request $request)
{
$this->eio = $request->param('EIO');
$payload = json_encode(
[
'sid' => base64_encode(uniqid()),
'upgrades' => [],
'pingInterval' => $this->pingInterval,
'pingTimeout' => $this->pingTimeout,
]
);
$this->push(EnginePacket::open($payload));
$this->event->trigger('worker.websocket.Open', $request);
if ($this->eio < 4) {
$this->resetPingTimeout($this->pingInterval + $this->pingTimeout);
$this->onConnect();
} else {
$this->schedulePing();
}
}
/**
* "onMessage" listener.
*
* @param Frame $frame
*/
public function onMessage(Frame $frame)
{
$enginePacket = EnginePacket::fromString($frame->data);
$this->event->trigger('worker.websocket.Message', $enginePacket);
$this->resetPingTimeout($this->pingInterval + $this->pingTimeout);
switch ($enginePacket->type) {
case EnginePacket::MESSAGE:
$packet = Packet::fromString($enginePacket->data);
switch ($packet->type) {
case Packet::CONNECT:
$this->onConnect($packet->data);
break;
case Packet::EVENT:
$type = array_shift($packet->data);
$data = $packet->data;
$result = $this->event->trigger('worker.websocket.Event', new WsEvent($type, $data));
if ($packet->id !== null) {
$responsePacket = Packet::create(Packet::ACK, [
'id' => $packet->id,
'nsp' => $packet->nsp,
'data' => $result,
]);
$this->push($responsePacket);
}
break;
case Packet::DISCONNECT:
$this->event->trigger('worker.websocket.Disconnect');
$this->websocket->close();
break;
default:
$this->websocket->close();
break;
}
break;
case EnginePacket::PING:
$this->event->trigger('worker.websocket.Ping');
$this->push(EnginePacket::pong($enginePacket->data));
break;
case EnginePacket::PONG:
$this->event->trigger('worker.websocket.Pong');
$this->schedulePing();
break;
default:
$this->websocket->close();
break;
}
}
/**
* "onClose" listener.
*/
public function onClose()
{
Timer::del($this->pingTimeoutTimer);
Timer::del($this->pingIntervalTimer);
$this->event->trigger('worker.websocket.Close');
}
protected function onConnect($data = null)
{
try {
$this->event->trigger('worker.websocket.Connect', $data);
$packet = Packet::create(Packet::CONNECT);
if ($this->eio >= 4) {
$packet->data = ['sid' => base64_encode(uniqid())];
}
} catch (Exception $exception) {
$packet = Packet::create(Packet::CONNECT_ERROR, [
'data' => ['message' => $exception->getMessage()],
]);
}
$this->push($packet);
}
protected function resetPingTimeout($timeout)
{
Timer::del($this->pingTimeoutTimer);
$this->pingTimeoutTimer = Timer::delay($timeout, function () {
$this->websocket->close();
});
}
protected function schedulePing()
{
Timer::del($this->pingIntervalTimer);
$this->pingIntervalTimer = Timer::delay($this->pingInterval, function () {
$this->push(EnginePacket::ping());
$this->resetPingTimeout($this->pingTimeout);
});
}
public function encodeMessage($message)
{
if ($message instanceof WsEvent) {
$message = Packet::create(Packet::EVENT, [
'data' => array_merge([$message->type], $message->data),
]);
}
if ($message instanceof Packet) {
$message = EnginePacket::message($message->toString());
}
if ($message instanceof EnginePacket) {
$message = $message->toString();
}
return $message;
}
protected function push($data)
{
$this->websocket->push($data);
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace think\worker\websocket\socketio;
/**
* Class Packet
*/
class Packet
{
/**
* Socket.io packet type `connect`.
*/
const CONNECT = 0;
/**
* Socket.io packet type `disconnect`.
*/
const DISCONNECT = 1;
/**
* Socket.io packet type `event`.
*/
const EVENT = 2;
/**
* Socket.io packet type `ack`.
*/
const ACK = 3;
/**
* Socket.io packet type `connect_error`.
*/
const CONNECT_ERROR = 4;
/**
* Socket.io packet type 'binary event'
*/
const BINARY_EVENT = 5;
/**
* Socket.io packet type `binary ack`. For acks with binary arguments.
*/
const BINARY_ACK = 6;
public $type;
public $nsp = '/';
public $data = null;
public $id = null;
public function __construct(int $type)
{
$this->type = $type;
}
public static function create($type, array $decoded = [])
{
$new = new self($type);
$new->id = $decoded['id'] ?? null;
if (isset($decoded['nsp'])) {
$new->nsp = $decoded['nsp'] ?: '/';
} else {
$new->nsp = '/';
}
$new->data = $decoded['data'] ?? null;
return $new;
}
public function toString()
{
$str = '' . $this->type;
if ($this->nsp && '/' !== $this->nsp) {
$str .= $this->nsp . ',';
}
if ($this->id !== null) {
$str .= $this->id;
}
if (null !== $this->data) {
$str .= json_encode($this->data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return $str;
}
public static function fromString(string $str)
{
$i = 0;
$packet = new Packet((int) substr($str, 0, 1));
// look up namespace (if any)
if ('/' === substr($str, $i + 1, 1)) {
$nsp = '';
while (++$i) {
$c = substr($str, $i, 1);
if (',' === $c) {
break;
}
$nsp .= $c;
if ($i === strlen($str)) {
break;
}
}
$packet->nsp = $nsp;
} else {
$packet->nsp = '/';
}
// look up id
$next = substr($str, $i + 1, 1);
if ('' !== $next && is_numeric($next)) {
$id = '';
while (++$i) {
$c = substr($str, $i, 1);
if (null == $c || !is_numeric($c)) {
--$i;
break;
}
$id .= substr($str, $i, 1);
if ($i === strlen($str)) {
break;
}
}
$packet->id = intval($id);
}
// look up json data
if (substr($str, ++$i, 1)) {
$packet->data = json_decode(substr($str, $i), true);
}
return $packet;
}
}

2
tests/Pest.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
define('STUB_DIR', realpath(__DIR__ . '/stub'));

153
tests/feature/HttpTest.php Normal file
View File

@@ -0,0 +1,153 @@
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use Symfony\Component\Process\Process;
$process = null;
beforeAll(function () use (&$process) {
$process = new Process(['php', 'think', 'worker'], STUB_DIR, [
'PHP_WEBSOCKET_ENABLE' => 'false',
'PHP_QUEUE_ENABLE' => 'false',
]);
$process->start();
$wait = 0;
while (!$process->getOutput()) {
$wait++;
if ($wait > 30) {
throw new Exception('server start failed');
}
sleep(1);
}
});
afterAll(function () use (&$process) {
echo $process->getOutput();
$process->stop();
});
beforeEach(function () {
$this->httpClient = new Client([
'base_uri' => 'http://127.0.0.1:8080',
'cookies' => true,
'http_errors' => false,
'timeout' => 1,
]);
});
it('callback route', function () {
$response = $this->httpClient->get('/');
expect($response->getStatusCode())
->toBe(200)
->and($response->getBody()->getContents())
->toBe('hello world');
});
it('controller route', function () {
$jar = new CookieJar();
$response = $this->httpClient->get('/test', ['cookies' => $jar]);
expect($response->getStatusCode())
->toBe(200)
->and($response->getBody()->getContents())
->toBe('test')
->and($jar->getCookieByName('name')->getValue())
->toBe('think');
});
it('json post', function () {
$data = [
'name' => 'think',
];
$response = $this->httpClient->post('/json', [
'json' => $data,
]);
expect($response->getStatusCode())
->toBe(200)
->and($response->getBody()->getContents())
->toBe(json_encode($data));
});
it('put and delete request', function () {
$response = $this->httpClient->put('/');
expect($response->getStatusCode())
->toBe(200)
->and($response->getBody()->getContents())
->toBe('put');
$response = $this->httpClient->delete('/');
expect($response->getStatusCode())
->toBe(200)
->and($response->getBody()->getContents())
->toBe('delete');
});
it('file response', function () {
$response = $this->httpClient->get('/static/asset.txt');
expect($response->getStatusCode())
->toBe(200)
->and($response->getBody()->getContents())
->toBe(file_get_contents(STUB_DIR . '/public/asset.txt'));
});
it('sse', function () {
$response = $this->httpClient->get('/sse', [
'stream' => true,
'timeout' => 3,
]);
$body = $response->getBody();
$buffer = '';
while (!$body->eof()) {
$text = $body->read(1);
if ($text == "\r") {
continue;
}
$buffer .= $text;
if ($text == "\n") {
if ($buffer != "\n") {
expect($buffer)->toStartWith('data: ');
}
$buffer = '';
}
}
});
it('hot update', function () {
$response = $this->httpClient->get('/hot');
expect($response->getStatusCode())
->toBe(404);
$route = <<<PHP
<?php
use think\\facade\\Route;
Route::get('/hot', function () {
return 'hot';
});
PHP;
file_put_contents(STUB_DIR . '/route/hot.php', $route);
sleep(2);
$response = $this->httpClient->get('/hot');
expect($response->getStatusCode())
->toBe(200)
->and($response->getBody()->getContents())
->toBe('hot');
})->after(function () {
@unlink(STUB_DIR . '/route/hot.php');
})->skipOnWindows();

View File

@@ -0,0 +1,5 @@
<?php
it('queue', function () {
});

View File

@@ -0,0 +1,77 @@
<?php
use GuzzleHttp\Client;
use React\EventLoop\Loop;
use Symfony\Component\Process\Process;
use function Ratchet\Client\connect;
$process = null;
beforeAll(function () use (&$process) {
$process = new Process(['php', 'think', 'worker'], STUB_DIR, [
'PHP_WEBSOCKET_ENABLE' => 'true',
'PHP_QUEUE_ENABLE' => 'false',
'PHP_HOT_ENABLE' => 'false',
]);
$process->start();
$wait = 0;
while (!$process->getOutput()) {
$wait++;
if ($wait > 30) {
throw new Exception('server start failed');
}
sleep(1);
}
});
afterAll(function () use (&$process) {
echo $process->getOutput();
$process->stop();
});
beforeEach(function () {
$this->httpClient = new Client([
'base_uri' => 'http://127.0.0.1:8080',
'cookies' => true,
'http_errors' => false,
'timeout' => 1,
]);
});
it('http', function () {
$response = $this->httpClient->get('/');
expect($response->getStatusCode())
->toBe(200)
->and($response->getBody()->getContents())
->toBe('hello world');
});
it('websocket', function () {
$connected = 0;
$messages = [];
connect('ws://127.0.0.1:8080/websocket')
->then(function (\Ratchet\Client\WebSocket $conn) use (&$connected, &$messages) {
$connected++;
$conn->on('message', function ($msg) use ($conn, &$messages) {
$messages[] = (string) $msg;
$conn->close();
});
});
connect('ws://127.0.0.1:8080/websocket')
->then(function (\Ratchet\Client\WebSocket $conn) use (&$connected, &$messages) {
$connected++;
$conn->on('message', function ($msg) use ($conn, &$messages) {
$messages[] = (string) $msg;
$conn->close();
});
$conn->send('hello');
});
Loop::get()->run();
expect($connected)->toBe(2);
expect($messages)->toBe(['hello', 'hello']);
});

1
tests/stub/.env Normal file
View File

@@ -0,0 +1 @@
APP_DEBUG=true

View File

@@ -0,0 +1,20 @@
<?php
namespace app\controller;
use think\facade\Cookie;
use think\Request;
class Index
{
public function test()
{
Cookie::set('name', 'think');
return 'test';
}
public function json(Request $request)
{
return json($request->post());
}
}

View File

@@ -0,0 +1,4 @@
<?php
return [
'error_message' => 'error',
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'default' => 'file',
'stores' => [
'file' => [
'type' => 'File',
],
]
];

View File

@@ -0,0 +1,40 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2016 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: yunwuxin <448901948@qq.com>
// +----------------------------------------------------------------------
return [
'default' => 'redis',
'connections' => [
'sync' => [
'type' => 'sync',
],
'database' => [
'type' => 'database',
'queue' => 'default',
'table' => 'jobs',
'connection' => null,
],
'redis' => [
'type' => 'redis',
'queue' => 'default',
'host' => env('REDIS_HOST', 'redis'),
'port' => env('REDIS_PORT', 6379),
'password' => '',
'select' => 0,
'timeout' => 0,
'persistent' => true,
'retry_after' => 600,
],
],
'failed' => [
'type' => 'none',
'table' => 'failed_jobs',
],
];

View File

@@ -0,0 +1,45 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
use think\worker\websocket\Handler;
return [
'http' => [
'enable' => env('HTTP_ENABLE', true),
'host' => '0.0.0.0',
'port' => 8080,
'worker_num' => 2,
'options' => [],
],
'websocket' => [
'enable' => env('WEBSOCKET_ENABLE', true),
'handler' => Handler::class,
'ping_interval' => 25000,
'ping_timeout' => 60000,
],
//队列
'queue' => [
'enable' => env('QUEUE_ENABLE', true),
'workers' => [
'default' => [],
],
],
//共享数据
'conduit' => [
'type' => 'socket',
],
'hot_update' => [
'enable' => env('HOT_ENABLE', true),
'name' => ['*.php'],
'include' => [app_path(), config_path(), root_path('route')],
'exclude' => [],
],
];

View File

@@ -0,0 +1 @@
Asset

51
tests/stub/route/app.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
use think\facade\Route;
Route::get('/', function () {
return 'hello world';
});
Route::put('/', function () {
return 'put';
});
Route::delete('/', function () {
return 'delete';
});
Route::get('/sse', function () {
$generator = function () {
foreach (range(0, 9) as $event) {
yield 'data: ' . json_encode($event) . "\n\n";
}
yield "data: [DONE]\n\n";
};
$response = new \think\worker\response\Iterator($generator());
return $response->header([
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache, must-revalidate',
]);
});
Route::get('/websocket', function () {
return (new \think\worker\response\Websocket())
->onOpen(function (\think\worker\Websocket $websocket) {
$websocket->join('foo');
})
->onMessage(function (\think\worker\Websocket $websocket, \think\worker\websocket\Frame $frame) {
$websocket->to('foo')->push($frame->data);
});
});
Route::get('test', 'index/test');
Route::post('json', 'index/json');
Route::get('static/:path', function (string $path) {
$filename = public_path() . $path;
return new \think\worker\response\File($filename);
})->pattern(['path' => '.*\.\w+$']);

2
tests/stub/runtime/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

12
tests/stub/think Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env php
<?php
use think\App;
require __DIR__ . '/../../vendor/autoload.php';
$app = new App(__DIR__);
$app->console->addCommands([\think\worker\command\Server::class]);
$app->console->run();