分组件改造
数据库(mysqli)
在Config\Database中重写框架原始数据库连接实例化方法
// app/Config/Database.php
class Database extends Config
{
// ...其他内容
// 新增这个方法
public static function connect($group = null, bool $getShared = true) : \CodeIgniter\Database\ConnectionInterface
{
if(empty($group)){
$cfg=config('Database');
$group=$cfg->defaultGroup;
$config=$cfg->$group;
}elseif(is_string($group)){
$cfg=config('Database');
if(!isset($cfg->$group)) throw new \InvalidArgumentException('不存在的数据库连接组!');
$config=$cfg->$group;
}elseif(!($group instanceof \CodeIgniter\Database\ConnectionInterface)){
$config=$group;
$group='custom-'.md5(json_encode($config));
}else return $group;
if(empty($config['DBDriver'])) throw new \InvalidArgumentException('数据库驱动不能为空!');
// method_exists会触发autoloader,为节约性能不宜使用
// 如果将来PHP支持不触发autoloader查找类方法,将下面的if改成method_exists可以更直观
if(class_exists('\Swoole\ConnectionPool',FALSE)&&\Swoole\Coroutine::getCid()!==-1){
$connectionName='\App\Swoole\Database\\'.$config['DBDriver'].'\Connection';
// 大多情况下,连接池中的连接都是拿来就能用的,因此这里完全不需要考虑$getShared参数
if(class_exists($connectionName)) return new $connectionName($group,$config);
}
if($getShared&&isset(static::$instances[$group])) return static::$instances[$group];
if(class_exists('\App\Database\\'.$config['DBDriver'].'\Connection')) $config['DBDriver']='\App\Database\\'.$config['DBDriver'];
static::ensureFactory();
return static::$instances[$group]=static::$factory->load($config, $group);
}
在app/Database目录下增加几个自定义的改动,可以在这些文件里面扩展框架的数据库功能
app/Database/Trait_Builder.php
<?php namespace App\Database;
trait Trait_Builder {
public function selectIntoTempTable(string $tb=NULL)
{
$tempTable=$this->db->createTempTable($this->compileSelect(),$tb,$this->binds,false);
$this->resetSelect();
$this->binds=[];
return $tempTable;
}
}
app/Database/Trait_Connection.php
<?php namespace App\Database;
trait Trait_Connection {
/*临时表*/
public function createTempTable(string $sql,string $name=NULL,...$args) : string
{
if(empty($name)){
$name='temp_';
do{
$name.=chr(rand(65,90));
}while(in_array($name,$this->tempTables));
}
$this->query("CREATE TEMPORARY TABLE `{$name}` ".$sql,...$args);
return $this->tempTables[]=$name;
}
private array $tempTables=[];
public function __destruct()
{
if(empty($this->connID)) return;
foreach($this->tempTables AS $tb) $this->query("DROP TEMPORARY TABLE IF EXISTS `{$tb}`");
}
}
app/Database/MySQLi/Builder.php
<?php namespace App\Database\MySQLi;
class Builder extends \CodeIgniter\Database\MySQLi\Builder {
use \App\Database\Trait_Builder;
}
app/Database/MySQLi/Connection.php
<?php namespace App\Database\MySQLi;
class Connection extends \CodeIgniter\Database\MySQLi\Connection {
use \App\Database\Trait_Connection;
}
app/Database/MySQLi/Forge.php
<?php namespace App\Database\MySQLi;
class Forge extends \CodeIgniter\Database\MySQLi\Forge {}
app/Database/MySQLi/PreparedQuery.php
<?php namespace App\Database\MySQLi;
class PreparedQuery extends \CodeIgniter\Database\MySQLi\PreparedQuery {}
app/Database/MySQLi/Result.php
<?php namespace App\Database\MySQLi;
class Result extends \CodeIgniter\Database\MySQLi\Result {
}
app/Database/MySQLi/Utils.php
<?php namespace App\Database\MySQLi;
class Utils extends \CodeIgniter\Database\MySQLi\Utils {
// 从CI3中拷贝过来的,用不着的话可以不加这段
public function _backup(array $params = null)
{
if (count($params) === 0)
{
return FALSE;
}
// Extract the prefs for simplicity
extract($params);
// Build the output
$output = '';
// Do we need to include a statement to disable foreign key checks?
if ($foreign_key_checks === FALSE)
{
$output .= 'SET foreign_key_checks = 0;'.$newline;
}
foreach ( (array) $tables as $table)
{
// Is the table in the "ignore" list?
if (in_array($table, (array) $ignore, TRUE))
{
continue;
}
// Get the table schema
$query = $this->db->query('SHOW CREATE TABLE '.$this->db->escapeIdentifiers($this->db->database.'.'.$table));
// No result means the table name was invalid
if ($query === FALSE)
{
continue;
}
// Write out the table schema
$output .= '#'.$newline.'# TABLE STRUCTURE FOR: '.$table.$newline.'#'.$newline.$newline;
if ($add_drop === TRUE)
{
$output .= 'DROP TABLE IF EXISTS '.$this->db->protectIdentifiers($table).';'.$newline.$newline;
}
$i = 0;
$result = $query->getResultArray();
foreach ($result[0] as $val)
{
if ($i++ % 2)
{
$output .= $val.';'.$newline.$newline;
}
}
// If inserts are not needed we're done...
if ($add_insert === FALSE)
{
continue;
}
// Grab all the data from the current table
$query = $this->db->query('SELECT * FROM '.$this->db->protectIdentifiers($table));
if ($query->numRows === 0)
{
continue;
}
// Fetch the field names and determine if the field is an
// integer type. We use this info to decide whether to
// surround the data with quotes or not
$i = 0;
$field_str = '';
$is_int = array();
while ($field = $query->resultID->fetch_field())
{
// Most versions of MySQL store timestamp as a string
$is_int[$i] = in_array(strtolower($field->type),
array('tinyint', 'smallint', 'mediumint', 'int', 'bigint'), //, 'timestamp'),
TRUE);
// Create a string of field names
$field_str .= $this->db->escapeIdentifiers($field->name).', ';
$i++;
}
// Trim off the end comma
$field_str = preg_replace('/, $/' , '', $field_str);
// Build the insert string
foreach ($query->getResultArray() as $row)
{
$val_str = '';
$i = 0;
foreach ($row as $v)
{
// Is the value NULL?
if ($v === NULL)
{
$val_str .= 'NULL';
}
else
{
// Escape the data if it's not an integer
$val_str .= ($is_int[$i] === FALSE) ? $this->db->escape($v) : $v;
}
// Append a comma
$val_str .= ', ';
$i++;
}
// Remove the comma at the end of the string
$val_str = preg_replace('/, $/' , '', $val_str);
// Build the INSERT string
$output .= 'INSERT INTO '.$this->db->protectIdentifiers($table).' ('.$field_str.') VALUES ('.$val_str.');'.$newline;
}
$output .= $newline.$newline;
}
// Do we need to include a statement to re-enable foreign key checks?
if ($foreign_key_checks === FALSE)
{
$output .= 'SET foreign_key_checks = 1;'.$newline;
}
return $output;
}
}
划重点!!!
在app目录下建一个Swoole目录,专门用来放适配swoole的内容,这里先介绍数据库相关的,后面还有其他内容也要放到这个目录下。
改造数据库的核心文件有三个:
/app/Swoole/Database/Trait_Connection.php
<?php namespace App\Swoole\Database;
trait Trait_Connection {
// 分组存放连接池的容器,键名为数据库参数组或自定义参数的json
public static $pools=[];
// 经测试,在trait中修改静态属性不会影响trait本身的静态属性
// 当然,更不可能影响其他引用此trait的类的静态属性
public function __construct(private string $group,...$args)
{
parent::__construct(...$args);
$this->queryClass=Query::class;
if(!isset(self::$pools[$group])) self::$pools[$group]=$this->createPool();
}
abstract private function createPool() : \Swoole\ConnectionPool;
public function __destruct()
{
parent::__destruct();
if(!empty($this->connID)) self::$pools[$this->group]->put($this->connID);
}
// __clone在新对象中执行,因此不会影响原对象的后续处理
public function __clone()
{
if(!empty($this->connID)) $this->connID=NULL;
}
}
/app/Swoole/Database/Query.php
<?php namespace App\Swoole\Database;
class Query extends \CodeIgniter\Database\Query
{
// 如果这里不克隆,就会出现如下情形:
// 一次SELECT查询后,db实例中有了一个lastQuery属性,lastQuery属性中又有一个db属性,指向创建它的db实例
// 结果在资源释放的时候陷入死循环:db要释放lastQuery属性,lastQuery又要释放db属性
public function __construct(\CodeIgniter\Database\ConnectionInterface $db)
{
$this->db=clone $db;
}
}
/app/Swoole/Database/MySQLi/Connection.php
<?php namespace App\Swoole\Database\MySQLi;
final class Connection extends \App\Database\MySQLi\Connection {
use \App\Swoole\Database\Trait_Connection;
private function createPool() : \Swoole\ConnectionPool
{
$config=new \Swoole\Database\MysqliConfig();
$config->withHost($this->hostname);
$config->withPort($this->port);
$config->withUsername($this->username);
$config->withPassword($this->password);
$config->withDbName($this->database);
$config->withCharset($this->charset); //???未生效??
// $config->withUnixSocket('/tmp/mysql.sock');
return new \Swoole\Database\MysqliPool($config,DB_POOL_SIZE);
}
protected function _close()
{
$this->connID->close();
self::$pools[$this->group]->put(NULL);
}
public function connect(bool $persistent = false)
{
if(!empty($this->connID)) $this->_close();
$this->connID=self::$pools[$this->group]->get();
return $this->mysqli=$this->connID;
}
}
// mysql的默认空闲时长为8小时,超时后会自动断开连接,故每8小时要清空一次连接池
\Swoole\Timer::tick(28700000,function(){
foreach(Connection::$pools AS $pool){
for($i=DB_POOL_SIZE;$i!==0;$i--){
$pool->get();
$pool->put(NULL);
}
}
});
外加三个兼容性文件
/app/Swoole/Database/MySQLi/Builder.php
<?php namespace App\Swoole\Database\MySQLi;
class Builder extends \App\Database\MySQLi\Builder {
//这里可以做一些协程化的处理,例如batch
}
/app/Swoole/Database/MySQLi/PreparedQuery.php
<?php namespace App\Swoole\Database\MySQLi;
class PreparedQuery extends \App\Database\MySQLi\PreparedQuery {}
/app/Swoole/Database/MySQLi/Result.php
<?php namespace App\Swoole\Database\MySQLi;
class Result extends \App\Database\MySQLi\Result {}
“全局”空间
曾经听过这么一种说法:越差劲的程序员越喜欢往全局空间塞变量。如果一个PHPer喜欢动不动就global一下,那TA大概是一个面向过程的爱好者,然而PHP早就转为面向对象语言了。
在FPM模式下大量使用全局变量通常只产生一些阅读方面的问题,但是在swoole中就万万不能这么干了,因为同一个worker进程中的所有request共用了同一个全局空间,并且在worker退出前会一直占着内存。可确实有一些变量需要在多处使用,并且要在请求结束后释放,同时又难以层层传递,咋办呢?
好在swoole提供了协程上下文这么个东西,我的思路就是每个请求都创建一个根级协程,在这个请求周期内用的全局变量都放到协程上下文中,请求结束后随着根协程一起自动释放。核心方法就几行,具体放哪见后面$app生命周期部分。
// 这个方法很简单,但是不推荐在业务代码中使用,而是提供一些专门的方法
function getRootContext()
{
$pcid=Swoole\Coroutine::getPcid();
while($pCtx=Swoole\Coroutine::getContext($pcid)){
$ctx=$pCtx;
$pcid=Swoole\Coroutine::getPcid($pcid);
}
return $ctx??Swoole\Coroutine::getContext();
}
// 例如存放共享类实例
function getSharedInstance(string $className)
{
$ctx=getRootContext();
return $ctx[$className]??$ctx[$className]=new $className();
}
// 以及覆盖框架的model和session方法
function model(string $name, bool $getShared = true, ?CodeIgniter\Database\ConnectionInterface &$conn = null)
{
if(!$getShared) return CodeIgniter\Config\Factories::models($name, ['getShared' => FALSE], $conn);
$rootContext=getRootContext();
if(isset($rootContext[$name])) return $rootContext[$name];
return $rootContext[$name]=CodeIgniter\Config\Factories::models($name, ['getShared' => FALSE], $conn);
}
function session(?string $val=null)
{
global $sessionFactory;
$ctx=getRootContext();
if(!isset($ctx->session)) $ctx->session=$sessionFactory->create($ctx->request,$ctx->response,$ctx->logger);
return is_string($val)?$ctx->session->get($val):$ctx->session;
}
HTTP请求和响应
如上所述,在swoole中已经很难再用全局变量了,那么在FPM中常用的$_GET、$_POST、$_SESSION等请求相关的变量自然也是不能用的。
另外,PHP进程中的echo是对进程启动者的响应,在FPM模式下,一个请求就会启动一个进程,可以理解为是用户启动了一个进程,因此很容易做到面向用户echo。而swoole进程通常是开发者启动的,就算不是开发者也是某个脚本启动,不会对应到用户,因此也就不可能面向用户echo,那么对用户的响应自然也就不能用echo了。因此swoole提供了HTTP\Response对象来解决这个问题。其实很多web服务的编程语言都是这么干的,比如nodejs中也有ServerResponse类,PHP中的echo可以类比为前端的console.log等,也即某些语言中的“标准输出(stdout)”,例如c#。
IncomingRequest类有一些没用到的内容,因为懒得花精力调试,干脆就没改,用到的都改完了:
<?php namespace App\Swoole\HTTP;
use CodeIgniter\HTTP\Exceptions\HTTPException;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\HTTP\URI;
use Config\Services;
use Config\App;
use Locale;
class UserAgent extends \CodeIgniter\HTTP\UserAgent
{
public function __construct(string $agent)
{
$this->agent=$agent;
$this->config=new \Config\UserAgents();
$this->compileData();
}
}
class IncomingRequest extends \CodeIgniter\HTTP\Request
{
/**
* Enable CSRF flag
*
* Enables a CSRF cookie token to be set.
* Set automatically based on Config setting.
*
* @var bool
*
* @deprecated Not used
*/
protected $enableCSRF = false;
/**
* The URI for this request.
*
* Note: This WILL NOT match the actual URL in the browser since for
* everything this cares about (and the router, etc) is the portion
* AFTER the script name. So, if hosted in a sub-folder this will
* appear different than actual URL. If you need that use getPath().
*
* @TODO should be protected. Use getUri() instead.
*
* @var URI
*/
public $uri;
/**
* The detected path (relative to SCRIPT_NAME).
*
* Note: current_url() uses this to build its URI,
* so this becomes the source for the "current URL"
* when working with the share request instance.
*
* @var string|null
*/
protected $path;
/**
* File collection
*
* @var FileCollection|null
*/
protected $files;
/**
* Negotiator
*
* @var Negotiate|null
*/
protected $negotiator;
/**
* The default Locale this request
* should operate under.
*
* @var string
*/
protected $defaultLocale;
/**
* The current locale of the application.
* Default value is set in Config\App.php
*
* @var string
*/
protected $locale;
/**
* Stores the valid locale codes.
*
* @var array
*/
protected $validLocales = [];
/**
* Configuration settings.
*
* @var App
*/
/**
* Holds the old data from a redirect.
*
* @var array
*/
protected $oldInput = [];
/**
* The user agent this request is from.
*
* @var UserAgent
*/
protected $userAgent;
public function __construct(public $config,private \Swoole\Http\Request $rqst)
{
$this->proxyIPs = $config->proxyIPs;
$this->userAgent=new UserAgent(isset($rqst->header['user-agent'])?trim($rqst->header['user-agent']):'');
$this->validLocales=$config->supportedLocales;
$this->method=$rqst->server['request_method'];
$this->body=$rqst->getContent()?:null;
foreach($rqst->header AS $k => $v){
$this->setHeader($k,$v);
$this->headerMap[strtolower($k)] = $k;
}
$this->populateGlobals('server');
$this->populateGlobals('get');
$uri=URI::removeDotSegments($rqst->server['request_uri']);
$this->path=($uri==='/'||$uri==='')?'/':ltrim($uri,'/');
$this->uri=Services::uri($this->path,FALSE);
$this->uri->setScheme(parse_url($config->baseURL, PHP_URL_SCHEME));
$this->uri->setHost(parse_url($config->baseURL, PHP_URL_HOST));
$this->uri->setPort(parse_url($config->baseURL, PHP_URL_PORT));
$this->uri->setQuery($rqst->server['query_string']??'');
if($config->forceGlobalSecureRequests&&$this->uri->getScheme()==='http') $this->uri->setScheme('https');
$this->locale=$this->defaultLocale = $config->defaultLocale;
if($config->negotiateLocale) $this->setLocale($this->negotiate('language',$config->supportedLocales));
}
public function getServer($index = null, $filter = null, $flags = null)
{
if($index){
if (sscanf($index, 'HTTP_%s', $header) === 1) {
// take SOME_HEADER and turn it into Some-Header
$header = str_replace('_', ' ', strtolower($header));
return $this->getHeaderLine(str_replace(' ', '-', ucwords($header)));
}
$index=strtolower($index);
}
return $this->fetchGlobal('server', $index, $filter, $flags);
}
protected function populateGlobals(string $method)
{
// Don't populate ENV as it might contain
// sensitive data that we don't want to get logged.
switch ($method) {
case 'get':
$this->globals['get'] = $this->rqst->get??[];
break;
case 'post':
$this->globals['post'] = $this->rqst->post??[];
break;
case 'cookie':
$this->globals['cookie'] = $this->rqst->cookie??[];
break;
case 'server':
$this->globals['server'] = $this->rqst->server;
break;
default:
$this->globals[$method] = [];
break;
}
}
/**
* Provides a convenient way to work with the Negotiate class
* for content negotiation.
*/
public function negotiate(string $type, array $supported, bool $strictMatch = false): string
{
if ($this->negotiator === null) {
$this->negotiator = Services::negotiator($this, true);
}
switch (strtolower($type)) {
case 'media':
return $this->negotiator->media($supported, $strictMatch);
case 'charset':
return $this->negotiator->charset($supported);
case 'encoding':
return $this->negotiator->encoding($supported);
case 'language':
return $this->negotiator->language($supported);
}
throw HTTPException::forInvalidNegotiationType($type);
}
/**
* Determines if this request was made from the command line (CLI).
*/
public function isCLI(): bool
{
return false;
}
/**
* Test to see if a request contains the HTTP_X_REQUESTED_WITH header.
*/
public function isAJAX(): bool
{
return $this->hasHeader('X-Requested-With') && strtolower($this->header('X-Requested-With')->getValue()) === 'xmlhttprequest';
}
/**
* Attempts to detect if the current connection is secure through
* a few different methods.
*/
public function isSecure(): bool
{
if ($this->hasHeader('X-Forwarded-Proto') && $this->header('X-Forwarded-Proto')->getValue() === 'https') return true;
return $this->hasHeader('Front-End-Https') && ! empty($this->header('Front-End-Https')->getValue()) && strtolower($this->header('Front-End-Https')->getValue()) !== 'off';
}
public function getPath(): string
{
return $this->path;
}
/**
* Sets the locale string for this request.
*
* @return IncomingRequest
*/
public function setLocale(string $locale)
{
// If it's not a valid locale, set it
// to the default locale for the site.
if (! in_array($locale, $this->validLocales, true)) {
$locale = $this->defaultLocale;
}
$this->locale = $locale;
Locale::setDefault($locale);
return $this;
}
/**
* Gets the current locale, with a fallback to the default
* locale if none is set.
*/
public function getLocale(): string
{
return $this->locale ?? $this->defaultLocale;
}
/**
* Returns the default locale as set in Config\App.php
*/
public function getDefaultLocale(): string
{
return $this->defaultLocale;
}
/**
* Fetch an item from JSON input stream with fallback to $_REQUEST object. This is the simplest way
* to grab data from the request object and can be used in lieu of the
* other get* methods in most cases.
*
* @param array|string|null $index
* @param int|null $filter Filter constant
* @param mixed $flags
*
* @return mixed
*/
public function getVar($index = null, $filter = null, $flags = null)
{
if (strpos($this->getHeaderLine('Content-Type'), 'application/json') !== false && $this->body !== null) {
if ($index === null) {
return $this->getJSON();
}
if (is_array($index)) {
$output = [];
foreach ($index as $key) {
$output[$key] = $this->getJsonVar($key, false, $filter, $flags);
}
return $output;
}
return $this->getJsonVar($index, false, $filter, $flags);
}
return $this->fetchGlobal('request', $index, $filter, $flags);
}
/**
* A convenience method that grabs the raw input stream and decodes
* the JSON into an array.
*
* If $assoc == true, then all objects in the response will be converted
* to associative arrays.
*
* @param bool $assoc Whether to return objects as associative arrays
* @param int $depth How many levels deep to decode
* @param int $options Bitmask of options
*
* @see http://php.net/manual/en/function.json-decode.php
*
* @return mixed
*/
public function getJSON(bool $assoc = false, int $depth = 512, int $options = 0)
{
return json_decode($this->body ?? '', $assoc, $depth, $options);
}
/**
* Get a specific variable from a JSON input stream
*
* @param string $index The variable that you want which can use dot syntax for getting specific values.
* @param bool $assoc If true, return the result as an associative array.
* @param int|null $filter Filter Constant
* @param array|int|null $flags Option
*
* @return mixed
*/
public function getJsonVar(string $index, bool $assoc = false, ?int $filter = null, $flags = null)
{
helper('array');
$json = $this->getJSON(true);
if (! is_array($json)) {
return null;
}
$data = dot_array_search($index, $json);
if ($data === null) {
return null;
}
if (! is_array($data)) {
$filter ??= FILTER_DEFAULT;
$flags = is_array($flags) ? $flags : (is_numeric($flags) ? (int) $flags : 0);
return filter_var($data, $filter, $flags);
}
if (! $assoc) {
return json_decode(json_encode($data));
}
return $data;
}
/**
* A convenience method that grabs the raw input stream(send method in PUT, PATCH, DELETE) and decodes
* the String into an array.
*
* @return mixed
*/
public function getRawInput()
{
parse_str($this->body ?? '', $output);
return $output;
}
/**
* Fetch an item from GET data.
*
* @param array|string|null $index Index for item to fetch from $_GET.
* @param int|null $filter A filter name to apply.
* @param mixed|null $flags
*
* @return mixed
*/
public function getGet($index = null, $filter = null, $flags = null)
{
return $this->fetchGlobal('get', $index, $filter, $flags);
}
/**
* Fetch an item from POST.
*
* @param array|string|null $index Index for item to fetch from $_POST.
* @param int|null $filter A filter name to apply
* @param mixed $flags
*
* @return mixed
*/
public function getPost($index = null, $filter = null, $flags = null)
{
return $this->fetchGlobal('post', $index, $filter, $flags);
}
public function getPostGet($index = null, $filter = null, $flags = null)
{
// Use $_POST directly here, since filter_has_var only
// checks the initial POST data, not anything that might
// have been added since.
return isset($this->rqst->post[$index]) ? $this->getPost($index, $filter, $flags) : (isset($this->rqst->get[$index]) ? $this->getGet($index, $filter, $flags) : $this->getPost($index, $filter, $flags));
}
public function getGetPost($index = null, $filter = null, $flags = null)
{
// Use $_GET directly here, since filter_has_var only
// checks the initial GET data, not anything that might
// have been added since.
return isset($this->rqst->get[$index]) ? $this->getGet($index, $filter, $flags) : (isset($this->rqst->post[$index]) ? $this->getPost($index, $filter, $flags) : $this->getGet($index, $filter, $flags));
}
/**
* Fetch an item from the COOKIE array.
*
* @param array|string|null $index Index for item to be fetched from $_COOKIE
* @param int|null $filter A filter name to be applied
* @param mixed $flags
*
* @return mixed
*/
public function getCookie($index = null, $filter = null, $flags = null)
{
return $this->fetchGlobal('cookie', $index, $filter, $flags);
}
/**
* Fetch the user agent string
*
* @return UserAgent
*/
public function getUserAgent()
{
return $this->userAgent;
}
/**
* Attempts to get old Input data that has been flashed to the session
* with redirect_with_input(). It first checks for the data in the old
* POST data, then the old GET data and finally check for dot arrays
*
* @return mixed
*/
public function getOldInput(string $key)
{
// If the session hasn't been started, or no
// data was previously saved, we're done.
if (empty($_SESSION['_ci_old_input'])) {
return null;
}
// Check for the value in the POST array first.
if (isset($_SESSION['_ci_old_input']['post'][$key])) {
return $_SESSION['_ci_old_input']['post'][$key];
}
// Next check in the GET array.
if (isset($_SESSION['_ci_old_input']['get'][$key])) {
return $_SESSION['_ci_old_input']['get'][$key];
}
helper('array');
// Check for an array value in POST.
if (isset($_SESSION['_ci_old_input']['post'])) {
$value = dot_array_search($key, $_SESSION['_ci_old_input']['post']);
if ($value !== null) {
return $value;
}
}
// Check for an array value in GET.
if (isset($_SESSION['_ci_old_input']['get'])) {
$value = dot_array_search($key, $_SESSION['_ci_old_input']['get']);
if ($value !== null) {
return $value;
}
}
// requested session key not found
return null;
}
/**
* Returns an array of all files that have been uploaded with this
* request. Each file is represented by an UploadedFile instance.
*/
public function getFiles(): array
{
if ($this->files === null) {
$this->files = new FileCollection($this->rqst->files);
}
return $this->files->all(); // return all files
}
/**
* Verify if a file exist, by the name of the input field used to upload it, in the collection
* of uploaded files and if is have been uploaded with multiple option.
*
* @return array|null
*/
public function getFileMultiple(string $fileID)
{
if ($this->files === null) {
$this->files = new FileCollection($this->rqst->files);
}
return $this->files->getFileMultiple($fileID);
}
/**
* Retrieves a single file by the name of the input field used
* to upload it.
*
* @return UploadedFile|null
*/
public function getFile(string $fileID)
{
if ($this->files === null) {
$this->files = new FileCollection($this->rqst->files);
}
return $this->files->getFile($fileID);
}
}
这两个类改完了:
/app/Swoole/HTTP/FileCollection.php
<?php namespace App\Swoole\HTTP;
class FileCollection extends \CodeIgniter\HTTP\Files\FileCollection {
public function __construct(array $files)
{
$this->files=[];
if(!empty($files)) foreach($this->fixFilesArray($files) AS $name=>$file) $this->files[$name] = $this->createFileObject($file);
}
}
/app/Swoole/HTTP/Response.php
<?php namespace App\Swoole\HTTP;
class Response extends \CodeIgniter\HTTP\Response{
public function __construct($config,public \Swoole\Http\Response $resp,private \Swoole\Http\Request $rqst)
{
parent::__construct($config);
}
/**
* Sends the headers of this HTTP response to the browser.
*
* @return Response
*/
private bool $headersSent=FALSE;
public function sendHeaders()
{
// Have the headers already been sent?
if ($this->pretend || $this->headersSent) {
return $this;
}
if(!isset($this->headers['Date'])) $this->setDate(\DateTime::createFromFormat('U',(string)time()));
$this->resp->status($this->getStatusCode(), $this->getReasonPhrase());
foreach(array_keys($this->getHeaders()) AS $name){
$this->resp->header($name,$this->getHeaderLine($name));
}
$this->headersSent=TRUE;
return $this;
}
public function sendBody()
{
if($this->resp->isWritable()) $this->resp->end($this->body);
return $this;
}
/**
* Perform a redirect to a new URL, in two flavors: header or location.
*
* @param string $uri The URI to redirect to
* @param int $code The type of redirection, defaults to 302
*
* @throws HTTPException For invalid status code.
*
* @return $this
*/
public function redirect(string $uri, string $method = 'auto', ?int $code = null)
{
// Assume 302 status code response; override if needed
if (empty($code)) {
$code = 302;
}
// IIS environment likely? Use 'refresh' for better compatibility
if ($method === 'auto' && isset($this->rqst->server['server_software']) && strpos($this->rqst->server['server_software'], 'Microsoft-IIS') !== false) {
$method = 'refresh';
}
// override status code for HTTP/1.1 & higher
// reference: http://en.wikipedia.org/wiki/Post/Redirect/Get
if (isset($this->rqst->server['server_protocol']) && $this->getProtocolVersion() >= 1.1 && $method !== 'refresh') {
$code = ($this->rqst->getMethod() !== 'GET') ? 303 : ($code === 302 ? 307 : $code);
}
switch ($method) {
case 'refresh':
$this->setHeader('Refresh', '0;url=' . $uri);
$this->setStatusCode($code);
break;
default:
$this->resp->redirect($uri,$code);
break;
}
return $this;
}
}
Session
相比请求和响应,session的处理还要复杂一些。PHP原生提供的session是个完全面向过程的东西,启动时调用session_set_save_handler方法注册钩子,然后PHP就会自动在相应节点调用钩子,完成数据的读删存。
我的办法是用魔术方法替代钩子,用类属性替代全局变量$_SESSION:
<?php namespace App\Swoole\Session;
use CodeIgniter\Cookie\Cookie;
use CodeIgniter\I18n\Time;
class Session extends \CodeIgniter\Session\Session {
private SerializeHandlerInterface $serializeHandler;
public function __construct(private \Config\App $config)
{
$handlerClass=__NAMESPACE__.'\\SerializeHandler_'.ini_get('session.serialize_handler');
$this->serializeHandler=new $handlerClass();
$session = config('Session');
$this->sessionDriverName = $session->driver;
if ($this->sessionDriverName===\CodeIgniter\Session\Handlers\DatabaseHandler::class) {
$DBGroup = $session->DBGroup ?? config(\Config\Database::class)->defaultGroup;
$driver = \Config\Database::connect($DBGroup,FALSE)->getPlatform();
if ($driver === 'MySQLi') {
$this->sessionDriverName = \CodeIgniter\Session\Handlers\Database\MySQLiHandler::class;
} elseif ($driver === 'Postgre') {
$this->sessionDriverName = \CodeIgniter\Session\Handlers\Database\PostgreHandler::class;
}
}
$this->sessionCookieName = $session->cookieName ?? $this->sessionCookieName;
$this->sessionExpiration = $session->expiration ?? $this->sessionExpiration;
$this->sessionSavePath = $session->savePath;
$this->sessionMatchIP = $session->matchIP ?? $this->sessionMatchIP;
$this->sessionTimeToUpdate = $session->timeToUpdate ?? $this->sessionTimeToUpdate;
$this->sessionRegenerateDestroy = $session->regenerateDestroy ?? $this->sessionRegenerateDestroy;
/** @var CookieConfig $cookie */
$cookie = config('Cookie');
$this->cookie = (new Cookie($this->sessionCookieName, '', [
'expires' => $this->sessionExpiration === 0 ? 0 : Time::now()->getTimestamp() + $this->sessionExpiration,
'path' => $cookie->path ?? $config->cookiePath,
'domain' => $cookie->domain ?? $config->cookieDomain,
'secure' => $cookie->secure ?? $config->cookieSecure,
'httponly' => true, // for security
'samesite' => $cookie->samesite ?? $config->cookieSameSite ?? Cookie::SAMESITE_LAX,
'raw' => $cookie->raw ?? false,
]))->withPrefix(''); // Cookie prefix should be ignored.
helper('array');
$this->configure();
}
private \App\Swoole\HTTP\IncomingRequest $request;
private \App\Swoole\HTTP\Response $response;
private array $data=[];
private string $id;
public function create(
\App\Swoole\HTTP\IncomingRequest $request,
\App\Swoole\HTTP\Response $response,
\Psr\Log\LoggerInterface $logger
) : self
{
$res=clone $this;
$res->request=$request;
$res->response=$response;
$res->setLogger($logger);
$className=$this->sessionDriverName;
$res->driver=new $className($this->config,$request->getIPAddress());
$res->driver->setLogger($logger);
return $res->start();
}
public function start()
{
// Sanitize the cookie, because apparently PHP doesn't do that for userspace handlers
$cookie=$this->request->getCookie($this->sessionCookieName);
if(!is_string($cookie)||!preg_match('#\A'.$this->sidRegexp.'\z#',$cookie)){
$this->id=session_create_id();
$this->setCookie();
}else $this->id=$cookie;
if (ENVIRONMENT !== 'testing'){
// session_start();
$this->driver->open($this->sessionSavePath,$this->sessionCookieName);
$data=$this->driver->read($this->id);
if($data) $this->data=$this->serializeHandler->unserialize($data);
}
// Is session ID auto-regeneration configured? (ignoring ajax requests)
if (!$this->request->isAJAX()&&$this->sessionTimeToUpdate>0) {
if (! isset($this->data['__ci_last_regenerate'])) {
$this->data['__ci_last_regenerate'] = Time::now()->getTimestamp();
} elseif ($this->data['__ci_last_regenerate']+$this->sessionTimeToUpdate<Time::now()->getTimestamp()){
$this->regenerate((bool) $this->sessionRegenerateDestroy);
}
}
// Another work-around ... PHP doesn't seem to send the session cookie
// unless it is being currently created or regenerated
elseif ($cookie===$this->id) {
$this->setCookie();
}
$this->initVars();
$this->logger->info("Session: Class initialized using '" . $this->sessionDriverName . "' driver.");
return $this;
}
protected function setCookie()
{
$expiration = $this->sessionExpiration === 0 ? 0 : Time::now()->getTimestamp() + $this->sessionExpiration;
$this->cookie = $this->cookie->withValue($this->id)->withExpires($expiration);
$this->response->setCookie($this->cookie);
}
public function regenerate(bool $destroy = false)
{
$this->data['__ci_last_regenerate'] = Time::now()->getTimestamp();
if($destroy) $this->driver->destroy($this->id);
$this->removeOldSessionCookie();
$this->id=session_create_id();
if($destroy) $this->driver->read($this->id);
$this->setCookie();
}
private function removeOldSessionCookie(): void
{
$cookieStoreInResponse = $this->response->getCookieStore();
if (! $cookieStoreInResponse->has($this->sessionCookieName)) {
return;
}
// CookieStore is immutable.
$newCookieStore = $cookieStoreInResponse->remove($this->sessionCookieName);
// But clear() method clears cookies in the object (not immutable).
$cookieStoreInResponse->clear();
foreach ($newCookieStore as $cookie) {
$this->response->setCookie($cookie);
}
}
public function __destruct()
{
if(!isset($this->driver)) return;
if($this->id) $this->driver->write($this->id,$this->serializeHandler->serialize($this->data));
$this->driver->close();
}
public function stop()
{
$this->response->resp->cookie(
$this->sessionCookieName,
$this->id,
['expires' => 1, 'path' => $this->cookie->getPath(), 'domain' => $this->cookie->getDomain(), 'secure' => $this->cookie->isSecure(), 'httponly' => true]
);
$this->driver->destroy($this->id);
$this->id='';
}
public function destroy()
{
if (ENVIRONMENT!=='testing') $this->stop();
}
/**
* Handle temporary variables
*
* Clears old "flash" data, marks the new one for deletion and handles
* "temp" data deletion.
*/
protected function initVars()
{
if (empty($this->data['__ci_vars'])) {
return;
}
$currentTime = Time::now()->getTimestamp();
foreach ($this->data['__ci_vars'] as $key => &$value) {
if ($value === 'new') {
$this->data['__ci_vars'][$key] = 'old';
}
// DO NOT move this above the 'new' check!
elseif ($value === 'old' || $value < $currentTime) {
unset($this->data[$key], $this->data['__ci_vars'][$key]);
}
}
if (empty($this->data['__ci_vars'])) {
unset($this->data['__ci_vars']);
}
}
public function set($data, $value = null)
{
if (is_array($data)) {
foreach ($data as $key => &$value) {
if (is_int($key)) {
$this->data[$value] = null;
} else {
$this->data[$key] = $value;
}
}
return;
}
$this->data[$data] = $value;
}
public function get(?string $key = null)
{
if (! empty($key) && (null !== ($value = $this->data[$key] ?? null) || null !== ($value = dot_array_search($key, $this->data ?? [])))) {
return $value;
}
if (empty($this->data)) {
return $key === null ? [] : null;
}
if (! empty($key)) {
return null;
}
$userdata = [];
$_exclude = array_merge(['__ci_vars'], $this->getFlashKeys(), $this->getTempKeys());
$keys = array_keys($this->data);
foreach ($keys as $key) {
if (! in_array($key, $_exclude, true)) {
$userdata[$key] = $this->data[$key];
}
}
return $userdata;
}
public function has(string $key): bool
{
return isset($this->data[$key]);
}
public function remove($key)
{
if (is_array($key)) {
foreach ($key as $k) {
unset($this->data[$k]);
}
return;
}
unset($this->data[$key]);
}
public function __set(string $key, $value)
{
$this->data[$key] = $value;
}
public function __get(string $key)
{
// Note: Keep this order the same, just in case somebody wants to
// use 'session_id' as a session data key, for whatever reason
if (isset($this->data[$key])) {
return $this->data[$key];
}
if ($key === 'session_id') {
return $this->id;
}
return null;
}
public function __isset(string $key): bool
{
return isset($this->data[$key]) || ($key === 'session_id');
}
/**
* Retrieve one or more items of flash data from the session.
*
* If the item key is null, return all flashdata.
*
* @param string $key Property identifier
*
* @return array|null The requested property value, or an associative array of them
*/
public function getFlashdata(?string $key = null)
{
if (isset($key)) {
return (isset($this->data['__ci_vars'], $this->data['__ci_vars'][$key], $this->data[$key])
&& ! is_int($this->data['__ci_vars'][$key])) ? $this->data[$key] : null;
}
$flashdata = [];
if (! empty($this->data['__ci_vars'])) {
foreach ($this->data['__ci_vars'] as $key => &$value) {
if (! is_int($value)) {
$flashdata[$key] = $this->data[$key];
}
}
}
return $flashdata;
}
/**
* Mark a session property or properties as flashdata.
*
* @param array|string $key Property identifier or array of them
*
* @return bool False if any of the properties are not already set
*/
public function markAsFlashdata($key): bool
{
if (is_array($key)) {
foreach ($key as $sessionKey) {
if (! isset($this->data[$sessionKey])) {
return false;
}
}
$new = array_fill_keys($key, 'new');
$this->data['__ci_vars'] = isset($this->data['__ci_vars']) ? array_merge($this->data['__ci_vars'], $new) : $new;
return true;
}
if (! isset($this->data[$key])) {
return false;
}
$this->data['__ci_vars'][$key] = 'new';
return true;
}
/**
* Unmark data in the session as flashdata.
*
* @param mixed $key Property identifier or array of them
*/
public function unmarkFlashdata($key)
{
if (empty($this->data['__ci_vars'])) {
return;
}
if (! is_array($key)) {
$key = [$key];
}
foreach ($key as $k) {
if (isset($this->data['__ci_vars'][$k]) && ! is_int($this->data['__ci_vars'][$k])) {
unset($this->data['__ci_vars'][$k]);
}
}
if (empty($this->data['__ci_vars'])) {
unset($this->data['__ci_vars']);
}
}
/**
* Retrieve all of the keys for session data marked as flashdata.
*
* @return array The property names of all flashdata
*/
public function getFlashKeys(): array
{
if (! isset($this->data['__ci_vars'])) {
return [];
}
$keys = [];
foreach (array_keys($this->data['__ci_vars']) as $key) {
if (! is_int($this->data['__ci_vars'][$key])) {
$keys[] = $key;
}
}
return $keys;
}
/**
* Returns either a single piece of tempdata, or all temp data currently
* in the session.
*
* @param string $key Session data key
*
* @return mixed Session data value or null if not found.
*/
public function getTempdata(?string $key = null)
{
if (isset($key)) {
return (isset($this->data['__ci_vars'], $this->data['__ci_vars'][$key], $this->data[$key])
&& is_int($this->data['__ci_vars'][$key])) ? $this->data[$key] : null;
}
$tempdata = [];
if (! empty($this->data['__ci_vars'])) {
foreach ($this->data['__ci_vars'] as $key => &$value) {
if (is_int($value)) {
$tempdata[$key] = $this->data[$key];
}
}
}
return $tempdata;
}
/**
* Removes a single piece of temporary data from the session.
*
* @param string $key Session data key
*/
public function removeTempdata(string $key)
{
$this->unmarkTempdata($key);
unset($this->data[$key]);
}
/**
* Mark one of more pieces of data as being temporary, meaning that
* it has a set lifespan within the session.
*
* @param array|string $key Property identifier or array of them
* @param int $ttl Time to live, in seconds
*
* @return bool False if any of the properties were not set
*/
public function markAsTempdata($key, int $ttl = 300): bool
{
$ttl += Time::now()->getTimestamp();
if (is_array($key)) {
$temp = [];
foreach ($key as $k => $v) {
// Do we have a key => ttl pair, or just a key?
if (is_int($k)) {
$k = $v;
$v = $ttl;
} elseif (is_string($v)) {
$v = Time::now()->getTimestamp() + $ttl;
} else {
$v += Time::now()->getTimestamp();
}
if (! array_key_exists($k, $this->data)) {
return false;
}
$temp[$k] = $v;
}
$this->data['__ci_vars'] = isset($this->data['__ci_vars']) ? array_merge($this->data['__ci_vars'], $temp) : $temp;
return true;
}
if (! isset($this->data[$key])) {
return false;
}
$this->data['__ci_vars'][$key] = $ttl;
return true;
}
/**
* Unmarks temporary data in the session, effectively removing its
* lifespan and allowing it to live as long as the session does.
*
* @param array|string $key Property identifier or array of them
*/
public function unmarkTempdata($key)
{
if (empty($this->data['__ci_vars'])) {
return;
}
if (! is_array($key)) {
$key = [$key];
}
foreach ($key as $k) {
if (isset($this->data['__ci_vars'][$k]) && is_int($this->data['__ci_vars'][$k])) {
unset($this->data['__ci_vars'][$k]);
}
}
if (empty($this->data['__ci_vars'])) {
unset($this->data['__ci_vars']);
}
}
/**
* Retrieve the keys of all session data that have been marked as temporary data.
*/
public function getTempKeys(): array
{
if (! isset($this->data['__ci_vars'])) {
return [];
}
$keys = [];
foreach (array_keys($this->data['__ci_vars']) as $key) {
if (is_int($this->data['__ci_vars'][$key])) {
$keys[] = $key;
}
}
return $keys;
}
}
其中,由于底层对数据序列化的处理句柄(session.serialize_handler,主要是php)没有现成的替代方案,索性额外写个接口并实现:
/app/Swoole/Session/SerializeHandlerInterface.php
<?php namespace App\Swoole\Session;
interface SerializeHandlerInterface {
public function serialize(array $data) : string;
public function unserialize(string $data) : array;
}
/app/Swoole/Session/SerializeHandler_php.php
<?php namespace App\Swoole\Session;
class SerializeHandler_php implements SerializeHandlerInterface {
public function serialize(array $data) : string
{
$res='';
foreach($data AS $k => $v) $res.=$k.'|'.serialize($v);
return $res;
}
public function unserialize(string $data) : array
{
$parts=explode('|',$data);
$end=count($parts)-1;
$keys=[$parts[0]];
for($i=1;$i!==$end;$i++){
$len=strrpos($parts[$i],';')+1;
if($parts[$i][$len]==='}') $len++;
$values[]=substr($parts[$i],0,$len);
$keys[]=substr($parts[$i],$len);
}
$values[]=$parts[$end];
foreach($keys AS $i => $k){
$res[$k]=unserialize($values[$i]);
}
return $res;
}
}
另外,框架中有些地方调用了PHP提供的公共函数setcookie等对客户端的cookie进行处理。虽然函数不能覆盖,但PHP调用函数是先在本命名空间查找的,而不同命名空间下是可以存在同名函数的。利用这一特点,我们只要在调用函数的命名空间下定义同名函数,就可以劫持调用:
<?php
namespace CodeIgniter\Cookie {
function setcookie(string $name, string $value, array $options)
{
// 很遗憾swoole的cookie方法和PHP的setcookie没有完全一致,不支持$options传参,以后如果支持了可以改用下面一行,与框架保持完全一致
return getRootContext()->response->resp->cookie($name, $value, $options['expires'], $options['path'], $options['domain'], $options['secure'], $options['httponly']);
// getRootContext()->response->resp->cookie($name, $value, $options);
}
function setrawcookie(string $name, string $value, array $options)
{
return getRootContext()->response->resp->rawCookie($name, $value, $options['expires'], $options['path'], $options['domain'], $options['secure'], $options['httponly']);
// getRootContext()->response->resp->rawCookie($name, $value, $options);
}
}
namespace CodeIgniter\HTTP {
function setcookie(...$args)
{
return \CodeIgniter\Cookie\setcookie(...$args);
}
function setrawcookie(...$args)
{
return \CodeIgniter\Cookie\setrawcookie(...$args);
}
}
namespace CodeIgniter\Session\Handlers {
function setcookie(...$args)
{
return \CodeIgniter\Cookie\setcookie(...$args);
}
function setrawcookie(...$args)
{
return \CodeIgniter\Cookie\setrawcookie(...$args);
}
function ini_set(...$args)
{
}
}
Exceptions
前面四个组件的改造,纯FPM框架应该都是需要的,而异常、错误的处理则要看具体框架。如果你想改造的框架也是通过set_exception_handler等方法注册全局回调,那么也需要像我这样改成手动调用相应方法进行处理。
首先要梳理框架处理异常的逻辑。CI4中有一个CodeIgniter\Debug\Exceptions类,类本身存了一些异常处理的配置,然后外部会调用initialize方法进行全局注册,这样当有未捕获的异常抛出时,就会触发注册的回调。因为回调就是这个类的方法,所以我的办法就是在抛出异常的时候实例化一个异常处理对象,然后主动调用处理方法。
当然有一小部分内容还是需要调整的:
/app/Swoole/Exceptions.php
<?php namespace App\Swoole;
class Exceptions extends \CodeIgniter\Debug\Exceptions {
public function __construct(HTTP\IncomingRequest $request,HTTP\Response $response)
{
$this->config = self::$configs;
$this->viewPath = rtrim($this->config->errorViewPath,'\\/ ').DIRECTORY_SEPARATOR;
$this->request = $request;
$this->response = $response;
}
public function exceptionHandler(\Throwable $exception)
{
[$statusCode, $exitCode] = $this->determineCodes($exception);
if($this->config->log===true&&!in_array($statusCode,$this->config->ignoreCodes,true)) logThrowable($exception);
try {
$this->response->setStatusCode($statusCode);
} catch (\CodeIgniter\HTTP\Exceptions\HTTPException $e) {
// Workaround for invalid HTTP status code.
$statusCode = 500;
$this->response->setStatusCode($statusCode);
}
if (strpos($this->request->getHeaderLine('accept'), 'text/html') === false) {
$this->respond(ENVIRONMENT === 'development' ? $this->collectVars($exception, $statusCode) : '', $statusCode)->send();
}else $this->render($exception, $statusCode);
}
protected function render(\Throwable $exception, int $statusCode)
{
// Determine possible directories of error views
$path = $this->viewPath.'html'.DIRECTORY_SEPARATOR;
$altPath = rtrim((new \Config\Paths())->viewDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'errors' . DIRECTORY_SEPARATOR.'html'.DIRECTORY_SEPARATOR;
// Determine the views
$view = $this->determineView($exception, $path);
$altView = $this->determineView($exception, $altPath);
// Check if the view exists
if (is_file($path . $view)) {
$viewFile = $path . $view;
} elseif (is_file($altPath . $altView)) {
$viewFile = $altPath . $altView;
} else {
$this->response->setBody('The error view files were not found. Cannot render exception trace.')->send();
return;
}
$this->response->setBody((function () use ($exception, $statusCode, $viewFile): string {
$vars = $this->collectVars($exception, $statusCode);
$vars['response']=$this->response;
$vars['request']=$this->request;
extract($vars, EXTR_SKIP);
ob_start();
include $viewFile;
return ob_get_clean();
})())->send();
}
protected function determineView(\Throwable $exception, string $templatePath): string
{
// Production environments should have a custom exception file.
$view = 'production.php';
$templatePath = rtrim($templatePath, '\\/ ') . DIRECTORY_SEPARATOR;
if (str_ireplace(['off', 'none', 'no', 'false', 'null'], '', ini_get('display_errors'))) {
$view = 'error_exception_swoole.php';
}
// 404 Errors
if ($exception instanceof CodeIgniter\Exceptions\PageNotFoundException) {
return 'error_404.php';
}
// Allow for custom views based upon the status code
if (is_file($templatePath . 'error_' . $exception->getCode() . '.php')) {
return 'error_' . $exception->getCode() . '.php';
}
return $view;
}
public static $configs;
}
Exceptions::$configs=config('Exceptions');
if (! isset(Exceptions::$configs->sensitiveDataInTrace)) {
Exceptions::$configs->sensitiveDataInTrace = [];
}
if (! isset(Exceptions::$configs->logDeprecations, Exceptions::$configs->deprecationLogLevel)) {
Exceptions::$configs->logDeprecations = false;
Exceptions::$configs->deprecationLogLevel = \Psr\Log\LogLevel::WARNING;
}
添加到/app/Common.php中
function logThrowable(Throwable $th,string $level='critical',string $prepend='')
{
$backtraces = [];
foreach ($th->getTrace() as $index => $trace) {
$frame = $trace + ['file' => '[internal function]', 'line' => '', 'class' => '', 'type' => '', 'args' => []];
if ($frame['file'] !== '[internal function]') {
$frame['file'] = sprintf('%s(%s)', $frame['file'], $frame['line']);
}
unset($frame['line']);
$idx = $index;
$idx = str_pad((string) ++$idx, 2, ' ', STR_PAD_LEFT);
$args = implode(', ', array_map(static function ($value): string {
switch (true) {
case is_object($value):
return sprintf('Object(%s)', get_class($value));
case is_array($value):
return $value !== [] ? '[...]' : '[]';
case $value === null:
return 'null';
case is_resource($value):
return sprintf('resource (%s)', get_resource_type($value));
case is_string($value):
return var_export(clean_path($value), true);
default:
return var_export($value, true);
}
}, $frame['args']));
$backtraces[] = sprintf(
'%s %s: %s%s%s(%s)',
$idx,
clean_path($frame['file']),
$frame['class'],
$frame['type'],
$frame['function'],
$args
);
}
log_message($level,$prepend."{message}\nin {exFile} on line {exLine}.\n{trace}", [
'message' => $th->getMessage(),
'exFile' => clean_path($th->getFile()), // {file} refers to THIS file
'exLine' => $th->getLine(), // {line} refers to THIS line
'trace' => implode("\n", $backtraces),
]);
}
运行
$app生命周期
至此,适配swoole需要的组件改造已搞定,接下来就是循着FPM模式下一个请求的生命周期,进行代码移植。需要注意的是,使用swoole通常还会用到它的websocket功能,因此一般都是以多进程模式运行。
在多进程模式下,开发者手动启动一个主进程,这个主进程里加载到内存的代码是可以在worker进程中访问的,而worker进程里加载的代码却是不能被其他worker进程访问的,并且会随着进程退出而全部释放。因此在下方代码中,我注册了一个WorkerExit事件,在回调里清空定时器,因为定时器在终止前会一直占着内存,导致worker进程无法正常退出。
CI框架的进程周期是从/public/index.php开始的,因此下面这个文件的代码就是从index.php开始,按照执行顺序逐一复制过来,并根据swoole进程的特点进行微调,一些关键的组件改用前面改造后的。
需要特别注意的是,只有那些启动后不会改变,或者即使改变了也不会出现协程间冲突的内容可以加载到主进程中,例如$sessionFactory。
<?php
<?php
function getRootContext()
{
$pcid=Swoole\Coroutine::getPcid();
while($pCtx=Swoole\Coroutine::getContext($pcid)){
$ctx=$pCtx;
$pcid=Swoole\Coroutine::getPcid($pcid);
}
return $ctx??Swoole\Coroutine::getContext();
}
function session(?string $val=null)
{
global $sessionFactory;
$ctx=getRootContext();
if(!isset($ctx->session)) $ctx->session=$sessionFactory->create($ctx->request,$ctx->response,$ctx->logger);
return is_string($val)?$ctx->session->get($val):$ctx->session;
}
function model(string $name, bool $getShared = true, ?CodeIgniter\Database\ConnectionInterface &$conn = null)
{
if(!$getShared) return CodeIgniter\Config\Factories::models($name, ['getShared' => FALSE], $conn);
$rootContext=getRootContext();
if(isset($rootContext[$name])) return $rootContext[$name];
return $rootContext[$name]=CodeIgniter\Config\Factories::models($name, ['getShared' => FALSE], $conn);
}
class Connection {
private function __construct(
private readonly Swoole\WebSocket\Server $server,
private readonly int $fd
)
{
}
// push/exist/disconnect/isEstablished
public function __call(string $name,array $arguments)
{
return $this->server->$name($this->fd,...$arguments);
}
public function remove()
{
return self::$table->del($this->fd);
}
// public修饰是便于初始化,不要在其他任何地方直接访问$table!
public static Swoole\Table $table;
public static function __callStatic(string $name,array $arguments)
{
return self::$table->$name(...$arguments);
}
public static function get(Swoole\WebSocket\Server $ws,string $fd) : ?self
{
$data=self::$table->get($fd);
if(!$data) return NULL;
$res=new self($ws,$fd);
foreach($data AS $k => $v) $res->$k=$v;
return $res;
}
public static function set(string $fd,int $loginId)
{
self::$table->set($fd,['login_id'=>$loginId]);
}
public function update(array $data)
{
foreach($data AS $k => $v) $this->$k=$v;
self::$table->set($this->fd,[
'login_id'=>$this->login_id,
'chat_type'=>$this->chat_type,
'chat_id'=>$this->chat_id
]);
}
private int $login_id;
public int $chat_type;
public int $chat_id;
}
Connection::$table=new Swoole\Table(2048); // worker_num × max_request
Connection::$table->column('login_id',Swoole\Table::TYPE_INT);
Connection::$table->column('chat_type',Swoole\Table::TYPE_INT);
Connection::$table->column('chat_id',Swoole\Table::TYPE_INT);
Connection::$table->create();
define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR);
// Ensure the current directory is pointing to the front controller's directory
chdir(FCPATH);
/*
*---------------------------------------------------------------
* BOOTSTRAP THE APPLICATION
*---------------------------------------------------------------
* This process sets up the path constants, loads and registers
* our autoloader, along with Composer's, loads our constants
* and fires up an environment-specific bootstrapping.
*/
// Load our paths config file
// This is the line that might need to be changed, depending on your folder structure.
require FCPATH . '../app/Config/Paths.php';
$paths = new Config\Paths();
// Location of the framework bootstrap file.
require rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'bootstrap.php';
// Load environment settings from .env files into $_SERVER and $_ENV
require_once SYSTEMPATH . 'Config/DotEnv.php';
(new CodeIgniter\Config\DotEnv(ROOTPATH))->load();
if (! defined('ENVIRONMENT')) {
define('ENVIRONMENT', env('CI_ENVIRONMENT', 'production'));
}
if (is_file(APPPATH . 'Config/Boot/' . ENVIRONMENT . '.php')) {
require_once APPPATH . 'Config/Boot/' . ENVIRONMENT . '.php';
} else {
// @codeCoverageIgnoreStart
echo 'The application environment is not set correctly.';
exit(EXIT_ERROR); // EXIT_ERROR
// @codeCoverageIgnoreEnd
}
require_once __DIR__.DIRECTORY_SEPARATOR.'override.php';
use CodeIgniter\Events\Events;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\DownloadResponse;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Router\Exceptions\RedirectException;
use Config\Kint as KintConfig;
use Config\Services;
use Kint\Renderer\CliRenderer;
use Kint\Renderer\RichRenderer;
use CodeIgniter\Filters\Filters;
if (CI_DEBUG){
if (! defined('KINT_DIR')) {
spl_autoload_register(function ($class) {
$class = explode('\\', $class);
if (array_shift($class) !== 'Kint') {
return;
}
$file = SYSTEMPATH . 'ThirdParty/Kint/' . implode('/', $class) . '.php';
if (is_file($file)) {
require_once $file;
}
});
require_once SYSTEMPATH . 'ThirdParty/Kint/init.php';
}
$kintConfig = config(KintConfig::class);
Kint::$depth_limit = $kintConfig->maxDepth;
Kint::$display_called_from = $kintConfig->displayCalledFrom;
Kint::$expanded = $kintConfig->expanded;
if (! empty($kintConfig->plugins) && is_array($kintConfig->plugins)) {
Kint::$plugins = $kintConfig->plugins;
}
$csp = Services::csp();
if ($csp->enabled()) {
RichRenderer::$js_nonce = $csp->getScriptNonce();
RichRenderer::$css_nonce = $csp->getStyleNonce();
}
RichRenderer::$theme = $kintConfig->richTheme;
RichRenderer::$folder = $kintConfig->richFolder;
RichRenderer::$sort = $kintConfig->richSort;
if (! empty($kintConfig->richObjectPlugins) && is_array($kintConfig->richObjectPlugins)) {
RichRenderer::$value_plugins = $kintConfig->richObjectPlugins;
}
if (! empty($kintConfig->richTabPlugins) && is_array($kintConfig->richTabPlugins)) {
RichRenderer::$tab_plugins = $kintConfig->richTabPlugins;
}
CliRenderer::$cli_colors = $kintConfig->cliColors;
CliRenderer::$force_utf8 = $kintConfig->cliForceUTF8;
CliRenderer::$detect_width = $kintConfig->cliDetectWidth;
CliRenderer::$min_terminal_width = $kintConfig->cliMinWidth;
} elseif (class_exists(Kint::class)) {
// In case that Kint is already loaded via Composer.
Kint::$enabled_mode = false;
// @codeCoverageIgnore
}
helper('kint');
$config = config('App');
Locale::setDefault($config->defaultLocale ?? 'en');
date_default_timezone_set($config->appTimezone ?? 'UTC');
define('DB_POOL_SIZE',32);
/**
* 文档介绍$mode默认值是SWOOLE_PROCESS(https://wiki.swoole.com/#/server/methods?id=__construct)
* 但经测试似乎并不是:
* 如果不传入SWOOLE_PROCESS,WorkerExit后会关闭websocket连接,并且是在回调执行后关闭。
* 猜测底层是在回调执行后立即销毁协程环境,导致无法执行数据库操作,进而引发死锁。
* 因此目前SWOOLE_PROCESS值必须传入
*/
$server=new Swoole\WebSocket\Server('0.0.0.0',80,SWOOLE_PROCESS);
$server->on('WorkerExit',function(){
Swoole\Timer::clearAll();
});
$routes = Services::routes(FALSE);
$routes->setDefaultNamespace('App\Swoole\Controllers');
$routes->set404Override();
$routes->get('/swoole(/(.+))?', 'Swoole::$2');
$filtersConfig=config('Filters');
if(empty(config('Feature')->multipleFilters)){
function getFilters($router,$rqst, $resp)
{
global $filtersConfig;
$filters=new Filters($filtersConfig, $rqst, $resp);
if($routeFilter=$router->getFilter()){
$filters->enableFilter($routeFilter, 'before');
$filters->enableFilter($routeFilter, 'after');
}
return $filters;
}
}else{
function getFilters($router,$rqst, $resp)
{
global $filtersConfig;
$filters=new Filters($filtersConfig, $rqst, $resp);
if($routeFilter=$router->getFilters()){
$filters->enableFilters($routeFilter, 'before');
$filters->enableFilters($routeFilter, 'after');
}
return $filters;
}
}
$sessionFactory=new App\Swoole\Session\Session($config);
$server->on('request',function($request,$response)use(&$config,&$routes){
$startTime = microtime(true);
$benchmark = Services::timer(FALSE);
$benchmark->start('total_execution', $startTime);
$benchmark->start('bootstrap');
$rqst=new App\Swoole\HTTP\IncomingRequest($config,$request);
if (strtolower($rqst->getMethod())==='post'&&($method=$rqst->getPost('_method'))&&in_array(strtoupper($method), ['PUT', 'PATCH', 'DELETE'], true)) $rqst->setMethod($method);
$resp=new App\Swoole\HTTP\Response($config,$response,$request);
$resp->setProtocolVersion($rqst->getProtocolVersion());
$router = Services::router($routes, $rqst,FALSE);
$path=$rqst->getPath();
$benchmark->stop('bootstrap');
$benchmark->start('routing');
$ctx=getRootContext();
$ctx->request=$rqst;
$ctx->response=$resp;
$ctx->logger=Services::logger(FALSE);
try {
$controller = $router->handle($path);
$method = $router->methodName();
if ($router->hasLocale()) {
$rqst->setLocale($router->getLocale());
}
$benchmark->stop('routing');
$filters=getFilters($router,$rqst, $resp);
$benchmark->start('before_filters');
$possibleResponse = $filters->run($path, 'before');
$benchmark->stop('before_filters');
if ($possibleResponse instanceof ResponseInterface) {
return $possibleResponse->send();
}
$benchmark->start('controller');
$benchmark->start('controller_constructor');
// Is it routed to a Closure?
if ($controller instanceof Closure) {
$returned = $controller(...$router->params());
$benchmark->stop('controller_constructor');
$benchmark->stop('controller');
}elseif (empty($controller)) {
throw PageNotFoundException::forEmptyController();
}elseif (! class_exists($controller, true) || $method[0] === '_') {
throw PageNotFoundException::forControllerNotFound($controller, $method);
}else {
$class = new $controller();
$class->initController($rqst, $resp,$ctx->logger);
$benchmark->stop('controller_constructor');
if (! method_exists($class, '_remap') && ! is_callable([$class, $method], false)) {
throw PageNotFoundException::forMethodNotFound($method);
}
// Is there a "post_controller_constructor" event?
Events::trigger('post_controller_constructor');
$params = $router->params();
$returned = method_exists($class, '_remap')
? $class->_remap($method, ...$params)
: $class->{$method}(...$params);
$benchmark->stop('controller');
}
if ($returned instanceof ResponseInterface) $resp=$returned;
elseif(is_string($returned)) $resp->setBody($returned);
$filters->setResponse($resp);
$totalTime = $benchmark->getElapsedTime('total_execution');
// Run "after" filters
$benchmark->start('after_filters');
$returned = $filters->run($path, 'after');
$benchmark->stop('after_filters');
if ($returned instanceof ResponseInterface) {
$resp = $returned;
}
if (
! $resp instanceof DownloadResponse
&& ! $resp instanceof RedirectResponse
) {
// Update the performance metrics
$body = $resp->getBody();
if ($body !== null) {
$output = str_replace('0.0776', (string) $totalTime, $body);
$resp->setBody($output);
}
}
unset($path);
$resp->send();
} catch (RedirectException $e) {
$ctx->logger->info('REDIRECTED ROUTE at ' . $e->getMessage());
// If the route is a 'redirect' route, it throws
// the exception with the $to as the message
$resp->redirect(base_url($e->getMessage()), 'auto', $e->getCode());
$resp->send();
return;
} catch (PageNotFoundException $e) {
if ($override = $router->get404Override()) {
$returned = null;
if ($override instanceof Closure) {
$returned=$override($e->getMessage());
} elseif (is_array($override)) {
$benchmark->start('controller');
$benchmark->start('controller_constructor');
list($controller,$method)=$override;
$class = new $controller();
$class->initController($rqst, $resp, $ctx->logger);
$benchmark->stop('controller_constructor');
$params = $router->params();
$returned = method_exists($class, '_remap')
? $class->_remap($method, ...$params)
: $class->{$method}(...$params);
$benchmark->stop('controller');
}
unset($override);
if ($returned instanceof ResponseInterface) $resp=$returned;
elseif(is_string($returned)) $resp->setBody($returned);
$resp->send();
return;
}
$exceptions=new App\Swoole\Exceptions($rqst,$resp);
$exceptions->exceptionHandler(PageNotFoundException::forPageNotFound(
(ENVIRONMENT !== 'production') ? $e->getMessage() : null
));
} catch (\Throwable $th) {
$exceptions=new App\Swoole\Exceptions($rqst,$resp);
$exceptions->exceptionHandler($th);
}
});
$server->on('open',function($ws,$request)use(&$config){
$rqst=new App\Swoole\HTTP\IncomingRequest($config,$request);
if(!$rqst->getGet('token')){
// 其他处理
echo 'open:',$request->fd,'没有token',PHP_EOL;
return;
}
$loginId=mt_rand(0,1023);
Connection::set($request->fd,$loginId);
echo 'open:',$request->fd,'有token,loginId是',$loginId,PHP_EOL;
});
$server->on('message',function($ws,$frame){
if($frame->data===''){ //心跳
echo '心跳',PHP_EOL;
$pingFrame=new \Swoole\WebSocket\Frame();
$pingFrame->opcode=WEBSOCKET_OPCODE_PING;
$ws->push($frame->fd,$pingFrame);
return;
}
if(!$connection=Connection::get($ws,$frame->fd)){
echo 'message:没有token的',$frame->fd,PHP_EOL;
return;
}
// 其他处理
echo 'message:有token的',$frame->fd,PHP_EOL;
$ws->disconnect($frame->fd);
});
$server->on('close',function($ws,$fd){
$info=$ws->getClientInfo($fd);
if(empty($info['websocket_status'])) return;
if(!$conn=Connection::get($ws,$fd)){
echo 'closed:没有token的',$fd,PHP_EOL;
// 其他处理
return;
}
echo 'closed:有token的',$fd,PHP_EOL;
Swoole\Coroutine::create(function()use($conn){
$conn->remove();
// 收尾工作
});
});
$logFile=WRITEPATH.'logs'.DIRECTORY_SEPARATOR.'swoole.txt';
if(!file_exists($logFile)){
fclose(fopen($logFile,'w+'));
chown($logFile,'nobody');
}
$server->set([
'daemonize'=>ENVIRONMENT!=='development', // 后台运行
'group'=>'nobody',
'user'=>'nobody',
'worker_num'=>2,
'reload_async'=>TRUE, // 异步事件完成后再退出 Worker 进程
'max_wait_time'=>60, // 等待异步事件完成的最大时长
'max_request'=>512, // 每个worker进程处理请求数
'max_connection'=>10000, // 并发连接数
// 'enable_deadlock_check'=>FALSE, // 检测协程死锁,默认开启
'hook_flags'=>SWOOLE_HOOK_NATIVE_CURL|SWOOLE_HOOK_FILE|SWOOLE_HOOK_TCP,
'open_http2_protocol'=>TRUE,
'log_level'=>SWOOLE_LOG_WARNING,
'log_file'=>$logFile,
'document_root'=>FCPATH,
'enable_coroutine'=>TRUE,
'enable_static_handler'=>TRUE, //允许访问静态文件
'heartbeat_check_interval'=>10, // 心跳间隔
'heartbeat_idle_time'=>30, // 超时断开
]);
$server->start();
nginx配置
swoole的优势在于轻,处理请求消耗的平均资源远小于FPM,但并不是所有请求都适合用swoole,特别是一些业务层比较重的请求,swoole常驻内存部分所节省的消耗相对业务本身不足一提,那这时候自然是用FPM一步到底划算一些。例如GD库输出图像二进制数据,在swoole中还要用缓冲区函数把内容截下来,再响应给客户端,反而不如直接输出给终端高效。
插一条友链,本人改造的基于GD库的QRcode类,使用超方便:
$logoUri='./path/to/logo.png'; //支持本地和网络资源,如需支持其他图片格式需要在相应位置作调整
$qr=new QRcode('这里是二维码内容');
$qr->png(NULL,$logoUri,20); //第一个参数是要写入的文件名(不含后缀),第三个参数是logo宽占二维码宽的比例
exit;
回归正题,很多时候纯swoole也不能完全满足需求,因此个人推荐用反向代理,一套代码运行两种模式,我的nginx配置server部分如下:
server {
listen 443 ssl http2;
server_name test.cn;
root /www/test/public/;
#access_log /www/test/writable/logs/nginx_access.txt;
error_log /www/test/writable/logs/nginx_error.txt;
ssl_certificate /path/to.pem;
ssl_certificate_key /path/to.key;
# 添加index.php
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
# PHP-FPM
location ~ \.php$ {
include fastcgi_params;
fastcgi_param HTTPS on;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 指向反代
fastcgi_pass 127.0.0.1:9000;
try_files $uri =404;
}
# Swoole
location ~ ^/(swoole|otherRootPath) {
proxy_pass http://127.0.0.1:80;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'Upgrade';
proxy_http_version 1.1;
proxy_read_timeout 60s;
}
# location ~ ^/html {
# rewrite ^/html/(.*).html /html/$1/index.html break;
# }
location ~* /\. {
deny all;
}
}
注:经测试证明,即使是反向代理,SSL校验也是nginx处理的,因此swoole中不需要配置SSL。但我调试过程中偶现浏览器提示不安全,即SSL校验不成功的问题,还没找到原因。如果你也遇到了,不用在swoole上找原因,我试过不是那个问题。
启动
关于“全局”空间中提到的代码,在$app生命周期中已经有呈现了,而session中提到的覆盖原生方法的代码,就是$app生命周期代码中的这一行:
require_once __DIR__.DIRECTORY_SEPARATOR.'override.php';
$app生命周期的代码文件随便取啥名,但是要和override.php以及框架的spark平级,都在项目根目录下。至于其他框架,自己看情况吧,别让用户访问到,并且要注意路径调整。
因为我的文件名是swoole.php,并且使用的是swoole-cli 5.0.1,因此启动命令为:
swoole-cli -c /usr/local/etc/ /www/test/swoole.php
-c是指定配置所在目录,详见swoole-cli -h
服务启动后,根据nginx的配置,请求swoole路径下的资源都会走swoole进程,反之则走FPM。
总结
作为个人开发者,swoole让我可以用一门PHP语言解决多种需求,并且还是国人自己造的轮子,我真心希望PHPer们都能用上swoole。
去年swoole-cli发布后,连安装都省了(包括PHP包哦!),只要下载下来,放到/usr/bin目录下,并添加可执行权限,就能同时拥有swoole和php(缺陷是只有8.1版并且没有兼容旧版的计划),像我这种喜欢官网下载压缩包后编译安装的人,部署服务器能省不少时间。
因此我思考再三还是决定把这些东西写出来和大家分享,虽然CodeIgniter在国内是比较小众的框架,但纯FPM与swoole间的差异基本就这几点,我想或多或少都能帮到一些。
最后,借用某个swoole交流群里看到的一段话与PHPer们共(zi)勉(wo)一(an)下(wei):
过去三年,绝大部分人都过得很艰难,很多原本有着“宏伟梦想”的企业都倒下了。正因如此,技术负责人们越来越喜欢“够用而且便宜”的技术方案。解释执行的PHP在性能上确实跟Java等编译执行的语言存在不少差距,底层安全问题也是很多高级程序员嫌弃的原因,但其易上手、成本低的优势是不容置疑的。无数历史证明,越简单易用的技术越容易得到普及,我相信PHP的未来一定是上升的,swoole-cli的出现让我更坚信了这点!