Yii2.0源码阅读-PHP如何与redis通信?
PHP与Redis可以通过socket进行通信,前提是PHP需要实现Redis的协议
RESP协议描述:
-
- 字符串 \r\n : 表示一个正确的状态信息,具体信息是'+’后面的字符(Simple Strings)
-
- 错误前缀 错误信息 \r\n : 表示一个错误信息,具体信息是当前行'-'后面的字符(Errors)
- $ 字符串的长度 \r\n 字符串 \r\n : 表示字符串(Bulk Strings)
-
- 数组元素个数 \r\n 其他所有类型 : 表示消息体总共有多少行(array)
- : 数字\r\n:表示返回一个数值,:后面是相应的数字 (integer)
详细描述参考:https://redis.io/topics/protocol
1、PHP与redis建立连接
通过PHP的stream_socket_client函数可以建立一个socket连接,然后PHP就可以通过组装符合Redis协议格式的字符串,然后将消息发送给Redis
所以建立连接的代码如下:
public function open()
{
if($this->_socket !== false){
return;
}
//socket要连接的地址
$remoteSocket = 'tcp://127.0.0.1:6379';
//socket连接建立超时时间
$timeout = ini_get('default_socket_timeout');
//创建socket连接
$this->_socket = @stream_socket_client($remoteSocket, $errorNumber, $errorDescription, $timeout, STREAM_CLIENT_CONNECT);
}
2、执行redis操作命令
执行操作命令首先根据协议进行命令的构造,比如我们执行SET name xiaoming,那么对应在PHP中调用函数的方式可能是setValue($key, $value),PHP是怎么处理的呢?
首先,这一条set命令包含了多个字段:set、key、value,可能还有expire过期时间,所以需要用到协议中的,也就是Redis的RESP Arrays,可以包含多个字符串,如果没有过期时间,那么这个set操作转换之后的命令就是:
$command = "*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$8\r\nxiaoming\r\n”;
解释一下就是:
- 消息数组包含三个元素
- 第一个是个字符串,长度为3,值为SET
- 第二个是个字符串,长度为4,值为name
- 第三个是个字符串,长度为8,值为xiaoming
然后写入socket:
fwrite($this->_socket, $command);
这样一条set key value的命令就执行完成了
这里要注意的一点是,关于命令中字符串长度的计算,Redis文档中的描述:* A "$" byte followed by the number of bytes composing the string (a prefixed length), terminated by CRLF. 也就是说这个长度是按照byte字节来计算的,计算字符串有多少个字节,那么在php中计算字符串长度的时候就不能简单的用strlen了,而需要使用mb_strlen($str, '8bit'),来计算
3、解析Redis返回的消息
在向socket发送了消息之后,Redis执行之后会返回一些信息,同样写入这个socket中,我们要做的是按照协议格式进行消息的解析:
$line = fgets($this->_socket);
$line[0]就是消息的类型,对应上面协议中的:+、 - 、 $ 、 * 、 : 这五种
根据类型的不同再对$line剩余的部分进行解析
- 如果为+,正确的消息,PONG | OK 返回true,否则返回redis返回的内容
- 如果为-,错误的消息,说明Redis那边执行这条命令发生了错误,应该抛出异常
- 如果为$,返回的是个字符串,先获取字符串的长度,也就是紧跟在$后面的数字,然后向后读取相应长度的字符串
- 如果为:,返回的是数字,直接返回就好
- 如果是,是个数组,解析获得数组中元素的个数,也就是紧跟在后面的数字,然后,递归的去解读每一行
4、完整的示例代码
class Redis
{
//保存socket连接
private $_socket = false;
//redis server 的地址,可以是ip或者主机名
public $hostname = '127.0.0.1';
//端口
public $port = 6379;
//redis登录密码
public $password;
//redis 数据库 默认为0
public $database = 0;
/**
* 建立一个Redis socket连接
*/
public function open()
{
if($this->_socket !== false){
return;
}
//socket要连接的地址
$remoteSocket = 'tcp://' . $this->hostname . ':' . $this->port;
//socket连接建立的超时时间
$timeout = ini_get('default_socket_timeout');
//创建socket连接
$this->_socket = @stream_socket_client($remoteSocket, $errorNumber, $errorDescription, $timeout, STREAM_CLIENT_CONNECT);
if($this->_socket){
//如果有密码,使用密码以授权访问
if($this->pasword){
$this->executeCommand('AUTH', [$this->password]);
}
//选择数据库
$this->executeCommand('SELECT', [$this->database]);
}else{
throw new Exception("创建redis连接失败");
}
}
/**
* 关闭与Redis的socket连接
*/
public function close()
{
$this->executeCommand('QUIT');
stream_socket_shutdown($this->_socket, STREAM_SHUT_RDWR);
$this->_socket = null;
}
/**
* 执行Redis命令
*/
public function executeCommand($name, $params = [])
{
$this->open();
//操作命令加到params中,以计算数组元素个数,也就是消息总共多少行
array_unshift($params, $name);
//按照redis通信协议组装消息
$command = '*' . count($params) . "\r\n";
foreach($params as $arg){
$command .= '$' . mb_strlen($arg, '8bit') . "\r\n" . $arg . "\r\n";
}
//向socket中写入消息
fwrite($this->_socket, $command);
return $this->parseResponse(implode(' ', $params));
}
/**
* 解析Redis返回的信息
*/
public function parseResponse($command)
{
if(($line = fgets($this->_socket)) === false){
throw new Exception("redis socket 没有返回任何数据");
}
//根据redis协议解析返回的消息
$type = $line[0];
//去除末尾的\r\n
$line = mb_substr($line, 1, -2, '8bit');
//按照type解析redis返回的消息类型
switch($type){
//返回的是正确的消息
case '+':
if($line == 'OK' || $line == 'PONG'){
return true;
}else{
return $line;
}
//返回的是错误的消息
case '-':
throw new Exception("Redis error: $line");
//返回的是一个数字
case ':':
return $line;
//返回的是一个字符串
case '$':
//根据redis协议,如果返回的是-1 代表null "Null Bulk String"
if($line == -1){
return null;
}
//读取字符串
/**
* 加2是因为,字符串的协议是:$字符串长度字符串\r\n
* 也就是+2 加的是\r\n的长度
*/
$length = $line + 2;
//读取的数据
$data = '';
//这里用while循环处理字符串长度为0的情况,后面可能有多个\r\n,如文档中的:"$0\r\n\r\n"
while($length > 0){
if(($block = fread($this->_socket, $length)) === false){
throw new Exception("读取redis返回的字符串消息失败");
}
$data .= $block;
//长度减去$length,如果为空那就是减去一个\r\n,也就是2
$length -= mb_strlen($block, '8bit');
}
return mb_substr($data, 0, -2, '8bit');
//返回的是一个数组消息
case '*':
$count = (int)$line;
$data = [];
for($i = 0; $i < $count; $i++){
$data[] = $this->parseResponse($command);
}
return $data;
default:
throw new Exception("redis返回了错误的消息标志");
}
}
//get 命令示例
public function getValue($key)
{
return $this->executeCommand('GET', [$key]);
}
//set 命令示例
public function setValue($key, $value, $expire)
{
if($expire == 0){
return $this->executeCommand('SET', [$key, $value]);
}else{
$expire = (int)$expire*1000;
return $this->executeCommand('SET', [$key, $value, 'PX', $expire]);
}
}
}
//使用示例
$redis = new Redis();
$redis->setValue("blank","\r\n\r\n",100);
更多的命令在Yii2.0中的实现方式是定义一个数组,里面包含了所有的Redis操作命令,然后实现了__call方法,在__call中判断命令是否存在于数组中,存在则直接executeCommand
参考:Yii2.0的Redis实现