《大型》(番外篇)~另类Session

来源:百度文库 编辑:神马文学网 时间:2024/07/08 08:32:50
Session就是Session。我懒得不行,距离上次的长贴有个把月了,我也连着跳票,这几天突发奇想,也算是和朋友赌点饭,在Session上做了点手脚。
以下文字可以算做是Session扫盲,其实本文主要内容集中在创建一个服务上,每次喝酒之后都会弄些变态的东西。
PHP的Session是PHP4开始系统级提供的,PHP4之前在PHPLIB中有对Session的模拟实现,Session的作用是维持一个较长时间的会话,以保存一些跨页面的变量。通常Session有一个唯一的ID,PHP的SessionID保存在Cookie里,如果客户端不支持 Cookie,也可以由URL显式传递。
SessionID的生成方式可以有很多种,主要的是尽量保证唯一性,可以由当前服务器时间、客户端地址、随机变量、服务器pid等参数通过哈稀算法生成一个唯一的id,PHP默认的SessionID生成算法如下:(session.c)
CODE:
[Copy to clipboard]
PHPAPI char *php_session_create_id(PS_CREATE_SID_ARGS)
{
PHP_MD5_CTX md5_context;
PHP_SHA1_CTX sha1_context;
unsigned char digest[21];
int digest_len;
int j;
char *buf;
struct timeval tv;
zval **array;
zval **token;
char *remote_addr = NULL;
gettimeofday(&tv, NULL);
if (zend_hash_find(&EG(symbol_table), "_SERVER",
sizeof("_SERVER"), (void **) &array) == SUCCESS &&
Z_TYPE_PP(array) == IS_ARRAY &&
zend_hash_find(Z_ARRVAL_PP(array), "REMOTE_ADDR",
sizeof("REMOTE_ADDR"), (void **) &token) == SUCCESS) {
remote_addr = Z_STRVAL_PP(token);
}
buf = emalloc(100);
/* maximum 15+19+19+10 bytes */
sprintf(buf, "%.15s%ld%ld%0.8f", remote_addr ? remote_addr : "",
tv.tv_sec, tv.tv_usec, php_combined_lcg(TSRMLS_C) * 10);
switch (PS(hash_func)) {
case PS_HASH_FUNC_MD5:
PHP_MD5Init(&md5_context);
PHP_MD5Update(&md5_context, buf, strlen(buf));
digest_len = 16;
break;
case PS_HASH_FUNC_SHA1:
PHP_SHA1Init(&sha1_context);
PHP_SHA1Update(&sha1_context, buf, strlen(buf));
digest_len = 20;
break;
efree(buf);
return NULL;
}
if (PS(entropy_length) > 0) {
int fd;
fd = VCWD_OPEN(PS(entropy_file), O_RDONLY);
if (fd >= 0) {
unsigned char rbuf[2048];
int n;
int to_read = PS(entropy_length);
while (to_read > 0) {
n = read(fd, rbuf, MIN(to_read, sizeof(rbuf)));
if (n <= 0) break;
switch (PS(hash_func)) {
case PS_HASH_FUNC_MD5:
PHP_MD5Update(&md5_context, rbuf, n);
break;
case PS_HASH_FUNC_SHA1:
PHP_SHA1Update(&sha1_context, rbuf, n);
break;
}
to_read -= n;
}
close(fd);
}
}
switch (PS(hash_func)) {
case PS_HASH_FUNC_MD5:
PHP_MD5Final(digest, &md5_context);
break;
case PS_HASH_FUNC_SHA1:
PHP_SHA1Final(digest, &sha1_context);
break;
}
if (PS(hash_bits_per_character) < 4
|| PS(hash_bits_per_character) > 6) {
PS(hash_bits_per_character) = 4;
php_error_docref(NULL TSRMLS_CC, E_WARNING, "The ini setting hash_bits_per_character is out of range (should be 4, 5,
or 6) - using 4 for now");
}
j = (int) (bin_to_readable(digest, digest_len, buf, PS(hash_bits_per_character)) - buf);
if (newlen)
*newlen = j;
return buf;
}
可以看出它包含客户端地址、服务器时间(秒&微秒),通过SHA1或MD5来构造哈稀。
Session的工作原理描述如下:
1、        提出Session请求,服务器生成一个唯一的SessionID,发送给客户端保存
2、        服务器根据Session的保存方式,创建数据保存空间(如文件)
3、        初始化特殊变量$_SESSION
4、        对Session操作,改变数据
5、        将结构化数据整理成可逆向的字符串(如序列化,具体可参考php.ini)
6、        保存到空间(如文件)
7、        换页后,客户端保存这个SessionID(Cookie是随HTTP请求发送的,所以要跨页后才生效)
8、        之后每次都由已保存的SessionID初始化Session。Session有一个指定的生存周期,超时之后Session会失效,数据也会丢失,需要重新创建。
以上是Session的工作原理(不是过程),PHP默认的Session保存方式是文件形式。每一个Session一个文件,文件名为sess_ [SessionID],可能保存在/tmp中或者其它地方。PHP默认所有Session文件是保存在一起的,这样文件多了的话会有问题,还可以修改参数,改成Hash目录的方式。上一次贴过一个模拟Session的class,有兴趣的可以去翻翻。
PHP提供一个自定义Session的很方便的方法:session_set_save_handle函数,可以用用户自定义的函数来代替系统的保存方式,包括open、close、read、write、destroy、gc六种操作,操作者可以很方便地用这种方式修改Session,比如修改成将 Session保存进数据库等。
有人说过,Session就是给懒人用的。在大型的应用中,默认的Session工作方式是不够的,在性能、效率和安全性上都有问题,所以出现了很多特殊的Session方式甚至专用的Session产品。比如很多通用系统,都不使用系统Session,类似PHPBB、Discuz!这样的论坛,它们会用Cookie来模拟一些Session动作,同时也会用数据库来完成其它的部分。一方面可以提高性能,另一方面也可以方便跟踪和统计,比如获取某用户当前在哪个版块哪个帖子,在干什么,统计在线用户情况等,而且也可以使通用系统少受服务器尤其是虚拟主机的限制。
Session在大型系统里所面对的问题主要集中在1、效率;2、共享;3跨域支持这三个方面。效率自不用说——世界上本没有Session,有了人登陆,自然就出现了Session……登陆的人多了,自然就出现了很多Session,几百万人登陆,自然就出现了几百万的Session,呵呵。共享问题出现在多服务器、负载均衡等体系中,而且 Session需要集中管理。跨域支持其实和Session关系并不是很大,主要要解决的是如何在客户端保存合法的ID。因为Cookie是有域属性的,所以必须解决这个跳转关系,才能实现网络通行证等单点集中登陆的效果(我一直觉得这玩意挺变态的,哈哈)。
在《大型》(一)中我提到了一些cache的实现和保存方法,比较集中的热点是tmpfs,其实tmpfs可以做很多事,它比共享内存更适合做大量数据的交换。其它的像memcached等内存数据管理方式也很不错,不过memcached有一个很讨厌的问题是不能很方便地取得变量列表,也就是说过了一段时间之后你可能自己都不知道曾经在里面存过什么……
如果我们需要用一个另类的方式保存Session,根据上面的结论,我们要找一个可以提供效率和性能,并且可以提供共享的方式。以前尝试过在 memcached里保存Session,因为memcached是可以通过网络访问的,但是它的那个讨厌的毛病使得不好做统计,而且保存得也是乱乱的。此外也尝试过以文件的方式保存,然后通过NFS来做网络共享,但是NFS在这种小文件密集操作情况下的效率是比较差的,而且也不稳定。比较“正常的”内存表方式也有它致命的弱点——内存表字段太短(HEAP里不支持TEXT类字段)。所以以上的方式都不“另类”
所以我需要的是一个可以提供TCP访问的透明的Session保存方式。我选择了SQLite,(当然也可以用文件),SQLite本身是一个轻型的文件数据库系统,它可以提供很方便很标准的SQL操作方式和相对不错的负载能力,更重要的是它很快,而且是万全基于磁盘文件的。但是它本身不支持网络访问。在解决IO的问题上,可以把数据库文件创建在tmpfs上来解决物理磁盘带来的性能瓶颈,反正Session也是易失数据,没有必要长期保存的。在解决共享的问题上,我创建了一个TCP Server:
TCP Server由对外的端口监听方式提供服务,像Apache等守护类程序一样。MySQL也可以对外提供网络访问(那个3306),但是MySQL的性质和Apache不同,MySQL虽然和Web开发关系比较密切,但是MySQL在更多的情况下需要提供长连接和持续连接,而且它不能处理非常大的并发,而且对于本地访问,MySQL是通过sock方式连接的(UNIX)而不是通过TCP端口。Apache则不同,首先Apache没有必要提供特殊的对本地优化的连接方式,因为没人在服务器上浏览自己(调试除外),其次Apache处理的连接绝大部分是HTTP请求,是瞬时的,它可能会面对一段时间内非常大的访问高峰,所以它需要提供非常好的连接响应。UNIX上的Apache默认是工作在“预创建进程”的方式下,它会在服务启动后,生成一个pid文件,里面保存了本次启动的主进程的进程号,然后每隔一个比较短的时间(比如1秒)fork出一个子进程直到满足一定数量(比如5个),这个时候我们会在ps的时候看见若干个httpd进程,其中一个是主进程,它可能是由root身份启动并运行,其它的是它fork出的子进程,它们由apache用户身份执行(比如nobody,httpd.conf中可调整)。这些子进程来处理连接请求,当它们收到连接请求后,Apache会再次fork出一个新的子进程来保证有足够多的空闲子进程来处理连接,因为本身fork是需要时间的,所以预选创建好这些等待服务请求的子进程可以提高连接性能。而且HTTP的请求从开始到完成的时间通常很短,所以子进程会再次闲下来阻塞。我们经常会看见一个运行了一段时间的服务器上有一大堆的httpd进程,即使当前没几个人在访问。当然子进程不是无休止地创建,它也有一个上限,httpd.conf中可以调整最大进程数,最小空闲进程数等参数。
Apache2.0之后提供了特殊的thread方式,它避免了每个进入的连接都要fork的开销,它由在一个进程内由多个线程来循环处理accept(),它叫做MPM,它是由多线程和预创建进程混合的服务方式,提供比单纯多进程方式更好的连接性能。
我的Session服务器就需要使用类似Web服务器的这种处理短连接的方式。开发这样的一个TCP Server可以使用很多种语言中都比较通用的sock类函数,PHP中也有很好的socket支持,可以在加载sockets模块之后做一些比较复杂的网络开发。但是PHP本身是不支持多线程的,它的socket实现级别也不够底层,同时PHP对多进程的支持也不够好(有些人说PHP不能fork,其实还是可以由变通的方法实现的,后面会附上一些这样的内容)。我们可以选择C、Perl一类的语言来完成这个工作,这次我选择了Perl。Perl的网络性能和IO性能其实是很好的,使用起来也非常方便,Perl目前在程序开发方面不够流行我总觉得更多地“归功”于它怪异的程序结构和语法(它确实很怪……)。
本来可以完全从0开始实现一个预创建进程/线程的TCP Server,但是懒人就要会偷懒,CPAN中有一个包,可以非常方便地创建一个TCP Server,同时它有各种可调参数,这个包是NetServer::Generic,可以通过CPAN安装。
接下来需要定义一些参数,首先是默认端口,我随便写了一个34343,为了和Sky同学的SessionD保持兼容。然后是命令格式,根据Session的使用特性,定义了五种操作:
+:表示创建/更新一个Session
-:表示删除一个Session
:表示取得一个Session的内容
!:表示标识一个Session为过期
*:表示清理Session表,即删除已过期的Session数据
这其中只有?操作要返回字符串类数据。命令格式包括三段,第一个字节为操作符号,就是以上的五种字符,然后是分隔符,我写的是两个冒号,后面是32位的SessionID,然后再是分割附,最后是变长的数据。这样,一条完整的创建指令可能是:
+::12341234123412341234123412341234::Hello World!!!
取得这个Session值的指令可能是:
::12341234123412341234123412341234::0
发送成功这条指令的结果是服务器返回“Hello World!!!”
在这五种合法的指令之外,定义如果接收到其它的指令就表示操作结束,断开连接。
在服务开始之前,需要创建一个SQLite数据库来保存Session数据,可以根据需要创建一些相应的字段,同时每次服务启动的时候都要检查并清空它(如果是tmpfs上的,重启服务器之后文件就丢掉了)。我定义了如下的一个表:
CODE:
[Copy to clipboard]
"CREATE TABLE `sess` (
`sid` VARCHAR(32) NOT NULL UNIQUE,
`expire` INT NOT NULL,
`sess_data` TEXT NOT NULL)";
然后在开始服务之前要确定一个绑定地址,可以是一个IP,可以是一个域名,或者绑定本机所有(0.0.0.0)
整个服务程序如下:
CODE:
[Copy to clipboard]
#!/usr/local/bin/perl
use NetServer::Generic;
use DBI;
use Switch;
my $server = sub {
while (defined ($tcp_input = )) {
chomp $tcp_input;
my ($sess_cmd, $sess_sid, $sess_data) = split (/::/, $tcp_input, 3);
my $sql;
my $sth;
my $now = time ();
my $expire = $now + 900;
chomp $sess_sid;
chomp $sess_data;
switch ($sess_cmd) {
case "+" {
#Create a Session
$sth = $db->prepare ($sql);
$sth->execute ();
}
case "-" {
#Delete a Session
$sql = "DELETE FROM `sess` WHERE `sid` = ‘$sess_sid‘";
$sth = $db->prepare ($sql);
$sth->execute ();
}
case "!" {
#Mark a Session as EXPIRED!!!
$sql = "UPDATE INTO `sess` SET `expire` = $now WHERE `sid` = ‘$sess_sid‘";
$sth = $db->prepare ($sql);
$sth->execute ();
}
case "?" {
#Get a Session from SQLite
$sql = "SELECT `sess_data` FROM `sess` WHERE `sid` = ‘$sess_sid‘ AND `expire` >= $now";
$sth = $db->prepare ($sql);
$sth->execute ();
my @row = $sth->fetchrow_array();
print "$row[0]\n";
}
case "*" {
#Clear all Session which expired
$sql = "DELETE FROM `sess` WHERE `expire` < $expire";
$sth = $db->prepare ($sql);
$sth->execute ();
}
else {
return 0;
}
}
}
};
my $create_sql = "CREATE TABLE `sess` (
`sid` VARCHAR(32) NOT NULL UNIQUE,
`expire` INT NOT NULL,
`sess_data` TEXT NOT NULL)";
$db = DBI->connect(‘dbi:SQLite:/www/htdocs/sess.db‘);
my $sth = $db->prepare ("DROP TABLE IF EXISTS `sess`");
$sth->execute () || print $db->get_error . "\n";
$sth = $db->prepare ($create_sql);
$sth->execute () || print $db->get_error . "\n";
my $port = 34343;
my $hostname = ‘admin.qiaoqiao.org‘;
my (%config) = (‘port‘ => $port, ‘callback‘ => $server,
‘mode‘ => ‘prefork‘,
‘start_servers‘ => 5,
‘max_servers‘ => 255,
‘min_spare_servers‘ => 2,
‘hostname‘ => $hostname);
my ($TCP_Server) = new NetServer::Generic (%config);
print "BSM_Session Daemon 1.01 Alpha\nBy Dr.NP 2006 \nServer Started...Bind on $hostname\n";
$TCP_Server->run();
上面使用DBI来操作SQLite数据库,为了看起来比较清晰,使用了Switch包,Perl本身是没有明确的Switch-case结构的,因为它不需要。NetServer::Generic具体使用方式可以查阅相关文档,这里创建的是一个prefork方式(预创建进程)的服务器程序,绑定在 admin.qiaoqiao.org的34343端口上。启动这个程序,它就可以开始提供对外的网络服务了。它会接受并处理上面提到的五种指令,然后在?的时候反馈一个字符串接一个换行符,但并不马上断开连接。
具体的程序不做太多解释,因为这次不是以讲述perl为主。
服务器端大概就是这个样子,只需要mount或者fstab一个tmpfs,同时修改这个脚本把SQLite的库文件创建在tmpfs上可以了。客户端的情况要比这个清晰一些,只要连接到服务器上,发送相应的指令并读取服务器反馈的数据就可以了。根据这样的一个情况,我写了一个class,这次是PHP 的……
CODE:
[Copy to clipboard]
/* BSM_Session Client
*  By Dr.NP 07-19-2006
*  BSM_Session Daemon is a very BT session handle...Hahahahahaha
*/
class Bsm_Session
{
var $hostname            = ‘localhost‘;
var $port                = 34343;
var $sock_fp            = false;
var $sess_id            = ‘‘;
var $sess_data            = array ();
var $sid_cookie_var        = ‘BSM_SESS_ID‘;
var $sess_expire        = ‘3600‘;
var $cookie_domain        = ‘localhost‘;
var $cookie_path        = ‘/‘;
function BSM_Session ($hostname = ‘‘, $port = ‘‘, $sid_cookie_var = ‘‘, $sess_expire = ‘‘, $cookie_domain = ‘‘)
{
if (trim ($hostname))
$this->hostname = trim ($hostname);
if (intval (trim ($port)))
$this->port = intval (trim ($port));
if (trim ($sid_cookie_var))
$this->sid_cookie_var = trim ($sid_cookie_var);
if (intval (trim ($sess_expire)))
$this->sess_expire = intval (trim ($sess_expire));
if (trim ($cookie_domain))
$this->cookie_domain = trim ($cookie_domain);
$socket = socket_create (AF_INET, SOCK_STREAM, SOL_TCP);
if (!$socket)
return false;
$result = socket_connect($socket, $this->hostname, $this->port);
if ($result) {
$this->sock_fp = $socket;
$this->_gen_sid ();
$data = $this->_read_daemon ();
$this->sess_data = is_array (@unserialize ($data)) ? @unserialize ($data) : array ();
return true;
}
else
return false;
}
function put ($key, $value)
{
$this->sess_data[$key] = $value;
$data = serialize ($this->sess_data);
return $this->_write_daemon ($data);
}
function get ($key)
{
return $this->sess_data[$key];
}
function destroy ()
{
$this->sess_data = array ();
return $this->_del_daemon ();
}
function _gen_sid ()
{
if ($_COOKIE[$this->sid_cookie_var])
$this->sess_id = $_COOKIE[$this->sid_cookie_var];
else {
$client_ip = $_SERVER[‘REMOTE_ADDR‘];
$this->sess_id = md5 (uniqid (microtime () . $client_ip));
@setcookie ($this->sid_cookie_var, $this->sess_id, $this->sess_expire + time(), $this->cookie_path, $this->cookie_domain);
$_COOKIE[$this->sid_cookie_var] = $this->sess_id;
}
return;
}
function _read_daemon ()
{
$cmd = ‘?::‘ . $this->sess_id . "::0rn";
socket_write ($this->sock_fp, $cmd);
$ret = socket_read ($this->sock_fp, 16384, PHP_NORMAL_READ);
if (!$ret)
$ret = ‘‘;
return $ret;
}
function _write_daemon ($data)
{
$cmd = ‘+::‘ . $this->sess_id . ‘::‘ . $data . "rn";
$bytes = socket_write ($this->sock_fp, $cmd);
return;
}
function _del_daemon ()
{
$cmd = ‘-::‘ . $this->sess_id . "::0n";
socket_write ($this->sock_fp, $cmd);
return;
}
function _set_expire_daemon ()
{
$cmd = ‘!::‘ . $this->sess_id . "::0n";
socket_write ($this->sock_fp, $cmd);
return;
}
function _flush_daemon ()
{
$cmd = ‘*::‘ . $this->sess_id . "::0n";
socket_write ($this->sock_fp, $cmd);
return;
}
function _disconnect ()
{
$cmd = ‘QUIT‘;
socket_write ($this->sock_fp, $cmd);
}
}
?>
我还是一如既往地懒得写注释,因为它比较好理解,需要提及的是在做_read_daemon()的时候,也就是在发送?指令之后,socket_read要使用PHP_NORMAL_READ方式。在php4.1之后,它就不再是默认的了。因为服务器端接受?指令并返回Session结果之后并不主动关闭连接,而只是返回一个换行符,所以客户端不能傻傻地等待它送出所有数据。
这个class怎么用,其实一看就知道……呵呵,唯一要注意的就是cookie在下一个HTTP请求的时候才生效。
之后的事情就是要测试它的性能和负载能力,不太方便得到具体的数值,但是可以使用类似ab的测试工具或采用“DOS自己”的变态办法来观察它的性能。 NetServer::Generic的性能是非常好的,至于SQLite,如果你信不过它,完全可以换成别的,或者是用文件的方式实现保存,对于客户端来说,用什么都没关系,鬼才会在乎服务器上用什么保存数据,就像鬼才会在意浏览网页时候Apache在做什么一样(在专门研究这个的人群中,不排除鬼的存在)。
OK了,我创建了一个另类的Session实现结构,呵呵,如果聪明的话,完全可以用它来做别的,session只不过是个特殊一点的变量而已……
附一个PHP创建子进程的例子(说话要算数):
CODE:
[Copy to clipboard]
require_once (‘init.php‘);
if ($pid != $shm->get_var (SHM_VAR_PID)) {
// Child Process
$fh = fopen (‘temp.pid‘, ‘wb‘);
fwrite ($fh, $pid);
fclose ($fh);
}
else {
// Master Process
$descriptorspec = array(
0 => array("pipe", "r"),
1 => array("pipe", "w"),
2 => array("file", "error-output.txt", "a")
);
$res = proc_open (‘php ‘ . basename (__FILE__), $descriptorspec, $pipe);
$ret = proc_close ($res);
$shm->put_var (SHM_VAR_SYS_RUN, false);
$shm->put_var (SHM_VAR_PID, 0);
}
exit (0);
?>
这个程序使用了我上次给出的一个SHM类,用来保存pid,或者你可以把它保存在一个文件里。这个程序模拟了fork(),fork()实际上就是一个完全的自我复制,所不同的是有不同的pid和ppid,程序可以根据它们来区分当前的是父进程还是子进程,以完成不同的操作。上面这个程序中,如果pid不等于SHM里保存的pid,说明它是子进程,它要完成在temp.pid里写入它自己的进程号这样一个动作。如果当前进程是主进程,表明这个程序是由 shell运行的,它要保存自己的进程号在SHM中,同时用proc_open创建一个“自身”,在子进程结束动作之后,父进程结束。Init.php内容如下:
CODE:
[Copy to clipboard]
require_once (‘constants.inc.php‘);
require_once (‘basic.function.php‘);
require_once (‘shm.inc.php‘);
// Fetch Self PID
$pid = posix_getpid ();
$shm = new BsmShm ();
if (!$shm->get_var (SHM_VAR_SYS_RUN) || !$shm->get_var (SHM_VAR_PID)) {
// Master Process Startup...
$shm->put_var (SHM_VAR_SYS_RUN, true);
$shm->put_var (SHM_VAR_PID, $pid);
}
?>
NP博士