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

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)

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上的,重启服务器之后文件就丢掉了)。我定义了如下的一个表:

"CREATE TABLE `sess` (
                `sid` VARCHAR(32) NOT NULL UNIQUE,
                `expire` INT NOT NULL,
                `sess_data` TEXT NOT NULL)";
然后在开始服务之前要确定一个绑定地址,可以是一个IP,可以是一个域名,或者绑定本机所有(0.0.0.0)

整个服务程序如下:

#!/usr/local/bin/perl           
                                
use NetServer::Generic;         
use DBI;                        
use Switch;
                        
my $server = sub {
        while (defined ($tcp_input = <STDIN>)) {
                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 <bssoft/@263.net>/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 的……
[PHP]
<?php
/* 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 . "::0/r/n";
                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 . "/r/n";
                $bytes = socket_write ($this->sock_fp, $cmd);
               
                return;
        }
       
        function _del_daemon ()
        {
                $cmd = '-::' . $this->sess_id . "::0/n";
                socket_write ($this->sock_fp, $cmd);
               
                return;
        }
       
        function _set_expire_daemon ()
        {
                $cmd = '!::' . $this->sess_id . "::0/n";
                socket_write ($this->sock_fp, $cmd);
               
                return;
        }
       
        function _flush_daemon ()
        {
                $cmd = '*::' . $this->sess_id . "::0/n";
                socket_write ($this->sock_fp, $cmd);
               
                return;
        }
       
        function _disconnect ()
        {
                $cmd = 'QUIT';
                socket_write ($this->sock_fp, $cmd);
        }
}

?>
[/PHP]

我还是一如既往地懒得写注释,因为它比较好理解,需要提及的是在做_read_daemon()的时候,也就是在发送?指令之后,socket_read 要使用PHP_NORMAL_READ方式。在php4.1之后,它就不再是默认的了。因为服务器端接受?指令并返回Session结果之后并不主动关闭 连接,而只是返回一个换行符,所以客户端不能傻傻地等待它送出所有数据。
这个class怎么用,其实一看就知道……呵呵,唯一要注意的就是cookie在下一个HTTP请求的时候才生效。
之后的事情就是要测试它的性能和负载能力,不太方便得到具体的数值,但是可以使用类似ab的测试工具或采用“DOS自己”的变态办法来观察它的性能。 NetServer::Generic的性能是非常好的,至于SQLite,如果你信不过它,完全可以换成别的,或者是用文件的方式实现保存,对于客户端 来说,用什么都没关系,鬼才会在乎服务器上用什么保存数据,就像鬼才会在意浏览网页时候Apache在做什么一样(在专门研究这个的人群中,不排除鬼的存 在)。
OK了,我创建了一个另类的Session实现结构,呵呵,如果聪明的话,完全可以用它来做别的,session只不过是个特殊一点的变量而已……

附一个PHP创建子进程的例子(说话要算数):
[PHP]
<?php

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);
?>
[/PHP]

这个程序使用了我上次给出的一个SHM类,用来保存pid,或者你可以把它保存在一个文件里。这个程序模拟了fork(),fork()实际上就是一个完 全的自我复制,所不同的是有不同的pid和ppid,程序可以根据它们来区分当前的是父进程还是子进程,以完成不同的操作。上面这个程序中,如果pid不 等于SHM里保存的pid,说明它是子进程,它要完成在temp.pid里写入它自己的进程号这样一个动作。如果当前进程是主进程,表明这个程序是由 shell运行的,它要保存自己的进程号在SHM中,同时用proc_open创建一个“自身”,在子进程结束动作之后,父进程结束。Init.php内 容如下:
[PHP]
<?php

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);
}

?>
[/PHP]
                                                                     NP博士
                                                                    07-21-2006 酒后

+++++++++++++++++++++++
转载自http://sex.phpx.com.cn
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值