This commit is contained in:
2022-12-24 22:10:40 +08:00
parent 84fc1030a7
commit 2a00928da5
4898 changed files with 429855 additions and 77 deletions
+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2014-present rap2hpoutre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+341
View File
@@ -0,0 +1,341 @@
EasyHttp 是一个轻量级、语义化、对IDE友好的HTTP客户端,支持常见的HTTP请求、异步请求和并发请求,让你可以快速地使用 HTTP 请求与其他 Web 应用进行通信。
> EasyHttp并不强制依赖于cURL,如果没有安装cURL,EasyHttp会自动选择使用PHP流处理,或者你也可以提供自己的发送HTTP请求的处理方式。
如果您觉得EasyHttp对您有用的话,别忘了给点个赞哦^_^ !
github:[github.com/yzh52521/easyhttp](https://github.com/yzh52521/easyhttp "github.com/yzh52521/easyhttp")
gitee:[gitee.com/yzh52521/easyhttp](https://gitee.com/yzh52521/easyhttp "gitee.com/yzh52521/easyhttp")
本包是基于 [ gouguoyin/easyhttp ](https://gitee.com/gouguoyin/easyhttp "gitee.com/gouguoyin/easyhttp") 进行扩展开发,主要实现了以下扩展:
1. 增加 retry() 重试机制。
2. 增加 debug 日志调试功能。
3. 增加 withHost 指定服务端base_url
# 安装说明
#### 环境依赖
- PHP >= 7.2.5
- 如果使用PHP流处理,allow_url_fopen 必须在php.ini中启用。
- 如果使用cURL处理,cURL >= 7.19.4,并且编译了OpenSSL 与 zlib。
#### 一键安装
composer require yzh52521/easyhttp
## 发起请求
#### 同步请求
###### 常规请求
```php
$response = Http::get('http://httpbin.org/get');
$response = Http::get('http://httpbin.org/get?name=yzh52521');
$response = Http::get('http://httpbin.org/get?name=yzh52521', ['age' => 18]);
$response = Http::post('http://httpbin.org/post');
$response = Http::post('http://httpbin.org/post', ['name' => 'yzh52521']);
$response = Http::patch(...);
$response = Http::put(...);
$response = Http::delete(...);
$response = Http::head(...);
$response = Http::options(...);
```
###### 指定服务端base_url的请求
```php
// 指定服务端base_url地址,最终请求地址为 https://serv.yzh52521.com/login
$response = Http::withHost('https://serv.yzh52521.com')->post('/login');
```
###### 发送 Content-Type 编码请求
```php
// application/x-www-form-urlencoded(默认)
$response = Http::asForm()->post(...);
// application/json
$response = Http::asJson()->post(...);
```
###### 发送 Multipart 表单请求
```php
$response = Http::asMultipart(
'file_input_name', file_get_contents('photo1.jpg'), 'photo2.jpg'
)->post('http://test.com/attachments');
$response = Http::asMultipart(
'file_input_name', fopen('photo1.jpg', 'r'), 'photo2.jpg'
)->post(...);
$response = Http::attach(
'file_input_name', file_get_contents('photo1.jpg'), 'photo2.jpg'
)->post(...);
$response = Http::attach(
'file_input_name', fopen('photo1.jpg', 'r'), 'photo2.jpg'
)->post(...);
```
> 表单enctype属性需要设置成 multipart/form-data
###### 携带请求头的请求
```php
$response = Http::withHeaders([
'x-powered-by' => 'yzh52521'
])->post(...);
```
###### 携带重定向的请求
```php
// 默认
$response = Http::withRedirect(false)->post(...);
$response = Http::withRedirect([
'max' => 5,
'strict' => false,
'referer' => true,
'protocols' => ['http', 'https'],
'track_redirects' => false
])->post(...);
```
###### 携带认证的请求
```php
// Basic认证
$response = Http::withBasicAuth('username', 'password')->post(...);
// Digest认证(需要被HTTP服务器支持)
$response = Http::withDigestAuth('username', 'password')->post(...);
```
###### 携带 User-Agent 的请求
```php
$response = Http::withUA('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3100.0 Safari/537.36')->post(...);
```
###### 携带Token令牌的请求
```php
$response = Http::withToken('token')->post(...);
```
###### 携带认证文件的请求
```php
$response = Http::withCert('/path/server.pem', 'password')->post(...);
```
###### 携带SSL证书的请求
```php
// 默认
$response = Http::withVerify(false)->post(...);
$response = Http::withVerify('/path/to/cert.pem')->post(...);
```
###### 携带COOKIE的请求
```php
$response = Http::withCookies(array $cookies, string $domain)->post(...);
```
###### 携带协议版本的请求
```php
$response = Http::withVersion(1.1)->post(...);
```
###### 携带代理的请求
```php
$response = Http::withProxy('tcp://localhost:8125')->post(...);
$response = Http::withProxy([
'http' => 'tcp://localhost:8125', // Use this proxy with "http"
'https' => 'tcp://localhost:9124', // Use this proxy with "https",
'no' => ['.com.cn', 'yzh52521.cn'] // Don't use a proxy with these
])->post(...);
```
###### 设置超时时间(单位秒)
```php
$response = Http::timeout(60)->post(...);
```
###### 设置延迟时间(单位秒)
```php
$response = Http::delay(60)->post(...);
```
###### 设置并发次数
```php
$response = Http::concurrency(10)->promise(...);
```
###### 重发请求,设置retry方法。重试次数/两次重试之间的时间间隔(毫秒):
```php
$response = Http::retry(3, 100)->post(...);
```
#### 异步请求
```php
use yzh52521\EasyHttp\Response;
use yzh52521\EasyHttp\RequestException;
Http::getAsync('http://easyhttp.yzh52521.cn/api/sleep3.json', ['token' => TOKEN], function (Response $response) {
echo '异步请求成功,响应内容:' . $response->body() . PHP_EOL;
}, function (RequestException $e) {
echo '异步请求异常,错误码:' . $e->getCode() . ',错误信息:' . $e->getMessage() . PHP_EOL;
});
echo json_encode(['code' => 200, 'msg' => '请求成功'], JSON_UNESCAPED_UNICODE) . PHP_EOL;
//输出
{"code":200,"msg":"请求成功"}
异步请求成功,响应内容:{"code":200,"msg":"success","second":3}
Http::getAsync('http1://easyhttp.yzh52521.cn/api/sleep3.json', function (Response $response) {
echo '异步请求成功,响应内容:' . $response->body() . PHP_EOL;
}, function (RequestException $e) {
echo '异步请求异常,错误信息:' . $e->getMessage() . PHP_EOL;
});
echo json_encode(['code' => 200, 'msg' => '请求成功'], JSON_UNESCAPED_UNICODE) . PHP_EOL;
//输出
{"code":200,"msg":"请求成功"}
异步请求异常,错误信息:cURL error 1: Protocol "http1" not supported or disabled in libcurl (see https://curl.haxx.se/libcurl/c/libcurl-errors.html)
Http::postAsync(...);
Http::patchAsync(...);
Http::putAsync(...);
Http::deleteAsync(...);
Http::headAsync(...);
Http::optionsAsync(...);
```
#### 异步并发请求
```php
use yzh52521\EasyHttp\Response;
use yzh52521\EasyHttp\RequestException;
$promises = [
Http::getAsync('http://easyhttp.yzh52521.cn/api/sleep3.json'),
Http::getAsync('http1://easyhttp.yzh52521.cn/api/sleep1.json', ['name' => 'yzh52521']),
Http::postAsync('http://easyhttp.yzh52521.cn/api/sleep2.json', ['name' => 'yzh52521']),
];
Http::concurrency(10)->multiAsync($promises, function (Response $response, $index) {
echo "发起第 $index 个异步请求,请求时长:" . $response->json()->second . '秒' . PHP_EOL;
}, function (RequestException $e, $index) {
echo "发起第 $index 个请求失败,失败原因:" . $e->getMessage() . PHP_EOL;
});
//输出
发起第 1 个请求失败,失败原因:cURL error 1: Protocol "http1" not supported or disabled in libcurl (see https://curl.haxx.se/libcurl/c/libcurl-errors.html)
发起第 2 个异步请求,请求时长:2
发起第 0 个异步请求,请求时长:3
```
> 如果未调用concurrency()方法,并发次数默认为$promises的元素个数,$promises数组里必须是异步请求
## 使用响应
发起请求后会返回一个 yzh52521\EasyHttp\Response $response的实例,该实例提供了以下方法来检查请求的响应:
```php
$response->body() : string;
$response->json() : object;
$response->array() : array;
$response->status() : int;
$response->ok() : bool;
$response->successful() : bool;
$response->serverError() : bool;
$response->clientError() : bool;
$response->headers() : array;
$response->header($header) : string;
```
## 异常处理
请求在发生客户端或服务端错误时会抛出 yzh52521\EasyHttp\RequestException $e异常,该实例提供了以下方法来返回异常信息:
```php
$e->getCode() : int;
$e->getMessage() : string;
$e->getFile() : string;
$e->getLine() : int;
$e->getTrace() : array;
$e->getTraceAsString() : string;
```
## 调试日志
有时候难免要对 Http 的请求和响应包体进行记录以方便查找问题或做什么
```php
//传递一个日志类 thinkphp \think\facade\Log laravel Illuminate\Support\Facades\Log
Http::debug(Log::class)->post(...);
```
## 更新日志
### 2022-05-11
* 新增removeBodyFormat() 用于withOptions 指定body时,清除原由的bodyFromat
### 2022-05-10
* 新增发送原生请求的方法client()
* 新增发送原生异步请求的方法clientASync()
### 2021-09-03
* 新增 debug() 调试日志
* 新增 retry() 重试机制
* 修复header重叠的bug
### 2020-03-30
* 修复部分情况下IDE不能智能提示的BUG
* get()、getAsync()方法支持带参数的url
* 新增withUA()方法
* 新增withStream()方法
* 新增asMultipart()方法,attach()的别名
* 新增multiAsync()异步并发请求方法
### 2020-03-20
* 新增异步请求getAsync()方法
* 新增异步请求postAsync()方法
* 新增异步请求patchAsync()方法
* 新增异步请求putAsync()方法
* 新增异步请求deleteAsync()方法
* 新增异步请求headAsync()方法
* 新增异步请求optionsAsync()方法
## Todo List
- [x] 异步请求
- [x] 并发请求
- [x] 重试机制
- [ ] 支持http2
- [ ] 支持swoole
# easyhttp
+33
View File
@@ -0,0 +1,33 @@
{
"name": "yzh52521/easyhttp",
"description": "EasyHttp 是一个轻量级、语义化、对IDE友好的HTTP客户端,支持常见的HTTP请求、异步请求和并发请求,让你可以快速地使用 HTTP 请求与其他 Web 应用进行通信。",
"license": "MIT",
"keywords": [
"easyhttp",
"EasyHttp",
"php-http",
"phphttp",
"easy-http",
"php",
"http",
"curl"
],
"homepage": "https://github.com/yzh52521/easyhttp",
"authors": [
{
"name": "yzh52521",
"email": "396751927@qq.com"
}
],
"require": {
"php": "^7.2.5|^8.0",
"guzzlehttp/guzzle": "^6.0|^7.0",
"psr/log":"^1.0|^2.0|^3.0"
},
"autoload": {
"psr-4": {
"yzh52521\\EasyHttp\\": "src/"
}
},
"minimum-stability": "stable"
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace yzh52521\EasyHttp;
use Exception;
class ConnectionException extends Exception
{
//
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace yzh52521\EasyHttp;
class Facade
{
protected $facade;
public function __construct()
{
$this->facade = new $this->facade;
}
public function __call($name, $params) {
return call_user_func_array([$this->facade, $name], $params);
}
public static function __callStatic($name, $params) {
return call_user_func_array([new static(), $name], $params);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace yzh52521\EasyHttp;
/**
* @method static \yzh52521\EasyHttp\Request asJson()
* @method static \yzh52521\EasyHttp\Request asForm()
* @method static \yzh52521\EasyHttp\Request asMultipart(string $name, string $contents, string|null $filename = null, array $headers)
* @method static \yzh52521\EasyHttp\Request attach(string $name, string $contents, string|null $filename = null, array $headers)
*
* @method static \yzh52521\EasyHttp\Request withRedirect(bool|array $redirect)
* @method static \yzh52521\EasyHttp\Request withStream(bool $boolean)
* @method static \yzh52521\EasyHttp\Request withVerify(bool|string $verify)
* @method static \yzh52521\EasyHttp\Request withHost(string $host)
* @method static \yzh52521\EasyHttp\Request withHeaders(array $headers)
* @method static \yzh52521\EasyHttp\Request withBasicAuth(string $username, string $password)
* @method static \yzh52521\EasyHttp\Request withDigestAuth(string $username, string $password)
* @method static \yzh52521\EasyHttp\Request withUA(string $ua)
* @method static \yzh52521\EasyHttp\Request withToken(string $token, string $type = 'Bearer')
* @method static \yzh52521\EasyHttp\Request withCookies(array $cookies, string $domain)
* @method static \yzh52521\EasyHttp\Request withProxy(string|array $proxy)
* @method static \yzh52521\EasyHttp\Request withVersion(string $version)
* @method static \yzh52521\EasyHttp\Request withOptions(array $options)
*
* @method static \yzh52521\EasyHttp\Request debug($class)
* @method static \yzh52521\EasyHttp\Request retry(int $retries=1,int $sleep=0)
* @method static \yzh52521\EasyHttp\Request delay(int $seconds)
* @method static \yzh52521\EasyHttp\Request timeout(int $seconds)
* @method static \yzh52521\EasyHttp\Request concurrency(int $times)
* @method static \yzh52521\EasyHttp\Request client(string $method, string $url, array $options = [])
* @method static \yzh52521\EasyHttp\Request clientAsync(string $method, string $url, array $options = [])
* @method static \yzh52521\EasyHttp\Request removeBodyFormat()
*
* @method static \yzh52521\EasyHttp\Request get(string $url, array $query = [])
* @method static \yzh52521\EasyHttp\Request post(string $url, array $data = [])
* @method static \yzh52521\EasyHttp\Request patch(string $url, array $data = [])
* @method static \yzh52521\EasyHttp\Request put(string $url, array $data = [])
* @method static \yzh52521\EasyHttp\Request delete(string $url, array $data = [])
* @method static \yzh52521\EasyHttp\Request head(string $url, array $data = [])
* @method static \yzh52521\EasyHttp\Request options(string $url, array $data = [])
*
* @method static \yzh52521\EasyHttp\Request getAsync(string $url, array|null $query = null, callable $success = null, callable $fail = null)
* @method static \yzh52521\EasyHttp\Request postAsync(string $url, array|null $data = null, callable $success = null, callable $fail = null)
* @method static \yzh52521\EasyHttp\Request patchAsync(string $url, array|null $data = null, callable $success = null, callable $fail = null)
* @method static \yzh52521\EasyHttp\Request putAsync(string $url, array|null $data = null, callable $success = null, callable $fail = null)
* @method static \yzh52521\EasyHttp\Request deleteAsync(string $url, array|null $data = null, callable $success = null, callable $fail = null)
* @method static \yzh52521\EasyHttp\Request headAsync(string $url, array|null $data = null, callable $success = null, callable $fail = null)
* @method static \yzh52521\EasyHttp\Request optionsAsync(string $url, array|null $data = null, callable $success = null, callable $fail = null)
* @method static \yzh52521\EasyHttp\Request multiAsync(array $promises, callable $success = null, callable $fail = null)
* @method static \yzh52521\EasyHttp\Request wait()
*/
class Http extends Facade
{
protected $facade = Request::class;
}
+273
View File
@@ -0,0 +1,273 @@
<?php
namespace yzh52521\EasyHttp;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\MessageFormatter;
use GuzzleHttp\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LogLevel;
use Psr\Log\LoggerInterface;
use InvalidArgumentException;
/**
* Guzzle middleware which logs a request and its response.
*/
class Logger
{
/**
* @var \Psr\Log\LoggerInterface|callable
*/
protected $logger;
/**
* @var \GuzzleHttp\MessageFormatter|callable
*/
protected $formatter;
/**
* @var string|callable Constant or callable that accepts a Response.
*/
protected $logLevel;
/**
* @var boolean Whether or not to log requests as they are made.
*/
protected $logRequests;
/**
* Creates a callable middleware for logging requests and responses.
*
* @param LoggerInterface|callable $logger
* @param string|callable Constant or callable that accepts a Response.
*/
public function __construct($logger, $formatter = null)
{
// Use the setters to take care of type validation
$this->setLogger($logger);
$this->setFormatter($formatter ?: $this->getDefaultFormatter());
}
/**
* Returns the default formatter;
*
* @return MessageFormatter
*/
protected function getDefaultFormatter()
{
return new MessageFormatter();
}
/**
* Sets whether requests should be logged before the response is received.
*
* @param boolean $logRequests
*/
public function setRequestLoggingEnabled($logRequests = true)
{
$this->logRequests = (bool) $logRequests;
}
/**
* Sets the logger, which can be a PSR-3 logger or a callable that accepts
* a log level, message, and array context.
*
* @param LoggerInterface|callable $logger
*
* @throws InvalidArgumentException
*/
public function setLogger($logger)
{
if ($logger instanceof LoggerInterface || is_callable($logger)) {
$this->logger = $logger;
} else {
throw new InvalidArgumentException(
"Logger has to be a Psr\Log\LoggerInterface or callable"
);
}
}
/**
* Sets the formatter, which can be a MessageFormatter or callable that
* accepts a request, response, and a reason if an error has occurred.
*
* @param MessageFormatter|callable $formatter
*
* @throws InvalidArgumentException
*/
public function setFormatter($formatter)
{
if ($formatter instanceof MessageFormatter || is_callable($formatter)) {
$this->formatter = $formatter;
} else {
throw new InvalidArgumentException(
"Formatter has to be a \GuzzleHttp\MessageFormatter or callable"
);
}
}
/**
* Sets the log level to use, which can be either a string or a callable
* that accepts a response (which could be null). A log level could also
* be null, which indicates that the default log level should be used.
*
* @param string|callable|null
*/
public function setLogLevel($logLevel)
{
$this->logLevel = $logLevel;
}
/**
* Logs a request and/or a response.
*
* @param RequestInterface $request
* @param ResponseInterface|null $response
* @param $reason
* @return mixed
*/
protected function log(
RequestInterface $request,
ResponseInterface $response = null,
$reason = null
) {
if ($reason instanceof RequestException) {
$response = $reason->getResponse();
}
$level = $this->getLogLevel($response);
$message = $this->getLogMessage($request, $response, $reason);
$context = compact('request', 'response', 'reason');
// Make sure that the content of the body is available again.
if ($response) {
$response->getBody()->seek(0);;
}
if (is_callable($this->logger)) {
return call_user_func($this->logger, $level, $message, $context);
}
$this->logger->log($level, $message, $context);
}
/**
* Formats a request and response as a log message.
*
* @param RequestInterface $request
* @param ResponseInterface|null $response
* @param mixed $reason
*
* @return string The formatted message.
*/
protected function getLogMessage(
RequestInterface $request,
ResponseInterface $response = null,
$reason = null
) {
if ($this->formatter instanceof MessageFormatter) {
return $this->formatter->format(
$request,
$response,
$reason
);
}
return call_user_func($this->formatter, $request, $response, $reason);
}
/**
* Returns a log level for a given response.
*
* @param ResponseInterface $response The response being logged.
*
* @return string LogLevel
*/
protected function getLogLevel(ResponseInterface $response = null)
{
if ( ! $this->logLevel) {
return $this->getDefaultLogLevel($response);
}
if (is_callable($this->logLevel)) {
return call_user_func($this->logLevel, $response);
}
return (string) $this->logLevel;
}
/**
* Returns the default log level for a response.
*
* @param ResponseInterface $response
*
* @return string LogLevel
*/
protected function getDefaultLogLevel(ResponseInterface $response = null) {
if ($response && $response->getStatusCode() >= 300) {
return LogLevel::NOTICE;
}
return LogLevel::INFO;
}
/**
* Returns a function which is handled when a request was successful.
*
* @param RequestInterface $request
*
* @return \Closure
*/
protected function onSuccess(RequestInterface $request)
{
return function ($response) use ($request) {
$this->log($request, $response);
return $response;
};
}
/**
* Returns a function which is handled when a request was rejected.
*
* @param RequestInterface $request
*
* @return \Closure
*/
protected function onFailure(RequestInterface $request)
{
return function ($reason) use ($request) {
// Only log a rejected request if it hasn't already been logged.
if ( ! $this->logRequests) {
$this->log($request, null, $reason);
}
return Promise\rejection_for($reason);
};
}
/**
* Called when the middleware is handled by the client.
*
* @param callable $handler
*
* @return \Closure
*/
public function __invoke(callable $handler)
{
return function ($request, array $options) use ($handler) {
// Only log requests if explicitly set to do so
if ($this->logRequests) {
$this->log($request);
}
return $handler($request, $options)->then(
$this->onSuccess($request),
$this->onFailure($request)
);
};
}
}
+615
View File
@@ -0,0 +1,615 @@
<?php
namespace yzh52521\EasyHttp;
use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Exception\ConnectException;
/**
* @method \yzh52521\EasyHttp\Response body()
* @method \yzh52521\EasyHttp\Response array()
* @method \yzh52521\EasyHttp\Response json()
* @method \yzh52521\EasyHttp\Response headers()
* @method \yzh52521\EasyHttp\Response header( string $header )
* @method \yzh52521\EasyHttp\Response status()
* @method \yzh52521\EasyHttp\Response successful()
* @method \yzh52521\EasyHttp\Response ok()
* @method \yzh52521\EasyHttp\Response redirect()
* @method \yzh52521\EasyHttp\Response clientError()
* @method \yzh52521\EasyHttp\Response serverError()
*/
class Request
{
/**
* \GuzzleHttp\Client单例
* @var array
*/
private static $instances = [];
/**
* \GuzzleHttp\Client;
* @var Client
*/
protected $client;
/**
* Body格式
* @var string
*/
protected $bodyFormat;
protected $isRemoveBodyFormat = false;
/**
* @var array
*/
protected $options = [];
/**
* @var array
*/
protected $promises = [];
/**
* 并发次数
* @var
*/
protected $concurrency;
/**
*
* @var HandlerStack
*/
protected $handlerStack;
/**
* Request constructor.
*/
public function __construct()
{
$this->client = $this->getInstance();
$this->bodyFormat = 'form_params';
$this->options = [
'http_errors' => false,
];
if (!$this->handlerStack instanceof HandlerStack) {
$this->handlerStack = HandlerStack::create( new CurlHandler() );
}
$this->options['handler'] = $this->handlerStack;
}
/**
* Request destructor.
*/
public function __destruct()
{
if (!empty( $this->promises )) {
Promise\settle( $this->promises )->wait();
}
}
/**
* 获取单例
* @return mixed
*/
public function getInstance()
{
$name = get_called_class();
if (!isset( self::$instances[$name] )) {
self::$instances[$name] = new Client();
}
return self::$instances[$name];
}
public function asForm()
{
$this->bodyFormat = 'form_params';
$this->withHeaders( ['Content-Type' => 'application/x-www-form-urlencoded'] );
return $this;
}
public function asJson()
{
$this->bodyFormat = 'json';
$this->withHeaders( ['Content-Type' => 'application/json'] );
return $this;
}
public function asMultipart(string $name,string $contents,string $filename = null,array $headers = [])
{
$this->bodyFormat = 'multipart';
$this->options = array_filter( [
'name' => $name,
'contents' => $contents,
'headers' => $headers,
'filename' => $filename,
] );
return $this;
}
public function withHost(string $host)
{
$this->options['base_uri'] = $host;
return $this;
}
public function withOptions(array $options)
{
unset( $this->options[$this->bodyFormat],$this->options['body'] );
$this->options = array_merge_recursive( $this->options,$options );
return $this;
}
public function withCert(string $path,string $password)
{
$this->options['cert'] = [$path,$password];
return $this;
}
public function withHeaders(array $headers)
{
$this->options = array_merge_recursive( $this->options,[
'headers' => $headers,
] );
return $this;
}
public function withBasicAuth(string $username,string $password)
{
$this->options['auth'] = [$username,$password];
return $this;
}
public function withDigestAuth(string $username,string $password)
{
$this->options['auth'] = [$username,$password,'digest'];
return $this;
}
public function withUA(string $ua)
{
$this->options['headers']['User-Agent'] = trim( $ua );
return $this;
}
public function withToken(string $token,string $type = 'Bearer')
{
$this->options['headers']['Authorization'] = trim( $type.' '.$token );
return $this;
}
public function withCookies(array $cookies,string $domain)
{
$this->options = array_merge_recursive( $this->options,[
'cookies' => CookieJar::fromArray( $cookies,$domain ),
] );
return $this;
}
public function withProxy($proxy)
{
$this->options['proxy'] = $proxy;
return $this;
}
public function withVersion($version)
{
$this->options['version'] = $version;
return $this;
}
public function withRedirect($redirect = false)
{
$this->options['allow_redirects'] = $redirect;
return $this;
}
public function withVerify($verify = false)
{
$this->options['verify'] = $verify;
return $this;
}
public function withStream($boolean = false)
{
$this->options['stream'] = $boolean;
return $this;
}
public function concurrency(int $times)
{
$this->concurrency = $times;
return $this;
}
public function retry(int $retries = 1,int $sleep = 0)
{
$this->handlerStack->push( ( new Retry() )->handle( $retries,$sleep ) );
$this->options['handler'] = $this->handlerStack;
return $this;
}
public function delay(int $seconds)
{
$this->options['delay'] = $seconds * 1000;
return $this;
}
public function timeout(int $seconds)
{
$this->options['timeout'] = $seconds * 1000;
return $this;
}
public function removeBodyFormat()
{
$this->isRemoveBodyFormat = true;
return $this;
}
public function debug($class)
{
$logger = new Logger( function ($level,$message,array $context) use ($class) {
$class::log( $level,$message );
},function ($request,$response,$reason) {
$requestBody = $request->getBody();
$requestBody->rewind();
//请求头
$requestHeaders = [];
foreach ( (array)$request->getHeaders() as $k => $vs ) {
foreach ( $vs as $v ) {
$requestHeaders[] = "$k: $v";
}
}
//响应头
$responseHeaders = [];
foreach ( (array)$response->getHeaders() as $k => $vs ) {
foreach ( $vs as $v ) {
$responseHeaders[] = "$k: $v";
}
}
$uri = $request->getUri();
$path = $uri->getPath();
if ($query = $uri->getQuery()) {
$path .= '?'.$query;
}
return sprintf(
"Request %s\n%s %s HTTP/%s\r\n%s\r\n\r\n%s\r\n--------------------\r\nHTTP/%s %s %s\r\n%s\r\n\r\n%s",
$uri,
$request->getMethod(),
$path,
$request->getProtocolVersion(),
join( "\r\n",$requestHeaders ),
$requestBody->getContents(),
$response->getProtocolVersion(),
$response->getStatusCode(),
$response->getReasonPhrase(),
join( "\r\n",$responseHeaders ),
$response->getBody()->getContents()
);
} );
$this->handlerStack->push( $logger );
$this->options['handler'] = $this->handlerStack;
return $this;
}
public function attach(string $name,string $contents,string $filename = null,array $headers = [])
{
$this->options['multipart'] = array_filter( [
'name' => $name,
'contents' => $contents,
'headers' => $headers,
'filename' => $filename,
] );
return $this;
}
public function get(string $url,array $query = [])
{
$params= parse_url( $url,PHP_URL_QUERY );
parse_str( $params?:'',$result );
$this->options['query'] = array_merge( $result,$query );
return $this->request( 'GET',$url,$query );
}
public function post(string $url,array $data = [])
{
$this->options[$this->bodyFormat] = $data;
return $this->request( 'POST',$url,$data );
}
public function patch(string $url,array $data = [])
{
$this->options[$this->bodyFormat] = $data;
return $this->request( 'PATCH',$url,$data );
}
public function put(string $url,array $data = [])
{
$this->options[$this->bodyFormat] = $data;
return $this->request( 'PUT',$url,$data );
}
public function delete(string $url,array $data = [])
{
$this->options[$this->bodyFormat] = $data;
return $this->request( 'DELETE',$url,$data );
}
public function head(string $url,array $data = [])
{
$this->options[$this->bodyFormat] = $data;
return $this->request( 'HEAD',$url,$data );
}
public function options(string $url,array $data = [])
{
$this->options[$this->bodyFormat] = $data;
return $this->request( 'OPTIONS',$url,$data );
}
public function getAsync(string $url,$query = null,callable $success = null,callable $fail = null)
{
is_callable( $query ) || $this->options['query'] = $query;
return $this->requestAsync( 'GET',$url,$query,$success,$fail );
}
public function postAsync(string $url,$data = null,callable $success = null,callable $fail = null)
{
is_callable( $data ) || $this->options[$this->bodyFormat] = $data;
return $this->requestAsync( 'POST',$url,$data,$success,$fail );
}
public function patchAsync(string $url,$data = null,callable $success = null,callable $fail = null)
{
is_callable( $data ) || $this->options[$this->bodyFormat] = $data;
return $this->requestAsync( 'PATCH',$url,$data,$success,$fail );
}
public function putAsync(string $url,$data = null,callable $success = null,callable $fail = null)
{
is_callable( $data ) || $this->options[$this->bodyFormat] = $data;
return $this->requestAsync( 'PUT',$url,$data,$success,$fail );
}
public function deleteAsync(string $url,$data = null,callable $success = null,callable $fail = null)
{
is_callable( $data ) || $this->options[$this->bodyFormat] = $data;
return $this->requestAsync( 'DELETE',$url,$data,$success,$fail );
}
public function headAsync(string $url,$data = null,callable $success = null,callable $fail = null)
{
is_callable( $data ) || $this->options[$this->bodyFormat] = $data;
return $this->requestAsync( 'HEAD',$url,$data,$success,$fail );
}
public function optionsAsync(string $url,$data = null,callable $success = null,callable $fail = null)
{
is_callable( $data ) || $this->options[$this->bodyFormat] = $data;
return $this->requestAsync( 'OPTIONS',$url,$data,$success,$fail );
}
public function multiAsync(array $promises,callable $success = null,callable $fail = null)
{
$count = count( $promises );
$this->concurrency = $this->concurrency ?: $count;
$requests = function () use ($promises) {
foreach ( $promises as $promise ) {
yield function () use ($promise) {
return $promise;
};
}
};
$fulfilled = function ($response,$index) use ($success) {
if (!is_null( $success )) {
$response = $this->response( $response );
call_user_func_array( $success,[$response,$index] );
}
};
$rejected = function ($exception,$index) use ($fail) {
if (!is_null( $fail )) {
$exception = $this->exception( $exception );
call_user_func_array( $fail,[$exception,$index] );
}
};
$pool = new Pool( $this->client,$requests(),[
'concurrency' => $this->concurrency,
'fulfilled' => $fulfilled,
'rejected' => $rejected,
] );
$pool->promise();
return $pool;
}
protected function request(string $method,string $url,array $options = [])
{
isset( $this->options[$this->bodyFormat] ) && $this->options[$this->bodyFormat] = $options;
if ($this->isRemoveBodyFormat) {
unset( $this->options[$this->bodyFormat] );
}
try {
$response = $this->client->request( $method,$url,$this->options );
return $this->response( $response );
} catch ( ConnectException $e ) {
throw new ConnectionException( $e->getMessage(),0,$e );
}
}
/**
* 原生请求
* @param string $method
* @param string $url
* @param array $options
* @return Response
* @throws ConnectionException
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function client(string $method,string $url,array $options = [])
{
try {
if (empty( $options )) {
$options = $this->options;
}
$response = $this->client->request( $method,$url,$options );
return $this->response( $response );
} catch ( ConnectException $e ) {
throw new ConnectionException( $e->getMessage(),0,$e );
}
}
/**
* 原生异常请求
* @param string $method
* @param string $url
* @param array $options
* @return Response
* @throws ConnectionException
*/
public function clientAsync(string $method,string $url,array $options = [])
{
try {
if (empty( $options )) {
$options = $this->options;
}
$response = $this->client->requestAsync( $method,$url,$options );
return $this->response( $response );
} catch ( ConnectException $e ) {
throw new ConnectionException( $e->getMessage(),0,$e );
}
}
protected function requestAsync(
string $method,
string $url,
$options = null,
callable $success = null,
callable $fail = null
)
{
if (is_callable( $options )) {
$successCallback = $options;
$failCallback = $success;
} else {
$successCallback = $success;
$failCallback = $fail;
}
isset( $this->options[$this->bodyFormat] ) && $this->options[$this->bodyFormat] = $options;
if ($this->isRemoveBodyFormat) {
unset( $this->options[$this->bodyFormat] );
}
try {
$promise = $this->client->requestAsync( $method,$url,$this->options );
$fulfilled = function ($response) use ($successCallback) {
if (!is_null( $successCallback )) {
$response = $this->response( $response );
call_user_func_array( $successCallback,[$response] );
}
};
$rejected = function ($exception) use ($failCallback) {
if (!is_null( $failCallback )) {
$exception = $this->exception( $exception );
call_user_func_array( $failCallback,[$exception] );
}
};
$promise->then( $fulfilled,$rejected );
$this->promises[] = $promise;
return $promise;
} catch ( ConnectException $e ) {
throw new ConnectionException( $e->getMessage(),0,$e );
}
}
public function wait()
{
if (!empty($this->promises)) {
Promise\settle($this->promises)->wait();
}
$this->promises = [];
}
protected function response($response)
{
return new Response( $response );
}
protected function exception($exception)
{
return new RequestException( $exception );
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace yzh52521\EasyHttp;
class RequestException
{
public $exception;
public function __construct($exception)
{
$this->exception = $exception;
}
public function getCode()
{
return $this->exception->getCode();
}
public function getMessage()
{
return $this->exception->getMessage();
}
public function getFile()
{
return $this->exception->getFile();
}
public function getLine()
{
return $this->exception->getLine();
}
public function getTrace()
{
return $this->exception->getTrace();
}
public function getTraceAsString()
{
return $this->exception->getTraceAsString();
}
}
+212
View File
@@ -0,0 +1,212 @@
<?php
namespace yzh52521\EasyHttp;
use ArrayAccess;
use LogicException;
class Response implements ArrayAccess
{
protected $response;
/**
* The decoded JSON response.
*
* @var array
*/
protected $decoded;
public function __construct($response)
{
$this->response = $response;
}
/**
* Get the body of the response.
* @return string
*/
public function body()
{
return (string)$this->response->getBody();
}
/**
* Get the Array decoded body of the response.
* @return array|mixed
*/
public function array()
{
if (!$this->decoded) {
$this->decoded = json_decode( (string)$this->response->getBody(),true );
}
return $this->decoded;
}
/**
* Get the JSON decoded body of the response.
* @return object|mixed
*/
public function json()
{
if (!$this->decoded) {
$this->decoded = json_decode( (string)$this->response->getBody() );
}
return $this->decoded;
}
/**
* Get a header from the response.
* @param string $header
* @return mixed
*/
public function header(string $header)
{
return $this->response->getHeaderLine( $header );
}
/**
* Get the headers from the response.
* @return mixed
*/
public function headers()
{
return $this->mapWithKeys( $this->response->getHeaders(),function ($v,$k) {
return [$k => $v];
} )->response;
}
/**
* Get the status code of the response.
* @return int
*/
public function status()
{
return (int)$this->response->getStatusCode();
}
/**
* Determine if the request was successful.
* @return bool
*/
public function successful()
{
return $this->status() >= 200 && $this->status() < 300;
}
/**
* Determine if the response code was "OK".
* @return bool
*/
public function ok()
{
return $this->status() === 200;
}
/**
* Determine if the response was a redirect.
* @return bool
*/
public function redirect()
{
return $this->status() >= 300 && $this->status() < 400;
}
/**
* Determine if the response indicates a client error occurred.
* @return bool
*/
public function clientError()
{
return $this->status() >= 400 && $this->status() < 500;
}
/**
* Determine if the response indicates a server error occurred.
* @return bool
*/
public function serverError()
{
return $this->status() >= 500;
}
/**
* Determine if the given offset exists.
*
* @param string $offset
* @return mixed
*/
#[\ReturnTypeWillChange]
public function offsetExists($offset)
{
return array_key_exists( $offset,$this->json() );
}
/**
* Get the value for a given offset.
*
* @param string $offset
* @return mixed
*/
#[\ReturnTypeWillChange]
public function offsetGet($offset)
{
return $this->json()[$offset];
}
/**
* Set the value at the given offset.
*
* @param string $offset
* @param mixed $value
* @return void
*
* @throws \LogicException
*/
#[\ReturnTypeWillChange]
public function offsetSet($offset,$value)
{
throw new LogicException( 'Response data may not be mutated using array access.' );
}
/**
* Unset the value at the given offset.
*
* @param string $offset
* @return void
*
* @throws \LogicException
*/
#[\ReturnTypeWillChange]
public function offsetUnset($offset)
{
throw new LogicException( 'Response data may not be mutated using array access.' );
}
/**
* Get the body of the response.
*
* @return string
*/
public function __toString()
{
return $this->body();
}
protected function mapWithKeys($items,callable $callback)
{
$result = [];
foreach ( $items as $key => $value ) {
$assoc = $callback( $value,$key );
foreach ( $assoc as $mapKey => $mapValue ) {
$result[$mapKey] = $mapValue;
}
}
return new static( $result );
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace yzh52521\EasyHttp;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Exception\ConnectException;
class Retry
{
public function handle($retries,$sleep)
{
return Middleware::retry($this->decider($retries), $this->delay($sleep));
}
protected function decider(int $times)
{
return function (
$retries,
Request $request,
Response $response = null,
RequestException $exception = null
) use ($times) {
// 超过最大重试次数,不再重试
if ($retries >= $times) {
return false;
}
return $exception instanceof ConnectException || $exception instanceof ServerException || ($response && $response->getStatusCode() >= 500);
};
}
/**
* 返回一个匿名函数,该匿名函数返回下次重试的时间(毫秒)
* @param int $retry_delay
* @return \Closure
*/
protected function delay(int $retry_delay)
{
return function ($retries) use ($retry_delay) {
return $retry_delay * $retries;
};
}
}
@@ -0,0 +1,6 @@
build
vendor
.vscode
.phpunit*
composer.lock
.idea
+158
View File
@@ -0,0 +1,158 @@
# webman-throttle
## 作用
通过本中间件可限定用户在一段时间内的访问次数,可用于保护接口防爬防爆破的目的。
## 安装
```composer require yzh52521/webman-throttle```
安装后会自动为项目生成 config/plugin/yzh52521/throttle/app.php (***注意:任何时候你都不应该修改最后一行的 hash注释***)配置文件,安装后组件不会自动启用,需要手动设置。
## 开启
插件以中间件的方式进行工作,因此它的开启与其他中间件一样,例如在全局中间件中使用:
```
<?php
//cat config/middleware.php
return [
// 全局中间件
'' => [
// ... 这里省略其它中间件
app\middleware\Throttle::class,
]
];
```
## 配置说明
```
<?php
// cat config/plugin/yzh52521/throttle/app.php
// 中间件配置
return [
// 缓存键前缀,防止键值与其他应用冲突
'prefix' => 'throttle_',
// 缓存的键,true 表示使用来源ip (request->getRealIp(true))
'key' => true,
// 要被限制的请求类型, eg: GET POST PUT DELETE HEAD
'visit_method' => ['GET'],
// 设置访问频率,例如 '10/m' 指的是允许每分钟请求10次。值 null 表示不限制,
// eg: null 10/m 20/h 300/d 200/300
'visit_rate' => '100/m',
// 响应体中设置速率限制的头部信息,含义见:https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
'visit_enable_show_rate_limit' => true,
// 访问受限时返回的响应( type: null|callable )
'visit_fail_response' => function (Throttle $throttle, Request $request, int $wait_seconds): Response {
return response('Too many requests, try again after ' . $wait_seconds . ' seconds.', 429);
},
/*
* 设置节流算法,组件提供了四种算法:
* - CounterFixed :计数固定窗口
* - CounterSlider: 滑动窗口
* - TokenBucket : 令牌桶算法
* - LeakyBucket : 漏桶限流算法
*/
'driver_name' => CounterFixed::class,
// Psr-16通用缓存库规范: https://blog.csdn.net/maquealone/article/details/79651111
// Cache驱动必须符合PSR-16缓存库规范,最低实现get/set俩个方法 (且需静态化实现)
// static get(string $key, mixed $default=null)
// static set(string $key, mixed $value, int $ttl=0);
//webman默认使用 symfony/cache作为cache组件(https://www.workerman.net/doc/webman/db/cache.html)
'cache_drive' => support\Cache::class,
//使用ThinkCache
//'cache_drive' => think\facade\Cache::class,
];
```
当配置项满足以下条件任何一个时,不会限制访问频率:
```key === false || key === null || visit_rate === null```
其中 key 用来设置缓存键的, 而 visit_rate 用来设置访问频率,单位可以是秒,分,时,天。例如:1/s, 10/m, 98/h, 100/d , 也可以是 100/600 600 秒内最多 100 次请求)。
### 灵活定制
###### 示例一:针对用户个体做限制,key的值可以设为函数,该函数返回新的缓存键值(需要Session支持),例如:
```
'key' => function($throttle, $request) {
return $request->session()->get('user_id');
},
```
###### 实例二:在回调函数里针对不同控制器和方法定制生成key,中间件会进行转换:
```
'key' => function($throttle, $request) {
return implode('/', [
$request->controller,
$request->action,
$request->getRealIp($safe_mode=true)
]);
},
//'key' => 'controller/action/ip' //上述配置的快捷实现
```
###### 示例三:在闭包内修改本次访问频率或临时更换限流策略:(PS:此示例需要本中间件在路由中间件后启用,这样预设的替换功能才会生效。)
```
'key' => function($throttle, $request) {
$throttle->setRate('5/m'); // 设置频率
$throttle->setDriverClass(CounterSlider::class);// 设置限流策略
return true;
},
```
###### 示例四:在路由中独立配置
```
Route::any('/api/driver-ocr', [ app\api\controller\Ocr::class, 'driver'])->middleware([
app\middleware\Throttle::class
]);
//正确使用向中间件传参例子 Webman-framework >= 1.3.16
Route::group('/path', function() {
//路由注册
...
})->setParams(['visit_rate' => '20/m',
...
...
])->middleware(\app\middleware\Throttle::class);
```
## 注意:
1、Webman-framework >= 1.3.16 支持路由向中间件传参 使用 $route->param() 方法
2、禁止访问时,throttle 默认是抛出 HttpResponseException, 当前插件场景下将正常响应一个httpResponse(即不会主动:Throw Exception),特殊需求请在 "visit_fail_response" 匿名函数中配置
## 申明
本库提取于[think-throttle v1.3.x](https://github.com/top-think/think-throttle)
+23
View File
@@ -0,0 +1,23 @@
{
"name": "yzh52521/webman-throttle",
"description": "Webman框架限流中间件",
"type": "library",
"authors": [
{
"name": "yzh52521",
"email": "396751927@qq.com"
}
],
"keywords": [ "webman", "middleware", "throttle", "php"],
"license": "MIT",
"require": {
"php": ">=7.2",
"workerman/webman-framework": "^1.2.1"
},
"minimum-stability": "dev",
"autoload": {
"psr-4": {
"yzh52521\\middleware\\": "src"
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace yzh52521\middleware;
class Install
{
const WEBMAN_PLUGIN = true;
/**
* @var array
*/
protected static $pathRelation = array(
'config/plugin/yzh52521/throttle' => 'config/plugin/yzh52521/throttle',
);
/**
* Install
* @return void
*/
public static function install()
{
copy(__DIR__ . '/middleware.tpl', app_path() . '/middleware/Throttle.php');
static::installByRelation();
}
/**
* Uninstall
* @return void
*/
public static function uninstall()
{
if (is_file(app_path() . "/middleware/Throttle.php")) {
unlink(app_path() . "/middleware/Throttle.php");
}
self::uninstallByRelation();
}
/**
* installByRelation
* @return void
*/
public static function installByRelation()
{
foreach (static::$pathRelation as $source => $dest) {
if ($pos = strrpos($dest, '/')) {
$parent_dir = base_path() . '/' . substr($dest, 0, $pos);
if (!is_dir($parent_dir)) {
mkdir($parent_dir, 0777, true);
}
}
//symlink(__DIR__ . "/$source", base_path()."/$dest");
copy_dir(__DIR__ . "/$source", base_path() . "/$dest");
}
}
/**
* uninstallByRelation
* @return void
*/
public static function uninstallByRelation()
{
foreach (static::$pathRelation as $source => $dest) {
$path = base_path() . "/$dest";
if (!is_dir($path) && !is_file($path)) {
continue;
}
/*if (is_link($path) {
unlink($path);
}*/
remove_dir($path);
}
}
}
+267
View File
@@ -0,0 +1,267 @@
<?php
/**
*
* 访问频率限制中间件
* @link https://github.com/yzh52521/webman-throttle
* @license http://www.apache.org/licenses/LICENSE-2.0
* @copyright The PHP - Tools
*/
declare(strict_types=1);
namespace yzh52521\middleware;
use Psr\SimpleCache\CacheInterface;
use yzh52521\middleware\throttle\{CounterFixed, ThrottleAbstract};
use Webman\Config;
use support\{Container, Cache};
use Webman\Http\{Request,Response};
use function sprintf;
/**
* 访问频率限制中间件
* Class Throttle
* @package app\middleware\Throttle
*/
class Throttle
{
/**
* 默认配置参数
* @var array
*/
public static $default_config = [
'prefix' => 'throttle_', // 缓存键前缀,防止键与其他应用冲突
'key' => true, // 节流规则 true为自动规则
'visit_method' => ['GET', 'HEAD'], // 要被限制的请求类型
'visit_rate' => null, // 节流频率 null 表示不限制 eg: 10/m 20/h 300/d
'visit_enable_show_rate_limit' => true, // 在响应体中设置速率限制的头部信息
'visit_fail_code' => 429, // 访问受限时返回的http状态码,当没有visit_fail_response时生效
'visit_fail_text' => 'Too Many Requests', // 访问受限时访问的文本信息,当没有visit_fail_response时生效
'visit_fail_response' => null, // 访问受限时的响应信息闭包回调
'driver_name' => CounterFixed::class, // 限流算法驱动
'cache_drive' => Cache::class // 缓存驱动
];
public static $duration = [
's' => 1,
'm' => 60,
'h' => 3600,
'd' => 86400,
];
/**
* 缓存对象
* @var CacheInterface
*/
protected $cache;
/**
* 配置参数
* @var array
*/
protected $config = [];
protected $key = null; // 解析后的标识
protected $wait_seconds = 0; // 下次合法请求还有多少秒
protected $now = 0; // 当前时间戳
protected $max_requests = 0; // 规定时间内允许的最大请求次数
protected $expire = 0; // 规定时间
protected $remaining = 0; // 规定时间内还能请求的次数
/**
* @var ThrottleAbstract|null
*/
protected $driver_class = null;
/**
* Throttle constructor.
* @param Cache $cache
* @param Config $config
*/
public function __construct(array $params = [])
{
$this->config = array_merge(static::$default_config, Config::get('plugin.yzh52521.throttle.app',[]), $params);
$this->cache = Container::make($this->config['cache_drive'], []);
}
/**
* 请求是否允许
* @param Request $request
* @return bool
*/
protected function allowRequest(Request $request): bool
{
// 若请求类型不在限制内
if (!in_array($request->method(), $this->config['visit_method'])) {
return true;
}
$key = $this->getCacheKey($request);
if (null === $key) {
return true;
}
[$max_requests, $duration] = $this->parseRate($this->config['visit_rate']);
$micronow = microtime(true);
$now = (int)$micronow;
$this->driver_class = Container::make($this->config['driver_name'], []);
if (!$this->driver_class instanceof ThrottleAbstract) {
throw new \TypeError('The throttle driver must extends ' . ThrottleAbstract::class);
}
$allow = $this->driver_class->allowRequest($key, $micronow, $max_requests, $duration, $this->cache);
if ($allow) {
// 允许访问
$this->now = $now;
$this->expire = $duration;
$this->max_requests = $max_requests;
$this->remaining = $max_requests - $this->driver_class->getCurRequests();
return true;
}
$this->wait_seconds = $this->driver_class->getWaitSeconds();
return false;
}
/**
* 处理限制访问
* @param Request $request
* @param array $params
* @return Response
* @exception
*/
public function handle(Request $request, callable $next, array $params = []): Response
{
if ($params) {
$this->config = array_merge($this->config, $params);
}
$allow = $this->allowRequest($request);
if (!$allow) {
// 访问受限
return $this->buildLimitException($this->wait_seconds, $request);
}
$response = $next($request);
if ((200 <= $response->getStatusCode() || 300 > $response->getStatusCode()) && $this->config['visit_enable_show_rate_limit']) {
// 将速率限制 headers 添加到响应中
$response->withHeaders($this->getRateLimitHeaders());
}
return $response;
}
/**
* 生成缓存的 key
* @param Request $request
* @return null|string
*/
protected function getCacheKey(Request $request): ?string
{
$key = $this->config['key'];
if ($key instanceof \Closure) {
$key = $key($this, $request);
}
if ($key === null || $key === false || $this->config['visit_rate'] === null) {
// 关闭当前限制
return null;
}
if ($key === true) {
$key = $request->getRealIp($safe_mode = true);
} else {
$key = str_replace(
[' ', 'controller/action/ip'],
['', $request->controller . '/' . $request->action . '/' . $request->getRealIp($safe_mode = true)],
strtolower(trim($key))
);
}
return md5($this->config['prefix'] . $key . $this->config['driver_name']);
}
/**
* 解析频率配置项
* @param string $rate
* @return int[]
*/
protected function parseRate($rate): array
{
[$num, $period] = explode("/", $rate);
$max_requests = (int)$num;
$duration = static::$duration[$period] ?? (int)$period;
return [$max_requests, $duration];
}
/**
* 设置速率
* @param string $rate '10/m' '20/300'
* @return $this
*/
public function setRate(string $rate): self
{
$this->config['visit_rate'] = $rate;
return $this;
}
/**
* 设置缓存驱动
* @param CacheInterface $cache
* @return $this
*/
public function setCache(CacheInterface $cache): self
{
$this->cache = $cache;
return $this;
}
/**
* 设置限流算法类
* @param string $class_name
* @return $this
*/
public function setDriverClass(string $class_name): self
{
$this->config['driver_name'] = $class_name;
return $this;
}
/**
* 获取速率限制头
* @return array
*/
public function getRateLimitHeaders(): array
{
return [
'X-Rate-Limit-Limit' => $this->max_requests,
'X-Rate-Limit-Remaining' => max($this->remaining, 0),
'X-Rate-Limit-Reset' => $this->now + $this->expire,
];
}
/**
* 构建 Response Exception
* @param int $wait_seconds
* @param Request $request
* @return Response
*/
public function buildLimitException(int $wait_seconds, Request $request): Response
{
$visitFail = $this->config['visit_fail_response'] ?? null;
if ($visitFail instanceof \Closure) {
$response = $visitFail($this, $request, $wait_seconds);
if (!$response instanceof Response) {
throw new \TypeError(sprintf('The closure must return %s instance', Response::class));
}
} else {
$content = str_replace('__WAIT__', (string)$wait_seconds, $this->config['visit_fail_text']);
$response = new Response($this->config['visit_fail_code'], [], $content);
}
if ($this->config['visit_enable_show_rate_limit']) {
$response->withHeaders(['Retry-After' => $wait_seconds]);
}
return $response;
}
}
@@ -0,0 +1,54 @@
<?php
// +----------------------------------------------------------------------
// | 节流设置
// +----------------------------------------------------------------------
use yzh52521\middleware\Throttle;
use yzh52521\middleware\throttle\CounterFixed;
// use yzh52521\middleware\throttle\CounterSlider;
// use yzh52521\middleware\throttle\TokenBucket;
// use yzh52521\middleware\throttle\LeakyBucket;
use Webman\Http\{Request, Response};
return [
'enable' => true,
// 缓存键前缀,防止键值与其他应用冲突
'prefix' => 'throttle_',
// 缓存的键,true 表示使用来源ip (request->getRealIp(true))
'key' => true,
// 要被限制的请求类型, eg: GET POST PUT DELETE HEAD
'visit_method' => ['GET'],
// 设置访问频率,例如 '10/m' 指的是允许每分钟请求10次。值 null 表示不限制,
// eg: null 10/m 20/h 300/d 200/300
'visit_rate' => '100/m',
// 响应体中设置速率限制的头部信息,含义见:https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
'visit_enable_show_rate_limit' => true,
// 访问受限时返回的响应( type: null|callable )
'visit_fail_response' => function (Throttle $throttle, Request $request, int $wait_seconds): Response {
return response('Too many requests, try again after ' . $wait_seconds . ' seconds.');
},
/*
* 设置节流算法,组件提供了四种算法:
* - CounterFixed :计数固定窗口
* - CounterSlider: 滑动窗口
* - TokenBucket : 令牌桶算法
* - LeakyBucket : 漏桶限流算法
*/
'driver_name' => CounterFixed::class,
// Psr-16通用缓存库规范: https://blog.csdn.net/maquealone/article/details/79651111
// Cache驱动必须符合PSR-16缓存库规范,最低实现get/set俩个方法 (且需静态化实现)
// static get(string $key, mixed $default=null)
// static set(string $key, mixed $value, int $ttl=0);
//webman默认使用 symfony/cache作为cache组件(https://www.workerman.net/doc/webman/db/cache.html)
'cache_drive' => support\Cache::class,
//使用ThinkCache
//'cache_drive' => think\facade\Cache::class,
];
+26
View File
@@ -0,0 +1,26 @@
<?php
/**
* 节流设置
* @copyright The PHP-Tools
*/
namespace app\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Response;
use Webman\Http\Request;
/**
* Class StaticFile
* @package app\middleware
*/
class Throttle implements MiddlewareInterface
{
public function process(Request $request, callable $next):Response
{
if ( $route = $request->route ) {
$params = $route->param();
}
return (new \yzh52521\middleware\Throttle())->handle($request, $next, $params??[]);
}
}
@@ -0,0 +1,28 @@
<?php
namespace yzh52521\middleware\throttle;
/**
* 计数器固定窗口算法
* Class CounterFixed
* @package yzh52521\middleware\throttle
*/
class CounterFixed extends ThrottleAbstract
{
public function allowRequest(string $key, float $micronow, int $max_requests, int $duration, $cache): bool
{
$cur_requests = (int)$cache::get($key, 0);
$now = (int)$micronow;
$wait_reset_seconds = $duration - $now % $duration; // 距离下次重置还有n秒时间
$this->wait_seconds = $wait_reset_seconds % $duration + 1;
$this->cur_requests = $cur_requests;
if ($cur_requests < $max_requests) { // 允许访问
$cache::set($key, $this->cur_requests + 1, $wait_reset_seconds);
return true;
}
return false;
}
}
@@ -0,0 +1,38 @@
<?php
namespace yzh52521\middleware\throttle;
/**
* 计数器滑动窗口算法
* Class CouterSlider
* @package yzh52521\middleware\throttle
*/
class CounterSlider extends ThrottleAbstract
{
public function allowRequest(string $key, float $micronow, int $max_requests, int $duration, $cache): bool
{
$history = $cache::get($key, []);
$now = (int)$micronow;
// 移除过期的请求的记录
$history = array_values(array_filter($history, function ($val) use ($now, $duration) {
return $val >= $now - $duration;
}));
$this->cur_requests = count($history);
if ($this->cur_requests < $max_requests) {
// 允许访问
$history[] = $now;
$cache::set($key, $history, $duration);
return true;
}
if ($history) {
$wait_seconds = $duration - ($now - $history[0]) + 1;
$this->wait_seconds = max($wait_seconds, 0);
}
return false;
}
}
@@ -0,0 +1,29 @@
<?php
namespace yzh52521\middleware\throttle;
/**
* 漏桶算法
* Class LeakyBucket
* @package yzh52521\middleware\throttle
*/
class LeakyBucket extends ThrottleAbstract
{
public function allowRequest(string $key, float $micronow, int $max_requests, int $duration, $cache): bool
{
if ($max_requests <= 0) return false;
$last_time = (float)$cache::get($key, 0); // 最近一次请求
$rate = (float)$duration / $max_requests; // 平均 n 秒一个请求
if ($micronow - $last_time < $rate) {
$this->cur_requests = 1;
$this->wait_seconds = ceil($rate - ($micronow - $last_time));
return false;
}
$cache::set($key, $micronow, $duration);
return true;
}
}
@@ -0,0 +1,43 @@
<?php
namespace yzh52521\middleware\throttle;
use Psr\SimpleCache\CacheInterface;
abstract class ThrottleAbstract
{
/** @var int */
protected int $cur_requests = 0; // 当前已有的请求数
/** @var int */
protected int $wait_seconds = 0; // 距离下次合法请求还有多少秒
/**
* 是否允许访问
* @param string $key 缓存键
* @param float $micronow 当前时间戳,可含毫秒
* @param int $max_requests 允许最大请求数
* @param int $duration 限流时长
* @param CacheInterface $cache 缓存对象
* @return bool
*/
abstract public function allowRequest(string $key, float $micronow, int $max_requests, int $duration, CacheInterface $cache): bool;
/**
* 计算距离下次合法请求还有多少秒
* @return int
*/
public function getWaitSeconds(): int
{
return $this->wait_seconds;
}
/**
* 当前已有的请求数
* @return int
*/
public function getCurRequests(): int
{
return $this->cur_requests;
}
}
@@ -0,0 +1,42 @@
<?php
namespace yzh52521\middleware\throttle;
/**
* 令牌桶算法
* Class TokenBucket
* @package yzh52521\middleware\throttle
*/
class TokenBucket extends ThrottleAbstract
{
public function allowRequest(string $key, float $micronow, int $max_requests, int $duration, $cache): bool
{
if ($max_requests <= 0 || $duration <= 0) return false;
$assist_key = $key . 'store_num'; // 辅助缓存
$rate = (float)$max_requests / $duration; // 平均一秒生成 n 个 token
$last_time = $cache::get($key, null);
$store_num = $cache::get($assist_key, null);
if ($last_time === null || $store_num === null) { // 首次访问
$cache::set($key, $micronow, $duration);
$cache::set($assist_key, $max_requests - 1, $duration);
return true;
}
$create_num = floor(($micronow - $last_time) * $rate); // 推算生成的 token 数
$token_left = (int)min($max_requests, $store_num + $create_num); //当前剩余 tokens 数量
if ($token_left < 1) {
$tmp = (int)ceil($duration / $max_requests);
$this->wait_seconds = $tmp - ($micronow - $last_time) % $tmp;
return false;
}
$this->cur_requests = $max_requests - $token_left;
$cache::set($key, $micronow, $duration);
$cache::set($assist_key, $token_left - 1, $duration);
return true;
}
}