hello,大家好呀,我是yangyang,今天给php的朋友分享下关于在自己的tp项目中使用了think-swoole来全面协程化后遇到的关于导出文件输出流的一点小问题
导出文件
导出报表是非常常见的需求,实现的技术方式有很多,小编不才,先给大家分享一下php我遇到的几种业务场景
- 【一般数据】服务端导出文件二进制流:前端取流操作,
- 下面展示前端的操作
// download.js
import axios from 'axios'
export function download(type, name) {
axios({
method: 'post',
url: 'http://127.0.0.1:8080/api/download',
// headers里面设置token
headers: {
loginCode: 'xxx',
authorization: 'xxx'
},
data: {
name: name,
type: type
},
// 二进制流文件,一定要设置成blob,默认是json
responseType: 'blob'
}).then(res => {
const link = document.createElement('a')
const blob = new Blob([res.data], { type: 'application/vnd.ms-excel' })
link.style.display = 'none'
link.href = URL.createObjectURL(blob)
link.setAttribute('download', `${name}.xlsx`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
})
}
- 【一般数据】服务端生成文件后返回文件下载地址
- 【数据量巨大】服务端通过消息队列等方式后台处理,并合理使用分批次处理数据思想,最后生成文件(若文件很大,可以压缩)后,给用户发邮件,在邮件内容嵌入下载地址。
导出文件的包
- phpoffice(一般数据推荐)
- xlswriter(数据大推荐)
- fputscsv (表格导出csv)
- ....
进入正题
先看有问题的代码
$filename = sprintf("%s-%s.xlsx", $where['did'], date('Ymd'));
$config = [
'path' => $tmp // xlsx文件保存路径
];
$excel = new \Vtiful\Kernel\Excel($config);
// $endIndex = StringHelper::computeExcelCellWord(count($headers));
$filePath = $excel->fileName($filename, 'sheet1')
->header($headers)
->data($dataRows)
->output();
$response = Response::create($filePath, 'file')->options(['denyFilter' => true]);
ob_clean();
flush();
$excel->close();
return $response;
报错信息
Swoole\Http\Response::write(): You have set 'Transfer-Encoding', 'Content-Length' is ignored
我给大家解析下:
这是因为在HTTP/1.1中,当使用分块传输编码(chunked transfer encoding)时,不需要提前知道整个响应体的长度,因此Content-Length头部会被忽略,而响应体会被分成一系列的数据块,每个数据块都带有自己的长度信息。
这个警告通常是正常的,只要你确实打算使用分块传输编码来响应数据,它应该不会影响你的应用程序。只需确保你的代码正确设置了 Transfer-encoding(Transfer-Encoding - HTTP | MDN )头部,以便Swoole知道响应是以分块传输编码的方式发送的。
好的,知道问题了,我们也更好继续排查问题了,首先我们从`response::create($filePath, 'file')` 的方法来分析
- 创建response 对象
这段代码就是根据不同的输出类型,实例调用对应的Response类,我们这里输出的是file,因此我们看下file的output方法。
/**
* 创建Response对象
* @access public
* @param mixed $data 输出数据
* @param string $type 输出类型
* @param int $code 状态码
* @return Response
*/
public static function create($data = '', string $type = 'html', int $code = 200): Response
{
$class = false !== strpos($type, '\\') ? $type : '\\think\\response\\' . ucfirst(strtolower($type));
return Container::getInstance()->invokeClass($class, [$data, $code]);
}
- File Response
根据上面的问题解析,我们可以把代码定位到下面output方法里面的`$this->header['Content-Length'] = $size;`。在原来的fpm模式下,不会指定分块传输,而是整包传输。这里就不会报错,而在swoole模式下,使用了分块传输。因此,我们就把分析对象放到think-swoole的源码上,来看看这个运行时是如何代理承接tp的response的
class File extends Response
{
protected $expire = 360;
protected $name;
protected $mimeType;
protected $isContent = false;
protected $force = true;
public function __construct($data = '', int $code = 200)
{
$this->init($data, $code);
}
/**
* 处理数据
* @access protected
* @param mixed $data 要处理的数据
* @return mixed
* @throws \Exception
*/
protected function output($data)
{
if (!$this->isContent && !is_file($data)) {
throw new Exception('file not exists:' . $data);
}
while (ob_get_level() > 0) {
ob_end_clean();
}
if (!empty($this->name)) {
$name = $this->name;
} else {
$name = !$this->isContent ? pathinfo($data, PATHINFO_BASENAME) : '';
}
if ($this->isContent) {
$mimeType = $this->mimeType;
$size = strlen($data);
} else {
$mimeType = $this->getMimeType($data);
$size = filesize($data);
}
$this->header['Pragma'] = 'public';
$this->header['Content-Type'] = $mimeType ?: 'application/octet-stream';
$this->header['Cache-control'] = 'max-age=' . $this->expire;
$this->header['Content-Disposition'] = ($this->force ? 'attachment; ' : '') . 'filename="' . $name . '"';
$this->header['Content-Length'] = $size;
$this->header['Content-Transfer-Encoding'] = 'binary';
$this->header['Expires'] = gmdate("D, d M Y H:i:s", time() + $this->expire) . ' GMT';
$this->lastModified(gmdate('D, d M Y H:i:s', time()) . ' GMT');
return $this->isContent ? $data : file_get_contents($data);
}
/**
* 设置是否为内容 必须配合mimeType方法使用
* @access public
* @param bool $content
* @return $this
*/
public function isContent(bool $content = true)
{
$this->isContent = $content;
return $this;
}
/**
* 设置有效期
* @access public
* @param integer $expire 有效期
* @return $this
*/
public function expire(int $expire)
{
$this->expire = $expire;
return $this;
}
/**
* 设置文件类型
* @access public
* @param string $filename 文件名
* @return $this
*/
public function mimeType(string $mimeType)
{
$this->mimeType = $mimeType;
return $this;
}
/**
* 设置文件强制下载
* @access public
* @param bool $force 强制浏览器下载
* @return $this
*/
public function force(bool $force)
{
$this->force = $force;
return $this;
}
/**
* 获取文件类型信息
* @access public
* @param string $filename 文件名
* @return string
*/
protected function getMimeType(string $filename): string
{
if (!empty($this->mimeType)) {
return $this->mimeType;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
return finfo_file($finfo, $filename);
}
/**
* 设置下载文件的显示名称
* @access public
* @param string $filename 文件名
* @param bool $extension 后缀自动识别
* @return $this
*/
public function name(string $filename, bool $extension = true)
{
$this->name = $filename;
if ($extension && false === strpos($filename, '.')) {
$this->name .= '.' . pathinfo($this->data, PATHINFO_EXTENSION);
}
return $this;
}
}
浅浅分析think-swoole找答案
进入源码包(大家可以在phpstorm下打开composer.json后, ctrl或者command 后点击指定包进入)
- 服务启动,这里重点关注Manager 下 InteractsWithHttp 特性, events 属性, initialize方法
use InteractsWithServer,
InteractsWithSwooleTable,
InteractsWithHttp,
InteractsWithWebsocket,
InteractsWithPools,
InteractsWithRpcClient,
InteractsWithRpcServer,
WithApplication;
/**
* @var App
*/
protected $container;
/**
* Server events.
*
* @var array
*/
protected $events = [
'start',
'shutDown',
'workerStart',
'workerStop',
'workerError',
'workerExit',
'packet',
'task',
'finish',
'pipeMessage',
'managerStart',
'managerStop',
'request',
];
/**
* 启动服务
*/
public function run(): void
{
$this->getServer()->set([
'task_enable_coroutine' => true,
'send_yield' => true,
'reload_async' => true,
'enable_coroutine' => true,
'max_request' => 0,
'task_max_request' => 0,
]);
$this->initialize();
$this->triggerEvent('init');
//热更新
if ($this->getConfig('hot_update.enable', false)) {
$this->addHotUpdateProcess();
}
$this->getServer()->start();
}
- 监听绑定各种事件
/**
* Set swoole server listeners.
*/
protected function setSwooleServerListeners()
{
foreach ($this->events as $event) {
$listener = Str::camel("on_$event");
$callback = method_exists($this, $listener) ? [$this, $listener] : function () use ($event) {
$this->triggerEvent($event, func_get_args());
};
$this->getServer()->on($event, $callback);
}
}
- InteractsWithHttp-runInSandbox
这段代码就是在swoole环境下去生成tp框架的生命周期,类比tp框架原来的web入口文件。最后在回调里就是对请求的解析和响应的生成(我这里没有深读每一句代码,有错的地方,大家可以评论留言)。runInSandbox 配合一个fn,非常灵活,可以自定义,或者接入其它的运行时(比如workerman、native php 、 snow)
/**
* 在沙箱中执行
* @param Closure $callable
* @param null $fd
* @param bool $persistent
*/
protected function runInSandbox(Closure $callable, $fd = null, $persistent = false)
{
try {
$this->getSandbox()->run($callable, $fd, $persistent);
} catch (Throwable $e) {
$this->logServerError($e);
}
}
- InteractsWithHttp-sendResponse
一起看下sendChunk:
这个方法用于以块的形式发送响应内容,特别是对于较大的响应内容,避免一次性发送整个内容,而是按照一定的块大小分块发送。可见think-swoole这样的方案有助于优化内存的使用,特别是在处理大量数据时,通过将数据分块发送,可以减小每次发送的数据量,降低内存压力。
protected function sendResponse(Response $res, \think\Response $response, Cookie $cookie)
{
// 发送Header
foreach ($response->getHeader() as $key => $val) {
$res->header($key, $val);
}
// 发送状态码
$code = $response->getCode();
$res->status($code, isset(self::$statusTexts[$code]) ? self::$statusTexts[$code] : 'unknown status');
foreach ($cookie->getCookie() as $name => $val) {
[$value, $expire, $option] = $val;
$res->cookie($name, $value, $expire, $option['path'], $option['domain'], $option['secure'] ? true : false, $option['httponly'] ? true : false, $option['samesite']);
}
$content = $response->getContent();
$this->sendByChunk($res, $content);
}
protected function sendByChunk(Response $res, $content)
{
$contentSize = \strlen($content);
$chunkSize = 8192;
if ($contentSize > $chunkSize) {
$sendSize = 0;
do {
if (!$res->write(\substr($content, $sendSize, $chunkSize))) {
break;
}
} while (($sendSize += $chunkSize) < $contentSize);
$res->end();
} else {
$res->end($content);
}
}
- 关于8192字节
8192 字节(8 KB)通常是在网络传输中一个比较合理的默认块大小。这是因为在计算机网络中,很多协议和系统都以字节为基本单位进行数据传输,而 8192 字节是 8 KB,这个大小在许多情况下可以被高效地处理。
参考:https://blog.csdn.net/zhangdong2516941/article/details/84203858
一道有趣的网络题
- 推荐一篇关于讲http transfer-encoding的好文:HTTP 鍗忚涓殑 Transfer-Encoding - 鎺橀噾
解决方案
重写File类,取消output方法内的:$this->header['Content-Length'] = $size;
Think-Swoole介绍
早期版本的 ThinkPHP 主要是运行在 PHP-FPM 模式下。为了解决长连接、高并发、阻塞 IO 的问题,ThinkPHP 官方提供了 Think-Swoole 组件,底层全面适配了 Swoole 协程,使得 ThinkPHP 应用可以一键协程化。
GitHub 地址
- ThinkPHP
- Think-Swoole
创建 ThinkPHP 项目
composer create-project topthink/think tp
使用 composer 命令可以快速创建一个 ThinkPHP 新项目。已有项目可跳过此步骤。
引入 Think-Swoole 组件
composer require topthink/think-swoole
启动 HTTP 服务
直接在命令行下启动 HTTP 服务端。
php think swoole
启动完成后,默认会在 0.0.0.0:80 启动一个 HTTP Server,可以直接访问当前的应用。相关配置参数可以在 config/swoole.php 里面配置(具体参考配置文件内容)。
若本机已安装了 Nginx,可能 80 已被占用,可修改 config/swoole.php 设置为其他的端口
启动后通过 http://127.0.0.1:9580/ 访问程序
热更新
由于 Swoole 服务运行过程中 PHP 文件是常驻内存运行的,这样可以避免重复读取磁盘、重复解释编译,以便达到最高性能。所以更改业务代码后必须手动reload 或者 restart 才能生效。
Think-Swoole 提供了热更新功能,在检测到相关目录的文件有更新后会自动 reload,从而不需要手动进行 reload 操作,方便开发调试。
如果你的应用开启了调试模式,默认是开启热更新的。原则上,在部署模式下不建议开启文件监控,一方面有性能损耗,另外一方面对文件所做的任何修改都需要确认无误才能进行更新部署。
热更新的默认配置如下:
'hot_update' => [
'enable' => env('APP_DEBUG', false),
'name' => ['*.php'],
'include' => [app_path()],
'exclude' => [],
],
当我们在应用的根目录下定义一个特殊的 .env 环境变量文件,里面设置了 APP_DEBUG=true 会默认开启热更新,你也可以直接把 enable 设置为true 。
连接池
Think-Swoole 实现了数据库连接池功能,包括 MySQL、Redis 等。
使用连接池要先开启 Swoole 一键协程,需要配置如下参数:
'coroutine' => [
'enable' => true,
'flags' => SWOOLE_HOOK_ALL,
],
连接池配置参数如下:
'pool' =>[
'db' => [
'enable' => true,
'max_active' => 3,
'max_wait_time' => 5,
],
'cache' => [
'enable' => true,
'max_active' => 3,
'max_wait_time' => 5,
],
],
参数说明:
- enable:是否启用连接池
- max_active:最大连接数,超过将不再新建连接
- max_wait_time:超时时间,单位为秒
max_active 和 max_wait_time 需要根据自身业务和环境进行适当调整,最大化提高系统负载